Files
siloqy/prod/clean_arch/runtime/pink_direct.py
Codex 3d7b00e28d Snapshot PINK DITAv2 system + Sprint 0 flaw-fix verification
First commit of the previously-untracked PINK-on-DITAv2 migration system
(execution moves to the Rust kernel; policy stays on legacy DITA, so Alpha
Engine algorithmic integrity is preserved). BLUE is untouched.

Sprint 0 (safety snapshot + flaw-fix verification, MARKET single-leg scope):
- Verified Rust FSM fixes (flaws 2,4,10,11,13) by source read of lib.rs.
- Hardened 5 vacuous/guarded assertions in test_flaws.py so each flaw test
  genuinely exercises its fix. Most important: Flaw 5 now asserts capital
  moves by EXACTLY realized PnL (was entering/exiting at the same price).
- Offline suites: 533 passed, 0 failed (35 flaws + 402 kernel/accounting/
  bridge + 96 runtime/persistence/multi-exit/restart/seams).
- GATE PASS: MARKET-path-critical flaws 1,2,5 confirmed fixed + green.
- Added SPRINT0_FLAW_VERIFICATION.md report and _rust_kernel/.gitignore
  (excludes Rust target/ build artifacts).

LIMIT/partial-fill remain explicitly out of scope (MARKET-only bring-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:26:43 +02:00

467 lines
18 KiB
Python

"""Node-free PINK runtime built on DITAv2 kernel + BingX venue adapter.
The kernel owns the single-slot FSM, AccountProjection, and event
normalization. This module translates policy-layer Decision/Intent into
KernelIntent and reads final state from the kernel's slot + account
snapshot. Capital is seeded from exchange balance at startup/recovery
then maintained by kernel.account.settle() on close — no balance-poll
overwrites during the hot loop.
"""
from __future__ import annotations
import inspect
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, Callable, Optional
from prod.clean_arch.dita import (
Decision,
DecisionAction,
DecisionConfig,
DecisionContext,
DecisionEngine,
Intent,
IntentContext,
IntentEngine,
TradeSide as LegacyTradeSide,
)
from prod.clean_arch.dita_v2.contracts import (
KernelCommandType,
KernelIntent,
TradeSide as DitaTradeSide,
TradeStage,
)
from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel
from prod.clean_arch.persistence import PinkClickHousePersistence
from prod.clean_arch.ports.data_feed import DataFeedPort, MarketSnapshot
LOGGER = logging.getLogger(__name__)
def _slot_to_position_dict(slot) -> dict[str, Any]:
"""Convert a DITAv2 TradeSlot into a simple position dict compatible
with the persistence layer's expected shape."""
if slot is None:
return {}
return {
"trade_id": slot.trade_id,
"asset": slot.asset,
"side": slot.side.value,
"entry_price": float(slot.entry_price or 0.0),
"entry_time": slot.entry_time.isoformat() if hasattr(slot.entry_time, "isoformat") else str(slot.entry_time),
"size": float(slot.size or 0.0),
"initial_size": float(slot.initial_size or 0.0),
"leverage": float(slot.leverage or 0.0),
"realized_pnl": float(slot.realized_pnl or 0.0),
"unrealized_pnl": float(slot.unrealized_pnl or 0.0),
"closed": bool(slot.closed),
"close_reason": slot.close_reason or "",
"fsm_state": slot.fsm_state.value,
"exit_leg_ratios": list(slot.exit_leg_ratios),
"active_leg_index": int(slot.active_leg_index or 0),
"active_exit_order": dict(slot.active_exit_order.to_dict()) if slot.active_exit_order and hasattr(slot.active_exit_order, "to_dict") else ({"status": slot.active_exit_order.status.value, "venue_order_id": slot.active_exit_order.venue_order_id} if slot.active_exit_order else None),
"active_entry_order": dict(slot.active_entry_order.to_dict()) if slot.active_entry_order and hasattr(slot.active_entry_order, "to_dict") else ({"status": slot.active_entry_order.status.value, "venue_order_id": slot.active_entry_order.venue_order_id} if slot.active_entry_order else None),
}
def _decision_to_kernel_intent(
decision: Decision,
intent: Intent,
slot_id: int = 0,
) -> KernelIntent:
"""Translate policy-layer Decision/Intent into a DITAv2 KernelIntent.
The action map is:
ENTER -> KernelCommandType.ENTER
EXIT -> KernelCommandType.EXIT
HOLD -> KernelCommandType.MARK_PRICE
"""
action_map = {
DecisionAction.ENTER: KernelCommandType.ENTER,
DecisionAction.EXIT: KernelCommandType.EXIT,
DecisionAction.HOLD: KernelCommandType.MARK_PRICE,
}
side = (
DitaTradeSide.SHORT
if intent.side == LegacyTradeSide.SHORT
else DitaTradeSide.LONG
)
return KernelIntent(
timestamp=decision.timestamp,
intent_id=decision.decision_id,
trade_id=intent.trade_id,
slot_id=slot_id,
asset=intent.asset,
side=side,
action=action_map.get(decision.action, KernelCommandType.MARK_PRICE),
reference_price=float(decision.reference_price or intent.reference_price or 0.0),
target_size=float(intent.target_size or 0.0),
leverage=float(intent.leverage or 1.0),
exit_leg_ratios=tuple(intent.exit_leg_ratios),
reason=intent.reason,
metadata=dict(intent.metadata or {}),
)
def _reconcile_position_slot(
kernel: ExecutionKernel,
exchange_balance_capital: float,
slot_id: int = 0,
) -> None:
"""Synchronise a single kernel slot from the venue's open positions.
This is called at startup/recovery to make the kernel state match the
exchange. It also seeds the kernel's AccountProjection.capital from the
exchange balance — the single place where an external balance snapshot
writes capital.
"""
venue = kernel.venue
try:
positions = venue.open_positions() if hasattr(venue, "open_positions") else []
except Exception:
positions = []
# Build TradeSlot[] from exchange positions
from prod.clean_arch.dita_v2.contracts import TradeSlot, TradeSide
reconciled = []
if positions:
for row in positions if isinstance(positions, list) else (
list(positions.values()) if isinstance(positions, dict) else []):
raw_side = str(row.get("positionSide") or row.get("side") or "").upper()
raw_qty = 0.0
for key in ("positionAmt", "positionQty", "positionSize", "quantity", "pa", "qty"):
try:
raw_qty = float(row.get(key) or 0.0)
except Exception:
continue
if raw_qty != 0.0:
break
if abs(raw_qty) <= 1e-12:
continue
qty = abs(raw_qty)
entry = 0.0
for key in ("entryPrice", "avgPrice", "avgEntryPrice", "ep", "ap", "price"):
try:
entry = float(row.get(key) or 0.0)
except Exception:
continue
if entry > 0:
break
mark = 0.0
for key in ("markPrice", "mark", "price"):
try:
mark = float(row.get(key) or 0.0)
except Exception:
continue
if mark > 0:
break
if mark <= 0:
mark = entry
lev = float(row.get("leverage") or row.get("lev") or 1.0)
side = TradeSide.SHORT if raw_side in {"SHORT", "SELL"} or raw_qty < 0 else TradeSide.LONG
asset = str(row.get("symbol") or row.get("symbolName") or "")
trade_id = asset # use asset as trade ID for exchange-led recovery
slot = TradeSlot(
slot_id=slot_id,
trade_id=trade_id,
asset=asset,
side=side,
entry_price=entry if entry > 0 else mark,
size=qty,
initial_size=qty,
leverage=lev if lev > 0 else 1.0,
entry_time=datetime.now(timezone.utc),
fsm_state=TradeStage.POSITION_OPEN,
metadata={"reconciled_from_exchange": True},
)
reconciled.append(slot)
if reconciled:
kernel.reconcile_from_slots(reconciled)
else:
# No open positions — ensure slot is idle
kernel.reconcile_from_slots([])
# Seed capital once from exchange balance.
if exchange_balance_capital > 0:
kernel.account.snapshot.capital = exchange_balance_capital
kernel.account.snapshot.peak_capital = max(
kernel.account.snapshot.peak_capital, exchange_balance_capital
)
kernel.account.snapshot.equity = exchange_balance_capital
@dataclass
class PinkDirectRuntime:
"""Drive DITAv2 kernel against BingX exchange and a market data feed.
The kernel owns the FSM and account projection. This runtime provides
the policy loop: data feed -> decision engine -> intent engine ->
kernel intent -> outcome -> persistence.
"""
data_feed: DataFeedPort
kernel: ExecutionKernel
decision_engine: DecisionEngine
intent_engine: IntentEngine
persistence: Optional[PinkClickHousePersistence] = None
market_state_runtime: Any = None
event_sink: Optional[Callable[[dict[str, Any]], None]] = None
logger: Any = LOGGER
async def connect(self, initial_capital: float = 25000.0) -> None:
"""Connect data feed, venue, and seed capital from exchange."""
await self.data_feed.connect()
venue = self.kernel.venue
# VenueAdapter methods are synchronous (the adapter bridges async
# internally via _run). Try connect() if it exists.
if hasattr(venue, "connect"):
try:
result = venue.connect()
if inspect.isawaitable(result):
await result
except Exception as exc:
self.logger.warning("Venue connect failed: %s", exc)
# Seed capital from env default — the kernel tracks capital via
# settle() on close, not from exchange balance polls.
_reconcile_position_slot(self.kernel, initial_capital, slot_id=0)
async def disconnect(self) -> None:
await self.data_feed.disconnect()
venue = self.kernel.venue
if hasattr(venue, "disconnect"):
try:
await venue.disconnect()
except Exception:
pass
def _emit(self, phase: str, **fields: Any) -> None:
if self.event_sink is not None:
payload = {"phase": phase, **fields}
self.event_sink(payload)
@staticmethod
def _scan_payload_prices(
scan_payload: dict[str, Any] | None,
fallback_symbol: str,
fallback_price: float,
) -> dict[str, float]:
payload = scan_payload or {}
assets = payload.get("assets") or []
prices = payload.get("asset_prices") or []
out: dict[str, float] = {}
if isinstance(assets, list) and isinstance(prices, list):
for asset, price in zip(assets, prices):
try:
px = float(price)
except Exception:
continue
if px > 0:
out[str(asset).upper()] = px
if not out and fallback_symbol and fallback_price > 0:
out[str(fallback_symbol).upper()] = float(fallback_price)
return out
def _update_market_state_runtime(
self, snapshot: MarketSnapshot
) -> dict[str, Any]:
runtime = self.market_state_runtime
scan_payload = (
snapshot.scan_payload if isinstance(snapshot.scan_payload, dict) else {}
)
if runtime is None or not scan_payload:
return {}
try:
prices_dict = self._scan_payload_prices(
scan_payload, snapshot.symbol, snapshot.price
)
bundle = runtime.update_scan_state(
scan_payload=scan_payload,
prices_dict=prices_dict,
scan_number=int(
scan_payload.get("scan_number") or snapshot.scan_number or 0
),
vel_div=float(
scan_payload.get("vel_div")
or snapshot.velocity_divergence
or 0.0
),
v50_vel=float(scan_payload.get("w50_velocity") or 0.0),
v750_vel=float(scan_payload.get("w750_velocity") or 0.0),
vol_ok=bool(scan_payload.get("vol_ok", True)),
posture=str(scan_payload.get("posture") or "APEX"),
exf_snapshot=scan_payload.get("exf_snapshot")
if isinstance(scan_payload.get("exf_snapshot"), dict)
else None,
esof_payload=scan_payload.get("esof_payload")
if isinstance(scan_payload.get("esof_payload"), dict)
else None,
)
return dict(
getattr(runtime, "latest_bundle_dict", {}) or bundle.as_dict()
)
except Exception:
return {}
async def step(self, snapshot: MarketSnapshot) -> Decision:
"""Single policy + execution cycle.
1. Update market state
2. Decide (policy layer)
3. Plan (intent layer)
4. Translate to KernelIntent -> kernel.process_intent()
5. Read final slot + account state from kernel
6. Persist
"""
market_state = self._update_market_state_runtime(snapshot)
acc = self.kernel.snapshot()["account"]
slot_view = self.kernel.slot(0) if self.kernel.max_slots > 0 else None
slot_dict = slot_view.to_dict() if slot_view is not None else {}
is_open = slot_dict and slot_dict.get("size", 0) > 0 and not slot_dict.get("closed", False)
# Convert the kernel slot dict into a TradePosition for the legacy
# decision/intent engines.
legacy_position = None
if is_open:
from prod.clean_arch.dita import TradePosition, TradeSide as LS
legacy_position = TradePosition(
trade_id=slot_dict.get("trade_id", ""),
asset=slot_dict.get("asset", ""),
side=LS.SHORT if slot_dict.get("side", "").upper() in ("SHORT", "SELL") else LS.LONG,
entry_price=float(slot_dict.get("entry_price", 0.0)),
entry_time=datetime.now(timezone.utc),
size=float(slot_dict.get("size", 0.0)),
leverage=float(slot_dict.get("leverage", 1.0)),
entry_velocity_divergence=float(slot_dict.get("entry_velocity_divergence", 0.0)),
entry_irp_alignment=float(slot_dict.get("entry_irp_alignment", 0.0)),
current_price=float(slot_dict.get("entry_price", 0.0)),
initial_size=float(slot_dict.get("initial_size", 0.0)),
exit_leg_ratios=tuple(slot_dict.get("exit_leg_ratios", [1.0])),
closed=False,
)
context = DecisionContext(
capital=float(acc.get("capital", 0.0)),
open_positions=int(acc.get("open_positions", 0)),
trade_seq=int(acc.get("trade_seq", 0)),
)
decision = self.decision_engine.decide(snapshot, context, legacy_position)
self._emit("decision", decision=decision)
intent_context = IntentContext(
capital=context.capital,
open_positions=context.open_positions,
trade_seq=context.trade_seq,
)
plan = self.intent_engine.plan(decision, intent_context, legacy_position)
intent = plan.intent
if decision.action in {DecisionAction.ENTER, DecisionAction.EXIT}:
kernel_intent = _decision_to_kernel_intent(decision, intent, slot_id=0)
outcome = self.kernel.process_intent(kernel_intent)
# Read authoritative final state from kernel.
final_slot = self.kernel.slot(0)
slot_dict = final_slot.to_dict()
acc = self.kernel.snapshot()["account"]
self._emit(
"execution",
decision=decision,
intent=intent,
outcome_code=outcome.diagnostic_code.value,
)
if self.persistence is not None:
self.persistence.persist_step(
snapshot=snapshot,
decision=decision,
intent=intent,
outcome=outcome,
slot_dict=slot_dict,
acc_dict=acc,
phase="execution",
market_state=market_state,
)
else:
# HOLD / no-op: update mark price in kernel.
if snapshot.price and snapshot.price > 0:
self.kernel.mark_price(snapshot.symbol, snapshot.price)
slot_dict = self.kernel.slot(0).to_dict() if self.kernel.max_slots > 0 else {}
acc = self.kernel.snapshot()["account"]
if self.persistence is not None:
self.persistence.persist_step(
snapshot=snapshot,
decision=decision,
intent=intent,
outcome=None,
slot_dict=slot_dict,
acc_dict=acc,
phase="decision",
market_state=market_state,
)
return decision
async def recover(
self, snapshot: MarketSnapshot | None = None
) -> dict[str, Any]:
"""Full recovery — reconcile exchange state into kernel and reseed capital."""
return await self.recover_account(
snapshot=snapshot, phase="recovery", event_type="RECOVERY"
)
async def recover_account(
self,
*,
snapshot: MarketSnapshot | None = None,
phase: str = "recovery",
event_type: str = "RECOVERY",
) -> dict[str, Any]:
"""Reconcile exchange state, reseed capital, and persist recovery row.
The kernel's VenueAdapter is sync — all async bridging is handled
internally by ``_run()``. We seed capital from the kernel's existing
value (which was set at startup) rather than re-polling the exchange.
"""
capital = float(self.kernel.account.snapshot.capital or 25000.0)
_reconcile_position_slot(self.kernel, capital, slot_id=0)
acc = self.kernel.snapshot()["account"]
if self.persistence is not None:
persist_snapshot = snapshot
if persist_snapshot is None:
persist_snapshot = SimpleNamespace(
timestamp=datetime.now(timezone.utc), symbol=""
)
market_state = {}
if snapshot is not None:
market_state = self._update_market_state_runtime(snapshot)
self.persistence.persist_recovery_state(
snapshot=persist_snapshot,
acc_dict=acc,
phase=phase,
event_type=event_type,
market_state=market_state,
)
return acc
async def reconcile_account(
self, snapshot: MarketSnapshot | None = None
) -> dict[str, Any]:
"""Periodic exchange-led account sync.
Tags the recovery path as a scheduled reconciliation. Capital is
re-seeded from the exchange balance as a guard against long-running
drift, but the primary capital authority remains kernel.settle().
"""
return await self.recover_account(
snapshot=snapshot,
phase="account_reconcile",
event_type="ACCOUNT_RECONCILE",
)