PINK Phase 0: FET -$5,990 fix batch — leverage-free PnL, true fill prices, reconcile baseline anchors
Defects fix (FET -$5,990 replay, 2026-06-11): - realized_pnl() and mark_price(): PnL = qty × Δprice, side-signed; no ×leverage inflation (was 3× every leg). - BingX MARKET fill events carry true fill price (avgPrice/lastFillPrice), never the order's nominal price (protective bound ±20-25% from mark, poisoned PnL to -$5,990 on a +$164 round-trip). - Fill routing by ORDER IDENTITY first, FSM state second — late entry-remainder fills during EXIT_WORKING no longer misclassify as exits. - Entry basis = VWAP across entry fills, not last fill price. - reconcile_from_slots / restore_state: re-anchor _last_settled_pnl / _slot_was_closed to adopted slot state (cross-restart double-book of carried PnL). - ACCOUNT_UPDATE with wallet_balance=0 dropped (margin-only frames no longer zero e_available_margin). - Foreign-fill skip on shared VST account (PRODGREEN collision filter). - exec_router TTL: entry-requote venue-truth gate (recent own fill + live exchange position probes prevent double-entry). - bingx_direct: openOrders fetched BEFORE positions (sequential ordering prevents dangerous tear → double-entries). - Dual-leverage translation via map_internal_conviction_to_exchange_leverage() (strategy conviction → integer at-exchange leverage, bankers rounding). - BLUE-parity alpha components wired: asset picker (IRP universe ranking) + alpha sizer (cubic-convex dynamic leverage, 0.5-8.0 range). - ch_writer: date_time_input_format=best_effort on insert URLs; flush error logging at WARNING with counter. - blue_parity.price_of(): hyphen-tolerant fallback (FET-USDT → FETUSDT). - Fill test updated to incremental filled_size semantics (BingX WS lastFilledQty). - Env-override base URLs, supervisord autorestart, per-asset DC histories, single-slot invariant, fill-attribution filter. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -395,18 +395,29 @@ impl TradeSlot {
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// NOTE: a mark price must never become the PnL entry basis. The old
|
||||
// fallback (`entry_price = first mark when entry_price == 0`) silently
|
||||
// contaminated the basis of reconcile-adopted slots; if entry is
|
||||
// unknown the unrealized stays 0 and the gap is flagged in metadata
|
||||
// for the operator/reconcile layer to repair from exchange facts.
|
||||
if self.entry_price <= 0.0 {
|
||||
self.entry_price = price;
|
||||
self.metadata
|
||||
.insert("entry_basis_missing".to_string(), Value::from(true));
|
||||
self.unrealized_pnl = 0.0;
|
||||
self.metadata
|
||||
.insert("mark_price".to_string(), Value::from(price));
|
||||
return;
|
||||
}
|
||||
if self.entry_price <= 0.0 || self.size <= 0.0 {
|
||||
if self.size <= 0.0 {
|
||||
self.unrealized_pnl = 0.0;
|
||||
return;
|
||||
}
|
||||
let mut delta = (price - self.entry_price) / self.entry_price;
|
||||
// Quantity-denominated, leverage-free (leverage scales margin, not PnL).
|
||||
let mut delta = price - self.entry_price;
|
||||
if self.side == TradeSide::SHORT {
|
||||
delta = -delta;
|
||||
}
|
||||
self.unrealized_pnl = delta * self.size * self.entry_price * self.leverage;
|
||||
self.unrealized_pnl = delta * self.size;
|
||||
self.metadata
|
||||
.insert("mark_price".to_string(), Value::from(price));
|
||||
}
|
||||
@@ -1151,15 +1162,22 @@ impl KernelCore {
|
||||
}
|
||||
|
||||
fn realized_pnl(slot: &TradeSlot, exit_price: f64, exit_size: f64) -> f64 {
|
||||
if slot.entry_price <= 0.0 || exit_size <= 0.0 {
|
||||
// PnL is fill-price based and quantity-denominated:
|
||||
// LONG: (exit − entry) × qty SHORT: (entry − exit) × qty
|
||||
// Leverage does NOT multiply PnL — it only scales margin. (The old
|
||||
// ×leverage factor inflated every realized leg by the leverage and
|
||||
// was one of the two factors in the 2026-06-11 FET −$5,990 mis-book.)
|
||||
// exit_price <= 0 means the venue event carried no true fill price
|
||||
// (e.g. BingX market-order bound price stripped by the adapter) —
|
||||
// refuse to fabricate PnL from a missing price.
|
||||
if slot.entry_price <= 0.0 || exit_size <= 0.0 || exit_price <= 0.0 || !exit_price.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut delta = (exit_price - slot.entry_price) / slot.entry_price;
|
||||
let mut delta = exit_price - slot.entry_price;
|
||||
if slot.side == TradeSide::SHORT {
|
||||
delta = -delta;
|
||||
}
|
||||
let notional = exit_size * slot.entry_price * slot.leverage.max(1.0);
|
||||
delta * notional
|
||||
delta * exit_size
|
||||
}
|
||||
|
||||
fn append_event_id(slot: &mut TradeSlot, event_id: &str) {
|
||||
@@ -1906,15 +1924,41 @@ impl KernelCore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a fill to the entry or exit side by ORDER IDENTITY first, FSM
|
||||
/// state second. A late entry-remainder fill arriving while an exit is
|
||||
/// working must not be booked as an exit (it would reduce size and
|
||||
/// fabricate realized PnL — fill-misclassification class of the
|
||||
/// 2026-06-11 FET incident family).
|
||||
fn fill_matches_order(order: &Option<VenueOrder>, event: &VenueEvent) -> bool {
|
||||
match order {
|
||||
Some(o) => {
|
||||
(!event.venue_order_id.is_empty()
|
||||
&& !o.venue_order_id.is_empty()
|
||||
&& event.venue_order_id == o.venue_order_id)
|
||||
|| (!event.venue_client_id.is_empty()
|
||||
&& !o.venue_client_id.is_empty()
|
||||
&& event.venue_client_id == o.venue_client_id)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_fill(&mut self, slot: &mut TradeSlot, event: &VenueEvent, partial: bool) {
|
||||
// Identity-based routing: when the event carries order ids that match
|
||||
// one of the working orders, that match decides entry-vs-exit.
|
||||
let id_matches_entry = Self::fill_matches_order(&slot.active_entry_order, event);
|
||||
let id_matches_exit = Self::fill_matches_order(&slot.active_exit_order, event);
|
||||
|
||||
if slot.active_entry_order.is_some()
|
||||
&& matches!(
|
||||
slot.fsm_state,
|
||||
TradeStage::ORDER_REQUESTED
|
||||
| TradeStage::ORDER_SENT
|
||||
| TradeStage::ENTRY_WORKING
|
||||
| TradeStage::IDLE
|
||||
)
|
||||
&& !id_matches_exit
|
||||
&& (id_matches_entry
|
||||
|| matches!(
|
||||
slot.fsm_state,
|
||||
TradeStage::ORDER_REQUESTED
|
||||
| TradeStage::ORDER_SENT
|
||||
| TradeStage::ENTRY_WORKING
|
||||
| TradeStage::IDLE
|
||||
))
|
||||
{
|
||||
let fill_size = if event.filled_size > 0.0 {
|
||||
event.filled_size
|
||||
@@ -1937,6 +1981,25 @@ impl KernelCore {
|
||||
.as_ref()
|
||||
.map(|order| order.intended_size)
|
||||
.unwrap_or(event.size);
|
||||
// Entry basis = VWAP across entry fills (never the last fill's
|
||||
// price alone, and never a price-less event's 0.0).
|
||||
let prev_basis = if slot.entry_price > 0.0 {
|
||||
slot.entry_price
|
||||
} else {
|
||||
slot.active_entry_order
|
||||
.as_ref()
|
||||
.map(|o| o.average_fill_price)
|
||||
.unwrap_or(0.0)
|
||||
};
|
||||
let vwap_entry = if event.price > 0.0 && accumulated > 0.0 {
|
||||
if prev_basis > 0.0 && prev_filled > 0.0 {
|
||||
(prev_basis * prev_filled + event.price * fill_size) / accumulated
|
||||
} else {
|
||||
event.price
|
||||
}
|
||||
} else {
|
||||
prev_basis
|
||||
};
|
||||
slot.active_entry_order = Some(VenueOrder {
|
||||
internal_trade_id: slot.trade_id.clone(),
|
||||
venue_order_id: event.venue_order_id.clone(),
|
||||
@@ -1944,7 +2007,7 @@ impl KernelCore {
|
||||
side: slot.side.clone(),
|
||||
intended_size,
|
||||
filled_size: accumulated,
|
||||
average_fill_price: event.price,
|
||||
average_fill_price: vwap_entry,
|
||||
status: if partial {
|
||||
VenueOrderStatus::PARTIALLY_FILLED
|
||||
} else {
|
||||
@@ -1961,8 +2024,8 @@ impl KernelCore {
|
||||
slot.initial_size = if intended_size > 0.0 { intended_size } else { accumulated };
|
||||
}
|
||||
slot.size = accumulated;
|
||||
if event.price > 0.0 {
|
||||
slot.entry_price = event.price;
|
||||
if vwap_entry > 0.0 {
|
||||
slot.entry_price = vwap_entry;
|
||||
}
|
||||
slot.unrealized_pnl = 0.0;
|
||||
slot.last_event_time = Some(event.timestamp);
|
||||
@@ -1977,7 +2040,7 @@ impl KernelCore {
|
||||
side: slot.side.clone(),
|
||||
intended_size: slot.size,
|
||||
filled_size: slot.size,
|
||||
average_fill_price: event.price,
|
||||
average_fill_price: slot.entry_price,
|
||||
status: VenueOrderStatus::FILLED,
|
||||
metadata: {
|
||||
let mut map = Map::new();
|
||||
@@ -1990,6 +2053,7 @@ impl KernelCore {
|
||||
}
|
||||
|
||||
if slot.active_exit_order.is_some()
|
||||
&& !id_matches_entry
|
||||
&& matches!(
|
||||
slot.fsm_state,
|
||||
TradeStage::EXIT_REQUESTED
|
||||
@@ -2005,6 +2069,14 @@ impl KernelCore {
|
||||
}
|
||||
.max(0.0);
|
||||
let realized = Self::realized_pnl(slot, event.price, fill_size);
|
||||
if fill_size > 0.0 && (event.price <= 0.0 || !event.price.is_finite()) {
|
||||
// Exit fill without a true fill price: size is still reduced
|
||||
// (the position really shrank) but no PnL is fabricated.
|
||||
// Flag it so the settled/exchange-fact path can repair the
|
||||
// realized figure from venue truth.
|
||||
slot.metadata
|
||||
.insert("realized_skipped_no_price".to_string(), Value::from(true));
|
||||
}
|
||||
slot.realized_pnl += realized;
|
||||
slot.size = (slot.size - fill_size).max(0.0);
|
||||
slot.mark_price(event.price);
|
||||
|
||||
@@ -181,7 +181,7 @@ class BingxUserStream:
|
||||
data = bal.get("balance") if isinstance(bal.get("balance"), dict) else bal
|
||||
else:
|
||||
data = {}
|
||||
wallet = _safe_float(data.get("equity") or data.get("balance") or data.get("totalWalletBalance"))
|
||||
wallet = _safe_float(data.get("balance") or data.get("equity") or data.get("totalWalletBalance"))
|
||||
avail = _safe_float(data.get("availableMargin") or data.get("availableBalance"))
|
||||
used = _safe_float(data.get("usedMargin") or data.get("frozenMargin") or data.get("totalInitialMargin"))
|
||||
maint = _safe_float(data.get("maintenanceMargin") or data.get("totalMaintMargin") or 0.0)
|
||||
|
||||
@@ -451,8 +451,26 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
include_history=False: all_orders/all_fills require a symbol (symbol=None
|
||||
skips them anyway), so include_history=True was fetching nothing extra.
|
||||
"""
|
||||
# FILL VISIBILITY (2026-06-10): when the kernel slot owns an asset,
|
||||
# fetch symbol-scoped history (all_orders + all_fills) so a maker
|
||||
# entry that FILLED — and therefore left openOrders — reaches the FSM
|
||||
# as a FULL_FILL event. With symbol=None the snapshot skips history
|
||||
# entirely: the FSM stayed fill-blind (slot size 0 in ENTRY_WORKING),
|
||||
# the DecisionEngine saw "no position", and re-entered → the live
|
||||
# double-entries at 15:20 and 17:24 UTC.
|
||||
recon_symbol = None
|
||||
kernel = getattr(self, "_kernel_ref", None)
|
||||
if kernel is not None:
|
||||
try:
|
||||
slot = kernel.slot(0)
|
||||
if not slot.is_free() and getattr(slot, "asset", ""):
|
||||
recon_symbol = str(slot.asset)
|
||||
except Exception:
|
||||
recon_symbol = None
|
||||
try:
|
||||
snapshot = await self.backend.refresh_state(None, include_history=False)
|
||||
snapshot = await self.backend.refresh_state(
|
||||
recon_symbol, include_history=recon_symbol is not None
|
||||
)
|
||||
except Exception as exc:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).warning("reconcile: refresh_state failed: %s", exc)
|
||||
@@ -564,7 +582,13 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
venue_client_id=client_order_id,
|
||||
side=intent.side,
|
||||
asset=intent.asset,
|
||||
price=safe_float(_row_float(ack_row, "avgPrice", "ap", "price", "lastFillPrice", default=getattr(receipt, "price", 0.0)), 0.0),
|
||||
# FILL price must be a TRUE fill price (avgPrice/lastFillPrice).
|
||||
# Never fall back to the order's nominal "price" or the submit
|
||||
# receipt price: for BingX MARKET orders that is the protective
|
||||
# bound (±20-25% from mark) — it poisoned realized PnL on every
|
||||
# market fill (FET −$5,990 mis-book, 2026-06-11). 0.0 = unknown;
|
||||
# the kernel refuses to compute PnL from a missing price.
|
||||
price=safe_float(_row_float(ack_row, "avgPrice", "ap", "lastFillPrice", "L", default=0.0), 0.0),
|
||||
size=float(intent.target_size or 0.0),
|
||||
filled_size=float(filled_size),
|
||||
remaining_size=float(remaining_size),
|
||||
@@ -680,6 +704,13 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
filled = _row_float(row, "executedQty", "cumFilledQty", "filledQty", "z", "lastFilledQty", default=0.0)
|
||||
if filled <= 0.0 and kind in {KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
filled = size
|
||||
# For FILL events only true fill-price fields qualify; the nominal
|
||||
# "price" is the MARKET bound price on BingX and must never feed PnL.
|
||||
# Non-fill events (ACK/CANCEL/REJECT) may keep it as informational.
|
||||
if kind in {KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
row_price = _row_float(row, "avgPrice", "ap", "lastFillPrice", "L", default=0.0)
|
||||
else:
|
||||
row_price = _row_float(row, "avgPrice", "ap", "price", "lastFillPrice", default=0.0)
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=_event_id(self._event_seq),
|
||||
@@ -691,7 +722,7 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
venue_client_id=_row_text(row, "clientOrderID", "clientOrderId", "c", default=""),
|
||||
side=_trade_side_from_row(row),
|
||||
asset=_row_text(row, "symbol", default=""),
|
||||
price=safe_float(_row_float(row, "avgPrice", "ap", "price", "lastFillPrice", default=0.0), 0.0),
|
||||
price=safe_float(row_price, 0.0),
|
||||
size=abs(float(size or 0.0)),
|
||||
filled_size=abs(float(filled or 0.0)),
|
||||
remaining_size=max(0.0, abs(float(size or 0.0)) - abs(float(filled or 0.0))),
|
||||
@@ -715,7 +746,9 @@ class BingxVenueAdapter(VenueAdapter):
|
||||
venue_client_id=_row_text(row, "clientOrderID", "clientOrderId", "c", default=""),
|
||||
side=_trade_side_from_row(row),
|
||||
asset=_row_text(row, "symbol", default=""),
|
||||
price=safe_float(_row_float(row, "lastFillPrice", "L", "price", "ap", default=0.0), 0.0),
|
||||
# True fill-price fields only — nominal "price" excluded (MARKET
|
||||
# bound-price poisoning; see _events_from_submit note).
|
||||
price=safe_float(_row_float(row, "lastFillPrice", "L", "avgPrice", "ap", default=0.0), 0.0),
|
||||
size=abs(_row_float(row, "executedQty", "z", "lastFilledQty", default=0.0)),
|
||||
filled_size=abs(_row_float(row, "lastFilledQty", "l", "z", default=0.0)),
|
||||
remaining_size=max(0.0, abs(_row_float(row, "executedQty", "z", "lastFilledQty", default=0.0)) - abs(_row_float(row, "lastFilledQty", "l", "z", default=0.0))),
|
||||
|
||||
@@ -1080,6 +1080,13 @@ class ExecutionKernel:
|
||||
slots = [self._get_slot(i) for i in range(self.max_slots)]
|
||||
self.account.observe_slots(slots)
|
||||
for current in slots:
|
||||
# Anchor the settle baseline to the adopted slot's realized_pnl.
|
||||
# _last_settled_pnl starts empty each process; without this, the
|
||||
# first venue event on a reconcile-adopted slot settles the slot's
|
||||
# ENTIRE carried realized_pnl into AccountProjection as if it were
|
||||
# new PnL (cross-restart double-book class, 2026-06-11).
|
||||
self._last_settled_pnl[current.slot_id] = float(current.realized_pnl or 0.0)
|
||||
self._slot_was_closed[current.slot_id] = bool(current.closed)
|
||||
self.projection.write_slot(current)
|
||||
self.zinc_plane.write_slot(current)
|
||||
return outcome
|
||||
@@ -1175,9 +1182,18 @@ class ExecutionKernel:
|
||||
Safe to call on a fresh kernel (e.g. after startup) before any trades.
|
||||
"""
|
||||
try:
|
||||
return _get_rust().restore_state(self._backend, json_str)
|
||||
ok = _get_rust().restore_state(self._backend, json_str)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return False
|
||||
if ok:
|
||||
# Re-anchor settle baselines to restored slot state (same
|
||||
# cross-restart double-book guard as reconcile_from_slots).
|
||||
self.state.refresh()
|
||||
for slot_id in range(self.max_slots):
|
||||
restored = self._get_slot(slot_id)
|
||||
self._last_settled_pnl[slot_id] = float(restored.realized_pnl or 0.0)
|
||||
self._slot_was_closed[slot_id] = bool(restored.closed)
|
||||
return ok
|
||||
|
||||
def is_capital_frozen(self) -> bool:
|
||||
"""Return True if the kernel's capital is frozen (reconcile ERROR active).
|
||||
|
||||
Reference in New Issue
Block a user