From 1a751071d72c72e09a2753f3ab6b3841938f4205 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 14:32:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E6=AE=98=E5=80=89=E9=81=8E?= =?UTF-8?q?=E6=BF=BE=20=E2=80=94=20=E5=B8=82=E5=80=BC<0.1%=20=E9=83=A8?= =?UTF-8?q?=E4=BD=8D=E4=B8=8D=E9=80=B2=E5=88=86=E6=95=A3=E5=BA=A6/what-if/?= =?UTF-8?q?=E8=A8=BA=E6=96=B7/=E6=9C=AA=E5=88=86=E9=A1=9E=E8=A8=88?= =?UTF-8?q?=E6=95=B8=20(closes=20#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit owner 拍板(#172):1 股殘倉(股息零頭/賣到剩一絲的尾倉)讓「持股數」對用戶失去意義、 也讓假分散判定的啟動門檻恆成立(n_holdings≥8 恆真)。判準用**相對佔比**(市值<0.1%), 非絕對股數(1 股 MSTR 可能好幾百鎊)/固定金額(對不同帳戶規模失真)。 - `meaningful_tickers(held, last_px)`:市值佔全持倉 ≥0.1% 的 ticker set(缺價用成本近似,離線可算) - main 統一過濾:去殘 `held_dx` 餵 `dim_diversify`(n/top3/max_sector/ai_pct 分母)、`what_if`、 `ticker_diagnosis`;`unclassified_drivers` 計數也排除殘倉(核能小倉 LEU 之類不冒充「連歸類都做不到」) - **overview(P&L)/dim_size/n_held/state 不動**:市值基準會把「重倉崩 99%」的部位當殘倉, 但只從『集中度診斷』排除、不從『總覽 P&L』排除 → 虧損不藏、對帳 held_n 全量一致 - 常數 `RESIDUAL_POS_TH = 0.001` 放檔頭常數區 驗證:run_all 十套綠;全 13 mock div_n==held_n(無殘倉→零影響,現有 persona 卡面不變); 端到端 fixture(0.02 股 AAPL 殘倉)→ dim_diversify n 排除、overview held_n 保留; 單元測試(市值基準/崩倉靠市值排除/dim_diversify n 去殘/零市值邊界)。 closes #172 · cross-ref #138(同動 dim_diversify,判準不同,無 open PR 重疊) Co-Authored-By: Claude Opus 4.8 --- skills/fomo-kernel/engine/trade_recap.py | 27 ++++++++++++++--- tests/test_engine_units.py | 38 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/skills/fomo-kernel/engine/trade_recap.py b/skills/fomo-kernel/engine/trade_recap.py index 1a74578..49daeaf 100644 --- a/skills/fomo-kernel/engine/trade_recap.py +++ b/skills/fomo-kernel/engine/trade_recap.py @@ -41,6 +41,7 @@ def _no_rich_notice(what="復盤卡"): SELL_EARLY_TH = 0.10 SECTOR_MAX_TH = 0.40 # #87/#95:跟 dim_diversify() severity 的 40% 起算點對齊,triggered/severity 不再各吹各的號 RF_ANNUAL = 0.043 # 無風險利率(年):美國短期國庫券約 4.3%,Jensen's Alpha 用(tunable) +RESIDUAL_POS_TH = 0.001 # 殘倉閾值:市值佔全持倉 <0.1% = 噪音(股息零頭/1 股尾倉),不計入分散度/what-if/per-ticker 診斷/未分類計數(#172,owner 2026-07-12 拍板;相對佔比自適應帳戶規模,非絕對股數/金額) # ── ticker → (sector, thematic?) thematic=1 代表同屬一個跨產業主題(如 AI capex)= VY B2 的「driver」── # 這張表只是「常見股 fallback」。主路徑:SKILL 指引 Claude 對『實際持倉』用世界知識生成 driver map @@ -881,6 +882,19 @@ def dim_size(rows, held, last_px): severity=sev, max_ticker=max_t, max_pct=max_pct, avg_pct=statistics.mean(others) if others else 0.0, weights=weights) +def meaningful_tickers(held, last_px, floor=RESIDUAL_POS_TH): + """回傳「非殘倉」的 ticker set:市值佔全持倉 ≥ floor(預設 0.1%)。市值缺價用成本近似。 + 殘倉(股息零頭/賣到剩 1 股的尾倉)不該灌 n_holdings/分散度/what-if/per-ticker 診斷/未分類計數(#172)。 + **只給診斷用,不動 overview/P&L**:重倉崩 99% 的部位市值雖小、在此不進集中度診斷,但它的未實現虧損仍留在總覽(不藏虧損)。 + 相對佔比自適應帳戶規模(owner 排除絕對股數/金額門檻)。全零市值(理論邊界)→ 全保留,不誤殺。""" + vals = {} + for t, (sh, cost) in (held or {}).items(): + px = (last_px or {}).get(t); vals[t] = sh * px if px else cost + tot = sum(vals.values()) + if tot <= 1e-9: + return set(vals) + return {t for t, v in vals.items() if v / tot >= floor} + def dim_diversify(held, last_px): vals = {} for t, (sh, cost) in held.items(): @@ -1915,8 +1929,12 @@ def main(): decision_rts_u = [r for r in rts_u if driver(r["ticker"])[0] not in BENCH_SELF] else: rts_u, held_u, lastpx_u, decision_rts_u = rts, held, last_px, decision_rts + # 殘倉過濾(#172):市值<0.1% 的部位不進分散度/what-if/per-ticker 診斷/未分類計數; + # overview(P&L)/dim_size(單筆過重本就只看大倉)/n_held(對帳全量)不動 → 不藏虧損、對帳一致。 + keep_dx = meaningful_tickers(held_u, lastpx_u) + held_dx = {t: v for t, v in held_u.items() if t in keep_dx} d_size = dim_size(rows, held_u, lastpx_u) - d_exit = dim_exit(decision_rts, fwds, n_fwd); d_div = dim_diversify(held_u, lastpx_u) + d_exit = dim_exit(decision_rts, fwds, n_fwd); d_div = dim_diversify(held_dx, lastpx_u) d_hold = dim_hold(rts); d_avgdown = dim_avgdown(avg_down, held_u, lastpx_u, d_size) dims = [d_exit, d_size, d_div, d_hold, d_avgdown] strength = dim_strength(d_exit, d_size, d_avgdown, d_div, d_hold, decision_rts) # 先給做對的(附案例) @@ -1930,17 +1948,18 @@ def main(): overview = overview_stats(decision_rts_u, ab, held_u, lastpx_u) # 已實現 + 未實現都報(聚合幣別上) pa = payoff_attribution(decision_rts_u) # 盈虧比拆解:重點交易的貢獻度(聚合幣別上) best, worst = best_worst(decision_rts) # 做得最好/最差的一筆(ret%,無因次 → 原幣) - wi = what_if(held_u, lastpx_u) # 可量化的 what-if(聚合幣別上) + wi = what_if(held_dx, lastpx_u) # 可量化的 what-if(聚合幣別上,#172 殘倉不計) trend = time_trend(decision_rts, avg_down) # (engine 保留,卡片暫不顯示) rx = prescribe(ab, dims, overview) # 處方層:揚長/外包/砍損耗 adds_class = classify_adds(rows) # 主從分類:疑似定投 vs 凹單 vs 待確認 # 標的層:按金額排序,對事不對人。排序/佔比是跨 ticker 比較 → 混幣必須在聚合幣別(USD 視圖)上做, # 否則 TWD 名目大數霸榜(review 2026-07-06);比率欄(cur_ret/fwd)無因次不受縮放影響。 - tdiag = ticker_diagnosis(rts_u, adds_class, held_u, lastpx_u) + tdiag = ticker_diagnosis(rts_u, adds_class, held_dx, lastpx_u) # #172 殘倉不列 per-ticker 診斷 # 資料完整性(賣超 / 未分類 driver)— 影響數據可信度,JSON 與人話卡共用同一份 orphans = orphan_sells(rows) - unclassified = sorted(t for t in held if driver(t)[0] == "未分類") + # 未分類 driver 計數排除殘倉(#172):核能小倉 LEU 之類 <0.1% 的未分類尾倉不該冒充「連歸類都做不到」的誠實缺口 + unclassified = sorted(t for t in held if t in keep_dx and driver(t)[0] == "未分類") data_integrity = { "orphan_sells": {t: round(q, 2) for t, q in sorted(orphans.items())}, "unclassified_drivers": unclassified, diff --git a/tests/test_engine_units.py b/tests/test_engine_units.py index f68130b..87b9145 100644 --- a/tests/test_engine_units.py +++ b/tests/test_engine_units.py @@ -868,6 +868,44 @@ def fired(cash): assert not fired(None), "無 cash 欄(None)不觸發" +def test_meaningful_tickers_mv_basis(): + """殘倉過濾(#172):市值佔全持倉 <0.1% 的部位不進診斷集合;市值優先、缺價用成本近似。""" + held = {"NVDA": (100.0, 50000.0), # 99% → 保留 + "AAPL": (0.02, 30.0), # 市值 0.02*180=3.6,<0.1% → 殘倉 + "KO": (5.0, 500.0)} # ~1% → 保留 + last_px = {"NVDA": 500.0, "AAPL": 180.0, "KO": 60.0} + keep = tr.meaningful_tickers(held, last_px) + assert keep == {"NVDA", "KO"}, keep + # 無現價 → 用成本當市值近似(離線也能濾) + keep_offline = tr.meaningful_tickers(held, {}) + assert "AAPL" not in keep_offline and "NVDA" in keep_offline, keep_offline + + +def test_meaningful_tickers_excludes_crashed_by_mv_not_cost(): + """關鍵:用市值不用成本(#172 owner 拍板)——重倉後崩 99% 的部位市值極小 → 不進集中度診斷。 + 安全性靠『overview 不套此過濾』保證(見 main:overview_stats 吃全量 held_u),虧損不因此消失。""" + held = {"BIG": (100.0, 90000.0), # 當初重壓 $90k,現崩到剩 $5(市值 5) + "GOOD": (10.0, 5000.0)} # 現值 $6000 + keep = tr.meaningful_tickers(held, {"BIG": 0.05, "GOOD": 600.0}) # BIG 市值 5/(5+6000)=0.08% <0.1% + assert keep == {"GOOD"}, keep # 市值基準把崩掉的重倉排除出診斷(overview 吃全量 held → 虧損仍在) + + +def test_dim_diversify_excludes_residual_from_n(): + """#172 驗收:造一個 0.05% 殘倉,確認它不進 n_holdings(dim_diversify.n)/集中度分母。""" + held = {f"BIG{i}": (10.0, 10000.0) for i in range(5)} # 5 檔各 ~20% + held["DUST"] = (1.0, 5.0) # 市值 1*5=5,佔比 5/(5*60k...) + last_px = {**{f"BIG{i}": 1000.0 for i in range(5)}, "DUST": 5.0} + # main 已在呼叫前過濾;dim_diversify 收到去殘 held → n 不含 DUST + keep = tr.meaningful_tickers(held, last_px) + d = tr.dim_diversify({t: v for t, v in held.items() if t in keep}, last_px) + assert d["n"] == 5 and "DUST" not in keep, (d["n"], keep) + + +def test_meaningful_tickers_zero_mv_keeps_all(): + """全零市值(理論邊界)不判殘,全保留,不除零。""" + assert tr.meaningful_tickers({"X": (1.0, 0.0)}, {"X": 0.0}) == {"X"} + + # ─────────────────── 標準庫 runner(免 pytest 即可跑,與 test_sample_styles 一致)─────────────────── def _main():