Files
siloqy/prod/launch_dolphin_violet.py

431 lines
17 KiB
Python

#!/usr/bin/env python3
"""DOLPHIN VIOLET launcher — Stage V1: observe-only runtime (DARK until keys).
VIOLET is the staged rebuild of PINK-on-DITAv2 (charter:
violet-subsecond-rebuild-plan). V1 scope, all enforced here:
- OWN namespaces everywhere: ClickHouse ``dolphin_violet``, Hazelcast
``DOLPHIN_STATE_VIOLET``/``DOLPHIN_PNL_VIOLET``, Zinc prefix ``violet``,
trader id ``DOLPHIN-VIOLET-001``. Namespace isolation is a stage gate.
- OBSERVE-ONLY: the venue adapter is wrapped in ``ObserveOnlyVenue`` (order
placement raises) AND the policy step loop is never invoked. The account
stream folds fills/balances into the kernel and journals them; nothing is
ever submitted.
- DARK by default: without ``BINGX_VIOLET_API_KEY``/``BINGX_VIOLET_SECRET_KEY``
the service idles with a periodic WARNING. The scan-vs-venue divergence
monitor (public data only) runs even while dark
(``DOLPHIN_VIOLET_DARK_DIVERGENCE=1`` default).
- DDL discipline: this launcher NEVER creates ClickHouse objects. If the
``dolphin_violet`` tables are missing it logs CRITICAL naming the apply
command (prod/clickhouse/violet/apply_violet_ddl.py) and idles dark.
Code-reuse decision (V1): import-and-parameterize from
``prod.launch_dolphin_pink`` — its import is side-effect-free (sys.path +
dotenv only; ``_apply_pink_env`` is never invoked at import). A shared
launcher-core extraction is deliberately deferred to V2, when VIOLET starts
trading and the loops converge (V2 work item per the approved plan).
"""
from __future__ import annotations
import asyncio
import logging
import os
import sys
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
sys.path.insert(0, "/mnt/dolphinng5_predict")
sys.path.insert(0, "/mnt/dolphinng5_predict/nautilus_dolphin")
# Side-effect-free helpers shared with the PINK launcher (verified: importing
# the module mutates no environment).
from prod.launch_dolphin_pink import ( # noqa: E402
_build_data_feed,
_env_bool,
_resolve_bingx_allow_mainnet,
_resolve_bingx_environment,
_resolve_bingx_exchange_leverage_cap,
_resolve_bingx_recv_window_ms,
)
from prod.clean_arch.violet.shadow_live_factors import ( # noqa: E402
build_shadow_live_source,
shadow_decision_step,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s violet %(name)s: %(message)s",
)
LOGGER = logging.getLogger("launch_dolphin_violet")
VIOLET_DEFAULTS = {
"strategy_name": "violet",
"state_map": "DOLPHIN_STATE_VIOLET",
"pnl_map": "DOLPHIN_PNL_VIOLET",
"trader_id": "DOLPHIN-VIOLET-001",
"journal_strategy": "violet",
"journal_db": "dolphin_violet",
}
# Tables that must exist BEFORE this service may emit a single row.
REQUIRED_TABLES = (
"account_events", "status_snapshots", "trade_events", "trade_exit_legs",
"trade_reconstruction", "position_state", "policy_events",
"anomaly_events", "v7_decision_events", "violet_feed_divergence",
)
DDL_APPLY_CMD = (
"python3 /mnt/dolphinng5_predict/prod/clickhouse/violet/apply_violet_ddl.py"
)
_KERNEL_STATE_PATH_VIOLET = Path("/tmp/.violet_kernel_state.json")
def _apply_violet_env() -> None:
"""HARD-set (not setdefault) every namespace knob before any import that
reads them. DITA_V2_PREFIX drives Zinc region names (violet_intent/
_state/_control — disjoint from pink by construction)."""
os.environ["DITA_V2_PREFIX"] = "violet"
os.environ["DOLPHIN_STRATEGY_NAME"] = VIOLET_DEFAULTS["strategy_name"]
os.environ["DOLPHIN_STATE_MAP"] = VIOLET_DEFAULTS["state_map"]
os.environ["DOLPHIN_PNL_MAP"] = VIOLET_DEFAULTS["pnl_map"]
os.environ["DOLPHIN_JOURNAL_STRATEGY"] = VIOLET_DEFAULTS["journal_strategy"]
os.environ["DOLPHIN_JOURNAL_DB"] = VIOLET_DEFAULTS["journal_db"]
os.environ["DOLPHIN_TRADER_ID"] = VIOLET_DEFAULTS["trader_id"]
os.environ["DOLPHIN_BINGX_ENV"] = os.environ.get("DOLPHIN_BINGX_ENV", "VST")
os.environ["DOLPHIN_BINGX_ALLOW_MAINNET"] = "0" # V1 is VST-only, hard
# Repoint the kernel snapshot file: pink_direct's module constant is a
# fixed path (/tmp/.pink_kernel_state.json) — without this, VIOLET would
# restore PINK's kernel state and overwrite it on fills.
# Deferred to V2 (approved plan): parameterize the path in pink_direct.
import prod.clean_arch.runtime.pink_direct as _pd
_pd._KERNEL_STATE_PATH = _KERNEL_STATE_PATH_VIOLET
def _violet_keys_present() -> bool:
return bool(
(os.environ.get("BINGX_VIOLET_API_KEY") or "").strip()
and (os.environ.get("BINGX_VIOLET_SECRET_KEY") or "").strip()
)
def build_bingx_exec_client_config():
"""VIOLET's exchange config — its OWN credentials, violet journaling."""
from prod.clean_arch.adapters.bingx_direct import (
BingxExecClientConfig,
BingxInstrumentProviderConfig,
)
return BingxExecClientConfig(
api_key=os.environ.get("BINGX_VIOLET_API_KEY"),
secret_key=os.environ.get("BINGX_VIOLET_SECRET_KEY"),
environment=_resolve_bingx_environment(),
allow_mainnet=False, # V1 hard guarantee
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=VIOLET_DEFAULTS["journal_strategy"],
journal_db=VIOLET_DEFAULTS["journal_db"],
instrument_provider=BingxInstrumentProviderConfig(load_all=True),
)
def _preflight_clickhouse() -> list[str]:
"""SELECT-probe every required dolphin_violet table. NEVER creates."""
missing: list[str] = []
ch_url = os.environ.get("CH_URL", "http://localhost:8123")
user = os.environ.get("CH_USER", "dolphin")
password = os.environ.get("CH_PASS", "dolphin_ch_2026")
for table in REQUIRED_TABLES:
q = urllib.parse.quote_plus(
f"SELECT 0 FROM dolphin_violet.{table} LIMIT 0"
)
req = urllib.request.Request(f"{ch_url}/?query={q}", method="POST")
req.add_header("X-ClickHouse-User", user)
req.add_header("X-ClickHouse-Key", password)
try:
urllib.request.urlopen(req, timeout=5).read()
except Exception:
missing.append(table)
return missing
def _build_observe_runtime():
"""Bundle + observe guard + violet persistence/HZ. NO policy execution."""
from prod.ch_writer import ch_put_violet
from prod.clean_arch.dita.decision import (
DecisionConfig,
DecisionEngine,
IntentEngine,
)
from prod.clean_arch.dita_v2.hazelcast_projection import PinkHzStateWriter
from prod.clean_arch.dita_v2.launcher import build_launcher_bundle
from prod.clean_arch.persistence.pink_clickhouse import PinkClickHousePersistence
from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime
from prod.clean_arch.violet.observe_guard import ObserveOnlyVenue
bundle = build_launcher_bundle(
venue_mode="BINGX", max_slots=1, bingx_config=build_bingx_exec_client_config()
)
kernel = bundle.kernel
# HARD observe-only guarantee, independent of policy config.
kernel.venue = ObserveOnlyVenue(kernel.venue)
cfg = DecisionConfig(
vel_div_threshold=-1.0, # unreachable — V1 never decides anyway
allow_short=False,
allow_long=False,
policy_version="violet_observe_v1",
)
persistence = PinkClickHousePersistence(
kernel.account, sink=ch_put_violet, v7_sink=ch_put_violet
)
hz_state_writer = None
try:
hz_state_writer = PinkHzStateWriter(
cluster=os.environ.get("HZ_CLUSTER", "dolphin"),
host=os.environ.get("HZ_HOST", "localhost:5701"),
state_map_name=VIOLET_DEFAULTS["state_map"],
pnl_map_name=VIOLET_DEFAULTS["pnl_map"],
)
except Exception:
pass # HZ down → still observes; TUI falls back to CH
return PinkDirectRuntime(
data_feed=_build_data_feed(),
kernel=kernel,
decision_engine=DecisionEngine(cfg),
intent_engine=IntentEngine(cfg),
persistence=persistence,
market_state_runtime=None,
hz_state_writer=hz_state_writer,
)
def _build_divergence(sink=None):
from prod.ch_writer import ch_put_violet
from prod.clean_arch.violet.clock import (
PlaneClock,
SCAN_STALENESS_NS,
VENUE_STALENESS_NS,
)
from prod.clean_arch.violet.divergence import FeedDivergenceMonitor
return FeedDivergenceMonitor(
sink=sink or ch_put_violet,
scan_clock=PlaneClock("scan", SCAN_STALENESS_NS),
venue_clock=PlaneClock("venue", VENUE_STALENESS_NS),
session_id=uuid.uuid4().hex,
)
async def _divergence_driver(divergence, data_feed, poll_s: float, shadow=None) -> None:
"""Shared scan-sampling loop for both DARK and observe modes.
When ``shadow`` is provided (DOLPHIN_VIOLET_DECISION_SHADOW=1), each scan is
ALSO fed to the V3 VioletDecisionEngine and any actuated decision is journaled
to dolphin_violet.violet_decisions. This NEVER executes — muted shadow only.
"""
# The driver owns this feed instance and is its only connector; an
# unconnected HazelcastDataFeed has features_map=None and every poll
# raises 'NoneType' has no attribute 'get' at ERROR level (1 Hz).
while True:
ok = False
try:
ok = await data_feed.connect()
except Exception as exc: # noqa: BLE001 — sampling must never die
LOGGER.warning("divergence data feed connect error: %s", exc)
if ok:
break
LOGGER.warning("divergence data feed not connected; retrying in 5s")
await asyncio.sleep(5.0)
started = False
symbol = os.environ.get("DOLPHIN_VIOLET_SNAPSHOT_SYMBOL", "BTCUSDT")
mode = os.environ.get("DOLPHIN_VIOLET_VENUE_MID_MODE", "ws").lower()
while True:
try:
snapshot = await data_feed.get_latest_snapshot(symbol)
if snapshot is not None:
payload = getattr(snapshot, "scan_payload", None) or {}
assets = payload.get("assets") or []
if not started and assets:
await divergence.start(
[str(a).upper() for a in assets], mode=mode
)
started = True
if started:
divergence.on_scan(snapshot)
if shadow is not None and started:
try:
sn = int(payload.get("scan_number") or 0)
vd = payload.get("vel_div")
if vd is not None:
now_ns = shadow["mono_ns"]()
if shadow_decision_step(
shadow,
payload,
scan_number=sn,
now_ns=now_ns,
vel_div=float(vd),
vol_ok=bool(payload.get("vol_ok", True)),
):
shadow["live_decisions"] += 1
except Exception as exc: # noqa: BLE001 — shadow must never die
LOGGER.debug("shadow decision failed: %s", exc)
except Exception as exc: # noqa: BLE001 — sampling must never die
LOGGER.debug("divergence scan sample failed: %s", exc)
await asyncio.sleep(poll_s)
async def _dark_idle_loop(divergence_task: asyncio.Task | None) -> None:
msg = (
"VIOLET DARK — BINGX_VIOLET_API_KEY/BINGX_VIOLET_SECRET_KEY not set; "
"idling (divergence sampling %s)."
% ("running" if divergence_task is not None else "off")
)
LOGGER.warning(msg)
while True:
await asyncio.sleep(300)
LOGGER.warning(msg)
def _violet_table_present(table: str) -> bool:
"""One-table SELECT-probe (additive tables not in REQUIRED_TABLES preflight)."""
ch_url = os.environ.get("CH_URL", "http://localhost:8123")
q = urllib.parse.quote_plus(f"SELECT 0 FROM dolphin_violet.{table} LIMIT 0")
req = urllib.request.Request(f"{ch_url}/?query={q}", method="POST")
req.add_header("X-ClickHouse-User", os.environ.get("CH_USER", "dolphin"))
req.add_header("X-ClickHouse-Key", os.environ.get("CH_PASS", "dolphin_ch_2026"))
try:
urllib.request.urlopen(req, timeout=5).read()
return True
except Exception:
return False
def _build_shadow():
"""V3 shadow-decision path (DOLPHIN_VIOLET_DECISION_SHADOW=1, default OFF).
Returns wiring dict or None. NEVER executes — journals muted decisions only.
Self-disables if violet_decisions is absent so a missing additive DDL can
NEVER cause a CH-flush doom-loop (the 2026-06-11 spool/disk-fill lesson).
"""
if not _env_bool("DOLPHIN_VIOLET_DECISION_SHADOW", False):
return None
if not _violet_table_present("violet_decisions"):
LOGGER.warning(
"VIOLET shadow requested but dolphin_violet.violet_decisions is MISSING "
"— apply 22_violet_decisions.sql; shadow DISABLED (no doom-loop)."
)
return None
from prod.ch_writer import ch_put_violet
from prod.clean_arch.violet.clock import mono_ns
from prod.clean_arch.violet.decision_engine import VioletDecisionEngine
from prod.clean_arch.violet.shadow_journal import VioletDecisionJournal
sess = uuid.uuid4().hex
capital = float(os.environ.get("DOLPHIN_VIOLET_SHADOW_CAPITAL", "69000"))
# engine defaults == live BLUE base curve (max_leverage 9.0, vel_div_threshold -0.02).
# DOLPHIN_VIOLET_ENTRY_VEL_DIV_THRESHOLD relaxes the entry gate VIOLET-ONLY (e.g. to
# exercise the journal on a low-vol day). NOT BLUE-parity-faithful while relaxed.
thr = float(os.environ.get("DOLPHIN_VIOLET_ENTRY_VEL_DIV_THRESHOLD", "-0.02"))
relaxed = abs(thr - (-0.02)) > 1e-9
engine = VioletDecisionEngine(entry_vel_div_threshold=thr)
journal = VioletDecisionJournal(sink=ch_put_violet, session_id=sess)
try:
live_source = build_shadow_live_source()
except Exception as exc:
LOGGER.warning(
"VIOLET shadow live-factor source unavailable (%s) — shadow DISABLED.",
exc,
)
return None
LOGGER.warning(
"VIOLET DECISION SHADOW ON (session=%s ref_capital=%.0f entry_thr=%.4f%s) — "
"journaling muted decisions to dolphin_violet.violet_decisions; NO orders.",
sess, capital, thr,
" RELAXED:not-parity-faithful" if relaxed else "",
)
return {
"engine": engine,
"journal": journal,
"capital": capital,
"mono_ns": mono_ns,
**live_source,
"live_decisions": 0,
"last_live_source": None,
}
async def run() -> None:
_apply_violet_env()
poll_s = float(os.environ.get("DOLPHIN_VIOLET_POLL_INTERVAL_SEC", "1.0"))
missing = _preflight_clickhouse()
if missing:
LOGGER.critical(
"dolphin_violet tables MISSING: %s — this launcher never creates "
"tables (DDL-before-code discipline). Run: %s — idling dark.",
missing, DDL_APPLY_CMD,
)
while True: # dark, emitting nothing
await asyncio.sleep(300)
LOGGER.critical("still missing dolphin_violet tables: %s", missing)
divergence_task = None
if _env_bool("DOLPHIN_VIOLET_DARK_DIVERGENCE", True) or _violet_keys_present():
divergence = _build_divergence()
shadow = _build_shadow()
divergence_task = asyncio.create_task(
_divergence_driver(divergence, _build_data_feed(), poll_s, shadow=shadow),
name="violet_divergence_driver",
)
if not _violet_keys_present():
await _dark_idle_loop(divergence_task)
return
runtime = _build_observe_runtime()
LOGGER.info(
"VIOLET observe-only runtime starting: trader=%s zinc_prefix=violet "
"hz=%s/%s ch=dolphin_violet",
VIOLET_DEFAULTS["trader_id"], VIOLET_DEFAULTS["state_map"],
VIOLET_DEFAULTS["pnl_map"],
)
await runtime.connect()
symbol = os.environ.get("DOLPHIN_VIOLET_SNAPSHOT_SYMBOL", "BTCUSDT")
account_sync_s = float(os.environ.get("DOLPHIN_VIOLET_ACCOUNT_SYNC_SEC", "60"))
last_sync = 0.0
loop = asyncio.get_running_loop()
while True:
try:
snapshot = await runtime.data_feed.get_latest_snapshot(symbol)
now = loop.time()
if snapshot is not None and now - last_sync >= account_sync_s:
await runtime.reconcile_account(snapshot)
last_sync = now
# OBSERVE-ONLY: runtime.step() is NEVER called — no decisions,
# no intents, no orders. The account stream + persistence run
# as connect()-spawned background tasks.
except Exception as exc: # noqa: BLE001 — observe loop must survive
LOGGER.warning("observe loop iteration failed: %s", exc)
await asyncio.sleep(poll_s)
def main() -> int:
try:
asyncio.run(run())
except KeyboardInterrupt:
LOGGER.info("VIOLET shutdown (SIGINT)")
return 0
if __name__ == "__main__":
raise SystemExit(main())