feat(slots): additive strategy fields + filter (REQ-116, BAT-055)
trex.slots.req wire 계약 additive 확장 — 기존 소비자(orb/tsl) backward compatible. - PortfolioItem: strategy_name(Optional[str]) + markets(list) 필드 추가 - hub_read.py: get_portfolios()에서 strategy_name/markets 파싱 추가 (safe default) - slot_to_dict(): strategy_name/strategy_params/markets 3필드 row에 추가 (SnapshotStore→PortfolioItem 경유) - make_slots_rpc_handler(): strategy 요청 파라미터 → Python 레벨 후처리 필터 (available_slots 계산 이후, REQ-073 AC-6 불변) - 테스트: AC-1~8 전수 + hub_read 파싱 + 회귀 갱신 (777 passed, 47 skipped) - docs/interface-contract.md: 요청 파라미터·응답 행 스키마·소스 테이블·REQ-074 AC-5 주석 갱신 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -65,6 +65,9 @@ def test_get_portfolios_parses_envelope_and_filters_by_daemon():
|
||||
# strategy fields absent on PortfolioItem -> graceful defaults
|
||||
assert items[0].active_strategy_id is None
|
||||
assert items[0].strategy_params == {}
|
||||
# REQ-116 (BAT-055): safe defaults when hub response has no strategy_name/markets
|
||||
assert items[0].strategy_name is None
|
||||
assert items[0].markets == []
|
||||
# no price field on the domain item
|
||||
for it in items:
|
||||
assert not hasattr(it, "price")
|
||||
@@ -380,3 +383,72 @@ def test_fetch_broker_credential_app_secret_not_in_logs(caplog):
|
||||
full_log = caplog.text
|
||||
# app_secret value must not appear in any log output
|
||||
assert "secret-value" not in full_log
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REQ-116 (BAT-055): strategy_name + markets parsing in get_portfolios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_portfolios_parses_strategy_name_and_markets():
|
||||
"""REQ-116 AC-1: hub provides strategy_name/markets → PortfolioItem carries them."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 1_000_000, "asset_quantity": 0,
|
||||
"managed_by_daemon_id": "d-7",
|
||||
"strategy_name": "tvr_v1",
|
||||
"strategy_params": {"target_weight": 0.05},
|
||||
"markets": ["KRX"]},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||
client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert len(items) == 1
|
||||
it = items[0]
|
||||
assert it.strategy_name == "tvr_v1"
|
||||
assert it.strategy_params == {"target_weight": 0.05}
|
||||
assert it.markets == ["KRX"]
|
||||
|
||||
|
||||
def test_get_portfolios_strategy_name_empty_string_becomes_none():
|
||||
"""REQ-116 AC-2: empty string strategy_name → None."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0,
|
||||
"strategy_name": ""},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].strategy_name is None
|
||||
|
||||
|
||||
def test_get_portfolios_markets_non_list_becomes_empty():
|
||||
"""REQ-116 AC-2: markets not a list (None, str, int) → []."""
|
||||
for bad_val in [None, "KRX", 42]:
|
||||
def handler(req: httpx.Request, _v=bad_val) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0,
|
||||
"markets": _v},
|
||||
], "total": 1})
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].markets == [], f"expected [] for markets={bad_val!r}"
|
||||
|
||||
|
||||
def test_get_portfolios_strategy_fields_absent_safe_default():
|
||||
"""REQ-116 AC-2: hub response has no strategy_name/markets keys → safe defaults."""
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"portfolios": [
|
||||
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||
"cash_balance": 0, "asset_quantity": 0},
|
||||
], "total": 1})
|
||||
|
||||
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||
items = adapter.get_portfolios("d-7")
|
||||
assert items[0].strategy_name is None
|
||||
assert items[0].markets == []
|
||||
assert items[0].strategy_params == {}
|
||||
|
||||
@@ -174,15 +174,20 @@ def test_paused_holding_orthogonal_to_filter():
|
||||
assert resp.payload["slots"][0]["paused"] is True
|
||||
|
||||
|
||||
# REQ-074 AC-5: strategy fields absent from row
|
||||
# REQ-074 AC-5 (REQ-116 additive 재도입): slot-origin strategy fields absent from row.
|
||||
# strategy_name / strategy_params / markets (PortfolioItem-origin) are now present
|
||||
# (REQ-116 BAT-055 additive); slot-origin fields still excluded.
|
||||
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
|
||||
# strategy_params / strategy_name / markets are now present (REQ-116 additive)
|
||||
assert "strategy_params" in s
|
||||
assert "strategy_name" in s
|
||||
assert "markets" in s
|
||||
|
||||
|
||||
# REQ-073 AC-4: spy=True → watchdog NOT updated
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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
|
||||
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"]
|
||||
@@ -58,12 +58,22 @@ def test_row_omits_strategy_state_when_absent():
|
||||
|
||||
|
||||
def test_no_strategy_reasoning_fields():
|
||||
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
|
||||
|
||||
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
|
||||
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
|
||||
"""
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="x", current_occupancy_seq=1, slot_active=True)
|
||||
row = _rows(handler)[0]
|
||||
for k in ("strategy_id", "sell_strategy_id", "strategy_params", "active_strategy_id"):
|
||||
# slot-origin fields must be absent (REQ-074 AC-5 unchanged)
|
||||
for k in ("strategy_id", "sell_strategy_id", "active_strategy_id"):
|
||||
assert k not in row
|
||||
# PortfolioItem-origin fields now present (REQ-116 additive)
|
||||
assert "strategy_params" in row # {} (safe default, no snapshot_store)
|
||||
assert "strategy_name" in row # None
|
||||
assert "markets" in row # []
|
||||
|
||||
|
||||
def test_reused_slot_shows_no_state():
|
||||
|
||||
Reference in New Issue
Block a user