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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user