Skip to content

[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

Description

@atomchung

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:

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:

mixed_ccy = len(currencies) > 1                                        # trade_recap.py:2632
fx, fx_err = fetch_fx(fx_currencies, bundle=bundle) if mixed_ccy else ({"USD": 1.0}, None)

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:

  1. trade_recap.cash_positionf = fx.get(c, 1.0) (trade_recap.py:294), reached with fx=None because mixed_ccy is False (trade_recap.py:2767).
  2. 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 #612 di["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.

TradeDate,Action,Symbol,Quantity,Price,Amount,Currency,RecordType
2025-01-06,BUY,AAPL,100,200.00,-20000.00,USD,Trade
2025-01-06,BUY,MSFT,50,380.00,-19000.00,USD,Trade
2025-03-10,SELL,AAPL,40,240.00,9600.00,USD,Trade
2025-04-01,INTEREST,,,,1000000.00,TWD,Interest
python3 engine/review.py prepare book.csv --language en --root "$ROOT" \
  --cash '[{"currency":"TWD","amount":1000000,"as_of":"2025-04-02"},
           {"currency":"USD","amount":5000,"as_of":"2025-04-02"}]'

prepare exits 0 and persists this engine_card:

Field Reported True (TWDUSD=X close 2026-07-31 = 0.030849)
cash.balance $1,005,000 $35,849 — 28.0x
cash.weight 95.9% 45.7%
acct_perf.acct_twr 2945.23%
acct_perf.irr_annual 724.67%
acct_perf.cash_drag 2903.77%
acct_perf.hold_twr 41.47% 41.47% (unaffected)

Every honesty surface affirmatively denies the problem:

"currency_meta": {"mixed": false, "currencies": ["USD"], "fx": null}
"price_provenance": {"fx": "not_needed"}
"acct_perf": {"basis": {"cash_source": "anchored", "fx_approx": false, "unanchored": []}}
"valuation_frame": {"coverage": {"missing_fx": []}}
"honesty_ledger": [{"key": "acct_perf_basis", "status": "external_flows_absent", ...}]

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 unlocks acct_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.

Not affected

Candidate remedies — not decided

  1. Refuse, matching [defect·P1] prepare aggregates a missing FX rate as 1.0 — #602's refusal never reached the review lane #612. Widen the fail-closed predicate's domain from held currencies to every currency that enters the USD aggregate, cash flows included, and make 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.
  2. Fetch, then refuse only on real absence. Same widened set for 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.
  3. Disclose and degrade. Gate the account-level trio and report cash per-currency without a USD total. Cheapest, but [defect·P1] prepare aggregates a missing FX rate as 1.0 — #602's refusal never reached the review lane #612 explicitly rejected "continuing with stronger disclosure beside the wrong aggregate" for the stock case, and the same argument applies to a headline cash weight.
  4. 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.
  • Mechanism read at trade_recap.py:233 (load_cash_flows currency), :294 (cash_position 1.0), :491 (currency_map domain), :546 (held_currency_fx_gaps), :2632 (mixed_ccy), :2767/:2796 (both fx=None call sites), and perf.py:184-211 (_fx_getter).
  • perf.py:205's "上游 fx_gaps 揭露" comment contradicted by trade_recap.py:2405-2406.

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions