From 83d6cce63d62ceaf1f6ab310c3dc2b9412f219a9 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 1 Aug 2026 00:48:47 +0800 Subject: [PATCH] fix(fx): every currency entering account aggregation is fetched, and a missing rate fails closed (closes #649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cur_map` is filled by `load()`, which keeps BUY/SELL rows only, while `load_cash_flows` reads a separate per-row `Currency`. #612's refusal and the `mixed_ccy` fetch decision both read `cur_map`, so a currency present only in a cash-flow row was invisible to both: on a USD stock book with one TWD interest row no rate was ever requested, and `cash_position` summed raw TWD into a USD total at 1.0 — a balance 28x its real size, reported under `currency_meta.mixed: false`, `fx: "not_needed"`, `basis.fx_approx: false`, `unanchored: []` and `cash_source: "anchored"`. Remedy 2 from the maintainer disposition: fetch every currency that can enter the aggregate, then fail closed only when acquisition genuinely fails. - `aggregate_currencies` is the one domain builder (held positions plus cash-flow rows) and `missing_aggregate_fx_rates` the one predicate over whatever domain a lane built. `held_currency_fx_gaps` becomes their composition over the holdings domain instead of a second walk of `cur_map`, so `usd_view` and `consequence` are unchanged. - `main()` composes the universe before `market_request`, so the same set drives acquisition and the gate, and refuses upstream of the card, the state write and the ledger ingest when a required rate did not arrive. - The identity fallback is removed wherever currencies differ rather than only guarded upstream: `cash_position` refuses two buckets it has no rate for, and `account_perf` gates `missing_fx` instead of letting `_fx_getter` return `[1.0] * len(px_index)`. That getter's domain drops the unconditional union with USD, which had made a pure TWD book indistinguishable from a mixed one missing a rate. - Accepted cash anchors need no term of their own: `cash_position` buckets by cash-flow currency, so an anchor in a currency no flow created is dropped rather than converted, and refusing over it would block a book on an amount nothing reads. - `MissingHeldCurrencyRate` is now `MissingAggregateCurrencyRate`; its sentence names the aggregate's span rather than claiming every currency in it is held. Single-currency books are byte-stable: a pure USD book, a pure TWD book and #612's own mixed-holdings shape each produce a plan identical to `origin/main`'s, compared field by field. Co-Authored-By: Claude Fable 5 --- docs/maintainer-guide.md | 2 +- docs/prd-ledger.md | 2 +- skills/fomo-kernel/engine/perf.py | 62 +++++-- skills/fomo-kernel/engine/trade_recap.py | 152 +++++++++++++----- skills/fomo-kernel/references/price-feed.md | 2 +- .../schemas/price-feed.schema.json | 2 +- tests/test_engine_units.py | 105 +++++++++++- tests/test_price_paths.py | 72 ++++++++- tests/test_review_v2.py | 132 +++++++++++++++ 9 files changed, 468 insertions(+), 63 deletions(-) diff --git a/docs/maintainer-guide.md b/docs/maintainer-guide.md index 2af602bf..144c3e59 100644 --- a/docs/maintainer-guide.md +++ b/docs/maintainer-guide.md @@ -145,7 +145,7 @@ When multiple sessions are active: | Split adjustment (#550) | `engine/splits.py` is the only implementation of what a split does to a recorded quantity — `trade_recap.adjust_for_splits` is now a delegate, and `revisit.detect_exits` reads the same rule rather than walking raw quantities — in **both** of its lanes: the trade walk, and the `absence_exits` rows it appends for a confirmed disappearance (#485 Slice C). That second lane shipped without the map, so one returned list carried two bases and the book reported a departure ten times the exit row beside it — #550's own defect one level down, and the reason `absence_exits` takes `splits` at all. Retrieval moved to `engine/market_data.py` (#605) and is now a projection: `trade_recap.fetch_splits` selects this book's tickers out of one resolved bundle whose split **observations**, closes and FX all came from a single provider response, so a review can no longer state a price, a split and a rate observed at three different instants. `splits.py` is still stdlib and offline and never fetches. One property of that boundary is load-bearing here: a resolved map is complete only from its request's window start, where `Ticker(t).splits` was unbounded — safe because every factor applies splits strictly *after* a real book date, enforced by `market_data.build_request` refusing a window that cannot cover its caller's `rebase_origin`, and by `trade_recap.market_request` deriving that origin from the **union** of the CSV's first trade and the ledger anchor (`_ledger_rebase_origin`) rather than from the CSV alone — the map a review freezes is read back by `consider` and `refresh` against an anchor the CSV never mentioned. Gated by `tests/test_split_basis.py`'s three window tests and `tests/test_market_data.py`. The map is derived once per review and **stamped** into `state["splits"]` (JSON-safe, key locked by `tests/test_tr_json_contract.py`), which `review._prepare_exit_capture` passes to `revisit.enqueue_from_ledger` — the collapse-to-one-reader move from development-guide §7, so the analytics path and the exit path cannot disagree about what a split did. Two invariants. **The ledger keeps as-transacted quantities**: normalization happens at read time, never by rewriting `ledger.jsonl`. **The exit basis is the exit's own date, not today's**: `factor_between` scales the *running* position as each split arrives, so `shares_sold` — and therefore `revisit_id` — is the count that actually executed and a later split cannot re-base an exit already in the queue. Owner-ratified 2026-07-29 against the alternative of restating old exits in today's basis: that count is the one on the user's own broker statement for that sale, and re-basing it would move `revisit_id` for exits already answered, so the user would be asked a second time why they sold something they have already explained. Every reader of the recorded book must be handed the map — gated mechanically by `tests/test_split_basis.py`'s `test_every_reader_of_the_book_is_told_what_a_split_did_to_it`, because a caller that omits `splits` gets raw quantities and raises nothing; its `_NO_MAP_NEEDED` exemptions are themselves checked for still matching a live call site. **A reader of the book is anything that returns one**, which is why that net watches `portfolio_basis.query_current_book`/`query_current_book_from_ledger` alongside the two `ledger` functions: scoped to the latter, it read a split-blind `query_current_book` call as compliant — the wrapper forwards its own map faithfully — and the caller one level up went unexamined for a release. That caller was `review._virtual_valuation_frame`, which narrows the price frame from a *provisional* book and must be given the same map as the canonical query that then validates the frame against holdings; split-blind on one read and split-aware on the other, a position whose raw quantities reach zero is dropped from the frame without even appearing as `missing_price`, and `prepare` refuses the very book `derive_holdings`, `refresh` and `consider` read correctly. `factor_after` is the other basis (rebase onto today) and belongs to `rebase_rows` alone. Split events are fail-closed on parse: a ratio is a multiplier on a share count, and a dropped bad one is a confident wrong number. Readers: `references/price-feed.md`'s `splits` bullet ↔ `schemas/price-feed.schema.json` ↔ `tests/test_revisit.py` section A2 + `tests/test_engine_units.py` + `tests/test_review_v2.py`. `ledger.derive_holdings` closed the same gap for the book itself (#558): it takes the map as a parameter and applies it at **three** points — the anchor's declared shares, each trade as it arrives, and once more at the tail, which is the only one that fires in the most common shape of all (a split with no trading since). `holdings_as_of` bounds that tail with its own `as_of`, so a diff computed for an earlier day is not silently stated in today's basis. Everything downstream threads it: `reconcile`, `snapshot_reconciliation`, `build_derived_book` (which **writes the number down**, so an unadjusted count there stops being a read-time error and becomes a durable one), `book_refresh`, `portfolio_basis.query_current_book`. **Cost is never scaled** by any of the three — a split is a zero-dollar event, and preserving cost while shares move is what makes the derived `avg_cost` correct without a second adjustment. `revisit.rebased_exit_price` is the #559 half: a read-time rebase of a stored exit price onto today's quote basis, never a stored one, because `revisit_id` derives from what executed and must not churn on the next split. Retrieval for the lanes with no review of their own is `review._recorded_splits`, which reads the map the last review froze rather than fetching — `refresh` runs as two CLI calls and content-addresses what the user was shown, so a network blip between them must not invalidate a `refresh_id` mid-answer. `consider` resolves its map once, in `_consider_rows`, for **both** of its routes: precedence is **per ticker, never per call** (#583 post-merge finding): the frozen map is the floor, a ticker with supplied `price_feed.splits_map` events overrides its own entry, and an unrelated ticker's recorded split survives — whole-map replacement read a legitimate omission (a close already past its split needs no `splits` row) as "never split", mis-valued that book by the split factor, and disarmed `basis_conflicts` for exactly that ticker; it still never fetches. Its CSV route was the last reader with no adjustment at all — `trade_recap.load()` rows go straight into `consequence.portfolio_state`, so a split-crossing position was simply absent from the book a pre-trade challenge reasoned about, which is #558's own defect on the one lane a CSV-only user reaches. That route applies `trade_recap.adjust_for_splits` (rebase onto today, `factor_after`) rather than the running-position carry, because `last_px` is a current quote and the rows must be denominated against it — the same call `prepare` makes right after resolving its bundle, and the reason the two bases coexist by design rather than by accident. Gate: `tests/test_split_basis.py`, one test per application point plus the counterweight that a real full exit still reads as one — **and a route-level half, because the call-graph half was escaped three times in one day for three different reasons** (#572: a caller one level above a watched callee; #577: a route that calls no watched callee at all; #576: a supplier returning nothing, which left four suites green). `test_every_route_that_reads_the_book_is_classified` derives the route list from `review.py`'s own call graph rather than a hand list — `consider`, `prepare`, `refresh`, and nothing else — so a new subcommand that reads the book arrives as a failure instead of as an untested route, and the four route tests beside it drive one split-crossing book through each and assert that route's own honest observable: the share count for `consider` (**both** of its book-building routes), `reconciled` with nothing to confirm for `refresh`, and that the review starts at all for `prepare`. That last one is the compounding failure worth knowing — split-blind, `plan_refresh` invents a share change, #530's catch-up gate then refuses the review outright, and the user is sent to settle a purchase they never made. Everything in this row is about the **quantity**; the price it gets multiplied by has a basis of its own, and that is the row below. | | Split basis of a price observation (#583) | The other operand. Every row above audits quantities, and #558's closing sweep said so explicitly — "no third unadjusted accumulator remains" was true, and true of one dimension only. `engine/splits.py` owns this side too: `rebase_price` (divide a price by the splits after its observation — the mirror of `rebase_rows` multiplying a share count by them), `rebase_series` (the same, per entry, for a daily series), `basis_date` (**the only** derivation of what basis a rebased value is stated in), and `last_applied` (when a window last moved a basis, the companion to the factors' *how much*). `revisit.rebased_exit_price` and `_applied`'s single walk mean every factor and every basis statement read one implementation. Readers, in the order the number travels. **`price_feed.parse` normalizes on the way in**: a supplied `close`/`history` is declared a raw observation as of its own date (`schemas/price-feed.schema.json` ↔ `references/price-feed.md` "Prices are raw observations"), and each is rebased from that date onto the envelope's declared-split basis, keeping `observed_close`/`observed_date`/`basis_date`/`split_factor` beside it so the alignment replays. Before this, a structurally valid envelope paired a pre-split close with a post-split share count: a tenfold market value, a tenfold weight, and an inverted answer to *which position is the largest* — `consider` naming the split-crossing holding as the book's biggest at 51% when it was nine per cent of it. Because the split is declared on the price row, `parse` fixes every downstream consumer at once, including `to_frame`'s series before beta, alpha, the P&L curve and account TWR difference it. **`price_feed.basis_conflicts` is the fail-closed half**, called by `review.cmd_consider` before any book is read: `prepare` with `--prices` performs no split retrieval of its own so the two sides are the same events by construction, but `consider` falls back to `review._recorded_splits` when the envelope declares none — the shares then cross a split the prices were never told about. It compares the divisor each observation received against the one its share count did, past that same date; unequal is a refusal naming the ticker, the observation date and the split, because applying the external map would assert a corporate action the price source never confirmed (#566) and ignoring it is the tenfold error. `review._effective_splits` is the single reader of the precedence rule — per-ticker merge, recorded floor, supplied override, per the #550 row — so the map the check validates is the map the arithmetic used, and a recorded event cannot vanish because an unrelated ticker supplied one (gated by `tests/test_split_basis.py`'s `test_a_recorded_split_survives_an_unrelated_tickers_supplied_one`). **`trade_recap.price_observations` is the single producer** of per-ticker evidence, feeding both `state["price_snapshot"].observations` and `build_valuation_frame(price_observations=...)`; `last_prices` is now its projection, so the staleness gate and the basis statement are one read. One frame-level `as_of` could not say which session a given ticker's number came from, so one fresh instrument made every stale one look same-day (#583 §2). The frame's per-ticker `observed_at`/`basis_date` are **additive** — a stored frame from before them still validates untouched and is never rewritten — but never partial within one frame, and never a basis older than its own observation. **`ledger.derive_holdings` states the book's side** as `quantity_basis`, recorded at each rebase by `_moved_basis` rather than re-derived, and `portfolio_basis.query_current_book` folds it into `effective_date`: a split is the day a share count changed, exactly as a trade is, and without it a valuation dated between the last trade and the split passed the `reference_as_of` gate while the holdings had already moved (§3). Advancing that date rather than adding a parallel field is what keeps a book no split touched **byte-identical** — `quantity_basis` is `None` there, so `state_version` does not move and there is nothing to migrate or roll back. `_refuse_unaligned_price_basis` is the gate the two sides exist for, **per ticker, never per book**: the newest split in a book belongs to some instrument, and holding every other price to it would refuse an ordinary book for an action that never touched them. **`revisit.compare`/`scan_backlog` take `price_basis`** and `review._prepare_exit_capture` supplies it from the frozen `observations` — it previously dropped `price_snapshot.as_of` entirely, so the comparison rebased across every split after the exit and merely assumed the quote postdated them (§4). A missing entry degrades to that same unbounded rebase, which is #559's answer and stays correct; `rebased_exit_price` is still read-time only, so `revisit_id` never churns. Gate: `tests/test_split_basis.py`'s price-basis sections (both `consider` routes on one crossing book, the refusal *and* the repair it names, the return consumer through `pnl_curve`, the per-ticker evidence from the real `TR_STATE_OUT` artifact, the effective date and its byte-identical counterweight) + `tests/test_price_feed.py` section 1b + `tests/test_tr_json_contract.py`'s `price_snapshot` key set and coverage relation. One coercion rule earns its own mention because a green suite hid it for a release: **every factor coerces its own window edges**. `build_derived_book` hands `derive_holdings` an ISO string where `holdings_as_of` hands it a date, the tail rebase compared the two directly, and recording the derived book for any split-crossing history raised `TypeError` — `prepare` died with a traceback rather than the refusal this module promises. It was invisible offline because the only split-crossing `prepare` test used a snapshot root that never reaches that writer; `test_a_review_records_its_derived_book_on_a_split_crossing_history` drives the CSV lane that does. | | Which basis the transaction file itself is on (#582) | The premise under both rows above, unstated anywhere until now: **a transaction file records what executed, un-rebased**. `rebase_rows` is correct only on a file of that kind, and a broker export that has already restated its own history gets the split applied twice — ten times the real share count on a ten-for-one, cost preserved, so `avg_cost` falls tenfold and weight, concentration, the sizing cap and every rule measured against them read on a book that never existed. It survived every gate above because it **inflates**: #550's family erased positions, and a book reaching zero is a visible anomaly, while a position ten times too large reads as a large position rather than a broken one. `splits.basis_disagreements` is the single implementation of the check, and it lives beside the arithmetic it audits because it needs nothing this module does not already own; `splits.EXECUTION_BAND` is its only constant — how far a fill may sit from its own session's close — and the separation requirement is *derived* from it (`band ** 2`), so a three-for-two is not examined rather than guessed at. Its readers, and the whole path: `trade_recap.close_series` supplies `{ticker: [(date, close), ...]}` from **`bundle.frame`, not the narrowed frame `fetch_prices` returns** — that window starts at the earliest round-trip entry, and a position bought long ago and never sold has no round trip, so its own trades would fall outside the very window the check needs — ↔ `trade_recap` main, which calls it immediately after `adjust_for_splits` and records a non-empty result as `data_integrity["split_basis"]` ↔ `review._refuse_an_unsettled_transaction_basis`, the only reader, called from `_prepare_session` **before `save_pending`, before any ledger write, and before a card is built** ↔ `references/data-contract.md` "Transaction basis" ↔ `tests/test_split_basis.py`'s #582 section. Three design facts no surface may weaken. **It refuses rather than discloses**, unlike #330's `price_feed.plausibility_flags` one row over: that check flags one suspicious *supplied close* and the card still prices normally, while this one says the share basis of the whole file may be wrong, and every number on the card is measured against it — continuing while merely disclosing *is* choosing the as-executed reading, silently. That is also why it is deliberately **not** in `build_honesty_ledger`: no card carrying it is ever rendered, so a ledger key there would be a dead field (#429). **It never adjudicates and never re-bases** — an adjusted export, a currency in the wrong unit and a strange fill all print the same disagreement, and picking one from the numbers is what #416 forbids; the refusal states the contradiction, names the rows it measured, and hands the question to the user, which is the confirmation posture #530/#531 established for an observed difference (`book_refresh`'s own `pending_confirmations` lane cannot be used here: it needs a declared holdings view, and a CSV-only user — the entire exposed population — has none. `book_refresh.plan_refresh` does catch this by accident for a user who supplies one, because derived comes out exactly `factor ×` declared and that raises `large_change` — but only while the inflated position clears both `REFRESH_MAJOR_DELTA` and `REFRESH_CORE_WEIGHT`; measured, the same defect on a small corner of the book returns `status: ready` with nothing pending and is adopted silently, so this complements that defence rather than duplicating it, and fires at ingest at any position size). **It cannot see the case #330 sees and #330 cannot see this one**: #330 anchors a supplied close against the ticker's *most recent* trade, which for almost every real book is after the split, where both readings agree — the comparison has to reach a **pre-split** row, which is why every row it examines has a non-unit `factor_after`. Gate: `tests/test_split_basis.py`'s pair of CLI runs over one synthetic already-adjusted file and the identical history as-executed, plus the no-split counterweight on a ticker in the same file whose fill sits a factor of ten under its own close and is never examined, plus `test_the_check_reads_the_real_close_series_and_not_merely_its_absence` — the supply is driven on a real resolved frame, because a gate that only proves a parameter was passed is the failure shape #576 shipped. `consider` is deliberately not covered: its CSV route fetches current prices only, so checking there would need the extra network segment #582 rules out. | -| Whether a held currency may enter a cross-currency aggregate (#612) | `trade_recap.held_currency_fx_gaps` is the single predicate, and `trade_recap.usd_view` — the one place amounts are converted before every aggregate reader (weights, concentration, rankings, `what_if`, the diagnosis order, the persisted reconciliation metrics) — is where it refuses. Putting it in the primitive rather than at each consumer is what makes a lane added later inherit it: #602 had already made `consequence.portfolio_state` refuse the same book, and the review lane never inherited that, which is the whole defect. `consequence` now reads the predicate instead of restating it, and the identity factor it replaced was not an approximation but a different number — one ~840,000 TWD position read as ~97% of a book that is really ~47% of it. Two properties are load-bearing and each has its own compatibility test: a **single-currency** book, including a pure non-USD one, aggregates itself self-consistently and is never a gap; and the predicate's domain is `cur_map`, which holds only *held* currencies, so the display currency `fx_request_currencies` widens the request with can never trigger it — a missing display rate stays a rendering degradation. The refusal reaches the CLI as one controlled error raised upstream of the card, the state write and the ledger ingest, quoting #611's machine-readable `fx_unavailable` gap code rather than parsing provider prose. Its readers: `references/price-feed.md`'s `fx` field rule (the repair the message names) ↔ `tests/test_engine_units.py`'s refusal and compatibility pair ↔ `tests/test_review_v2.py`'s four-state CLI walk ↔ `tests/test_consider.py` section on the pre-trade lane. The harness half: a mixed-currency persona has no card at all without a rate, so `tests/persona_sweep.py`'s `persona_prices` supplies the committed `mock/sample_.prices.json` beside it — the product's own repair path, not a harness-only bypass — and `trade_recap`'s `data_integrity["fx_gaps"]` producer is gone because after the refusal its only possible value was empty (#429); the key still lives, written by `snapshot_adapter`, whose lane suppresses weights rather than refusing. | +| Which currencies may enter a cross-currency aggregate (#612, #649) | `trade_recap.aggregate_currencies` is the single **domain** builder and `trade_recap.missing_aggregate_fx_rates` the single **predicate** over whatever domain a lane built; `held_currency_fx_gaps` is now the composition of the two over the holdings domain rather than a second walk of `cur_map`. `trade_recap.usd_view` — the one place *holdings* are converted before every aggregate reader (weights, concentration, rankings, `what_if`, the diagnosis order, the persisted reconciliation metrics) — still refuses there for the lanes that call it directly. Putting it in the primitive rather than at each consumer is what makes a lane added later inherit it: #602 had already made `consequence.portfolio_state` refuse the same book, and the review lane never inherited that, which is the whole defect. `consequence` now reads the predicate instead of restating it, and the identity factor it replaced was not an approximation but a different number — one ~840,000 TWD position read as ~97% of a book that is really ~47% of it. Two properties are load-bearing and each has its own compatibility test: a **single-currency** book, including a pure non-USD one, aggregates itself self-consistently and is never a gap; and no domain the builder produces contains a currency the aggregate does not convert, so the display currency `fx_request_currencies` widens the *request* with can never trigger it — a missing display rate stays a rendering degradation. #649 is why the domain became a named builder. `cur_map` is filled by `load()`, which keeps BUY/SELL rows only, while `load_cash_flows` reads a separate per-row `Currency`; a currency present only there was invisible to this predicate **and** to the `mixed_ccy` fetch decision that reads the same set, so on a USD stock book with one TWD interest row no rate was ever requested and `cash_position` summed raw TWD into a USD total at 1.0 — a balance 28x its real size, under `currency_meta.mixed: false`, `fx: not_needed`, `fx_approx: false`, `unanchored: []`. **One set drives acquisition and the gate**: `main()` composes the universe from `cur_map` plus `load_cash_flows(paths)` before `market_request`, and a currency may never be invisible to the request and visible to `cash_position`/`account_perf` later. Accepted cash anchors need no term of their own — `cash_position` buckets by cash-flow currency and `_anchor_for` pairs into those buckets, so an anchor in a currency no flow created is dropped rather than converted, and refusing over it would block a book on an amount nothing reads (`test_cash_position_single_bucket_and_flowless_anchor_keep_the_identity`). The universe is built without `load_cash_flows`'s `trade_rows` estimate because that estimate needs split-adjusted amounts the request has to precede; it cannot narrow the answer, because those rows take their currency from a trade row, which is already a `cur_map` value — asserted, not reasoned, in `test_aggregate_currencies_spans_cash_flow_rows_not_only_holdings`. **The identity factor is removed wherever currencies differ, not merely guarded upstream**: `cash_position` refuses two buckets it has no rate for, and `perf.account_perf` gates `missing_fx` rather than letting `_fx_getter` return `[1.0] * len(px_index)` — that second rule is a deliberate second implementation of the same predicate, because `perf.py` is stdlib-only by contract and cannot import `trade_recap`; its domain is the currencies that actually carry value (the cash buckets unioned with `cur_map`'s values), **without** the unconditional union with `USD` the old code added, which made a pure TWD book indistinguishable from a mixed one missing a rate. The refusal reaches the CLI as one controlled error raised upstream of the card, the state write and the ledger ingest, quoting #611's machine-readable `fx_unavailable` gap code rather than parsing provider prose. Its readers: `references/price-feed.md`'s `fx` field rule (the repair the message names) ↔ `docs/prd-ledger.md`'s multi-currency policy ↔ `tests/test_engine_units.py`'s refusal and compatibility pairs ↔ `tests/test_price_paths.py`'s account-pillar pair ↔ `tests/test_review_v2.py`'s four-state CLI walk plus #649's own refuse/convert pair and its single-currency-with-cash-rows regression ↔ `tests/test_consider.py` section on the pre-trade lane, which is unaffected because `consequence` derives its cash flows from the trade rows themselves, so its cash currencies are a subset of the held ones by construction. The harness half: a mixed-currency persona has no card at all without a rate, so `tests/persona_sweep.py`'s `persona_prices` supplies the committed `mock/sample_.prices.json` beside it — the product's own repair path, not a harness-only bypass — and `trade_recap`'s `data_integrity["fx_gaps"]` producer is gone because after the refusal its only possible value was empty (#429); the key still lives, written by `snapshot_adapter`, whose lane suppresses weights rather than refusing. | | What a recorded book is, and which one re-bases the replay (#549) | `ledger.latest_anchor(events, *, declared_only=False)` is the single reader of both questions, one argument apart, so they cannot drift about ordering or validity. **Default = "has this root recorded a book at all"**: every `type: "snapshot"` row qualifies, whatever its `source` and whoever wrote it — nothing has to qualify, because whatever the user handed over *is* what the system records. Its callers are the ones asking that question: `book_refresh.plan_refresh`'s gate, `ledger.snapshot_reconciliation`'s existence check and `against` stamp, and `session._assert_initial_snapshot_boundary`'s idempotent-replay check. **`declared_only=True` = "which row re-bases the replay"**, and exactly four readers call it — `ledger.derive_holdings`, `portfolio_basis.query_current_book`, `review._rows_from_ledger`, and `revisit.detect_exits` — because a `DERIVED_BOOK_SOURCE` row is `derive_holdings`' own output written down: replaying the trades it summarizes reproduces it exactly, while re-basing on it would discard the cycle start, cycle sequence and add count a summary row cannot carry. Two more readers sit one level down and follow the same rule for their own reason: `book_refresh.carry_recorded_starts`' `recorded` map (a restatement never stamps a `since_basis`, so reading the newest row would spend the user's #531 answer the first time they imported a CSV; the map is assembled inside that primitive rather than by either lane, because a single reader is still two readers when its input is composed twice) and both ingest lanes' overlay gate in `review.py` (a restatement is the derivation being compared, so routing it there reconciles the book against itself). That round-trip equality is the load-bearing invariant and it is gated, not argued: `tests/test_ledger.py::test_a_recorded_derived_book_re_derives_to_exactly_the_same_book` re-derives six histories with the recorded row appended and demands field-for-field equality; its matching mutation (`derive_holdings` dropping `declared_only`) turns it red. Two rules about the row itself. **The writer is single**: `ledger.build_derived_book` (self-validating against `SNAPSHOT_POSITION_KEYS`, the `build_position_absence` precedent) ↔ `review._record_derived_book`, the one place either transaction lane records its result, appending through the shared `session.append_book_adoption` rather than a second writer of "this is the book now". **A derived book states positions and never a balance**: `perf.cash_reconcile_residuals` reads every cash-bearing snapshot row as a declaration, so a copied-forward balance would manufacture the exact shape it reports as an unexplained residual; `snapshot_reconciliation` reads its cash baseline from the last *declaration* instead. Its readers: `references/data-contract.md` ↔ `docs/prd-ledger.md` "Holding derivation" ↔ `flows/book-refresh.md`'s two routes ↔ `README.md`/`README.zh-TW.md` "What does a snapshot anchor?" ↔ `tests/test_book_refresh.py` (the trades-built root that can now be refreshed) ↔ `tests/test_consider.py` (the CSV route writes the row and `consider` still reads the replay). | | Completeness is gone, everywhere (#549, finishing #485) | There is no `is_complete`: not on the envelope (`snapshot_adapter.ENVELOPE_KEYS`), not on a ledger row's identity (`session._snapshot_payload`), not in `latest_anchor`'s selection, not in `portfolio_basis` (`_normalized_anchor`, the `current_book.anchor` key set, `schemas/portfolio-basis.schema.json`, and `_COMPLETENESS`, whose `declared_partial` value is now unreachable and survives only in `schemas/trade-evaluation.schema.json` for replay compatibility), not in `session`'s projection gates, and not on the card (`card_renderer._snapshot_strength_line` has two branches, and `copy/*.json` `snapshot.strength` has `weighted`/`baseline`). `tests/test_book_refresh.py::test_no_engine_module_reads_is_complete_any_more` parses every non-test engine module for the exact string constant and fails on a reader coming back — prose that merely names the removed flag is not a reader, which is why it parses rather than greps. The reason it may not come back: a user who said their view covered one brokerage of several got a helpful `false`, and their book then silently stopped updating — #549's own defect in a second place. Nothing replaces it on the input side; the anomaly worth raising between two updates is `book_refresh`'s confirmation lane (#530, #531), which asks about a *specific observed difference* rather than about an account nobody can see. Taking the flag out of the engine was only half of it: both guaranteed-delivery entry points and three routed surfaces went on teaching the retired rule for a release, and `flows/snapshot-review.md` went furthest — it told the agent to *ask for the complete account view*, the one question this ruling forbids, behind an engine rejection that no longer existed. `tests/test_doc_language.py`'s `RECORDED_BOOK_SECTIONS` pins the replacement rule in `AGENTS.md` and `SKILL.md`, and `RECORDED_BOOK_RETIRED_PHRASES` rejects the retired phrasing across every file `agent_runtime_files()` reaches; scoping that rejection to the two entry points is precisely how the routed surfaces kept the old semantics while the entry points read correctly. One consequence to know: `thesis.build_snapshot_cycle_relinks` lost its `snapshot_complete is False` condition with the flag, and since every declaration now anchors, no shipped flow reaches it — its remaining coverage is unit-level. | | What book a current-view figure was measured over (#630) | `trade_recap.book_scope` is the single derivation of a current-book `coverage.scope`, and it takes two halves: every holding valued, **and** those holdings known to be the recorded book. Its readers: `trade_recap.current_book_projection` (which mints `book_basis` and forwards it from `dim_size`) ↔ `snapshot_adapter.prepare` (a declared holdings view *is* the recorded book, per #549, so it states `BOOK_BASIS_RECORDED` directly) ↔ `review._stamp_recorded_book_basis` (the single decider for the transaction lanes, called only after `_record_derived_book` wrote the book this import produced) ↔ `tests/test_engine_units.py`'s `test_current_book_projection_vocabulary_locked_to_portfolio_basis`, which locks the scope vocabulary to `portfolio_basis._COVERAGE_SCOPES` and therefore to `schemas/trade-evaluation.schema.json`'s stored enum. Three rules. **A projection may not assert what it cannot check**: it receives holdings and no book to compare them with, so the whole-book claim is the caller's to make and the default is the value that assumes nothing — deciding `scope` from valuation coverage alone is exactly how a weekly review declared `full_current_book` with `total_holdings: 1` over a six-position account, telling every reader that checked the field the number was trustworthy. **A bounded book and a bounded valuation are one word, not two**: both are `bounded_valued_subset` with `book_basis` naming which bounded it, because `portfolio_basis` always measures the recorded book and a fourth value only this shell could produce would sit permanently unreachable in that stored enum. **The reconciliation that withdrew the figures may not also relabel them**: a mismatching `_overlay_ledger_holdings` result has already stripped every current-view surface through `_gate_current_view`, so the stamp refuses rather than skipping silently. | diff --git a/docs/prd-ledger.md b/docs/prd-ledger.md index 07fd2b65..2b9918a1 100644 --- a/docs/prd-ledger.md +++ b/docs/prd-ledger.md @@ -114,7 +114,7 @@ Every incoming source records the book at its own time (#549): a holdings view r - Store every event in original currency with explicit `market` and `currency`. - Normalize Taiwan tickers to the data-provider convention when fetching prices. - Convert only for aggregate presentation; preserve original-currency detail for brokerage reconciliation. -- Use cached rates offline and disclose the rate date. If no rate exists, show original currencies rather than guessing. +- Use cached rates offline and disclose the rate date. If no rate exists, the aggregate that would have needed it is refused rather than guessed: a factor of 1.0 is a different number, not a rougher one (#612, and #649 for a currency that reaches the account total through a cash-flow row alone). Per-currency detail stays in its own currency, as above. - Compare each market sub-portfolio with its own benchmark. Never synthesize a cross-market total alpha. - Keep behavioral concentration global because one user can hold the same driver across markets. diff --git a/skills/fomo-kernel/engine/perf.py b/skills/fomo-kernel/engine/perf.py index e48905c4..89a15c73 100644 --- a/skills/fomo-kernel/engine/perf.py +++ b/skills/fomo-kernel/engine/perf.py @@ -182,16 +182,41 @@ def cash_reconcile_residuals(snapshots, cash_flows, fx=None, abs_floor=50.0, pct # ───────────────────────── 帳戶級 V_t 序列 + 三數字 ───────────────────────── def _fx_getter(currencies, px_index, fx_series, fx_spot): - """回 (fx_at(ccy, i), fx_approx):每交易日的 usd_per_unit。優先 fx_series(每日,含匯損益, - ffill/bfill 補洞);缺 → 即期常數近似(fx_approx=True);連即期都缺 → 1.0。持股幣別 - (cur_map 值域)不會踩到最後這個分支:呼叫端 account_perf 的 fx_spot 與 - trade_recap.usd_view 拒答用的是同一份 fx,#612 後 usd_view 沒拒答就代表每個持股 - 幣別都已有匯率。唯一還可能落到 1.0 的是只出現在現金流(股息/利息/存提/手續費)、 - 沒有對應持股的幣別——不在 usd_view 檢查的 cur_map 定義域裡,cash_position 對這類 - 幣別本身也是同樣近似(#642)。這條近似不再對外揭露成一份 data_integrity.fx_gaps - 清單,該鍵在此線已不存在(#612/#429)。USD 恆 1。""" + """Return ``(fx_at(ccy, i), fx_approx, missing)`` — the usd_per_unit factor + for every trading day. ``fx_series`` wins (daily, so FX gain and loss is in + the chain); absent, the spot rate is held flat across the window and + ``fx_approx`` says so. USD is always 1. + + ``currencies`` is the set that actually *carries value* (``cur_map``'s + values unioned with the cash buckets). It deliberately excludes the + unconditional ``"USD"`` ``account_perf`` used to add, which made a pure TWD + book look like a mixed one. + + When even the spot rate is absent there are exactly two cases, and they need + opposite treatment (#649): + + * **A single-currency world** (``len(currencies) < 2``, a pure TWD book + included): the aggregate currency *is* that currency, so a factor of 1.0 + is an identity rather than an approximation, and nothing is missing. + * **Two or more currencies**: 1.0 adds one currency's raw units into + another's total. That is not a gap in the number, it is a different + number — one TWD interest row made a cash balance 28x its real size while + ``basis.fx_approx`` and ``unanchored`` both reported no problem. Such a + currency is returned in ``missing`` and ``account_perf`` withholds the + whole account pillar; ``cols`` is still filled with 1.0 only to keep the + shape uniform, and past that gate it has no reader. + + Since #612 this gap is no longer disclosed as ``data_integrity.fx_gaps`` + (the key does not exist on this lane — #612/#429), so approximating quietly + here means no honesty layer at all, which is why it has to be a gate rather + than a note. The criterion is the same one + ``trade_recap.missing_aggregate_fx_rates`` applies; this module is + stdlib-only by contract (see the module docstring), so it is a second + implementation of one rule, not a second rule.""" fx_approx = False cols = {} + missing = [] + single_currency = len({c for c in currencies if c}) < 2 for c in currencies: if c == "USD": continue @@ -207,12 +232,14 @@ def _fx_getter(currencies, px_index, fx_series, fx_spot): fx_approx = True # 全窗用今日即期 = 匯損益歸零的近似 ser = [float(rate)] * len(px_index) else: - ser = [1.0] * len(px_index) # 缺匯率:原幣近似(僅現金流獨有幣別會踩到,見上方 docstring #642) + if not single_currency: + missing.append(c) # #649: mixed and no rate -> gate, never 1.0 + ser = [1.0] * len(px_index) # the single-currency identity (see docstring) cols[c] = ser def fx_at(c, i): return 1.0 if c == "USD" else cols[c][i] - return fx_at, fx_approx + return fx_at, fx_approx, sorted(missing) def account_perf(rows, px, cash_flows, cash_data, cur_map, @@ -233,8 +260,19 @@ def account_perf(rows, px, cash_flows, cash_data, cur_map, cash_flows = cash_flows or [] by_ccy = (cash_data or {}).get("by_currency") or {} cash_ccys = sorted(set(by_ccy) | {cf.get("currency", "USD") for cf in cash_flows}) - all_ccys = sorted(set(cash_ccys) | {cur_map.get(r["ticker"], "USD") for r in rows} | {"USD"}) - fx_at, fx_approx = _fx_getter(all_ccys, px.index, fx_series, fx_spot) + # #649: the currencies that actually carry value. The old set unioned "USD" + # unconditionally, so a pure TWD book looked like two currencies with TWD + # permanently rateless — the identity factor and a missing rate were + # indistinguishable. USD does not need to be in the set: `fx_at` short + # circuits it to 1.0. + value_ccys = sorted(set(cash_ccys) | {cur_map.get(r["ticker"], "USD") for r in rows}) + fx_at, fx_approx, fx_missing = _fx_getter(value_ccys, px.index, fx_series, fx_spot) + if fx_missing: + # The whole account pillar (acct_twr / irr_annual / cash_drag) rests on + # V_t = holdings + cash, and the cash bucket would add one currency into + # another at 1.0. Withhold it rather than emit a wrong number with a + # note beside it (#649). + return {"gate": _reason("missing_fx", currencies=fx_missing)} def day_idx(d): """flow/trade 日 → 掛到 ≥d 的第一個交易日(BOD:週末入金下個開盤到位);窗尾外 → None。""" diff --git a/skills/fomo-kernel/engine/trade_recap.py b/skills/fomo-kernel/engine/trade_recap.py index cfb184e1..b56e6184 100644 --- a/skills/fomo-kernel/engine/trade_recap.py +++ b/skills/fomo-kernel/engine/trade_recap.py @@ -271,12 +271,24 @@ def cash_position(cash_flows, held_mv, anchor=None, prev_end=None, fx=None): - 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 明細(呈現層可展開、可只揭露缺錨點的幣別)。""" + - by_currency:{ccy: {balance(原幣), source, reliable}} per-currency 明細(呈現層可展開、可只揭露缺錨點的幣別)。 + + Raises :class:`MissingAggregateCurrencyRate` when two cash buckets would be + added together and one of them has no rate (#649). A factor of 1.0 is the + identity of a single-currency world, never a stand-in for a rate that did + not arrive: one TWD interest row summed into a USD total at face value put + a balance 28x its real size on the card, under ``source: "anchored"``. + Whether the *holdings* are in the same currency as these buckets is one + question up — :func:`aggregate_currencies` owns it, because only the caller + sees both sides.""" 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"] + gaps = missing_aggregate_fx_rates(currencies, fx) + if gaps: + raise MissingAggregateCurrencyRate(currencies, gaps) def _anchor_for(c): # 帶 currency 的錨點按幣別配對;無 currency 的錨點只在單一幣別時對應(向後相容舊單桶格式)。 @@ -509,25 +521,31 @@ def currency_map(rows): "feed_incomplete") -class MissingHeldCurrencyRate(ValueError): - """A held currency this aggregate would convert has no rate (#612). +class MissingAggregateCurrencyRate(ValueError): + """A currency this aggregate would convert has no rate (#612, widened #649). Carries the currencies as data — the CLI frame formats them, and nothing downstream has to read the sentence back out of a string. + + #649 widened what ``currencies`` may hold. It used to be the *held* set, so + the sentence said "this book holds"; a currency that reaches the account + aggregate through a cash-flow row alone is equally unaddable and equally + invisible in a holdings list, so the sentence names the aggregate's span + instead of claiming every currency in it is held. """ - def __init__(self, held, missing): - self.held = list(held) + def __init__(self, currencies, missing): + self.currencies = list(currencies) self.missing = list(missing) self.cause_codes = [] super().__init__(self._sentence()) def _sentence(self): - return (f"this book holds {', '.join(self.held)} and no FX rate covers " - f"{', '.join(self.missing)}, so its holdings cannot be added into one " - "denominator. Every weight, concentration figure, ranking and stored " - f"metric would be computed with 1 {self.missing[0]} treated as 1 USD. " - "Supply the rate through --prices (the `fx` block in " + return (f"this account's aggregate spans {', '.join(self.currencies)} and no FX " + f"rate covers {', '.join(self.missing)}, so its amounts cannot be added " + "into one denominator. Every weight, concentration figure, ranking, cash " + f"balance and stored metric would be computed with 1 {self.missing[0]} " + "treated as 1 USD. Supply the rate through --prices (the `fx` block in " "references/price-feed.md), then run prepare again.") def with_cause(self, gaps): @@ -543,32 +561,74 @@ def with_cause(self, gaps): return self -def held_currency_fx_gaps(cur_map, fx): - """The held currencies a cross-currency conversion would silently call USD. +def aggregate_currencies(cur_map, cash_flows=None): + """Every currency whose amounts can enter the account-level aggregate (#649). + + The one builder. #612 put the FX refusal in the primitive that converts + *holdings*, and its domain was ``cur_map`` — so a currency that reaches the + account total only through a cash-flow row (a dividend, an interest credit, + a custody fee, a wire) was invisible to it. It was invisible to the *fetch* + decision for the same reason, which is worse: a USD stock book with one TWD + interest row never requested a TWD rate, so ``cash_position`` added raw TWD + units into a USD total at 1.0 while ``currency_meta.mixed`` said ``false``. + + Two domains are deliberately outside this set. + + * **The display currency.** ``fx_request_currencies`` widens the *request* + with the renderer's target, which the account never converts; a missing + display rate stays a rendering degradation, exactly as #612 requires. + * **A cash anchor in a currency with no flow.** ``cash_position`` buckets + by cash-flow currency and ``_anchor_for`` pairs anchors into those + buckets, so such an anchor is dropped rather than converted — it cannot + widen what gets added together, and treating it as if it could would + refuse a review over an amount nothing reads. + + ``cash_flows`` is :func:`load_cash_flows` output. Passing it without its + ``trade_rows`` estimate cannot narrow the answer: that fallback derives each + row's currency from a trade row, and every trade row's currency is already a + value of ``cur_map``. + """ + held = {c for c in (cur_map or {}).values() if c} + cash = {(cf.get("currency") or "USD") for cf in (cash_flows or [])} + return sorted(held | cash) + + +def missing_aggregate_fx_rates(currencies, fx): + """The currencies a cross-currency conversion would silently call USD. The single predicate behind every FX refusal in this repository (#612), so a - lane that aggregates inherits it instead of restating it. Two properties are - load-bearing: + lane that aggregates inherits it instead of restating it. Its domain is + whatever :func:`aggregate_currencies` built for that lane — the held set for + the holdings view, the account-wide set for the review — never a set + assembled at the call site. Two properties are load-bearing: * A **single-currency** book — including a pure non-USD one — aggregates itself self-consistently and never needed a rate, so it is never a gap. That is the same "單一幣別組合...零行為變化" convention the module keeps. - * The domain is ``cur_map``, which only ever holds *held* currencies. A - display currency requested by ``fx_request_currencies`` is not in it, so a - missing display rate stays a rendering degradation and can never reach - here. + * The domain never contains a currency the aggregate does not convert, which + is what keeps a requested display rate a rendering degradation. - The keys are read **verbatim**, never normalized: ``usd_view``'s factor + The codes are read **verbatim**, never normalized: ``usd_view``'s factor lookup is ``fx.get(cur_map.get(t, "USD"), 1.0)``, so a predicate that case-folded or trimmed them could call a currency covered that the lookup then misses, and the identity factor would be back through the guard's own front door. Agreeing by construction is worth more here than tolerating a dirty currency code, which fails closed and is fixable in the input. """ - held = {c for c in (cur_map or {}).values() if c} - if len(held) < 2: + ccys = {c for c in (currencies or ()) if c} + if len(ccys) < 2: return [] - return sorted(c for c in held if c not in (fx or {})) + return sorted(c for c in ccys if c not in (fx or {})) + + +def held_currency_fx_gaps(cur_map, fx): + """The predicate over the holdings view's own domain (#612). + + Kept as the name ``usd_view`` and ``consequence.portfolio_state`` read, + and now literally the shared predicate over the shared builder rather than + a second walk of ``cur_map``. + """ + return missing_aggregate_fx_rates(aggregate_currencies(cur_map), fx) def fetch_fx(currencies, bundle=None, feed=None): @@ -633,7 +693,7 @@ def usd_view(rts, held, last_px, cur_map, fx): ret(比率)與 hold(天數)不受等比縮放影響。 ⚠️ 只給「聚合」消費;per-ticker 呈現(ticker_diagnosis / best_worst / 卡上單檔數字)一律用原幣原物件。 - Raises :class:`MissingHeldCurrencyRate` when a held currency has no rate + Raises :class:`MissingAggregateCurrencyRate` when a held currency has no rate (#612). The check is *here*, at the one place the conversion happens, rather than at each consumer: every aggregate reader — weights, concentration, rankings, ``what_if``, the diagnosis order, the persisted reconciliation @@ -650,7 +710,7 @@ def usd_view(rts, held, last_px, cur_map, fx): """ gaps = held_currency_fx_gaps(cur_map, fx) if gaps: - raise MissingHeldCurrencyRate(sorted({c for c in (cur_map or {}).values() if c}), gaps) + raise MissingAggregateCurrencyRate(aggregate_currencies(cur_map), gaps) f = lambda t: fx.get(cur_map.get(t, "USD"), 1.0) rts_u = [dict(r, buy_px=r["buy_px"] * f(r["ticker"]), sell_px=r["sell_px"] * f(r["ticker"])) for r in rts] @@ -2602,7 +2662,16 @@ def main(): # #605:一次請求、一份 bundle。收盤價、分割觀測與匯率出自同一次回應,所以這張卡 # 上的價、分割與匯率不可能來自三個不同瞬間(實測 ^VIX 的末筆收盤在相隔數秒的兩次 # 呼叫間就不同了)。請求必須在分割套用之前組好——理由見 market_request。 - cur_map, currencies, cur_conflicts = currency_map(rows) + cur_map, _held_currencies, cur_conflicts = currency_map(rows) + # #649: the requested currency universe must cover every currency that can + # enter the aggregate, not only the held ones. The cash-flow currencies have + # to be known before this line, while their *amounts* are only correct after + # splits are applied — so this borrows `load_cash_flows` for the currencies + # alone, without `trade_rows`: that estimate's rows take their currency from + # a trade row, which is already a `cur_map` value, so omitting it cannot + # narrow the universe. One extra CSV parse buys the thing this issue is + # about — the fetch decision and the refusal predicate reading one set. + currencies = aggregate_currencies(cur_map, load_cash_flows(paths)) requested_display = str(os.environ.get("TR_DISPLAY_CURRENCY") or "").strip().upper() bundle = market_data.resolve( market_request(rows, date_end, prev_end, currencies=currencies, @@ -2668,6 +2737,17 @@ def main(): # during prepare, even when that currency is not held in the portfolio. fx_currencies = fx_request_currencies(currencies, requested_display) fx, fx_err = fetch_fx(fx_currencies, bundle=bundle) if mixed_ccy else ({"USD": 1.0}, None) + # #649: the acquisition above and this refusal read the *same* set. Before, + # the fetch decision was made over held currencies while `cash_position` and + # `account_perf` went on to convert a wider one, so a currency could be + # invisible to the request and visible to the total — no rate was ever asked + # for, and none was ever reported missing. Raised here rather than at each + # consumer, and upstream of the card, the state write and the ledger ingest, + # so a rate that genuinely did not arrive withholds the account-level + # aggregates instead of standing beside a wrong one with a disclaimer. + aggregate_fx_gaps = missing_aggregate_fx_rates(currencies, fx) + if aggregate_fx_gaps: + raise MissingAggregateCurrencyRate(currencies, aggregate_fx_gaps).with_cause(bundle.gaps) # 價格可得性(#289):這次的價從哪來、覆蓋到哪、還缺什麼——全部機讀化。 # 缺價不是「零報酬」也不是「下市」,是資料可得性故障,必須對用戶與 QA 都保持可見。 priced = {t for t in tickers if t in last_px} @@ -2692,13 +2772,11 @@ def main(): earliest_trade=start, reason=price_feed.classify_error(yf_err), missing=price_provenance["coverage"]["missing"]) if mixed_ccy: - try: - rts_u, held_u, lastpx_u = usd_view(rts, held, last_px, cur_map, fx) - except MissingHeldCurrencyRate as exc: - # Not a second check — the predicate lives in usd_view. This frame - # only owns the acquisition codes (#611's machine-readable gaps), - # so it attaches them and re-raises for the one CLI error below. - raise exc.with_cause(bundle.gaps) + # `usd_view` keeps its own refusal for the lanes that call it directly + # (`consequence.portfolio_state`, unit tests). It cannot fire here: its + # domain is the held currencies, a subset of the set the check above + # already proved covered, so nothing on this path re-derives the rule. + rts_u, held_u, lastpx_u = usd_view(rts, held, last_px, cur_map, fx) decision_rts_u = [r for r in rts_u if driver(r["ticker"])[0] not in BENCH_SELF and not instrument_policy.is_diversified_allocation(r["ticker"])] @@ -2952,12 +3030,12 @@ def main(): if __name__ == "__main__": # #612: one controlled CLI error, never a traceback, and never a partial - # artifact — the refusal is raised inside `usd_view`, upstream of the card, - # the state write and the ledger ingest, so nothing has been produced yet. - # Caught at the process frame rather than at the call site so any later - # aggregation path in `main()` inherits the same single error line. + # artifact — the refusal is raised right after acquisition (#649), upstream + # of the card, the state write and the ledger ingest, so nothing has been + # produced yet. Caught at the process frame rather than at the call site so + # any later aggregation path in `main()` inherits the same single error line. try: main() - except MissingHeldCurrencyRate as exc: + except MissingAggregateCurrencyRate as exc: print(f"❌ {exc}", file=sys.stderr) sys.exit(1) diff --git a/skills/fomo-kernel/references/price-feed.md b/skills/fomo-kernel/references/price-feed.md index e7918cec..d331e5ea 100644 --- a/skills/fomo-kernel/references/price-feed.md +++ b/skills/fomo-kernel/references/price-feed.md @@ -89,7 +89,7 @@ Validated against [../schemas/price-feed.schema.json](../schemas/price-feed.sche } ``` -`prices` and `fx` are each optional; the envelope needs at least one of them, non-empty. The shape above answers a fully unpriced book. A book refused only for a missing held-currency rate (`MissingHeldCurrencyRate`, #612) needs *only* the rate — the closes are a separate, independent gap, and demanding both to clear a refusal that is purely about the rate would push you toward inventing prices, which is forbidden (below). This is a complete repair for that refusal: +`prices` and `fx` are each optional; the envelope needs at least one of them, non-empty. The shape above answers a fully unpriced book. A book refused only for a missing rate on a currency its account aggregate spans (`MissingAggregateCurrencyRate`, #612/#649 — a currency that appears only in a cash-flow row counts) needs *only* the rate — the closes are a separate, independent gap, and demanding both to clear a refusal that is purely about the rate would push you toward inventing prices, which is forbidden (below). This is a complete repair for that refusal: ```json { diff --git a/skills/fomo-kernel/schemas/price-feed.schema.json b/skills/fomo-kernel/schemas/price-feed.schema.json index 5a785132..66b7bed5 100644 --- a/skills/fomo-kernel/schemas/price-feed.schema.json +++ b/skills/fomo-kernel/schemas/price-feed.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://fomo-kernel.local/schemas/price-feed.schema.json", "title": "Fomo Kernel Agent-Supplied Price Feed", - "description": "Closing prices, an FX rate, or both, transcribed from a recognized market-data source when the host cannot retrieve them itself. Declared facts only: the agent never computes a return, a weight, or a P&L number. Every close and history entry is a RAW market observation as printed on its own session date; never send a split-adjusted price. The engine rebases each observation onto the split basis of the share count it will be multiplied by, using that row's date against the declared splits. `prices` and `fx` are each optional (#642) -- a host that can read one public FX rate but not every instrument's close may supply `fx` alone, which is what makes a book refused only for a missing held-currency rate (#612) repairable without fabricating closes nobody asked for -- but the envelope must declare at least one of the two; see `anyOf` below.", + "description": "Closing prices, an FX rate, or both, transcribed from a recognized market-data source when the host cannot retrieve them itself. Declared facts only: the agent never computes a return, a weight, or a P&L number. Every close and history entry is a RAW market observation as printed on its own session date; never send a split-adjusted price. The engine rebases each observation onto the split basis of the share count it will be multiplied by, using that row's date against the declared splits. `prices` and `fx` are each optional (#642) -- a host that can read one public FX rate but not every instrument's close may supply `fx` alone, which is what makes a book refused only for a missing rate on a currency its account aggregate spans (#612, and #649 for a currency that only a cash-flow row carries) repairable without fabricating closes nobody asked for -- but the envelope must declare at least one of the two; see `anyOf` below.", "type": "object", "additionalProperties": false, "required": ["as_of", "source"], diff --git a/tests/test_engine_units.py b/tests/test_engine_units.py index 04de4277..aa904ab3 100644 --- a/tests/test_engine_units.py +++ b/tests/test_engine_units.py @@ -1177,8 +1177,9 @@ def test_usd_view_refuses_a_held_currency_it_has_no_rate_for(): try: tr.usd_view([], {"2330.TW": (1, 900.0), "AAPL": (1, 100.0)}, {}, {"2330.TW": "TWD", "AAPL": "USD"}, {"USD": 1.0}) - except tr.MissingHeldCurrencyRate as exc: - assert exc.missing == ["TWD"] and exc.held == ["TWD", "USD"], (exc.missing, exc.held) + except tr.MissingAggregateCurrencyRate as exc: + assert exc.missing == ["TWD"] and exc.currencies == ["TWD", "USD"], \ + (exc.missing, exc.currencies) assert "TWD" in str(exc) and "--prices" in str(exc), str(exc) else: raise AssertionError("a held currency with no rate must refuse, never convert at 1.0") @@ -1188,10 +1189,11 @@ def test_held_currency_fx_gaps_leaves_single_currency_and_display_only_books_alo """The two compatibility halves of the same predicate (#612). A single-currency book — including a pure non-USD one — aggregates itself - self-consistently and never asked for a rate, so it is not a gap. And the - predicate's domain is `cur_map`, which only ever holds *held* currencies: - the display currency `fx_request_currencies` widens the request with is not - in it, so a missing display rate stays a rendering degradation.""" + self-consistently and never asked for a rate, so it is not a gap. And this + lane's domain is the *held* currencies: the display currency + `fx_request_currencies` widens the request with is not in it, so a missing + display rate stays a rendering degradation. #649 widened what the *review* + lane's domain is without touching either half of this one.""" assert tr.held_currency_fx_gaps({"2330.TW": "TWD"}, {"USD": 1.0}) == [], \ "a pure TWD book aggregates itself and must not require a rate" _, held_u, _ = tr.usd_view([], {"2330.TW": (1, 900.0)}, {}, {"2330.TW": "TWD"}, {"USD": 1.0}) @@ -1210,6 +1212,97 @@ def test_held_currency_fx_gaps_leaves_single_currency_and_display_only_books_alo {"USD": 1.0, "TWD": 1 / 30}) == ["twd"] +def test_aggregate_currencies_spans_cash_flow_rows_not_only_holdings(): + """#649. The one builder every FX decision reads. + + #612's domain was `cur_map`, so a currency that reaches the account total + through a cash-flow row alone — a dividend, an interest credit, a custody + fee — was invisible to it *and* to the fetch decision that shares it. A USD + stock book with one TWD interest row therefore never requested a TWD rate, + and `cash_position` added raw TWD units into a USD total at 1.0. + """ + flows = [dict(date=dt.date(2025, 1, 6), amount=-20000.0, kind="trade", currency="USD"), + dict(date=dt.date(2025, 4, 1), amount=1000000.0, kind="interest", currency="TWD")] + assert tr.aggregate_currencies({"AAPL": "USD", "MSFT": "USD"}, flows) == ["TWD", "USD"], \ + "a cash-only currency is still added into the base aggregate" + # Both compatibility halves: a genuinely single-currency book stays single, + # whichever side of it the currency came from. + assert tr.aggregate_currencies({"AAPL": "USD"}, []) == ["USD"] + assert tr.aggregate_currencies({"2330.TW": "TWD"}, + [dict(currency="TWD")]) == ["TWD"] + # A blank Currency column is USD on both sides, matching `load`/`load_cash_flows`. + assert tr.aggregate_currencies({"AAPL": "USD"}, [{}]) == ["USD"] + + # The review lane builds the universe from `load_cash_flows(paths)` — no + # `trade_rows` — because the estimate that argument unlocks needs amounts + # that are only correct after splits are applied, which cannot happen before + # the market request is composed. That omission is safe for exactly one + # reason, asserted here rather than reasoned about: the estimated rows take + # their currency from a trade row, and every trade row's currency is already + # a value of `cur_map`. + rows = [dict(date=dt.date(2025, 1, 6), ticker="2330.TW", side="buy", qty=1000.0, + price=550.0, currency="TWD", market="TW")] + estimated = tr.load_cash_flows([], trade_rows=rows) + assert estimated and all(f.get("estimated") for f in estimated), estimated + cur_map, _held, _conflicts = tr.currency_map(rows) + assert tr.aggregate_currencies(cur_map, estimated) == tr.aggregate_currencies(cur_map), \ + "the qty x price estimate must never widen the universe" + + +def test_cash_position_refuses_a_second_bucket_it_has_no_rate_for(): + """#649, at the primitive that adds the buckets together. + + The reported balance was 1,005,000 on a book whose real one is 35,849 — 28x + — while `source: "anchored"`, `reliable: true` and `unanchored: []` all said + the number was as solid as this product ever claims. A missing rate is not a + gap in that number, it is a different number, so the bucket sum refuses + rather than falling back to 1.0. + + The converted counterweight is deliberate: a refusal test on its own stays + green if the supply side stops delivering rates entirely. + """ + flows = [dict(date=dt.date(2025, 1, 6), amount=-20000.0, kind="trade", currency="USD"), + dict(date=dt.date(2025, 4, 1), amount=1000000.0, kind="interest", currency="TWD")] + anchors = [{"as_of": "2025-04-02", "amount": 1000000.0, "currency": "TWD"}, + {"as_of": "2025-04-02", "amount": 5000.0, "currency": "USD"}] + try: + tr.cash_position(flows, held_mv=34100.0, anchor=anchors) + except tr.MissingAggregateCurrencyRate as exc: + assert "TWD" in exc.missing and "TWD" in str(exc), (exc.missing, str(exc)) + else: + raise AssertionError("two cash buckets and no rate must refuse, never sum at 1.0") + + cp = tr.cash_position(flows, held_mv=34100.0, anchor=anchors, + fx={"USD": 1.0, "TWD": 0.030849}) + assert _approx(cp["balance"], 35849.0), cp["balance"] # 1,000,000 x 0.030849 + 5,000 + assert 0.51 < cp["weight"] < 0.52, cp["weight"] # not the 0.967 of the 1.0 sum + assert cp["by_currency"]["TWD"]["balance"] == 1000000.0, cp # per-currency stays raw + + +def test_cash_position_single_bucket_and_flowless_anchor_keep_the_identity(): + """#649's two compatibility halves at this primitive. + + A single bucket — including a pure non-USD one — is the aggregate currency + itself, so its factor is an identity and no rate was ever needed. And an + anchor in a currency no cash flow created is *dropped* by `_anchor_for` + rather than converted, so it can never widen what is added together; + refusing a review over it would block a book on an amount nothing reads. + """ + twd_only = [dict(date=dt.date(2025, 1, 10), amount=-100000.0, kind="trade", currency="TWD")] + cp = tr.cash_position(twd_only, held_mv=500000.0, + anchor={"as_of": "2025-01-05", "amount": 300000.0}) + assert _approx(cp["balance"], 200000.0), cp["balance"] + + usd_only = [dict(date=dt.date(2025, 1, 6), amount=-20000.0, kind="trade", currency="USD")] + anchors = [{"as_of": "2025-01-01", "amount": 500.0, "currency": "TWD"}, # no TWD flow + {"as_of": "2025-01-01", "amount": 5000.0, "currency": "USD"}] + assert tr.aggregate_currencies({"AAPL": "USD"}, usd_only) == ["USD"], \ + "an anchor with no matching bucket must not widen the universe" + cp2 = tr.cash_position(usd_only, held_mv=100000.0, anchor=anchors) + assert list(cp2["by_currency"]) == ["USD"], cp2["by_currency"] + assert _approx(cp2["balance"], -15000.0), cp2["balance"] + + def test_fetch_fx_usd_only_is_offline_noop(): fx, err = tr.fetch_fx(["USD", "USD"]) assert fx == {"USD": 1.0} and err is None, "純 USD 不碰網路、不報錯" diff --git a/tests/test_price_paths.py b/tests/test_price_paths.py index a25ab7d9..f67289a8 100644 --- a/tests/test_price_paths.py +++ b/tests/test_price_paths.py @@ -678,28 +678,92 @@ def test_acct_csv_sum_gated_hold_only(): def test_acct_partial_broken_rollback_gated(): - """partial 但盲算幣別回滾出負現金 = 假設破裂 → 帳戶柱降 None(不出污染數字)。""" + """partial 但盲算幣別回滾出負現金 = 假設破裂 → 帳戶柱降 None(不出污染數字)。 + + #649: this fixture is mixed (USD holdings, a TWD cash flow), so it now has + to carry `fx_spot` — the production path always does, because + `trade_recap`'s currency universe spans cash-flow rows. The rollback is + computed in the original currency, so this gate's verdict is unaffected by + the rate and still fires for its own reason.""" px = _px_frame({"X": _lin(100, 150)}) flows = [_cf(0, -1000, "trade"), _cf(50, -10000, "withdrawal", ccy="TWD")] cd = {"source": "partial", "reliable": False, "by_currency": {"USD": _anch(0), "TWD": _anch(-10000, reliable=False)}} - a = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}) + a = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}, + fx_spot={"USD": 1.0, "TWD": 0.03}) assert a["acct_twr"] is None and a["gate"]["status"] == "negative_cash_rollback", a assert a["gate"]["data"]["currencies"] == ["TWD"], a["gate"] assert a["hold_twr"] is not None, a["hold_twr"] def test_acct_partial_ok_discloses_unanchored(): - """partial 且盲算桶不負 → 帳戶柱照出,basis.unanchored 記缺錨點幣別(honesty 揭露源)。""" + """partial 且盲算桶不負 → 帳戶柱照出,basis.unanchored 記缺錨點幣別(honesty 揭露源)。 + + #649: as above, a mixed fixture needs `fx_spot` before the account pillar + can be computed at all.""" px = _px_frame({"X": _lin(100, 150)}) flows = [_cf(0, -1000, "trade"), _cf(50, 10000, "deposit", ccy="TWD")] cd = {"source": "partial", "reliable": False, "by_currency": {"USD": _anch(0), "TWD": _anch(10000, reliable=False)}} - a = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}) + a = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}, + fx_spot={"USD": 1.0, "TWD": 0.03}) assert a["acct_twr"] is not None, a assert a["basis"]["unanchored"] == ["TWD"], a["basis"] +def test_acct_refuses_a_cash_only_currency_it_has_no_rate_for(): + """#649. A currency carried only by cash-flow rows — a dividend, an interest + credit, a deposit, a fee — with no holding behind it. + + This is what #612's predicate cannot reach: its domain is `cur_map`, the + held currencies, so a US stock book plus one TWD interest row looks like a + single-currency book. No rate was ever requested, and `_fx_getter` then + added raw TWD units into a USD net value at 1.0. The whole account pillar + (TWR / IRR / cash drag) rests on V_t = holdings + cash, so no single field + can be kept. + + Both halves on purpose: a refusal test alone stays green if the supply side + stops delivering rates entirely — a bill this repository keeps paying. The + converted counterweight is what makes the pair mean "the rate arrived and + was actually used".""" + px = _px_frame({"X": _lin(100, 150)}) + flows = [_cf(0, -1000, "trade"), _cf(50, 300000, "interest", ccy="TWD")] + cd = {"source": "anchored", "reliable": True, + "by_currency": {"USD": _anch(0), "TWD": _anch(300000)}} + + refused = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}) + assert refused == {"gate": {"status": "missing_fx", + "data": {"currencies": ["TWD"]}}}, refused + assert "acct_twr" not in refused and "hold_twr" not in refused, refused + + priced = pf.account_perf([_row(0)], px, flows, cd, {"X": "USD"}, + fx_spot={"USD": 1.0, "TWD": 0.03}) + assert priced["gate"] is None, priced["gate"] + # 300,000 TWD converts to 9,000 USD: cash really is the larger side + # (holdings run 1,000 -> 1,500), but not at "300,000 dollars" magnitude. + # avg_cash_weight is the direct reading of that. + assert 0.7 < priced["avg_cash_weight"] < 0.75, priced["avg_cash_weight"] + assert priced["basis"]["fx_approx"] is True, priced["basis"] + assert priced["hold_twr"] is not None and abs(priced["hold_twr"] - 0.5) < 1e-9, priced + + +def test_acct_single_currency_book_still_needs_no_rate(): + """#649's compatibility half: a pure TWD book has no second currency to add + into, so 1.0 is an identity rather than a gap. + + The old `all_ccys` unioned `"USD"` unconditionally, which made "only one + currency carries value" and "a rate is missing" the same set size. Dropping + that union is what separates them, and this test is the boundary itself.""" + px = _px_frame({"X": _lin(100, 150)}) + flows = [_cf(0, -1000, "trade", ccy="TWD")] + cd = {"source": "anchored", "reliable": True, "by_currency": {"TWD": _anch(1000)}} + a = pf.account_perf([_row(0)], px, flows, cd, {"X": "TWD"}) + assert a["gate"] is None, a["gate"] + assert abs(a["acct_twr"] - 0.25) < 0.02, a["acct_twr"] + assert abs(a["hold_twr"] - 0.5) < 1e-9, a["hold_twr"] + assert a["basis"]["fx_approx"] is False, a["basis"] + + def test_acct_fx_daily_series_captures_currency_gain(): """混幣拍板默認:每日 fx 序列 → TWD 現金升值計入帳戶報酬(匯損益不歸零)。""" px = _px_frame({"X": [100.0] * len(IDX)}) diff --git a/tests/test_review_v2.py b/tests/test_review_v2.py index 6cb1e586..17a78682 100644 --- a/tests/test_review_v2.py +++ b/tests/test_review_v2.py @@ -10659,6 +10659,138 @@ def test_the_612_refusal_is_repaired_by_fx_alone_with_no_closes_supplied(): assert (root / "ledger.jsonl").exists() +"""#649's fixture: a US-only stock book whose only foreign currency arrives in a +cash-flow row. Synthetic throughout. The shape is ordinary — a Taiwanese +sub-brokerage account trading US stocks with interest posting in TWD, an IBKR +account whose base-currency interest accrues beside a single-market book, any +account with an FX-conversion fee logged at home. It takes one such row.""" +_CASH_CCY_HEADER = "TradeDate,Action,Symbol,Quantity,Price,Amount,Currency,RecordType" +_CASH_CCY_TRADES = ["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"] +_CASH_CCY_TWD_INTEREST = "2025-04-01,INTEREST,,,,1000000.00,TWD,Interest" +_CASH_CCY_USD_INTEREST = "2025-04-01,INTEREST,,,,120.00,USD,Interest" +_CASH_CCY_CLOSES = [{"ticker": "AAPL", "close": 210.0, "date": "2026-07-30", "currency": "USD"}, + {"ticker": "MSFT", "close": 430.0, "date": "2026-07-30", "currency": "USD"}] +_CASH_CCY_ANCHOR = json.dumps([{"currency": "TWD", "amount": 1000000, "as_of": "2025-04-02"}, + {"currency": "USD", "amount": 5000, "as_of": "2025-04-02"}]) + + +def _cash_ccy_case(tmp, name, rows, fx=None, prices=_CASH_CCY_CLOSES): + csv_path = pathlib.Path(tmp) / f"{name}.csv" + csv_path.write_text("\n".join([_CASH_CCY_HEADER] + rows) + "\n", encoding="utf-8") + envelope = pathlib.Path(tmp) / f"{name}.prices.json" + payload = {"schema_version": 1, "as_of": "2026-07-30", "source": "test fixture", + "prices": [dict(row) for row in prices]} + if fx: + payload["fx"] = [{"currency": currency, "usd_per_unit": rate, "date": "2026-07-30"} + for currency, rate in sorted(fx.items())] + envelope.write_text(json.dumps(payload), encoding="utf-8") + return csv_path, envelope + + +def test_a_currency_only_a_cash_flow_row_carries_is_fetched_and_refused_when_absent(): + """#649, through the real CLI. + + #612 put the FX refusal in the primitive that converts *holdings*, and its + domain was `cur_map` — which `load()` fills from BUY/SELL rows only. + `load_cash_flows` reads a separate per-row `Currency`, so a currency present + only there was invisible to the refusal *and* to the fetch decision that + shares the same set. On this book that was not a rate that failed to arrive: + none was ever requested. `cash_position` then added 1,000,000 raw TWD into a + USD total at 1.0 — a balance 28x its real size — while every honesty surface + said the opposite: `currency_meta.mixed: false`, `fx: "not_needed"`, + `basis.fx_approx: false`, `unanchored: []`, `cash_source: "anchored"`. That + is a strictly worse posture than the pre-#612 holdings case, which at least + degraded into a disclosed `fx_gaps` list. + + Both halves on purpose, matching the #612 pair above: a refusal test alone + stays green when the supply side stops delivering rates at all, and the + converted counterweight is what makes the pair mean "TWD was asked for, the + rate arrived, and it was used". + """ + with tempfile.TemporaryDirectory() as tmp: + env = _offline_engine_env(tmp) + rows = _CASH_CCY_TRADES + [_CASH_CCY_TWD_INTEREST] + + # 1. No rate: refused before anything is computed or persisted. + csv_path, no_rate = _cash_ccy_case(tmp, "cash_ccy_no_rate", rows) + refused_root = pathlib.Path(tmp) / "refused" + run = _run("prepare", csv_path, "--root", refused_root, "--language", "en", + "--prices", no_rate, "--cash", _CASH_CCY_ANCHOR, env=env) + assert run.returncode == 2, run.stdout + run.stderr + payload = json.loads(run.stdout) + assert payload["status"] == "error" + assert "TWD" in payload["error"], payload["error"] + assert "--prices" in payload["error"] and "fx" in payload["error"], payload["error"] + assert "Traceback" not in run.stdout and "Traceback" not in run.stderr + # `_from_feed` emits `fx_unavailable` per *requested* currency, so this + # code is itself the evidence that TWD entered the acquisition request — + # the half of #649 that a refusal message alone would not prove. + assert "fx_unavailable" in payload["error"], payload["error"] + written = {str(path.relative_to(refused_root)) + for path in refused_root.rglob("*")} if refused_root.exists() else set() + assert not [name for name in written if name.startswith(".pending")], written + assert "ledger.jsonl" not in written, written + + # 2. The same book with the rate supplied: converted, and honest about it. + _csv2, with_rate = _cash_ccy_case(tmp, "cash_ccy_rate", rows, fx={"TWD": 0.030849}) + priced_root = pathlib.Path(tmp) / "priced" + ok = _run("prepare", csv_path, "--root", priced_root, "--language", "en", + "--prices", with_rate, "--cash", _CASH_CCY_ANCHOR, env=env) + assert ok.returncode == 0, ok.stdout + ok.stderr + card = _fx_plan(priced_root)["engine_card"] + meta = card["currency_meta"] + assert meta["mixed"] is True and meta["currencies"] == ["TWD", "USD"], meta + assert meta["fx"] == {"TWD": 0.030849}, meta + assert card["price_provenance"]["fx"] == "feed", card["price_provenance"] + # 1,000,000 TWD is 30,849 USD beside 5,000 USD: 35,849, not 1,005,000. + cash = card["cash"] + assert abs(cash["balance"] - 35849.0) < 0.01, cash + assert 0.50 < cash["weight"] < 0.53, cash # not the 0.967 of the 1.0 sum + assert cash["by_currency"]["TWD"]["balance"] == 1000000.0, cash # per-currency stays raw + # The mix is now stated rather than denied. + assert [entry for entry in card["honesty_ledger"] + if entry["key"] == "currency_mix"], card["honesty_ledger"] + assert (priced_root / "ledger.jsonl").exists() + + +def test_a_book_whose_cash_rows_share_the_stock_currency_is_byte_stable(): + """#649's regression half: a genuinely single-currency book must not change. + + The universe now spans cash-flow rows, so the book that must be proven + unchanged is not only a trade-only one — it is a book that *has* cash-flow + rows in the same currency it trades in. Neither of these may request a rate, + report a mix, or convert anything, whichever currency it is denominated in. + """ + with tempfile.TemporaryDirectory() as tmp: + env = _offline_engine_env(tmp) + twd_closes = [{"ticker": "2330.TW", "close": 1050.0, + "date": "2026-07-30", "currency": "TWD"}] + twd_rows = ["2024-01-05,BUY,2330.TW,1000,550.00,-550000.00,TWD,Trade", + "2024-02-05,SELL,2330.TW,200,600.00,120000.00,TWD,Trade", + "2024-03-01,INTEREST,,,,900.00,TWD,Interest"] + for name, rows, closes, aggregate in ( + ("cash_pure_usd", _CASH_CCY_TRADES + [_CASH_CCY_USD_INTEREST], + _CASH_CCY_CLOSES, "USD"), + ("cash_pure_twd", twd_rows, twd_closes, "TWD")): + csv_path, envelope = _cash_ccy_case(tmp, name, rows, prices=closes) + root = pathlib.Path(tmp) / name + run = _run("prepare", csv_path, "--root", root, "--language", "en", + "--prices", envelope, env=env) + assert run.returncode == 0, name + ": " + run.stdout + run.stderr + card = _fx_plan(root)["engine_card"] + meta = card["currency_meta"] + assert meta["mixed"] is False and meta["fx"] is None, (name, meta) + assert meta["currencies"] == [aggregate], (name, meta) + assert meta["aggregate_currency"] == aggregate, (name, meta) + # No rate was requested, so none can be reported missing: the + # "單一幣別組合...零行為變化" convention, still intact. + assert card["price_provenance"]["fx"] == "not_needed", (name, card["price_provenance"]) + assert not [entry for entry in card["honesty_ledger"] + if entry["key"] == "currency_mix"], (name, card["honesty_ledger"]) + + def test_single_currency_and_display_only_gaps_are_untouched_by_the_fx_refusal(): """#612's two compatibility halves, through the CLI.