PINK DITAv2 L0-L2: two-phase persistence + async-fill pump + LIMIT wiring
Execution-infra only (policy stays MARKET; algorithmic integrity untouched). L0 — two-phase (request->result) persistence (pink_clickhouse.py): - Split persist_step into persist_request (policy_events + trade_reconstruction ORDER_REQUESTED) and persist_result (state snapshot + per-fill lifecycle rows). - Lifecycle rows (ENTRY_FILLED/EXIT/trade_events/trade_exit_legs) gated on evidence of an actual fill (FULL/PARTIAL_FILL event, closed slot, or size drop vs _leg_state) -> a resting LIMIT (ACK only) emits no terminal rows. - Add persist_fill_events: synthesizes a minimal decision/intent from slot+event for async fills and routes through persist_result. L1 — async-fill pump (pink_direct.py): - PinkDirectRuntime.pump_venue_events(): venue.reconcile() -> kernel.on_venue_event (capital settles, FSM advances), persists applied fills; kernel dedups duplicates (no double-settle). Called at the start of step(). L2 — LIMIT placement (bingx_direct.py): - submit_intent now honors _order_type/_limit_price from intent metadata (was hardcoded MARKET): LIMIT -> type=LIMIT + price + GTC; MARKET default; invalid limit price falls back to MARKET. Offline: 63 passed (persistence/groundwork/pump/limit-payload/runtime/accounting/ flaws/kernel). MARKET path unchanged; resting LIMIT now correct end-to-end offline. Live VST validation (L3) pending. BLUE untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
73
prod/tests/test_bingx_direct_limit_order.py
Normal file
73
prod/tests/test_bingx_direct_limit_order.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""L2 — LIMIT order payload wiring in BingxDirectExecutionAdapter.submit_intent.
|
||||
|
||||
The venue adapter forwards _order_type/_limit_price in the intent metadata; the
|
||||
backend must place a LIMIT order (type=LIMIT + price + GTC) when asked, and keep
|
||||
MARKET as the default. Offline unit test of payload construction — the signed_post
|
||||
client is stubbed to capture the order payload; no exchange contact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
||||
from prod.clean_arch.dita import DecisionAction, Intent, TradeSide
|
||||
|
||||
|
||||
def _adapter(captured: dict):
|
||||
a = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
|
||||
a._config = SimpleNamespace(recv_window_ms=5000, default_leverage=1, exchange_leverage_cap=3)
|
||||
a._client_order_run_id = "test"
|
||||
a._entry_client_order_seq = 0
|
||||
a._exit_client_order_seq = 0
|
||||
a._state = SimpleNamespace(open_positions={}, account={})
|
||||
|
||||
async def _signed_post(path, params):
|
||||
if path.endswith("/trade/order"):
|
||||
captured["order"] = dict(params)
|
||||
return {"orderId": "1", "status": "NEW"}
|
||||
|
||||
a._client = SimpleNamespace(signed_post=_signed_post)
|
||||
a._instrument_venue_symbol = lambda asset: "BTC-USDT"
|
||||
a._format_quantity = lambda asset, q: f"{float(q)}"
|
||||
a._format_price = lambda asset, p: f"{float(p)}"
|
||||
|
||||
async def _refresh(asset, include_history=True):
|
||||
return a._state
|
||||
|
||||
a._refresh_exchange_state = _refresh
|
||||
return a
|
||||
|
||||
|
||||
def _intent(metadata: dict) -> Intent:
|
||||
return Intent(
|
||||
timestamp=datetime.now(timezone.utc), trade_id="T1", decision_id="D1",
|
||||
asset="BTCUSDT", action=DecisionAction.ENTER, side=TradeSide.SHORT,
|
||||
reason="TEST", target_size=0.01, leverage=2.0, reference_price=100.0,
|
||||
confidence=0.5, exit_leg_ratios=(1.0,), metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_limit_intent_places_limit_order():
|
||||
captured: dict = {}
|
||||
asyncio.run(_adapter(captured).submit_intent(_intent({"_order_type": "LIMIT", "_limit_price": 95.0})))
|
||||
o = captured["order"]
|
||||
assert o["type"] == "LIMIT", o
|
||||
assert "price" in o and float(o["price"]) == 95.0, o
|
||||
assert o.get("timeInForce") == "GTC", o
|
||||
|
||||
|
||||
def test_market_intent_places_market_order():
|
||||
captured: dict = {}
|
||||
asyncio.run(_adapter(captured).submit_intent(_intent({})))
|
||||
o = captured["order"]
|
||||
assert o["type"] == "MARKET", o
|
||||
assert "price" not in o, o
|
||||
|
||||
|
||||
def test_limit_without_valid_price_falls_back_to_market():
|
||||
captured: dict = {}
|
||||
asyncio.run(_adapter(captured).submit_intent(_intent({"_order_type": "LIMIT", "_limit_price": 0.0})))
|
||||
assert captured["order"]["type"] == "MARKET", captured["order"]
|
||||
182
prod/tests/test_pink_async_fill_pump.py
Normal file
182
prod/tests/test_pink_async_fill_pump.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""L1 — async-fill pump.
|
||||
|
||||
A resting order (LIMIT-style: ACK on submit, no synchronous fill) fills on a
|
||||
*later* venue reconcile. `PinkDirectRuntime.pump_venue_events()` must drain that
|
||||
fill into the kernel so capital settles and the FSM advances, persist the result,
|
||||
and dedup duplicate reconcile events (no double-settle). MockVenue only; no exchange.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelEventKind,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.contracts import KernelIntent, TradeStage
|
||||
from prod.clean_arch.dita import DecisionConfig, DecisionEngine, IntentEngine
|
||||
from prod.clean_arch.persistence import PinkClickHousePersistence
|
||||
from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime
|
||||
from prod.clean_arch.ports.data_feed import DataFeedPort
|
||||
|
||||
|
||||
class _Sink:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
def __call__(self, table: str, row: dict) -> None:
|
||||
self.calls.append((table, dict(row)))
|
||||
|
||||
def tables(self) -> list[str]:
|
||||
return [t for t, _ in self.calls]
|
||||
|
||||
|
||||
class _StubFeed(DataFeedPort):
|
||||
async def connect(self) -> bool:
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
pass
|
||||
|
||||
async def get_latest_snapshot(self, symbol):
|
||||
return None
|
||||
|
||||
async def subscribe_snapshots(self, callback) -> None:
|
||||
pass
|
||||
|
||||
async def get_acb_update(self):
|
||||
return None
|
||||
|
||||
def get_latency_ms(self) -> float:
|
||||
return 0.0
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _DelayedFillVenue(MockVenueAdapter):
|
||||
"""MockVenue whose submit ACKs only; queued fills surface on reconcile()."""
|
||||
|
||||
def __init__(self, scenario=None) -> None:
|
||||
super().__init__(scenario)
|
||||
self._pending: list[VenueEvent] = []
|
||||
|
||||
def queue(self, event: VenueEvent) -> None:
|
||||
self._pending.append(event)
|
||||
|
||||
def reconcile(self):
|
||||
out, self._pending = list(self._pending), []
|
||||
return out
|
||||
|
||||
|
||||
def _mk_runtime():
|
||||
# ACK-only: no synchronous fill on submit (resting order).
|
||||
venue = _DelayedFillVenue(
|
||||
MockVenueScenario(emit_fill_on_submit=False, partial_fill_ratio=0.0, emit_ack_before_fill=True)
|
||||
)
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=venue,
|
||||
journal=MemoryKernelJournal(),
|
||||
)
|
||||
kernel.account.snapshot.capital = 25_000.0
|
||||
kernel.account.snapshot.peak_capital = 25_000.0
|
||||
kernel.account.snapshot.equity = 25_000.0
|
||||
sink = _Sink()
|
||||
cfg = DecisionConfig()
|
||||
persistence = PinkClickHousePersistence(kernel.account, sink=sink, v7_sink=sink)
|
||||
runtime = PinkDirectRuntime(
|
||||
data_feed=_StubFeed(), kernel=kernel,
|
||||
decision_engine=DecisionEngine(cfg), intent_engine=IntentEngine(cfg),
|
||||
persistence=persistence, market_state_runtime=None,
|
||||
)
|
||||
return runtime, kernel, venue, sink
|
||||
|
||||
|
||||
def _intent(action, *, size, price, reason="TEST"):
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc), intent_id=f"i-{reason}", trade_id="T1",
|
||||
slot_id=0, asset="BTCUSDT", side=TradeSide.SHORT, action=action,
|
||||
reference_price=price, target_size=size, leverage=2.0, exit_leg_ratios=(1.0,), reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _fill_for(order, *, kind, price, filled, remaining, eid):
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc), event_id=eid, trade_id="T1", slot_id=0,
|
||||
kind=kind, status=VenueEventStatus.FILLED if kind == KernelEventKind.FULL_FILL else VenueEventStatus.PARTIALLY_FILLED,
|
||||
venue_order_id=order.venue_order_id, venue_client_id=order.venue_client_id,
|
||||
side=TradeSide.SHORT, asset="BTCUSDT", price=price, size=1.0,
|
||||
filled_size=filled, remaining_size=remaining,
|
||||
)
|
||||
|
||||
|
||||
def test_resting_entry_fills_via_pump_and_dedups():
|
||||
runtime, kernel, venue, sink = _mk_runtime()
|
||||
|
||||
# ENTER rests (ACK only, nothing filled).
|
||||
kernel.process_intent(_intent(KernelCommandType.ENTER, size=1.0, price=100.0))
|
||||
slot = kernel.slot(0)
|
||||
assert slot.fsm_state == TradeStage.ENTRY_WORKING
|
||||
assert abs(slot.size) < 1e-9
|
||||
entry_order = slot.active_entry_order
|
||||
assert entry_order is not None
|
||||
|
||||
# A later reconcile surfaces the fill -> pump settles it.
|
||||
venue.queue(_fill_for(entry_order, kind=KernelEventKind.FULL_FILL, price=100.0, filled=1.0, remaining=0.0, eid="EVF1"))
|
||||
applied = asyncio.run(runtime.pump_venue_events())
|
||||
assert applied == 1
|
||||
assert kernel.slot(0).fsm_state == TradeStage.POSITION_OPEN
|
||||
assert abs(kernel.slot(0).size - 1.0) < 1e-9
|
||||
assert "account_events" in sink.tables() and "position_state" in sink.tables()
|
||||
assert "ENTRY_FILLED" in [r["event_type"] for t, r in sink.calls if t == "trade_reconstruction"]
|
||||
|
||||
# Duplicate reconcile event -> kernel dedups; pump applies nothing, no double-settle.
|
||||
cap_before = kernel.account.snapshot.capital
|
||||
rows_before = len(sink.calls)
|
||||
venue.queue(_fill_for(entry_order, kind=KernelEventKind.FULL_FILL, price=100.0, filled=1.0, remaining=0.0, eid="EVF1"))
|
||||
applied2 = asyncio.run(runtime.pump_venue_events())
|
||||
assert applied2 == 0, "duplicate fill must be deduped by the kernel"
|
||||
assert kernel.account.snapshot.capital == cap_before
|
||||
assert len(sink.calls) == rows_before, "no rows persisted for a deduped event"
|
||||
|
||||
|
||||
def test_resting_exit_fills_via_pump_settles_capital():
|
||||
runtime, kernel, venue, sink = _mk_runtime()
|
||||
|
||||
# Open a position via the pump (entry rests, then fills).
|
||||
kernel.process_intent(_intent(KernelCommandType.ENTER, size=1.0, price=100.0))
|
||||
venue.queue(_fill_for(kernel.slot(0).active_entry_order, kind=KernelEventKind.FULL_FILL, price=100.0, filled=1.0, remaining=0.0, eid="EVE1"))
|
||||
asyncio.run(runtime.pump_venue_events())
|
||||
assert kernel.slot(0).fsm_state == TradeStage.POSITION_OPEN
|
||||
cap_after_entry = kernel.account.snapshot.capital # entry does not realize PnL
|
||||
|
||||
# EXIT rests (ACK only), then fills @90 on a later reconcile -> SHORT profit.
|
||||
kernel.process_intent(_intent(KernelCommandType.EXIT, size=1.0, price=90.0, reason="TP"))
|
||||
exit_order = kernel.slot(0).active_exit_order
|
||||
assert exit_order is not None
|
||||
venue.queue(_fill_for(exit_order, kind=KernelEventKind.FULL_FILL, price=90.0, filled=1.0, remaining=0.0, eid="EVX1"))
|
||||
applied = asyncio.run(runtime.pump_venue_events())
|
||||
assert applied == 1
|
||||
assert kernel.slot(0).closed
|
||||
assert kernel.slot(0).fsm_state == TradeStage.CLOSED
|
||||
# SHORT 1.0 @100 -> exit @90, leverage 2 => realized profit > 0; capital rose.
|
||||
assert kernel.account.snapshot.capital > cap_after_entry
|
||||
tables = sink.tables()
|
||||
assert "trade_exit_legs" in tables, "async exit must persist a leg row"
|
||||
assert "trade_events" in tables, "async close must persist a terminal trade_event"
|
||||
@@ -19,10 +19,14 @@ from prod.clean_arch.dita import (
|
||||
)
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelOutcome,
|
||||
KernelSeverity,
|
||||
KernelTransition,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.contracts import TradeSide as DitaTradeSide
|
||||
from prod.clean_arch.persistence.pink_clickhouse import PinkClickHousePersistence
|
||||
|
||||
|
||||
@@ -324,3 +328,96 @@ def test_persistence_writes_account_reconcile_rows() -> None:
|
||||
assert status_row["phase"] == "account_reconcile"
|
||||
assert recon_row["event_type"] == "ACCOUNT_RECONCILE"
|
||||
assert "market_state_bundle_json" in recon_row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# L0 — two-phase (request -> result) persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fill_event(kind: KernelEventKind, *, filled: float, remaining: float, price: float = 100.0) -> VenueEvent:
|
||||
return VenueEvent(
|
||||
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
|
||||
event_id=f"EV-{kind.value}",
|
||||
trade_id="BTCUSDT-T-000000000001",
|
||||
slot_id=0,
|
||||
kind=kind,
|
||||
status=VenueEventStatus.FILLED if kind == KernelEventKind.FULL_FILL else VenueEventStatus.ACKED,
|
||||
side=DitaTradeSide.SHORT,
|
||||
asset="BTCUSDT",
|
||||
price=price,
|
||||
size=1.0,
|
||||
filled_size=filled,
|
||||
remaining_size=remaining,
|
||||
)
|
||||
|
||||
|
||||
def _outcome_with_events(*events: VenueEvent) -> KernelOutcome:
|
||||
return KernelOutcome(
|
||||
accepted=True, slot_id=0, trade_id="BTCUSDT-T-000000000001",
|
||||
state=DitaTradeStage.POSITION_OPEN, diagnostic_code=KernelDiagnosticCode.OK,
|
||||
severity=KernelSeverity.INFO, transitions=(), emitted_events=tuple(events), details={},
|
||||
)
|
||||
|
||||
|
||||
def test_request_row_precedes_result_rows_on_filled_entry() -> None:
|
||||
"""ENTER with a FULL_FILL event: ORDER_REQUESTED is logged before ENTRY_FILLED."""
|
||||
sink = _Sink()
|
||||
persistence = PinkClickHousePersistence(_make_account(), sink=sink, v7_sink=sink)
|
||||
persistence.persist_step(
|
||||
snapshot=_make_snapshot(),
|
||||
decision=_make_decision(DecisionAction.ENTER),
|
||||
intent=_make_intent(DecisionAction.ENTER),
|
||||
outcome=_outcome_with_events(_fill_event(KernelEventKind.FULL_FILL, filled=1.0, remaining=0.0)),
|
||||
slot_dict=_make_slot_dict(closed=False, size=1.0),
|
||||
phase="execution",
|
||||
)
|
||||
recon_types = [r["event_type"] for t, r in sink.calls if t == "trade_reconstruction"]
|
||||
assert "ORDER_REQUESTED" in recon_types, recon_types
|
||||
assert "ENTRY_FILLED" in recon_types, recon_types
|
||||
assert recon_types.index("ORDER_REQUESTED") < recon_types.index("ENTRY_FILLED")
|
||||
|
||||
|
||||
def test_resting_limit_entry_logs_request_but_no_fill() -> None:
|
||||
"""ACK-only LIMIT entry (slot still working, size 0) -> request row, NO ENTRY_FILLED."""
|
||||
sink = _Sink()
|
||||
persistence = PinkClickHousePersistence(_make_account(), sink=sink, v7_sink=sink)
|
||||
# Working entry order: slot not closed, size 0 (nothing filled yet).
|
||||
working_slot = _make_slot_dict(closed=False, size=0.0)
|
||||
working_slot["fsm_state"] = "ENTRY_WORKING"
|
||||
persistence.persist_step(
|
||||
snapshot=_make_snapshot(),
|
||||
decision=_make_decision(DecisionAction.ENTER),
|
||||
intent=_make_intent(DecisionAction.ENTER),
|
||||
outcome=_outcome_with_events(_fill_event(KernelEventKind.ORDER_ACK, filled=0.0, remaining=1.0)),
|
||||
slot_dict=working_slot,
|
||||
phase="execution",
|
||||
)
|
||||
recon_types = [r["event_type"] for t, r in sink.calls if t == "trade_reconstruction"]
|
||||
assert "ORDER_REQUESTED" in recon_types, recon_types
|
||||
assert "ENTRY_FILLED" not in recon_types, f"resting LIMIT must not log a fill: {recon_types}"
|
||||
# State snapshot rows still written (observability).
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "account_events" in tables and "position_state" in tables
|
||||
|
||||
|
||||
def test_resting_limit_exit_emits_no_terminal_rows() -> None:
|
||||
"""An exit intent whose order rests (size unchanged) -> no trade_exit_legs / trade_events."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
# Seed leg state as if a 1.0 position is open (prev_size = 1.0).
|
||||
persistence._leg_state["BTCUSDT-T-000000000001"] = {"prev_realized": 0.0, "prev_size": 1.0, "prev_leg_id": ""}
|
||||
# Exit order resting: slot still open at full size, ACK only, no fill.
|
||||
resting = _make_slot_dict(closed=False, size=1.0)
|
||||
persistence.persist_step(
|
||||
snapshot=_make_snapshot(),
|
||||
decision=_make_decision(DecisionAction.EXIT),
|
||||
intent=_make_intent(DecisionAction.EXIT),
|
||||
outcome=_outcome_with_events(_fill_event(KernelEventKind.ORDER_ACK, filled=0.0, remaining=1.0)),
|
||||
slot_dict=resting,
|
||||
phase="execution",
|
||||
)
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "trade_exit_legs" not in tables, "resting exit must not emit a leg row"
|
||||
assert "trade_events" not in tables
|
||||
assert "ORDER_REQUESTED" in [r["event_type"] for t, r in sink.calls if t == "trade_reconstruction"]
|
||||
|
||||
Reference in New Issue
Block a user