Files
siloqy/prod/tests/test_pink_clickhouse_persistence.py
Codex 55ed6902d8 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>
2026-05-31 03:23:44 +02:00

424 lines
15 KiB
Python

"""PINK ClickHouse persistence tests — DITAv2 outcome + slot_dict API."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import SimpleNamespace
from prod.clean_arch.dita_v2.contracts import TradeStage as DitaTradeStage
from prod.clean_arch.dita import (
AccountProjection,
AccountSnapshot,
Decision,
DecisionAction,
Intent,
TradeSide,
TradeStage,
)
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
@dataclass
class _Sink:
calls: list[tuple[str, dict]] = field(default_factory=list)
def __call__(self, table: str, row: dict) -> None:
self.calls.append((table, row))
def _make_snapshot():
return SimpleNamespace(
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
symbol="BTCUSDT",
price=100.0,
)
def _make_decision(action: DecisionAction) -> Decision:
return Decision(
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
decision_id="BTCUSDT-D-000000000001",
asset="BTCUSDT",
action=action,
side=TradeSide.SHORT,
reason="STRUCTURAL_DISLOCATION" if action == DecisionAction.ENTER else "TAKE_PROFIT",
confidence=0.9,
velocity_divergence=-0.12,
irp_alignment=0.8,
reference_price=100.0,
target_size=1.0,
leverage=2.0,
bars_held=0,
stage=TradeStage.ORDER_REQUESTED,
metadata={},
)
def _make_intent(action: DecisionAction) -> Intent:
return Intent(
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
trade_id="BTCUSDT-T-000000000001",
decision_id="BTCUSDT-D-000000000001",
asset="BTCUSDT",
action=action,
side=TradeSide.SHORT,
reason="STRUCTURAL_DISLOCATION" if action == DecisionAction.ENTER else "TAKE_PROFIT",
target_size=1.0,
leverage=2.0,
reference_price=100.0,
confidence=0.9,
bars_held=0,
exit_leg_ratios=(0.5, 1.0),
metadata={"exit_ratio": 0.5},
)
def _make_account() -> AccountProjection:
return AccountProjection(
runtime_namespace="pink",
strategy_namespace="pink",
event_namespace="pink",
actor_name="PinkDirectRuntime",
exec_venue="bingx",
data_venue="binance",
ledger_authority="exchange",
snapshot=AccountSnapshot(capital=25_000.0, equity=25_000.0),
)
def _make_outcome(
accepted: bool = True,
code: KernelDiagnosticCode = KernelDiagnosticCode.OK,
) -> KernelOutcome:
return KernelOutcome(
accepted=accepted,
slot_id=0,
trade_id="BTCUSDT-T-000000000001",
state=DitaTradeStage.POSITION_OPEN,
diagnostic_code=code,
severity=KernelSeverity.INFO,
transitions=(),
emitted_events=(),
details={},
)
def _make_slot_dict(
closed: bool = False,
size: float = 1.0,
pnl: float = 0.0,
) -> dict:
return {
"slot_id": 0,
"trade_id": "BTCUSDT-T-000000000001",
"asset": "BTCUSDT",
"side": "SHORT",
"entry_price": 100.0,
"size": size,
"initial_size": 1.0,
"leverage": 2.0,
"realized_pnl": pnl,
"unrealized_pnl": 0.0,
"closed": closed,
"close_reason": "TAKE_PROFIT" if closed else "",
"fsm_state": "CLOSED" if closed else "POSITION_OPEN",
"exit_leg_ratios": [0.5, 1.0],
"active_leg_index": 0,
"active_exit_order": None,
"active_entry_order": None,
}
def _make_acc_dict(capital: float = 25120.0) -> dict:
return {
"capital": capital,
"equity": capital,
"realized_pnl": 120.0,
"unrealized_pnl": 0.0,
"open_positions": 0,
"open_notional": 0.0,
"leverage": 0.0,
}
def test_persistence_mirrors_policy_account_and_position_rows() -> None:
"""ENTER phase: policy_events, account_events, position_state, trade_reconstruction."""
sink = _Sink()
account = _make_account()
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
snapshot = _make_snapshot()
decision = _make_decision(DecisionAction.ENTER)
intent = _make_intent(DecisionAction.ENTER)
outcome = _make_outcome()
slot_dict = _make_slot_dict(closed=False, size=1.0)
acc_dict = _make_acc_dict(25000.0)
market_state = {
"market_fingerprint_choppiness_strength": 0.2,
"market_fingerprint_trend_persistence": 0.4,
"market_state_top_asset_target": "ETHUSDT",
}
persistence.persist_step(
snapshot=snapshot,
decision=decision,
intent=intent,
outcome=outcome,
slot_dict=slot_dict,
acc_dict=acc_dict,
phase="execution",
market_state=market_state,
)
tables = [t for t, _ in sink.calls]
assert "policy_events" in tables, f"Missing policy_events, got {tables}"
assert "v7_decision_events" in tables
assert "account_events" in tables
assert "position_state" in tables
assert "status_snapshots" in tables
assert "trade_reconstruction" in tables
assert "trade_events" not in tables, "No trade_events on ENTER"
policy = next(row for t, row in sink.calls if t == "policy_events")
v7 = next(row for t, row in sink.calls if t == "v7_decision_events")
position_row = next(row for t, row in sink.calls if t == "position_state")
recon_row = next(row for t, row in sink.calls if t == "trade_reconstruction")
assert policy["trade_id"] == intent.trade_id
assert policy["action"] == "ENTER"
assert policy == v7
assert "market_state_bundle_json" in position_row
assert position_row["tp_base_pct"] == 0.0
assert recon_row["market_state_bundle_json"]
assert "market_fingerprint_choppiness_strength" in recon_row["market_state_bundle_json"]
def test_persistence_writes_anomaly_for_diagnostic() -> None:
"""Non-OK diagnostic_code emits anomaly_events row."""
sink = _Sink()
account = _make_account()
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
snapshot = _make_snapshot()
decision = _make_decision(DecisionAction.ENTER)
intent = _make_intent(DecisionAction.ENTER)
outcome = _make_outcome(accepted=False, code=KernelDiagnosticCode.ORDER_REJECTED)
slot_dict = _make_slot_dict(closed=False, size=0.0)
acc_dict = _make_acc_dict(25000.0)
persistence.persist_step(
snapshot=snapshot,
decision=decision,
intent=intent,
outcome=outcome,
slot_dict=slot_dict,
acc_dict=acc_dict,
phase="execution",
)
tables = [t for t, _ in sink.calls]
assert "anomaly_events" in tables, f"Missing anomaly_events, got {tables}"
anomaly = next(row for t, row in sink.calls if t == "anomaly_events")
assert anomaly["anomaly"] == "ORDER_REJECTED"
def test_persistence_writes_terminal_trade_event_on_close() -> None:
"""EXIT with slot_dict.closed=True writes trade_events."""
sink = _Sink()
account = _make_account()
account.snapshot.capital = 25120.0
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
snapshot = _make_snapshot()
decision = _make_decision(DecisionAction.EXIT)
intent = _make_intent(DecisionAction.EXIT)
outcome = _make_outcome()
slot_dict = _make_slot_dict(closed=True, size=0.0, pnl=120.0)
acc_dict = _make_acc_dict(25120.0)
market_state = {"market_fingerprint_mean_reversion_strength": 0.3}
persistence.persist_step(
snapshot=snapshot,
decision=decision,
intent=intent,
outcome=outcome,
slot_dict=slot_dict,
acc_dict=acc_dict,
phase="execution",
market_state=market_state,
)
tables = [t for t, _ in sink.calls]
assert "trade_events" in tables, f"Missing trade_events, got {tables}"
trade = next(row for t, row in sink.calls if t == "trade_events")
assert trade["exit_reason"] == "TAKE_PROFIT"
assert trade["trade_id"] == intent.trade_id
assert "market_state_bundle_json" in trade
assert "market_fingerprint_mean_reversion_strength" in trade["market_state_bundle_json"]
def test_persistence_writes_anomaly_and_recovery_rows() -> None:
"""record_anomaly() + persist_recovery_state() write correct rows."""
sink = _Sink()
account = _make_account()
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
snapshot = _make_snapshot()
decision = _make_decision(DecisionAction.HOLD)
intent = _make_intent(DecisionAction.HOLD)
persistence.record_anomaly(
snapshot=snapshot,
decision=decision,
intent=intent,
anomaly="hung_exit",
origin="injected",
sensor="m8_execution_integrity",
detail="forced drop",
rm_meta=0.42,
)
persistence.persist_recovery_state(
snapshot=snapshot,
acc_dict={},
market_state={"market_fingerprint_dd_pressure": 0.2},
)
tables = [t for t, _ in sink.calls]
assert "anomaly_events" in tables
anomaly = next(row for t, row in sink.calls if t == "anomaly_events")
assert anomaly["anomaly"] == "hung_exit"
assert anomaly["sensor"] == "m8_execution_integrity"
assert "status_snapshots" in tables
assert "account_events" in tables
assert "position_state" in tables
def test_persistence_writes_account_reconcile_rows() -> None:
"""persist_recovery_state with account_reconcile phase writes correct rows."""
sink = _Sink()
account = _make_account()
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
snapshot = _make_snapshot()
persistence.persist_recovery_state(
snapshot=snapshot,
acc_dict={},
phase="account_reconcile",
event_type="ACCOUNT_RECONCILE",
market_state={"market_fingerprint_return_entropy": 0.1},
)
tables = [t for t, _ in sink.calls]
assert "status_snapshots" in tables
assert "account_events" in tables
assert "position_state" in tables
assert "trade_reconstruction" in tables
account_row = next(row for t, row in sink.calls if t == "account_events")
status_row = next(row for t, row in sink.calls if t == "status_snapshots")
recon_row = next(row for t, row in sink.calls if t == "trade_reconstruction")
assert account_row["event_type"] == "ACCOUNT_RECONCILE"
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"]