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
-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}"
),
}