113 lines
4.9 KiB
Python
113 lines
4.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""L3 — live VST validation of the LIMIT execution path.
|
||
|
|
|
||
|
|
The policy is MARKET-only (execution-infra scope), so LIMIT is validated by
|
||
|
|
injecting a LIMIT KernelIntent into the live kernel. This places a *non-marketable*
|
||
|
|
resting SHORT LIMIT (5% above market, so it will not fill), confirms the exchange
|
||
|
|
holds it as an open LIMIT order (i.e. the L2 wiring actually placed a LIMIT, not a
|
||
|
|
MARKET that would have filled), then cancels it and confirms the account is flat
|
||
|
|
with no dangling orders.
|
||
|
|
|
||
|
|
The LIMIT fill -> async-fill-pump -> settle/persist path is deterministically
|
||
|
|
covered offline (test_pink_async_fill_pump.py); live we validate placement +
|
||
|
|
resting + cancel against the real venue.
|
||
|
|
|
||
|
|
Gates: BINGX_SMOKE_LIVE, BINGX_SMOKE_ALLOW_TRADE, PINK_DITA_E2E, PINK_RUNTHROUGH.
|
||
|
|
Run on a FLAT account, from repo root with PYTHONPATH=/mnt/dolphinng5_predict.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import os
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
for _gate in ("BINGX_SMOKE_LIVE", "BINGX_SMOKE_ALLOW_TRADE", "PINK_DITA_E2E", "PINK_RUNTHROUGH"):
|
||
|
|
if not os.environ.get(_gate):
|
||
|
|
pytest.skip(f"{_gate} not set", allow_module_level=True)
|
||
|
|
|
||
|
|
from prod.tests.test_pink_bingx_dita_live_e2e import ( # noqa: E402
|
||
|
|
_build_config, _pick_sym, _snap, _check_open_orders, _verify, _flatten,
|
||
|
|
)
|
||
|
|
from prod.bingx.http import BingxHttpClient # noqa: E402
|
||
|
|
from prod.clean_arch.dita_v2.launcher import build_launcher_bundle # noqa: E402
|
||
|
|
from prod.clean_arch.dita_v2.contracts import ( # noqa: E402
|
||
|
|
KernelCommandType, KernelIntent, TradeSide, TradeStage,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _intent(action, *, asset, price, size, order_type="MARKET", limit_price=0.0, reason="LIMIT_TEST"):
|
||
|
|
return KernelIntent(
|
||
|
|
timestamp=datetime.now(timezone.utc), intent_id=f"{reason}-{int(datetime.now().timestamp()*1000)}",
|
||
|
|
trade_id=f"{reason}-T", slot_id=0, asset=asset, side=TradeSide.SHORT, action=action,
|
||
|
|
reference_price=price, target_size=size, leverage=1.0, exit_leg_ratios=(1.0,),
|
||
|
|
reason=reason, order_type=order_type, limit_price=limit_price,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def _run() -> dict:
|
||
|
|
bundle = build_launcher_bundle(venue_mode="BINGX", max_slots=1, bingx_config=_build_config())
|
||
|
|
k = bundle.kernel
|
||
|
|
k.account.snapshot.capital = 25_000.0
|
||
|
|
k.account.snapshot.peak_capital = 25_000.0
|
||
|
|
k.account.snapshot.equity = 25_000.0
|
||
|
|
client = BingxHttpClient(_build_config())
|
||
|
|
|
||
|
|
sym = await _pick_sym(k, client)
|
||
|
|
snap, vsym = await _snap(client, sym)
|
||
|
|
p = float(snap.price)
|
||
|
|
assert p > 0, f"no live price for {sym}"
|
||
|
|
k.venue.connect()
|
||
|
|
try:
|
||
|
|
assert k.slot(0).is_free(), f"slot not free (state={k.slot(0).fsm_state}); flatten the account first"
|
||
|
|
|
||
|
|
# Non-marketable resting SHORT LIMIT: sell 5% above market -> will not fill.
|
||
|
|
# Size to clear the exchange minimum order amount (~$25 notional);
|
||
|
|
# _format_quantity only quantizes to step, it does NOT floor to the min.
|
||
|
|
limit_px = round(p * 1.05, 6)
|
||
|
|
size = round(25.0 / p, 3)
|
||
|
|
out = k.process_intent(_intent(
|
||
|
|
KernelCommandType.ENTER, asset=sym, price=limit_px, size=size,
|
||
|
|
order_type="LIMIT", limit_price=limit_px,
|
||
|
|
))
|
||
|
|
assert out.accepted, f"LIMIT entry rejected: {out.diagnostic_code} {out.details}"
|
||
|
|
await asyncio.sleep(1.5)
|
||
|
|
slot = k.slot(0)
|
||
|
|
# A real resting LIMIT must NOT fill synchronously (a MARKET would have).
|
||
|
|
assert slot.fsm_state == TradeStage.ENTRY_WORKING, f"expected ENTRY_WORKING, got {slot.fsm_state}"
|
||
|
|
assert abs(slot.size) < 1e-9, f"resting LIMIT should not be filled, size={slot.size}"
|
||
|
|
|
||
|
|
# Exchange truth: a resting LIMIT order exists for the symbol.
|
||
|
|
oos = await _check_open_orders(client, vsym)
|
||
|
|
types = [str(o.get("type", "")).upper() for o in oos]
|
||
|
|
assert oos, "no open order on the exchange — LIMIT was not placed (or filled as MARKET)"
|
||
|
|
assert any(t == "LIMIT" for t in types), f"open order is not a LIMIT: {types}"
|
||
|
|
|
||
|
|
# Cancel the working LIMIT -> back to IDLE, account flat, no dangling order.
|
||
|
|
k.process_intent(_intent(KernelCommandType.CANCEL, asset=sym, price=limit_px, size=0.001, reason="LIMIT_CANCEL"))
|
||
|
|
await asyncio.sleep(1.5)
|
||
|
|
assert k.slot(0).is_free(), f"slot not free after cancel: {k.slot(0).fsm_state}"
|
||
|
|
result = {"symbol": sym, "limit_px": limit_px, "open_order_types": types}
|
||
|
|
finally:
|
||
|
|
# Safety net: cancel/flatten anything left.
|
||
|
|
try:
|
||
|
|
if not k.slot(0).is_free():
|
||
|
|
_flatten(k, sym, p, "limit-post")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
k.venue.disconnect()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
vr = await _verify(client, vsym)
|
||
|
|
assert vr.positions_flat, f"exchange not flat after cancel: {vr.error}"
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def test_pink_limit_order_rests_and_cancels() -> None:
|
||
|
|
result = asyncio.run(_run())
|
||
|
|
print(f"[PINK limit live] {result}")
|