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