You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[defect·P1] A currency that appears only in a cash-flow row is added to the USD total at 1.0 — #612's refusal has held currencies as its domain, and no rate is ever requested #649
Discussion — the defect is confirmed and reproduced; the remedy shape (refuse, disclose, or fetch) is a product decision that is deliberately not made here.
Milestone
None. This is confirmed debt in the same truth-critical class as #612, but the milestone owner has not promoted it and it is not the M1 acceptance step (#610 is). #610's walk already avoids this territory by requiring "a single-currency book, or a book whose FX is already available".
Owning outcome
An account's cash total, cash weight, account-level return, annualized return and cash drag are never computed by adding one currency's raw units to another's — including when the second currency appears only in a cash-flow row and never in a trade.
#612 fixed exactly this arithmetic for held stock currencies and its scope statement is explicit:
Trigger only when a held currency needed for cross-currency aggregation is absent from the resolved FX map.
with Per-route FX checks beside usd_view listed as a non-goal. That was the right cut. But the domain of the refusal is cur_map, which trade_recap.currency_map(rows) builds from load()'s output — and load() keeps BUY/SELL rows only. load_cash_flows reads a separate per-row Currency column for DEPOSIT/WITHDRAWAL/DIVIDEND/INTEREST/FEE rows (trade_recap.py:233). A currency present only there is structurally invisible to held_currency_fx_gaps, so usd_view cannot refuse it.
Worse, the same cur_map-derived currencies list drives the fetch decision:
So on an all-USD stock book this is not a rate that failed to arrive — no rate is ever requested. Two places then supply 1.0 with no disclosure:
trade_recap.cash_position — f = fx.get(c, 1.0) (trade_recap.py:294), reached with fx=None because mixed_ccy is False (trade_recap.py:2767).
perf._fx_getter — the final else branch, ser = [1.0] * len(px_index) (perf.py:205), reached with fx_spot=None, fx_series=None for the same reason.
perf.py:205's comment — # 缺匯率:原幣近似(上游 fx_gaps 揭露) — is stale for this lane. trade_recap.py:2405 already records that after #612di["fx_gaps"] no longer appears on the review lane, because a missing rate became a refusal rather than a disclosure. The refusal does not cover cash-only currencies, so this path now has neither.
The design premise the module rests on — "單一幣別組合(含純台股)聚合天然自洽 → 不抓匯率、零行為變化" — is sound, but the predicate that tests it counts only stocks. A book with USD stocks and a TWD interest row is not a single-currency book; it only looks like one.
Reproduced end-to-end through the CLI
Synthetic data only; no real trades or balances. main@3e174d9, isolated --root, TR_LEDGER=/dev/null.
This is a strictly worse posture than the pre-#612 stock case, which at least degraded into data_integrity.fx_gaps. Here the card is cash_source: anchored, unanchored: [], fx_approx: false — maximum stated confidence on a number that is wrong by 28x.
The product asks for the input that triggers it
Before the anchor is supplied, input.cash_anchor.next_action reads:
...ask the user for the account's current cash balance in TWD, USD — stating what it unlocks...
with unanchored_currencies: ["TWD", "USD"]. The engine knows two currencies are in play, names them to the user, solicits both balances, and then adds them at 1:1 — and supplying them is what unlocksacct_twr/irr_annual/cash_drag. The unanchored case gates those three to null; answering the question the product asks is what turns them on.
Is the shape real?
Nothing constrains cash-flow currency to a held stock's currency. references/data-contract.md documents Currency only for trade rows ("Always set Currency (and Market) for a non-US trade row"), and load_cash_flows defaults a blank to USD independently. Plausible real books with this shape: a Taiwanese sub-brokerage (複委託) account trading only US stocks while custody/wire fees and interest post in TWD; an IBKR account trading one market with interest accruing in the base currency; any account with an FX-conversion fee logged in the home currency. It needs one such row in the whole file.
Do nothing, document the constraint. Only defensible if the owner judges the shape unreachable in practice — the reproduction above argues it is not.
Option 2 subsumes 1 and looks like the natural reading of #612's intent, but choosing is an owner call: it trades a blocked review against a wrong one, and it touches the retrieval boundary #605 owns.
Current evidence
Reproduced against main@3e174d9 with the CSV and command above; synthetic data throughout.
Status
Discussion — the defect is confirmed and reproduced; the remedy shape (refuse, disclose, or fetch) is a product decision that is deliberately not made here.
Milestone
None. This is confirmed debt in the same truth-critical class as #612, but the milestone owner has not promoted it and it is not the M1 acceptance step (#610 is). #610's walk already avoids this territory by requiring "a single-currency book, or a book whose FX is already available".
Owning outcome
An account's cash total, cash weight, account-level return, annualized return and cash drag are never computed by adding one currency's raw units to another's — including when the second currency appears only in a cash-flow row and never in a trade.
Current scope
Why #612's guarantee cannot reach this
#612 fixed exactly this arithmetic for held stock currencies and its scope statement is explicit:
with
Per-route FX checks beside usd_viewlisted as a non-goal. That was the right cut. But the domain of the refusal iscur_map, whichtrade_recap.currency_map(rows)builds fromload()'s output — andload()keeps BUY/SELL rows only.load_cash_flowsreads a separate per-rowCurrencycolumn forDEPOSIT/WITHDRAWAL/DIVIDEND/INTEREST/FEErows (trade_recap.py:233). A currency present only there is structurally invisible toheld_currency_fx_gaps, sousd_viewcannot refuse it.Worse, the same
cur_map-derivedcurrencieslist drives the fetch decision:So on an all-USD stock book this is not a rate that failed to arrive — no rate is ever requested. Two places then supply 1.0 with no disclosure:
trade_recap.cash_position—f = fx.get(c, 1.0)(trade_recap.py:294), reached withfx=Nonebecausemixed_ccyis False (trade_recap.py:2767).perf._fx_getter— the finalelsebranch,ser = [1.0] * len(px_index)(perf.py:205), reached withfx_spot=None, fx_series=Nonefor the same reason.perf.py:205's comment —# 缺匯率:原幣近似(上游 fx_gaps 揭露)— is stale for this lane.trade_recap.py:2405already records that after #612di["fx_gaps"]no longer appears on the review lane, because a missing rate became a refusal rather than a disclosure. The refusal does not cover cash-only currencies, so this path now has neither.The design premise the module rests on — "單一幣別組合(含純台股)聚合天然自洽 → 不抓匯率、零行為變化" — is sound, but the predicate that tests it counts only stocks. A book with USD stocks and a TWD interest row is not a single-currency book; it only looks like one.
Reproduced end-to-end through the CLI
Synthetic data only; no real trades or balances.
main@3e174d9, isolated--root,TR_LEDGER=/dev/null.prepareexits 0 and persists thisengine_card:cash.balancecash.weightacct_perf.acct_twracct_perf.irr_annualacct_perf.cash_dragacct_perf.hold_twrEvery honesty surface affirmatively denies the problem:
This is a strictly worse posture than the pre-#612 stock case, which at least degraded into
data_integrity.fx_gaps. Here the card iscash_source: anchored,unanchored: [],fx_approx: false— maximum stated confidence on a number that is wrong by 28x.The product asks for the input that triggers it
Before the anchor is supplied,
input.cash_anchor.next_actionreads:with
unanchored_currencies: ["TWD", "USD"]. The engine knows two currencies are in play, names them to the user, solicits both balances, and then adds them at 1:1 — and supplying them is what unlocksacct_twr/irr_annual/cash_drag. The unanchored case gates those three tonull; answering the question the product asks is what turns them on.Is the shape real?
Nothing constrains cash-flow currency to a held stock's currency.
references/data-contract.mddocumentsCurrencyonly for trade rows ("Always setCurrency(andMarket) for a non-US trade row"), andload_cash_flowsdefaults a blank toUSDindependently. Plausible real books with this shape: a Taiwanese sub-brokerage (複委託) account trading only US stocks while custody/wire fees and interest post in TWD; an IBKR account trading one market with interest accruing in the base currency; any account with an FX-conversion fee logged in the home currency. It needs one such row in the whole file.Not affected
consider— builds cash flows viaload_cash_flows([], trade_rows=rows)(consequence.py:489), so its cash currencies are ⊆ held currencies and are already covered by [bug·data] consider's legacy CSV lane silently mixes currencies (missing FX defaults to 1.0) — a live-fetch path already exists but isn't wired to it #600/fix(consider): the concentration numbers state what they could not read, and refuse a book they cannot add up (closes #598) (closes #600) (refs #599) #602's refusal. Anchors in a currency with no matching flow are dropped by_anchor_for, not converted.account_perfhas one caller (trade_recap.py:2795);snapshot_adapterkeeps its ownfx_gapspath.Candidate remedies — not decided
mixed_ccy(the fetch trigger) read the same widened set so a rate is requested before it can be missing. Most consistent with [defect·P1] prepare aggregates a missing FX rate as 1.0 — #602's refusal never reached the review lane #612 and [bug·data] consider's legacy CSV lane silently mixes currencies (missing FX defaults to 1.0) — a live-fetch path already exists but isn't wired to it #600; cost is that one stray foreign-currency fee row can block a whole review that is otherwise fine.fetch_fx/fetch_fx_series, so the common case simply works and the refusal fires only when acquisition genuinely fails. Strictly more useful, slightly more retrieval surface — bounded against [architecture·implementation] One market-data resolver — minimum Yahoo calls, one fact bundle for prepare and consider #605, which owns the retrieval layer.Option 2 subsumes 1 and looks like the natural reading of #612's intent, but choosing is an owner call: it trades a blocked review against a wrong one, and it touches the retrieval boundary #605 owns.
Current evidence
main@3e174d9with the CSV and command above; synthetic data throughout.trade_recap.py:233(load_cash_flowscurrency),:294(cash_position1.0),:491(currency_mapdomain),:546(held_currency_fx_gaps),:2632(mixed_ccy),:2767/:2796(bothfx=Nonecall sites), andperf.py:184-211(_fx_getter).perf.py:205's "上游 fx_gaps 揭露" comment contradicted bytrade_recap.py:2405-2406.Dependencies
cur_map.usd_view), and a second aggregation path that does not route through that primitive still shipped wrong. Commented there.