#!/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())