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:
@@ -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