feat(core-B): 운영자 sync/verify CLI + cold-start onboarding (BAT-051, REQ-105/106, PR #32)
dcy-034 마지막 조각. sync(슬롯 qty←브로커+warning해제+OQ-A 전이엣지+FR-7 평단seed)/verify(C 재대조·강제해제없음). core-A QC minor 2건 정리 동봉. QC(opus) PASS, 683 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
+269
-1
@@ -1,4 +1,4 @@
|
||||
"""trex-core operator CLI (REQ-032, dcy-016).
|
||||
"""trex-core operator CLI (REQ-032, dcy-016; REQ-105/106, dcy-034).
|
||||
|
||||
Subcommands:
|
||||
pause <portfolio_id> — durably pause a portfolio (Postgres, direct-DB)
|
||||
@@ -9,6 +9,8 @@ Subcommands:
|
||||
enter <portfolio_id> — submit a buy intent (intent RPC; safeguarded)
|
||||
exit <slot_id> — submit a sell intent (intent RPC; safeguarded)
|
||||
cancel <slot_id> — cancel a pending order (control RPC; safeguarded)
|
||||
sync — sync slot qty to broker ground truth (direct-DB; REQ-105)
|
||||
verify — re-check C-case reconcile-warning (direct-DB; REQ-106)
|
||||
|
||||
universe/slots/enter/exit go through the daemon's RabbitMQ RPC surface so the
|
||||
single-writer admission + invariants are preserved (no direct slot writes,
|
||||
@@ -19,6 +21,11 @@ enter/exit safeguards (dcy-016 OQ-2): confirmation prompt ON by default;
|
||||
`--yes` skips it (paper); live accounts require `--yes --live` (double ack);
|
||||
`--dry-run` previews the resolved intent without submitting. `--json` is a
|
||||
non-interactive machine mode, so enter/exit with `--json` require `--yes`.
|
||||
|
||||
sync safeguards (REQ-105): `--yes` required (skip confirmation prompt);
|
||||
live accounts require `--yes --live` (double ack); `--dry-run` previews
|
||||
before/after without writing. sync is direct-DB (no RPC).
|
||||
verify (REQ-106): read-centric (no --yes/--live gate needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -402,9 +409,262 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
_add_common(p_report)
|
||||
|
||||
# REQ-105 (dcy-034 / BAT-051): sync — slot qty ← broker (direct-DB).
|
||||
p_sync = sub.add_parser(
|
||||
"sync",
|
||||
help="Sync slot qty to broker ground truth (direct-DB; REQ-105)",
|
||||
)
|
||||
target_grp = p_sync.add_mutually_exclusive_group()
|
||||
target_grp.add_argument(
|
||||
"--portfolio-id", dest="portfolio_id", default=None,
|
||||
help="target a single portfolio (slot must exist)",
|
||||
)
|
||||
target_grp.add_argument(
|
||||
"--all", action="store_true",
|
||||
help="sync all warned + inactive slots for this instance",
|
||||
)
|
||||
p_sync.add_argument("--yes", action="store_true", help="skip confirmation (paper)")
|
||||
p_sync.add_argument("--live", action="store_true",
|
||||
help="confirm a LIVE sync (with --yes)")
|
||||
p_sync.add_argument("--dry-run", dest="dry_run", action="store_true",
|
||||
help="preview before/after without writing")
|
||||
_add_common(p_sync)
|
||||
|
||||
# REQ-106 (dcy-034 / BAT-051): verify — C-case re-check (read-centric, direct-DB).
|
||||
p_verify = sub.add_parser(
|
||||
"verify",
|
||||
help="Re-check C-case reconcile-warning (read-centric, direct-DB; REQ-106)",
|
||||
)
|
||||
p_verify.add_argument(
|
||||
"--portfolio-id", dest="portfolio_id", default=None,
|
||||
help="target a single portfolio (omit for all C-warned slots)",
|
||||
)
|
||||
_add_common(p_verify)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _cmd_sync(args, cfg, confirm) -> int:
|
||||
"""REQ-105 (dcy-034, BAT-051): sync slot qty to broker ground truth."""
|
||||
import psycopg
|
||||
|
||||
from trex_core.adapters.portfolio.reconcile_warning_store import (
|
||||
PostgreSQLReconcileWarningStore,
|
||||
)
|
||||
from trex_core.application.reconciliation import sync_slot_to_broker
|
||||
|
||||
if not args.portfolio_id and not args.all:
|
||||
_fail("--portfolio-id <pid> or --all is required", args.json)
|
||||
return 1
|
||||
|
||||
broker, is_live = _broker_mode(cfg)
|
||||
instance = _resolve_instance(args)
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
|
||||
# live double-ack gate
|
||||
if not dry_run:
|
||||
if is_live and not getattr(args, "live", False):
|
||||
_fail("live account requires --live (with --yes) to confirm a real sync", args.json)
|
||||
return 2
|
||||
if not getattr(args, "yes", False):
|
||||
if args.json:
|
||||
_fail("--json sync requires --yes (no auto-submit)", True)
|
||||
return 2
|
||||
prompt = (f"Sync slot(s) to broker on instance={instance} "
|
||||
f"broker={broker}{' LIVE' if is_live else ''}?")
|
||||
if not confirm(prompt):
|
||||
print("aborted")
|
||||
return 1
|
||||
|
||||
warn_store = PostgreSQLReconcileWarningStore(cfg.dsn)
|
||||
|
||||
# Build broker port
|
||||
from trex_core.runtime.daemon import _build_broker
|
||||
broker_port = _build_broker(cfg)
|
||||
|
||||
# Build slot_repo + slot_service
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.domain.slot.service import SlotService
|
||||
|
||||
slot_repo = PostgreSQLSlotRepository(cfg.dsn)
|
||||
slot_service = SlotService(slot_repo, instance)
|
||||
|
||||
# Optional cost_basis_port (graceful degrade when not configured)
|
||||
cost_basis_port = None
|
||||
if cfg.portfolio_hub and cfg.portfolio_hub.base_url:
|
||||
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||
cost_basis_port = PortfolioHubReadAdapter(
|
||||
cfg.portfolio_hub.base_url, token=cfg.portfolio_hub.token
|
||||
)
|
||||
|
||||
# Determine target slot_ids
|
||||
if args.portfolio_id:
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id FROM daily_slot "
|
||||
"WHERE instance_key = %s AND portfolio_id = %s LIMIT 1",
|
||||
(instance, args.portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
_fail(f"no slot found for portfolio_id={args.portfolio_id} instance={instance}", args.json)
|
||||
return 1
|
||||
slot_ids = [str(row[0])]
|
||||
else:
|
||||
# --all: target all warned slots (B case) plus all inactive slots with broker holding
|
||||
warned = warn_store.list_warned()
|
||||
with psycopg.connect(cfg.dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id FROM daily_slot "
|
||||
"WHERE instance_key = %s AND status IN ('inactive', 'holding')",
|
||||
(instance,),
|
||||
)
|
||||
all_ids = {str(r[0]) for r in cur.fetchall()}
|
||||
slot_ids = sorted(all_ids | warned)
|
||||
|
||||
results = []
|
||||
exit_code = 0
|
||||
for sid in slot_ids:
|
||||
res = sync_slot_to_broker(
|
||||
slot_id=sid,
|
||||
slot_repo=slot_repo,
|
||||
slot_service=slot_service,
|
||||
broker=broker_port,
|
||||
instance_key=instance,
|
||||
warn_store=warn_store,
|
||||
cost_basis_port=cost_basis_port,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
results.append(res)
|
||||
if res.get("error") and not res.get("skipped"):
|
||||
exit_code = 1
|
||||
|
||||
if args.json:
|
||||
print(_json.dumps({"instance": instance, "dry_run": dry_run, "results": results},
|
||||
ensure_ascii=False, default=str))
|
||||
else:
|
||||
for res in results:
|
||||
sid = res["slot_id"]
|
||||
sym = res.get("symbol", "")
|
||||
pid = res.get("portfolio_id", "")
|
||||
before = res.get("before", {})
|
||||
after = res.get("after", {})
|
||||
skipped = res.get("skipped", False)
|
||||
skip_reason = res.get("skip_reason", "")
|
||||
cleared = res.get("warning_cleared", False)
|
||||
prefix = "[dry-run] " if dry_run else ""
|
||||
if skipped:
|
||||
print(f" {prefix}SKIP slot={sid} symbol={sym} pid={pid} reason={skip_reason}")
|
||||
else:
|
||||
print(f" {prefix}SYNC slot={sid} symbol={sym} pid={pid}")
|
||||
print(f" before: status={before.get('status')} "
|
||||
f"qty={before.get('qty')} avg_price={before.get('avg_price')}")
|
||||
print(f" after: status={after.get('status')} "
|
||||
f"qty={after.get('qty')} avg_price={after.get('avg_price')}")
|
||||
if cleared:
|
||||
print(f" reconcile-warning CLEARED")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def _cmd_verify(args, cfg) -> int:
|
||||
"""REQ-106 (dcy-034, BAT-051): re-check C-case reconcile-warning (read-centric)."""
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.adapters.portfolio.reconcile_warning_store import (
|
||||
PostgreSQLReconcileWarningStore,
|
||||
)
|
||||
from trex_core.application.portfolio import SnapshotStore
|
||||
from trex_core.application.reconciliation import verify_slot_reconcile
|
||||
|
||||
instance = _resolve_instance(args)
|
||||
warn_store = PostgreSQLReconcileWarningStore(cfg.dsn)
|
||||
slot_repo = PostgreSQLSlotRepository(cfg.dsn)
|
||||
|
||||
from trex_core.adapters.broker import make_broker
|
||||
broker_port = make_broker(cfg)
|
||||
|
||||
# Build a minimal snapshot store (we only need asset_quantity for C-case check).
|
||||
# For verify we use a fresh SnapshotStore populated from warn_store metadata.
|
||||
# The hub_qty is read from the snapshot; if snapshot is empty we fall back to 0
|
||||
# and the mismatch will surface as "rejected" (correct: re-check fails until
|
||||
# portfolio-hub CLI is run and snapshot is refreshed). This is acceptable for
|
||||
# operator CLI verify (not a real-time path).
|
||||
store = SnapshotStore()
|
||||
|
||||
# Populate store from portfolio-hub if configured (best-effort).
|
||||
if cfg.portfolio_hub and cfg.portfolio_hub.base_url:
|
||||
try:
|
||||
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||
hub = PortfolioHubReadAdapter(cfg.portfolio_hub.base_url, token=cfg.portfolio_hub.token)
|
||||
daemon_id = hub.whoami()
|
||||
items = hub.get_portfolios(daemon_id)
|
||||
store.swap(items)
|
||||
except Exception: # noqa: BLE001 — degrade: verify with empty store
|
||||
pass
|
||||
|
||||
if args.portfolio_id:
|
||||
target_pids = [args.portfolio_id]
|
||||
else:
|
||||
# All C-warned slots
|
||||
c_slots: list[str] = []
|
||||
for sid in warn_store.list_warned():
|
||||
info = warn_store.get_warning_info(sid)
|
||||
if info and info.get("case_code") == "C":
|
||||
c_slots.append(info.get("portfolio_id", ""))
|
||||
target_pids = [p for p in c_slots if p]
|
||||
|
||||
if not target_pids:
|
||||
msg = "no C-warned slots found"
|
||||
if args.json:
|
||||
print(_json.dumps({"instance": instance, "results": [], "message": msg}))
|
||||
else:
|
||||
print(msg)
|
||||
return 0
|
||||
|
||||
results = []
|
||||
exit_code = 0
|
||||
for pid in target_pids:
|
||||
res = verify_slot_reconcile(
|
||||
portfolio_id=pid,
|
||||
store=store,
|
||||
broker=broker_port,
|
||||
slot_repo=slot_repo,
|
||||
instance_key=instance,
|
||||
warn_store=warn_store,
|
||||
)
|
||||
results.append(res)
|
||||
if res["result"] == "rejected":
|
||||
exit_code = 1
|
||||
|
||||
if args.json:
|
||||
print(_json.dumps({"instance": instance, "results": results},
|
||||
ensure_ascii=False, default=str))
|
||||
else:
|
||||
for res in results:
|
||||
pid = res["portfolio_id"]
|
||||
sid = res.get("slot_id")
|
||||
result = res["result"]
|
||||
hub_qty = res.get("hub_qty")
|
||||
broker_qty = res.get("broker_qty")
|
||||
reason = res.get("reason", "")
|
||||
if result == "cleared":
|
||||
print(f" CLEARED portfolio_id={pid} slot={sid} "
|
||||
f"hub_qty={hub_qty} broker_qty={broker_qty}")
|
||||
elif result == "rejected":
|
||||
print(f" REJECTED portfolio_id={pid} slot={sid} — {reason}",
|
||||
file=sys.stderr)
|
||||
elif result == "not_c_case":
|
||||
print(f" INFO portfolio_id={pid} slot={sid} — {reason}")
|
||||
elif result == "not_warned":
|
||||
print(f" OK portfolio_id={pid} slot={sid} — already cleared")
|
||||
else:
|
||||
print(f" {result.upper()} portfolio_id={pid} slot={sid} — {reason}")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def _cmd_report(args, cfg) -> int:
|
||||
"""REQ-088: holdings / today's fills / realized PnL / pending — direct-DB."""
|
||||
import psycopg
|
||||
@@ -479,6 +739,14 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
|
||||
if args.cmd == "report":
|
||||
return _cmd_report(args, cfg)
|
||||
|
||||
# REQ-105 (BAT-051): sync — direct-DB, slot qty ← broker.
|
||||
if args.cmd == "sync":
|
||||
return _cmd_sync(args, cfg, confirm)
|
||||
|
||||
# REQ-106 (BAT-051): verify — direct-DB, C-case re-check (read-centric).
|
||||
if args.cmd == "verify":
|
||||
return _cmd_verify(args, cfg)
|
||||
|
||||
# direct-DB control commands (pause/resume stay direct-DB, OQ-3).
|
||||
if args.cmd in ("pause", "resume", "paused"):
|
||||
store = PostgreSQLPauseStore(cfg.dsn)
|
||||
|
||||
Reference in New Issue
Block a user