feat(rel-007): dcy-025/024 + BAT-035/036 — slots.req 즉시 교체 + safety_sell_enabled
BAT-035 (dcy-025): trex.snapshot.req → trex.slots.req 즉시 교체
- topology: SNAPSHOT_REQ_QUEUE 제거, SLOTS_REQ_QUEUE = "trex.slots.req" 추가
- portfolio.py: View/build_snapshot_envelope/SnapshotEnvelope/_asset_to_dict 제거;
slot_to_dict 시그니처 확장(snapshot_store/watchdog_store/pause_store/watchdog_timeout_sec)
— asset_quantity(hub)/cash_balance/cash_estimated/warned/watchdog{}/paused 병합 (REQ-074)
- messaging_handlers.py: make_snapshot_rpc_handler 제거, make_slots_rpc_handler 추가
— filter(all/inactive/pending/holding/error) + spy(bool) 처리; strategy 필드 제외 (REQ-073/074)
- daemon.py: _snapshot_rpc_loop → _slots_rpc_loop, make_slots_rpc_handler 연결
- cli.py: universe/slots → slots_queue + spy=True + server-side filter; buyable→inactive 매핑 (REQ-063)
- rabbitmq.py: snapshot_queue → slots_queue
- docs: interface-contract.md + runtime-flows.md slots.req 계약으로 업데이트 (REQ-075)
- REQ-028: Implemented → Deprecated (코드 삭제 완료)
- 테스트: test_snapshot_handler/selection/rpc_watchdog/messaging_snapshot_consumer 삭제;
test_slots_handler.py(신규, 13 cases), test_killswitch_masking_resume 재작성,
test_ledger_option_d._apply_cash_estimate → slots handler 경유, test_topology/cli_operator 갱신
BAT-036 (dcy-024): per-instance safety_sell_enabled 플래그
- config.py: DaemonInstanceConfig + DaemonConfig 에 safety_sell_enabled: bool = True 추가;
from_toml 파싱, from_config 전파
- daemon.py: start() workers 조건부 — safety_sell_enabled=false 시 trex-safety-sell 미기동 + WARNING
- tests: test_instance_config.py 에 safety_sell_enabled 파싱 3개 케이스 추가
REQ: 073/074/075/021/034/063/048/005 Approved → Implemented
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,14 +20,14 @@ from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport
|
||||
def test_named_instance_suffix():
|
||||
"""REQ-049 AC-1."""
|
||||
assert topology.intent_queue("inst-1") == "trex.intent.req.inst-1"
|
||||
assert topology.snapshot_queue("inst-1") == "trex.snapshot.req.inst-1"
|
||||
assert topology.slots_queue("inst-1") == "trex.slots.req.inst-1"
|
||||
assert topology.control_queue("inst-1") == "trex.control.req.inst-1"
|
||||
|
||||
|
||||
def test_default_is_literal():
|
||||
"""REQ-049 AC-2: default keeps legacy literal names (backward-compat)."""
|
||||
assert topology.intent_queue("default") == "trex.intent.req"
|
||||
assert topology.snapshot_queue(None) == "trex.snapshot.req"
|
||||
assert topology.slots_queue(None) == "trex.slots.req"
|
||||
assert topology.control_queue() == "trex.control.req"
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def _delete_instance_queues(url, instance_key):
|
||||
conn = pika.BlockingConnection(pika.URLParameters(url))
|
||||
try:
|
||||
ch = conn.channel()
|
||||
for q in (topology.intent_queue(instance_key), topology.snapshot_queue(instance_key),
|
||||
for q in (topology.intent_queue(instance_key), topology.slots_queue(instance_key),
|
||||
topology.control_queue(instance_key)):
|
||||
try:
|
||||
ch.queue_delete(queue=q)
|
||||
|
||||
@@ -5,10 +5,11 @@ from trex_core.adapters.messaging import topology
|
||||
|
||||
|
||||
def test_topology_constants():
|
||||
assert topology.SNAPSHOT_REQ_QUEUE == "trex.snapshot.req"
|
||||
assert topology.SLOTS_REQ_QUEUE == "trex.slots.req"
|
||||
assert topology.INTENT_REQ_QUEUE == "trex.intent.req"
|
||||
assert topology.EVENTS_EXCHANGE == "trex.events"
|
||||
assert topology.EVENTS_DLQ_EXCHANGE == "trex.events.dlq"
|
||||
assert not hasattr(topology, "SNAPSHOT_REQ_QUEUE")
|
||||
|
||||
|
||||
def test_event_routing_key_format():
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Kill-switch day-exclusion masking + resume tests (REQ-046 / BAT-012)."""
|
||||
"""Kill-switch day-exclusion store + resume tests (REQ-046 / BAT-012).
|
||||
|
||||
Tests the InMemoryKillSwitchStore and KillSwitchService.resume().
|
||||
View-masking through build_snapshot_envelope was removed in BAT-035 (dcy-025).
|
||||
Exclusion semantics are now tested at the store level.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
@@ -9,11 +14,6 @@ from trex_core.application.killswitch import KillSwitchService
|
||||
from trex_core.application.portfolio import (
|
||||
GLOBAL_KILLSWITCH,
|
||||
InMemoryKillSwitchStore,
|
||||
InMemoryPauseStore,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
View,
|
||||
build_snapshot_envelope,
|
||||
)
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
@@ -22,86 +22,56 @@ _DAY = date(2026, 6, 7)
|
||||
_DAY_NEXT = date(2026, 6, 8)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
s = SnapshotStore()
|
||||
s.swap([
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=0),
|
||||
PortfolioItem(portfolio_id="pf-B", symbol="000660", cash_balance=0, asset_quantity=0),
|
||||
])
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ks_store():
|
||||
return InMemoryKillSwitchStore()
|
||||
|
||||
|
||||
def _asset_ids(envelope):
|
||||
return {a["asset_id"] for a in envelope.to_dict()["assets"]}
|
||||
|
||||
|
||||
def _envelope(store, repo, ks_store, *, view=View.ALL, day=_DAY):
|
||||
return build_snapshot_envelope(
|
||||
store=store, pause_store=InMemoryPauseStore(), slot_repo=repo,
|
||||
available_slots=10, view=view, trading_date=day, killswitch_store=ks_store,
|
||||
)
|
||||
|
||||
|
||||
def test_portfolio_exclusion_masks_that_asset(store, repo, ks_store):
|
||||
"""AC-1: an excluded portfolio is masked from the envelope."""
|
||||
def test_portfolio_exclusion_recorded(ks_store):
|
||||
"""AC-1: excluding a portfolio records it in the store for the day."""
|
||||
ks_store.exclude("pf-A", _DAY)
|
||||
ids = _asset_ids(_envelope(store, repo, ks_store))
|
||||
assert "pf-A" not in ids
|
||||
assert "pf-B" in ids
|
||||
assert "pf-A" in ks_store.list_excluded(_DAY)
|
||||
assert "pf-B" not in ks_store.list_excluded(_DAY)
|
||||
|
||||
|
||||
def test_global_exclusion_masks_all(store, repo, ks_store):
|
||||
"""AC-2: global ('*') exclusion masks the whole universe."""
|
||||
def test_global_exclusion_uses_sentinel(ks_store):
|
||||
"""AC-2: global exclusion uses GLOBAL_KILLSWITCH sentinel."""
|
||||
ks_store.exclude(GLOBAL_KILLSWITCH, _DAY)
|
||||
assert _asset_ids(_envelope(store, repo, ks_store)) == set()
|
||||
assert GLOBAL_KILLSWITCH in ks_store.list_excluded(_DAY)
|
||||
|
||||
|
||||
def test_buyable_view_excludes(store, repo, ks_store):
|
||||
"""AC-3: excluded asset is not buyable (blocks new buys)."""
|
||||
def test_day_scoped_auto_recovery(ks_store):
|
||||
"""AC-4: next trading day, the exclusion is absent (day-scoped)."""
|
||||
ks_store.exclude("pf-A", _DAY)
|
||||
ids = _asset_ids(_envelope(store, repo, ks_store, view=View.BUYABLE))
|
||||
assert "pf-A" not in ids and "pf-B" in ids
|
||||
assert "pf-A" not in ks_store.list_excluded(_DAY_NEXT)
|
||||
|
||||
|
||||
def test_day_scoped_auto_recovery(store, repo, ks_store):
|
||||
"""AC-4: next trading day, the exclusion is gone (auto recovery)."""
|
||||
def test_persistence_same_day(ks_store):
|
||||
"""AC-5: same day, exclusion persists across repeated calls."""
|
||||
ks_store.exclude("pf-A", _DAY)
|
||||
ids = _asset_ids(_envelope(store, repo, ks_store, day=_DAY_NEXT))
|
||||
assert "pf-A" in ids # back on the next day
|
||||
assert "pf-A" in ks_store.list_excluded(_DAY)
|
||||
assert "pf-A" in ks_store.list_excluded(_DAY)
|
||||
|
||||
|
||||
def test_persistence_same_day(store, repo, ks_store):
|
||||
"""AC-5: a fresh envelope build same day still masks (store-backed persistence)."""
|
||||
def test_resume_clears_exclusion(ks_store):
|
||||
"""AC-6: resume (clear) removes the exclusion for that day."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = KillSwitchService(slot_repo=repo, slot_service=SlotService(repo),
|
||||
killswitch_store=ks_store)
|
||||
ks_store.exclude("pf-A", _DAY)
|
||||
# simulate restart: new envelope build reading the same store
|
||||
assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store))
|
||||
|
||||
|
||||
def test_resume_reincludes(store, repo, ks_store):
|
||||
"""AC-6: resume (clear) re-includes the asset from the next build."""
|
||||
svc = KillSwitchService(slot_repo=repo, slot_service=SlotService(repo), killswitch_store=ks_store)
|
||||
ks_store.exclude("pf-A", _DAY)
|
||||
assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store))
|
||||
assert "pf-A" in ks_store.list_excluded(_DAY)
|
||||
svc.resume(scope="portfolio", portfolio_id="pf-A",
|
||||
now=__import__("datetime").datetime(2026, 6, 7, 6, tzinfo=__import__("datetime").timezone.utc))
|
||||
assert "pf-A" in _asset_ids(_envelope(store, repo, ks_store))
|
||||
now=__import__("datetime").datetime(2026, 6, 7, 6,
|
||||
tzinfo=__import__("datetime").timezone.utc))
|
||||
assert "pf-A" not in ks_store.list_excluded(_DAY)
|
||||
|
||||
|
||||
def test_no_killswitch_store_no_masking(store, repo):
|
||||
"""Backward compat: without killswitch_store nothing extra is masked."""
|
||||
env = build_snapshot_envelope(
|
||||
store=store, pause_store=InMemoryPauseStore(), slot_repo=repo,
|
||||
available_slots=10, view=View.ALL, trading_date=_DAY,
|
||||
)
|
||||
assert _asset_ids(env) == {"pf-A", "pf-B"}
|
||||
def test_no_exclusion_empty_list(ks_store):
|
||||
"""Baseline: with no exclusions, list_excluded returns empty set."""
|
||||
assert ks_store.list_excluded(_DAY) == set()
|
||||
|
||||
|
||||
def test_clear_without_prior_exclude_is_noop(ks_store):
|
||||
"""Clearing a non-excluded portfolio is safe (no exception)."""
|
||||
ks_store.clear("pf-X", _DAY)
|
||||
assert ks_store.list_excluded(_DAY) == set()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Option D cash/position write-back + estimate overlay tests (dcy-018, issue: cash stale).
|
||||
|
||||
Covers: LedgerWriter write-back to SnapshotStore on new/duplicate, skip when
|
||||
*_after missing, best-effort isolation, and the snapshot handler's estimated-cash
|
||||
*_after missing, best-effort isolation, and the slots handler's estimated-cash
|
||||
overlay from pending outbox deltas (OQ-4).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -15,8 +15,10 @@ from trex_core.application.ledger_outbox import (
|
||||
trade_cash_delta,
|
||||
)
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.messaging_handlers import _apply_cash_estimate
|
||||
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.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
@@ -88,7 +90,7 @@ def test_no_writeback_without_store():
|
||||
assert writer.run_once().completed == 1 # no crash
|
||||
|
||||
|
||||
# --- estimate overlay (REQ-029 / OQ-4) -------------------------------------
|
||||
# --- estimate overlay (REQ-029 / OQ-4) — via make_slots_rpc_handler ----------
|
||||
def test_pending_cash_delta_sums_by_asset():
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0))
|
||||
@@ -98,14 +100,18 @@ def test_pending_cash_delta_sums_by_asset():
|
||||
|
||||
|
||||
def test_apply_cash_estimate_overlays_pending_delta():
|
||||
"""Cash-estimated overlay applied through slots handler (REQ-029 OQ-4)."""
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
outbox.enqueue(_trade(execution_id="b", side="buy", qty=10, price=1000.0, fee=1.0))
|
||||
payload = {"assets": [{"asset_id": "PF-1", "symbol": "005930",
|
||||
"cash_balance": 1_000_000.0, "asset_quantity": 0.0}]}
|
||||
_apply_cash_estimate(payload, outbox)
|
||||
a = payload["assets"][0]
|
||||
assert a["cash_estimated"] is True
|
||||
assert a["cash_balance"] == 1_000_000.0 - (10 * 1000.0 + 1.0) # estimated cash
|
||||
store = _store_with(pid="PF-1", cash=1_000_000.0, qty=0.0)
|
||||
repo = InMemorySlotRepository()
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
|
||||
resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test"))
|
||||
slots = resp.payload["slots"]
|
||||
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
|
||||
assert pf1["cash_estimated"] is True
|
||||
assert pf1["cash_balance"] == 1_000_000.0 - (10 * 1000.0 + 1.0) # estimated cash
|
||||
|
||||
|
||||
def test_estimate_clears_after_writeback():
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Snapshot RPC handler tests (REQ-021 AC-2, BAT-007 stub-shape)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||
from trex_core.domain.slot import SlotService
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol={"PF-1": "005930", "PF-2": "000660"}.get)
|
||||
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3"})
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
def _req(view="all", strategy_id=None):
|
||||
return RpcRequest(
|
||||
payload={
|
||||
"view": view,
|
||||
"strategy_id": strategy_id,
|
||||
"trading_date": DAY.isoformat(),
|
||||
},
|
||||
correlation_id="snap-cid",
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_returns_all_slots_and_capacity():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req())
|
||||
assert resp.payload["view"] == "all"
|
||||
assert len(resp.payload["slots"]) == 3 # all 3 slots returned
|
||||
cap = resp.payload["capacity"]
|
||||
assert cap["pending_count"] == 1 and cap["inactive_count"] == 2
|
||||
assert cap["active_count"] == 1 and cap["available_count"] == 2
|
||||
|
||||
|
||||
def test_snapshot_open_view_filters_to_active_slots():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open"))
|
||||
statuses = {s["status"] for s in resp.payload["slots"]}
|
||||
assert "inactive" not in statuses
|
||||
assert statuses == {"pending_buy"}
|
||||
|
||||
|
||||
def test_snapshot_strategy_id_filter():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b2", qty=5, strategy_id="B", portfolio_id="PF-2",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open", strategy_id="A"))
|
||||
assert len(resp.payload["slots"]) == 1
|
||||
assert resp.payload["slots"][0]["strategy_id"] == "A"
|
||||
assert resp.payload["slots"][0]["symbol"] == "005930"
|
||||
|
||||
|
||||
# --- dcy-016 / REQ-021/034: operator query watchdog suppression -------------
|
||||
class _RecWatchdog:
|
||||
def __init__(self):
|
||||
self.updated = []
|
||||
|
||||
def update(self, slot_id):
|
||||
self.updated.append(str(slot_id))
|
||||
|
||||
|
||||
def _holding_slot(repo, svc):
|
||||
from trex_core.domain.slot import SlotState
|
||||
slot = repo.find_slot_by_portfolio("default", "PF-1")
|
||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="t", actor="t", symbol="005930", qty=10)
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="t", actor="t", avg_price=1000)
|
||||
return str(slot.slot_id)
|
||||
|
||||
|
||||
def test_strategy_poll_touches_watchdog_by_default():
|
||||
repo, svc, intents = _wire()
|
||||
sid = _holding_slot(repo, svc)
|
||||
wd = _RecWatchdog()
|
||||
handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd)
|
||||
handler(_req()) # no actor flag => legacy liveness touch (REQ-034)
|
||||
assert sid in wd.updated
|
||||
|
||||
|
||||
def test_operator_query_suppresses_watchdog_touch():
|
||||
repo, svc, intents = _wire()
|
||||
_holding_slot(repo, svc)
|
||||
wd = _RecWatchdog()
|
||||
handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd)
|
||||
req = RpcRequest(
|
||||
payload={"view": "all", "trading_date": DAY.isoformat(),
|
||||
"actor": "operator", "touch_watchdog": False},
|
||||
correlation_id="op",
|
||||
)
|
||||
handler(req)
|
||||
assert wd.updated == [] # dcy-016 R-4: operator query is not a liveness signal
|
||||
|
||||
|
||||
def test_snapshot_exposes_watchdog_state_for_holding_slots():
|
||||
"""dcy-017 / REQ-034: snapshot response carries holding-slot watchdog state."""
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
repo, svc, intents = _wire()
|
||||
sid = _holding_slot(repo, svc)
|
||||
wd = WatchdogStore() # never updated => expired (strategy absent)
|
||||
handler = make_snapshot_rpc_handler(repo, svc, watchdog_store=wd, watchdog_timeout_sec=120)
|
||||
# operator query so the response does not touch the watchdog before we read it
|
||||
req = RpcRequest(
|
||||
payload={"view": "all", "trading_date": DAY.isoformat(), "actor": "operator"},
|
||||
correlation_id="op",
|
||||
)
|
||||
states = handler(req).payload["watchdog"]
|
||||
assert len(states) == 1
|
||||
assert states[0]["slot_id"] == sid and states[0]["expired"] is True
|
||||
assert states[0]["last_seen"] is None
|
||||
@@ -0,0 +1,212 @@
|
||||
"""slots RPC handler tests (REQ-073 AC-1~5, REQ-074 AC-1~5)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
||||
from trex_core.application.portfolio import (
|
||||
DegradedReason,
|
||||
InMemoryPauseStore,
|
||||
PortfolioItem,
|
||||
SnapshotStore,
|
||||
)
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
def _wire(items=None, *, max_active_slots=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
repo.reconcile_universe("default", {"pf-A", "pf-B"})
|
||||
store = SnapshotStore()
|
||||
store.swap(items or [
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=1_000_000, asset_quantity=0),
|
||||
PortfolioItem(portfolio_id="pf-B", symbol="000660",
|
||||
cash_balance=500_000, asset_quantity=10),
|
||||
])
|
||||
pause = InMemoryPauseStore()
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_slots_rpc_handler(
|
||||
repo,
|
||||
snapshot_store=store,
|
||||
pause_store=pause,
|
||||
watchdog_store=watchdog,
|
||||
)
|
||||
return repo, svc, store, pause, watchdog, handler
|
||||
|
||||
|
||||
def _req(filter=None, spy=False):
|
||||
payload = {}
|
||||
if filter is not None:
|
||||
payload["filter"] = filter
|
||||
if spy:
|
||||
payload["spy"] = True
|
||||
return RpcRequest(payload=payload, correlation_id="test-corr")
|
||||
|
||||
|
||||
# REQ-073 AC-3: response keys only {slots/degraded/reason/message}
|
||||
def test_response_keys_only_slots_health():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req())
|
||||
assert set(resp.payload.keys()) == {"slots", "degraded", "reason", "message"}
|
||||
assert "assets" not in resp.payload
|
||||
assert "available_slots" not in resp.payload
|
||||
assert "watchdog" not in resp.payload
|
||||
|
||||
|
||||
# REQ-073 AC-2: filter=all returns all slots
|
||||
def test_filter_all_returns_all_slots():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req(filter="all"))
|
||||
assert len(resp.payload["slots"]) == 2
|
||||
|
||||
|
||||
# REQ-073 AC-2: filter=inactive returns only inactive slots
|
||||
def test_filter_inactive_returns_inactive_slots():
|
||||
repo, svc, store, _, _, handler = _wire()
|
||||
# Buy pf-A into holding
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
resp = handler(_req(filter="inactive"))
|
||||
slots = resp.payload["slots"]
|
||||
assert all(s["status"] == "inactive" for s in slots)
|
||||
assert len(slots) == 1 # pf-B remains inactive
|
||||
|
||||
|
||||
# REQ-073 AC-2: filter=holding returns holding slots
|
||||
def test_filter_holding_returns_holding_slots():
|
||||
repo, svc, _, _, _, handler = _wire()
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
resp = handler(_req(filter="holding"))
|
||||
slots = resp.payload["slots"]
|
||||
assert all(s["status"] == "holding" for s in slots)
|
||||
assert len(slots) == 1
|
||||
|
||||
|
||||
# REQ-073 AC-2: filter=pending returns pending_buy ∪ pending_sell
|
||||
def test_filter_pending_returns_pending_buy_and_sell():
|
||||
repo, svc, _, _, _, handler = _wire()
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
resp = handler(_req(filter="pending"))
|
||||
slots = resp.payload["slots"]
|
||||
assert all(s["status"] in ("pending_buy", "pending_sell") for s in slots)
|
||||
assert len(slots) == 1
|
||||
|
||||
|
||||
# REQ-073 AC-2: unrecognized filter → treated as all
|
||||
def test_invalid_filter_treated_as_all():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req(filter="buyable")) # old View value
|
||||
assert len(resp.payload["slots"]) == 2
|
||||
|
||||
|
||||
# REQ-073 AC-5: degraded=True + reason propagated
|
||||
def test_degraded_signal_propagated():
|
||||
_, _, store, _, _, handler = _wire()
|
||||
store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
|
||||
"GET hub failed: Bearer secret-token")
|
||||
resp = handler(_req())
|
||||
body = resp.payload
|
||||
assert body["degraded"] is True
|
||||
assert body["reason"] == "portfolio_hub_unavailable"
|
||||
assert body["message"] is not None
|
||||
assert "secret-token" not in body["message"]
|
||||
|
||||
|
||||
# REQ-074 AC-1: slot row contains merged fields
|
||||
def test_slot_row_contains_merged_fields():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req())
|
||||
for s in resp.payload["slots"]:
|
||||
assert "slot_id" in s
|
||||
assert "status" in s
|
||||
assert "symbol" in s
|
||||
assert "qty" in s
|
||||
assert "asset_quantity" in s
|
||||
assert "cash_balance" in s
|
||||
assert "cash_estimated" in s
|
||||
assert "warned" in s
|
||||
assert "watchdog" in s
|
||||
assert "paused" in s
|
||||
|
||||
|
||||
# REQ-074 AC-2: qty ≠ asset_quantity → warned=True
|
||||
def test_warned_true_when_qty_diverges():
|
||||
repo, svc, store, _, _, handler = _wire([
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
cash_balance=1_000_000, asset_quantity=3),
|
||||
])
|
||||
repo.reconcile_universe("default", {"pf-A"})
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
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["asset_quantity"] == 3.0
|
||||
assert s["warned"] is True
|
||||
|
||||
|
||||
# REQ-074 AC-4: paused holding slot → paused=True, filter=holding still returns it
|
||||
def test_paused_holding_orthogonal_to_filter():
|
||||
repo, svc, store, pause, _, handler = _wire()
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
pause.pause("pf-A")
|
||||
resp = handler(_req(filter="holding"))
|
||||
assert len(resp.payload["slots"]) == 1
|
||||
assert resp.payload["slots"][0]["paused"] is True
|
||||
|
||||
|
||||
# REQ-074 AC-5: strategy fields absent from row
|
||||
def test_strategy_fields_absent():
|
||||
_, _, _, _, _, handler = _wire()
|
||||
resp = handler(_req())
|
||||
for s in resp.payload["slots"]:
|
||||
assert "strategy_id" not in s
|
||||
assert "sell_strategy_id" not in s
|
||||
assert "strategy_params" not in s
|
||||
assert "active_strategy_id" not in s
|
||||
|
||||
|
||||
# REQ-073 AC-4: spy=True → watchdog NOT updated
|
||||
def test_spy_true_no_watchdog_update():
|
||||
repo, svc, _, _, watchdog, handler = _wire()
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
sid = str(holding.slot_id)
|
||||
assert watchdog.last_seen(sid) is None # not yet seen
|
||||
handler(_req(filter="holding", spy=True))
|
||||
assert watchdog.last_seen(sid) is None # still not updated (spy=True)
|
||||
|
||||
|
||||
# REQ-073 AC-4: spy=False (default) → holding watchdog updated
|
||||
def test_spy_false_updates_watchdog():
|
||||
repo, svc, _, _, watchdog, handler = _wire()
|
||||
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930")
|
||||
holding = svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=5.0)
|
||||
sid = str(holding.slot_id)
|
||||
handler(_req(filter="holding", spy=False))
|
||||
assert watchdog.last_seen(sid) is not None # updated
|
||||
@@ -1,95 +0,0 @@
|
||||
"""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)
|
||||
repo.reconcile_universe("default", {"pf-A"})
|
||||
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, max_active_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
|
||||
# BAT-022: `slots[]` additive operator-facing per-slot state.
|
||||
assert set(body.keys()) == {"assets", "view", "available_slots", "degraded",
|
||||
"reason", "message", "slots"}
|
||||
assert isinstance(body["assets"], list)
|
||||
assert isinstance(body["slots"], 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)
|
||||
repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"})
|
||||
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
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Snapshot RPC handler watchdog integration tests (REQ-034 AC-1 / BAT-010)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
def _make_req(trading_day):
|
||||
return RpcRequest(
|
||||
payload={"trading_date": trading_day.isoformat(), "view": "all"},
|
||||
correlation_id="test-corr",
|
||||
)
|
||||
|
||||
|
||||
def _buy_slot(service, repo, trading_day, symbol="005930"):
|
||||
"""Seed an asset slot + buy it into HOLDING state."""
|
||||
pid = f"pf-{symbol}"
|
||||
repo.reconcile_universe("default", {pid})
|
||||
slot = repo.find_slot_by_portfolio("default", pid)
|
||||
pending = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=10.0,
|
||||
)
|
||||
return holding
|
||||
|
||||
|
||||
class TestSnapshotRpcWatchdog:
|
||||
def test_holding_slot_watchdog_updated_on_snapshot_rpc(self, repo, service, trading_day):
|
||||
"""AC-1: snapshot RPC updates watchdog for each holding slot in response."""
|
||||
holding = _buy_slot(service, repo, trading_day)
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
assert str(holding.slot_id) in watchdog._store
|
||||
|
||||
def test_idle_slot_watchdog_not_updated(self, repo, service, trading_day):
|
||||
"""Only HOLDING slots update the watchdog — inactive slots are ignored."""
|
||||
repo.reconcile_universe("default", {"pf-X"})
|
||||
inactive = repo.find_slot_by_portfolio("default", "pf-X")
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
assert str(inactive.slot_id) not in watchdog._store
|
||||
|
||||
def test_no_watchdog_store_does_not_raise(self, repo, service, trading_day):
|
||||
"""Without watchdog_store= the handler works as before (backward compat)."""
|
||||
_buy_slot(service, repo, trading_day)
|
||||
handler = make_snapshot_rpc_handler(repo, service)
|
||||
resp = handler(_make_req(trading_day))
|
||||
assert resp is not None
|
||||
|
||||
def test_multiple_holding_slots_all_updated(self, repo, service, trading_day):
|
||||
"""All holding slots in the response have their watchdog updated."""
|
||||
slots = []
|
||||
for sym in ("A", "B", "C"):
|
||||
repo.reconcile_universe("default", {f"pf-{sym}"})
|
||||
slot = repo.find_slot_by_portfolio("default", f"pf-{sym}")
|
||||
p = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=sym,
|
||||
)
|
||||
h = service.transit_slot(
|
||||
p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=1.0,
|
||||
)
|
||||
slots.append(h)
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
for s in slots:
|
||||
assert str(s.slot_id) in watchdog._store, f"{s.slot_id} not in watchdog"
|
||||
@@ -1,182 +0,0 @@
|
||||
"""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)
|
||||
repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"})
|
||||
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_slot_by_portfolio("default", portfolio_id)
|
||||
assert s is not None
|
||||
svc.transit_slot(
|
||||
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy_intent", actor="t",
|
||||
symbol=symbol, 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()
|
||||
# BAT-022: `slots[]` is an additive operator-facing field alongside assets.
|
||||
assert set(d.keys()) == {"assets", "view", "available_slots", "degraded",
|
||||
"reason", "message", "slots"}
|
||||
assert isinstance(d["assets"], list)
|
||||
assert isinstance(d["slots"], list)
|
||||
# strategy-facing assets[] keep the strict no-price shape (REQ-028 AC-3).
|
||||
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"]
|
||||
@@ -25,137 +25,136 @@ class _RpcRecorder:
|
||||
return self.response
|
||||
|
||||
|
||||
_SNAPSHOT = {
|
||||
"assets": [{"asset_id": "PF-1", "symbol": "005930", "asset_quantity": 0,
|
||||
"active_strategy_id": "orb"}],
|
||||
"view": "all", "available_slots": 9, "degraded": False, "reason": None,
|
||||
# BAT-022: envelope per-slot state (operator-facing). `slots` queries read
|
||||
# this, not the universe `assets[]`.
|
||||
"slots": [
|
||||
{"slot_id": "S-1", "status": "holding",
|
||||
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
|
||||
"strategy_id": "orb", "sell_strategy_id": None,
|
||||
"portfolio_id": "PF-1", "forced_close_at": None},
|
||||
{"slot_id": "S-2", "status": "inactive",
|
||||
"symbol": None, "qty": None, "avg_price": None, "entry_price": None,
|
||||
"strategy_id": None, "sell_strategy_id": None,
|
||||
"portfolio_id": None, "forced_close_at": None},
|
||||
],
|
||||
_SLOT_HOLDING = {
|
||||
"slot_id": "S-1", "status": "holding", "symbol": "005930",
|
||||
"qty": 10.0, "asset_quantity": 10.0, "warned": False,
|
||||
"avg_price": 28700.0, "entry_price": None,
|
||||
"portfolio_id": "PF-1", "forced_close_at": None,
|
||||
"cash_balance": 1_000_000.0, "cash_estimated": False,
|
||||
"watchdog": {"expired": False, "last_seen": None},
|
||||
"paused": False, "universe_detached": False,
|
||||
}
|
||||
_SLOT_INACTIVE = {
|
||||
"slot_id": "S-2", "status": "inactive", "symbol": None,
|
||||
"qty": None, "asset_quantity": None, "warned": False,
|
||||
"avg_price": None, "entry_price": None,
|
||||
"portfolio_id": None, "forced_close_at": None,
|
||||
"cash_balance": None, "cash_estimated": False,
|
||||
"watchdog": None, "paused": False, "universe_detached": False,
|
||||
}
|
||||
_SLOTS_RESP = {
|
||||
"slots": [_SLOT_HOLDING, _SLOT_INACTIVE],
|
||||
"degraded": False, "reason": None, "message": None,
|
||||
}
|
||||
_ADMITTED = {"intent_id": "i", "status": "admitted", "slot_id": "S-1", "reason": None}
|
||||
_REJECTED = {"intent_id": "i", "status": "rejected", "slot_id": None,
|
||||
"reason": "unknown_portfolio"}
|
||||
|
||||
|
||||
# --- universe / slots (snapshot RPC + watchdog suppression) ------------------
|
||||
def test_universe_uses_snapshot_queue_with_operator_suppression(capsys):
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
# --- universe / slots (slots RPC + spy=True suppression) ---------------------
|
||||
def test_universe_uses_slots_queue_with_spy(capsys):
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
rc = cli.main(["universe"], rpc=rpc)
|
||||
assert rc == 0
|
||||
call = rpc.calls[0]
|
||||
assert call["queue"] == "trex.snapshot.req" # default instance
|
||||
assert call["payload"]["actor"] == "operator"
|
||||
assert call["payload"]["touch_watchdog"] is False # FR-2a / R-4
|
||||
assert "PF-1" in capsys.readouterr().out
|
||||
assert call["queue"] == "trex.slots.req" # default instance
|
||||
assert call["payload"]["spy"] is True # operator view — no watchdog touch
|
||||
assert call["payload"]["filter"] == "inactive"
|
||||
|
||||
|
||||
def test_universe_json_is_scoped_to_universe(capsys):
|
||||
"""BAT-022: `universe --json` emits universe data only — `slots[]`/`watchdog[]`
|
||||
(operator slot table) are NOT leaked into the universe view."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
def test_universe_json_emits_slots_and_degraded(capsys):
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
cli.main(["universe", "--json"], rpc=rpc)
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert set(out.keys()) == {"instance", "view", "assets", "available_slots",
|
||||
"degraded", "reason"}
|
||||
assert "slots" not in out and "watchdog" not in out
|
||||
assert "slots" in out and "degraded" in out and "filter" in out
|
||||
assert "assets" not in out and "available_slots" not in out
|
||||
|
||||
|
||||
def test_slots_shows_real_slot_state_not_universe(capsys):
|
||||
"""BAT-022: `slots` shows envelope `slots[]` (slot table) with status,
|
||||
not the universe `assets[]`."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
def test_slots_shows_slot_state(capsys):
|
||||
rpc = _RpcRecorder(_SLOTS_RESP)
|
||||
cli.main(["slots"], rpc=rpc)
|
||||
out = capsys.readouterr().out
|
||||
# request stays non-invasive (operator suppression)
|
||||
assert rpc.calls[0]["payload"]["actor"] == "operator"
|
||||
assert rpc.calls[0]["payload"]["touch_watchdog"] is False
|
||||
# both slots shown, with status column (slot state, not universe assets)
|
||||
# request uses slots.req + spy=True
|
||||
assert rpc.calls[0]["queue"] == "trex.slots.req"
|
||||
assert rpc.calls[0]["payload"]["spy"] is True
|
||||
assert "count=2" in out
|
||||
assert "S-1\tholding" in out and "S-2\tinactive" in out
|
||||
assert "S-1" in out and "S-2" in out
|
||||
|
||||
|
||||
def test_slots_status_filters_by_slot_status(capsys):
|
||||
"""BAT-022: `--status` filters by slot status client-side (no view mapping)."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
def test_slots_status_sends_server_side_filter(capsys):
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
cli.main(["slots", "--status", "holding"], rpc=rpc)
|
||||
assert rpc.calls[0]["payload"]["filter"] == "holding"
|
||||
out = capsys.readouterr().out
|
||||
assert "count=1" in out and "S-1\tholding" in out and "S-2" not in out
|
||||
assert "count=1" in out and "S-1" in out
|
||||
|
||||
|
||||
def test_slots_buyable_maps_to_inactive_filter():
|
||||
rpc = _RpcRecorder({"slots": [], "degraded": False, "reason": None, "message": None})
|
||||
cli.main(["slots", "--status", "buyable"], rpc=rpc)
|
||||
assert rpc.calls[0]["payload"]["filter"] == "inactive"
|
||||
|
||||
|
||||
def test_slots_symbol_filters_to_one_symbol(capsys):
|
||||
"""`--symbol` shows only the slot(s) bound to that symbol (client-side filter)."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder(_SLOTS_RESP)
|
||||
cli.main(["slots", "--symbol", "005930"], rpc=rpc)
|
||||
out = capsys.readouterr().out
|
||||
assert "count=1" in out and "S-1" in out and "S-2" not in out
|
||||
|
||||
|
||||
def test_slots_symbol_combines_with_status(capsys):
|
||||
"""`--symbol` composes with `--status` (both must match)."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_HOLDING], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
cli.main(["slots", "--status", "holding", "--symbol", "999999"], rpc=rpc)
|
||||
out = capsys.readouterr().out
|
||||
assert "count=0" in out # S-1 is holding but symbol != 999999
|
||||
assert "count=0" in out
|
||||
|
||||
|
||||
def test_slots_slot_id_filters_to_one_slot(capsys):
|
||||
"""`--slot-id` shows only the matching slot, in the detailed block."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder(_SLOTS_RESP)
|
||||
cli.main(["slots", "--slot-id", "S-2"], rpc=rpc)
|
||||
out = capsys.readouterr().out
|
||||
assert "count=1" in out and "slot S-2" in out and "S-1" not in out
|
||||
# detailed block fields present
|
||||
assert "portfolio_id" in out and "universe_detached" in out
|
||||
|
||||
|
||||
def test_slots_symbol_detailed_output(capsys):
|
||||
"""`--symbol` switches to the detailed per-slot block (not the terse table)."""
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder(_SLOTS_RESP)
|
||||
cli.main(["slots", "--symbol", "005930"], rpc=rpc)
|
||||
out = capsys.readouterr().out
|
||||
assert "slot S-1" in out and "status" in out and "avg_price" in out
|
||||
|
||||
|
||||
def test_slots_marks_watchdog_owned(capsys):
|
||||
"""dcy-017: slots flags strategy-absent (watchdog-owned) positions."""
|
||||
def test_slots_marks_watchdog_expired(capsys):
|
||||
"""REQ-034: slots flags watchdog-expired (strategy absent) positions."""
|
||||
resp = {
|
||||
"assets": [], "view": "all", "available_slots": 9, "degraded": False,
|
||||
"reason": None,
|
||||
"slots": [{"slot_id": "S-1",
|
||||
"status": "holding", "symbol": "005930", "qty": 10.0,
|
||||
"avg_price": 28700.0, "entry_price": None,
|
||||
"strategy_id": "orb",
|
||||
"sell_strategy_id": None, "portfolio_id": "PF-1",
|
||||
"forced_close_at": None}],
|
||||
"watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930",
|
||||
"expired": True, "last_seen": None}],
|
||||
"slots": [{
|
||||
**_SLOT_HOLDING,
|
||||
"watchdog": {"expired": True, "last_seen": None},
|
||||
}],
|
||||
"degraded": False, "reason": None, "message": None,
|
||||
}
|
||||
cli.main(["slots", "--status", "held"], rpc=_RpcRecorder(resp))
|
||||
cli.main(["slots", "--status", "holding"], rpc=_RpcRecorder(resp))
|
||||
out = capsys.readouterr().out
|
||||
assert "watchdog_owned=1" in out and "WATCHDOG-OWNED" in out
|
||||
assert "watchdog_expired=1" in out and "WATCHDOG" in out
|
||||
|
||||
|
||||
def test_instance_shortcut_routes_to_instance_queue():
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
cli.main(["universe", "--instance", "inst-2"], rpc=rpc)
|
||||
assert rpc.calls[0]["queue"] == "trex.snapshot.req.inst-2"
|
||||
assert rpc.calls[0]["queue"] == "trex.slots.req.inst-2"
|
||||
|
||||
|
||||
def test_json_output_is_valid_json(capsys):
|
||||
rpc = _RpcRecorder(_SNAPSHOT)
|
||||
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
|
||||
"reason": None, "message": None})
|
||||
cli.main(["universe", "--json"], rpc=rpc)
|
||||
parsed = json.loads(capsys.readouterr().out)
|
||||
assert parsed["assets"][0]["asset_id"] == "PF-1"
|
||||
assert "slots" in parsed
|
||||
|
||||
|
||||
# --- enter (buy intent RPC) + safeguards ------------------------------------
|
||||
|
||||
@@ -88,3 +88,30 @@ def test_gitignore_excludes_instance_toml():
|
||||
assert "/config/*" in gi
|
||||
assert "!/config/*.example.toml" in gi
|
||||
assert "!/config/.env.example" in gi
|
||||
|
||||
|
||||
# --- safety_sell_enabled (REQ-048 CHG-20260611-01, BAT-036) -----------------
|
||||
|
||||
def test_safety_sell_enabled_default_true(tmp_path, monkeypatch):
|
||||
"""AC-10: absent from TOML → safety_sell_enabled=True (default on)."""
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||
inst = DaemonInstanceConfig.from_toml(_write_toml(tmp_path, 'instance_key = "i3"\n'))
|
||||
assert inst.safety_sell_enabled is True
|
||||
cfg = DaemonConfig.from_config(_write_toml(tmp_path, 'instance_key = "i3"\n'))
|
||||
assert cfg.safety_sell_enabled is True
|
||||
|
||||
|
||||
def test_safety_sell_enabled_false_from_toml(tmp_path, monkeypatch):
|
||||
"""AC-10: safety_sell_enabled = false in TOML → DaemonConfig.safety_sell_enabled=False."""
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||
path = _write_toml(tmp_path, 'instance_key = "i4"\nsafety_sell_enabled = false\n')
|
||||
cfg = DaemonConfig.from_config(path)
|
||||
assert cfg.safety_sell_enabled is False
|
||||
|
||||
|
||||
def test_safety_sell_enabled_true_explicit_from_toml(tmp_path, monkeypatch):
|
||||
"""AC-10: safety_sell_enabled = true in TOML → DaemonConfig.safety_sell_enabled=True."""
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||
path = _write_toml(tmp_path, 'instance_key = "i5"\nsafety_sell_enabled = true\n')
|
||||
cfg = DaemonConfig.from_config(path)
|
||||
assert cfg.safety_sell_enabled is True
|
||||
|
||||
Reference in New Issue
Block a user