PINK: fix persistence layer — exit_price, entry_bar, recovery, external exits, NaN tracing

G21/E23/A13 — exit_price used entry_price (every trade had exit_price==entry_price):
  _write_trade_event: exit_price = fill_price_hint > intent.reference_price > decision.reference_price
  _write_trade_exit_leg: same priority chain via fill_price_hint parameter
  persist_result: extracts fill_price_hint from FULL_FILL/PARTIAL_FILL events in outcome
  persist_fill_events: intent.reference_price = actual fill price → propagates correctly

A14 — entry_bar was active_leg_index (exit leg counter, not bar count):
  _write_position_state: entry_bar = intent.bars_held (0 when intent is None)

A15 — persist_recovery_state used acc_dict as slot_dict (trade_id always ""):
  Now reads kernel.slot(0).to_dict() when kernel is wired; trade_id from real slot

External-position exit_qty=0 fix:
  _write_trade_exit_leg: when prev_size<=0 (no prior ENTER tracked), falls back to
  initial_size or intent.target_size so exit legs for reconcile-detected positions are meaningful

exit_qty field added to trade_exit_legs rows (was computed but not emitted)

NaN tracing (_checked_float):
  Introduces _checked_float() wrapper that logs WARNING + writes anomaly_events spool
  row on NaN/inf in financial fields; applied to realized_pnl in exit paths

29 new persistence unit tests (mocked) + chaos/fuzz suite:
  exit_price correctness, capital ordering, pnl_leg incremental, entry_bar,
  recovery trade_id, external position exits, multi-leg, restart-mid-trade, NaN/None fields
  164/164 total (97 flaws + 25 kernel reliability + 29 persistence + 13 phase4) green

FLAWS doc: pass 6 — G21/E23/A13/A14/A15 closed; 26 total fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-05 09:30:30 +02:00
parent 025d381623
commit b30205ceb6
3 changed files with 979 additions and 23 deletions

View File

@@ -17,6 +17,7 @@ Capital/peak_capital/trade_seq are read from the kernel's AccountProjection
from __future__ import annotations
import json
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -28,6 +29,7 @@ from prod.clean_arch.dita_v2.contracts import KernelDiagnosticCode, KernelEventK
from prod.clean_arch.dita_v2.contracts import KernelSeverity, TradeStage as KernelStage
Writer = Callable[[str, dict[str, Any]], None]
_log = logging.getLogger(__name__)
def _json_safe(value: Any) -> Any:
@@ -78,6 +80,48 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
return out
def _checked_float(
value: Any,
default: float = 0.0,
*,
field: str = "?",
trade_id: str = "",
sink: Writer | None = None,
) -> float:
"""_safe_float with anomaly tracing.
Any NaN/inf/non-numeric value is a bug indicator, not a normal condition.
Sanitise to ``default`` but log a WARNING and optionally write an
``anomaly_events`` spool row so the trace is queryable in ClickHouse.
"""
try:
out = float(value)
except Exception:
out = float("nan")
if not math.isfinite(out):
_log.warning(
"NaN/inf in financial field field=%s trade_id=%s raw=%r → replacing with %s",
field, trade_id or "?", value, default,
)
if sink is not None:
try:
sink("anomaly_events", {
"ts": datetime.now(timezone.utc).isoformat(),
"decision_id": "",
"trade_id": trade_id,
"symbol": "",
"anomaly": f"NaN_FINANCIAL_FIELD:{field}",
"origin": "persistence_nan_guard",
"sensor": field,
"detail": f"raw={value!r} replaced_with={default}",
"rm_meta": 0.0,
})
except Exception:
pass
return default
return out
def _decision_summary(decision: Decision | None) -> dict[str, Any]:
if decision is None:
return {}
@@ -410,9 +454,22 @@ class PinkClickHousePersistence:
return
partial = (not slot_closed) and cur_size > 0.0
# Extract the fill price from emitted venue events (G21 fix): the actual
# exchange fill price lives in the FULL_FILL/PARTIAL_FILL event, not in
# the slot dict. Pass it explicitly so _write_trade_event does not fall
# back to entry_price.
fill_price_hint = 0.0
for ev in events:
p_val = getattr(ev, "price", 0.0)
if p_val and math.isfinite(float(p_val)) and float(p_val) > 0:
fill_price_hint = float(p_val)
break
# One trade_exit_legs row per exit leg (partial or final), BLUE-schema
# compatible so PINK multi-exit trades reconcile against the same table.
self._write_trade_exit_leg(snapshot, decision, intent, slot, outcome)
self._write_trade_exit_leg(snapshot, decision, intent, slot, outcome,
fill_price_hint=fill_price_hint)
self._write_trade_reconstruction(
snapshot, intent.trade_id,
event_type="PARTIAL_EXIT" if partial else "EXIT",
@@ -428,7 +485,9 @@ class PinkClickHousePersistence:
)
# Terminal trade event.
if slot_closed:
self._write_trade_event(snapshot, decision, intent, slot, outcome, market_state=market_state)
self._write_trade_event(snapshot, decision, intent, slot, outcome,
market_state=market_state,
exit_price_hint=fill_price_hint)
def persist_fill_events(
self,
@@ -455,7 +514,12 @@ class PinkClickHousePersistence:
closed = bool(slot.get("closed", False))
cur_size = self._slot_size(slot)
leverage = _safe_float(slot.get("leverage", 1.0), 1.0)
price = next((float(getattr(e, "price", 0.0) or 0.0) for e in event_list if getattr(e, "price", 0.0)), 0.0) or self._slot_entry_price(slot)
# Extract fill price from venue events (used as exit_price_hint for G21 fix).
price = next(
(float(getattr(e, "price", 0.0)) for e in event_list
if getattr(e, "price", 0.0) and math.isfinite(float(getattr(e, "price", 0.0)))),
0.0,
) or self._slot_entry_price(slot)
prev_size = _safe_float(self._leg_state.get(trade_id, {}).get("prev_size", 0.0), 0.0)
is_exit = closed or (prev_size > 0.0 and cur_size < prev_size - 1e-12)
action = DecisionAction.EXIT if is_exit else DecisionAction.ENTER
@@ -493,27 +557,47 @@ class PinkClickHousePersistence:
event_type: str = "RECOVERY",
market_state: Mapping[str, Any] | None = None,
) -> None:
"""Persist recovery-only state after kernel reconcile."""
slot_dict = acc_dict or {}
"""Persist recovery-only state after kernel reconcile.
A15 fix: acc_dict is the kernel account snapshot (capital/equity/pnl),
not a slot dict. Read the actual slot from kernel.slot(0) so that
trade_id, asset, size, and entry_price are correctly populated in the
recovery rows.
"""
# A15: read slot from kernel instead of misusing acc_dict as slot dict.
slot_dict: dict[str, Any] = {}
if self._kernel is not None:
try:
slot_view = self._kernel.slot(0)
raw = slot_view.to_dict() if hasattr(slot_view, "to_dict") else {}
slot_dict = dict(raw) if raw else {}
except Exception:
pass
trade_id = (
str(slot_dict.get("trade_id") or "")
or (str(acc_dict.get("trade_id", "")) if acc_dict else "")
)
self._write_status_snapshot(
snapshot, decision=None, intent=None, slot_dict={}, phase=phase,
snapshot, decision=None, intent=None, slot_dict=slot_dict, phase=phase,
)
self._write_account_event(
snapshot, decision=None, intent=None,
stage=TradeStage.TRADE_TERMINAL_WRITTEN,
slot_dict={}, event_type=event_type,
slot_dict=slot_dict, event_type=event_type,
)
self._write_position_state(
snapshot, decision=None, intent=None,
slot_dict={}, stage=TradeStage.TRADE_TERMINAL_WRITTEN,
status=self._state_label({}, phase), market_state=market_state,
slot_dict=slot_dict, stage=TradeStage.TRADE_TERMINAL_WRITTEN,
status=self._state_label(slot_dict, phase), market_state=market_state,
)
self._write_trade_reconstruction(
snapshot,
trade_id=acc_dict.get("trade_id", "") if acc_dict else "",
trade_id=trade_id,
event_type=event_type,
event_id=f"recovery:{phase}",
payload={"acc_dict": _json_safe(acc_dict or {}), "phase": phase, "market_state": _json_safe(market_state or {})},
payload={"acc_dict": _json_safe(acc_dict or {}), "slot": _json_safe(slot_dict), "phase": phase, "market_state": _json_safe(market_state or {})},
market_state=market_state,
)
@@ -736,7 +820,9 @@ class PinkClickHousePersistence:
"notional": _notional(self._slot_size(slot_dict), self._slot_entry_price(slot_dict)),
"leverage": _safe_float(slot_dict.get("leverage", 0.0), 0.0),
"bucket_id": -1,
"entry_bar": int(slot_dict.get("active_leg_index", 0) or 0),
# A14 fix: active_leg_index is the exit-leg counter, not the bar count.
# Use intent.bars_held when available; fall back to 0.
"entry_bar": int(intent.bars_held if intent is not None else 0) or 0,
"status": status,
"exit_reason": slot_dict.get("close_reason", ""),
"pnl": _safe_float(slot_dict.get("realized_pnl", 0.0), 0.0),
@@ -789,6 +875,7 @@ class PinkClickHousePersistence:
def _write_trade_exit_leg(
self, snapshot: Any, decision: Decision, intent: Intent,
slot_dict: dict[str, Any], outcome: KernelOutcome | None,
fill_price_hint: float = 0.0,
) -> None:
"""Emit one BLUE-schema-compatible ``trade_exit_legs`` row per exit leg.
@@ -805,14 +892,20 @@ class PinkClickHousePersistence:
"prev_leg_id": "",
}
entry_price = self._slot_entry_price(slot_dict) or _safe_float(intent.reference_price, 0.0)
exit_price = _safe_float(intent.reference_price, 0.0) or _safe_float(decision.reference_price, 0.0)
# G21 fix: use fill price hint (actual exchange fill) > intent ref > decision ref.
exit_price = (
fill_price_hint
or _safe_float(intent.reference_price, 0.0)
or _safe_float(decision.reference_price, 0.0)
)
side = self._slot_side(slot_dict)
if side == TradeSide.FLAT:
side = intent.side
leverage_val = _safe_float(slot_dict.get("leverage", intent.leverage), 1.0)
cur_size = self._slot_size(slot_dict)
cur_realized = _safe_float(slot_dict.get("realized_pnl", 0.0), 0.0)
cur_realized = _checked_float(slot_dict.get("realized_pnl", 0.0), 0.0,
field="realized_pnl", trade_id=trade_id, sink=self._sink)
prev_size = _safe_float(prev.get("prev_size", 0.0), 0.0)
prev_realized = _safe_float(prev.get("prev_realized", 0.0), 0.0)
@@ -822,7 +915,15 @@ class PinkClickHousePersistence:
leg_index = max(0, int(slot_dict.get("active_leg_index", 0) or 0) - 1)
fraction = _safe_float(ratios[leg_index], 0.0) if 0 <= leg_index < len(ratios) else 0.0
exit_qty = max(0.0, prev_size - cur_size)
# External-position fix: if leg_state was never seeded (position detected via
# reconcile/pump rather than our own ENTER), prev_size=0 would make exit_qty=0.
# Fall back to initial_size or intent.target_size so the leg row is meaningful.
if prev_size <= 1e-12:
# No prior leg tracking — use the slot's initial_size or intent size.
initial = _safe_float(slot_dict.get("initial_size", 0.0), 0.0) or _safe_float(intent.target_size, 0.0)
exit_qty = initial - cur_size if initial > cur_size else initial
else:
exit_qty = max(0.0, prev_size - cur_size)
pnl_leg = cur_realized - prev_realized
capital_after = self._capital()
capital_before = capital_after - pnl_leg
@@ -852,6 +953,7 @@ class PinkClickHousePersistence:
"side": side.value,
"entry_price": entry_price,
"exit_price": exit_price,
"exit_qty": exit_qty,
"fraction": fraction,
"capital_before": capital_before,
"capital_after": capital_after,
@@ -875,11 +977,23 @@ class PinkClickHousePersistence:
self, snapshot: Any, decision: Decision, intent: Intent,
slot_dict: dict[str, Any], outcome: KernelOutcome | None,
*, market_state: Mapping[str, Any] | None = None,
exit_price_hint: float = 0.0,
) -> None:
entry_price = _safe_float(slot_dict.get("entry_price", 0.0), 0.0) or _safe_float(intent.reference_price, 0.0)
quantity = _safe_float(slot_dict.get("initial_size", slot_dict.get("size", 0.0)), 0.0) or _safe_float(intent.target_size, 0.0)
exit_price = _safe_float(slot_dict.get("entry_price", 0.0), 0.0)
pnl = _safe_float(slot_dict.get("realized_pnl", 0.0), 0.0)
# G21 fix: exit_price is the fill/order price, NOT the entry price.
# Priority: explicit hint (fill event price) > intent reference price > decision price.
# Fall back to entry_price only as absolute last resort (avoids the G21 bug where
# every trade_events row had exit_price == entry_price and PnL reconstruction was zero).
exit_price = (
exit_price_hint
or _safe_float(intent.reference_price, 0.0)
or _safe_float(decision.reference_price, 0.0)
or _safe_float(slot_dict.get("entry_price", 0.0), 0.0)
)
tid = intent.trade_id if intent is not None else ""
pnl = _checked_float(slot_dict.get("realized_pnl", 0.0), 0.0,
field="realized_pnl", trade_id=tid, sink=self._sink)
pnl_pct = 0.0
leverage_val = _safe_float(slot_dict.get("leverage", intent.leverage), 1.0)
denom = abs(quantity * entry_price * max(leverage_val, 1e-9))