"""REQ-116 AC-1~8: strategy 필드 노출 + strategy 필터 테스트 (BAT-055). AC coverage: AC-1: strategy fields present and correct when hub provides them AC-2: safe defaults when hub does not provide strategy fields AC-3: strategy filter — matching AC-4: strategy filter — absent (identical to pre-REQ-116 behaviour) AC-5: strategy + status AND combination AC-6: available_slots invariant (not affected by strategy filter) AC-7: orb/tsl regression — no-strategy request returns unchanged key set + values AC-8: contract test — strategy fields present in slot row (EXPECTED row keys) """ 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 ( 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 # --------------------------------------------------------------------------- helpers def _make_item(portfolio_id, symbol="005930", **kwargs) -> PortfolioItem: defaults = dict( cash_balance=1_000_000, asset_quantity=0, strategy_name=None, markets=[], strategy_params={}, ) defaults.update(kwargs) return PortfolioItem(portfolio_id=portfolio_id, symbol=symbol, **defaults) def _wire_strategy(items=None, *, universe=None, max_active_slots=None): """Build a handler with an optional universe + snapshot.""" repo = InMemorySlotRepository() svc = SlotService(repo) uni = universe or {"pf-A": "SYM", "pf-B": "SYM"} repo.reconcile_universe("default", uni) store = SnapshotStore() store.swap(items or [ _make_item("pf-A"), _make_item("pf-B"), ]) pause = InMemoryPauseStore() watchdog = WatchdogStore() handler = make_slots_rpc_handler( repo, snapshot_store=store, pause_store=pause, watchdog_store=watchdog, max_active_slots=max_active_slots, ) return repo, svc, store, pause, watchdog, handler def _req(filter=None, spy=False, strategy=None): payload: dict = {} if filter is not None: payload["filter"] = filter if spy: payload["spy"] = True else: payload["strategy_instance_id"] = "test-instance" if strategy is not None: payload["strategy"] = strategy return RpcRequest(payload=payload, correlation_id="test-corr") # --------------------------------------------------------------------------- AC-1 def test_ac1_strategy_fields_present_when_hub_provides_them(): """AC-1: hub returns strategy_name/strategy_params/markets → slot row contains them.""" items = [ _make_item("pf-A", strategy_name="tvr_v1", strategy_params={"target_weight": 0.05}, markets=["KRX"]), _make_item("pf-B"), ] _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req()) rows = {r["portfolio_id"]: r for r in resp.payload["slots"]} row_a = rows["pf-A"] assert row_a["strategy_name"] == "tvr_v1" assert row_a["strategy_params"] == {"target_weight": 0.05} assert row_a["markets"] == ["KRX"] # --------------------------------------------------------------------------- AC-2 def test_ac2_safe_defaults_when_hub_does_not_provide_strategy_fields(): """AC-2: hub returns no strategy fields → strategy_name=null, strategy_params={}, markets=[].""" items = [_make_item("pf-A")] # no strategy_name / markets / strategy_params _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req()) row = resp.payload["slots"][0] assert row["strategy_name"] is None assert row["strategy_params"] == {} assert row["markets"] == [] def test_ac2_no_snapshot_store_uses_safe_defaults(): """AC-2 variant: snapshot_store=None → safe defaults, daemon does not crash.""" repo = InMemorySlotRepository() repo.reconcile_universe("default", {"pf-A": "SYM"}) handler = make_slots_rpc_handler(repo) # no snapshot_store resp = handler(_req()) row = resp.payload["slots"][0] assert row["strategy_name"] is None assert row["strategy_params"] == {} assert row["markets"] == [] # --------------------------------------------------------------------------- AC-3 def test_ac3_strategy_filter_matching(): """AC-3: strategy="tvr_v1" → only tvr_v1 slots returned.""" items = [ _make_item("pf-A", strategy_name="tvr_v1"), _make_item("pf-B", strategy_name="orb_v1"), ] _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req(strategy="tvr_v1")) rows = resp.payload["slots"] assert len(rows) == 1 assert rows[0]["portfolio_id"] == "pf-A" assert rows[0]["strategy_name"] == "tvr_v1" def test_ac3_strategy_filter_no_match_returns_empty(): """AC-3 edge: strategy value matches nothing → slots=[].""" items = [_make_item("pf-A", strategy_name="orb_v1")] _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req(strategy="nonexistent")) assert resp.payload["slots"] == [] # --------------------------------------------------------------------------- AC-4 def test_ac4_no_strategy_param_returns_all_slots(): """AC-4: strategy absent → all slots returned, no filter applied.""" items = [ _make_item("pf-A", strategy_name="tvr_v1"), _make_item("pf-B", strategy_name="orb_v1"), ] _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req()) # no strategy param assert len(resp.payload["slots"]) == 2 def test_ac4_response_keys_unchanged_without_strategy_param(): """AC-4: response key set unchanged when strategy not provided (additive only).""" _, _, _, _, _, handler = _wire_strategy() resp = handler(_req()) assert set(resp.payload.keys()) == {"slots", "available_slots", "degraded", "reason", "message"} # --------------------------------------------------------------------------- AC-5 def test_ac5_strategy_and_status_filter_and_combination(): """AC-5: {filter:"holding", strategy:"tvr_v1"} → only tvr_v1 + holding slots.""" items = [ _make_item("pf-A", strategy_name="tvr_v1"), _make_item("pf-B", strategy_name="tvr_v1"), _make_item("pf-C", strategy_name="orb_v1"), ] repo, svc, _, _, _, handler = _wire_strategy( items, universe={"pf-A": "SYM", "pf-B": "SYM", "pf-C": "SYM"}, ) # Put 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) # pf-B stays inactive; pf-C stays inactive resp = handler(_req(filter="holding", strategy="tvr_v1")) rows = resp.payload["slots"] assert len(rows) == 1 assert rows[0]["portfolio_id"] == "pf-A" assert rows[0]["status"] == "holding" assert rows[0]["strategy_name"] == "tvr_v1" def test_ac5_strategy_and_status_no_match_returns_empty(): """AC-5: AND combination yields empty when no slot satisfies both conditions.""" items = [ _make_item("pf-A", strategy_name="tvr_v1"), ] _, _, _, _, _, handler = _wire_strategy(items) # tvr_v1 exists but filter=holding: no holding slots resp = handler(_req(filter="holding", strategy="tvr_v1")) assert resp.payload["slots"] == [] # --------------------------------------------------------------------------- AC-6 def test_ac6_available_slots_invariant_with_strategy_filter(): """AC-6: available_slots = instance-wide (M - active), not affected by strategy filter.""" items = [ _make_item("pf-A", strategy_name="tvr_v1"), _make_item("pf-B", strategy_name="orb_v1"), ] repo, svc, _, _, _, handler = _wire_strategy(items, max_active_slots=10) # Put pf-A into holding (1 active overall) 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) # strategy="tvr_v1" matches 1 slot (pf-A holding), but available_slots = 10-1 = 9 resp_filtered = handler(_req(strategy="tvr_v1")) resp_all = handler(_req()) assert resp_filtered.payload["available_slots"] == 9 assert resp_all.payload["available_slots"] == 9 # Filtered slots differ but available_slots is the same assert len(resp_filtered.payload["slots"]) == 1 assert len(resp_all.payload["slots"]) == 2 # --------------------------------------------------------------------------- AC-7 def test_ac7_orb_tsl_regression_no_strategy_param(): """AC-7: existing orb/tsl-style request (no strategy param) unchanged behaviour. Verifies that existing mandatory fields are present and that strategy_name / strategy_params / markets are additive (unknown fields, safe to ignore). """ items = [ _make_item("pf-A", strategy_name="orb_v1", strategy_params={"risk": 0.01}, markets=["KRX"]), ] # universe matches items (single slot pf-A only) _, _, _, _, _, handler = _wire_strategy( items, universe={"pf-A": "SYM"}, ) # Legacy request: no strategy param resp = handler(_req(filter="all", spy=True)) rows = resp.payload["slots"] assert len(rows) == 1 row = rows[0] # Existing fields must still be present with correct types mandatory = { "slot_id", "status", "symbol", "qty", "avg_price", "entry_price", "portfolio_id", "forced_close_at", "universe_detached", "occupancy_seq", "asset_quantity", "cash_balance", "cash_estimated", "warned", "watchdog", "paused", "reconcile_warned", } for key in mandatory: assert key in row, f"mandatory field {key!r} missing from slot row" # Slot-origin strategy fields must NOT be present assert "strategy_id" not in row assert "sell_strategy_id" not in row assert "active_strategy_id" not in row # REQ-116 additive fields ARE present (unknown to old consumers, safe to ignore) assert "strategy_name" in row assert "strategy_params" in row assert "markets" in row # --------------------------------------------------------------------------- AC-8 def test_ac8_contract_test_strategy_fields_in_slot_row(): """AC-8: slot row schema includes strategy_name, strategy_params, markets.""" items = [ _make_item("pf-A", strategy_name="tvr_v1", strategy_params={"target_weight": 0.05}, markets=["KRX", "NASDAQ"]), ] _, _, _, _, _, handler = _wire_strategy(items) resp = handler(_req()) row = resp.payload["slots"][0] # AC-8: new fields present in row schema assert "strategy_name" in row assert "strategy_params" in row assert "markets" in row # Values correct assert row["strategy_name"] == "tvr_v1" assert row["strategy_params"] == {"target_weight": 0.05} assert row["markets"] == ["KRX", "NASDAQ"]