launch_dolphin_violet.py: own namespaces hard-set (CH dolphin_violet, HZ DOLPHIN_STATE_VIOLET/PNL, Zinc prefix violet, DOLPHIN-VIOLET-001); own credentials (BINGX_VIOLET_API_KEY/SECRET) — DARK idle with periodic WARNING until provisioned; CH preflight SELECT-probes the required tables and NEVER creates (DDL-before-code); kernel snapshot path repointed away from PINK's fixed /tmp/.pink_kernel_state.json; mainnet hard-disabled; observe loop never calls runtime.step(). ObserveOnlyVenue: submit/cancel raise ObserveOnlyViolation with full attribute delegation — the kernel's venue-submit-failure rollback converts a refusal into a synthetic REJECT (slot back to IDLE), proven against the real kernel. FeedDivergenceMonitor: per-asset scan-vs-venue divergence rows (bookTicker WS via prod/bingx/market_stream, REST fallback) with stale-mid suppression and plane seq propagation — the FET 0.2176-vs-0.1878 detector; runs even DARK (public data). Supervisord [program:dolphin_violet] autostart=false, no keys in conf by design. Violet package: 42 tests green + V0 gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
12 KiB
Python
323 lines
12 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,
|
|
)
|
|
|
|
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) -> None:
|
|
"""Shared scan-sampling loop for both DARK and observe modes."""
|
|
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)
|
|
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)
|
|
|
|
|
|
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()
|
|
divergence_task = asyncio.create_task(
|
|
_divergence_driver(divergence, _build_data_feed(), poll_s),
|
|
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())
|