feat: add portfolio snapshot read layer
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""pause/resume RPC handler tests (REQ-032 AC-4)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_pause_rpc_handler
|
||||
from trex_core.application.portfolio import InMemoryPauseStore
|
||||
|
||||
|
||||
def _handler():
|
||||
store = InMemoryPauseStore()
|
||||
return store, make_pause_rpc_handler(store)
|
||||
|
||||
|
||||
def _call(handler, action, pid=None, actor="cli"):
|
||||
return handler(RpcRequest(
|
||||
payload={"action": action, "portfolio_id": pid, "actor": actor},
|
||||
correlation_id="cid-1",
|
||||
)).payload
|
||||
|
||||
|
||||
def test_pause_then_list():
|
||||
store, handler = _handler()
|
||||
assert _call(handler, "pause", "pf-A")["status"] == "ok"
|
||||
assert _call(handler, "pause", "pf-B")["status"] == "ok"
|
||||
listed = _call(handler, "list")
|
||||
assert listed["status"] == "ok"
|
||||
assert listed["paused"] == ["pf-A", "pf-B"]
|
||||
|
||||
|
||||
def test_resume_clears_pause():
|
||||
store, handler = _handler()
|
||||
_call(handler, "pause", "pf-A")
|
||||
_call(handler, "resume", "pf-A")
|
||||
assert _call(handler, "list")["paused"] == []
|
||||
|
||||
|
||||
def test_missing_portfolio_id_returns_error():
|
||||
_, handler = _handler()
|
||||
resp = _call(handler, "pause", None)
|
||||
assert resp["status"] == "error"
|
||||
assert "portfolio_id" in resp["reason"]
|
||||
|
||||
|
||||
def test_unknown_action_returns_error():
|
||||
_, handler = _handler()
|
||||
resp = _call(handler, "kill", "pf-A")
|
||||
assert resp["status"] == "error"
|
||||
assert "unknown action" in resp["reason"]
|
||||
|
||||
|
||||
def test_cli_pause_visible_to_rpc_list_via_shared_store():
|
||||
"""REQ-032 AC-4 — CLI and RPC share the same store."""
|
||||
store = InMemoryPauseStore()
|
||||
handler = make_pause_rpc_handler(store)
|
||||
# CLI-side: directly mutate the store (CLI uses the same impl)
|
||||
store.pause("pf-cli", actor="cli")
|
||||
# RPC-side: list returns the same pause
|
||||
resp = handler(RpcRequest(
|
||||
payload={"action": "list"}, correlation_id="cid-cli",
|
||||
)).payload
|
||||
assert "pf-cli" in resp["paused"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Position reconciliation tests (REQ-031)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.broker_port import BalanceItem
|
||||
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
|
||||
from trex_core.application.reconciliation import reconcile_positions
|
||||
|
||||
|
||||
class FakeBroker:
|
||||
def __init__(self, balances):
|
||||
self._b = balances
|
||||
def name(self): return "fake"
|
||||
def submit_order(self, req): ...
|
||||
def poll_fill(self, *args, **kw): ...
|
||||
def cancel_order(self, *args, **kw): ...
|
||||
def get_balance(self): return list(self._b)
|
||||
|
||||
|
||||
def _store_with(*items):
|
||||
store = SnapshotStore()
|
||||
store.swap(list(items))
|
||||
return store
|
||||
|
||||
|
||||
def test_mismatch_raises_warning_and_masks_asset():
|
||||
store = _store_with(
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=0, asset_quantity=10),
|
||||
)
|
||||
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)]) # mismatch
|
||||
decisions = reconcile_positions(store=store, broker=broker)
|
||||
assert decisions["pf-A"] == "warning"
|
||||
assert store.warnings() == {"pf-A"}
|
||||
|
||||
|
||||
def test_subsequent_match_auto_clears_warning():
|
||||
store = _store_with(
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=0, asset_quantity=10),
|
||||
)
|
||||
# round 1 — mismatch
|
||||
reconcile_positions(store=store, broker=FakeBroker([BalanceItem("005930", 7)]))
|
||||
assert "pf-A" in store.warnings()
|
||||
# round 2 — match
|
||||
decisions = reconcile_positions(store=store, broker=FakeBroker([BalanceItem("005930", 10)]))
|
||||
assert decisions["pf-A"] == "cleared"
|
||||
assert store.warnings() == set()
|
||||
|
||||
|
||||
def test_multiple_portfolios_same_symbol_aggregate():
|
||||
store = _store_with(
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=0, asset_quantity=6),
|
||||
PortfolioItem(portfolio_id="pf-B", symbol="005930",
|
||||
cash_balance=0, asset_quantity=4),
|
||||
)
|
||||
# hub total 6+4=10 == broker 10 → both ok
|
||||
decisions = reconcile_positions(
|
||||
store=store, broker=FakeBroker([BalanceItem("005930", 10)]),
|
||||
)
|
||||
assert decisions == {"pf-A": "ok", "pf-B": "ok"}
|
||||
assert store.warnings() == set()
|
||||
|
||||
|
||||
def test_paper_broker_returns_deterministic_balance():
|
||||
"""REQ-031 AC-3: paper stub provides deterministic balance."""
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.application.broker_port import OrderRequest
|
||||
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
ack = broker.submit_order(OrderRequest(slot_id="s", side="buy",
|
||||
symbol="005930", qty=10, limit_price=1000))
|
||||
broker.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # fill
|
||||
balances = broker.get_balance()
|
||||
assert len(balances) == 1
|
||||
assert balances[0].symbol == "005930"
|
||||
assert balances[0].hldg_qty == 10
|
||||
|
||||
# zero-qty rows dropped (D-2 0-qty leftover semantics)
|
||||
ack2 = broker.submit_order(OrderRequest(slot_id="s2", side="buy",
|
||||
symbol="000660", qty=5, limit_price=500))
|
||||
broker.poll_fill(ack2.broker_order_id, side="buy", symbol="000660")
|
||||
ack3 = broker.submit_order(OrderRequest(slot_id="s3", side="sell",
|
||||
symbol="000660", qty=5, limit_price=500))
|
||||
broker.poll_fill(ack3.broker_order_id, side="sell", symbol="000660")
|
||||
syms = {b.symbol for b in broker.get_balance()}
|
||||
assert "000660" not in syms # net 0 → dropped
|
||||
assert "005930" in syms
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Snapshot RPC handler envelope path (REQ-028 AC-1/AC-2/AC-3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||
from trex_core.application.portfolio import (
|
||||
DegradedReason,
|
||||
InMemoryPauseStore,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
)
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire(items=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
store = SnapshotStore()
|
||||
store.swap(items or [
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=1_000_000, asset_quantity=0,
|
||||
active_strategy_id="target-value-v1",
|
||||
strategy_params={"k": 1}),
|
||||
])
|
||||
pause = InMemoryPauseStore()
|
||||
handler = make_snapshot_rpc_handler(
|
||||
repo, svc,
|
||||
snapshot_store=store, pause_store=pause, default_total_slots=5,
|
||||
)
|
||||
return store, pause, handler
|
||||
|
||||
|
||||
def test_envelope_shape_returned():
|
||||
_, _, handler = _wire()
|
||||
resp = handler(RpcRequest(
|
||||
payload={"view": "all", "trading_date": DAY.isoformat()},
|
||||
correlation_id="snap-1",
|
||||
))
|
||||
body = resp.payload
|
||||
assert set(body.keys()) == {"assets", "view", "available_slots", "degraded", "reason", "message"}
|
||||
assert isinstance(body["assets"], list)
|
||||
assert body["view"] == "all"
|
||||
assert body["degraded"] is False
|
||||
assert body["reason"] is None
|
||||
|
||||
|
||||
def test_envelope_strategy_key_filter():
|
||||
_, _, handler = _wire([
|
||||
PortfolioItem(portfolio_id="x", symbol="X", cash_balance=0, asset_quantity=0,
|
||||
active_strategy_id="alpha"),
|
||||
PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=0, asset_quantity=0,
|
||||
active_strategy_id="beta"),
|
||||
])
|
||||
resp = handler(RpcRequest(
|
||||
payload={"view": "all", "strategy_key": "alpha"}, correlation_id="snap-2",
|
||||
))
|
||||
pids = {a["asset_id"] for a in resp.payload["assets"]}
|
||||
assert pids == {"x"}
|
||||
|
||||
|
||||
def test_degraded_signal_does_not_leak_credentials():
|
||||
store, _, handler = _wire()
|
||||
store.set_degraded(
|
||||
DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
|
||||
"GET https://hub failed: 401 Bearer secret-token-abc",
|
||||
)
|
||||
resp = handler(RpcRequest(payload={"view": "all"}, correlation_id="snap-3"))
|
||||
body = resp.payload
|
||||
assert body["degraded"] is True
|
||||
assert body["reason"] == "portfolio_hub_unavailable"
|
||||
assert body["message"] is not None
|
||||
assert "secret-token-abc" not in body["message"]
|
||||
|
||||
|
||||
def test_bat007_legacy_path_when_stores_unset():
|
||||
"""Make sure pre-BAT-008 callers still work (slots+capacity stub shape)."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(RpcRequest(
|
||||
payload={"view": "all", "trading_date": DAY.isoformat()}, correlation_id="legacy",
|
||||
))
|
||||
body = resp.payload
|
||||
assert "slots" in body and "capacity" in body
|
||||
assert "assets" not in body
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Snapshot scheduler tests (REQ-030 AC-1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
|
||||
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
||||
|
||||
|
||||
def test_run_once_fetches_universe_and_swaps_store():
|
||||
read = StubPortfolioRead(daemon_id="d-1", items=[
|
||||
PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||
cash_balance=100, asset_quantity=0),
|
||||
])
|
||||
store = SnapshotStore()
|
||||
sched = SnapshotScheduler(read_port=read, store=store, broker=PaperBroker())
|
||||
assert sched.run_once() is True
|
||||
pids = {a.item.portfolio_id for a in store.list_assets()}
|
||||
assert pids == {"pf-X"}
|
||||
# universe membership + strategy params come from fetch (baseline)
|
||||
# cash/quantity are baseline values (option D will overwrite later)
|
||||
a = store.get("pf-X")
|
||||
assert a.item.cash_balance == 100
|
||||
assert a.warning is False
|
||||
|
||||
|
||||
def test_run_once_failure_sets_degraded_signal():
|
||||
class BadRead:
|
||||
def whoami(self): return "d-2"
|
||||
def get_portfolios(self, daemon_id):
|
||||
from trex_core.application.portfolio import PortfolioHubUnavailableError
|
||||
raise PortfolioHubUnavailableError("simulated")
|
||||
store = SnapshotStore()
|
||||
sched = SnapshotScheduler(read_port=BadRead(), store=store, broker=PaperBroker())
|
||||
assert sched.run_once() is False
|
||||
reason, msg = store.degraded_state()
|
||||
assert reason is not None and reason.value == "portfolio_hub_unavailable"
|
||||
|
||||
|
||||
def test_swap_preserves_warning_for_carried_over_portfolio():
|
||||
store = SnapshotStore()
|
||||
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||
cash_balance=100, asset_quantity=0)])
|
||||
store.set_warning("pf-X", True)
|
||||
# second swap includes the same portfolio with refreshed cash
|
||||
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||
cash_balance=200, asset_quantity=0)])
|
||||
a = store.get("pf-X")
|
||||
assert a.warning is True
|
||||
assert a.item.cash_balance == 200
|
||||
|
||||
|
||||
def test_option_d_update_cash_position_overrides_baseline():
|
||||
"""REQ-030 wiring — when *_after lands we update the authoritative values."""
|
||||
store = SnapshotStore()
|
||||
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||
cash_balance=100, asset_quantity=0)])
|
||||
store.update_cash_position("pf-X", cash_balance=92.5, asset_quantity=1)
|
||||
a = store.get("pf-X")
|
||||
assert a.item.cash_balance == 92.5
|
||||
assert a.item.asset_quantity == 1
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Snapshot selection + masking + view composition (REQ-029, REQ-028)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.application.portfolio import (
|
||||
DegradedReason,
|
||||
InMemoryPauseStore,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
View,
|
||||
build_snapshot_envelope,
|
||||
)
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _universe():
|
||||
return [
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=1_000_000, asset_quantity=0,
|
||||
active_strategy_id="target-value-v1"),
|
||||
PortfolioItem(portfolio_id="pf-B", symbol="000660",
|
||||
cash_balance=2_000_000, asset_quantity=10,
|
||||
active_strategy_id="trend-follow-v1"),
|
||||
PortfolioItem(portfolio_id="pf-C", symbol="035720",
|
||||
cash_balance=3_000_000, asset_quantity=0,
|
||||
active_strategy_id="target-value-v1"),
|
||||
]
|
||||
|
||||
|
||||
def _wire(*, slots_setup=None):
|
||||
store = SnapshotStore()
|
||||
store.swap(_universe())
|
||||
pause = InMemoryPauseStore()
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
if slots_setup:
|
||||
slots_setup(svc, repo)
|
||||
return store, pause, repo, svc
|
||||
|
||||
|
||||
def _take_slot(svc, repo, *, portfolio_id, symbol, strategy_id, status):
|
||||
s = repo.find_idle_slot(DAY)
|
||||
assert s is not None
|
||||
svc.transit_slot(
|
||||
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy_intent", actor="t",
|
||||
symbol=symbol, portfolio_id=portfolio_id,
|
||||
strategy_id=strategy_id, qty=10,
|
||||
)
|
||||
if status is SlotState.PENDING_BUY:
|
||||
return s.slot_id
|
||||
svc.transit_slot(
|
||||
s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", avg_price=100.0, qty=10,
|
||||
)
|
||||
return s.slot_id
|
||||
|
||||
|
||||
def _env(store, pause, repo, **kw):
|
||||
"""Helper: always pin trading_date to the test's hardcoded DAY."""
|
||||
return build_snapshot_envelope(
|
||||
store=store, pause_store=pause, slot_repo=repo, trading_date=DAY, **kw,
|
||||
)
|
||||
|
||||
|
||||
def test_view_all_includes_holding_excludes_pending_for_same_symbol():
|
||||
def setup(svc, repo):
|
||||
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||
strategy_id="target-value-v1", status=SlotState.HOLDING)
|
||||
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||
strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY)
|
||||
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||
pids = {a["asset_id"] for a in env.assets}
|
||||
assert "pf-A" in pids # holding -> included
|
||||
assert "pf-B" not in pids # pending -> masked
|
||||
assert "pf-C" in pids # universe-only -> included
|
||||
|
||||
|
||||
def test_view_buyable_with_zero_available_slots_returns_empty():
|
||||
store, pause, repo, svc = _wire()
|
||||
env = _env(store, pause, repo, available_slots=0, view=View.BUYABLE)
|
||||
assert env.assets == []
|
||||
assert env.available_slots == 0
|
||||
|
||||
|
||||
def test_view_buyable_excludes_occupied_portfolios():
|
||||
def setup(svc, repo):
|
||||
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||
strategy_id="target-value-v1", status=SlotState.PENDING_BUY)
|
||||
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||
strategy_id="trend-follow-v1", status=SlotState.HOLDING)
|
||||
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.BUYABLE)
|
||||
pids = {a["asset_id"] for a in env.assets}
|
||||
assert pids == {"pf-C"} # A occupied (pending), B occupied (holding)
|
||||
|
||||
|
||||
def test_view_held_returns_only_holding_slot_assets():
|
||||
def setup(svc, repo):
|
||||
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||
strategy_id="target-value-v1", status=SlotState.HOLDING)
|
||||
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||
strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY)
|
||||
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.HELD)
|
||||
pids = {a["asset_id"] for a in env.assets}
|
||||
assert pids == {"pf-A"}
|
||||
|
||||
|
||||
def test_strategy_key_orthogonal_filter():
|
||||
store, pause, repo, svc = _wire()
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL,
|
||||
strategy_key="target-value-v1")
|
||||
assert {a["active_strategy_id"] for a in env.assets} == {"target-value-v1"}
|
||||
|
||||
|
||||
def test_strategy_key_missing_includes_everything_unmasked():
|
||||
store, pause, repo, svc = _wire()
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL, strategy_key=None)
|
||||
assert {a["asset_id"] for a in env.assets} == {"pf-A", "pf-B", "pf-C"}
|
||||
|
||||
|
||||
def test_warning_overlay_masks_asset_uniformly_across_views():
|
||||
store, pause, repo, svc = _wire()
|
||||
store.set_warning("pf-A", True)
|
||||
for view in (View.ALL, View.BUYABLE):
|
||||
env = _env(store, pause, repo, available_slots=3, view=view)
|
||||
pids = {a["asset_id"] for a in env.assets}
|
||||
assert "pf-A" not in pids
|
||||
|
||||
|
||||
def test_pause_overlay_masks_asset():
|
||||
store, pause, repo, svc = _wire()
|
||||
pause.pause("pf-B")
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||
assert all(a["asset_id"] != "pf-B" for a in env.assets)
|
||||
|
||||
|
||||
def test_envelope_shape_no_price_field():
|
||||
store, pause, repo, svc = _wire()
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||
d = env.to_dict()
|
||||
assert set(d.keys()) == {"assets", "view", "available_slots", "degraded", "reason", "message"}
|
||||
assert isinstance(d["assets"], list)
|
||||
for asset in d["assets"]:
|
||||
assert set(asset.keys()) == {"symbol", "asset_id", "cash_balance",
|
||||
"asset_quantity", "active_strategy_id",
|
||||
"strategy_params"}
|
||||
assert "price" not in asset
|
||||
|
||||
|
||||
def test_empty_envelope_assets_is_list_not_null():
|
||||
store, pause, repo, svc = _wire()
|
||||
pause.pause("pf-A")
|
||||
pause.pause("pf-B")
|
||||
pause.pause("pf-C")
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||
d = env.to_dict()
|
||||
assert d["assets"] == []
|
||||
assert d["assets"] is not None
|
||||
|
||||
|
||||
def test_degraded_signal_in_envelope():
|
||||
store, pause, repo, svc = _wire()
|
||||
store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
|
||||
"auth.daemon get failed (Bearer abc123)")
|
||||
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||
d = env.to_dict()
|
||||
assert d["degraded"] is True
|
||||
assert d["reason"] == "portfolio_hub_unavailable"
|
||||
# sanitized — credentials stripped
|
||||
assert "Bearer abc123" not in d["message"]
|
||||
assert "<redacted>" in d["message"] or "auth" in d["message"]
|
||||
Reference in New Issue
Block a user