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:
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -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}"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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