feat: add portfolio snapshot read layer

This commit is contained in:
2026-06-06 09:33:00 +09:00
parent 56e977a7ad
commit ca757009f5
51 changed files with 3565 additions and 51 deletions
@@ -16,7 +16,8 @@ _EXPECTED_TABLES = {
"daily_symbol_entries",
"slot_transition_log",
"intent_log",
"ledger_outbox", # BAT-006 / REQ-019
"ledger_outbox", # BAT-006 / REQ-019
"snapshot_pause", # BAT-008 / REQ-032
}
+74
View File
@@ -0,0 +1,74 @@
"""PortfolioHubReadAdapter HTTP contract tests (REQ-027 AC-1/AC-4)."""
from __future__ import annotations
import httpx
import pytest
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
from trex_core.application.portfolio import PortfolioHubUnavailableError
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler))
def test_whoami_returns_token_daemon_id():
captured = {}
def handler(req: httpx.Request) -> httpx.Response:
captured["url"] = str(req.url)
captured["auth"] = req.headers.get("authorization")
return httpx.Response(200, json={"daemon_id": "d-7", "scope": ["read"]})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
client=_client(handler))
assert adapter.whoami() == "d-7"
assert captured["url"] == "https://pf.test/v1/auth/whoami"
assert captured["auth"] == "Bearer tok"
def test_get_portfolios_scoped_by_managed_by_daemon_id():
captured = {}
def handler(req: httpx.Request) -> httpx.Response:
captured["url"] = str(req.url)
return httpx.Response(200, json={
"items": [
{"portfolio_id": "pf-1", "symbol": "005930",
"cash_balance": 1_000_000, "asset_quantity": 10,
"active_strategy_id": "target-value-v1",
"strategy_params": {"target_value": 1_000_000}},
{"portfolio_id": "pf-2", "symbol": "000660",
"cash_balance": 2_000_000, "asset_quantity": 0,
"active_strategy_id": None, "strategy_params": {}},
],
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
client=_client(handler))
items = adapter.get_portfolios("d-7")
assert "managed_by_daemon_id=d-7" in captured["url"]
assert {it.portfolio_id for it in items} == {"pf-1", "pf-2"}
assert items[0].symbol == "005930"
assert items[0].cash_balance == 1_000_000
assert items[0].active_strategy_id == "target-value-v1"
assert items[0].strategy_params == {"target_value": 1_000_000}
# no price field
for it in items:
assert not hasattr(it, "price")
def test_whoami_5xx_raises_unavailable():
def handler(req):
return httpx.Response(503, text="upstream down")
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.whoami()
def test_get_portfolios_network_error_raises_unavailable():
def handler(req):
raise httpx.ConnectError("nope")
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_portfolios("d-7")
@@ -0,0 +1,34 @@
"""PostgreSQLPauseStore integration tests (REQ-032 AC-2 — durable across restart)."""
from __future__ import annotations
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
def test_pause_persists_and_listing_works(pg_clean):
store = PostgreSQLPauseStore(pg_clean)
store.pause("pf-A", actor="cli")
store.pause("pf-B", actor="rpc")
assert store.list_paused() == {"pf-A", "pf-B"}
def test_resume_removes_row(pg_clean):
store = PostgreSQLPauseStore(pg_clean)
store.pause("pf-X")
store.resume("pf-X")
assert store.list_paused() == set()
def test_pause_is_upsert_on_repeat(pg_clean):
store = PostgreSQLPauseStore(pg_clean)
store.pause("pf-A", actor="op1")
store.pause("pf-A", actor="op2")
assert store.list_paused() == {"pf-A"} # still one row
def test_durability_simulated_via_fresh_store_instance(pg_clean):
"""REQ-032 AC-2: pause survives a process restart (re-instantiate store)."""
a = PostgreSQLPauseStore(pg_clean)
a.pause("pf-restart")
# "restart" — new store instance, same DSN
b = PostgreSQLPauseStore(pg_clean)
assert "pf-restart" in b.list_paused()
@@ -0,0 +1,34 @@
"""StubPortfolioRead tests (REQ-027 AC-2)."""
from __future__ import annotations
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
from trex_core.application.portfolio import PortfolioItem
def test_stub_returns_fixed_universe_without_network():
stub = StubPortfolioRead()
items = stub.get_portfolios(daemon_id="anything")
assert items
assert all(isinstance(it, PortfolioItem) for it in items)
# required fields populated, no `price` attribute
for it in items:
assert it.symbol
assert it.portfolio_id
assert it.cash_balance >= 0
assert not hasattr(it, "price")
def test_stub_whoami_returns_configured_daemon_id():
stub = StubPortfolioRead(daemon_id="d-42")
assert stub.whoami() == "d-42"
def test_stub_items_are_mutable_for_tests():
stub = StubPortfolioRead(items=[
PortfolioItem(portfolio_id="x", symbol="X", cash_balance=1.0, asset_quantity=0),
])
assert len(stub.get_portfolios("d")) == 1
stub.items.append(
PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=2.0, asset_quantity=0)
)
assert len(stub.get_portfolios("d")) == 2
+61
View File
@@ -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"]
+88
View File
@@ -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"]
+1
View File
@@ -15,6 +15,7 @@ import os
import pytest
_PG_TABLES = (
"snapshot_pause",
"ledger_outbox",
"intent_log",
"slot_transition_log",