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>
This commit is contained in:
466
prod/clean_arch/runtime/pink_direct.py
Normal file
466
prod/clean_arch/runtime/pink_direct.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""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",
|
||||
)
|
||||
Reference in New Issue
Block a user