feat(rel-006): dcy-022/023 + BAT-032~034 — 실행 정책·qty 검증·allocated_capital 제거 통합

BAT-032 (dcy-022): timeout_sec/slippage_pct 실행 정책, 미체결 자동 취소,
보호 지정가, `trex-core cancel` 백스톱. 마이그레이션 012.
BAT-033: `trex-core enter --qty` 필수 검증 — 미지정 시 RPC 전 즉시 오류.
BAT-034 (dcy-023): allocated_capital 죽은 필드 전 경로 제거 (domain·intent·
persistence·snapshot·CLI). 마이그레이션 013(비가역 DROP). 외부 발신자
unknown key 무시 수신 의미론 적용.
REL-006 confirmed: BAT-026~034 통합 릴리즈, pre-release smoke 증거 포함.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:42:03 +09:00
co-authored by Claude Sonnet 4.6
parent 5ee5f7f4e2
commit fb05a73130
56 changed files with 3151 additions and 103 deletions
+84 -14
View File
@@ -34,11 +34,11 @@ _SNAPSHOT = {
"slots": [
{"slot_id": "S-1", "status": "holding",
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
"allocated_capital": None, "strategy_id": "orb", "sell_strategy_id": 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,
"allocated_capital": None, "strategy_id": None, "sell_strategy_id": None,
"strategy_id": None, "sell_strategy_id": None,
"portfolio_id": None, "forced_close_at": None},
],
}
@@ -134,7 +134,7 @@ def test_slots_marks_watchdog_owned(capsys):
"slots": [{"slot_id": "S-1",
"status": "holding", "symbol": "005930", "qty": 10.0,
"avg_price": 28700.0, "entry_price": None,
"allocated_capital": None, "strategy_id": "orb",
"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",
@@ -161,26 +161,45 @@ def test_json_output_is_valid_json(capsys):
# --- enter (buy intent RPC) + safeguards ------------------------------------
def test_enter_aborts_without_confirmation_no_rpc():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000"],
rc = cli.main(["enter", "PF-1", "--qty", "10"],
rpc=rpc, confirm=lambda _p: False)
assert rc == 1 and rpc.calls == [] # aborted -> no order
def test_enter_without_qty_errors_before_rpc(capsys):
# REQ-032 AC-7 (CHG-20260610-06, issue #23): missing --qty → rc=1, no RPC.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc)
assert rc == 1 and rpc.calls == []
assert "--qty" in capsys.readouterr().err
def test_enter_rejects_capital_option(capsys):
# REQ-032 AC-8 (CHG-20260610-07, dcy-023/issue #22): --capital is gone —
# argparse rejects it as an unknown argument before any RPC.
rpc = _RpcRecorder(_ADMITTED)
with pytest.raises(SystemExit) as exc:
cli.main(["enter", "PF-1", "--qty", "10", "--capital", "100000", "--yes"], rpc=rpc)
assert exc.value.code == 2 and rpc.calls == []
assert "--capital" in capsys.readouterr().err
def test_enter_with_yes_submits_buy_intent():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 0
p = rpc.calls[0]["payload"]
assert rpc.calls[0]["queue"] == "trex.intent.req"
assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1"
assert p["allocated_capital"] == 100000
assert p["qty"] == 10
assert "allocated_capital" not in p # dcy-023 / REQ-032 AC-8
assert "symbol" not in p # server-resolved (dcy-011)
def test_enter_with_forced_close_at_maps_to_payload():
# REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
"--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc)
assert rc == 0
p = rpc.calls[0]["payload"]
@@ -190,7 +209,7 @@ def test_enter_with_forced_close_at_maps_to_payload():
def test_enter_without_forced_close_at_omits_field():
# REQ-032 AC-6: unspecified → field absent (backward compat).
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 0
assert "forced_close_at" not in rpc.calls[0]["payload"]
@@ -198,27 +217,27 @@ def test_enter_without_forced_close_at_omits_field():
def test_enter_rejects_malformed_forced_close_at_before_submit():
# REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted.
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
"--forced-close-at", "notatime"], rpc=rpc)
assert rc == 1 and rpc.calls == []
def test_enter_rejected_returns_nonzero():
rpc = _RpcRecorder(_REJECTED)
rc = cli.main(["enter", "PF-x", "--yes"], rpc=rpc)
rc = cli.main(["enter", "PF-x", "--qty", "10", "--yes"], rpc=rpc)
assert rc == 1
def test_enter_dry_run_does_not_submit(capsys):
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--capital", "5", "--dry-run"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "5", "--dry-run"], rpc=rpc)
assert rc == 0 and rpc.calls == []
assert "dry-run" in capsys.readouterr().out
def test_enter_json_requires_yes():
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--json"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--json"], rpc=rpc)
assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7)
@@ -241,14 +260,14 @@ def test_exit_market_when_no_price():
def test_live_requires_live_flag(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc) # --yes but no --live
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) # --yes but no --live
assert rc == 2 and rpc.calls == []
def test_live_proceeds_with_yes_live(monkeypatch):
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
rpc = _RpcRecorder(_ADMITTED)
rc = cli.main(["enter", "PF-1", "--yes", "--live"], rpc=rpc)
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--live"], rpc=rpc)
assert rc == 0 and len(rpc.calls) == 1
@@ -259,3 +278,54 @@ def test_rpc_failure_surfaces_error(capsys):
rc = cli.main(["universe"], rpc=boom)
assert rc == 3
assert "daemon unreachable" in capsys.readouterr().err
# --- cancel (control RPC operator backstop — dcy-022 / REQ-071) -------------
_CANCEL_OK = {"status": "ok", "slot_id": "S-pending"}
_CANCEL_IGNORED = {"status": "ignored", "slot_id": "S-1", "reason": "not_pending"}
_CANCEL_NOT_FOUND = {"status": "error", "slot_id": "S-x", "reason": "slot_not_found"}
def test_cancel_aborts_without_confirmation_no_rpc():
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending"], rpc=rpc, confirm=lambda _p: False)
assert rc == 1 and rpc.calls == []
def test_cancel_with_yes_sends_control_rpc(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--yes"], rpc=rpc)
assert rc == 0
call = rpc.calls[0]
assert call["queue"] == "trex.control.req"
assert call["payload"]["action"] == "cancel_pending"
assert call["payload"]["slot_id"] == "S-pending"
assert "cancel sent" in capsys.readouterr().out
def test_cancel_ignored_not_pending_exits_zero(capsys):
rpc = _RpcRecorder(_CANCEL_IGNORED)
rc = cli.main(["cancel", "S-1", "--yes"], rpc=rpc)
assert rc == 0
assert "ignored" in capsys.readouterr().out
def test_cancel_slot_not_found_exits_nonzero(capsys):
rpc = _RpcRecorder(_CANCEL_NOT_FOUND)
rc = cli.main(["cancel", "S-x", "--yes"], rpc=rpc)
assert rc == 1
def test_cancel_dry_run_does_not_submit(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--dry-run"], rpc=rpc)
assert rc == 0 and rpc.calls == []
assert "dry-run" in capsys.readouterr().out
def test_cancel_json_outputs_json(capsys):
rpc = _RpcRecorder(_CANCEL_OK)
rc = cli.main(["cancel", "S-pending", "--yes", "--json"], rpc=rpc)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["status"] == "ok"