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:
@@ -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