""" Exp 1 — proxy_B-driven position sizing. Instead of binary gating, scale bet_sizer.base_fraction proportionally to the proxy_B percentile in a rolling window. High proxy_B (stress incoming) → scale UP (better mean-reversion environment) Low proxy_B (calm market) → scale DOWN (weaker signal) Variants tested: S1: [0.50x, 1.50x] linear, window=500 S2: [0.25x, 2.00x] linear, window=500 (more aggressive) S3: [0.50x, 1.50x] linear, window=1000 (slower adaptation) S4: [0.50x, 1.50x] clipped at p25/p75 (only extreme ends change) Results logged to exp1_proxy_sizing_results.json. """ import sys, time sys.stdout.reconfigure(encoding='utf-8', errors='replace') from pathlib import Path import numpy as np _HERE = Path(__file__).resolve().parent sys.path.insert(0, str(_HERE.parent)) from exp_shared import ( ensure_jit, ENGINE_KWARGS, GOLD, load_data, load_forewarner, run_backtest, print_table, log_results ) from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine # ── ProxyBSizedEngine ───────────────────────────────────────────────────────── class ProxyBSizedEngine(NDAlphaEngine): """ NDAlphaEngine that scales base_fraction by rolling proxy_B percentile. Parameters ---------- proxy_b_min_scale : float Minimum fraction multiplier (at p0 of proxy_B) proxy_b_max_scale : float Maximum fraction multiplier (at p100 of proxy_B) proxy_b_clip_low : float Percentile below which use min_scale (0=linear, 0.25=clip p25) proxy_b_clip_high : float Percentile above which use max_scale proxy_b_window : int Rolling history length for percentile """ def __init__(self, *args, proxy_b_min_scale: float = 0.5, proxy_b_max_scale: float = 1.5, proxy_b_clip_low: float = 0.0, proxy_b_clip_high: float = 1.0, proxy_b_window: int = 500, **kwargs): super().__init__(*args, **kwargs) self._pb_min = proxy_b_min_scale self._pb_max = proxy_b_max_scale self._pb_clip_lo = proxy_b_clip_low self._pb_clip_hi = proxy_b_clip_high self._pb_window = proxy_b_window self._pb_history = [] self._current_inst50 = 0.0 self._current_v750 = 0.0 # Stats self.sizing_scales = [] self.sizing_scale_mean = 1.0 def _proxy_b(self): return self._current_inst50 - self._current_v750 def _compute_scale(self): pb = self._proxy_b() self._pb_history.append(pb) if len(self._pb_history) > self._pb_window * 2: self._pb_history = self._pb_history[-self._pb_window:] if len(self._pb_history) < 20: return 1.0 # neutral until enough history hist = np.array(self._pb_history[-self._pb_window:]) pct = float(np.mean(hist <= pb)) # empirical percentile of current pb # Clip pct = max(self._pb_clip_lo, min(self._pb_clip_hi, pct)) # Normalize pct into [0,1] between clip boundaries span = self._pb_clip_hi - self._pb_clip_lo if span < 1e-9: return 1.0 t = (pct - self._pb_clip_lo) / span scale = self._pb_min + t * (self._pb_max - self._pb_min) return float(scale) def process_day(self, date_str, df, asset_columns, vol_regime_ok=None, direction=None, posture='APEX'): self.begin_day(date_str, posture=posture, direction=direction) bid = 0 for ri in range(len(df)): row = df.iloc[ri] vd = row.get('vel_div') if vd is None or not np.isfinite(float(vd)): self._global_bar_idx += 1; bid += 1; continue v50_raw = row.get('v50_lambda_max_velocity') v750_raw = row.get('v750_lambda_max_velocity') inst_raw = row.get('instability_50') v50_val = float(v50_raw) if (v50_raw is not None and np.isfinite(float(v50_raw))) else 0.0 v750_val = float(v750_raw) if (v750_raw is not None and np.isfinite(float(v750_raw))) else 0.0 inst_val = float(inst_raw) if (inst_raw is not None and np.isfinite(float(inst_raw))) else 0.0 self._current_inst50 = inst_val self._current_v750 = v750_val prices = {} for ac in asset_columns: p = row.get(ac) if p is not None and p > 0 and np.isfinite(p): prices[ac] = float(p) if not prices: self._global_bar_idx += 1; bid += 1; continue vrok = bool(vol_regime_ok[ri]) if vol_regime_ok is not None else (bid >= 100) self.step_bar(bar_idx=ri, vel_div=float(vd), prices=prices, vol_regime_ok=vrok, v50_vel=v50_val, v750_vel=v750_val) bid += 1 # Update mean scale stat if self.sizing_scales: self.sizing_scale_mean = float(np.mean(self.sizing_scales)) return self.end_day() def _try_entry(self, bar_idx, vel_div, prices, price_histories, v50_vel=0.0, v750_vel=0.0): scale = self._compute_scale() self.sizing_scales.append(scale) # Temporarily scale fraction orig = self.bet_sizer.base_fraction self.bet_sizer.base_fraction = orig * scale result = super()._try_entry(bar_idx, vel_div, prices, price_histories, v50_vel, v750_vel) self.bet_sizer.base_fraction = orig return result # ── Experiment configs ──────────────────────────────────────────────────────── SIZING_VARIANTS = [ # (name, min_scale, max_scale, clip_lo, clip_hi, window) ('S1: [0.5x–1.5x] lin w500', 0.50, 1.50, 0.0, 1.0, 500), ('S2: [0.25x–2.0x] lin w500', 0.25, 2.00, 0.0, 1.0, 500), ('S3: [0.5x–1.5x] lin w1000', 0.50, 1.50, 0.0, 1.0, 1000), ('S4: [0.5x–1.5x] clip p25-p75', 0.50, 1.50, 0.25, 0.75, 500), ] def main(): ensure_jit() print("\nLoading data & forewarner...") load_data() fw = load_forewarner() results = [] # Baseline (no sizing mod) — confirms alignment with gold print("\n" + "="*60) print("BASELINE (no proxy sizing)") t0 = time.time() r = run_backtest(lambda kw: NDAlphaEngine(**kw), 'Baseline (no sizing)', forewarner=fw) r['elapsed'] = time.time() - t0 results.append(r) print(f" {r['roi']:.2f}% PF={r['pf']:.4f} DD={r['dd']:.2f}% T={r['trades']} ({r['elapsed']:.0f}s)") # Sizing variants for vname, mn, mx, clo, chi, win in SIZING_VARIANTS: print(f"\n{'='*60}\n{vname}") t0 = time.time() def factory(kw, mn=mn, mx=mx, clo=clo, chi=chi, win=win): return ProxyBSizedEngine(**kw, proxy_b_min_scale=mn, proxy_b_max_scale=mx, proxy_b_clip_low=clo, proxy_b_clip_high=chi, proxy_b_window=win) r = run_backtest(factory, vname, forewarner=fw) r['elapsed'] = time.time() - t0 if r.get('sizing_scale_mean'): print(f" scale_mean={r['sizing_scale_mean']:.3f}") print(f" {r['roi']:.2f}% PF={r['pf']:.4f} DD={r['dd']:.2f}% T={r['trades']} ({r['elapsed']:.0f}s)") results.append(r) print("\n" + "="*83) print("EXP 1 — proxy_B POSITION SIZING RESULTS") print("="*83) print_table(results, gold=GOLD) log_results(results, _HERE / 'exp1_proxy_sizing_results.json', meta={ 'experiment': 'proxy_B position sizing', 'description': 'Scale base_fraction by rolling proxy_B percentile', 'proxy': 'instability_50 - v750_lambda_max_velocity', }) if __name__ == '__main__': main()