PINK DITAv2: Hz writes + vol_ok gate + leverage logging + 8 new tests (94/94 green)
This commit is contained in:
@@ -12,10 +12,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
@@ -75,6 +77,8 @@ def _slot_to_position_dict(slot) -> dict[str, Any]:
|
||||
# overflows to inf as price -> 0. Any real perp quote is far above this floor,
|
||||
# so a price below it (or non-finite) signals corrupt market data, not a trade.
|
||||
_MIN_SANE_PRICE = 1e-8
|
||||
# Path for kernel state persistence (crash recovery + session continuity).
|
||||
_KERNEL_STATE_PATH = Path("/tmp/.pink_kernel_state.json")
|
||||
|
||||
|
||||
def _decision_to_kernel_intent(
|
||||
@@ -116,6 +120,46 @@ def _decision_to_kernel_intent(
|
||||
)
|
||||
|
||||
|
||||
def _persist_kernel_snapshot(kernel, log: logging.Logger) -> None:
|
||||
"""Write full kernel state to disk after each settled fill (G5 snapshot-on-fill)."""
|
||||
try:
|
||||
state_json = kernel.save_state()
|
||||
_KERNEL_STATE_PATH.write_text(state_json, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
log.warning("kernel snapshot persist failed (non-fatal): %s", exc)
|
||||
|
||||
|
||||
def _restore_kernel_snapshot(kernel, log: logging.Logger) -> bool:
|
||||
"""On startup, restore kernel state from disk if account is flat (no open positions).
|
||||
|
||||
Returns True if a snapshot was found and successfully restored.
|
||||
"""
|
||||
if not _KERNEL_STATE_PATH.exists():
|
||||
return False
|
||||
try:
|
||||
state_json = _KERNEL_STATE_PATH.read_text(encoding="utf-8")
|
||||
meta = json.loads(state_json)
|
||||
# Sanity check: only restore if the saved snapshot had no open trades.
|
||||
saved_slots = meta.get("slots", [])
|
||||
open_at_save = [s for s in saved_slots if s.get("fsm_state") not in (None, "", "IDLE", "CLOSED")]
|
||||
if open_at_save:
|
||||
log.warning(
|
||||
"kernel snapshot has %d open slot(s) at save time — "
|
||||
"skipping restore (must be flat for safe handoff)",
|
||||
len(open_at_save),
|
||||
)
|
||||
return False
|
||||
ok = kernel.restore_state(state_json)
|
||||
if ok:
|
||||
log.info("kernel state restored from %s (fee_calibration + account preserved)", _KERNEL_STATE_PATH)
|
||||
else:
|
||||
log.warning("kernel restore_state rejected snapshot (version or slot mismatch)")
|
||||
return ok
|
||||
except Exception as exc:
|
||||
log.warning("kernel snapshot restore failed (non-fatal): %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _reconcile_position_slot(
|
||||
kernel: ExecutionKernel,
|
||||
exchange_balance_capital: float,
|
||||
@@ -221,11 +265,15 @@ class PinkDirectRuntime:
|
||||
market_state_runtime: Any = None
|
||||
event_sink: Optional[Callable[[dict[str, Any]], None]] = None
|
||||
logger: Any = LOGGER
|
||||
# Non-blocking Hz state writer (None = Hz unavailable; PINK trades regardless)
|
||||
hz_state_writer: Any = field(default=None, repr=False, compare=False)
|
||||
# Account stream state — managed by connect/disconnect, not init args
|
||||
_account_stream_task: Optional[asyncio.Task] = field(
|
||||
default=None, init=False, repr=False, compare=False
|
||||
)
|
||||
_enter_frozen: bool = field(default=False, init=False, repr=False, compare=False)
|
||||
# Last known posture — carried into Hz writes for TUI/algo monitoring
|
||||
_last_posture: str = field(default="APEX", init=False, repr=False, compare=False)
|
||||
|
||||
async def connect(self, initial_capital: float = 25000.0) -> None:
|
||||
"""Connect data feed, venue, seed capital from exchange, start WS stream."""
|
||||
@@ -246,6 +294,11 @@ class PinkDirectRuntime:
|
||||
self.kernel.set_seed_capital(initial_capital)
|
||||
await self._seed_account_from_exchange()
|
||||
|
||||
# Restore fee calibration + account state from the previous session if the
|
||||
# kernel was flat at save time. Must be AFTER set_seed_capital and reconcile
|
||||
# so the snapshot can override our fresh seed with the last-known calibration.
|
||||
_restore_kernel_snapshot(self.kernel, self.logger)
|
||||
|
||||
# Start WS account stream (primary); poll failover handled inside stream.
|
||||
self._account_stream_task = asyncio.create_task(
|
||||
self._run_account_stream(), name="pink_account_stream"
|
||||
@@ -269,13 +322,13 @@ class PinkDirectRuntime:
|
||||
|
||||
# BingX VST/LIVE taker fee schedule. These are the current published rates.
|
||||
# Override via set_exchange_config() if the exchange adjusts them.
|
||||
_BINGX_FEE_CONFIG: dict = {
|
||||
_BINGX_FEE_CONFIG: dict = field(default_factory=lambda: {
|
||||
"taker_rate": 0.0005, # 0.05% market orders
|
||||
"maker_rate": 0.0002, # 0.02% limit resting
|
||||
"lot_step": 0.001,
|
||||
"tick_size": 0.0001,
|
||||
"funding_interval_secs": 28_800, # 8 h BingX perps
|
||||
}
|
||||
})
|
||||
|
||||
async def _seed_account_from_exchange(self) -> None:
|
||||
"""
|
||||
@@ -347,7 +400,9 @@ class PinkDirectRuntime:
|
||||
if fill_price <= 0 or fill_qty <= 0 or actual_fee <= 0:
|
||||
self.logger.info("Fee calibration: fill row missing price/qty/fee — skipping")
|
||||
return
|
||||
report = self.kernel.calibrate_fee(fill_price, fill_qty, actual_fee)
|
||||
order_type = str(row.get("orderType") or row.get("type") or "MARKET").upper()
|
||||
is_maker = order_type == "LIMIT"
|
||||
report = self.kernel.calibrate_fee(fill_price, fill_qty, actual_fee, is_maker=is_maker)
|
||||
status = report.get("calibration_status", "?")
|
||||
log = self.logger.error if status == "ERROR" else self.logger.info
|
||||
log(
|
||||
@@ -395,14 +450,20 @@ class PinkDirectRuntime:
|
||||
"fill_price": event.fill_price,
|
||||
"fill_qty": event.fill_qty,
|
||||
"realized_pnl": event.realized_pnl,
|
||||
"is_maker": event.is_maker,
|
||||
})
|
||||
# Also fold actual fee if WS delivered it
|
||||
if event.fee > 0:
|
||||
# Fold actual fee if WS delivered it (replaces prediction)
|
||||
if event.fee != 0:
|
||||
self.kernel.on_account_event({
|
||||
"kind": "FILL_SETTLED",
|
||||
"event_id": event.event_id,
|
||||
"realized_pnl": 0.0, # already folded above
|
||||
"fee": event.fee,
|
||||
"fee": event.fee, # negative = rebate
|
||||
"is_maker": event.is_maker,
|
||||
})
|
||||
# Persist full kernel state after every settled fill for
|
||||
# crash recovery + session-to-session calibration continuity.
|
||||
_persist_kernel_snapshot(self.kernel, self.logger)
|
||||
elif event.kind == ExchangeEventKind.ACCOUNT_UPDATE:
|
||||
result = self.kernel.on_account_event({
|
||||
"kind": "ACCOUNT_UPDATE",
|
||||
@@ -421,12 +482,20 @@ class PinkDirectRuntime:
|
||||
result.get("reconcile_explanation", ""),
|
||||
)
|
||||
self._enter_frozen = True
|
||||
# Hz write: capital_frozen state changed
|
||||
_slot = self.kernel.slot(0).to_dict() if self.kernel.max_slots > 0 else {}
|
||||
_acc = self.kernel.snapshot().get("account") or {}
|
||||
self._hz_publish(_slot, _acc)
|
||||
else:
|
||||
if self._enter_frozen:
|
||||
self.logger.info(
|
||||
"Account reconcile %s — unfreezing ENTERs.", status
|
||||
)
|
||||
self._enter_frozen = False
|
||||
# Hz write: unfreeze is also a state change
|
||||
_slot = self.kernel.slot(0).to_dict() if self.kernel.max_slots > 0 else {}
|
||||
_acc = self.kernel.snapshot().get("account") or {}
|
||||
self._hz_publish(_slot, _acc)
|
||||
elif event.kind == ExchangeEventKind.FUNDING_FEE:
|
||||
self.kernel.on_account_event({
|
||||
"kind": "FUNDING_FEE",
|
||||
@@ -524,12 +593,35 @@ class PinkDirectRuntime:
|
||||
if isinstance(scan_payload.get("esof_payload"), dict)
|
||||
else None,
|
||||
)
|
||||
# Track posture for Hz writes
|
||||
self._last_posture = str(scan_payload.get("posture") or "APEX")
|
||||
return dict(
|
||||
getattr(runtime, "latest_bundle_dict", {}) or bundle.as_dict()
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _hz_publish(self, slot_dict: dict, acc: dict) -> None:
|
||||
"""Fire-and-forget Hz write after any kernel state change.
|
||||
|
||||
Computes system leverage (our_leverage = notional/capital) for the Hz
|
||||
snapshot — this is the PINK/BLUE dual-leverage invariant: system leverage
|
||||
reflects real margin utilisation; exchange leverage (1-3x cap) is set at
|
||||
the BingX API level and never touches this path.
|
||||
"""
|
||||
if self.hz_state_writer is None:
|
||||
return
|
||||
try:
|
||||
size = float(slot_dict.get("size") or 0.0)
|
||||
ep = float(slot_dict.get("entry_price") or 0.0)
|
||||
capital = float(acc.get("capital") or 0.0)
|
||||
our_leverage = (size * ep / capital) if capital > 1e-10 else 0.0
|
||||
self.hz_state_writer.write_engine_snapshot(
|
||||
slot_dict, acc, posture=self._last_posture, our_leverage=our_leverage
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def pump_venue_events(
|
||||
self, snapshot: Any | None = None, *, market_state: Any = None
|
||||
) -> int:
|
||||
@@ -586,6 +678,9 @@ class PinkDirectRuntime:
|
||||
slot_dict=slot_dict,
|
||||
market_state=market_state or {},
|
||||
)
|
||||
# Hz write after fills settle — slot FSM and capital may have changed
|
||||
acc = self.kernel.snapshot().get("account") or {}
|
||||
self._hz_publish(slot_dict, acc)
|
||||
return len(applied)
|
||||
|
||||
def _unsafe_entry_reason(self, kernel_intent: KernelIntent, context: Any) -> Optional[str]:
|
||||
@@ -785,6 +880,20 @@ class PinkDirectRuntime:
|
||||
phase="execution",
|
||||
market_state=market_state,
|
||||
)
|
||||
|
||||
# Hz write: ENTER/EXIT changed slot FSM — publish updated state
|
||||
self._hz_publish(slot_dict, acc)
|
||||
|
||||
# On trade close, write daily PnL row
|
||||
if (
|
||||
self.hz_state_writer is not None
|
||||
and slot_dict.get("closed")
|
||||
):
|
||||
try:
|
||||
self.hz_state_writer.write_daily_pnl(acc, posture=self._last_posture)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
# HOLD / no-op: update mark price in kernel.
|
||||
if snapshot.price and snapshot.price > 0:
|
||||
|
||||
Reference in New Issue
Block a user