333 lines
14 KiB
Python
333 lines
14 KiB
Python
|
|
#!/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))
|