fix(questions): a position's cost reaches the stem in its native currency, and ranking compares one base currency (closes #664) - #693
Merged
Conversation
…ency, and ranking compares one base currency (closes #664) On a mixed TW/US book, `build_state`'s per-position holdings dict never carried a currency field at all, so `_initial_thesis_question`'s `pos.get("currency") or "USD"` fallback silently relabeled a raw TWD cost basis as USD -- roughly a 30x overstatement as the user reads it. The same raw, unconverted `cost` also drove `_ticker_importance`'s "largest cost" ranking, so a TWD position's face value could outrank a genuinely larger USD position purely because TWD amounts carry more zeros. - trade_recap.build_state now threads the caller's `cur_map` (the same per-ticker currency source `portfolio_basis.build_valuation_frame` already reads) onto each position, so the native currency is a stored fact instead of a reader's assumption. - review._ticker_importance's `position_cost` fallback now normalizes through `card.currency_meta.fx` -- the resolved conversion #649 already froze onto the card -- exactly like the sibling `_exit_importance` already does for exit-notional ranking. When the required rate is unavailable the candidate is refused from the ranked selection (reason `fx_unavailable`) rather than compared on its raw, unconverted magnitude. The stem itself already read the position's own currency field; only the write side and the ranking read were wrong. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sting reader, not a second divergent default
Review found that _normalized_position_cost's own fallback --
`aggregate = str(meta.get("aggregate_currency") or currency).upper()` --
silently defaults the aggregate currency to the *position's own* currency
whenever `aggregate_currency` is absent from `currency_meta`. That makes
`currency == aggregate` trivially true for every position, disabling
normalization for the whole book without any signal: the same
identity-factor failure #649 removed from the aggregate reader itself,
reintroduced one layer up. Confirmed with the exact reproduction supplied:
`_normalized_position_cost(900000, "TWD", {"currency_meta": {"mixed": True,
"currencies": ["TWD", "USD"], "fx": {"TWD": 0.031}}})` returned `900000.0`
(raw) instead of `27900.0` (converted).
Both of the engine's two current real producers (`trade_recap.build_state`,
`snapshot_adapter`) always populate `aggregate_currency` explicitly, and the
#664 E2E test's real CLI card carries it too (verified: `plan["engine_card"]
["currency_meta"]["aggregate_currency"] == "USD"` on that fixture) -- so this
gap was not reachable through either documented production route. It is
reachable through the `--card-json`/`--state-json` adapter/testing lane
(review.py's own help text for those flags), and defensively through any
future producer, which is why the existing unit tests -- which all supplied
`aggregate_currency` explicitly -- never exercised it.
card_renderer._currency(card) is already the single reader for "what is
this card's aggregate currency," with its own documented `or "USD"`
fallback (card_renderer.py:842). _normalized_position_cost now calls it
instead of re-deriving the aggregate with a different, divergent default,
collapsing to one reader per docs/development-guide.md's "two readers, one
fact" discipline for this specific value. _exit_importance has the same
latent divergent-fallback pattern; left untouched here as a separate,
pre-existing surface out of this issue's scope.
Test changes:
- test_ticker_importance_ranking_uses_engine_fx_for_mixed_currency_amounts
and test_ticker_importance_refuses_rather_than_falls_back_to_raw_units_on_missing_fx
now each cover two currency_meta shapes -- no aggregate_currency key, and
the explicit key -- rather than only the explicit-key shape.
- The E2E test now asserts, rather than assumes, that ticker_diagnosis omits
all four test tickers (so the position_cost fallback this fix touches is
actually exercised) and that its own currency_meta does carry the explicit
key (so its docstring's claim about what it can and cannot catch is
verified, not asserted in prose alone).
Mutation-verified: reverting the aggregate lookup to the divergent fallback
reddens exactly the two direct _ticker_importance tests' "no key" cases and
leaves the E2E/queue tests green -- confirming those are the only tests that
exercise this specific shape, matching the reasoning above.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On a mixed TW/US book,
trade_recap.build_state's per-position holdings dict never carried acurrencyfield at all, soreview._initial_thesis_question'spos.get("currency") or "USD"fallback silently relabeled a raw TWD cost basis as USD -- roughly a 30x overstatement as the user reads it. The same raw, unconvertedcostalso drove_ticker_importance's "largest cost" ranking, so on a mixed book a TWD position's face value could outrank a genuinely larger USD position purely because TWD amounts carry more zeros.Per the maintainer disposition (2026-07-31 comment on #664): the question surface gets a deterministic position-cost record -- native amount + native currency for the stem, plus a single-base-currency ranking value from the shared valuation projection. No FX arithmetic in the agent/copy layer;
initial_thesisselection ranks only on the normalized value; missing FX omits the candidate from the ranking rather than falling back to raw units.Update: an earlier version of this PR's
_normalized_position_costhad its own bug, found in review -- see "Correction" below.What changed
skills/fomo-kernel/engine/trade_recap.py:build_statenow takescur_mapand threads it onto each held position'scurrencyfield ((cur_map or {}).get(t, "USD")) -- the same per-ticker currency sourceportfolio_basis.build_valuation_frame'spositions=already reads inmain(). Backward compatible: an omittedcur_mapkeeps the same USD defaultbuild_statealways produced.skills/fomo-kernel/engine/review.py: added_normalized_position_cost, which converts a native-currency amount into the card's aggregate currency throughcurrency_meta.fx-- the exact conversion result [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 (aggregate_currencies/fetch_fx/usd_view) already resolved and froze onto the card._ticker_importance'sposition_costfallback now uses it instead of comparing raw units. When the rate is unavailable, it returns(None, "fx_unavailable")rather than falling back to the raw amount, and theinitial_thesisselection loop drops that candidate from ranking (recording afx_unavailablerejection) instead of comparing it on an unconverted magnitude.tests/test_engine_units.py: unit test provingbuild_statethreadscur_mapontostate.holdings.positions[ticker]["currency"], and that an omittedcur_mapstays backward compatible.tests/test_review_v2.py: (1) two direct_ticker_importanceunit tests, each covering bothcurrency_metashapes (noaggregate_currencykey, and the explicit key) -- one for correct normalization, one for the missing-FX refusal; (2) a full-CLI end-to-end test (real CSV ingestion ->build_state->_question_queue) reproducing the book shape from the issue; (3) a direct_question_queuetest proving a candidate with no FX rate is refused from ranking, never compared raw.The stem construction itself already read the position's own
currencyfield correctly; only the write side (build_statenever populated it) and the ranking read (_ticker_importance's raw fallback) were wrong.Correction (post-review)
Review found a real defect in the first version:
_normalized_position_cost's own aggregate-currency fallback --aggregate = str(meta.get("aggregate_currency") or currency).upper()-- silently defaulted the aggregate to theposition's own currency whenever
aggregate_currencywas absent fromcurrency_meta, makingcurrency == aggregatetrivially true and disabling normalization for the whole book. Reproduction supplied and confirmed:
Investigation into whether this is reachable through the two real production producers:
trade_recap.build_state'scurrency_metaliteral (trade_recap.py) always setsaggregate_currencyexplicitly.snapshot_adapter.py'scurrency_metaliteral also always sets it explicitly.class CurrencyMetaexists anywhere in the engine; both producers are plain dict literals.aggregate_currency(verified:plan["engine_card"]["currency_meta"]["aggregate_currency"] == "USD"on that fixture, now asserted in the test itself).So the specific claim "production never writes this key" does not hold for either of the two current documented ingestion routes (CSV and snapshot). It is reachable through the
--card-json/--state-jsonadapter/testing lane (review.py's own help text calls those flags "adapter/testing"), and defensively through any future producer -- which is exactly why none of the original unit tests (which all hand-built acurrency_metawith the key present) caught it. The underlying defensive-correctness bug is real and worth fixing regardless of today's reachability, per this repo's own "the identity factor is removed wherever currencies differ, not merely guarded upstream" discipline (#649's mirrored-surfaces row).Fix:
_normalized_position_costnow callscard_renderer._currency(card)-- the single already-existing reader for "this card's aggregate currency," with its own documentedor "USD"fallback (card_renderer.py:842) -- instead of re-deriving the aggregate with a second, divergent default._exit_importancehas the identical latent pattern (aggregate = str(meta.get("aggregate_currency") or currency).upper()); left untouched here as a separate, pre-existing surface out of #664's scope (noted as an adjacent finding).On the "E2E contradiction": the E2E test's card comes from the real CLI (
trade_recap.main()), which always populatesaggregate_currency-- so the E2E test never exercised the missing-key shape either before or after this correction, and mutation (a) (raw-unit ranking restored) reddening it is unrelated to this specific bug; it reddens because that mutation removes normalization entirely, not because of the aggregate-currency key. Confirmed empirically both ways:plan["engine_card"].get("ticker_diagnosis")on the E2E fixture lists only["RTA", "RTC", "RTB"]-- none of the four test tickers -- so theposition_costfallback this fix touches is genuinely exercised, and raw vs. normalized orderings genuinely differ (900000 > 600000 > 30000 > 22000raw vs.30000 > 28530 > 22000 > 19020normalized). Both facts are now asserted in the test itself, not left to a comment.Proof
initial_thesisquestions:test_initial_thesis_native_currency_label_and_normalized_cost_ranking_on_mixed_bookruns a real CSV (3 closed round trips for thebehavioraltier + 2 TWD / 2 USD open positions) throughprepareand confirms 3initial_thesisquestions are queued."TWD 900,000"and never the literal"USD"; the USD position'scurrencyfield reads"USD".USOPEN1 (30,000) > 3001.TW (~28,530) > USOPEN2 (22,000) > 3002.TW (~19,020, trimmed)-- raw order would have put both TWD positions first. Verified selected== ["USOPEN1", "3001.TW", "USOPEN2"]and3002.TWrejected withinitial_thesis_limit. The test now also assertsticker_diagnosisomits all four tickers (proving theposition_costfallback is genuinely exercised) and that this fixture's owncurrency_metadoes carryaggregate_currency(proving what this specific test can and cannot catch).test_ticker_importance_refuses_rather_than_falls_back_to_raw_units_on_missing_fxandtest_initial_thesis_selection_refuses_a_candidate_it_cannot_normalize_instead_of_ranking_it_rawconstruct amixed=Truecard with an incompletefxmap directly (the real pipeline cannot reach this state --trade_recap.usd_viewalready refuses the whole review upstream) and confirm the TWD candidate is dropped with reasonfx_unavailable, never entered into the ranked queue.currency_metashapes:test_ticker_importance_ranking_uses_engine_fx_for_mixed_currency_amountsand the missing-FX test above both loop over "noaggregate_currencykey" and "explicit key" and assert identical, correct results for each.Mutation dance (verbatim red output, then restored)
(a) Restored the raw-unit ranking (reverted
_ticker_importance's fallback toabs(float(pos.get("cost") or 0)), "position_cost"), run against the corrected tests:(b) Restored the relabel (reverted
build_state's holdings dict to a constant"currency": "USD", nevercur_map):(c) Restored the divergent aggregate-currency fallback (
_normalized_position_cost's aggregate line reverted tostr(meta.get("aggregate_currency") or currency).upper()) -- the specific bug review found:All three mutations restored from backup; full suite re-verified green after each.
Test plan
python3 tests/run_all.py-- all 49 suites pass_ticker_importancetests' "no key" cases and leaves the E2E/queue tests green, as expected; restoredorigin/mainas it advanced during this session (fix(add-cash): the drift gate compares frozen source facts, so the card-beat cash answer is recordable (closes #665) #684/[defect·P1] add-cash refuses the session it exists for: the drift gate compares question_queue and state_snapshot whole, so the card-beat cash answer is unrecordable #665, then fix(questions): planned_entry declares and collects its thesis capture in the same exchange (closes #667) #692/[defect] planned_entry declares no answer requirement, then preview refuses without a captured thesis — a dead end reachable only by following the contract #667) -- neither touches the_ticker_importance/_initial_thesis_question/initial_candidatescode path this PR changes🤖 Generated with Claude Code