diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 6b70a36..414cfcf 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -89,9 +89,9 @@ "status": "inactive | pending_buy | pending_sell | holding | error", "symbol": "str | null", "qty": "float | null", // holding: broker authority / unavailable=null - "qty_source": "broker | unavailable", // holding-only metadata - "broker_quantity_available": "bool", // holding-only metadata - "broker_quantity_captured_at": "ISO-8601 | null", // holding-only metadata + "qty_source": "broker | unavailable | null", // non-holding=null + "broker_quantity_available": "bool", // non-holding=false + "broker_quantity_captured_at": "ISO-8601 | null", // non-holding=null "slot_quantity": "float | null", // core daily_slot mirror (diagnostic) "asset_quantity": "float | null", // hub authority (REQ-062) "avg_price": "float | null", @@ -124,9 +124,9 @@ |---|---|---| | `slot_id`, `status`, `avg_price`, `entry_price`, `forced_close_at`, `universe_detached` | slot (core) | 슬롯 row 직접 | | `qty` | broker account snapshot | holding 슬롯에서만 authoritative. unavailable/stale 시 null | -| `qty_source` | 파생(metadata) | holding 슬롯: `broker` \| `unavailable` | -| `broker_quantity_available` | 파생(metadata) | holding 슬롯 broker qty freshness 통과 여부 | -| `broker_quantity_captured_at` | broker account snapshot | holding 슬롯 broker qty capture 시각 | +| `qty_source` | 파생(metadata) | holding 슬롯: `broker` \| `unavailable`; non-holding은 null | +| `broker_quantity_available` | 파생(metadata) | holding 슬롯 broker qty freshness 통과 여부; non-holding=false | +| `broker_quantity_captured_at` | broker account snapshot | holding 슬롯 broker qty capture 시각; non-holding=null | | `slot_quantity` | slot (core) | 기존 daily_slot.qty mirror(진단용) | | `symbol`, `portfolio_id` | slot (core) | 자산 바인딩 | | `asset_quantity` | hub (SnapshotStore) | portfolio-hub 권위값 (REQ-062) | diff --git a/trex_core/application/account_snapshot.py b/trex_core/application/account_snapshot.py index 354c590..aeeca08 100644 --- a/trex_core/application/account_snapshot.py +++ b/trex_core/application/account_snapshot.py @@ -222,12 +222,15 @@ class AccountSnapshotService: Unlike ``holdings_for_sell()``, this read path never performs a live broker call and therefore cannot mutate broker state or mask snapshot staleness. + Freshness uses the same ``sell_max_age_sec`` policy (`self._sell_max_age`) + as pending-sell quantity protection so strategy reads and sell dispatch + share one stale-snapshot threshold. """ snapshot = self._load_current() - self._age(snapshot.holdings_captured_at, self._sell_max_age) captured_at = snapshot.holdings_captured_at if captured_at is None: raise AccountSnapshotUnavailable("not_ready") + self._age(captured_at, self._sell_max_age) return list(snapshot.holdings), captured_at def buy_guard( diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index afd5fe2..092c73c 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -273,18 +273,19 @@ def make_slots_rpc_handler( except AccountSnapshotUnavailable: broker_qty_available = False - for row, slot in zip(slot_rows, slots): + for row, slot in zip(slot_rows, slots, strict=True): row["slot_quantity"] = row.get("qty") + row["qty_source"] = None + row["broker_quantity_available"] = False + row["broker_quantity_captured_at"] = None if slot.status.value != "holding": continue - row["broker_quantity_available"] = broker_qty_available - row["broker_quantity_captured_at"] = ( - broker_qty_captured_at.isoformat() if broker_qty_captured_at is not None else None - ) if broker_qty_available: broker_qty = int(broker_qty_by_symbol.get(str(slot.symbol), 0)) row["qty"] = float(broker_qty) row["qty_source"] = "broker" + row["broker_quantity_available"] = True + row["broker_quantity_captured_at"] = broker_qty_captured_at.isoformat() else: row["qty"] = None row["qty_source"] = "unavailable" @@ -296,7 +297,7 @@ def make_slots_rpc_handler( slot_ids = [s.slot_id for s in slots] occ = {s.slot_id: getattr(s, "occupancy_seq", 0) for s in slots} state_map = strategy_state_store.get_for_slots(slot_ids, occ) - for row, s in zip(slot_rows, slots): + for row, s in zip(slot_rows, slots, strict=True): entries = state_map.get(s.slot_id) if entries: row["strategy_state"] = {