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
+27 -1
View File
@@ -1,4 +1,4 @@
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4).
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4; REQ-105 FR-7 cost-basis).
Matches the live portfolio-hub HTTP contract:
GET /v1/user_info -> UserInfoResponse {"daemon_id": "...", ...}
@@ -119,3 +119,29 @@ class PortfolioHubReadAdapter:
strategy_params=dict(raw.get("strategy_params") or {}),
))
return out
def get_cost_basis(self, portfolio_id: str) -> "Optional[float]":
"""REQ-105 FR-7: GET /v1/portfolios/{id}/holdings/cost-basis -> avg_cost (WAC).
Returns None on any error (graceful degrade — caller logs WARN and skips
avg_price seed). portfolio-hub REQ-043 must be live for this to succeed.
"""
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/holdings/cost-basis"
try:
resp = self._get(url)
except Exception: # noqa: BLE001 — network/timeout — degrade
return None
if not (200 <= resp.status_code < 300):
return None
try:
body = resp.json()
except ValueError:
return None
if isinstance(body, dict):
val = body.get("avg_cost") or body.get("wac") or body.get("cost_basis")
if val is not None:
try:
return float(val)
except (TypeError, ValueError):
return None
return None
@@ -67,3 +67,22 @@ class PostgreSQLReconcileWarningStore:
with conn.cursor() as cur:
cur.execute("SELECT slot_id FROM slot_reconcile_warning")
return {row[0] for row in cur.fetchall()}
def get_warning_info(self, slot_id: str) -> dict | None:
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None (REQ-105/106)."""
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT portfolio_id, case_code, actor, reason "
"FROM slot_reconcile_warning WHERE slot_id = %s LIMIT 1",
(slot_id,),
)
row = cur.fetchone()
if row is None:
return None
return {
"portfolio_id": row[0],
"case_code": row[1],
"actor": row[2],
"reason": row[3],
}
-17
View File
@@ -185,23 +185,6 @@ class IntentService:
return IntentDecision(intent_id, "rejected", None, "duplicate_in_flight")
return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason)
def _check_reconcile_warned(self, slot_id) -> Optional[IntentDecision]:
"""BAT-050 / REQ-104: return rejected decision if slot is reconcile-warned.
Returns None if not warned (gate passes). The warn_store check is best-effort:
if the store raises, we allow the intent through (fail-open for resilience).
Safety-sell / kill-switch / watchdog bypass this gate — they call
SlotService.sell() directly without going through IntentService (OQ-B).
"""
if self._warn_store is None:
return None
try:
if self._warn_store.is_warned(str(slot_id)):
return None # placeholder — intent_id unknown here; caller provides it
except Exception: # noqa: BLE001 - fail-open
pass
return None # not warned (or store error — fail-open)
def _admit_buy(self, intent: BuyIntent) -> IntentDecision:
"""dcy-020 / REQ-059·REQ-061 + dcy-021/REQ-066: activate or size up the
asset's permanent slot.
+14
View File
@@ -278,6 +278,15 @@ class ReconcileWarningStore(Protocol):
def list_warned(self) -> "set[str]":
...
def get_warning_info(self, slot_id: str) -> "Optional[dict]":
"""Return {"portfolio_id", "case_code", "actor", "reason"} or None.
Used by verify to determine whether the warning is case_code='C'
(verify's responsibility) vs 'B' (sync's responsibility, REQ-106 OQ-C).
Returns None when the slot has no active warning.
"""
...
class InMemoryReconcileWarningStore:
"""Process-local reconcile-warning store for tests + CI (REQ-102)."""
@@ -316,6 +325,11 @@ class InMemoryReconcileWarningStore:
with self._lock:
return set(self._warned.keys())
def get_warning_info(self, slot_id: str) -> "Optional[dict]":
with self._lock:
info = self._warned.get(slot_id)
return dict(info) if info is not None else None
def slot_to_dict(
s,
+17
View File
@@ -47,3 +47,20 @@ class IntentRecord:
status: str
slot_id: Optional[UUID]
reason: Optional[str]
class PortfolioHubCostBasisPort(Protocol):
"""FR-7 (dcy-034 / REQ-105): portfolio-hub WAC (weighted average cost) read port.
Wraps `GET /v1/portfolios/{id}/holdings/cost-basis` from portfolio-hub REQ-043.
When unavailable or when the portfolio has no holding, implementations return None.
sync() degrades gracefully: None => skip avg_price seed + WARN log.
"""
def get_cost_basis(self, portfolio_id: str) -> Optional[float]:
"""Return the weighted average cost (WAC) for the portfolio's current holding.
Returns None when the portfolio has no holding, the endpoint is unavailable,
or any error occurs. The caller must handle None as a graceful-degrade signal.
"""
...
+343 -8
View File
@@ -1,4 +1,4 @@
"""Position reconciliation between portfolio-hub and broker (REQ-031, REQ-103).
"""Position reconciliation between portfolio-hub and broker (REQ-031, REQ-103/105/106).
The broker account is single (invariant B) so portfolio-hub quantities are
aggregated to `symbol` before being compared with `BrokerPort.get_balance()`.
@@ -6,10 +6,19 @@ Mismatches flip the asset's `warning` overlay; a subsequent successful match
clears it automatically (in-memory only — pause is the durable channel for
manual holds).
BAT-050 / REQ-103: B-case (slot qty mismatch) and C-case (ledger mismatch)
now also set a durable reconcile-warning via ReconcileWarningStore (slot_id
keyed). The in-memory warning overlay is kept for backward compat; the durable
store is additive and Optional (None => warn-log-only, backward compat).
BAT-050 / REQ-103: B-case (slot qty != broker qty) and C-case (ledger/hub qty
!= broker qty) now also set a durable reconcile-warning via ReconcileWarningStore
(slot_id keyed). The in-memory warning overlay is kept for backward compat; the
durable store is additive and Optional (None => warn-log-only, backward compat).
REQ-103 case_code values in slot_reconcile_warning:
'B' — slot has a holding but slot.qty differs from broker qty
(set by reconcile_core_holdings)
'C' — portfolio-hub (ledger) qty differs from broker qty
(set by reconcile_positions)
BAT-051 / REQ-105: sync_slot_to_broker — operator CLI sync path.
BAT-051 / REQ-106: verify_slot_reconcile — operator CLI verify path (C-only, read-centric).
"""
from __future__ import annotations
@@ -42,9 +51,11 @@ def reconcile_core_holdings(
- C. both hold but qty differs (B in REQ-103 naming) -> WARN + durable reconcile-warning
- B. broker holds, no core holding slot -> WARN (unmanaged position)
REQ-103 case codes:
- case_code='B': slot has holding but qty differs from broker (reconcile_core_holdings)
- case_code='C': ledger/hub qty differs from broker (reconcile_positions)
REQ-103 case_code values (stored in slot_reconcile_warning.case_code):
- case_code='B': slot has a holding but slot.qty differs from broker qty
→ set by THIS function (reconcile_core_holdings)
- case_code='C': portfolio-hub (ledger) qty differs from broker qty
→ set by reconcile_positions
warn_store: Optional — when None, WARN-log-only (backward compat, REQ-103 AC-5).
Returns {key: 'error'|'qty_warning'|'ok'|'unmanaged_warning'} for telemetry.
@@ -192,3 +203,327 @@ def reconcile_positions(
else:
decisions[pid] = "ok"
return decisions
# ---------------------------------------------------------------------------
# BAT-051 / REQ-105: operator sync command — slot qty ← broker
# ---------------------------------------------------------------------------
def sync_slot_to_broker(
*,
slot_id: str,
slot_repo,
slot_service,
broker: BrokerPort,
instance_key: str,
warn_store: "ReconcileWarningStore",
cost_basis_port=None, # Optional[PortfolioHubCostBasisPort]
dry_run: bool = False,
) -> dict:
"""Sync one slot's qty to the broker ground truth (REQ-105, BAT-051).
Operator-only path (actor="sync"). No silent auto-fix — caller must confirm
before invoking (CLI --yes gate). Returns a summary dict for before/after output.
INACTIVE + broker qty > 0 → INACTIVE→HOLDING transition (OQ-A actor=sync).
HOLDING + qty mismatch → qty field updated to broker qty.
PENDING_* → skipped (in-flight, do not touch).
Broker qty == 0 for slot → skipped with WARN (slot-side A-case guard).
Broker-only (no slot) → NOT handled here; caller filters (FR-3).
After update, if slot.qty == broker_qty: clear_warned(slot_id).
"""
from uuid import UUID
try:
slot_uuid = UUID(slot_id)
except ValueError:
return {"slot_id": slot_id, "error": "invalid_slot_id", "skipped": True}
slot = slot_repo.fetch_slot(slot_uuid)
if slot is None:
return {"slot_id": slot_id, "error": "slot_not_found", "skipped": True}
sym = slot.symbol or ""
pid = slot.portfolio_id or ""
# Fetch broker balance for this symbol
broker_qty = 0
for item in broker.get_balance():
if item.symbol == sym:
broker_qty = int(item.hldg_qty)
break
before = {
"qty": float(slot.qty) if slot.qty is not None else None,
"status": slot.status.value,
"avg_price": float(slot.avg_price) if slot.avg_price is not None else None,
}
if broker_qty == 0:
_log.warning(
"sync skipped: broker_qty=0 for slot=%s symbol=%s (A-case guard — "
"use SlotService.transit_slot or manual review)",
slot_id, sym,
)
return {
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
"before": before,
"after": before,
"warning_cleared": False,
"dry_run": dry_run,
"skipped": True,
"skip_reason": "broker_qty_zero",
}
from trex_core.domain.slot.state import SlotState
if slot.status in (SlotState.PENDING_BUY, SlotState.PENDING_SELL):
_log.warning(
"sync skipped: slot=%s symbol=%s status=%s in-flight — "
"cannot sync while order is pending",
slot_id, sym, slot.status.value,
)
return {
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
"before": before,
"after": before,
"warning_cleared": False,
"dry_run": dry_run,
"skipped": True,
"skip_reason": f"status_in_flight:{slot.status.value}",
}
# Try to seed avg_price from portfolio-hub cost-basis port (FR-7, graceful degrade).
avg_price = slot.avg_price
seeded_avg_price = False
if cost_basis_port is not None and pid:
try:
wac = cost_basis_port.get_cost_basis(pid)
if wac is not None:
avg_price = wac
seeded_avg_price = True
except Exception: # noqa: BLE001 — graceful degrade
_log.warning(
"sync: cost_basis_port.get_cost_basis failed for portfolio_id=%s slot=%s "
"— skipping avg_price seed (graceful degrade)",
pid, slot_id,
)
elif cost_basis_port is None and pid:
_log.warning(
"sync: no cost_basis_port configured for slot=%s portfolio_id=%s "
"— skipping avg_price seed (graceful degrade)",
slot_id, pid,
)
if dry_run:
# Compute what *would* happen without writing anything.
new_status = SlotState.HOLDING if slot.status is SlotState.INACTIVE else slot.status
after = {
"qty": float(broker_qty),
"status": new_status.value,
"avg_price": float(avg_price) if avg_price is not None else None,
}
return {
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
"before": before,
"after": after,
"warning_cleared": False, # dry-run never clears
"dry_run": True,
"skipped": False,
"seeded_avg_price": seeded_avg_price,
}
# --- Live write ---
try:
if slot.status is SlotState.INACTIVE:
# OQ-A: INACTIVE→HOLDING transition (actor=sync, reason=onboarding).
slot_service.transit_slot(
slot_uuid,
SlotState.INACTIVE,
SlotState.HOLDING,
reason="onboarding",
actor="sync",
qty=float(broker_qty),
avg_price=avg_price,
portfolio_id=pid or None,
symbol=sym or None,
)
else:
# HOLDING: update qty (and optionally avg_price) in place.
# Use update_slot directly to avoid a state-machine transition.
from dataclasses import replace as _replace
updated = _replace(slot, qty=float(broker_qty), avg_price=avg_price)
slot_repo.update_slot(updated)
_log.info(
"sync updated slot qty: slot=%s symbol=%s qty %s→%d actor=sync",
slot_id, sym, slot.qty, broker_qty,
)
except Exception: # noqa: BLE001
_log.exception("sync_slot_to_broker: write failed for slot=%s", slot_id)
return {
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
"before": before, "after": before,
"warning_cleared": False,
"dry_run": False,
"skipped": False,
"error": "write_failed",
}
# Refresh to confirm
refreshed = slot_repo.fetch_slot(slot_uuid)
after_qty = float(refreshed.qty) if (refreshed and refreshed.qty is not None) else None
after_status = refreshed.status.value if refreshed else slot.status.value
after_avg = float(refreshed.avg_price) if (refreshed and refreshed.avg_price is not None) else None
after = {"qty": after_qty, "status": after_status, "avg_price": after_avg}
# Clear warning when qty now matches broker
warning_cleared = False
if after_qty is not None and int(after_qty) == broker_qty:
try:
warn_store.clear_warned(slot_id)
warning_cleared = True
_log.info(
"sync: reconcile-warning cleared slot=%s symbol=%s broker_qty=%d actor=sync",
slot_id, sym, broker_qty,
)
except Exception: # noqa: BLE001
_log.exception("sync: failed to clear_warned for slot=%s", slot_id)
return {
"slot_id": slot_id, "symbol": sym, "portfolio_id": pid,
"before": before,
"after": after,
"warning_cleared": warning_cleared,
"dry_run": False,
"skipped": False,
"seeded_avg_price": seeded_avg_price,
}
# ---------------------------------------------------------------------------
# BAT-051 / REQ-106: operator verify command — C-case re-check (read-centric)
# ---------------------------------------------------------------------------
def verify_slot_reconcile(
*,
portfolio_id: str,
store: SnapshotStore,
broker: BrokerPort,
slot_repo,
instance_key: str,
warn_store: "ReconcileWarningStore",
) -> dict:
"""Re-check C-case reconcile-warning for a portfolio (REQ-106, BAT-051).
Read-centric: does NOT modify slot qty. Clears warning only when re-check
confirms ledger qty == broker qty (no force-clear path, OQ-C confirmed).
result values:
"cleared" — re-check passed, warning cleared.
"rejected" — re-check failed (still mismatched), warning NOT cleared.
"not_c_case" — slot is warned but not case_code='C' (use sync for B case).
"not_warned" — slot has no active warning (idempotent, exit 0).
"slot_not_found" — no slot for this portfolio_id in this instance.
"no_warning_info" — is_warned but get_warning_info returned None (unexpected).
"""
slot = slot_repo.find_slot_by_portfolio(instance_key, portfolio_id)
if slot is None:
return {
"portfolio_id": portfolio_id,
"slot_id": None,
"result": "slot_not_found",
"hub_qty": None,
"broker_qty": None,
"reason": f"no slot for portfolio_id={portfolio_id} in instance={instance_key}",
}
slot_id_str = str(slot.slot_id)
if not warn_store.is_warned(slot_id_str):
return {
"portfolio_id": portfolio_id,
"slot_id": slot_id_str,
"result": "not_warned",
"hub_qty": None,
"broker_qty": None,
"reason": "no active reconcile-warning (already cleared or never set)",
}
# Check case_code — verify is C-only (REQ-106 OQ-C).
info = warn_store.get_warning_info(slot_id_str)
if info is None:
return {
"portfolio_id": portfolio_id,
"slot_id": slot_id_str,
"result": "no_warning_info",
"hub_qty": None,
"broker_qty": None,
"reason": "warning exists but get_warning_info returned None (unexpected)",
}
case_code = info.get("case_code", "")
if case_code != "C":
return {
"portfolio_id": portfolio_id,
"slot_id": slot_id_str,
"result": "not_c_case",
"hub_qty": None,
"broker_qty": None,
"reason": (
f"warning case_code='{case_code}' is not 'C' — "
"use 'trex-core sync' to resolve B-case (slot↔broker qty mismatch)"
),
}
# Re-run the C-case check: hub qty vs broker qty for this symbol.
sym = slot.symbol or ""
broker_qty_map: dict[str, int] = {
item.symbol: int(item.hldg_qty) for item in broker.get_balance()
}
broker_qty = broker_qty_map.get(sym, 0)
# Hub qty: sum asset_quantity for this portfolio_id from snapshot.
hub_qty: int = 0
snap = store.get(portfolio_id)
if snap is not None:
hub_qty = int(snap.item.asset_quantity)
match = hub_qty == broker_qty
if match:
try:
warn_store.clear_warned(slot_id_str)
store.set_warning(portfolio_id, False)
_log.info(
"verify: reconcile-warning cleared (C-case resolved) "
"slot=%s portfolio_id=%s symbol=%s hub_qty=%d broker_qty=%d",
slot_id_str, portfolio_id, sym, hub_qty, broker_qty,
)
except Exception: # noqa: BLE001
_log.exception("verify: failed to clear_warned for slot=%s", slot_id_str)
return {
"portfolio_id": portfolio_id,
"slot_id": slot_id_str,
"result": "cleared",
"hub_qty": hub_qty,
"broker_qty": broker_qty,
"reason": None,
}
else:
_log.warning(
"verify: C-case still mismatched — slot=%s portfolio_id=%s symbol=%s "
"hub_qty=%d broker_qty=%d diff=%d — warning NOT cleared",
slot_id_str, portfolio_id, sym, hub_qty, broker_qty, broker_qty - hub_qty,
)
return {
"portfolio_id": portfolio_id,
"slot_id": slot_id_str,
"result": "rejected",
"hub_qty": hub_qty,
"broker_qty": broker_qty,
"reason": (
f"hub_qty={hub_qty} broker_qty={broker_qty} "
f"diff={broker_qty - hub_qty} symbol={sym}"
),
}
+15 -1
View File
@@ -69,9 +69,23 @@ _FROM_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
}
)
# dcy-034 / REQ-105 OQ-A: cold-start onboarding transition (actor=sync/reconcile only).
# A permanent slot that already has a position in the broker at daemon cold-start can
# transition INACTIVE→HOLDING so the sync command can onboard it without going through
# the normal INACTIVE→PENDING_BUY buy-flow. Wire-contract unchanged: SlotState enum
# values remain 5 (inactive/pending_buy/holding/pending_sell/error). Only the transition
# table expands. Callers MUST supply actor="sync" and reason="onboarding" (or
# "reconcile_sync") so the transition_log is auditable and distinct from the normal
# strategy entry path (INACTIVE→PENDING_BUY→HOLDING).
_SYNC_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset(
{
(SlotState.INACTIVE, SlotState.HOLDING), # REQ-105 OQ-A: cold-start onboarding
}
)
ALLOWED_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = (
_BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS
_BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS | _SYNC_TRANSITIONS
)
+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)