feat(s6/esof): orchestrator single-site S6+EsoF+int-leverage gate

All sizing multipliers now applied at one location (esf_alpha_orchestrator.py
line 565 region) so there are no hidden side-effects and BLUE parity is
trivially preserved by leaving the new kwargs at None/False.

- S6 per-bucket notional multiplier via s6_size_table kwarg
- EsoF regime gate at _try_entry top: reads _current_esof_label, skips
  entry if esof_sizing_table maps to 0.0 (UNFAV/MILD_NEG regime)
- Integer-leverage gate: use_int_leverage=True → leverage_int=1 (FIXED,
  pending prod/scripts/analyze_leverage_winrate.py analysis); float
  leverage_raw preserved in NDPosition + return dict for CH logging
- notional <= 0 → return None guard (prevents 0-notional positions)
- NDPosition extended: asset_bucket_id, s6_mult, esof_mult, esof_label,
  leverage_raw fields (BLUE leaves these at defaults)

Plan ref: Task 2 — sizer stays pure (its returned notional is discarded
by line 565 anyway; adding mults inside would be dead code with hidden
side-effects).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hjnormey
2026-04-22 06:07:20 +02:00
parent ce7f3ce8ff
commit 0da46d8635

View File

@@ -14,6 +14,7 @@ Flow per tick:
Source: dolphin_vbt_real.py simulate_multi_asset_nb() lines 1715-2250 Source: dolphin_vbt_real.py simulate_multi_asset_nb() lines 1715-2250
""" """
import math
import uuid import uuid
import numpy as np import numpy as np
from numba import njit from numba import njit
@@ -57,6 +58,12 @@ class NDPosition:
entry_v50_vel: float = 0.0 entry_v50_vel: float = 0.0
entry_v750_vel: float = 0.0 entry_v750_vel: float = 0.0
current_price: float = 0.0 current_price: float = 0.0
# GREEN-only observability fields (BLUE leaves at defaults).
asset_bucket_id: Optional[int] = None
s6_mult: float = 1.0
esof_mult: float = 1.0
esof_label: Optional[str] = None
leverage_raw: Optional[float] = None # pre-int-rounding leverage, for CH analysis
@property @property
def pnl_pct(self) -> float: def pnl_pct(self) -> float:
@@ -134,6 +141,11 @@ class NDAlphaEngine:
use_dynamic_leverage: bool = True, use_dynamic_leverage: bool = True,
# Absolute leverage ceiling — ACB/MC/EsoF steer within [base, abs_max]; never breach # Absolute leverage ceiling — ACB/MC/EsoF steer within [base, abs_max]; never breach
abs_max_leverage: float = 6.0, abs_max_leverage: float = 6.0,
# GREEN-only toggles (BLUE no-op when left at None/False).
s6_size_table: Optional[Dict[int, float]] = None,
esof_sizing_table: Optional[Dict[str, float]] = None,
asset_bucket_data: Optional[Dict[str, Any]] = None,
use_int_leverage: bool = False,
# Seed # Seed
seed: int = 42, seed: int = 42,
): ):
@@ -147,6 +159,18 @@ class NDAlphaEngine:
self.leverage_convexity = leverage_convexity self.leverage_convexity = leverage_convexity
self.abs_max_leverage = abs_max_leverage self.abs_max_leverage = abs_max_leverage
# GREEN-only single-site mult tables + int-leverage gate.
# BLUE leaves these at None/False → notional/leverage math unchanged.
self.s6_size_table: Optional[Dict[int, float]] = s6_size_table
self.esof_sizing_table: Optional[Dict[str, float]] = esof_sizing_table
self.asset_bucket_data: Optional[Dict[str, Any]] = asset_bucket_data
self.use_int_leverage: bool = use_int_leverage
# EsoF label for current bar — set externally by dolphin_actor from HZ
# DOLPHIN_FEATURES.esof_advisor_latest each tick. None → treated as UNKNOWN.
self._current_esof_label: Optional[str] = None
# Runtime-computed per-entry mults (populated inside _try_entry).
self._esof_size_mult: float = 1.0
# Fee/slippage model # Fee/slippage model
self.use_sp_fees = use_sp_fees self.use_sp_fees = use_sp_fees
self.use_sp_slippage = use_sp_slippage self.use_sp_slippage = use_sp_slippage
@@ -466,6 +490,18 @@ class NDAlphaEngine:
if self.capital <= 0: if self.capital <= 0:
return None return None
# GREEN-only EsoF regime gate. Runs before selector/sizer at the highest-visible level.
# BLUE leaves esof_sizing_table=None → skipped entirely (mult stays 1.0, no gate).
# Treat a missing/None label as UNKNOWN (signals-in-conflict) to honour the renamed default.
esof_label: Optional[str] = None
if self.esof_sizing_table:
esof_label = self._current_esof_label or "UNKNOWN"
self._esof_size_mult = float(self.esof_sizing_table.get(esof_label, 1.0))
if self._esof_size_mult <= 0.0:
return None # regime-wide skip — no selector/sizer work
else:
self._esof_size_mult = 1.0
trade_direction = self.regime_direction trade_direction = self.regime_direction
# 1. IRP asset selection (matches dolphin_vbt_real.py lines 2047-2071) # 1. IRP asset selection (matches dolphin_vbt_real.py lines 2047-2071)
@@ -562,7 +598,46 @@ class NDAlphaEngine:
leverage = min(raw_leverage, clamped_max_leverage) leverage = min(raw_leverage, clamped_max_leverage)
leverage = max(self.bet_sizer.min_leverage, leverage) leverage = max(self.bet_sizer.min_leverage, leverage)
notional = self.capital * size_result["fraction"] * leverage # ── SINGLE-SITE notional application (S6 + EsoF + int-leverage gate) ──
# All sizing multipliers (S6 per-bucket, EsoF regime) are applied HERE, not
# inside AlphaBetSizer — the sizer stays pure so BLUE parity is trivial and
# we avoid the historical bug where sizer-internal `notional` was silently
# discarded by this very line. When every GREEN toggle is off/None, math
# collapses to the original BLUE form.
asset_bucket_id: Optional[int] = None
if self.asset_bucket_data is not None:
assignments = self.asset_bucket_data.get("assignments", {}) if isinstance(self.asset_bucket_data, dict) else {}
_bkt = assignments.get(trade_asset)
if _bkt is not None:
asset_bucket_id = int(_bkt)
s6_mult: float = 1.0
if self.s6_size_table and asset_bucket_id is not None:
s6_mult = float(self.s6_size_table.get(asset_bucket_id, 1.0))
esof_mult: float = self._esof_size_mult
# INTEGER-LEVERAGE GATE — target exchanges (e.g. Binance) require int leverage.
# DEFAULT = 1x pending leverage-vs-winrate analysis in prod/scripts/analyze_leverage_winrate.py.
# The float `leverage` variable computed above is preserved as `leverage_raw` for CH logging
# so the analysis has the data to later flip this rule.
#
# Once analysis completes, replace `leverage_int = 1` with one of:
# Option 1 (round-half-up, conservative): int(math.floor(leverage + 0.5))
# Option 2 (banker's round, aggressive): int(round(leverage))
# and keep the min=1 / abs_max clamp below.
leverage_raw: float = leverage
if self.use_int_leverage:
leverage_int = 1 # FIXED PENDING ANALYSIS
leverage_int = max(1, leverage_int)
leverage_int = min(leverage_int, int(self.abs_max_leverage))
effective_leverage: float = float(leverage_int)
else:
effective_leverage = leverage # BLUE path — unchanged float leverage
notional = self.capital * size_result["fraction"] * effective_leverage * s6_mult * esof_mult
if notional <= 0:
return None # explicit skip — prevents 0-notional NDPosition creation
# 5. Entry price — NO entry slippage (dolphin_vbt_real.py uses raw price for PnL calculation baseline) # 5. Entry price — NO entry slippage (dolphin_vbt_real.py uses raw price for PnL calculation baseline)
entry_price = prices.get(trade_asset, 0) entry_price = prices.get(trade_asset, 0)
@@ -577,12 +652,17 @@ class NDAlphaEngine:
entry_price=entry_price, entry_price=entry_price,
entry_bar=bar_idx, entry_bar=bar_idx,
notional=notional, notional=notional,
leverage=leverage, leverage=effective_leverage,
fraction=size_result["fraction"], fraction=size_result["fraction"],
entry_vel_div=vel_div, entry_vel_div=vel_div,
bucket_idx=size_result["bucket_idx"], bucket_idx=size_result["bucket_idx"],
entry_v50_vel=v50_vel, entry_v50_vel=v50_vel,
entry_v750_vel=v750_vel, entry_v750_vel=v750_vel,
asset_bucket_id=asset_bucket_id,
s6_mult=s6_mult,
esof_mult=esof_mult,
esof_label=esof_label,
leverage_raw=leverage_raw,
) )
# Consume pending overrides (set by subclasses before calling super()._try_entry). # Consume pending overrides (set by subclasses before calling super()._try_entry).
@@ -605,10 +685,15 @@ class NDAlphaEngine:
"trade_id": trade_id, "trade_id": trade_id,
"asset": trade_asset, "asset": trade_asset,
"direction": trade_direction, "direction": trade_direction,
"leverage": leverage, "leverage": effective_leverage,
"leverage_raw": leverage_raw,
"notional": notional, "notional": notional,
"vel_div": vel_div, "vel_div": vel_div,
"entry_price": entry_price, # needed by _exec_submit_entry when prices dict is empty "entry_price": entry_price, # needed by _exec_submit_entry when prices dict is empty
"asset_bucket_id": asset_bucket_id,
"s6_mult": s6_mult,
"esof_mult": esof_mult,
"esof_label": esof_label,
} }
def get_performance_summary(self) -> Dict[str, Any]: def get_performance_summary(self) -> Dict[str, Any]: