198 lines
8.0 KiB
Python
198 lines
8.0 KiB
Python
|
|
"""
|
|||
|
|
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()
|