feat: use broker-authoritative qty in slots RPC holdings
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
832dcb5fbb
commit
30bcbe1d12
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user