Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
800 lines
29 KiB
Python
800 lines
29 KiB
Python
"""Operator CLI tests (dcy-016 / REQ-032) — RPC injected, no broker/DB needed."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import types
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from trex_core.runtime import cli
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _env(monkeypatch):
|
|
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
|
monkeypatch.setenv("TREX_BROKER", "paper")
|
|
monkeypatch.delenv("TREX_KIS_ALLOW_LIVE", raising=False)
|
|
|
|
|
|
class _RpcRecorder:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
self.calls = []
|
|
|
|
def __call__(self, queue, payload, *, correlation_id, timeout):
|
|
self.calls.append({"queue": queue, "payload": payload, "cid": correlation_id})
|
|
return self.response
|
|
|
|
|
|
_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 (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.slots.req" # default instance
|
|
assert call["payload"]["spy"] is True # operator view — no watchdog touch
|
|
assert call["payload"]["filter"] == "inactive"
|
|
|
|
|
|
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 "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_slot_state(capsys):
|
|
rpc = _RpcRecorder(_SLOTS_RESP)
|
|
cli.main(["slots"], rpc=rpc)
|
|
out = capsys.readouterr().out
|
|
# 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" in out and "S-2" in out
|
|
|
|
|
|
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" 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):
|
|
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):
|
|
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
|
|
|
|
|
|
def test_slots_slot_id_filters_to_one_slot(capsys):
|
|
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
|
|
assert "portfolio_id" in out and "universe_detached" in out
|
|
|
|
|
|
def test_slots_symbol_detailed_output(capsys):
|
|
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_expired(capsys):
|
|
"""REQ-034: slots flags watchdog-expired (strategy absent) positions."""
|
|
resp = {
|
|
"slots": [{
|
|
**_SLOT_HOLDING,
|
|
"watchdog": {"expired": True, "last_seen": None},
|
|
}],
|
|
"degraded": False, "reason": None, "message": None,
|
|
}
|
|
cli.main(["slots", "--status", "holding"], rpc=_RpcRecorder(resp))
|
|
out = capsys.readouterr().out
|
|
assert "watchdog_expired=1" in out and "WATCHDOG" in out
|
|
|
|
|
|
def test_instance_shortcut_routes_to_instance_queue():
|
|
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.slots.req.inst-2"
|
|
|
|
|
|
def test_json_output_is_valid_json(capsys):
|
|
rpc = _RpcRecorder({"slots": [_SLOT_INACTIVE], "degraded": False,
|
|
"reason": None, "message": None})
|
|
cli.main(["universe", "--json"], rpc=rpc)
|
|
parsed = json.loads(capsys.readouterr().out)
|
|
assert "slots" in parsed
|
|
|
|
|
|
# --- enter (buy intent RPC) + safeguards ------------------------------------
|
|
def test_enter_aborts_without_confirmation_no_rpc():
|
|
rpc = _RpcRecorder(_ADMITTED)
|
|
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", "--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["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", "--qty", "10", "--yes",
|
|
"--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc)
|
|
assert rc == 0
|
|
p = rpc.calls[0]["payload"]
|
|
assert p["forced_close_at"] == "2026-06-09T15:20:00+09:00"
|
|
|
|
|
|
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", "--qty", "10", "--yes"], rpc=rpc)
|
|
assert rc == 0
|
|
assert "forced_close_at" not in rpc.calls[0]["payload"]
|
|
|
|
|
|
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", "--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", "--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", "--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", "--qty", "10", "--json"], rpc=rpc)
|
|
assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7)
|
|
|
|
|
|
# --- exit (sell intent RPC) -------------------------------------------------
|
|
def test_exit_with_yes_submits_sell_with_limit_price():
|
|
rpc = _RpcRecorder(_ADMITTED)
|
|
cli.main(["exit", "S-1", "--price", "70000", "--yes"], rpc=rpc)
|
|
p = rpc.calls[0]["payload"]
|
|
assert p["intent_type"] == "sell" and p["slot_id"] == "S-1"
|
|
assert p["limit_price"] == 70000
|
|
|
|
|
|
def test_exit_market_when_no_price():
|
|
rpc = _RpcRecorder(_ADMITTED)
|
|
cli.main(["exit", "S-1", "--yes"], rpc=rpc)
|
|
assert rpc.calls[0]["payload"]["limit_price"] is None
|
|
|
|
|
|
# --- live guard (OQ-2 double ack) -------------------------------------------
|
|
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", "--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", "--qty", "10", "--yes", "--live"], rpc=rpc)
|
|
assert rc == 0 and len(rpc.calls) == 1
|
|
|
|
|
|
# --- transport failure surfaces as a clear error ----------------------------
|
|
def test_rpc_failure_surfaces_error(capsys):
|
|
def boom(*a, **k):
|
|
raise RuntimeError("daemon unreachable")
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sync parser tests (REQ-105 / BAT-051) — parser-level only, no DB/broker
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sync_parser_portfolio_id_accepted():
|
|
"""sync --portfolio-id <pid> is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--yes"])
|
|
assert args.cmd == "sync"
|
|
assert args.portfolio_id == "PF-1"
|
|
assert args.yes is True
|
|
|
|
|
|
def test_sync_parser_all_accepted():
|
|
"""sync --all is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["sync", "--all", "--yes"])
|
|
assert args.cmd == "sync"
|
|
assert args.all is True
|
|
|
|
|
|
def test_sync_parser_dry_run_accepted():
|
|
"""sync --dry-run is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--dry-run"])
|
|
assert args.dry_run is True
|
|
|
|
|
|
def test_sync_parser_live_accepted():
|
|
"""sync --yes --live is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["sync", "--portfolio-id", "PF-1", "--yes", "--live"])
|
|
assert args.live is True
|
|
|
|
|
|
def test_sync_no_target_fails(capsys, monkeypatch):
|
|
"""sync without --portfolio-id or --all returns rc=1."""
|
|
def _fake_sync(args, cfg, confirm):
|
|
from trex_core.runtime.cli import _fail
|
|
_fail("--portfolio-id <pid> or --all is required", args.json)
|
|
return 1
|
|
monkeypatch.setattr(cli, "_cmd_sync", _fake_sync)
|
|
rc = cli.main(["sync"])
|
|
assert rc == 1
|
|
|
|
|
|
def test_sync_requires_yes_when_not_dry_run(capsys, monkeypatch):
|
|
"""sync without --yes (and not dry-run) should abort."""
|
|
def _fake_sync(args, cfg, confirm):
|
|
# Simulate the confirmation prompt returning False (no --yes, interactive aborts)
|
|
if (
|
|
not getattr(args, "dry_run", False)
|
|
and not getattr(args, "yes", False)
|
|
and not confirm("Sync?")
|
|
):
|
|
print("aborted")
|
|
return 1
|
|
return 0
|
|
monkeypatch.setattr(cli, "_cmd_sync", _fake_sync)
|
|
rc = cli.main(["sync", "--portfolio-id", "PF-1"], confirm=lambda _: False)
|
|
assert rc == 1
|
|
|
|
|
|
def test_sync_live_requires_live_flag(capsys, monkeypatch):
|
|
"""sync on live account without --live returns rc=2."""
|
|
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
|
|
|
|
def _fake_sync(args, cfg, confirm):
|
|
_broker, is_live = cli._broker_mode(cfg)
|
|
if is_live and not getattr(args, "live", False):
|
|
cli._fail("live account requires --live (with --yes) to confirm a real sync", args.json)
|
|
return 2
|
|
return 0
|
|
monkeypatch.setattr(cli, "_cmd_sync", _fake_sync)
|
|
rc = cli.main(["sync", "--portfolio-id", "PF-1", "--yes"])
|
|
assert rc == 2
|
|
|
|
|
|
@pytest.mark.parametrize("as_json", [False, True])
|
|
def test_sync_fail_closed_skipped_error_exits_nonzero_without_writes(
|
|
monkeypatch, capsys, as_json,
|
|
):
|
|
"""REQ-123: a fail-closed skipped live sync is a CLI failure."""
|
|
class _Provider:
|
|
def get_broker_for_read(self):
|
|
return SimpleNamespace(get_balance=list), None
|
|
|
|
class _WarningStore:
|
|
def clear_warned(self, *_args):
|
|
raise AssertionError("warning clear must not be called")
|
|
|
|
class _Connection:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return False
|
|
|
|
def cursor(self):
|
|
return self
|
|
|
|
def execute(self, *_args):
|
|
return None
|
|
|
|
def fetchone(self):
|
|
return ("slot-1",)
|
|
|
|
sync_calls = []
|
|
|
|
def _fail_closed_sync(**kwargs):
|
|
sync_calls.append(kwargs)
|
|
return {
|
|
"slot_id": "slot-1", "symbol": "005930", "portfolio_id": "PF-1",
|
|
"before": {"status": "inactive", "qty": None, "avg_price": None},
|
|
"after": {"status": "inactive", "qty": None, "avg_price": None},
|
|
"warning_cleared": False, "dry_run": False, "skipped": True,
|
|
"skip_reason": "cost_basis_unavailable", "seeded_avg_price": False,
|
|
"cost_basis_source": "none", "cost_basis_reason": "transport",
|
|
"error": "cost_basis_unavailable",
|
|
}
|
|
|
|
psycopg = types.ModuleType("psycopg")
|
|
psycopg.connect = lambda *_args, **_kwargs: _Connection()
|
|
monkeypatch.setitem(sys.modules, "psycopg", psycopg)
|
|
monkeypatch.setattr("trex_core.runtime.broker_provider.build_broker_provider", lambda _cfg: _Provider())
|
|
monkeypatch.setattr("trex_core.adapters.portfolio.reconcile_warning_store.PostgreSQLReconcileWarningStore", lambda *_args: _WarningStore())
|
|
monkeypatch.setattr("trex_core.adapters.persistence.postgresql.PostgreSQLSlotRepository", lambda *_args: object())
|
|
monkeypatch.setattr("trex_core.domain.slot.service.SlotService", lambda *_args: object())
|
|
monkeypatch.setattr("trex_core.application.reconciliation.sync_slot_to_broker", _fail_closed_sync)
|
|
|
|
args = SimpleNamespace(portfolio_id="PF-1", all=False, json=as_json, dry_run=False,
|
|
yes=True, live=False, instance=None, config=None, config_path=None)
|
|
rc = cli._cmd_sync(args, SimpleNamespace(dsn="dsn", broker="paper", kis=None, portfolio_hub=None), lambda _: True)
|
|
|
|
assert rc == 1
|
|
assert len(sync_calls) == 1
|
|
assert sync_calls[0]["dry_run"] is False
|
|
captured = capsys.readouterr()
|
|
if as_json:
|
|
result = json.loads(captured.out)["results"][0]
|
|
assert result["error"] == "cost_basis_unavailable"
|
|
assert result["skipped"] is True
|
|
else:
|
|
assert "SKIP" in captured.out
|
|
assert "error=cost_basis_unavailable" in captured.out
|
|
assert "CLEARED" not in captured.out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify parser tests (REQ-106 / BAT-051) — parser-level only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_verify_parser_portfolio_id_accepted():
|
|
"""verify --portfolio-id <pid> is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["verify", "--portfolio-id", "PF-1"])
|
|
assert args.cmd == "verify"
|
|
assert args.portfolio_id == "PF-1"
|
|
|
|
|
|
def test_verify_parser_no_portfolio_id_accepted():
|
|
"""verify (no --portfolio-id) is parseable (all C-warned slots)."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["verify"])
|
|
assert args.cmd == "verify"
|
|
assert args.portfolio_id is None
|
|
|
|
|
|
def test_verify_parser_json_accepted():
|
|
"""verify --json is parseable."""
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["verify", "--json"])
|
|
assert args.json is True
|
|
|
|
|
|
def test_verify_no_db_no_live_flag_needed(capsys, monkeypatch):
|
|
"""verify does not require --yes or --live (read-centric, REQ-106)."""
|
|
def _fake_verify(args, cfg):
|
|
# Simulate: read-centric, no live gate needed
|
|
print("no C-warned slots found")
|
|
return 0
|
|
monkeypatch.setattr(cli, "_cmd_verify", _fake_verify)
|
|
rc = cli.main(["verify"])
|
|
assert rc == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# unmanaged parser/tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_unmanaged_parser_accepts_list_and_record():
|
|
parser = cli._build_parser()
|
|
args = parser.parse_args(["unmanaged", "list", "--status", "all", "--symbol", "008110"])
|
|
assert args.cmd == "unmanaged"
|
|
assert args.unmanaged_cmd == "list"
|
|
assert args.status == "all"
|
|
args = parser.parse_args(
|
|
[
|
|
"unmanaged",
|
|
"record",
|
|
"008110",
|
|
"investigating",
|
|
"--evidence-ref",
|
|
"kis-balance:2026-09-11",
|
|
]
|
|
)
|
|
assert args.unmanaged_cmd == "record"
|
|
assert args.action == "investigating"
|
|
|
|
|
|
def test_unmanaged_list_outputs_durable_state(monkeypatch, capsys):
|
|
class _Store:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def list_states(self, **kwargs):
|
|
assert kwargs["instance_key"] == "inst-1"
|
|
assert kwargs["lifecycle_state"] == "all"
|
|
return [
|
|
{
|
|
"instance_key": "inst-1",
|
|
"symbol": "008110",
|
|
"quantity": 3,
|
|
"lifecycle_state": "active",
|
|
"first_seen_at": "2026-09-07T00:00:00+00:00",
|
|
"last_seen_at": "2026-09-11T00:00:00+00:00",
|
|
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
|
|
"resolved_at": None,
|
|
"operator_action": "investigating",
|
|
"evidence_ref": "kis-balance:2026-09-11",
|
|
"operator_note": "confirmed",
|
|
"operator_actor": "operator",
|
|
"operator_updated_at": "2026-09-11T00:00:00+00:00",
|
|
"created_at": "2026-09-07T00:00:00+00:00",
|
|
"updated_at": "2026-09-11T00:00:00+00:00",
|
|
}
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
|
|
_Store,
|
|
)
|
|
args = SimpleNamespace(
|
|
cmd="unmanaged",
|
|
unmanaged_cmd="list",
|
|
status="all",
|
|
symbol=None,
|
|
instance="inst-1",
|
|
config=None,
|
|
config_path=None,
|
|
env_file=None,
|
|
json=True,
|
|
timeout=10.0,
|
|
)
|
|
|
|
rc = cli._cmd_unmanaged_list(args, SimpleNamespace(dsn="postgresql://x/y"))
|
|
|
|
assert rc == 0
|
|
out = json.loads(capsys.readouterr().out)
|
|
assert out["count"] == 1
|
|
assert out["instance_key"] == "inst-1"
|
|
assert out["states"][0]["symbol"] == "008110"
|
|
assert out["states"][0]["evidence_ref"] == "kis-balance:2026-09-11"
|
|
|
|
|
|
def test_unmanaged_record_requires_existing_state(monkeypatch, capsys):
|
|
class _Store:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def record_operator_action(self, **_kwargs):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
|
|
_Store,
|
|
)
|
|
args = SimpleNamespace(
|
|
cmd="unmanaged",
|
|
unmanaged_cmd="record",
|
|
symbol="008110",
|
|
action="approved_exception",
|
|
evidence_ref="kis-balance:2026-09-11",
|
|
note="confirmed",
|
|
actor="operator",
|
|
instance="inst-1",
|
|
config=None,
|
|
config_path=None,
|
|
env_file=None,
|
|
json=True,
|
|
timeout=10.0,
|
|
)
|
|
|
|
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
|
|
|
|
assert rc == 1
|
|
err = json.loads(capsys.readouterr().err)
|
|
assert "not found" in err["error"]
|
|
|
|
|
|
def test_unmanaged_record_persists_operator_action(monkeypatch, capsys):
|
|
class _Store:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def record_operator_action(self, **kwargs):
|
|
assert kwargs["instance_key"] == "inst-1"
|
|
assert kwargs["symbol"] == "008110"
|
|
assert kwargs["action"] == "managed_inclusion"
|
|
return {
|
|
"instance_key": "inst-1",
|
|
"symbol": "008110",
|
|
"quantity": 3,
|
|
"lifecycle_state": "active",
|
|
"first_seen_at": "2026-09-07T00:00:00+00:00",
|
|
"last_seen_at": "2026-09-11T00:00:00+00:00",
|
|
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
|
|
"resolved_at": None,
|
|
"operator_action": "managed_inclusion",
|
|
"evidence_ref": kwargs["evidence_ref"],
|
|
"operator_note": kwargs["note"],
|
|
"operator_actor": kwargs["actor"],
|
|
"operator_updated_at": "2026-09-11T00:00:00+00:00",
|
|
"created_at": "2026-09-07T00:00:00+00:00",
|
|
"updated_at": "2026-09-11T00:00:00+00:00",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
|
|
_Store,
|
|
)
|
|
args = SimpleNamespace(
|
|
cmd="unmanaged",
|
|
unmanaged_cmd="record",
|
|
symbol="008110",
|
|
action="managed_inclusion",
|
|
evidence_ref="kis-balance:2026-09-11",
|
|
note="manual inclusion planned",
|
|
actor="operator",
|
|
instance="inst-1",
|
|
config=None,
|
|
config_path=None,
|
|
env_file=None,
|
|
json=True,
|
|
timeout=10.0,
|
|
)
|
|
|
|
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
|
|
|
|
assert rc == 0
|
|
out = json.loads(capsys.readouterr().out)
|
|
assert out["result"] == "recorded"
|
|
assert out["state"]["operator_action"] == "managed_inclusion"
|
|
assert out["state"]["evidence_ref"] == "kis-balance:2026-09-11"
|
|
|
|
|
|
def test_unmanaged_record_text_output(monkeypatch, capsys):
|
|
class _Store:
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def record_operator_action(self, **kwargs):
|
|
return {
|
|
"instance_key": "inst-1",
|
|
"symbol": kwargs["symbol"],
|
|
"quantity": 3,
|
|
"lifecycle_state": "active",
|
|
"first_seen_at": "2026-09-07T00:00:00+00:00",
|
|
"last_seen_at": "2026-09-11T00:00:00+00:00",
|
|
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
|
|
"resolved_at": None,
|
|
"operator_action": kwargs["action"],
|
|
"evidence_ref": kwargs["evidence_ref"],
|
|
"operator_note": kwargs["note"],
|
|
"operator_actor": kwargs["actor"],
|
|
"operator_updated_at": "2026-09-11T00:00:00+00:00",
|
|
"created_at": "2026-09-07T00:00:00+00:00",
|
|
"updated_at": "2026-09-11T00:00:00+00:00",
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
|
|
_Store,
|
|
)
|
|
args = SimpleNamespace(
|
|
cmd="unmanaged",
|
|
unmanaged_cmd="record",
|
|
symbol="008110",
|
|
action="approved_exception",
|
|
evidence_ref="kis-balance:2026-09-11",
|
|
note="confirmed",
|
|
actor="operator",
|
|
instance="inst-1",
|
|
config=None,
|
|
config_path=None,
|
|
env_file=None,
|
|
json=False,
|
|
timeout=10.0,
|
|
)
|
|
|
|
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
|
|
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "recorded unmanaged position instance=inst-1 symbol=008110" in out
|
|
assert "evidence_ref=kis-balance:2026-09-11" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# REL-010 regression: _cmd_verify broker import must not raise ImportError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_verify_cmd_broker_import_does_not_raise(monkeypatch):
|
|
"""REL-010 regression: _cmd_verify must use _build_broker (not missing
|
|
make_broker) so it does not raise ImportError at the broker-build step.
|
|
|
|
Patch all DB/network calls so only the import path is exercised.
|
|
Paper broker config is used (no real network needed).
|
|
"""
|
|
from trex_core.runtime.config import DaemonConfig
|
|
|
|
cfg = DaemonConfig(
|
|
dsn="postgresql://x/y",
|
|
broker="paper",
|
|
execution_interval_sec=1.0,
|
|
instance_key="test",
|
|
)
|
|
|
|
# Patch at module level — _cmd_verify uses local `from ... import` so we
|
|
# must patch the source module, not the cli namespace.
|
|
import trex_core.adapters.persistence.postgresql as pg_mod
|
|
import trex_core.adapters.portfolio.reconcile_warning_store as rws_mod
|
|
from trex_core.application.portfolio import InMemoryReconcileWarningStore
|
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
|
|
|
monkeypatch.setattr(rws_mod, "PostgreSQLReconcileWarningStore",
|
|
lambda dsn: InMemoryReconcileWarningStore())
|
|
monkeypatch.setattr(pg_mod, "PostgreSQLSlotRepository",
|
|
lambda dsn: InMemorySlotRepository())
|
|
|
|
# Build args object with no portfolio_id (triggers "no C-warned slots" path).
|
|
import argparse
|
|
args = argparse.Namespace(
|
|
instance=None,
|
|
portfolio_id=None,
|
|
json=False,
|
|
)
|
|
|
|
# Must not raise ImportError — this is the regression guard.
|
|
rc = cli._cmd_verify(args, cfg)
|
|
|
|
# With no C-warned slots and paper broker the function returns 0.
|
|
assert rc == 0
|
|
|
|
|
|
def test_verify_cmd_uses_build_broker_not_make_broker():
|
|
"""Structural check: trex_core.adapters.broker has no 'make_broker' symbol.
|
|
This ensures the ImportError from REL-010 is permanently absent.
|
|
"""
|
|
import trex_core.adapters.broker as broker_pkg
|
|
assert not hasattr(broker_pkg, "make_broker"), (
|
|
"make_broker must not exist in trex_core.adapters.broker — "
|
|
"use _build_broker from trex_core.runtime.daemon instead"
|
|
)
|