feat: use broker-authoritative qty in slots RPC holdings

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-24 14:15:43 +00:00
committed by GitHub
co-authored by genichin
parent 832dcb5fbb
commit 30bcbe1d12
11 changed files with 303 additions and 15 deletions
+17 -2
View File
@@ -1,7 +1,7 @@
"""Bounded account-read recovery (dcy-041 / GitHub #45)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
import pytest
@@ -37,7 +37,7 @@ class FakeAccountBroker:
def _wire():
now = [datetime(2026, 7, 29, tzinfo=timezone.utc)]
now = [datetime(2026, 7, 29, tzinfo=UTC)]
broker = FakeAccountBroker()
store = InMemoryAccountSnapshotStore()
service = AccountSnapshotService(
@@ -162,3 +162,18 @@ def test_invalid_or_missing_buy_notional_fails_closed():
assert service.buy_guard(qty=1, limit_price=None) == (
"account_snapshot_price_required"
)
def test_slots_rpc_uses_snapshot_only_with_sell_ttl_freshness():
broker, _, service, now = _wire()
assert service.refresh()
broker.balance = [BalanceItem("005930", 999)]
holdings, captured_at = service.holdings_for_slots_rpc()
assert holdings == [BalanceItem("005930", 2)]
assert captured_at == now[0]
now[0] += timedelta(seconds=3601)
with pytest.raises(AccountSnapshotUnavailable) as raised:
service.holdings_for_slots_rpc()
assert raised.value.code == "expired"
+2 -1
View File
@@ -155,7 +155,8 @@ def test_warned_true_when_qty_diverges():
resp = handler(_req(filter="holding"))
s = resp.payload["slots"][0]
# slot.qty=5 vs hub asset_quantity=3 → divergence
assert s["qty"] == 5.0
assert s["qty"] is None
assert s["slot_quantity"] == 5.0
assert s["asset_quantity"] == 3.0
assert s["warned"] is True
@@ -0,0 +1,187 @@
"""Broker-authoritative qty projection in `trex.slots.req` holding rows."""
from __future__ import annotations
from datetime import UTC, datetime
from trex_core.application.account_snapshot import AccountSnapshotUnavailable
from trex_core.application.broker_port import BalanceItem
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
class _Reader:
def __init__(
self,
*,
holdings: list[BalanceItem] | None = None,
captured_at: datetime | None = None,
unavailable_code: str | None = None,
) -> None:
self._holdings = holdings or []
self._captured_at = captured_at
self._unavailable_code = unavailable_code
def holdings_for_slots_rpc(self) -> tuple[list[BalanceItem], datetime]:
if self._unavailable_code is not None:
raise AccountSnapshotUnavailable(self._unavailable_code)
if self._captured_at is None:
raise AccountSnapshotUnavailable("not_ready")
return list(self._holdings), self._captured_at
def _holding_handler(
*,
broker_reader: _Reader,
instance_key: str = "default",
portfolio_id: str = "pf-A",
symbol: str = "104620",
):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
pending = svc.transit_slot(
slot.slot_id,
SlotState.INACTIVE,
SlotState.PENDING_BUY,
reason="buy",
actor="test",
symbol=symbol,
)
svc.transit_slot(
pending.slot_id,
SlotState.PENDING_BUY,
SlotState.HOLDING,
reason="filled",
actor="test",
avg_price=100.0,
qty=435.0,
)
store = SnapshotStore()
store.swap(
[PortfolioItem(portfolio_id=portfolio_id, symbol=symbol, cash_balance=0, asset_quantity=435)]
)
handler = make_slots_rpc_handler(
repo,
instance_key=instance_key,
snapshot_store=store,
broker_quantity_reader=broker_reader,
)
return repo, handler
def _req_holding():
return RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="corr")
def test_holding_qty_uses_broker_authority_with_diagnostic_fields():
_, handler = _holding_handler(
broker_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=440)],
captured_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] == 440.0
assert row["qty_source"] == "broker"
assert row["broker_quantity_available"] is True
assert row["broker_quantity_captured_at"] == "2026-08-24T12:00:00+00:00"
assert row["slot_quantity"] == 435.0
assert row["asset_quantity"] == 435.0
def test_holding_qty_distinguishes_confirmed_broker_zero_from_unavailable():
_, handler = _holding_handler(
broker_reader=_Reader(
holdings=[],
captured_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] == 0.0
assert row["qty_source"] == "broker"
assert row["broker_quantity_available"] is True
assert row["slot_quantity"] == 435.0
def test_holding_qty_unavailable_does_not_fallback_to_slot_or_hub():
_, handler = _holding_handler(
broker_reader=_Reader(unavailable_code="not_ready"),
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] is None
assert row["qty_source"] == "unavailable"
assert row["broker_quantity_available"] is False
assert row["broker_quantity_captured_at"] is None
assert row["slot_quantity"] == 435.0
assert row["asset_quantity"] == 435.0
def test_holding_qty_stale_snapshot_is_unavailable():
_, handler = _holding_handler(
broker_reader=_Reader(unavailable_code="expired"),
)
row = handler(_req_holding()).payload["slots"][0]
assert row["qty"] is None
assert row["qty_source"] == "unavailable"
assert row["broker_quantity_available"] is False
def test_broker_quantity_reader_is_instance_bound_per_handler():
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("inst-A", {"pf-A": "104620"})
repo.reconcile_universe("inst-B", {"pf-B": "104620"})
slot_a = repo.find_slot_by_portfolio("inst-A", "pf-A")
slot_b = repo.find_slot_by_portfolio("inst-B", "pf-B")
pending_a = svc.transit_slot(
slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="104620",
)
pending_b = svc.transit_slot(
slot_b.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="104620",
)
svc.transit_slot(
pending_a.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=435.0,
)
svc.transit_slot(
pending_b.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=435.0,
)
store = SnapshotStore()
store.swap(
[
PortfolioItem(portfolio_id="pf-A", symbol="104620", cash_balance=0, asset_quantity=435),
PortfolioItem(portfolio_id="pf-B", symbol="104620", cash_balance=0, asset_quantity=435),
]
)
captured_at = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
handler_a = make_slots_rpc_handler(
repo,
instance_key="inst-A",
snapshot_store=store,
broker_quantity_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=440)],
captured_at=captured_at,
),
)
handler_b = make_slots_rpc_handler(
repo,
instance_key="inst-B",
snapshot_store=store,
broker_quantity_reader=_Reader(
holdings=[BalanceItem(symbol="104620", hldg_qty=111)],
captured_at=captured_at,
),
)
row_a = handler_a(_req_holding()).payload["slots"][0]
row_b = handler_b(_req_holding()).payload["slots"][0]
assert row_a["qty"] == 440.0
assert row_b["qty"] == 111.0