Files
siloqy/prod/launch_dolphin_pink.py
Codex 2c9da8f592 PINK Phase 0: FET -$5,990 fix batch — leverage-free PnL, true fill prices, reconcile baseline anchors
Defects fix (FET -$5,990 replay, 2026-06-11):
- realized_pnl() and mark_price(): PnL = qty × Δprice, side-signed; no ×leverage inflation (was 3× every leg).
- BingX MARKET fill events carry true fill price (avgPrice/lastFillPrice), never the order's nominal price (protective bound ±20-25% from mark, poisoned PnL to -$5,990 on a +$164 round-trip).
- Fill routing by ORDER IDENTITY first, FSM state second — late entry-remainder fills during EXIT_WORKING no longer misclassify as exits.
- Entry basis = VWAP across entry fills, not last fill price.
- reconcile_from_slots / restore_state: re-anchor _last_settled_pnl / _slot_was_closed to adopted slot state (cross-restart double-book of carried PnL).
- ACCOUNT_UPDATE with wallet_balance=0 dropped (margin-only frames no longer zero e_available_margin).
- Foreign-fill skip on shared VST account (PRODGREEN collision filter).
- exec_router TTL: entry-requote venue-truth gate (recent own fill + live exchange position probes prevent double-entry).
- bingx_direct: openOrders fetched BEFORE positions (sequential ordering prevents dangerous tear → double-entries).
- Dual-leverage translation via map_internal_conviction_to_exchange_leverage() (strategy conviction → integer at-exchange leverage, bankers rounding).
- BLUE-parity alpha components wired: asset picker (IRP universe ranking) + alpha sizer (cubic-convex dynamic leverage, 0.5-8.0 range).
- ch_writer: date_time_input_format=best_effort on insert URLs; flush error logging at WARNING with counter.
- blue_parity.price_of(): hyphen-tolerant fallback (FET-USDT → FETUSDT).
- Fill test updated to incremental filled_size semantics (BingX WS lastFilledQty).
- Env-override base URLs, supervisord autorestart, per-asset DC histories, single-slot invariant, fill-attribution filter.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-06-11 20:53:49 +02:00

455 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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.
# DOLPHIN_PINK_VEL_DIV_THRESHOLD: relax for on-exchange debugging (e.g. -0.005).
# Default -0.02 matches BLUE production. BLUE is unaffected.
_vel_div_threshold = float(os.environ.get("DOLPHIN_PINK_VEL_DIV_THRESHOLD", "-0.02"))
# BLUE-parity sizing (SYSTEM BIBLE §6, restored 2026-06-10): cubic-convex
# dynamic leverage over [min, max] strategy conviction. BLUE production
# runs 0.58.0 convexity 3. The integer at-exchange leverage is derived
# separately at the venue boundary (prod/bingx/leverage.py conviction map,
# security-capped). Previously PINK pinned every entry at 3.0x flat.
_min_leverage = float(os.environ.get("DOLPHIN_PINK_MIN_LEVERAGE", "0.5"))
_max_leverage = float(os.environ.get("DOLPHIN_PINK_MAX_LEVERAGE", "8.0"))
_convexity = float(os.environ.get("DOLPHIN_PINK_LEVERAGE_CONVEXITY", "3.0"))
_vel_div_extreme = min(_vel_div_threshold * 2.5, -0.001)
cfg = DecisionConfig(
vel_div_threshold=_vel_div_threshold,
vel_div_extreme=_vel_div_extreme,
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=_max_leverage,
allow_short=True,
allow_long=False,
policy_version="pink_ditav2_v1",
exit_leg_ratios=_resolve_pink_exit_leg_ratios(phase),
)
# BLUE-parity alpha components. Kill switches: DOLPHIN_PINK_ALPHA_SIZER=0
# restores the legacy flat-leverage formula; DOLPHIN_PINK_ASSET_SELECTION=0
# restores single-symbol (snapshot anchor) trading.
alpha_sizer = None
asset_picker = None
if _env_bool("DOLPHIN_PINK_ALPHA_SIZER", True):
from prod.clean_arch.dita_v2.blue_parity import PinkAlphaSizer
alpha_sizer = PinkAlphaSizer(
base_fraction=0.20,
min_leverage=_min_leverage,
max_leverage=_max_leverage,
leverage_convexity=_convexity,
vel_div_threshold=_vel_div_threshold,
vel_div_extreme=_vel_div_extreme,
use_dynamic_leverage=_env_bool("DOLPHIN_PINK_DYNAMIC_LEVERAGE", True),
use_alpha_layers=_env_bool("DOLPHIN_PINK_ALPHA_LAYERS", True),
)
if _env_bool("DOLPHIN_PINK_ASSET_SELECTION", True):
from prod.clean_arch.dita_v2.blue_parity import PinkAssetPicker
asset_picker = PinkAssetPicker(
lookback=int(os.environ.get("DOLPHIN_PINK_IRP_LOOKBACK", "0") or 0),
min_irp_alignment=float(os.environ.get("DOLPHIN_PINK_MIN_IRP_ALIGNMENT", "0.0")),
)
decision = DecisionEngine(cfg, sizer=alpha_sizer)
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,
asset_picker=asset_picker,
alpha_sizer=alpha_sizer,
)
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())