Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions skills/fomo-kernel/engine/review.py
Original file line number Diff line number Diff line change
Expand Up @@ -2906,15 +2906,62 @@ def _exit_question(item, language, card=None, prior=None):
}


def _normalized_position_cost(cost, currency, card):
"""A native-currency cost basis converted into the card's aggregate
currency (#664), or ``None`` when the review's own resolved FX map has no
rate for it.

Reads the same frozen ``currency_meta`` (#649's shared conversion result)
``_exit_importance`` already reads, rather than a second acquisition of
the aggregate lookup. Never an identity/raw fallback: a book the engine
reports ``mixed`` already has a complete rate for every held currency —
``trade_recap.usd_view`` fails the whole review closed otherwise — so
``None`` here only guards a caller whose card/state did not go through
that gate, which is exactly the shape a unit test exercises directly.

The aggregate currency itself has exactly one reader,
``card_renderer._currency`` -- its ``or "USD"`` fallback is the review's
own convention for "no aggregate declared," not this function's caller's
currency. Resolving a missing ``aggregate_currency`` key to the position's
*own* currency would make every position trivially "already the
aggregate" and silently turn off normalization for the whole book instead
of naming the gap -- the same identity-factor failure #649 removed from
the aggregate reader itself, reintroduced one layer up.
"""
meta = (card or {}).get("currency_meta") or {}
currency = str(currency or "USD").upper()
aggregate = card_renderer._currency(card or {})
if not meta.get("mixed") or currency == aggregate:
return abs(float(cost or 0))
factor = (meta.get("fx") or {}).get(currency)
if factor is None:
return None
try:
return abs(float(cost) * float(factor))
except (TypeError, ValueError):
return None


def _ticker_importance(card, state, ticker):
for row in card.get("ticker_diagnosis") or []:
if row.get("ticker") == ticker and row.get("impact") is not None:
return abs(float(row["impact"])), "pnl_impact"
pos = (_active_positions(state).get(ticker) or {})
try:
return abs(float(pos.get("cost") or 0)), "position_cost"
cost = float(pos.get("cost") or 0)
except (TypeError, ValueError):
return 0.0, "unknown"
# #664: the position_cost fallback used to compare this raw native-currency
# magnitude directly against other tickers' raw magnitudes -- silently
# wrong on a mixed-currency book, where the same face value in TWD and USD
# differ by the exchange rate. Normalize it exactly like the ticker_diagnosis
# branch above already is (its rts/held/last_px are usd_view's own output).
normalized = _normalized_position_cost(cost, pos.get("currency"), card)
if normalized is None:
# Never fall back to the raw amount: refuse the ranking for this
# candidate rather than let an unconverted magnitude compete.
return None, "fx_unavailable"
return normalized, "position_cost"


def _initial_thesis_id(cycle_id):
Expand Down Expand Up @@ -3147,9 +3194,20 @@ def _question_queue(card, state, active, previous_state, language, recent_exits=
cost = card_renderer._finite_number(pos.get("cost"))
if cost is None or cost <= 0:
continue # cannot ground the stem in a concrete magnitude
initial_candidates.append(_initial_thesis_question(
candidate = _initial_thesis_question(
ticker, pos, cost, card, state, language,
recalled=_recalled_entry_statement(evaluation_recall, ticker, cycle_id)))
recalled=_recalled_entry_statement(evaluation_recall, ticker, cycle_id))
if candidate.get("_importance") is None:
# #664: the "largest cost" ranking compares normalized amounts
# only. When the aggregate FX map has no rate for this
# position's currency, refuse to rank it rather than let its
# raw native-currency magnitude compete against one already
# normalized -- the same fail-closed posture #649 gives the
# aggregate reader this candidate's importance came from.
rejected.append(_rejection(candidate["id"], "initial_thesis",
"fx_unavailable", cycle_id=cycle_id))
continue
initial_candidates.append(candidate)
initial_candidates.sort(key=lambda row: (-float(row.get("_importance") or 0), str(row.get("id"))))
candidates.extend(initial_candidates[:INITIAL_THESIS_LIMIT])
initial_overflow = initial_candidates[INITIAL_THESIS_LIMIT:]
Expand Down
9 changes: 7 additions & 2 deletions skills/fomo-kernel/engine/trade_recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2206,7 +2206,7 @@ def position_observables(holdings, last_px, date_end, prices=None, markets=None)


def build_state(rows, rts, held, dims, overview, ab, rx, currency_meta=None,
avg_down=None, last_px=None, prev_end=None, cash=None,
cur_map=None, avg_down=None, last_px=None, prev_end=None, cash=None,
portfolio_structure=None, price_snapshot=None, market_context=None,
max_pos_override=None, price_provenance=None, price_request=None,
prices=None, valuation_frame=None, splits=None,
Expand Down Expand Up @@ -2271,6 +2271,11 @@ def build_state(rows, rts, held, dims, overview, ab, rx, currency_meta=None,
add_cursors = current_cycle_add_cursors(rows)
holdings = {t: {"shares": round(sh, 4), "cost": round(c, 2),
"avg_cost": round(c / sh, 4) if sh > 1e-9 else None,
# #664: the position's own native currency (same reader as
# portfolio_basis.build_valuation_frame's `positions=` above),
# so a downstream cost-basis question can label its native
# amount correctly instead of a reader defaulting it to USD.
"currency": (cur_map or {}).get(t, "USD"),
"cycle_start": cyc.get(t, {}).get("start"),
# 算不出開倉(CSV 缺期初持倉)→ 標 #unknown,不 fallback 裸 ticker(雙審 codex#4)
# ⚠️ 格式契約 = 頂部 CYCLE_ID_RE / CYCLE_ID_UNKNOWN_RE(#61):改這裡必先改常數,契約測試會抓
Expand Down Expand Up @@ -3009,7 +3014,7 @@ def main():
fx_provenance=("engine_fetch" if not fx_err else "unavailable"),
).to_dict()
state = build_state(rows, rts, held, dims, overview, ab, rx,
currency_meta=currency_meta,
currency_meta=currency_meta, cur_map=cur_map,
avg_down=avg_down, last_px=last_px,
prev_end=prev_end,
cash=cash_data, portfolio_structure=portfolio_structure,
Expand Down
35 changes: 35 additions & 0 deletions tests/test_engine_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,41 @@ def test_build_state_echoes_position_cap_override():
max_pos_override=5)["max_position_pct"] is None, "壞值 fail-closed → None"


def test_build_state_threads_cur_map_onto_each_position_currency():
"""#664: a held position's own native currency must reach
`state["holdings"]["positions"][ticker]["currency"]`, not default silently
to USD. Before this, `build_state`'s per-ticker dict carried no currency
field at all regardless of the caller's `cur_map` -- so a downstream
reader (the initial-thesis question stem) had nothing but its own "USD"
fallback, and a TWD position's raw cost was relabeled as if it were USD.

`cur_map=None` (the parameter's default, and every call site before #664)
must keep resolving to "USD" -- the same default `cur_map.get(t, "USD")`
already uses elsewhere (e.g. `portfolio_basis.build_valuation_frame`'s
`positions=` in `main()`), so this is additive rather than a behavior
change for a single-currency book.
"""
rows = [_R("2330.TW", "buy", 900, 1000.0, "2024-03-01"),
_R("AAPL", "buy", 100, 150.0, "2024-03-02")]
rows[0]["currency"] = "TWD"
rows[1]["currency"] = "USD"
ab = dict(note="無價格")
cur_map, _currencies, _conflicts = tr.currency_map(rows)
assert cur_map == {"2330.TW": "TWD", "AAPL": "USD"}, cur_map

with_map = _state_from(rows, ab, cur_map=cur_map)["holdings"]["positions"]
assert with_map["2330.TW"]["currency"] == "TWD", with_map["2330.TW"]
assert with_map["AAPL"]["currency"] == "USD", with_map["AAPL"]

# Backward compatible: an existing caller that never learned about
# `cur_map` (every call site before #664) still gets the same USD default
# `build_state` always produced -- just now on an explicit field instead
# of a downstream reader assuming it.
no_map = _state_from(rows, ab)["holdings"]["positions"]
assert no_map["2330.TW"]["currency"] == "USD"
assert no_map["AAPL"]["currency"] == "USD"


# ─────── J3. current_book_projection():dim_size × ticker_diagnosis 分母合一(#477) ───────
#
# #324/#481 已把診斷/處方的觸發「線」對齊到同一個 effective_oversize_trigger;這裡收的是
Expand Down
Loading
Loading