Files
DOLPHIN/prod/tools/parity_test.py

261 lines
9.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Parity Harness: BLUE vs GREEN engine functional identity test
=============================================================
Creates two identical create_d_liq_engine instances with BLUE's gold ENGINE_KWARGS,
feeds them an identical sequence of step_bar() inputs, and reports any divergence.
NO production code is modified. Reads from HZ DOLPHIN_STATE_BLUE for real bar history
if available, otherwise generates synthetic scan data.
Usage:
cd /mnt/dolphinng5_predict
siloqy-env python prod/tools/parity_test.py [--n-bars 200] [--synthetic] [--verbose]
"""
import sys
import argparse
import json
import math
import random
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT / 'nautilus_dolphin'))
sys.path.insert(0, str(PROJECT_ROOT / 'prod'))
sys.path.insert(0, str(PROJECT_ROOT))
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
# ── Gold ENGINE_KWARGS — exactly BLUE's production config ────────────────────
ENGINE_KWARGS = dict(
initial_capital=25000.0, vel_div_threshold=-0.02, vel_div_extreme=-0.05,
min_leverage=0.5, max_leverage=8.0,
leverage_convexity=3.0,
fraction=0.20, fixed_tp_pct=0.0095, stop_pct=1.0, max_hold_bars=250,
use_direction_confirm=True, dc_lookback_bars=7, dc_min_magnitude_bps=0.75,
dc_skip_contradicts=True, dc_leverage_boost=1.0, dc_leverage_reduce=0.5,
use_asset_selection=True, min_irp_alignment=0.0,
use_sp_fees=True, use_sp_slippage=True,
sp_maker_entry_rate=0.62, sp_maker_exit_rate=0.50,
use_ob_edge=True, ob_edge_bps=5.0, ob_confirm_rate=0.40,
lookback=100, use_alpha_layers=True, use_dynamic_leverage=True, seed=42,
)
ASSETS = [
"BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT",
"ADAUSDT", "DOGEUSDT", "TRXUSDT", "DOTUSDT", "MATICUSDT",
"LTCUSDT", "AVAXUSDT", "LINKUSDT", "UNIUSDT", "ATOMUSDT",
"ETCUSDT", "XLMUSDT", "BCHUSDT", "NEARUSDT", "ALGOUSDT",
]
VOL_P60 = 0.00009868
def _synthetic_prices(rng, n_assets: int, base: float = 100.0):
"""Random walk prices for all assets."""
prices = {}
for i, sym in enumerate(ASSETS[:n_assets]):
prices[sym] = base * (1 + rng.uniform(-0.001, 0.001))
return prices
def _vol_ok(bar_idx: int) -> bool:
"""Replicate BLUE vol_ok gate: True when bar_idx >= 100."""
return bar_idx >= 100
def _vel_div(rng, bar_idx: int) -> float:
"""Generate realistic vel_div; trigger entries occasionally."""
# Every ~30 bars inject a signal below threshold
if bar_idx > 50 and bar_idx % 30 == 0:
return rng.uniform(-0.06, -0.022)
return rng.uniform(-0.015, 0.05)
def build_scan_sequence(n_bars: int, seed: int = 7) -> list:
"""Generate reproducible synthetic scan sequence.
Signal injection strategy:
- Every 50 bars (starting bar 110), run a 10-bar downtrend on all prices
so direction-confirm (dc_lookback_bars=7) sees consistent bearish movement.
- On the 10th bar of the downtrend, fire vel_div = -0.035 (below threshold).
- All other bars: gentle random walk + neutral vel_div.
"""
rng = random.Random(seed)
sequence = []
prices = {sym: 100.0 + rng.uniform(0, 50) for sym in ASSETS}
SIGNAL_CYCLE = 50 # bars between signal attempts
SIGNAL_START = 110 # first signal at bar 110 (well past lookback=100)
TREND_BARS = 10 # bars of forced downtrend before vel_div fires
TREND_DROP = 0.0012 # per-bar drop per asset (~0.12%, enough for dc_magnitude_bps=0.75)
def _in_trend_window(bar_idx):
if bar_idx < SIGNAL_START:
return False, False
offset = (bar_idx - SIGNAL_START) % SIGNAL_CYCLE
in_trend = offset < TREND_BARS
fire_bar = offset == TREND_BARS - 1
return in_trend, fire_bar
for bar_idx in range(n_bars):
in_trend, fire_bar = _in_trend_window(bar_idx)
if in_trend:
# Downtrend: push all prices down slightly
for sym in prices:
prices[sym] *= (1 - TREND_DROP + rng.uniform(-0.0002, 0.0002))
else:
# Normal random walk
for sym in prices:
prices[sym] *= (1 + rng.uniform(-0.002, 0.002))
prices_snap = dict(prices)
# vel_div
if fire_bar:
vd = -0.035 # strong signal
elif in_trend:
vd = -0.010 # mild bearish, not yet threshold
else:
vd = rng.uniform(-0.010, 0.040)
# v50/v750 — negative during trend (consistent with short signal)
if in_trend:
v50 = -0.005 + rng.uniform(-0.001, 0.001)
v750 = -0.002 + rng.uniform(-0.0005, 0.0005)
else:
v50 = rng.uniform(-0.01, 0.01)
v750 = rng.uniform(-0.005, 0.005)
sequence.append({
'bar_idx': bar_idx,
'vel_div': vd,
'prices': prices_snap,
'vol_regime_ok': _vol_ok(bar_idx),
'v50_vel': v50,
'v750_vel': v750,
})
return sequence
_SKIP_KEYS = {'trade_id'} # UUID — differs per instance by design; not functional
def _compare_dicts(label: str, da: dict, db: dict, tol: float = 1e-9) -> list:
"""Compare two dicts (entry or exit sub-dict). Return list of divergence strings."""
divs = []
if da is None and db is None:
return []
if da is None or db is None:
return [f" {label}: A={da!r} B={db!r}"]
all_keys = (set(da) | set(db)) - _SKIP_KEYS
for k in sorted(all_keys):
va, vb = da.get(k), db.get(k)
if va == vb:
continue
try:
if math.isclose(float(va), float(vb), rel_tol=tol, abs_tol=tol):
continue
except (TypeError, ValueError):
pass
divs.append(f" {label}[{k!r}]: A={va!r} B={vb!r}")
return divs
def _compare_results(idx: int, r_a: dict, r_b: dict, verbose: bool) -> list:
"""Compare two step_bar() return dicts {'exit':..., 'entry':...}."""
divs = []
divs += _compare_dicts('entry', r_a.get('entry'), r_b.get('entry'))
divs += _compare_dicts('exit', r_a.get('exit'), r_b.get('exit'))
if divs and verbose:
print(f"\n[bar {idx}] DIVERGENCE:")
for d in divs:
print(d)
print(f" A: {r_a}")
print(f" B: {r_b}")
elif not divs and verbose:
entry_a = r_a.get('entry')
exit_a = r_a.get('exit')
if entry_a is not None:
print(f"[bar {idx}] MATCH ENTRY asset={entry_a.get('asset')} lev={entry_a.get('leverage'):.3f}")
if exit_a is not None:
print(f"[bar {idx}] MATCH EXIT asset={exit_a.get('asset')} reason={exit_a.get('reason')} pnl={exit_a.get('pnl_pct',0)*100:.4f}%")
return divs
def run_parity(n_bars: int = 300, verbose: bool = False, seed: int = 7):
print(f"Parity harness: n_bars={n_bars} seed={seed}")
print("Creating engine A (reference) ...")
eng_a = create_d_liq_engine(**ENGINE_KWARGS)
print("Creating engine B (dut) ...")
eng_b = create_d_liq_engine(**ENGINE_KWARGS)
sequence = build_scan_sequence(n_bars=n_bars, seed=seed)
print(f"Scan sequence generated: {len(sequence)} bars, {len(ASSETS)} assets")
print("Running step_bar() on both engines with identical inputs ...")
print()
total_divs = 0
diverged_bars = []
n_entries_a = n_exits_a = 0
n_entries_b = n_exits_b = 0
for scan in sequence:
bar_idx = scan['bar_idx']
vel_div = scan['vel_div']
prices = scan['prices']
vol_regime_ok = scan['vol_regime_ok']
v50_vel = scan['v50_vel']
v750_vel = scan['v750_vel']
r_a = eng_a.step_bar(
bar_idx=bar_idx, vel_div=vel_div, prices=prices,
vol_regime_ok=vol_regime_ok, v50_vel=v50_vel, v750_vel=v750_vel,
)
r_b = eng_b.step_bar(
bar_idx=bar_idx, vel_div=vel_div, prices=prices,
vol_regime_ok=vol_regime_ok, v50_vel=v50_vel, v750_vel=v750_vel,
)
if r_a.get('entry') is not None: n_entries_a += 1
if r_a.get('exit') is not None: n_exits_a += 1
if r_b.get('entry') is not None: n_entries_b += 1
if r_b.get('exit') is not None: n_exits_b += 1
divs = _compare_results(bar_idx, r_a, r_b, verbose=verbose)
if divs:
total_divs += 1
diverged_bars.append(bar_idx)
# Summary
print("=" * 60)
print(f"Engine A: {n_entries_a} entries, {n_exits_a} exits")
print(f"Engine B: {n_entries_b} entries, {n_exits_b} exits")
print()
if total_divs == 0 and n_entries_a > 0:
print(f"RESULT: PASS — engines are functionally identical ({n_entries_a} trades exercised).")
elif total_divs == 0 and n_entries_a == 0:
print("RESULT: PASS (no entries exercised — increase --n-bars or check signal injection)")
else:
print(f"RESULT: FAIL — {total_divs} diverged bars: {diverged_bars[:20]}")
sys.exit(1)
def main():
ap = argparse.ArgumentParser(description="BLUE/GREEN engine parity test")
ap.add_argument('--n-bars', type=int, default=300, help='number of bars to replay')
ap.add_argument('--seed', type=int, default=7, help='RNG seed for synthetic scans')
ap.add_argument('--verbose', action='store_true', help='print each bar match/divergence')
args = ap.parse_args()
run_parity(n_bars=args.n_bars, verbose=args.verbose, seed=args.seed)
if __name__ == '__main__':
main()