feat: add portfolio snapshot read layer
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import pytest
|
||||
|
||||
_PG_TABLES = (
|
||||
"snapshot_pause",
|
||||
"ledger_outbox",
|
||||
"intent_log",
|
||||
"slot_transition_log",
|
||||
|
||||
Reference in New Issue
Block a user