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
|
||||
Reference in New Issue
Block a user