PINK DITAv2: Hz writes + vol_ok gate + leverage logging + 8 new tests (94/94 green)

This commit is contained in:
Codex
2026-06-03 13:26:36 +02:00
parent 0f2d3f556d
commit 8d85d75ded
6 changed files with 1570 additions and 6 deletions

View File

@@ -0,0 +1,513 @@
"""Direct BingX execution adapter with no Nautilus Trader node dependency.
This adapter speaks BingX REST directly and keeps the exchange state
authoritative. It is intended for PINK live execution under the DITA boundary.
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional
from nautilus_trader.model.identifiers import InstrumentId
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.config import BingxInstrumentProviderConfig
from prod.bingx.enums import BingxEnvironment
from prod.bingx.http import BingxHttpError
from prod.bingx.http import BingxHttpClient
from prod.bingx.instrument_provider import BingxInstrumentProvider
from prod.bingx.leverage import normalize_bingx_leverage_value
from prod.bingx.schemas import BingxOrderAck
from prod.bingx.schemas import unwrap_order_payload
from prod.clean_arch.dita import Intent, TradeSide, DecisionAction
from prod.clean_arch.ports.execution import ExchangeStateSnapshot
from prod.clean_arch.ports.execution import ExecutionReceipt
from prod.clean_arch.ports.execution import ExecutionPort
LOGGER = logging.getLogger(__name__)
def _rows_from_payload(payload: Any, *keys: str) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [row for row in payload if isinstance(row, dict)]
if isinstance(payload, dict):
for key in keys:
rows = payload.get(key)
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
return []
def _capital_from_balance_rows(rows: Any) -> float:
if not isinstance(rows, list):
return 0.0
for row in rows:
if not isinstance(row, dict):
continue
capital = 0.0
for key in ("total", "balance", "equity", "availableMargin", "availableBalance", "walletBalance", "free"):
try:
capital = float(row.get(key, 0.0) or 0.0)
except Exception:
continue
if capital > 0 and math.isfinite(capital):
return capital
if capital > 0 and math.isfinite(capital):
return capital
return 0.0
def _position_notional_from_rows(rows: Any) -> float:
if not isinstance(rows, list):
return 0.0
total = 0.0
for row in rows:
if not isinstance(row, dict):
continue
try:
qty = abs(
float(
row.get("positionAmt")
or row.get("positionQty")
or row.get("positionSize")
or row.get("quantity")
or row.get("pa")
or 0.0
)
)
if qty <= 0.0:
continue
notional = row.get("positionValue") or row.get("notional") or row.get("openNotional")
if notional is not None:
total += abs(float(notional or 0.0))
continue
entry = (
row.get("entryPrice")
or row.get("avgPrice")
or row.get("markPrice")
or row.get("avgEntryPrice")
or row.get("ep")
or row.get("ap")
or 0.0
)
total += qty * abs(float(entry or 0.0))
except Exception:
continue
return total
def _normalize_symbol(symbol: str) -> str:
return str(symbol or "").replace("-", "").replace("_", "").replace("/","").upper()
def _venue_symbol_from_asset(asset: str) -> str:
text = _normalize_symbol(asset)
if text.endswith("USDT"):
return f"{text[:-4]}-USDT"
return text
def _decimal_text(value: Decimal) -> str:
text = format(value.normalize(), "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text or "0"
def _is_rate_limited_error(exc: Exception) -> bool:
message = str(exc)
lowered = message.lower()
return "100410" in message or "frequency limit" in lowered or "rate limit" in lowered
@dataclass(frozen=True)
class BingxDirectExecutionConfig:
"""Execution-specific knobs for the direct adapter."""
environment: BingxEnvironment = BingxEnvironment.VST
allow_mainnet: bool = False
default_leverage: int = 1
exchange_leverage_cap: int = 3
recv_window_ms: int = 5_000
prefer_websocket: bool = False
use_reduce_only: bool = True
journal_strategy: str = "pink"
journal_db: str = "dolphin_pink"
instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True)
class BingxDirectExecutionAdapter(ExecutionPort):
"""Direct BingX execution boundary with exchange-led state snapshots."""
def __init__(
self,
config: BingxExecClientConfig | BingxDirectExecutionConfig,
*,
client: BingxHttpClient | None = None,
provider: BingxInstrumentProvider | None = None,
) -> None:
if isinstance(config, BingxExecClientConfig):
self._config = BingxDirectExecutionConfig(
environment=config.environment,
allow_mainnet=config.allow_mainnet,
default_leverage=int(config.default_leverage),
exchange_leverage_cap=int(config.exchange_leverage_cap),
recv_window_ms=int(config.recv_window_ms),
prefer_websocket=bool(config.prefer_websocket),
use_reduce_only=bool(config.use_reduce_only),
journal_strategy=str(config.journal_strategy or "pink"),
journal_db=str(config.journal_db or "dolphin_pink"),
instrument_provider=config.instrument_provider,
)
http_config = config
else:
self._config = config
http_config = BingxExecClientConfig(
api_key="",
secret_key="",
environment=config.environment,
allow_mainnet=config.allow_mainnet,
prefer_websocket=config.prefer_websocket,
sizing_mode="testnet",
exchange_leverage_cap=config.exchange_leverage_cap,
use_reduce_only=config.use_reduce_only,
default_leverage=config.default_leverage,
recv_window_ms=config.recv_window_ms,
journal_strategy=config.journal_strategy,
journal_db=config.journal_db,
instrument_provider=config.instrument_provider,
)
self._client = client or BingxHttpClient(http_config)
self._provider = provider or BingxInstrumentProvider(client=self._client, config=self._config.instrument_provider)
self._log = LOGGER
self._client_order_run_id = uuid.uuid4().hex[:8]
self._entry_client_order_seq = 0
self._exit_client_order_seq = 0
self._state: ExchangeStateSnapshot | None = None
self._connected = False
@property
def state(self) -> ExchangeStateSnapshot | None:
return self._state
async def connect(self) -> bool:
await self._provider.initialize()
self._connected = True
self._state = await self.refresh_state()
return True
async def disconnect(self) -> None:
self._connected = False
await self._client.close()
def _resolve_instrument(self, asset: str):
normalized = _normalize_symbol(asset)
candidates = [
InstrumentId.from_str(f"{normalized}.BINGX"),
InstrumentId.from_str(f"{_venue_symbol_from_asset(asset)}.BINGX"),
]
for candidate in candidates:
instrument = self._provider.find(candidate)
if instrument is not None:
return instrument
for instrument in self._provider.list_all():
if _normalize_symbol(instrument.symbol.value) == normalized:
return instrument
if _normalize_symbol(instrument.raw_symbol.value) == normalized:
return instrument
return None
def _instrument_venue_symbol(self, asset: str) -> str:
instrument = self._resolve_instrument(asset)
if instrument is not None:
return str(instrument.raw_symbol.value)
return _venue_symbol_from_asset(asset)
def _instrument_step(self, asset: str) -> Decimal:
instrument = self._resolve_instrument(asset)
if instrument is not None:
try:
return Decimal(str(instrument.size_increment.as_decimal()))
except Exception:
pass
return Decimal("0.001")
def _format_quantity(self, asset: str, quantity: float) -> str:
step = self._instrument_step(asset)
if step <= 0:
return str(max(0.0, quantity))
value = Decimal(str(quantity))
quantized = (value / step).to_integral_value(rounding=ROUND_DOWN) * step
return _decimal_text(max(Decimal("0"), quantized))
def _instrument_tick(self, asset: str) -> Decimal:
instrument = self._resolve_instrument(asset)
if instrument is not None:
try:
tick = getattr(instrument, "price_increment", None)
if tick is not None:
return Decimal(str(tick.as_decimal()))
except Exception:
pass
return Decimal("0.01")
def _format_price(self, asset: str, price: float) -> str:
tick = self._instrument_tick(asset)
if tick <= 0:
return f"{price:.8f}".rstrip("0").rstrip(".")
value = Decimal(str(price))
quantized = (value / tick).to_integral_value(rounding=ROUND_DOWN) * tick
return _decimal_text(max(Decimal("0"), quantized))
async def _safe_get(self, endpoint: str, params: dict | None = None, *, fallback: Any = None) -> Any:
"""GET an endpoint, returning *fallback* on rate-limit errors."""
try:
return await self._client.signed_get(endpoint, params)
except BingxHttpError as exc:
message = str(exc)
if "100410" in message or "frequency limit" in message.lower():
LOGGER.debug("BingX %s rate-limited; continuing with empty snapshot", endpoint)
return fallback if fallback is not None else []
raise
async def _refresh_exchange_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
"""Fetch exchange state with parallel HTTP calls.
The three primary calls (balance, positions, openOrders) are
independent and run concurrently via ``asyncio.gather``. Each has
its own rate-limit fallback so a single throttle does not block
the others. Historical calls (allOrders, allFillOrders) are gated
on ``include_history`` and also gathered.
"""
balance_task = self._safe_get("/openApi/swap/v2/user/balance")
positions_task = self._safe_get("/openApi/swap/v2/user/positions")
orders_task = self._safe_get("/openApi/swap/v2/trade/openOrders")
balance_payload, positions_payload, open_orders_payload = await asyncio.gather(
balance_task, positions_task, orders_task,
)
all_orders_payload: Any = []
all_fills_payload: Any = []
if include_history and symbol is not None:
venue_symbol = self._instrument_venue_symbol(symbol)
hist_tasks = asyncio.gather(
self._safe_get("/openApi/swap/v2/trade/allOrders", {"symbol": venue_symbol}),
self._safe_get("/openApi/swap/v2/trade/allFillOrders", {"symbol": venue_symbol}),
return_exceptions=True,
)
results = await hist_tasks
all_orders_payload = results[0] if not isinstance(results[0], Exception) else []
all_fills_payload = results[1] if not isinstance(results[1], Exception) else []
# Parse results (shared logic, same as before)
if isinstance(balance_payload, list):
balances = balance_payload
elif isinstance(balance_payload, dict):
rows_raw = balance_payload.get("balance") or balance_payload.get("balances") or balance_payload.get("data")
if isinstance(rows_raw, dict):
balances = [rows_raw]
elif isinstance(rows_raw, list):
balances = rows_raw
else:
balances = []
else:
balances = []
positions_rows = _rows_from_payload(positions_payload, "positions", "data")
positions: dict[str, dict[str, Any]] = {}
for row in positions_rows:
raw_symbol = str(row.get("symbol") or row.get("symbolName") or row.get("venueSymbol") or "")
key = _normalize_symbol(raw_symbol)
if not key:
continue
positions[key] = dict(row)
open_orders = _rows_from_payload(open_orders_payload, "orders", "data")
capital = _capital_from_balance_rows(balances)
open_notional = _position_notional_from_rows(positions_rows)
equity = capital
if open_notional > 0 and positions_rows:
equity = capital
snapshot = ExchangeStateSnapshot(
timestamp=datetime.now(timezone.utc),
capital=capital,
equity=equity,
open_positions=positions,
open_orders=[dict(row) for row in open_orders],
all_orders=[dict(row) for row in _rows_from_payload(all_orders_payload, "orders", "data")],
all_fills=[dict(row) for row in _rows_from_payload(all_fills_payload, "fills", "data")],
account={"balances": balances},
open_notional=open_notional,
source="bingx",
recovered=bool(include_history),
)
self._state = snapshot
return snapshot
async def refresh_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
return await self._refresh_exchange_state(symbol, include_history=include_history)
async def submit_intent(self, intent: Intent) -> ExecutionReceipt:
symbol = self._instrument_venue_symbol(intent.asset)
if intent.action == DecisionAction.EXIT:
side = "SELL" if intent.side == TradeSide.LONG else "BUY"
else:
side = "BUY" if intent.side == TradeSide.LONG else "SELL"
# Entries must be free to open the slot; only exits are reduce-only.
reduce_only = bool(intent.action == DecisionAction.EXIT)
if reduce_only:
self._exit_client_order_seq += 1
client_order_id = f"pink:{self._client_order_run_id}:x{self._exit_client_order_seq:02d}"
else:
self._entry_client_order_seq += 1
client_order_id = f"pink:{self._client_order_run_id}:e{self._entry_client_order_seq:02d}"
leverage = normalize_bingx_leverage_value(
int(round(float(intent.leverage or self._config.default_leverage))),
exchange_max=self._config.exchange_leverage_cap,
)
try:
await self._client.signed_post(
"/openApi/swap/v2/trade/leverage",
{"symbol": symbol, "side": "BOTH", "leverage": leverage},
)
except Exception as _lev_exc:
# W: leverage POST failed — order will execute at whatever leverage the
# exchange currently has for this symbol. Log prominently; do NOT abort
# the submit because the order may still succeed at the right leverage.
import logging as _logging
_logging.getLogger(__name__).warning(
"BingX leverage set failed (symbol=%s lev=%s): %s — proceeding with submit",
symbol, leverage, _lev_exc,
)
try:
# Honor the order type forwarded by the venue adapter
# (bingx_venue._legacy_intent sets _order_type/_limit_price). MARKET
# is the default; a LIMIT carries a resting price + GTC and will not
# fill synchronously — the async-fill pump settles it later.
order_type = str((intent.metadata or {}).get("_order_type", "MARKET") or "MARKET").upper()
limit_price = float((intent.metadata or {}).get("_limit_price", 0.0) or 0.0)
is_limit = order_type == "LIMIT" and limit_price > 0.0
payload: dict[str, Any] = {
"symbol": symbol,
"side": side,
"positionSide": "BOTH",
"type": "LIMIT" if is_limit else "MARKET",
"quantity": self._format_quantity(intent.asset, intent.target_size),
"clientOrderId": client_order_id,
"recvWindow": str(int(self._config.recv_window_ms)),
}
if is_limit:
payload["price"] = self._format_price(intent.asset, limit_price)
payload["timeInForce"] = "GTC"
if reduce_only:
payload["reduceOnly"] = "true"
ack_payload = await self._client.signed_post("/openApi/swap/v2/trade/order", payload)
ack = BingxOrderAck.from_http(ack_payload if isinstance(ack_payload, dict) else {})
ack_row = dict(unwrap_order_payload(ack_payload)) if isinstance(ack_payload, dict) else {}
status = str(ack_row.get("status") or ack.status or "ACKED")
fill_price = 0.0
for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice", "tradePrice"):
try:
value = float(ack_row.get(key) or 0.0)
except Exception:
value = 0.0
if value > 0:
fill_price = value
break
if fill_price <= 0 and self._state is not None:
# Use the last known exchange mark as a fallback for projected accounting.
fill_price = next((float(row.get("markPrice") or row.get("avgPrice") or 0.0) for row in self._state.open_positions.values() if float(row.get("markPrice") or row.get("avgPrice") or 0.0) > 0), 0.0)
except BingxHttpError as exc:
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
ack_row = {
"status": status,
"msg": str(exc),
"symbol": symbol,
"clientOrderId": client_order_id,
}
fill_price = 0.0
ack = None
receipt = ExecutionReceipt(
timestamp=datetime.now(timezone.utc),
status=status,
symbol=symbol,
side=side,
action=intent.action.value,
quantity=float(intent.target_size or 0.0),
price=fill_price,
client_order_id=client_order_id,
order_id=str((ack.order_id if 'ack' in locals() and ack is not None else '') or ack_row.get("orderId") or ""),
raw_ack=ack_row,
raw_state=dict(self._state.account if self._state is not None else {}),
)
# Refresh from the venue so the direct runtime can use exchange-led state.
self._state = await self._refresh_exchange_state(intent.asset, include_history=True)
return receipt
async def cancel(self, order: Any, *, reason: str = "") -> dict[str, Any]:
"""Cancel a working order on the venue (resting LIMIT support).
Signs the DELETE with the same client used for order placement, keyed by
the venue orderId (propagated onto the slot order by the kernel on ACK)
with a clientOrderId fallback. Returns the raw BingX response for the
venue adapter to map into a CANCEL_ACK / CANCEL_REJECT event.
"""
asset = str((getattr(order, "metadata", None) or {}).get("asset") or "")
symbol = self._instrument_venue_symbol(asset) if asset else ""
params: dict[str, Any] = {
"symbol": symbol,
"recvWindow": str(int(self._config.recv_window_ms)),
}
venue_order_id = str(getattr(order, "venue_order_id", "") or "")
venue_client_id = str(getattr(order, "venue_client_id", "") or "")
if venue_order_id:
params["orderId"] = venue_order_id
elif venue_client_id:
params["clientOrderId"] = venue_client_id
else:
return {"status": "REJECTED", "msg": "no order id to cancel",
"orderId": venue_order_id, "clientOrderId": venue_client_id}
delete_resp: dict[str, Any] = {}
try:
resp = await self._client.signed_delete("/openApi/swap/v2/trade/order", params)
delete_resp = resp if isinstance(resp, dict) else {"status": "CANCELED"}
except BingxHttpError as exc:
delete_resp = {"status": "RATE_LIMITED" if _is_rate_limited_error(exc) else "ERROR", "msg": str(exc)}
# Truth-based confirmation: the cancel succeeded iff the order is no
# longer open on the venue. BingX can return transient errors (e.g.
# "order not exist", "same order number ... within 1 second" from an
# internal retry) even when the order was actually removed — so we trust
# exchange state, not the DELETE response.
still_open: bool | None = None
try:
oo = await self._client.signed_get("/openApi/swap/v2/trade/openOrders", {"symbol": symbol})
rows = oo if isinstance(oo, list) else (oo.get("data") or oo.get("orders") or [])
if isinstance(rows, dict):
rows = rows.get("orders") or []
ids = {str(r.get("orderId")) for r in rows if isinstance(r, dict)}
cids = {str(r.get("clientOrderId") or r.get("clientOrderID")) for r in rows if isinstance(r, dict)}
still_open = (venue_order_id in ids) if venue_order_id else (venue_client_id in cids)
except Exception:
still_open = None
if still_open is False:
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
if str(delete_resp.get("status", "")).upper() in {"CANCELED", "CANCELLED", "SUCCESS", "OK"}:
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
return {
"status": delete_resp.get("status", "REJECTED"),
"msg": delete_resp.get("msg", "cancel not confirmed"),
"orderId": venue_order_id, "clientOrderId": venue_client_id,
}
async def reconcile(self, symbol: str | None = None) -> ExchangeStateSnapshot:
# Recovery-only path: ask the venue for authoritative account/position/order state.
return await self._refresh_exchange_state(symbol, include_history=True)

View File

@@ -0,0 +1,209 @@
"""Pure decision engine."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from prod.clean_arch.tp_curve import compute_our_leverage, compute_soft_tp_pct
from prod.clean_arch.ports.data_feed import MarketSnapshot
from .contracts import Decision, DecisionAction, DecisionConfig, DecisionContext, TradePosition, TradeSide, TradeStage
@dataclass(frozen=True)
class _SnapshotFields:
price: float
vdiv: float
irp: float
ts: datetime
class DecisionEngine:
"""BLUE-compatible decision engine.
Decision only answers whether the system should enter/hold/exit.
It does not size orders or own exchange state.
"""
def __init__(self, config: Optional[DecisionConfig] = None):
self.config = config or DecisionConfig()
def decide(
self,
snapshot: MarketSnapshot,
context: DecisionContext,
position: Optional[TradePosition] = None,
) -> Decision:
fields = self._extract(snapshot)
if (
not snapshot.is_valid()
or fields.price <= 0
or not self._finite(fields.price)
or not self._finite(fields.vdiv)
or not self._finite(fields.irp)
):
return Decision(
timestamp=fields.ts,
decision_id=self._decision_id(snapshot.symbol, context.trade_seq),
asset=snapshot.symbol,
action=DecisionAction.HOLD,
side=TradeSide.FLAT,
reason="INVALID_SNAPSHOT",
confidence=0.0,
velocity_divergence=fields.vdiv,
irp_alignment=fields.irp,
reference_price=fields.price,
target_size=0.0,
leverage=1.0,
metadata={"policy_version": self.config.policy_version},
)
if position is not None and not position.closed:
return self._decide_exit(snapshot, position, context, fields)
return self._decide_entry(snapshot, context, fields)
def _decide_entry(self, snapshot: MarketSnapshot, context: DecisionContext, fields: _SnapshotFields) -> Decision:
if context.open_positions >= 1:
return self._hold(snapshot, context, fields, reason="CAPACITY_FULL")
if not self.config.allow_short:
return self._hold(snapshot, context, fields, reason="SHORT_DISABLED")
if fields.vdiv >= self.config.vel_div_threshold or fields.irp < self.config.min_irp_alignment:
return self._hold(snapshot, context, fields, reason="NO_SIGNAL")
# vol_ok gate — scan bridge marks low-volume periods; block ENTERs when absent
if snapshot.scan_payload and not snapshot.scan_payload.get("vol_ok", True):
return self._hold(snapshot, context, fields, reason="VOL_GATE")
confidence = min(1.0, max(0.05, abs(fields.vdiv / self.config.vel_div_threshold)))
leverage = min(self.config.max_leverage, max(1.0, 1.0 + confidence * (self.config.max_leverage - 1.0)))
target_exposure = context.capital * self.config.capital_fraction * leverage
target_size = target_exposure / fields.price if fields.price > 0 else 0.0
our_leverage = compute_our_leverage(notional=target_exposure, capital=context.capital)
tp_base_pct = float(self.config.fixed_tp_pct)
tp_effective_pct = compute_soft_tp_pct(tp_base_pct, our_leverage)
return Decision(
timestamp=fields.ts,
decision_id=self._decision_id(snapshot.symbol, context.trade_seq),
asset=snapshot.symbol,
action=DecisionAction.ENTER,
side=TradeSide.SHORT,
reason="STRUCTURAL_DISLOCATION",
confidence=confidence,
velocity_divergence=fields.vdiv,
irp_alignment=fields.irp,
reference_price=fields.price,
target_size=target_size,
leverage=leverage,
metadata={
"policy_version": self.config.policy_version,
"tp_base_pct": tp_base_pct,
"tp_effective_pct": tp_effective_pct,
"our_leverage": our_leverage,
"tp_curve": "soft_leverage_curve_v1",
},
)
def _decide_exit(
self,
snapshot: MarketSnapshot,
position: TradePosition,
context: DecisionContext,
fields: _SnapshotFields,
) -> Decision:
action = DecisionAction.HOLD
reason = "HOLD"
position_notional = position.size * fields.price if fields.price > 0 else position.size * position.entry_price
our_leverage = compute_our_leverage(notional=position_notional, capital=context.capital)
tp_base_pct = float(self.config.fixed_tp_pct)
tp_effective_pct = compute_soft_tp_pct(tp_base_pct, our_leverage)
if position.side == TradeSide.SHORT:
tp_price = position.entry_price * (1.0 - tp_effective_pct)
if fields.price <= tp_price:
action = DecisionAction.EXIT
reason = "TAKE_PROFIT"
elif fields.price >= position.entry_price * (1.0 + (self.config.catastrophic_loss_pct / max(position.leverage, 1.0))):
action = DecisionAction.EXIT
reason = "CATASTROPHIC_LOSS"
elif position.bars_held >= self.config.max_hold_bars:
action = DecisionAction.EXIT
reason = "MAX_HOLD"
elif fields.vdiv >= 0.0:
action = DecisionAction.EXIT
reason = "MEAN_REVERSION"
if position.side == TradeSide.LONG:
tp_price = position.entry_price * (1.0 + tp_effective_pct)
if fields.price >= tp_price:
action = DecisionAction.EXIT
reason = "TAKE_PROFIT"
elif fields.price <= position.entry_price * (1.0 - (self.config.catastrophic_loss_pct / max(position.leverage, 1.0))):
action = DecisionAction.EXIT
reason = "CATASTROPHIC_LOSS"
elif position.bars_held >= self.config.max_hold_bars:
action = DecisionAction.EXIT
reason = "MAX_HOLD"
elif fields.vdiv <= 0.0:
action = DecisionAction.EXIT
reason = "MEAN_REVERSION"
return Decision(
timestamp=fields.ts,
decision_id=position.trade_id,
asset=position.asset,
action=action,
side=position.side,
reason=reason,
confidence=max(0.0, min(1.0, position.entry_irp_alignment)),
velocity_divergence=fields.vdiv,
irp_alignment=fields.irp,
reference_price=fields.price,
target_size=position.size,
leverage=position.leverage,
bars_held=position.bars_held,
stage=TradeStage.EXIT_REQUESTED if action == DecisionAction.EXIT else TradeStage.POSITION_UPDATED,
metadata={
"policy_version": self.config.policy_version,
"tp_base_pct": tp_base_pct,
"tp_effective_pct": tp_effective_pct,
"our_leverage": our_leverage,
"tp_curve": "soft_leverage_curve_v1",
},
)
def _hold(self, snapshot: MarketSnapshot, context: DecisionContext, fields: _SnapshotFields, reason: str) -> Decision:
return Decision(
timestamp=fields.ts,
decision_id=self._decision_id(snapshot.symbol, context.trade_seq),
asset=snapshot.symbol,
action=DecisionAction.HOLD,
side=TradeSide.FLAT,
reason=reason,
confidence=0.0,
velocity_divergence=fields.vdiv,
irp_alignment=fields.irp,
reference_price=fields.price,
target_size=0.0,
leverage=1.0,
metadata={"policy_version": self.config.policy_version},
)
@staticmethod
def _extract(snapshot: MarketSnapshot) -> _SnapshotFields:
ts = snapshot.timestamp if isinstance(snapshot.timestamp, datetime) else datetime.utcnow()
return _SnapshotFields(
price=float(snapshot.price or 0.0),
vdiv=float(snapshot.velocity_divergence or 0.0),
irp=float(snapshot.irp_alignment or 0.0),
ts=ts,
)
@staticmethod
def _decision_id(symbol: str, seq: int) -> str:
return f"{symbol}-D-{seq:012d}"
@staticmethod
def _finite(value: float) -> bool:
return value == value and value not in (float("inf"), float("-inf"))

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional, Protocol
from .contracts import KernelTransition, TradeSlot
from .control import KernelControlSnapshot
from .journal import _transition_row
from .projection import build_position_state_row
from .utils import json_safe
# ── Fire-and-forget Hz write helpers ─────────────────────────────────────────
def _hz_write_no_wait(hz_map: Any, key: str, value: str) -> None:
"""Submit Hz write to the client's internal thread pool. Never blocks.
.put() without .blocking() returns a hazelcast Future immediately.
The Future is intentionally discarded — the network write is already
queued in the Hz client's thread pool and is not cancelled by GC.
Hz writes are observability-only; any failure must never affect trading.
"""
try:
hz_map.put(key, value)
except Exception:
pass
def _json_encode(payload: dict) -> str:
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False, default=str)
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _today_iso() -> str:
return datetime.now(timezone.utc).date().isoformat()
class HazelcastClientLike(Protocol):
def get_map(self, name: str): ...
def get_topic(self, name: str): ...
class HazelcastProjector:
"""Durable BLUE/PINK-compatible projection mirror."""
def __init__(
self,
client: HazelcastClientLike | None = None,
*,
active_slots_map: str = "dita_active_slots",
events_topic: str = "dita_trade_events",
) -> None:
self.client = client
self.active_slots_map = active_slots_map
self.events_topic = events_topic
def publish_slot(self, slot: TradeSlot) -> None:
if self.client is None:
return
self.client.get_map(self.active_slots_map).put(slot.trade_id, build_position_state_row(slot))
def publish_event(self, event_type: str, payload: dict[str, Any]) -> None:
if self.client is None:
return
topic = self.client.get_topic(self.events_topic)
topic.publish(
json.dumps(
{"event_type": event_type, "payload": json_safe(payload)},
ensure_ascii=False,
sort_keys=True,
default=str,
)
)
class HazelcastRowWriter:
"""Callback bridge for ``HazelcastProjection`` writer hooks."""
def __init__(self, client: HazelcastClientLike) -> None:
self.client = client
def __call__(self, name: str, row: dict[str, Any]) -> None:
if name.endswith("trade_events"):
self.client.get_topic(name).publish(
json.dumps(row, ensure_ascii=False, sort_keys=True, default=str)
)
return
if name.endswith("control"):
key = "control"
else:
key = str(row.get("trade_id", row.get("slot_id", row.get("event_id", ""))))
self.client.get_map(name).put(key, json_safe(row))
# ── PINK DITAv2 non-blocking Hz state writer ──────────────────────────────────
class PinkHzStateWriter:
"""Non-blocking Hz writer for PINK DITAv2 kernel state.
Dedicated Hz client (separate from the data-feed read client).
All writes are fire-and-forget: .put() returns a Future that is intentionally
discarded. A failed write = missed TUI update only — never affects trading.
BLUE-compatible schema (same shape as DOLPHIN_STATE_BLUE) written to
DOLPHIN_STATE_PINK / DOLPHIN_PNL_PINK — no overlap with BLUE maps.
"""
def __init__(
self,
cluster: str,
host: str,
state_map_name: str,
pnl_map_name: str,
) -> None:
import hazelcast
self._client = hazelcast.HazelcastClient(
cluster_name=cluster,
cluster_members=[host],
)
# Non-blocking proxies (.put() returns Future, does NOT block)
self._state_map = self._client.get_map(state_map_name)
self._pnl_map = self._client.get_map(pnl_map_name)
def write_engine_snapshot(
self,
slot_dict: dict,
acc_dict: dict,
posture: str = "APEX",
our_leverage: float = 0.0,
) -> None:
"""Write full engine state. Called after every kernel mutation (non-blocking)."""
payload: dict[str, Any] = {
"strategy": "pink",
"capital": acc_dict.get("capital", 0.0),
"equity": acc_dict.get("equity", 0.0),
"available_capital": acc_dict.get("available_capital", 0.0),
"pnl": acc_dict.get("realized_pnl_total", 0.0),
"fee_total": acc_dict.get("fee_total", 0.0),
"open_positions": int(acc_dict.get("open_positions", 0)),
"trade_seq": int(acc_dict.get("trade_seq", 0)),
"posture": posture,
"capital_frozen": bool(acc_dict.get("capital_frozen", False)),
"our_leverage": our_leverage,
"slot": slot_dict,
"updated_at": _utcnow_iso(),
}
_hz_write_no_wait(self._state_map, "engine_snapshot", _json_encode(payload))
# Compact "latest" key — same shape as BLUE's DOLPHIN_STATE_BLUE["latest"]
_hz_write_no_wait(self._state_map, "latest", _json_encode({
"strategy": "pink",
"capital": payload["capital"],
"date": _today_iso(),
"pnl": payload["pnl"],
"trades": payload["trade_seq"],
"posture": posture,
"updated_at": payload["updated_at"],
}))
def write_daily_pnl(self, acc_dict: dict, posture: str = "APEX") -> None:
"""Write per-date PnL row. Called on trade close only."""
_hz_write_no_wait(self._pnl_map, _today_iso(), _json_encode({
"pnl": acc_dict.get("realized_pnl_total", 0.0),
"capital": acc_dict.get("capital", 0.0),
"trades": int(acc_dict.get("trade_seq", 0)),
"posture": posture,
}))
def close(self) -> None:
try:
self._client.shutdown()
except Exception:
pass

View File

@@ -1252,3 +1252,143 @@ class TestW10HttpErrorMapping:
def test_dns_error_is_rate_limited(self): def test_dns_error_is_rate_limited(self):
assert self._status("Name or service not known") == "RATE_LIMITED" assert self._status("Name or service not known") == "RATE_LIMITED"
# ============================================================
# PinkHzStateWriter: non-blocking Hz write correctness
# ============================================================
class TestPinkHzStateWriter:
"""PinkHzStateWriter: payload shape, vol_ok gate, and non-blocking guarantees."""
def _make_writer_no_hz(self):
"""Build a PinkHzStateWriter with a mock client that captures writes."""
from prod.clean_arch.dita_v2.hazelcast_projection import PinkHzStateWriter
import unittest.mock as mock
w = object.__new__(PinkHzStateWriter)
w._writes = {} # {(map_attr, key): value}
# Build fake non-blocking IMap proxy
def _make_map(name):
m = mock.MagicMock(name=f"map:{name}")
def _put(key, value):
w._writes[(name, key)] = value
m.put.side_effect = _put
return m
w._state_map = _make_map("DOLPHIN_STATE_PINK")
w._pnl_map = _make_map("DOLPHIN_PNL_PINK")
w._client = mock.MagicMock()
return w
def test_engine_snapshot_writes_two_keys(self):
w = self._make_writer_no_hz()
w.write_engine_snapshot(
{"slot_id": 0, "fsm_state": "IDLE"},
{"capital": 25000.0, "trade_seq": 42},
posture="APEX",
)
assert ("DOLPHIN_STATE_PINK", "engine_snapshot") in w._writes, (
"PinkHzStateWriter must write engine_snapshot key"
)
assert ("DOLPHIN_STATE_PINK", "latest") in w._writes, (
"PinkHzStateWriter must write latest key (BLUE-compatible)"
)
def test_engine_snapshot_has_strategy_pink(self):
import json
w = self._make_writer_no_hz()
w.write_engine_snapshot({"slot_id": 0}, {"capital": 10000.0})
snap = json.loads(w._writes[("DOLPHIN_STATE_PINK", "engine_snapshot")])
assert snap["strategy"] == "pink", "engine_snapshot must identify as pink"
def test_latest_key_has_blue_compatible_fields(self):
import json
w = self._make_writer_no_hz()
w.write_engine_snapshot({"slot_id": 0}, {"capital": 5000.0, "realized_pnl_total": 123.4, "trade_seq": 7})
latest = json.loads(w._writes[("DOLPHIN_STATE_PINK", "latest")])
for field in ("strategy", "capital", "date", "pnl", "trades", "posture", "updated_at"):
assert field in latest, f"BLUE-compatible 'latest' key missing field: {field}"
def test_our_leverage_in_snapshot(self):
import json
w = self._make_writer_no_hz()
w.write_engine_snapshot(
{"slot_id": 0, "size": 0.5, "entry_price": 50000.0},
{"capital": 25000.0},
our_leverage=1.0,
)
snap = json.loads(w._writes[("DOLPHIN_STATE_PINK", "engine_snapshot")])
assert "our_leverage" in snap, "our_leverage (dual-leverage: system layer) must be in Hz snapshot"
def test_daily_pnl_write(self):
import json
w = self._make_writer_no_hz()
w.write_daily_pnl({"realized_pnl_total": 45.6, "capital": 25000.0, "trade_seq": 3})
key = next((k for k in w._writes if k[0] == "DOLPHIN_PNL_PINK"), None)
assert key is not None, "write_daily_pnl must write to DOLPHIN_PNL_PINK"
row = json.loads(w._writes[key])
assert row["pnl"] == 45.6
def test_write_survives_exception(self):
"""Hz write failure must never propagate — observability must not affect trading."""
from prod.clean_arch.dita_v2.hazelcast_projection import _hz_write_no_wait
import unittest.mock as mock
bad_map = mock.MagicMock()
bad_map.put.side_effect = RuntimeError("Hz down")
_hz_write_no_wait(bad_map, "key", "value") # must not raise
# ============================================================
# vol_ok gate in DecisionEngine
# ============================================================
class TestVolOkGate:
"""DecisionEngine must block ENTERs when vol_ok=False in scan_payload."""
def _make_snapshot(self, vol_ok: bool, vdiv: float = -0.03, irp: float = 0.60):
from prod.clean_arch.ports.data_feed import MarketSnapshot
from datetime import datetime, timezone
return MarketSnapshot(
timestamp=datetime.now(timezone.utc),
symbol="BTCUSDT",
price=50000.0,
velocity_divergence=vdiv,
irp_alignment=irp,
scan_payload={"vol_ok": vol_ok, "posture": "APEX"},
)
def _engine(self):
from prod.clean_arch.dita.decision import DecisionEngine, DecisionConfig
cfg = DecisionConfig(
vel_div_threshold=-0.02,
vel_div_extreme=-0.05,
fixed_tp_pct=0.0020,
max_hold_bars=250,
capital_fraction=0.20,
max_leverage=3.0,
allow_short=True,
allow_long=False,
)
return DecisionEngine(cfg)
def _ctx(self, open_positions: int = 0, capital: float = 25000.0):
from prod.clean_arch.dita.contracts import DecisionContext
return DecisionContext(capital=capital, open_positions=open_positions)
def test_vol_ok_false_blocks_enter(self):
eng = self._engine()
snap = self._make_snapshot(vol_ok=False)
decision = eng.decide(snap, self._ctx())
assert decision.action.value in ("HOLD", "NO_ACTION", "SKIP", "VOL_GATE"), (
f"vol_ok=False must block ENTER, got action={decision.action.value!r} reason={getattr(decision, 'reason', '?')!r}"
)
def test_vol_ok_true_allows_enter(self):
eng = self._engine()
snap = self._make_snapshot(vol_ok=True)
decision = eng.decide(snap, self._ctx())
assert decision.action.value not in ("VOL_GATE",), (
"vol_ok=True must not block on vol_ok gate"
)

View File

@@ -12,10 +12,12 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import json
import logging import logging
import math import math
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, Callable, Optional 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, # 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. # so a price below it (or non-finite) signals corrupt market data, not a trade.
_MIN_SANE_PRICE = 1e-8 _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( 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( def _reconcile_position_slot(
kernel: ExecutionKernel, kernel: ExecutionKernel,
exchange_balance_capital: float, exchange_balance_capital: float,
@@ -221,11 +265,15 @@ class PinkDirectRuntime:
market_state_runtime: Any = None market_state_runtime: Any = None
event_sink: Optional[Callable[[dict[str, Any]], None]] = None event_sink: Optional[Callable[[dict[str, Any]], None]] = None
logger: Any = LOGGER 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 state — managed by connect/disconnect, not init args
_account_stream_task: Optional[asyncio.Task] = field( _account_stream_task: Optional[asyncio.Task] = field(
default=None, init=False, repr=False, compare=False default=None, init=False, repr=False, compare=False
) )
_enter_frozen: bool = field(default=False, 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: async def connect(self, initial_capital: float = 25000.0) -> None:
"""Connect data feed, venue, seed capital from exchange, start WS stream.""" """Connect data feed, venue, seed capital from exchange, start WS stream."""
@@ -246,6 +294,11 @@ class PinkDirectRuntime:
self.kernel.set_seed_capital(initial_capital) self.kernel.set_seed_capital(initial_capital)
await self._seed_account_from_exchange() 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. # Start WS account stream (primary); poll failover handled inside stream.
self._account_stream_task = asyncio.create_task( self._account_stream_task = asyncio.create_task(
self._run_account_stream(), name="pink_account_stream" 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. # BingX VST/LIVE taker fee schedule. These are the current published rates.
# Override via set_exchange_config() if the exchange adjusts them. # 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 "taker_rate": 0.0005, # 0.05% market orders
"maker_rate": 0.0002, # 0.02% limit resting "maker_rate": 0.0002, # 0.02% limit resting
"lot_step": 0.001, "lot_step": 0.001,
"tick_size": 0.0001, "tick_size": 0.0001,
"funding_interval_secs": 28_800, # 8 h BingX perps "funding_interval_secs": 28_800, # 8 h BingX perps
} })
async def _seed_account_from_exchange(self) -> None: 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: if fill_price <= 0 or fill_qty <= 0 or actual_fee <= 0:
self.logger.info("Fee calibration: fill row missing price/qty/fee — skipping") self.logger.info("Fee calibration: fill row missing price/qty/fee — skipping")
return 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", "?") status = report.get("calibration_status", "?")
log = self.logger.error if status == "ERROR" else self.logger.info log = self.logger.error if status == "ERROR" else self.logger.info
log( log(
@@ -395,14 +450,20 @@ class PinkDirectRuntime:
"fill_price": event.fill_price, "fill_price": event.fill_price,
"fill_qty": event.fill_qty, "fill_qty": event.fill_qty,
"realized_pnl": event.realized_pnl, "realized_pnl": event.realized_pnl,
"is_maker": event.is_maker,
}) })
# Also fold actual fee if WS delivered it # Fold actual fee if WS delivered it (replaces prediction)
if event.fee > 0: if event.fee != 0:
self.kernel.on_account_event({ self.kernel.on_account_event({
"kind": "FILL_SETTLED", "kind": "FILL_SETTLED",
"event_id": event.event_id,
"realized_pnl": 0.0, # already folded above "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: elif event.kind == ExchangeEventKind.ACCOUNT_UPDATE:
result = self.kernel.on_account_event({ result = self.kernel.on_account_event({
"kind": "ACCOUNT_UPDATE", "kind": "ACCOUNT_UPDATE",
@@ -421,12 +482,20 @@ class PinkDirectRuntime:
result.get("reconcile_explanation", ""), result.get("reconcile_explanation", ""),
) )
self._enter_frozen = True 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: else:
if self._enter_frozen: if self._enter_frozen:
self.logger.info( self.logger.info(
"Account reconcile %s — unfreezing ENTERs.", status "Account reconcile %s — unfreezing ENTERs.", status
) )
self._enter_frozen = False 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: elif event.kind == ExchangeEventKind.FUNDING_FEE:
self.kernel.on_account_event({ self.kernel.on_account_event({
"kind": "FUNDING_FEE", "kind": "FUNDING_FEE",
@@ -524,12 +593,35 @@ class PinkDirectRuntime:
if isinstance(scan_payload.get("esof_payload"), dict) if isinstance(scan_payload.get("esof_payload"), dict)
else None, else None,
) )
# Track posture for Hz writes
self._last_posture = str(scan_payload.get("posture") or "APEX")
return dict( return dict(
getattr(runtime, "latest_bundle_dict", {}) or bundle.as_dict() getattr(runtime, "latest_bundle_dict", {}) or bundle.as_dict()
) )
except Exception: except Exception:
return {} 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( async def pump_venue_events(
self, snapshot: Any | None = None, *, market_state: Any = None self, snapshot: Any | None = None, *, market_state: Any = None
) -> int: ) -> int:
@@ -586,6 +678,9 @@ class PinkDirectRuntime:
slot_dict=slot_dict, slot_dict=slot_dict,
market_state=market_state or {}, 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) return len(applied)
def _unsafe_entry_reason(self, kernel_intent: KernelIntent, context: Any) -> Optional[str]: def _unsafe_entry_reason(self, kernel_intent: KernelIntent, context: Any) -> Optional[str]:
@@ -785,6 +880,20 @@ class PinkDirectRuntime:
phase="execution", phase="execution",
market_state=market_state, 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: else:
# HOLD / no-op: update mark price in kernel. # HOLD / no-op: update mark price in kernel.
if snapshot.price and snapshot.price > 0: if snapshot.price and snapshot.price > 0:

417
prod/launch_dolphin_pink.py Normal file
View File

@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""PINK live launcher — DITAv2-backed execution.
Wires PINK decision/intent logic through the DITAv2 kernel + BingX venue
adapter. The kernel owns the single-slot FSM, AccountProjection (capital
settled from fills, not balance-poll overwritten), Zinc shared-memory mirror,
and Hazelcast slot projection.
"""
from __future__ import annotations
import asyncio
from copy import deepcopy
import contextlib
import os
import sys
from pathlib import Path
from enum import Enum
from typing import Any
from datetime import datetime
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT / "prod"))
sys.path.insert(0, str(PROJECT_ROOT / "prod" / "clean_arch"))
sys.path.insert(0, str(PROJECT_ROOT))
from dotenv import load_dotenv
load_dotenv(PROJECT_ROOT / ".env")
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.config import BingxInstrumentProviderConfig
from prod.bingx.enums import BingxEnvironment
from prod.clean_arch.adapters.hazelcast_feed import HazelcastDataFeed
from prod.clean_arch.dita import DecisionConfig
from prod.clean_arch.dita import DecisionEngine
from prod.clean_arch.dita import IntentEngine
from prod.clean_arch.dita_v2.launcher import build_launcher_bundle
from prod.clean_arch.persistence import PinkClickHousePersistence
from adaptive_exit.market_state_runtime import MarketStateRuntime
from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime
from prod.clean_arch.runtime.runner_heartbeat import (
build_runner_heartbeat_payload,
write_runner_heartbeat,
)
PINK_DEFAULTS = {
"strategy_name": "pink",
"state_map": "DOLPHIN_STATE_PINK",
"pnl_map": "DOLPHIN_PNL_PINK",
"trader_id": "DOLPHIN-PINK-001",
"journal_strategy": "pink",
"journal_db": "dolphin_pink",
"fixed_tp_pct": 0.0020,
"vol_p60_threshold": -1000000000.0,
}
class PinkPhase(str, Enum):
"""Feature-gate phases for the standalone PINK launcher."""
BOOTSTRAP = "bootstrap"
SINGLE_LEG = "single_leg"
MULTI_EXIT = "multi_exit"
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _env_upper(name: str, default: str = "") -> str:
return str(os.environ.get(name, default)).strip().upper()
def _resolve_bingx_environment() -> BingxEnvironment:
name = str(os.environ.get("DOLPHIN_BINGX_ENV", "VST")).strip().upper()
return BingxEnvironment.LIVE if name == "LIVE" else BingxEnvironment.VST
def _resolve_bingx_allow_mainnet() -> bool:
return _env_bool("DOLPHIN_BINGX_ALLOW_MAINNET", False)
def _resolve_bingx_recv_window_ms() -> int:
raw = str(os.environ.get("DOLPHIN_BINGX_RECV_WINDOW_MS", "5000")).strip()
try:
parsed = int(raw)
except Exception:
return 5000
return parsed if parsed > 0 else 5000
def _resolve_bingx_exchange_leverage_cap() -> int:
raw = str(os.environ.get("DOLPHIN_BINGX_EXCHANGE_LEVERAGE_CAP", "3")).strip()
try:
parsed = int(raw)
except Exception:
return 3
return parsed if parsed > 0 else 3
def _resolve_pink_vol_p60_threshold() -> float:
raw = str(os.environ.get("DOLPHIN_PINK_VOL_P60_THRESHOLD", PINK_DEFAULTS["vol_p60_threshold"])).strip()
try:
return float(raw)
except Exception:
return float(PINK_DEFAULTS["vol_p60_threshold"])
def _resolve_pink_phase() -> PinkPhase:
raw = str(os.environ.get("DOLPHIN_PINK_PHASE", PinkPhase.SINGLE_LEG.value)).strip().lower()
for phase in PinkPhase:
if raw == phase.value:
return phase
return PinkPhase.SINGLE_LEG
def _resolve_pink_account_sync_interval_sec() -> float:
"""Account sync is now advisory — kernel tracks capital via settle()
on close. Periodic reconcile re-seeds capital from exchange balance,
mainly as a safety net for long-running sessions."""
raw = str(os.environ.get("DOLPHIN_PINK_ACCOUNT_SYNC_INTERVAL_SEC", "300")).strip()
try:
parsed = float(raw)
except Exception:
return 300.0
return parsed if parsed > 0 else 300.0
def _resolve_pink_exit_leg_ratios(phase: PinkPhase) -> tuple[float, ...]:
if phase is PinkPhase.MULTI_EXIT:
raw = str(os.environ.get("DOLPHIN_PINK_EXIT_LEG_RATIOS", "0.5,1.0")).strip()
ratios: list[float] = []
for chunk in raw.split(","):
try:
value = float(chunk.strip())
except Exception:
continue
if 0.0 < value <= 1.0:
ratios.append(value)
if ratios:
return tuple(ratios)
return (0.5, 1.0)
return (1.0,)
def _set_ditav2_env_defaults() -> None:
os.environ.setdefault("DITA_V2_VENUE", "BINGX")
os.environ.setdefault("DITA_V2_HAZELCAST", "REAL")
os.environ.setdefault("DITA_V2_MODE", "DEBUG")
os.environ.setdefault("DITA_V2_VERBOSITY", "TRACE")
os.environ.setdefault("DITA_V2_PREFIX", "pink")
os.environ.setdefault("DOLPHIN_BINGX_ENV", "VST")
os.environ.setdefault("DOLPHIN_BINGX_ALLOW_MAINNET", "0")
def _apply_pink_namespace_env() -> None:
os.environ["DOLPHIN_STRATEGY_NAME"] = PINK_DEFAULTS["strategy_name"]
os.environ["DOLPHIN_STATE_MAP"] = PINK_DEFAULTS["state_map"]
os.environ["DOLPHIN_PNL_MAP"] = PINK_DEFAULTS["pnl_map"]
os.environ["DOLPHIN_JOURNAL_STRATEGY"] = PINK_DEFAULTS["journal_strategy"]
os.environ["DOLPHIN_JOURNAL_DB"] = PINK_DEFAULTS["journal_db"]
os.environ["DOLPHIN_FIXED_TP_PCT"] = f'{PINK_DEFAULTS["fixed_tp_pct"]:.4f}'
os.environ["DOLPHIN_BINGX_ENV"] = "VST"
os.environ["DOLPHIN_BINGX_ALLOW_MAINNET"] = "0"
def _apply_pink_env() -> None:
_set_ditav2_env_defaults()
_apply_pink_namespace_env()
def _apply_pink_actor_overrides(actor_cfg: dict[str, Any]) -> dict[str, Any]:
cfg: dict[str, Any] = deepcopy(actor_cfg) if actor_cfg else {}
cfg["strategy_name"] = PINK_DEFAULTS["strategy_name"]
hz = cfg.setdefault("hazelcast", {})
hz["state_map"] = PINK_DEFAULTS["state_map"]
hz["imap_pnl"] = PINK_DEFAULTS["pnl_map"]
hz["state_map_aliases"] = []
hz["imap_pnl_aliases"] = []
adaptive_exit = cfg.setdefault("adaptive_exit", {})
adaptive_exit["shadow_db"] = PINK_DEFAULTS["journal_db"]
cfg["v7_journal_db"] = PINK_DEFAULTS["journal_db"]
cfg["sync_bar_idx_from_blue"] = False
vol_p60_threshold = _resolve_pink_vol_p60_threshold()
cfg["vol_p60_threshold"] = vol_p60_threshold
cfg.setdefault("paper_trade", {})["vol_p60"] = vol_p60_threshold
cfg.setdefault("engine", {})["fixed_tp_pct"] = float(PINK_DEFAULTS["fixed_tp_pct"])
return cfg
class BinanceDataClientConfig: # pragma: no cover - compatibility shim
"""Local placeholder so legacy tests can patch the symbol without Nautilus imports."""
class TradingNode: # pragma: no cover - compatibility shim
"""Local placeholder so legacy tests can patch the symbol without Nautilus imports."""
def build_actor_config(
*,
data_venue: str | None = None,
exec_venue: str | None = None,
) -> dict[str, Any]:
"""Build the minimal actor config needed by the direct PINK launcher."""
return _apply_pink_actor_overrides(
{
"strategy_name": PINK_DEFAULTS["strategy_name"],
"hazelcast": {
"state_map": PINK_DEFAULTS["state_map"],
"imap_pnl": PINK_DEFAULTS["pnl_map"],
"state_map_aliases": [],
"imap_pnl_aliases": [],
},
"adaptive_exit": {"shadow_db": PINK_DEFAULTS["journal_db"]},
"paper_trade": {"vol_p60": _resolve_pink_vol_p60_threshold()},
"engine": {"fixed_tp_pct": PINK_DEFAULTS["fixed_tp_pct"]},
"data_venue": (data_venue or "BINANCE").upper(),
"exec_venue": (exec_venue or "BINGX").upper(),
"v7_journal_db": PINK_DEFAULTS["journal_db"],
"sync_bar_idx_from_blue": False,
}
)
def build_bingx_exec_client_config(**_: Any) -> BingxExecClientConfig:
"""Return the direct BingX client config shared by the DITAv2 bundle."""
return BingxExecClientConfig(
api_key=os.environ.get("BINGX_API_KEY"),
secret_key=os.environ.get("BINGX_SECRET_KEY"),
environment=_resolve_bingx_environment(),
allow_mainnet=_resolve_bingx_allow_mainnet(),
recv_window_ms=_resolve_bingx_recv_window_ms(),
default_leverage=int(os.environ.get("DOLPHIN_BINGX_DEFAULT_LEVERAGE", "1")),
exchange_leverage_cap=_resolve_bingx_exchange_leverage_cap(),
prefer_websocket=False,
sizing_mode=os.environ.get("DOLPHIN_BINGX_SIZING_MODE", "testnet"),
journal_strategy="pink",
journal_db="dolphin_pink",
instrument_provider=BingxInstrumentProviderConfig(load_all=True),
)
def build_pink_node(
*,
data_venue: str | None = None,
exec_venue: str | None = None,
trader_id: str | None = None,
) -> dict[str, Any]:
"""Compatibility shim for legacy tests/tools expecting a node-style builder."""
resolved_bingx_env = _resolve_bingx_environment()
resolved_bingx_allow_mainnet = _resolve_bingx_allow_mainnet()
if resolved_bingx_env is BingxEnvironment.LIVE and not resolved_bingx_allow_mainnet:
raise RuntimeError("BingX LIVE requested but DOLPHIN_BINGX_ALLOW_MAINNET is not enabled")
actor_cfg = build_actor_config(
data_venue=(data_venue or "BINANCE"),
exec_venue=(exec_venue or "BINGX"),
)
actor_cfg = _apply_pink_actor_overrides(actor_cfg)
actor_cfg["trader_id"] = trader_id or PINK_DEFAULTS["trader_id"]
actor_cfg["bingx_environment"] = str(resolved_bingx_env.value)
return {"actor_cfg": actor_cfg}
def _build_data_feed() -> HazelcastDataFeed:
return HazelcastDataFeed(
{
"hazelcast": {
"cluster": os.environ.get("HZ_CLUSTER", "dolphin"),
"host": os.environ.get("HZ_HOST", "localhost:5701"),
}
}
)
def _build_runtime(*, phase: PinkPhase) -> PinkDirectRuntime:
data_feed = _build_data_feed()
market_state_runtime = MarketStateRuntime()
# Decision and intent policy — unchanged from BLUE semantics.
cfg = DecisionConfig(
vel_div_threshold=-0.02,
vel_div_extreme=-0.05,
fixed_tp_pct=float(os.environ.get("DOLPHIN_FIXED_TP_PCT", "0.0020")),
max_hold_bars=int(os.environ.get("DOLPHIN_MAX_HOLD_BARS", "250")),
capital_fraction=0.20,
max_leverage=3.0,
allow_short=True,
allow_long=False,
policy_version="pink_ditav2_v1",
exit_leg_ratios=_resolve_pink_exit_leg_ratios(phase),
)
decision = DecisionEngine(cfg)
intent = IntentEngine(cfg)
# DITAv2 execution bundle: kernel + venue + control + Zinc + projection.
bundle = build_launcher_bundle(
venue_mode="BINGX",
max_slots=1,
bingx_config=build_bingx_exec_client_config(),
)
kernel = bundle.kernel
# Persistence reads from the kernel's AccountProjection (single authority).
persistence = PinkClickHousePersistence(kernel.account)
# Non-blocking Hz state writer — writes DOLPHIN_STATE_PINK / DOLPHIN_PNL_PINK.
# Separate client from data-feed (which is read-only). Lazy: any Hz failure
# during instantiation is caught and silenced so PINK still trades without Hz.
hz_state_writer = None
try:
from prod.clean_arch.dita_v2.hazelcast_projection import PinkHzStateWriter
hz_state_writer = PinkHzStateWriter(
cluster=os.environ.get("HZ_CLUSTER", "dolphin"),
host=os.environ.get("HZ_HOST", "localhost:5701"),
state_map_name=PINK_DEFAULTS["state_map"], # "DOLPHIN_STATE_PINK"
pnl_map_name=PINK_DEFAULTS["pnl_map"], # "DOLPHIN_PNL_PINK"
)
except Exception:
pass # Hz down at startup → PINK still trades; TUI shows kernel snapshot fallback
return PinkDirectRuntime(
data_feed=data_feed,
kernel=kernel,
decision_engine=decision,
intent_engine=intent,
persistence=persistence,
market_state_runtime=market_state_runtime,
hz_state_writer=hz_state_writer,
)
async def run() -> None:
_apply_pink_env()
phase = _resolve_pink_phase()
os.environ["DOLPHIN_PINK_PHASE"] = phase.value
runtime = _build_runtime(phase=phase)
symbol = str(os.environ.get("DOLPHIN_PINK_SNAPSHOT_SYMBOL", "BTCUSDT")).strip().upper()
poll_interval = float(os.environ.get("DOLPHIN_PINK_POLL_INTERVAL_SEC", "1.0"))
one_shot = _env_bool("DOLPHIN_PINK_ONE_SHOT", False)
account_sync_interval = _resolve_pink_account_sync_interval_sec()
initial_capital = float(os.environ.get("DOLPHIN_INITIAL_CAPITAL", "25000.0"))
await runtime.connect(initial_capital=initial_capital)
heartbeat_client = None
heartbeat_map = None
heartbeat_stop = asyncio.Event()
heartbeat_task = None
try:
import hazelcast
heartbeat_client = hazelcast.HazelcastClient(
cluster_name=os.environ.get("HZ_CLUSTER", "dolphin"),
cluster_members=[os.environ.get("HZ_HOST", "localhost:5701")],
)
heartbeat_map = heartbeat_client.get_map("DOLPHIN_HEARTBEAT").blocking()
async def _heartbeat_loop() -> None:
while not heartbeat_stop.is_set():
try:
write_runner_heartbeat(
heartbeat_map,
build_runner_heartbeat_payload(
flow="pink_ditav2_runtime",
phase=phase.value,
run_date=str(datetime.utcnow().date()),
runner="pink",
),
)
except Exception:
pass
try:
await asyncio.wait_for(heartbeat_stop.wait(), timeout=10.0)
except asyncio.TimeoutError:
continue
heartbeat_task = asyncio.create_task(_heartbeat_loop())
initial_snapshot = await runtime.data_feed.get_latest_snapshot(symbol)
await runtime.recover_account(
snapshot=initial_snapshot,
phase="startup_reconcile",
event_type="ACCOUNT_RECONCILE",
)
last_account_sync = asyncio.get_running_loop().time()
while True:
snapshot = await runtime.data_feed.get_latest_snapshot(symbol)
loop_now = asyncio.get_running_loop().time()
if account_sync_interval > 0 and loop_now - last_account_sync >= account_sync_interval:
await runtime.reconcile_account(snapshot)
last_account_sync = loop_now
if phase is not PinkPhase.BOOTSTRAP and snapshot is not None:
await runtime.step(snapshot)
if one_shot:
break
await asyncio.sleep(poll_interval)
finally:
heartbeat_stop.set()
if heartbeat_task is not None:
heartbeat_task.cancel()
with contextlib.suppress(BaseException):
await heartbeat_task
if heartbeat_client is not None:
heartbeat_client.shutdown()
if runtime.hz_state_writer is not None:
runtime.hz_state_writer.close()
await runtime.disconnect()
if __name__ == "__main__":
asyncio.run(run())