From 7af4d378fda62b5f3dad897018d7bb4c1606ad28 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 13:53:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine+skill):=20cash=20=E5=91=88=E7=8F=BE?= =?UTF-8?q?=E5=B1=A4=20=E2=80=94=20=E7=8F=BE=E9=87=91=E4=B8=8A=E5=8D=A1=20?= =?UTF-8?q?+=20=E5=85=A5=E9=87=91=E5=88=A4=E8=AE=80=20+=20=E7=84=A1?= =?UTF-8?q?=E9=8C=A8=E9=BB=9E=E8=AA=A0=E5=AF=A6=E6=8F=AD=E9=9C=B2=20(refs?= =?UTF-8?q?=20#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #174 地基層讓 engine 吃得到現金(load_cash_flows/cash_position/state.cash), 但只進對帳記憶、還沒對用戶「可見可用」。這個 PR 接呈現層(#174 body 的「還沒做」清單): - build_card_data 加 `cash` 欄(TR_JSON 上卡):Claude 讀 balance/weight/reliable/recent_net_deposit 寫進敘事 - build_honesty_ledger 加 `cash_reliability` key:只在「有可誤導的 weight 但不可信」(reliable=False + weight 非 None)觸發; weight=None(算不出、不上卡)不空吠。判定進 engine、文案留 Claude(照 #82 機制,Step 3 gate 無需新增 prose) - render() Rich 文字卡總覽段:reliable 才報「帳戶現金 $X(佔帳戶 Y%)」+ 本期淨入金;無錨點盲算不上卡 - SKILL Step 0 收現金餘額錨點 UX(TR_CASH JSON,讓 cash_weight 通電)+ Step 1 card.cash 欄位說明 - card-spec 現金與入金判讀段:reliable 講帳戶權重 + 「這筆入金加深還是解集中度」;無錨點誠實邀請補餘額 - EVALS B17 + 契約測試(TR_JSON cash key / HL cash_reliability)+ 單元測試(三情境觸發矩陣) 驗證:run_all 十套全綠;全 13 mock persona 產卡核對 cash 欄位 + cash_reliability 觸發一致(零 INCONSISTENT); 錨點路徑 render 顯示「帳戶現金 $19,284(佔帳戶 37%)」;入金判讀端到端(TR_PREV_END 過濾本期存款)。 呈現層需視覺核對,交棒乾淨 session 完成(#174 交棒理由)。 Co-Authored-By: Claude Opus 4.8 --- evals/EVALS.md | 1 + skills/fomo-kernel/SKILL.md | 5 ++++- skills/fomo-kernel/card-spec.md | 6 +++++- skills/fomo-kernel/engine/trade_recap.py | 27 ++++++++++++++++++------ tests/test_engine_units.py | 17 +++++++++++++++ tests/test_tr_json_contract.py | 14 +++++++++++- 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/evals/EVALS.md b/evals/EVALS.md index 6520d9d..b07aec7 100644 --- a/evals/EVALS.md +++ b/evals/EVALS.md @@ -51,6 +51,7 @@ | B14 | `honesty_ledger` 列 `unrealized_coverage` 時,卡上必講「未實現僅反映 `priced_n`/`held_n` 檔持倉,缺現價:…」;不可讓部分覆蓋的未實現金額看起來像完整數字 | honesty_ledger / SKILL Step 3 gate(#82) | | B15 | `honesty_ledger` 列 `sector_attribution` 時,卡上必補一句「這幾檔有 driver 標籤但查無板塊 ETF 對照,超額被歸入『選股』」;**即使 α 面板因樣本不足/不顯著整塊沒出也要講**(揭露不可只活在 α 面板) | card-spec α/拆帳段 / honesty_ledger(#92) | | B16 | `honesty_ledger` 非空時,每個列出的 `key` 卡面敘事都有對應人話(B6/B14/B15 是 alpha_credibility/unrealized_coverage/sector_attribution 三個 key 的具體講法,其餘 key 同規格);ledger 有列、卡面沒交代 = fail(卡面 ↔ ledger 對帳,非審風格) | SKILL.md Step 3 self-check gate(#82) | +| B17 | 現金(#171,讀 `card.cash`):`reliable=true` 才把現金講進卡(現金佔帳戶 % + `recent_net_deposit` 非 0 時的入金判讀「加深還是解集中度」),用對帳單語言不裸奔 `cash_weight`;`reliable=false`(`honesty_ledger` 列 `cash_reliability`)**不准把盲算佔比當真數字**,只誠實帶一句「現金我只能盲估、給我對帳單餘額才算得準」。無錨點且淨買入(weight=null)= 卡上不冒現金數字也不空吠 | card-spec 現金與入金判讀 / honesty_ledger(#171) | ## C · Goal-hiding(card-spec 拆檔的驗證) diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index 6f11bc3..c4d88bb 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -96,6 +96,7 @@ python3 engine/problems.py stats --today <今天> --rules ~/.trade-coach/rules.j (snapshot 語意 = 該日**收盤後**狀態,同日交易視為已含在宣告數字內。) - **已有帳本、又丟來新快照 → 先 `reconcile`,不要直接 append**:`python3 engine/ledger.py reconcile /tmp/pos.json` 會列宣告 vs 推導的差異——一致 = 對帳通過(卡上可標「帳本已對帳 ✓」);不一致 = 把差異講給用戶聽(「我推 NVDA 40 股,你說 35——中間可能有我沒看到的交易」),他確認後以**他的宣告為準**:`append-snapshot --source reconciled`。這是「數據準確」的機制:每丟一次快照 = 帳本自我修復一次。 - **交易 CSV**(標準化後)→ 除了餵 `trade_recap.py`,同時記帳:`python3 engine/ledger.py append-trades <標準化CSV>`(自動去重,每週增量匯入、重疊期重複匯入都安全);記完帳接著排出場追蹤:`python3 engine/revisit.py enqueue-from-ledger`(掃清倉/大減倉 → 30/60/90 佇列,去重、重跑安全)。enqueue 完**再跑一次 `revisit.py scan`**,讀輸出的 **`recent_exits`**(出場 ≤14 天、金額大者先)——這是 Step 2(d) 賣出理由 capture 的候選集(#136:「為什麼賣」只有出場後兩週內問得到,不可回補;空 = 該段靜默跳過)。enqueue 輸出的 `new` 只是「本次新排入」的參考訊號,**capture 候選一律以 `recent_exits` 為準**——上週中斷沒問到的、當週超過限額的,窗口內這裡還會再出現。 +- **💵 現金餘額錨點(#171,讓「這筆入金該不該部署」通電)**:交易 CSV 只記部位、記不到帳戶閒置現金——沒有它,`cash_weight` 算不出(引擎降級標不可信,見 Step 1 `honesty_ledger`)。所以 Step 0 順手抓一次**當前現金餘額**:對帳單/持倉頁多半有一行「Cash / 現金 / 可用餘額」——你直接讀出來;讀不到就用 `AskUserQuestion` 問一句「對帳單上的現金餘額大約多少?(想看帳戶層現金比重/入金判讀才需要;略過也能出卡)」。拿到就組 JSON 餵引擎:`TR_CASH='{"as_of":"<對帳單日期>","amount":<數字>,"currency":"USD"}'`(混幣各幣別現金分開;台股用 TWD)——引擎以錨點為準(對付 CSV 非從開戶完整),其後現金流才疊加。**入金判讀(`recent_net_deposit`)要看得到存提款流水**:標準化 CSV 時若來源有 deposit/withdrawal/股息/利息/費用列,連同 `Amount` 欄一起留著(格式見 `mock/sample_noisy_broker.csv`),引擎才算得出本期外部淨流入;來源沒有就只靠錨點給比重,判讀那句靜默跳過。 - **snapshot-only(只有快照、還沒有交易紀錄)**:行為診斷跑不了(那需要交易紀錄——誠實講,別硬掰),但出**開帳體檢卡**:用 `holdings` JSON 的成本權重 + 你的世界知識 driver map 講持倉結構(集中度/賽道/sizing,標明「成本基礎」),AI 猜 thesis(Step 2(c))照走,記憶迴圈當場啟動;`integrity` 非空(oversell/壞行)一律如實帶上卡。收尾邀請:「之後把交易紀錄丟給我,攤平/出場/盈虧比這些行為診斷就會解鎖」。 - **帳本誠實檢查**:`holdings` 輸出的 `counts.skipped_lines > 0` = 帳本檔有壞行(可能是中斷寫入)——**如實告訴用戶**、別當帳本完整;修復法就是請他丟一張最新持倉截圖走 reconcile(新錨點蓋過可疑歷史)。(`ledger.py` 純標準庫,不需要 venv——跟 `trade_recap.py` 的 ModuleNotFoundError 提示無關。) - ⚠️ **過渡期規則**:錨點帶入的持倉 engine 看不到(CSV 無該檔交易),所以 ledger 的 cycle_id 與 engine state 的 cycle_id 可能不同——**theses.jsonl 綁定一律仍照抄 engine state 的 cycle_id**(收尾 part 2 的既有規則,CLI 會驗格式),ledger 的 cycle_id 只供帳本自身追蹤。 @@ -115,6 +116,7 @@ TR_JSON=1 TR_STATE_OUT=~/.trade-coach/last_state.json python3 engine/trade_recap # TR_STATE_OUT → 寫一份薄 state(對帳用),跟 TR_JSON 平行,可同時設 # TR_PREV_END= → 對帳模式必帶(#137):問題帳的行為型事件只取其後的 # 新交易(不會把三個月前的舊攤平每週重複入帳);初診不設 = 全期補齊,問題帳統計冷啟動 +# TR_CASH='{"as_of":..,"amount":..,"currency":..}' → 現金餘額錨點(#171,Step 0 抓的);設了 cash_weight 才可信,不設引擎降級標不可信 # 都不設 → 印預設人話卡(README quickstart 用) # TR_DEBUG=1 → 在預設輸出補回 5 維 severity raw 表(開發/驗證用,絕不上卡) ``` @@ -127,8 +129,9 @@ TR_JSON=1 TR_STATE_OUT=~/.trade-coach/last_state.json python3 engine/trade_recap - **`alpha_beta_breakdown` / `payoff_attribution` / `ticker_diagnosis`**:完整數字,你拿去組敘事。 - **`dims_raw`**:5 維行為診斷(每維 severity 0–1)— **別整張攤出來**,用「一句人話」帶過非 headline 的維度(SKILL 鐵律:不放 5 維小數表)。 - **`overview.unrealized_coverage`**:未實現只加總抓得到現價的持倉(`priced_n`/`held_n`/`unpriced`)——讀這欄拿數字,**該不該揭露交給 `honesty_ledger` 統管**(不用自己記何時補)。 +- **`cash`**(#171 帳戶現金):`{balance, weight, source, reliable, recent_net_deposit}`。`reliable=true`(有 `TR_CASH` 錨點)才把 `weight`(現金佔帳戶比重)+ `recent_net_deposit`(本期外部淨入金,判「這筆錢部署了沒/解不解集中度」)講進卡;`reliable=false`(無錨點)是靠交易流水盲算,`weight` 多半 `null`(算不出就不上卡),該不該揭露一樣交 `honesty_ledger`。講法照 card-spec「現金與入金判讀」段。 - **`currency_meta`**:聚合幣別與匯率(💱 Display currency 段的資料源)——`aggregate_currency`(overview / what_if / `ticker_diagnosis` 金額等聚合數字的幣別)、`mixed`、`fx`(兌 USD)、`pnl_by_currency`(原幣分桶)、`fx_error`/`alpha_beta_note`。台股/混幣組合寫卡前**先讀這欄**,金額才不會標錯幣;混幣時單檔原幣金額用 `pnl_by_currency` 對照、或由你按 `fx` 反換算。 -- **`honesty_ledger`**(#82:誠實點的單一事實源):engine 已聚合好這張卡**必須交代**的誠實缺口清單(空 list = 無缺口),每項 `{key, status, data}`,涵蓋 α 不可信 / 板塊歸因不全 / 未實現缺價 / 未分類 driver / 賣超 / 混幣。**engine 判定「該講什麼」,你只管照 card-spec 的講法融入敘事「怎麼講」**;出卡前逐項核對(Step 3 gate)——取代了散在各欄位「自己記得哪些該揭露」的自律。 +- **`honesty_ledger`**(#82:誠實點的單一事實源):engine 已聚合好這張卡**必須交代**的誠實缺口清單(空 list = 無缺口),每項 `{key, status, data}`,涵蓋 α 不可信 / 板塊歸因不全 / 未實現缺價 / 未分類 driver / 賣超 / 混幣 / 現金無錨點。**engine 判定「該講什麼」,你只管照 card-spec 的講法融入敘事「怎麼講」**;出卡前逐項核對(Step 3 gate)——取代了散在各欄位「自己記得哪些該揭露」的自律。 - **alpha/beta**:贏大盤多少、其中多少只是「膽子大(高 beta)」、真本事(Jensen's α)剩多少。`excess_split` 把「贏大盤」機械拆成 **押對賽道(allocation)+ 板塊內選股(selection)**,兩項相加恆等於贏大盤 pp——這兩個數是會計恆等式、不需統計顯著,**永遠可講**;`alpha_stat` 給 α 的 95% 區間 / t 值 / 分級(顯著與否),語氣照它走。 **per-market(混市場組合必讀,#129)**:`alpha_beta_breakdown.scope` 非 null = 組合跨市場,α/β 已按市場分算(US→SPY、TW→台股加權指數),**頂層數字僅含 `scope` 那個市場的部位**——卡上 α/β 段要**兩行並列**(每市場一行,各含資金佔比、各對各的大盤、各自的顯著性語氣),讀 `by_market`;**絕不把兩個市場的 α 加總或平均**(不合成總 α)。台股部位的拆帳 `coverage=0`(無板塊對照、按大盤計)→ 只講「贏/輸台股大盤 X pp」,不拆賽道/選股;`by_market` 內某市場帶 `note`(如 `^TWII` 沒抓到價)→ 該行誠實寫「對照基準抓不到價,本期不判」。單一市場組合 `scope=null`,一切照舊。 - **結構化 state(`TR_STATE_OUT`)**:給對帳用的薄 JSON,讀這幾個欄位 —— diff --git a/skills/fomo-kernel/card-spec.md b/skills/fomo-kernel/card-spec.md index 2822b36..1680681 100644 --- a/skills/fomo-kernel/card-spec.md +++ b/skills/fomo-kernel/card-spec.md @@ -99,9 +99,13 @@ - **鏡片引言別當結語**。結尾突然冒「鏡片原則:…」像老師訓話,交易者「差點關掉」。要嘛融進敘事,要嘛不用。 - **規矩是機械的,不是自我喊話**。「動手前問自己…」沒用(沒人下單時覺得自己會賠)。要給「不靠當下忍住」的機制:「虧損部位一律不加碼,想加先整筆賣掉隔天重買」。 -**誠實點照 `honesty_ledger` 講(#82)**:engine 已把這張卡必講的誠實缺口聚合成 `honesty_ledger`(空=無缺口)——**它決定「講不講」,下面各段決定「怎麼講」**:每個列出的 `key` 融入對應敘事(`alpha_credibility`/`sector_attribution` → α 段、`unrealized_coverage`/`currency_mix` → 金額段、`unclassified_drivers`/`orphan_sells` → 總覽或標的層一句),**不列成獨立區塊、不上卡**。出卡前逐項核對(SKILL Step 3 gate),漏項不出卡。 +**誠實點照 `honesty_ledger` 講(#82)**:engine 已把這張卡必講的誠實缺口聚合成 `honesty_ledger`(空=無缺口)——**它決定「講不講」,下面各段決定「怎麼講」**:每個列出的 `key` 融入對應敘事(`alpha_credibility`/`sector_attribution` → α 段、`unrealized_coverage`/`currency_mix`/`cash_reliability` → 金額/現金段、`unclassified_drivers`/`orphan_sells` → 總覽或標的層一句),**不列成獨立區塊、不上卡**。出卡前逐項核對(SKILL Step 3 gate),漏項不出卡。 **金額 > 筆數勝率**:總覽絕不放「勝 X/負 Y、勝率 %」當主數字——**「6 勝 2 負」這種勝負筆數也算勝率敘事,不進 metric 格、不當句子主詞**(真人反饋:「勝率不重要,關鍵就是賠錢」)。放**已實現 + 未實現損益(兩個都要,只報一個失真)、盈虧比(平均賺 vs 平均賠)**;敘事聚焦**賠側**:平均一筆賠多少、最大拖累是哪檔、佔總賠幾成——錢是賠錢單決定的。**未實現若非全覆蓋要明講**(`honesty_ledger` 列 `unrealized_coverage` 時):補一句「未實現僅反映 priced_n/held_n 檔持倉,缺現價:…」,別讓沒抓到價的持倉靜默漏算成看起來完整的數字。**金額的幣別先讀 `currency_meta` 再落筆**(#51):`aggregate_currency` = 總覽/佔比類金額的幣別;混幣組合(`mixed=true`)單檔原幣數字用 `pnl_by_currency` 對照;display currency 換算與離線匯率規則照 SKILL.md「💱 Display currency」段,這裡不重複。 +**現金與入金判讀(#171,讀 `card.cash`)**:交易工具通常看不到帳戶閒置現金,這個 skill 看得到——但只在 `reliable=true`(用戶給了現金餘額錨點)才把它講進去,別拿盲算數字唬人: +- `reliable=true`:總覽帶一句帳戶現金——「持倉之外還壓著 $X 現金,佔帳戶 Y%」,用**對帳單語言**(現金 / 佔帳戶,不是 `cash_weight`)。這一句的價值是把「部位佔比」升級成「資產佔比」——集中度那個洞若之前只能叫「CSV 內成本占比」,有了現金錨點就能講真的帳戶權重。 +- `recent_net_deposit` 非 0 且 `reliable=true`:講**這筆錢的去向與集中度效應**,這是用戶最痛的一問——「這個月淨入金 $Z:你把它加進最重的那檔(加深集中),還是分散進新部位/留現金(解集中)?」用他實際的加碼行為對照,別空問。為 0 或看不到流水 → 這句靜默跳過,別硬掰。 +- `reliable=false`(`honesty_ledger` 列 `cash_reliability`):現金是靠交易流水盲算(假設開戶 $0),**不准把那個佔比當真數字講**;誠實帶一句邀請即可——「你的現金餘額我只能從流水盲估、可能差很多;下次把對帳單上的現金餘額給我,就能算準帳戶層比重和入金判讀」。這句同時兌現揭露 + 把功能的下一步交回用戶手上。 **α 永遠出數,語氣看統計**:alpha 一律 vs 通用大盤(SPY),**卡上 α 必帶 95% 區間**;`alpha_credible=true`(≥1 年且 |t|≥1.96)才用能力語氣,不顯著就說「區間 −Y%~+Z%,分不出本事還是運氣」+ 講清楚卡在哪(`gate.reason`:樣本不足 vs 區間太寬/持倉集中)——**別再用「不出數」表達誠實,數字+不確定性才是誠實**。**贏大盤必配拆帳**:engine 已把「贏大盤」機械拆成「押對賽道(allocation)」+「板塊內選股(selection)」(`excess_split`,兩項相加=贏大盤,會計恆等不需顯著性)——引用拆帳數字,別自己心算;`coverage<1` 補一句哪幾檔無板塊對照。**`honesty_ledger` 列 `sector_attribution` 時 → 這句必補,即使 α 面板因樣本不足/不顯著整塊沒出**(#92:有 driver 標籤但查無板塊 ETF 的檔,超額被靜默全歸「選股」、押對賽道的功勞被誤記——這揭露不可只活在 α 面板,它是永遠顯示的 data_integrity 一員,同「未分類 driver」)。**絕不拿板塊 ETF 當 α 基準**,板塊只當拆帳對照。這個「賽道 vs 選股」的分離才是用戶要的準確認知。 **廢話零容忍**:像「偏存活紀律、有些是提問不是判你錯」這種學究句一律刪。每行不是數字就是實例,沒有形容詞填充。 diff --git a/skills/fomo-kernel/engine/trade_recap.py b/skills/fomo-kernel/engine/trade_recap.py index 0897bb1..1a74578 100644 --- a/skills/fomo-kernel/engine/trade_recap.py +++ b/skills/fomo-kernel/engine/trade_recap.py @@ -1364,7 +1364,7 @@ def _pick_headline(dims): ranked = _rank_holes(dims) return ranked[0] if ranked else None -def render(dims, strength=None, overview=None, best=None, worst=None, wi=None, trend=None, rx=None, tdiag=None): +def render(dims, strength=None, overview=None, best=None, worst=None, wi=None, trend=None, rx=None, tdiag=None, cash=None): """把復盤卡渲染成一張 Rich Panel(cyan 邊框,ANSI color,中英對齊)。 架構:一張外框大 Panel,內部按段用 Rule(───) 分節;五維行為診斷用 bar chart 取代內部加權公式。""" if not _HAS_RICH: @@ -1414,6 +1414,15 @@ def render(dims, strength=None, overview=None, best=None, worst=None, wi=None, t ov.append(" (區間寬,分不出本事還是運氣 → 見下方)", style="dim") elif ab and ab.get("note"): ov.append(f"\nα/β:{ab['note']}", style="dim") + # 帳戶現金(#171):只在有現金餘額錨點(reliable)時報 weight/入金;無錨點靠 csv_sum 盲算不上卡,交 honesty 揭露 + if cash and cash.get("reliable") and cash.get("weight") is not None: + ov.append("\n帳戶現金 ") + ov.append(f"${cash['balance']:,.0f}", style="bold") + ov.append(f"(佔帳戶 {cash['weight']*100:.0f}%)") + rnd = cash.get("recent_net_deposit") or 0 + if rnd: # 本期外部淨流入/出:入金判讀鉤子(該不該部署) + ov.append(f" 本期淨{'入' if rnd > 0 else '提'}金 ") + ov.append(f"${abs(rnd):,.0f}", style="bold") parts.append(Padding(ov, (0, 1))) # 〔做得最好 / 最差的一筆〕 @@ -1743,7 +1752,7 @@ def build_state(rows, rts, held, dims, overview, ab, rx, currency_meta=None, } # ─────────────────── 結構化 card data(給 Claude 寫敘事卡用)─────────────────── -def build_honesty_ledger(overview, ab, data_integrity, currency_meta): +def build_honesty_ledger(overview, ab, data_integrity, currency_meta, cash=None): """聚合「卡面必須交代的誠實點」成一張清單(#82:機械強制取代 self-check 自律)。 只收『觸發的』揭露項 → 空 list = 這張卡沒有誠實缺口。判定條件對齊預設人話卡的 @@ -1793,11 +1802,16 @@ def build_honesty_ledger(overview, ab, data_integrity, currency_meta): "data": {"currencies": cm.get("currencies"), "aggregate_currency": cm.get("aggregate_currency"), "fx_gaps": di.get("fx_gaps")}}) + # 現金無錨點:cash weight 是靠交易流水盲算(假設開戶 $0),可能偏差 → 若上卡必揭露近似、邀用戶補現金餘額(#171)。 + # 只在「有得上卡的 weight 但不可信」時觸發(reliable=False + weight 非 None);weight=None(算不出,不上卡)沒有可誤導的數字,不進 ledger。 + if isinstance(cash, dict) and cash.get("weight") is not None and not cash.get("reliable"): + L.append({"key": "cash_reliability", "status": "no_anchor", + "data": {"balance": cash.get("balance"), "source": cash.get("source")}}) return L def build_card_data(dims, strength, overview, best, worst, wi, rx, tdiag, - ab, pa, master, data_integrity=None, currency_meta=None): + ab, pa, master, data_integrity=None, currency_meta=None, cash=None): """組裝 SKILL Step 3「定論卡」要用的結構化資料(JSON,非給人看的卡)。 Claude 拿這 dict 用敘事方式寫成一段連貫卡(SKILL.md Step 3 鐵律:連貫敘事 ≠ dashboard 拼接); @@ -1859,7 +1873,8 @@ def build_card_data(dims, strength, overview, best, worst, wi, rx, tdiag, "dims_raw": dims, # 5 維 raw,Claude 用「一句人話」帶過其餘維 "data_integrity": data_integrity or {}, # 賣超/未分類 driver — 影響數據可信度,Claude 該主動提 "currency_meta": currency_meta, # #51/#129 PR-2a:聚合幣別/fx/分幣桶;None=單幣 USD 舊行為 - "honesty_ledger": build_honesty_ledger(overview, ab, data_integrity, currency_meta), # #82:卡面必講的誠實點清單(空=無缺口);出卡前 gate 對照源 + "cash": cash, # #171 PR-1:帳戶現金(balance/weight/source/reliable/recent_net_deposit);reliable 才上 weight/入金判讀,無錨點靠 honesty 揭露 + "honesty_ledger": build_honesty_ledger(overview, ab, data_integrity, currency_meta, cash), # #82:卡面必講的誠實點清單(空=無缺口);出卡前 gate 對照源 } # ─────────────────────────── main ─────────────────────────── @@ -1986,7 +2001,7 @@ def main(): print(meta, file=sys.stderr) card = build_card_data(dims, strength, overview, best, worst, wi, rx, tdiag, ab, pa, master, data_integrity=data_integrity, - currency_meta=currency_meta) + currency_meta=currency_meta, cash=cash_data) print(json.dumps(card, ensure_ascii=False, indent=2, default=str)) else: # 預設:乾淨人話卡(quickstart / fallback 用,#20 違規條目已砍) @@ -1996,7 +2011,7 @@ def main(): f"|driver map: {n_dm} 檔{dm_skip}{split_note}" + (" (純 fallback,冷門股可能失準)" if not n_dm else "")) print(f"# 出場紀律只看「決策賣出」:{len(decision_rts)}/{len(rts)} round-trip" f"(排除 {len(rts)-len(decision_rts)} 筆大盤/債/商品 ETF 再平衡)") - render(dims, strength, overview, best, worst, wi, trend, rx, tdiag) + render(dims, strength, overview, best, worst, wi, trend, rx, tdiag, cash=cash_data) print_alpha_beta(ab) print_payoff_attr(pa) # 盈虧比拆解(誰在撐/拖,反事實) d_entry = dim_entry_style(rows, px) # 【風格】維雛形(不進洞排序,先驗訊號) diff --git a/tests/test_engine_units.py b/tests/test_engine_units.py index 8415c26..f68130b 100644 --- a/tests/test_engine_units.py +++ b/tests/test_engine_units.py @@ -851,6 +851,23 @@ def test_cash_position_none_weight_when_unanchored_negative(): assert cp2["weight"] is not None and cp2["weight"] < 0, cp2["weight"] # 有錨點負現金=融資,照報負值 +def test_honesty_ledger_cash_reliability_trigger(): + """#171 呈現層:cash_reliability 只在『有可誤導的 weight 但不可信』時進 ledger。 + reliable(錨點)→ 不觸發;weight=None(算不出,不上卡)→ 不觸發(無可誤導數字); + csv_sum 正餘額 weight 非 None → 觸發(盲算佔比上卡必揭露近似)。engine 判定,card-spec 講法。""" + def fired(cash): + hl = tr.build_honesty_ledger(overview={}, ab={}, data_integrity={}, currency_meta={}, cash=cash) + return [e for e in hl if e["key"] == "cash_reliability"] + assert not fired({"balance": 19284.0, "weight": 0.29, "source": "anchored", + "reliable": True, "recent_net_deposit": 0}), "錨點可信不該揭露" + assert not fired({"balance": -27411.5, "weight": None, "source": "csv_sum", + "reliable": False, "recent_net_deposit": 0}), "weight=None 沒上卡不該空吠" + hit = fired({"balance": 8000.0, "weight": 0.14, "source": "csv_sum", + "reliable": False, "recent_net_deposit": 0}) + assert hit and hit[0]["status"] == "no_anchor" and hit[0]["data"]["source"] == "csv_sum", hit + assert not fired(None), "無 cash 欄(None)不觸發" + + # ─────────────────── 標準庫 runner(免 pytest 即可跑,與 test_sample_styles 一致)─────────────────── def _main(): diff --git a/tests/test_tr_json_contract.py b/tests/test_tr_json_contract.py index e2d441c..32afe8a 100644 --- a/tests/test_tr_json_contract.py +++ b/tests/test_tr_json_contract.py @@ -36,6 +36,7 @@ "thesis_questions", "top_holes", "candidate_rules", "prescriptions", "alpha_beta_breakdown", "payoff_attribution", "dims_raw", "data_integrity", "currency_meta", # #51/#129 PR-2a:聚合幣別/fx/分幣桶 + "cash", # #171 PR-1 呈現層:帳戶現金上卡(balance/weight/source/reliable/recent_net_deposit;None=未提供) "honesty_ledger", # #82:卡面必講的誠實點清單(觸發項聚合;空=無缺口) } STATE_KEYS = { @@ -145,7 +146,7 @@ def main(): ok(all({"key", "status", "data"} <= set(e.keys()) for e in hl), "honesty_ledger 每項 = {key,status,data}", repr(hl)[:150]) HL_KEYS = {"alpha_credibility", "sector_attribution", "unclassified_drivers", - "unrealized_coverage", "orphan_sells", "currency_mix"} + "unrealized_coverage", "orphan_sells", "currency_mix", "cash_reliability"} ok(all(e["key"] in HL_KEYS for e in hl), "honesty_ledger key 都在允許集合", repr([e["key"] for e in hl])) hl_keys = {e["key"] for e in hl} @@ -156,6 +157,17 @@ def main(): ok("currency_mix" not in hl_keys, "單幣 mock → currency_mix 不進 ledger(觸發式,非無條件)", repr(sorted(hl_keys))) + # ── 1a. #171 呈現層:card.cash 形狀 + 無 TR_CASH 錨點時降級,cash_reliability 觸發式 ── + ccash = card["cash"] + ok(isinstance(ccash, dict) and set(ccash.keys()) == + {"balance", "weight", "source", "reliable", "recent_net_deposit"}, + "card.cash 5 欄位齊(與 state.cash 同源)", repr(ccash)[:120]) + ok(ccash["source"] == "csv_sum" and ccash["reliable"] is False, + "無 TR_CASH 錨點 → card.cash 降級 csv_sum + reliable=False", repr(ccash)) + # mock 淨買入 → balance<0 → weight=None(算不出,不上卡)→ cash_reliability 不觸發(只在有可誤導 weight 時才進 ledger) + ok(ccash["weight"] is None and "cash_reliability" not in hl_keys, + "無錨點且淨買入 → weight=None,cash_reliability 不進 ledger(不冒充也不空吠)", repr(sorted(hl_keys))) + # ── 2. state 契約 ── st = json.loads(state_path.read_text(encoding="utf-8")) ok(set(st.keys()) == STATE_KEYS, "state 頂層 key 恰等於契約",