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:
2026-06-26 09:53:49 +09:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ced7eda682
commit 07b393d27d
19 changed files with 1948 additions and 39 deletions
+269 -1
View File
@@ -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)