PINK: wire CH persistence to monitor + add missing friction DDL
- migrate_pink_sink_schema.sql: ALTER TABLE adds fee/fee_source/is_maker/ slippage_bps/mark_at_submit/exchange_ts to trade_events and trade_exit_legs; CREATE TABLE fee_settled_events (was missing entirely). DDL already applied. - monitor_pink.py: wire real PinkClickHousePersistence so monitor roundtrips write to dolphin_pink CH tables. Adds _make_decision_intent() helper; calls persist_step() after ENTER and EXIT. Persistence failure is non-fatal (warns and continues). 42 persistence tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
38
prod/clean_arch/dita_v2/migrate_pink_sink_schema.sql
Normal file
38
prod/clean_arch/dita_v2/migrate_pink_sink_schema.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- DITAv2 PINK sink schema migration
|
||||
-- Adds Gap 1/2/3 friction columns to trade_events + trade_exit_legs,
|
||||
-- and creates the missing fee_settled_events table.
|
||||
-- Safe to re-run: ADD COLUMN IF NOT EXISTS + CREATE TABLE IF NOT EXISTS.
|
||||
|
||||
-- ── trade_events ─────────────────────────────────────────────────────────────
|
||||
ALTER TABLE dolphin_pink.trade_events
|
||||
ADD COLUMN IF NOT EXISTS fee Float64 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS fee_source LowCardinality(String) DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS is_maker UInt8 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS slippage_bps Float32 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS mark_at_submit Float64 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS exchange_ts Int64 DEFAULT 0;
|
||||
|
||||
-- ── trade_exit_legs ──────────────────────────────────────────────────────────
|
||||
ALTER TABLE dolphin_pink.trade_exit_legs
|
||||
ADD COLUMN IF NOT EXISTS fee_leg Float64 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS fee_source LowCardinality(String) DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS is_maker UInt8 DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS slippage_bps Float32 DEFAULT 0;
|
||||
|
||||
-- ── fee_settled_events (new table) ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS dolphin_pink.fee_settled_events
|
||||
(
|
||||
ts DateTime64(6, 'UTC'),
|
||||
trade_id String DEFAULT '',
|
||||
fee Float64 DEFAULT 0,
|
||||
fee_asset LowCardinality(String) DEFAULT 'USDT',
|
||||
fee_source LowCardinality(String) DEFAULT 'WS_SETTLED',
|
||||
is_maker UInt8 DEFAULT 0,
|
||||
exchange_ts Int64 DEFAULT 0,
|
||||
realized_pnl_delta Float64 DEFAULT 0,
|
||||
runtime_namespace LowCardinality(String) DEFAULT '',
|
||||
strategy LowCardinality(String) DEFAULT ''
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(ts)
|
||||
ORDER BY (trade_id, ts);
|
||||
332
prod/clean_arch/dita_v2/monitor_pink.py
Normal file
332
prod/clean_arch/dita_v2/monitor_pink.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PINK VST live monitoring — continuous ENTER/EXIT rounds with full trade logging.
|
||||
|
||||
Runs N kernel roundtrips on BingX VST and streams each trade to the console.
|
||||
Use Ctrl+C to stop at any time; the monitor will issue a final flatten before exit.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=/mnt/dolphinng5_predict python monitor_pink.py [--rounds N] [--flatten]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(ROOT / ".env")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
# Quiet httpx noise
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("ch_writer").setLevel(logging.WARNING)
|
||||
log = logging.getLogger("pink_monitor")
|
||||
|
||||
_stop = False
|
||||
|
||||
def _handle_signal(sig, frame):
|
||||
global _stop
|
||||
log.warning("Caught signal %s — stopping after current round", sig)
|
||||
_stop = True
|
||||
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
|
||||
async def _flatten_account(adapter):
|
||||
"""Close all open positions on BingX VST."""
|
||||
try:
|
||||
state = await adapter.refresh_state(None, include_history=False)
|
||||
positions = state.open_positions
|
||||
if not positions:
|
||||
log.info(" FLATTEN: account already flat")
|
||||
return
|
||||
client = adapter._client
|
||||
for sym_key, pos in positions.items():
|
||||
amt = float(pos.get("positionAmt") or pos.get("positionQty") or 0)
|
||||
if abs(amt) < 1e-9:
|
||||
continue
|
||||
venue_sym = str(pos.get("symbol") or sym_key)
|
||||
pos_side = str(pos.get("positionSide") or "BOTH").upper()
|
||||
close_side = "BUY" if (amt < 0 or pos_side == "SHORT") else "SELL"
|
||||
log.info(" FLATTEN: closing %s qty=%.4f via %s", venue_sym, abs(amt), close_side)
|
||||
try:
|
||||
await client.signed_post("/openApi/swap/v2/trade/order", {
|
||||
"symbol": venue_sym,
|
||||
"side": close_side,
|
||||
"positionSide": "BOTH",
|
||||
"type": "MARKET",
|
||||
"quantity": str(abs(amt)),
|
||||
"reduceOnly": "true",
|
||||
})
|
||||
except Exception as e:
|
||||
log.warning(" FLATTEN failed for %s: %s", venue_sym, e)
|
||||
await asyncio.sleep(1.5)
|
||||
state2 = await adapter.refresh_state(None, include_history=False)
|
||||
remaining = {s: r for s, r in state2.open_positions.items()
|
||||
if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-8}
|
||||
if remaining:
|
||||
log.warning(" FLATTEN: still open: %s", list(remaining.keys()))
|
||||
else:
|
||||
log.info(" FLATTEN: account flat ✓")
|
||||
except Exception as e:
|
||||
log.error(" FLATTEN error: %s", e)
|
||||
|
||||
|
||||
def _make_decision_intent(ts, tid, asset, action, price, size):
|
||||
"""Build minimal Decision+Intent pair for persistence from raw monitor round data."""
|
||||
from prod.clean_arch.dita import Decision, Intent, TradeSide, TradeStage
|
||||
decision = Decision(
|
||||
timestamp=ts, decision_id=tid, asset=asset,
|
||||
action=action, side=TradeSide.SHORT,
|
||||
reason=f"monitor_{action.value.lower()}", confidence=0.0,
|
||||
velocity_divergence=0.0, irp_alignment=0.0,
|
||||
reference_price=price, target_size=size, leverage=1.0,
|
||||
bars_held=0, stage=TradeStage.ORDER_REQUESTED, metadata={},
|
||||
)
|
||||
intent = Intent(
|
||||
timestamp=ts, trade_id=tid, decision_id=tid, asset=asset,
|
||||
action=action, side=TradeSide.SHORT,
|
||||
reason=f"monitor_{action.value.lower()}", target_size=size, leverage=1.0,
|
||||
reference_price=price, confidence=0.0, exit_leg_ratios=(1.0,),
|
||||
bars_held=0, stage=TradeStage.ORDER_REQUESTED, metadata={},
|
||||
)
|
||||
return decision, intent
|
||||
|
||||
|
||||
async def _run_one_round(adapter, k, bundle, round_num: int, asset: str, price: float, size: float,
|
||||
pers=None):
|
||||
"""Execute one ENTER → EXIT roundtrip and return (enter_ms, exit_ms, pnl, success)."""
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelCommandType as KC, KernelIntent as KI,
|
||||
TradeSide as TS, TradeStage,
|
||||
)
|
||||
from prod.clean_arch.dita import DecisionAction
|
||||
|
||||
tid = f"mon-{round_num}-{int(time.time()*1000)}"
|
||||
ts = datetime.now(timezone.utc)
|
||||
|
||||
# ── ENTER ──────────────────────────────────────────────────────────────────
|
||||
log.info("Round %d | ENTER SHORT %s @ %.6f size=%.1f", round_num, asset, price, size)
|
||||
t0 = time.perf_counter()
|
||||
enter_outcome = await k.process_intent_async(KI(
|
||||
timestamp=ts,
|
||||
intent_id=tid, trade_id=tid, slot_id=0,
|
||||
asset=asset, side=TS.SHORT, action=KC.ENTER,
|
||||
reference_price=price, target_size=size, leverage=1.0,
|
||||
exit_leg_ratios=(1.0,), reason="monitor_enter", metadata={},
|
||||
))
|
||||
dt_enter = (time.perf_counter() - t0) * 1000
|
||||
|
||||
# accepted=True + state=IDLE = order processed but rejected by exchange (e.g. "Event loop
|
||||
# is closed" or BingX business reject). accepted=True + state=POSITION_OPEN = real success.
|
||||
if not enter_outcome.accepted or str(enter_outcome.state) in ("TradeStage.IDLE", "IDLE"):
|
||||
log.error(" ENTER FAILED state=%s diag=%s — skipping EXIT",
|
||||
enter_outcome.state, enter_outcome.diagnostic_code)
|
||||
return None, None, None, False
|
||||
|
||||
log.info(" ENTER OK state=%-20s latency=%.0fms", enter_outcome.state, dt_enter)
|
||||
|
||||
if pers is not None:
|
||||
try:
|
||||
snap = SimpleNamespace(timestamp=ts, price=price, symbol=asset)
|
||||
dec, intent = _make_decision_intent(ts, tid, asset, DecisionAction.ENTER, price, size)
|
||||
pers.persist_step(
|
||||
snapshot=snap, decision=dec, intent=intent, outcome=enter_outcome,
|
||||
slot_dict=k.slot(0).to_dict(), acc_dict=k.snapshot()["account"],
|
||||
phase="execution",
|
||||
)
|
||||
except Exception as _pe:
|
||||
log.warning("persist ENTER failed (non-fatal): %s", _pe)
|
||||
|
||||
await asyncio.sleep(1.5) # let S2 refresh settle
|
||||
|
||||
# refresh price for exit
|
||||
try:
|
||||
client = adapter._client
|
||||
ticker = await client.public_get("/openApi/swap/v2/quote/price", {"symbol": asset})
|
||||
if isinstance(ticker, dict):
|
||||
live_price = float(ticker.get("price") or price)
|
||||
else:
|
||||
live_price = price
|
||||
except Exception:
|
||||
live_price = price * 0.998
|
||||
|
||||
# ── EXIT ───────────────────────────────────────────────────────────────────
|
||||
ts_exit = datetime.now(timezone.utc)
|
||||
log.info(" EXIT SHORT @ %.6f", live_price)
|
||||
t1 = time.perf_counter()
|
||||
exit_outcome = await k.process_intent_async(KI(
|
||||
timestamp=ts_exit,
|
||||
intent_id=tid, trade_id=tid, slot_id=0,
|
||||
asset=asset, side=TS.SHORT, action=KC.EXIT,
|
||||
reference_price=live_price, target_size=size, leverage=1.0,
|
||||
exit_leg_ratios=(1.0,), reason="monitor_exit", metadata={},
|
||||
))
|
||||
dt_exit = (time.perf_counter() - t1) * 1000
|
||||
|
||||
if not exit_outcome.accepted:
|
||||
log.error(" EXIT REJECTED (diag=%s)", exit_outcome.diagnostic_code)
|
||||
return dt_enter, None, None, False
|
||||
|
||||
slot = k.slot(0).to_dict()
|
||||
pnl = float(slot.get("realized_pnl") or 0.0)
|
||||
log.info(
|
||||
" EXIT OK state=%-20s latency=%.0fms pnl=%.6f",
|
||||
exit_outcome.state, dt_exit, pnl,
|
||||
)
|
||||
|
||||
if pers is not None:
|
||||
try:
|
||||
snap_x = SimpleNamespace(timestamp=ts_exit, price=live_price, symbol=asset)
|
||||
dec_x, intent_x = _make_decision_intent(
|
||||
ts_exit, tid, asset, DecisionAction.EXIT, live_price, size,
|
||||
)
|
||||
pers.persist_step(
|
||||
snapshot=snap_x, decision=dec_x, intent=intent_x, outcome=exit_outcome,
|
||||
slot_dict=slot, acc_dict=k.snapshot()["account"],
|
||||
phase="execution",
|
||||
)
|
||||
except Exception as _pe:
|
||||
log.warning("persist EXIT failed (non-fatal): %s", _pe)
|
||||
|
||||
return dt_enter, dt_exit, pnl, True
|
||||
|
||||
|
||||
async def main(rounds: int, do_flatten: bool) -> None:
|
||||
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
||||
from prod.clean_arch.dita_v2.launcher import (
|
||||
build_launcher_bundle, LauncherVenueMode, build_bingx_exec_client_config,
|
||||
)
|
||||
from prod.bingx.config import BingxEnvironment
|
||||
|
||||
cfg = build_bingx_exec_client_config()
|
||||
adapter = BingxDirectExecutionAdapter(cfg)
|
||||
bundle = build_launcher_bundle(
|
||||
max_slots=1,
|
||||
venue_mode=LauncherVenueMode.BINGX,
|
||||
bingx_backend=adapter,
|
||||
)
|
||||
k = bundle.kernel
|
||||
|
||||
# Connect using the async path (avoids event-loop hygiene bug)
|
||||
try:
|
||||
await adapter.connect()
|
||||
log.info("Venue connected (async path)")
|
||||
except Exception as e:
|
||||
log.warning("Venue connect warning: %s", e)
|
||||
|
||||
# Wire real CH persistence so trades fire to data sinks.
|
||||
pers = None
|
||||
try:
|
||||
from prod.clean_arch.dita_v2.account import AccountProjection
|
||||
from prod.clean_arch.persistence import (
|
||||
PinkClickHousePersistence, PinkClickHousePersistenceConfig,
|
||||
)
|
||||
initial_capital = float(
|
||||
k.snapshot().get("account", {}).get("capital", 10000.0) or 10000.0
|
||||
)
|
||||
acc_proj = AccountProjection()
|
||||
acc_proj.snapshot.capital = initial_capital
|
||||
acc_proj.snapshot.peak_capital = initial_capital
|
||||
pers = PinkClickHousePersistence(
|
||||
account=acc_proj,
|
||||
config=PinkClickHousePersistenceConfig(
|
||||
strategy="pink",
|
||||
runtime_namespace="dita_v2",
|
||||
strategy_namespace="dita_v2",
|
||||
event_namespace="dita_v2",
|
||||
initial_capital=initial_capital,
|
||||
),
|
||||
kernel=k,
|
||||
)
|
||||
log.info("Persistence wired — trades will write to dolphin_pink CH tables")
|
||||
except Exception as _pe:
|
||||
log.warning("Persistence init failed (non-fatal, runs without CH writes): %s", _pe)
|
||||
|
||||
if do_flatten:
|
||||
log.info("── PRE-FLATTEN ──────────────────────────────────────────────")
|
||||
await _flatten_account(adapter)
|
||||
|
||||
# Fetch live price — use 10 units (confirmed minimum that works on BingX VST)
|
||||
client = adapter._client
|
||||
asset = "TRX-USDT"
|
||||
price = 0.32
|
||||
size = 10.0 # 10 units matches flat_and_start_pink.py minimum known-good size
|
||||
try:
|
||||
ticker = await client.public_get("/openApi/swap/v2/quote/price", {"symbol": asset})
|
||||
if isinstance(ticker, dict):
|
||||
price = float(ticker.get("price") or price)
|
||||
except Exception as e:
|
||||
log.warning("Price fetch failed: %s — using defaults", e)
|
||||
|
||||
log.info("Symbol: %s price=%.6f size=%.1f", asset, price, size)
|
||||
log.info("Starting %d rounds. Ctrl+C to stop early.\n", rounds)
|
||||
|
||||
results = []
|
||||
for i in range(1, rounds + 1):
|
||||
if _stop:
|
||||
log.info("Stopping at user request before round %d", i)
|
||||
break
|
||||
|
||||
log.info("─── Round %d / %d ───────────────────────────────────────────", i, rounds)
|
||||
# Refresh price each round
|
||||
try:
|
||||
ticker = await client.public_get("/openApi/swap/v2/quote/price", {"symbol": asset})
|
||||
if isinstance(ticker, dict):
|
||||
price = float(ticker.get("price") or price)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dt_enter, dt_exit, pnl, success = await _run_one_round(
|
||||
adapter, k, bundle, i, asset, price, size, pers=pers,
|
||||
)
|
||||
results.append((i, dt_enter, dt_exit, pnl, success))
|
||||
|
||||
if not success:
|
||||
log.warning("Round %d failed — pausing 3s then retrying with fresh state", i)
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
await asyncio.sleep(2) # brief pause between rounds
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────
|
||||
log.info("\n══ SUMMARY (%d rounds) ══════════════════════════════════════════", len(results))
|
||||
ok = [r for r in results if r[4]]
|
||||
fail = [r for r in results if not r[4]]
|
||||
if ok:
|
||||
avg_enter = sum(r[1] for r in ok if r[1]) / len(ok)
|
||||
avg_exit = sum(r[2] for r in ok if r[2]) / len(ok)
|
||||
total_pnl = sum(r[3] for r in ok if r[3] is not None)
|
||||
log.info(
|
||||
" Completed: %d/%d avg_enter=%.0fms avg_exit=%.0fms total_pnl=%.6f",
|
||||
len(ok), len(results), avg_enter, avg_exit, total_pnl,
|
||||
)
|
||||
if fail:
|
||||
log.warning(" Failed rounds: %s", [r[0] for r in fail])
|
||||
|
||||
if do_flatten or _stop:
|
||||
log.info("── FINAL FLATTEN ────────────────────────────────────────────")
|
||||
await _flatten_account(adapter)
|
||||
|
||||
bundle.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--rounds", type=int, default=5, help="Number of ENTER/EXIT roundtrips")
|
||||
parser.add_argument("--flatten", action="store_true", help="Flatten positions before start")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.rounds, args.flatten))
|
||||
Reference in New Issue
Block a user