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:
2026-06-11 14:45:45 +09:00
co-authored by Claude Opus 4.8
parent fb05a73130
commit f0ad37747b
44 changed files with 1963 additions and 1189 deletions
@@ -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)
+2 -1
View File
@@ -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()
+15 -9
View File
@@ -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
+212
View File
@@ -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"]
+70 -71
View File
@@ -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 ------------------------------------
+27
View File
@@ -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