diff --git a/evals/EVALS.md b/evals/EVALS.md index a9e61e9..fb37c8c 100644 --- a/evals/EVALS.md +++ b/evals/EVALS.md @@ -51,7 +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) | +| B17 | 現金(#171,讀 `card.cash`):`reliable=true` 才把現金講進卡(現金佔帳戶 % + `recent_net_deposit` 非 0 時的入金判讀「加深還是解集中度」),用對帳單語言不裸奔 `cash_weight`;`reliable=false`(`honesty_ledger` 列 `cash_reliability`)**不准把盲算佔比當真數字**,只誠實帶一句「現金我只能盲估、給我對帳單餘額才算得準」。無錨點且淨買入(weight=null)= 卡上不冒現金數字也不空吠;**多幣別現金桶(台美各帳戶各自 `TR_CASH` 錨點):全給→`reliable` 聚合上卡、只給部分→`source=partial` 只把 `unanchored_currencies` 那個帳戶講成盲估,別把已可信的那半也講不準** | card-spec 現金與入金判讀 / honesty_ledger(#171) | | B18 | 多市場(#173,台股+美股混倉):**combined 口徑含台股**——最大單點依賴/賽道曝險分母不因台股不在美股 CSV 就漏算(聚合 USD 視圖下台積電 `2330.TW` 是真最大依賴時要講出來,不被某支美股冒名);α/β per-market 各對自己大盤(台股對 `^TWII`)**不合成總 α**,頂層只講 `scope` 市場範圍;混幣聚合金額標 `aggregate_currency`(USD)、缺匯率明示近似。前置 = Step 0 把台股 `Symbol` 標成 `.TW`/`.TWO` + `Market=TW`/`Currency=TWD`(認格式是 Claude 職責,引擎不 hardcode) | Step 0 標準化 / dim_alpha_beta per-market / currency_meta(#173/#132) | ## C · Goal-hiding(card-spec 拆檔的驗證) diff --git a/skills/fomo-kernel/SKILL.md b/skills/fomo-kernel/SKILL.md index 7e468b8..28c1142 100644 --- a/skills/fomo-kernel/SKILL.md +++ b/skills/fomo-kernel/SKILL.md @@ -98,7 +98,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`),引擎才算得出本期外部淨流入;來源沒有就只靠錨點給比重,判讀那句靜默跳過。 +- **💵 現金餘額錨點(#171,讓「這筆入金該不該部署」通電)**:交易 CSV 只記部位、記不到帳戶閒置現金——沒有它,`cash_weight` 算不出(引擎降級標不可信,見 Step 1 `honesty_ledger`)。所以 Step 0 順手抓一次**當前現金餘額**:對帳單/持倉頁多半有一行「Cash / 現金 / 可用餘額」——你直接讀出來;讀不到就用 `AskUserQuestion` 問一句「對帳單上的現金餘額大約多少?(想看帳戶層現金比重/入金判讀才需要;略過也能出卡)」。拿到就組 JSON 餵引擎:單一帳戶 `TR_CASH='{"as_of":"<對帳單日期>","amount":<數字>,"currency":"USD"}'`;**台美等多帳戶/多幣別各給一個錨點,用 list**:`TR_CASH='[{"as_of":..,"amount":..,"currency":"USD"},{"as_of":..,"amount":..,"currency":"TWD"}]'`(引擎 per-currency 各算餘額再按匯率聚合;台股帳戶用 TWD)——引擎以錨點為準(對付 CSV 非從開戶完整),其後現金流才疊加。只給部分帳戶的錨點也行:沒給的幣別引擎標盲算,`honesty_ledger` 只揭露缺的那個、邀你補。**入金判讀(`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 只供帳本自身追蹤。 @@ -118,7 +118,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 才可信,不設引擎降級標不可信 +# TR_CASH='{"as_of":..,"amount":..,"currency":..}'(單帳戶)或 '[{..},{..}]'(台美多帳戶各一錨點) → 現金餘額錨點(#171,Step 0 抓的);設了 cash_weight 才可信,不設引擎降級標不可信 # 都不設 → 印預設人話卡(README quickstart 用) # TR_DEBUG=1 → 在預設輸出補回 5 維 severity raw 表(開發/驗證用,絕不上卡) ``` @@ -131,7 +131,7 @@ 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「現金與入金判讀」段。 +- **`cash`**(#171 帳戶現金):`{balance, weight, source, reliable, recent_net_deposit, by_currency}`。`balance`=聚合 USD、`by_currency`=per-幣別原幣明細。`reliable=true`(所有有現金流的幣別都給了 `TR_CASH` 錨點)才把 `weight`+ `recent_net_deposit`(判「這筆錢部署了沒/解不解集中度」)講進卡;`source=partial`(部分帳戶給了、部分沒)或 `csv_sum`(全無錨點)= 靠流水盲算,`weight` 多半 `null`,該不該揭露交 `honesty_ledger`(`cash_reliability.unanchored_currencies` 標哪個幣別缺)。講法照 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)——取代了散在各欄位「自己記得哪些該揭露」的自律。 - **alpha/beta**:贏大盤多少、其中多少只是「膽子大(高 beta)」、真本事(Jensen's α)剩多少。`excess_split` 把「贏大盤」機械拆成 **押對賽道(allocation)+ 板塊內選股(selection)**,兩項相加恆等於贏大盤 pp——這兩個數是會計恆等式、不需統計顯著,**永遠可講**;`alpha_stat` 給 α 的 95% 區間 / t 值 / 分級(顯著與否),語氣照它走。 diff --git a/skills/fomo-kernel/card-spec.md b/skills/fomo-kernel/card-spec.md index 1680681..7c69905 100644 --- a/skills/fomo-kernel/card-spec.md +++ b/skills/fomo-kernel/card-spec.md @@ -106,6 +106,7 @@ - `reliable=true`:總覽帶一句帳戶現金——「持倉之外還壓著 $X 現金,佔帳戶 Y%」,用**對帳單語言**(現金 / 佔帳戶,不是 `cash_weight`)。這一句的價值是把「部位佔比」升級成「資產佔比」——集中度那個洞若之前只能叫「CSV 內成本占比」,有了現金錨點就能講真的帳戶權重。 - `recent_net_deposit` 非 0 且 `reliable=true`:講**這筆錢的去向與集中度效應**,這是用戶最痛的一問——「這個月淨入金 $Z:你把它加進最重的那檔(加深集中),還是分散進新部位/留現金(解集中)?」用他實際的加碼行為對照,別空問。為 0 或看不到流水 → 這句靜默跳過,別硬掰。 - `reliable=false`(`honesty_ledger` 列 `cash_reliability`):現金是靠交易流水盲算(假設開戶 $0),**不准把那個佔比當真數字講**;誠實帶一句邀請即可——「你的現金餘額我只能從流水盲估、可能差很多;下次把對帳單上的現金餘額給我,就能算準帳戶層比重和入金判讀」。這句同時兌現揭露 + 把功能的下一步交回用戶手上。 +- `status=partial`(台美多帳戶只給了一部分錨點,`unanchored_currencies` 標缺的幣別):講法收窄到缺的那個帳戶,別把已可信的那半也講成不準——「你的美股帳戶現金我算得準,台股帳戶餘額還沒給、只能盲估;補上那個就完整了」。 **α 永遠出數,語氣看統計**: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 c6a7b7e..6dce190 100644 --- a/skills/fomo-kernel/engine/trade_recap.py +++ b/skills/fomo-kernel/engine/trade_recap.py @@ -197,48 +197,67 @@ def load_cash_flows(paths): return flows -def cash_position(cash_flows, held_mv, anchor=None, prev_end=None): - """帳戶現金地基(#171 PR-1)。 - - cash_balance:有現金餘額錨點 → 錨點 + 其後現金流(正確,對付 is_complete=False 的不完整 CSV); - 無錨點 → 全期 Σamount 並標不可信(假設開戶現金 0,CSV 漏一筆 deposit 就偏,honesty_ledger 揭露)。 - - cash_weight = 現金 /(持倉市值 + 現金);分母 ≤0 → None。 - - recent_net_deposit = 本期(prev_end 後)外部淨流入(deposit − withdrawal),給「這筆錢該不該部署」判讀。 - 幣別:假設 cash_flows.amount 已在聚合幣別(混幣換算由 main 接線層負責)。""" - if isinstance(prev_end, str): - prev_end = dt.date.fromisoformat(prev_end) if prev_end else None - a_date = None +def _cash_balance_one_ccy(flows, anchor, prev_end): + """單一幣別的現金餘額(原幣)。回 (balance, source, reliable)。 + 有錨點 → 錨點餘額 + 其後現金流(對付不完整 CSV,reliable); + 無 → Σ全部(假設開戶 0,CSV 漏一筆 deposit 就偏,csv_sum/unreliable)。""" if anchor and anchor.get("as_of") and anchor.get("amount") is not None: a_date = anchor["as_of"] if isinstance(a_date, str): a_date = dt.date.fromisoformat(a_date) - balance = float(anchor["amount"]) + sum(cf["amount"] for cf in cash_flows if cf["date"] > a_date) - source, reliable = "anchored", True - else: - balance = sum(cf["amount"] for cf in cash_flows) - source, reliable = "csv_sum", False - denom = held_mv + balance - # 無錨點的負現金 = csv_sum 假設破裂(買入為主、入金沒記全),weight 是垃圾 → 不報;denom≤0 亦不報。 - # 有錨點的負現金 = 真融資(margin debit),weight 負有意義(槓桿曝險),照報。 - if denom <= 1e-9 or (not reliable and balance < 0): + bal = float(anchor["amount"]) + sum(cf["amount"] for cf in flows if cf["date"] > a_date) + return bal, "anchored", True + return sum(cf["amount"] for cf in flows), "csv_sum", False + + +def cash_position(cash_flows, held_mv, anchor=None, prev_end=None, fx=None): + """帳戶現金地基(#171 PR-1;多幣別現金桶)。 + per-currency 各算餘額(錨點+其後現金流 or csv_sum),用 fx 聚合成 USD total—— + 台美各帳戶現金各自錨點(一個 TR_CASH 只表一種幣的舊限制在此解除)。 + - anchor:單 dict(向後相容,無 currency 欄 → 對應唯一幣別/單桶)或 list[dict](per-currency,各帶 currency)。 + - fx:{ccy: usd_per_unit} 多幣別聚合用;None/單幣 → 因子 1.0(原幣即聚合幣)。 + - cash_balance:聚合 USD;cash_weight = 現金 /(持倉市值 + 現金),分母 ≤0 → None。 + - recent_net_deposit:本期(prev_end 後)外部淨流入,per-currency 換 USD 加總。 + - reliable:所有有現金流的幣別都有錨點=True(source=anchored);部分=partial;全無=csv_sum。 + - by_currency:{ccy: {balance(原幣), source, reliable}} per-currency 明細(呈現層可展開、可只揭露缺錨點的幣別)。""" + if isinstance(prev_end, str): + prev_end = dt.date.fromisoformat(prev_end) if prev_end else None + fx = fx or {} + anchors = [anchor] if isinstance(anchor, dict) else list(anchor or []) + currencies = sorted({cf.get("currency", "USD") for cf in cash_flows}) or ["USD"] + + def _anchor_for(c): + # 帶 currency 的錨點按幣別配對;無 currency 的錨點只在單一幣別時對應(向後相容舊單桶格式)。 + for a in anchors: + ac = (a.get("currency") or "").strip().upper() + if ac == c or (not ac and len(currencies) == 1): + return a + return None + + by_ccy, total, recent = {}, 0.0, 0.0 + all_reliable, any_reliable = True, False + for c in currencies: + flows_c = [cf for cf in cash_flows if cf.get("currency", "USD") == c] + bal_c, src_c, rel_c = _cash_balance_one_ccy(flows_c, _anchor_for(c), prev_end) + f = fx.get(c, 1.0) + by_ccy[c] = dict(balance=round(bal_c, 2), source=src_c, reliable=rel_c) + total += bal_c * f + recent += f * sum(cf["amount"] for cf in flows_c + if cf["kind"] in ("deposit", "withdrawal") + and (prev_end is None or cf["date"] > prev_end)) + all_reliable = all_reliable and rel_c + any_reliable = any_reliable or rel_c + source = "anchored" if all_reliable else ("partial" if any_reliable else "csv_sum") + denom = held_mv + total + # 不完全可信的負現金 = csv_sum 假設破裂(入金沒記全),weight 垃圾 → None;denom≤0 亦 None。 + # 全可信(全錨點)的負現金 = 真融資 margin,weight 負有意義,照報。 + if denom <= 1e-9 or (not all_reliable and total < 0): weight = None else: - weight = balance / denom - recent = sum(cf["amount"] for cf in cash_flows - if cf["kind"] in ("deposit", "withdrawal") - and (prev_end is None or cf["date"] > prev_end)) - return dict(balance=round(balance, 2), weight=weight, source=source, - reliable=reliable, recent_net_deposit=round(recent, 2)) - - -def _anchor_to_aggregate(anchor, fx, mixed_ccy): - """混幣時把現金餘額錨點換到聚合幣(USD)。 - 錨點是 Step 0 從對帳單抓的帳戶現金,獨立於交易 CSV,原幣別記在 anchor.currency。 - cash_flows 已在 main 換過;錨點是另一路輸入,不在此補換 → TWD 錨點被當 USD 直接加, - cash_weight 放大數十倍。單幣時聚合幣=原幣,錨點原樣返回(純台股 TWD 錨點對 TWD 現金流自洽)。""" - if not (anchor and mixed_ccy and anchor.get("amount") is not None): - return anchor - cur = (anchor.get("currency") or "USD").strip().upper() - return dict(anchor, amount=float(anchor["amount"]) * fx.get(cur, 1.0)) + weight = total / denom + return dict(balance=round(total, 2), weight=weight, source=source, + reliable=all_reliable, recent_net_deposit=round(recent, 2), + by_currency=by_ccy) def _load_skip_note(): @@ -1830,8 +1849,12 @@ def build_honesty_ledger(overview, ab, data_integrity, currency_meta, cash=None) # 現金無錨點: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")}}) + # partial(部分帳戶給了餘額、部分沒)vs no_anchor(全無錨點);揭露哪些幣別是盲算,邀補那幾個帳戶餘額。 + missing = sorted(c for c, v in (cash.get("by_currency") or {}).items() if not v.get("reliable")) + L.append({"key": "cash_reliability", + "status": "partial" if cash.get("source") == "partial" else "no_anchor", + "data": {"balance": cash.get("balance"), "source": cash.get("source"), + "unanchored_currencies": missing}}) return L @@ -2004,22 +2027,20 @@ def main(): else ("多幣別組合(單一市場)的 α/β 按該市場大盤計" if mixed_ccy else None)), } - # 帳戶現金地基(#171 PR-1):現金流 + 現金餘額錨點 → cash_position。 - # held_mv = 持倉市值(聚合幣別,無現價用成本近似,同 dim_diversify)= cash_weight 分母。 + # 帳戶現金地基(#171):現金流 + 現金餘額錨點 → cash_position(多幣別 per-currency 各算再 fx 聚合)。 + # held_mv = 持倉市值(聚合幣別 USD,無現價用成本近似,同 dim_diversify)= cash_weight 分母。 import json - cash_flows = load_cash_flows(paths) - if mixed_ccy: # 混幣:現金流各幣別 → USD(對齊聚合視圖) - cash_flows = [dict(cf, amount=cf["amount"] * fx.get(cf["currency"], 1.0)) for cf in cash_flows] + cash_flows = load_cash_flows(paths) # per-currency(每筆帶 currency);聚合由 cash_position 內部做 held_mv = sum((sh * lastpx_u[t]) if lastpx_u.get(t) else c for t, (sh, c) in held_u.items()) - _ca = os.environ.get("TR_CASH") # SKILL Step 0 抓對帳單現金餘額 → JSON {as_of, amount, currency} + _ca = os.environ.get("TR_CASH") # SKILL Step 0 抓對帳單現金餘額 → 單 {as_of,amount,currency} 或多帳戶 list try: cash_anchor = json.loads(_ca) if _ca else None except (ValueError, TypeError): cash_anchor = None - # 混幣:錨點餘額也要換到聚合幣(USD),否則 TWD 錨點被當 USD 加 → cash_weight 放大數十倍。 - cash_anchor = _anchor_to_aggregate(cash_anchor, fx, mixed_ccy) + # 多幣別:cash_position 內部 per-currency 各算餘額再用 fx 聚合(台美各帳戶各自錨點);單幣 fx=None → 因子 1.0。 cash_data = cash_position(cash_flows, held_mv, anchor=cash_anchor, - prev_end=os.environ.get("TR_PREV_END") or None) + prev_end=os.environ.get("TR_PREV_END") or None, + fx=fx if mixed_ccy else None) dm_skip = f"({_DM_SKIPPED} 筆格式錯跳過)" if _DM_SKIPPED else "" split_note = f"|分割調整: {n_adj} 筆" if n_adj else "" diff --git a/tests/test_engine_units.py b/tests/test_engine_units.py index 773ad4a..4961464 100644 --- a/tests/test_engine_units.py +++ b/tests/test_engine_units.py @@ -851,21 +851,49 @@ def test_cash_position_none_weight_when_unanchored_negative(): assert cp2["weight"] is not None and cp2["weight"] < 0, cp2["weight"] # 有錨點負現金=融資,照報負值 -def test_anchor_to_aggregate_converts_by_currency(): - """混幣:現金餘額錨點按其原幣別換到聚合幣(USD)。錨點是 Step 0 抓的對帳單現金、獨立於交易 CSV, - cash_flows 已換但錨點是另一路輸入 → 不補換則 TWD 錨點被當 USD 加,cash_weight 放大數十倍(latent gap)。""" +def test_cash_position_multi_currency_anchors(): + """多幣別現金桶:台美各帳戶各自錨點 → per-currency 各算餘額,fx 聚合成 USD total。 + reliable 只在所有有現金流的幣別都有錨點時 True;by_currency 給原幣明細。""" + flows = [ + dict(date=dt.date(2024, 1, 10), amount=-100000.0, kind="trade", currency="TWD"), + dict(date=dt.date(2024, 1, 15), amount=5000.0, kind="deposit", currency="TWD"), + dict(date=dt.date(2024, 1, 12), amount=-2000.0, kind="trade", currency="USD"), + dict(date=dt.date(2024, 1, 20), amount=1000.0, kind="deposit", currency="USD"), + ] fx = {"USD": 1.0, "TWD": 0.03} - anc = {"as_of": "2024-03-15", "amount": 250000.0, "currency": "TWD"} - out = tr._anchor_to_aggregate(anc, fx, mixed_ccy=True) - assert _approx(out["amount"], 7500.0), out["amount"] # 250000 × 0.03 = 7500 USD - assert out["currency"] == "TWD" and out["as_of"] == "2024-03-15" # 其餘欄位不動 - assert tr._anchor_to_aggregate(anc, fx, mixed_ccy=False) is anc # 單幣:聚合幣=原幣,原樣返回不亂換 - miss = tr._anchor_to_aggregate(anc, {"USD": 1.0}, mixed_ccy=True) # 缺該幣別匯率 → 因子 1.0 近似(不 crash) - assert _approx(miss["amount"], 250000.0), miss["amount"] - assert tr._anchor_to_aggregate(None, fx, True) is None # None-safe - assert tr._anchor_to_aggregate({"as_of": "x"}, fx, True) == {"as_of": "x"} # 無 amount → 原樣 - usd_anc = {"amount": 5000.0, "currency": "USD"} # USD 錨點在混幣時因子 1.0 不變 - assert _approx(tr._anchor_to_aggregate(usd_anc, fx, True)["amount"], 5000.0) + anchors = [{"as_of": "2024-01-16", "amount": 300000.0, "currency": "TWD"}, # 其後無 TWD flow + {"as_of": "2024-01-18", "amount": 8000.0, "currency": "USD"}] # 其後 1/20 deposit 1000 + cp = tr.cash_position(flows, held_mv=50000.0, anchor=anchors, fx=fx) + assert cp["source"] == "anchored" and cp["reliable"] is True + assert cp["by_currency"]["TWD"]["balance"] == 300000.0 # 錨點+其後(無) + assert cp["by_currency"]["USD"]["balance"] == 9000.0 # 8000+1000 + assert _approx(cp["balance"], 18000.0), cp["balance"] # 300000×0.03 + 9000 + assert _approx(cp["weight"], 18000.0 / 68000.0), cp["weight"] + + +def test_cash_position_partial_anchor_is_unreliable(): + """部分幣別有錨點(台股給了餘額、美股沒給)→ 缺錨點幣別走 csv_sum,整體 source=partial/reliable=False; + by_currency 標明哪個幣別可信,呈現層可只揭露缺的那個。""" + flows = [ + dict(date=dt.date(2024, 1, 10), amount=-100000.0, kind="trade", currency="TWD"), + dict(date=dt.date(2024, 1, 12), amount=3000.0, kind="deposit", currency="USD"), + ] + fx = {"USD": 1.0, "TWD": 0.03} + cp = tr.cash_position(flows, held_mv=50000.0, fx=fx, + anchor=[{"as_of": "2024-01-05", "amount": 200000.0, "currency": "TWD"}]) # 只 TWD + assert cp["source"] == "partial" and cp["reliable"] is False + assert cp["by_currency"]["TWD"]["reliable"] is True # TWD 有錨點 + assert cp["by_currency"]["USD"]["reliable"] is False # USD 無 → csv_sum + assert _approx(cp["balance"], 6000.0), cp["balance"] # (200000-100000)×0.03 + 3000 + + +def test_cash_position_single_dict_anchor_backward_compat(): + """向後相容(#171 舊格式):單 dict 錨點(無 currency)在單一幣別時仍對應該幣別,行為不破;新增 by_currency 欄。""" + flows = [dict(date=dt.date(2024, 1, 10), amount=1000.0, kind="deposit", currency="USD")] + cp = tr.cash_position(flows, held_mv=10000.0, anchor={"as_of": "2024-01-05", "amount": 5000.0}) + assert cp["source"] == "anchored" and cp["reliable"] is True + assert _approx(cp["balance"], 6000.0), cp["balance"] # 5000+1000 + assert cp["by_currency"]["USD"]["balance"] == 6000.0 def test_honesty_ledger_cash_reliability_trigger(): diff --git a/tests/test_sample_styles.py b/tests/test_sample_styles.py index 8fc1314..17b047a 100644 --- a/tests/test_sample_styles.py +++ b/tests/test_sample_styles.py @@ -459,7 +459,8 @@ def test_tw_mixed_combined_exposure_network(): csv = os.path.join(MOCK, "sample_tw_mixed.csv") dm = os.path.join(MOCK, "sample_tw_mixed.driver_map.json") env = dict(os.environ, TR_JSON="1", TR_DRIVER_MAP=dm, - TR_CASH=_json.dumps({"as_of": "2024-03-15", "amount": 250000, "currency": "TWD"})) + TR_CASH=_json.dumps([{"as_of": "2024-03-15", "amount": 250000, "currency": "TWD"}, + {"as_of": "2024-03-15", "amount": 4000, "currency": "USD"}])) engine = os.path.join(SKILL, "engine", "trade_recap.py") r = subprocess.run([sys.executable, engine, csv], capture_output=True, text=True, env=env) if r.returncode != 0 or not r.stdout.strip(): @@ -474,8 +475,11 @@ def test_tw_mixed_combined_exposure_network(): ab = card["alpha_beta_breakdown"] assert ab["scope"] == "TW" and ab["bench"] == "^TWII", ab.get("scope") assert {"TW", "US"} <= set(ab["by_market"]) - # 混幣現金錨點換算聚合幣:250000 TWD ≈ 7-8.5k USD(非當 250000 USD 的 latent gap) - assert 6000 < card["cash"]["balance"] < 9000, card["cash"]["balance"] + # 多幣別現金桶:台美各帳戶各自錨點 → 聚合 USD(250000 TWD×fx + 4000 USD ≈ 11-12k),都有錨點 reliable + cash = card["cash"] + assert cash["reliable"] and cash["source"] == "anchored", cash + assert 10000 < cash["balance"] < 13000, cash["balance"] + assert set(cash["by_currency"]) == {"TWD", "USD"}, cash["by_currency"] # AI/半導體 combined 曝險分母含台股(what_if 板塊集中) assert "半導體" in (card["what_if"] or {}).get("label", "") and card["what_if"]["pct"] > 0.5 diff --git a/tests/test_tr_json_contract.py b/tests/test_tr_json_contract.py index 32afe8a..4cb9380 100644 --- a/tests/test_tr_json_contract.py +++ b/tests/test_tr_json_contract.py @@ -160,7 +160,7 @@ def main(): # ── 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"}, + {"balance", "weight", "source", "reliable", "recent_net_deposit", "by_currency"}, "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)) @@ -199,7 +199,7 @@ def main(): # ── 2a. #171 現金地基:state.cash 形狀 + 無 TR_CASH 錨點時降級為 csv_sum/不可信 ── cash = st["cash"] ok(isinstance(cash, dict) and set(cash.keys()) == - {"balance", "weight", "source", "reliable", "recent_net_deposit"}, + {"balance", "weight", "source", "reliable", "recent_net_deposit", "by_currency"}, "state.cash 5 欄位齊(balance/weight/source/reliable/recent_net_deposit)", repr(cash)[:120]) ok(cash["source"] == "csv_sum" and cash["reliable"] is False, "無 TR_CASH 錨點 → cash 降級 csv_sum + reliable=False(honesty 據此揭露,不冒充精確)",