From 025d3816231c9aaba15c62063609640d99d2690c Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 5 Jun 2026 07:37:56 +0200 Subject: [PATCH] =?UTF-8?q?PINK:=20flat=5Fand=5Fstart=5Fpink.py=20?= =?UTF-8?q?=E2=80=94=20flatten=20BingX=20VST=20+=20async=20startup=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: python flat_and_start_pink.py [--flatten] [--no-start] --flatten : cancel all orders + MARKET-close all positions (correct side by positionAmt sign, not abs()); verifies account flat after --no-start: flatten only, skip roundtrip (no flags): startup roundtrip only — ENTER/EXIT via process_intent_async() Startup roundtrip exercises the full N2/N3/N4 async hot path: process_intent_async → submit_async → await backend.submit_intent → BingX POST → on_venue_event(ORDER_ACK+FULL_FILL) → POSITION_OPEN → CLOSED Min-order detection: queries /quote/contracts for tradeMinQty/minOrderQuantity; fallback 10 units. Fixes the 0.001-TRX rejection that BingX returned. Bugs fixed in flatten: - positionAmt sign was lost via abs(); SHORT positions now correctly use BUY (positionAmt < 0) vs SELL for LONG (positionAmt > 0) with reduceOnly=true Co-Authored-By: Claude Sonnet 4.6 --- .../clean_arch/dita_v2/flat_and_start_pink.py | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 prod/clean_arch/dita_v2/flat_and_start_pink.py diff --git a/prod/clean_arch/dita_v2/flat_and_start_pink.py b/prod/clean_arch/dita_v2/flat_and_start_pink.py new file mode 100644 index 0000000..fe65c92 --- /dev/null +++ b/prod/clean_arch/dita_v2/flat_and_start_pink.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Optionally flatten BingX VST, then run a PINK roundtrip startup check. + +Usage: + # Start PINK only (no flatten): + PYTHONPATH=/mnt/dolphinng5_predict python flat_and_start_pink.py + + # Flatten first, then start PINK: + PYTHONPATH=/mnt/dolphinng5_predict python flat_and_start_pink.py --flatten + + # Flatten only (no PINK startup): + PYTHONPATH=/mnt/dolphinng5_predict python flat_and_start_pink.py --flatten --no-start + +Steps (with --flatten): + 1. Load creds from .env + 2. Fetch open positions + orders + 3. Cancel all open orders per symbol + 4. Close all open positions (MARKET reduceOnly) + 5. Verify account is flat + +Steps (startup, always): + 6. Run simple_entry_exit roundtrip via PINK kernel to confirm async path works +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys +import time +from pathlib import Path + +# ── path / env ──────────────────────────────────────────────────────────────── +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", +) +log = logging.getLogger("flat_pink") + +# ── imports after path setup ────────────────────────────────────────────────── +from prod.bingx.config import BingxExecClientConfig +from prod.bingx.enums import BingxEnvironment +from prod.bingx.http import BingxHttpClient +from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter +from prod.clean_arch.dita_v2.contracts import ( + KernelCommandType as E, + KernelIntent as KI, + TradeSide as TS, +) +from prod.clean_arch.dita_v2.launcher import build_launcher_bundle +from datetime import datetime, timezone + + +# ── config ──────────────────────────────────────────────────────────────────── +def _cfg() -> BingxExecClientConfig: + return BingxExecClientConfig( + api_key=os.environ["BINGX_API_KEY"], + secret_key=os.environ["BINGX_SECRET_KEY"], + environment=BingxEnvironment.VST, + allow_mainnet=False, + recv_window_ms=5000, + default_leverage=1, + exchange_leverage_cap=3, + prefer_websocket=False, + use_reduce_only=True, + sizing_mode="testnet", + journal_strategy="pink", + journal_db="dolphin_pink", + ) + + +# ── step 1: flatten BingX ───────────────────────────────────────────────────── +async def flatten_exchange(client: BingxHttpClient, adapter: BingxDirectExecutionAdapter) -> bool: + """Cancel all orders and close all positions. Returns True if account is flat.""" + log.info("── FLATTEN: fetching open positions + orders ──────────────────") + snap = await adapter.refresh_state(None, include_history=False) + + positions = snap.open_positions # {symbol: row_dict} + open_orders = snap.open_orders # [row_dict] + capital = snap.capital + + log.info(" capital=%.2f open_positions=%d open_orders=%d", + capital, len(positions), len(open_orders)) + + if not positions and not open_orders: + log.info(" Account already flat — nothing to do.") + return True + + # Cancel all open orders (per symbol) + cancelled_symbols: set[str] = set() + for order in open_orders: + sym = str(order.get("symbol") or order.get("symbolName") or "") + if not sym or sym in cancelled_symbols: + continue + log.info(" Cancelling all orders for %s …", sym) + try: + await client.signed_delete( + "/openApi/swap/v2/trade/allOpenOrders", + {"symbol": sym}, + ) + cancelled_symbols.add(sym) + log.info(" ✓ Orders cancelled for %s", sym) + except Exception as exc: + log.warning(" ✗ Cancel orders failed for %s: %s", sym, exc) + + await asyncio.sleep(0.5) + + # Close all open positions + for sym_key, pos in positions.items(): + # positionAmt: negative = SHORT (one-way mode), positive = LONG + pos_amt = float(pos.get("positionAmt") or pos.get("positionQty") or pos.get("positionSize") or 0.0) + if abs(pos_amt) < 1e-9: + continue + venue_sym = str(pos.get("symbol") or pos.get("symbolName") or sym_key) + pos_side = str(pos.get("positionSide") or "BOTH").upper() + qty = abs(pos_amt) + + # BingX one-way mode: positionAmt < 0 → SHORT → close with BUY + # positionAmt > 0 → LONG → close with SELL + if pos_amt < 0 or pos_side == "SHORT": + close_side = "BUY" + else: + close_side = "SELL" + + log.info(" Closing %s qty=%.6f via MARKET %s reduceOnly …", venue_sym, abs(qty), close_side) + try: + resp = await client.signed_post( + "/openApi/swap/v2/trade/order", + { + "symbol": venue_sym, + "side": close_side, + "positionSide": "BOTH", + "type": "MARKET", + "quantity": str(abs(qty)), + "reduceOnly": "true", + }, + ) + log.info(" ✓ Close submitted: %s", resp) + except Exception as exc: + log.warning(" ✗ Close failed for %s: %s", venue_sym, exc) + + await asyncio.sleep(1.5) + + # Verify flat + snap2 = await adapter.refresh_state(None, include_history=False) + remaining = {s: r for s, r in snap2.open_positions.items() + if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-8} + remaining_orders = snap2.open_orders + + if remaining: + log.error(" ✗ STILL OPEN positions after flatten: %s", list(remaining.keys())) + return False + if remaining_orders: + log.warning(" ⚠ Still %d open orders after cancel — continuing anyway", len(remaining_orders)) + + log.info(" ✓ Account FLAT. capital=%.2f", snap2.capital) + return True + + +# ── step 2: PINK startup roundtrip ──────────────────────────────────────────── +async def pink_startup_roundtrip(adapter: BingxDirectExecutionAdapter, capital: float) -> bool: + """Connect kernel to BingX VST, run simple SHORT roundtrip, verify flat.""" + log.info("") + log.info("── PINK STARTUP: building kernel bundle ──────────────────────") + + from prod.clean_arch.dita_v2.launcher import build_launcher_bundle, build_bingx_exec_client_config + + cfg = _cfg() + bundle = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=cfg) + k = bundle.kernel + k.account.snapshot.capital = capital + k.account.snapshot.peak_capital = capital + k.account.snapshot.equity = capital + + log.info(" Kernel built. max_slots=%d capital=%.2f", k.max_slots, capital) + + # Connect venue (sync call via _run) + try: + k.venue.connect() + log.info(" Venue connected.") + except Exception as exc: + log.warning(" Venue connect warning (may be ok): %s", exc) + + await asyncio.sleep(0.5) + + # Pick a live symbol + client = adapter._client + try: + snap = await client.signed_get("/openApi/swap/v2/user/positions") + # Just get first available contract from contract list + contracts = await client.public_get("/openApi/swap/v2/quote/contracts") + rows = [] + if isinstance(contracts, dict): + rows = contracts.get("data") or contracts.get("contracts") or [] + elif isinstance(contracts, list): + rows = contracts + # Pick TRXUSDT-PERP as default (small size, low price) + symbol = "TRX-USDT" + vsymbol = "TRX-USDT" + price = 0.085 # fallback price + for row in rows: + sym = str(row.get("symbol") or row.get("contractId") or "") + if "TRX" in sym.upper() and "USDT" in sym.upper(): + vsymbol = sym + break + # Get live price + try: + ticker = await client.public_get("/openApi/swap/v2/quote/price", {"symbol": vsymbol}) + if isinstance(ticker, dict): + price_raw = ticker.get("price") or (ticker.get("data") or {}).get("price") or price + price = float(price_raw) + except Exception: + pass + log.info(" Symbol: %s price=%.6f", vsymbol, price) + except Exception as exc: + log.warning(" Could not pick live symbol: %s — using TRX-USDT @ 0.085", exc) + vsymbol = "TRX-USDT" + price = 0.085 + + asset = vsymbol.replace("-PERP", "").replace("-SWAP", "") + + # Derive minimum viable size from contract info (fallback 10.0) + size = 10.0 + try: + for row in rows: + sym_r = str(row.get("symbol") or row.get("contractId") or "") + if sym_r == vsymbol: + min_qty = float( + row.get("tradeMinQty") or row.get("minOrderQuantity") or + row.get("minQty") or row.get("minOrderQty") or 0.0 + ) + size = max(min_qty, 1.0) if min_qty > 0 else 10.0 + break + except Exception: + pass + + log.info("") + log.info("── PINK STARTUP: simple SHORT roundtrip ──────────────────────") + + tid = f"startup-{int(time.time() * 1000)}" + + # Patch submit_async to log events before returning + log.info(" ENTER SHORT: asset=%s size=%s price=%.6f", asset, size, price) + t0 = time.time() + enter_outcome = await k.process_intent_async(KI( + timestamp=datetime.now(timezone.utc), + intent_id=tid, trade_id=tid, slot_id=0, + asset=asset, side=TS.SHORT, action=E.ENTER, + reference_price=price, target_size=size, leverage=1.0, + exit_leg_ratios=(1.0,), reason="startup_check", metadata={}, + )) + dt_enter = time.time() - t0 + log.info(" ENTER result: accepted=%s state=%s diag=%s (%.2fs)", + enter_outcome.accepted, enter_outcome.state, enter_outcome.diagnostic_code, dt_enter) + + if not enter_outcome.accepted: + log.error(" ✗ ENTER rejected — PINK startup FAILED") + bundle.close() + return False + + log.info(" Slot after ENTER: %s size=%s", k.slot(0).fsm_state, k.slot(0).size) + + await asyncio.sleep(1.0) + + log.info(" EXIT SHORT: price=%.6f (0.5%% below entry)", price * 0.995) + t0 = time.time() + exit_outcome = await k.process_intent_async(KI( + timestamp=datetime.now(timezone.utc), + intent_id=tid, trade_id=tid, slot_id=0, + asset=asset, side=TS.SHORT, action=E.EXIT, + reference_price=price * 0.995, target_size=size, leverage=1.0, + exit_leg_ratios=(1.0,), reason="startup_check_exit", metadata={}, + )) + dt_exit = time.time() - t0 + log.info(" EXIT result: accepted=%s state=%s diag=%s (%.2fs)", + exit_outcome.accepted, exit_outcome.state, exit_outcome.diagnostic_code, dt_exit) + + await asyncio.sleep(1.0) + + # Final account state + acc = k.snapshot()["account"] + slot = k.slot(0).to_dict() + log.info(" Slot final: %s closed=%s realized_pnl=%.6f", + slot.get("fsm_state"), slot.get("closed"), slot.get("realized_pnl", 0)) + log.info(" Account: capital=%.4f realized_pnl=%.6f", + acc.get("capital", 0), acc.get("realized_pnl", 0)) + + # Verify exchange flat + snap_final = await adapter.refresh_state(None, include_history=False) + open_pos = {s: r for s, r in snap_final.open_positions.items() + if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-8} + if open_pos: + log.warning(" ⚠ Exchange still shows open positions: %s", list(open_pos.keys())) + else: + log.info(" ✓ Exchange FLAT after exit") + + bundle.close() + + success = exit_outcome.accepted and not open_pos + if success: + log.info("") + log.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + log.info(" ✓ PINK STARTUP OK — async path functional") + log.info(" ENTER latency: %.2fs EXIT latency: %.2fs", dt_enter, dt_exit) + log.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + else: + log.error(" ✗ PINK STARTUP FAILED") + return success + + +# ── main ────────────────────────────────────────────────────────────────────── +async def main(do_flatten: bool, do_start: bool) -> None: + cfg = _cfg() + adapter = BingxDirectExecutionAdapter(cfg) + client = adapter._client + capital = 25000.0 + + try: + if do_flatten: + flat = await flatten_exchange(client, adapter) + if not flat: + log.error("Flatten FAILED — aborting") + sys.exit(1) + else: + log.info("── SKIP FLATTEN (no --flatten flag) ─────────────────────────") + + # Read current capital from exchange + try: + snap = await adapter.refresh_state(None, include_history=False) + capital = snap.capital or 25000.0 + log.info("Exchange capital: %.2f open_positions=%d", + capital, len(snap.open_positions)) + except Exception as exc: + log.warning("Could not read capital: %s — using 25000.0", exc) + + if not do_start: + log.info("── SKIP PINK STARTUP (--no-start flag) ──────────────────────") + sys.exit(0) + + ok = await pink_startup_roundtrip(adapter, capital) + sys.exit(0 if ok else 1) + except KeyboardInterrupt: + log.info("Interrupted.") + sys.exit(0) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Flatten BingX VST and/or start PINK roundtrip check") + p.add_argument("--flatten", action="store_true", + help="Cancel all orders and close all positions before starting PINK") + p.add_argument("--no-start", action="store_true", + help="Skip the PINK startup roundtrip (flatten only)") + args = p.parse_args() + asyncio.run(main(do_flatten=args.flatten, do_start=not args.no_start))