Files
trex-core/tests/runtime/test_cli_operator.py
copilot-swe-agent[bot]andgenichin c73e382b70 Merge remote-tracking branch 'origin/main' into copilot/feat-reconcile-unmanaged-position-durable-lifecycl
# Conflicts:
#	.proofline/criteria/ac-0029.md
#	.proofline/identities.json
#	tests/adapters/persistence/test_migration_and_drift.py

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
2026-09-13 02:53:22 +00:00

927 lines
34 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, **_kwargs):
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_audit_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", "audit", "008110"])
assert args.unmanaged_cmd == "audit"
assert args.symbol == "008110"
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",
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):
monkeypatch.setattr(cli, "_operator_actor", lambda: "cli:test")
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",
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"
assert out["state"]["operator_actor"] == "cli:test"
def test_unmanaged_record_text_output(monkeypatch, capsys):
monkeypatch.setattr(cli, "_operator_actor", lambda: "cli:test")
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",
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
def test_unmanaged_record_main_dispatch(monkeypatch, capsys):
monkeypatch.setattr(cli, "_operator_actor", lambda: "cli:test")
class _Store:
def __init__(self, *_args, **_kwargs):
pass
def record_operator_action(self, **kwargs):
return {
"instance_key": "default",
"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,
)
rc = cli.main(
[
"unmanaged",
"record",
"008110",
"investigating",
"--evidence-ref",
"kis-balance:2026-09-11",
"--json",
]
)
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["result"] == "recorded"
assert out["state"]["symbol"] == "008110"
def test_unmanaged_audit_outputs_lifecycle_and_operator_history(monkeypatch, capsys):
class _Store:
def __init__(self, *_args, **_kwargs):
pass
def get_state(self, **kwargs):
assert kwargs == {"instance_key": "inst-1", "symbol": "008110"}
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": "cli:test",
"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",
}
def list_audit(self, **kwargs):
assert kwargs == {"instance_key": "inst-1", "symbol": "008110"}
return [
{
"event_type": "detected",
"recorded_at": "2026-09-07T00:00:00+00:00",
"quantity": 3,
"action": None,
"actor": "reconcile",
"evidence_ref": None,
"note": "",
},
{
"event_type": "operator_action",
"recorded_at": "2026-09-11T00:00:00+00:00",
"quantity": 3,
"action": "investigating",
"actor": "cli:test",
"evidence_ref": "kis-balance:2026-09-11",
"note": "confirmed",
},
]
monkeypatch.setattr(
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
_Store,
)
args = SimpleNamespace(
cmd="unmanaged",
unmanaged_cmd="audit",
symbol="008110",
instance="inst-1",
config=None,
config_path=None,
env_file=None,
json=True,
timeout=10.0,
)
rc = cli._cmd_unmanaged_audit(args, SimpleNamespace(dsn="postgresql://x/y"))
assert rc == 0
out = json.loads(capsys.readouterr().out)
assert out["symbol"] == "008110"
assert out["state"]["operator_actor"] == "cli:test"
assert [row["event_type"] for row in out["audit"]] == ["detected", "operator_action"]
# ---------------------------------------------------------------------------
# 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"
)