Files
DOLPHIN/nautilus_dolphin/tests/test_green_blue_parity.py

541 lines
23 KiB
Python
Raw Normal View History

"""
GREEN-BLUE Algorithmic Parity Tests
Verifies that DolphinActor (GREEN) has full algorithmic parity with
the BLUE production system (nautilus_event_trader.py).
Covers:
1. MC_BASE_CFG parameter parity
2. ALGO_VERSION constant match
3. vel_div formula (v50 - v750)
4. vol_ok computation (rolling BTC dvol gate)
5. _BUCKET_SL_PCT parity
6. Engine kwargs gold-spec values
7. Hibernate protection logic
8. NG7 normalization parity
"""
import math
import numpy as np
import pytest
from pathlib import Path
from collections import deque
# ── Import GREEN (DolphinActor) ──────────────────────────────────────────────
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent / 'nautilus_dolphin'))
from nautilus_dolphin.nautilus.dolphin_actor import (
DolphinActor,
_MC_BASE_CFG,
ALGO_VERSION,
BTC_VOL_WINDOW,
VOL_P60_THRESHOLD,
_BUCKET_SL_PCT,
_GateSnap,
)
# ── Import BLUE constants for comparison ─────────────────────────────────────
_BLUE_ROOT = Path('/mnt/dolphinng5_predict/prod')
sys.path.insert(0, str(_BLUE_ROOT))
# ═══════════════════════════════════════════════════════════════════════════════
# 1. MC_BASE_CFG PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestMCBaseCfgParity:
"""GREEN's _MC_BASE_CFG must match BLUE's gold-spec MC config exactly."""
# BLUE gold values (from nautilus_event_trader.py MC_BASE_CFG)
BLUE_GOLD = {
'max_leverage': 8.00,
'max_hold_bars': 250,
'min_irp_alignment': 0.0,
'vel_div_threshold': -0.020,
'vel_div_extreme': -0.050,
'min_leverage': 0.50,
'leverage_convexity': 3.00,
'fraction': 0.20,
'fixed_tp_pct': 0.0095,
'stop_pct': 1.00,
'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.00,
'ob_confirm_rate': 0.40,
'lookback': 100,
'use_direction_confirm': True,
'dc_lookback_bars': 7,
'dc_min_magnitude_bps': 0.75,
'dc_skip_contradicts': True,
'dc_leverage_boost': 1.00,
'dc_leverage_reduce': 0.50,
'use_asset_selection': True,
'use_alpha_layers': True,
'use_dynamic_leverage': True,
'acb_beta_high': 0.80,
'acb_beta_low': 0.20,
'acb_w750_threshold_pct': 60,
}
@pytest.mark.parametrize("key,expected", list(BLUE_GOLD.items()))
def test_mc_cfg_key_matches_blue(self, key, expected):
assert key in _MC_BASE_CFG, f"Key '{key}' missing from _MC_BASE_CFG"
assert _MC_BASE_CFG[key] == expected, (
f"MC_BASE_CFG['{key}']: GREEN={_MC_BASE_CFG[key]} != BLUE={expected}"
)
def test_max_leverage_is_8x(self):
assert _MC_BASE_CFG['max_leverage'] == 8.0
def test_max_hold_bars_is_250(self):
assert _MC_BASE_CFG['max_hold_bars'] == 250
def test_min_irp_alignment_is_zero(self):
assert _MC_BASE_CFG['min_irp_alignment'] == 0.0
# ═══════════════════════════════════════════════════════════════════════════════
# 2. ALGO_VERSION PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestAlgoVersion:
def test_algo_version_is_v2_gold_fix(self):
assert ALGO_VERSION == "v2_gold_fix_v50-v750"
def test_algo_version_is_string(self):
assert isinstance(ALGO_VERSION, str)
def test_algo_version_not_v1_shakedown(self):
assert "v1" not in ALGO_VERSION
assert "v150" not in ALGO_VERSION
# ═══════════════════════════════════════════════════════════════════════════════
# 3. VEL_DIV FORMULA PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestVelDivFormula:
"""vel_div must always be v50 - v750, never v50 - v150."""
def test_v50_minus_v750_basic(self):
v50, v750 = -0.03, -0.01
assert (v50 - v750) == pytest.approx(-0.02)
def test_v50_minus_v750_signal_trigger(self):
vel_div = -0.035 - 0.005 # v50=-0.035, v750=0.005
assert vel_div < -0.02 # should trigger entry
def test_not_v50_minus_v150(self):
"""CRITICAL: v50-v150 was the v1 shakedown bug."""
v50, v150, v750 = -0.03, -0.02, -0.01
correct = v50 - v750 # -0.02
buggy = v50 - v150 # -0.01
assert correct != buggy, "v50-v750 must differ from v50-v150"
def test_ng7_normalize_uses_v750(self):
"""Verify _normalize_ng7_scan computes v50-v750."""
scan = {
'version': 'NG7',
'result': {
'multi_window_results': {
'50': {'tracking_data': {'lambda_max_velocity': -0.04}},
'150': {'tracking_data': {'lambda_max_velocity': -0.02}},
'750': {'tracking_data': {'lambda_max_velocity': -0.01}},
},
'pricing_data': {'current_prices': {'BTCUSDT': 70000.0}},
'regime_prediction': {'instability_score': 0.5},
},
}
result = DolphinActor._normalize_ng7_scan(scan)
expected = -0.04 - (-0.01) # v50 - v750 = -0.03
assert abs(result['vel_div'] - expected) < 1e-10, (
f"vel_div={result['vel_div']} but expected v50-v750={expected}"
)
def test_ng7_normalize_not_v150(self):
scan = {
'version': 'NG7',
'result': {
'multi_window_results': {
'50': {'tracking_data': {'lambda_max_velocity': -0.04}},
'150': {'tracking_data': {'lambda_max_velocity': -0.02}},
'750': {'tracking_data': {'lambda_max_velocity': -0.01}},
},
'pricing_data': {'current_prices': {'BTCUSDT': 70000.0}},
'regime_prediction': {'instability_score': 0.5},
},
}
result = DolphinActor._normalize_ng7_scan(scan)
buggy = -0.04 - (-0.02) # v50 - v150 = -0.02
assert result['vel_div'] != buggy, "vel_div must NOT be v50-v150"
# ═══════════════════════════════════════════════════════════════════════════════
# 4. VOL_OK COMPUTATION PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestVolOkParity:
"""GREEN must use the same BTC rolling dvol gate as BLUE."""
def _make_actor(self):
config = {'strategy_name': 'test', 'engine': {}}
return DolphinActor(config)
def test_vol_p60_threshold_matches_blue(self):
assert VOL_P60_THRESHOLD == 0.00009868
def test_btc_vol_window_matches_blue(self):
assert BTC_VOL_WINDOW == 50
def test_vol_ok_returns_true_when_insufficient_data(self):
actor = self._make_actor()
scan = {'assets': ['BTCUSDT'], 'asset_prices': [70000.0]}
assert actor._compute_vol_ok(scan) is True
def test_vol_ok_returns_true_when_no_btc(self):
actor = self._make_actor()
scan = {'assets': ['ETHUSDT'], 'asset_prices': [3500.0]}
assert actor._compute_vol_ok(scan) is True
def test_vol_ok_returns_true_high_vol(self):
"""High volatility regime should pass vol_ok."""
actor = self._make_actor()
# Simulate volatile BTC prices
base = 70000.0
for i in range(52):
noise = (i % 3 - 1) * 500.0 # large swings
actor.btc_prices.append(base + noise)
scan = {'assets': ['BTCUSDT'], 'asset_prices': [70000.0 + 500.0]}
assert actor._compute_vol_ok(scan) is True
def test_vol_ok_returns_false_low_vol(self):
"""Very low volatility should fail vol_ok."""
actor = self._make_actor()
base = 70000.0
for i in range(52):
actor.btc_prices.append(base + i * 0.001) # near-zero variance
scan = {'assets': ['BTCUSDT'], 'asset_prices': [70000.052]}
assert actor._compute_vol_ok(scan) is False
def test_vol_ok_empty_scan(self):
actor = self._make_actor()
assert actor._compute_vol_ok({}) is True
def test_vol_ok_matches_blue_formula(self):
"""Verify the dvol computation formula matches BLUE exactly."""
actor = self._make_actor()
# Use volatile prices so dvol > threshold
prices = [70000.0 + ((-1)**i) * (i * 50.0) for i in range(52)]
for p in prices:
actor.btc_prices.append(p)
scan = {'assets': ['BTCUSDT'], 'asset_prices': [70000.0]}
# Manually compute like BLUE
arr = np.array(list(actor.btc_prices))
expected_dvol = float(np.std(np.diff(arr) / arr[:-1]))
result = actor._compute_vol_ok(scan)
assert result == (expected_dvol > VOL_P60_THRESHOLD), (
f"dvol={expected_dvol:.8f} threshold={VOL_P60_THRESHOLD} result={result}"
)
# ═══════════════════════════════════════════════════════════════════════════════
# 5. BUCKET SL PCT PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestBucketSlPctParity:
"""GREEN must have the same per-bucket SL percentages as BLUE."""
BLUE_BUCKET_SL = {
0: 0.015, 1: 0.012, 2: 0.015, 3: 0.025,
4: 0.008, 5: 0.018, 6: 0.030, 'default': 0.015,
}
@pytest.mark.parametrize("bucket_id,expected", list(BLUE_BUCKET_SL.items()))
def test_bucket_sl_matches_blue(self, bucket_id, expected):
assert bucket_id in _BUCKET_SL_PCT, f"Bucket {bucket_id} missing"
assert _BUCKET_SL_PCT[bucket_id] == expected
def test_all_7_buckets_present(self):
for i in range(7):
assert i in _BUCKET_SL_PCT, f"Bucket {i} missing"
def test_default_present(self):
assert 'default' in _BUCKET_SL_PCT
# ═══════════════════════════════════════════════════════════════════════════════
# 6. ENGINE KWARGS GOLD-SPEC PARITY (via green.yml)
# ═══════════════════════════════════════════════════════════════════════════════
class TestGreenYmlParity:
"""Verify green.yml engine config matches BLUE's ENGINE_KWARGS."""
@pytest.fixture
def green_config(self):
import yaml
cfg_path = Path('/mnt/dolphinng5_predict/prod/configs/green.yml')
if not cfg_path.exists():
pytest.skip("green.yml not found")
with open(cfg_path) as f:
return yaml.safe_load(f)
BLUE_GOLD_ENGINE = {
'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,
}
@pytest.mark.parametrize("key,expected", list(BLUE_GOLD_ENGINE.items()))
def test_engine_param_matches_blue(self, green_config, key, expected):
eng = green_config.get('engine', {})
assert eng.get(key) == expected, (
f"green.yml engine['{key}']: got={eng.get(key)} expected={expected}"
)
def test_direction_is_short_only(self, green_config):
assert green_config.get('direction') == 'short_only'
def test_strategy_name_is_green(self, green_config):
assert green_config.get('strategy_name') == 'green'
def test_hz_state_map_is_green(self, green_config):
hz = green_config.get('hazelcast', {})
assert 'GREEN' in hz.get('state_map', ''), "State map must be GREEN-specific"
def test_hz_pnl_map_is_green(self, green_config):
hz = green_config.get('hazelcast', {})
assert 'GREEN' in hz.get('imap_pnl', ''), "PNL map must be GREEN-specific"
# ═══════════════════════════════════════════════════════════════════════════════
# 7. HIBERNATE PROTECTION PARITY
# ═══════════════════════════════════════════════════════════════════════════════
class TestHibernateProtectionParity:
"""Verify GREEN has hibernate protection matching BLUE's behavior."""
def _make_actor(self):
config = {'strategy_name': 'test', 'engine': {}}
return DolphinActor(config)
def test_actor_has_hibernate_protect_method(self):
actor = self._make_actor()
assert hasattr(actor, '_hibernate_protect_position')
def test_actor_has_hibernate_protect_active_field(self):
actor = self._make_actor()
assert hasattr(actor, '_hibernate_protect_active')
assert actor._hibernate_protect_active is None
def test_actor_has_bucket_assignments_field(self):
actor = self._make_actor()
assert hasattr(actor, '_bucket_assignments')
assert isinstance(actor._bucket_assignments, dict)
def test_actor_has_compute_vol_ok(self):
actor = self._make_actor()
assert hasattr(actor, '_compute_vol_ok')
assert callable(actor._compute_vol_ok)
def test_actor_has_btc_prices_deque(self):
actor = self._make_actor()
assert hasattr(actor, 'btc_prices')
assert isinstance(actor.btc_prices, deque)
def test_hibernate_noop_when_no_position(self):
actor = self._make_actor()
actor.engine = None
# Should not raise
actor._hibernate_protect_position()
def test_hibernate_label_map(self):
"""Verify the hibernate exit reason re-labeling matches BLUE."""
_map = {
'FIXED_TP': 'HIBERNATE_TP',
'STOP_LOSS': 'HIBERNATE_SL',
'MAX_HOLD': 'HIBERNATE_MAXHOLD',
}
assert _map == {
'FIXED_TP': 'HIBERNATE_TP',
'STOP_LOSS': 'HIBERNATE_SL',
'MAX_HOLD': 'HIBERNATE_MAXHOLD',
}
# ═══════════════════════════════════════════════════════════════════════════════
# 8. E2E PARITY: REPLAY DAY MATCHES BLUE
# ═══════════════════════════════════════════════════════════════════════════════
class TestE2EReplayParity:
"""Run a known-good day through both engines and compare capital trajectories."""
KNOWN_GOOD_DATE = '2026-02-25'
@pytest.fixture
def parquet_path(self):
p = Path(f'/mnt/dolphinng5_predict/vbt_cache_klines/{self.KNOWN_GOOD_DATE}.parquet')
if not p.exists():
pytest.skip(f"Parquet for {self.KNOWN_GOOD_DATE} not found")
return p
def test_replay_day_produces_finite_capital(self, parquet_path):
"""Run a full day replay and verify capital is finite and positive."""
import pandas as pd
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
df = pd.read_parquet(parquet_path)
meta_cols = {
'timestamp', 'scan_number', 'v50_lambda_max_velocity',
'v150_lambda_max_velocity', 'v300_lambda_max_velocity',
'v750_lambda_max_velocity', 'vel_div', 'instability_50', 'instability_150',
}
asset_columns = [c for c in df.columns if c not in meta_cols]
engine = create_d_liq_engine(
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,
)
# Vol ok mask
vol_ok_mask = np.zeros(len(df), dtype=bool)
bp = df['BTCUSDT'].values if 'BTCUSDT' in df.columns else None
if bp is not None:
dv = np.full(len(bp), np.nan)
for j in range(50, len(bp)):
seg = bp[max(0, j - 50):j]
with np.errstate(invalid='ignore', divide='ignore'):
rets = np.diff(seg) / seg[:-1]
fin = rets[np.isfinite(rets)]
if len(fin) >= 5:
dv[j] = float(np.std(fin))
vol_ok_mask = np.where(np.isfinite(dv), dv > VOL_P60_THRESHOLD, False)
engine.begin_day(self.KNOWN_GOOD_DATE, posture='APEX', direction=-1)
engine.process_day(
self.KNOWN_GOOD_DATE, df, asset_columns,
vol_regime_ok=vol_ok_mask, direction=-1, posture='APEX',
)
capital = engine.capital
assert math.isfinite(capital), f"Capital not finite: {capital}"
assert capital > 0, f"Capital not positive: {capital}"
def test_replay_produces_trades(self, parquet_path):
"""Verify the engine actually trades on the known-good date."""
import pandas as pd
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
df = pd.read_parquet(parquet_path)
meta_cols = {
'timestamp', 'scan_number', 'v50_lambda_max_velocity',
'v150_lambda_max_velocity', 'v300_lambda_max_velocity',
'v750_lambda_max_velocity', 'vel_div', 'instability_50', 'instability_150',
}
asset_columns = [c for c in df.columns if c not in meta_cols]
engine = create_d_liq_engine(
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,
)
vol_ok_mask = np.ones(len(df), dtype=bool)
result = engine.process_day(
self.KNOWN_GOOD_DATE, df, asset_columns,
vol_regime_ok=vol_ok_mask, direction=-1, posture='APEX',
)
trades = result.get('trades', 0)
assert trades > 0, f"Expected trades on {self.KNOWN_GOOD_DATE}, got {trades}"
# ═══════════════════════════════════════════════════════════════════════════════
# 9. CH OUTPUT SEPARATION
# ═══════════════════════════════════════════════════════════════════════════════
class TestOutputSeparation:
"""GREEN must write to GREEN-specific CH/HZ channels, never BLUE."""
def _make_actor(self):
config = {
'strategy_name': 'green',
'engine': {},
'hazelcast': {
'imap_pnl': 'DOLPHIN_PNL_GREEN',
'state_map': 'DOLPHIN_STATE_GREEN',
},
}
return DolphinActor(config)
def test_strategy_name_is_green(self):
actor = self._make_actor()
assert actor._strategy_name == 'green'
def test_default_strategy_name_is_green(self):
actor = DolphinActor({})
assert actor._strategy_name == 'green'
def test_hz_pnl_map_from_config(self):
actor = self._make_actor()
imap_name = actor.dolphin_config.get('hazelcast', {}).get('imap_pnl', '')
assert 'GREEN' in imap_name
def test_hz_state_map_from_config(self):
actor = self._make_actor()
state_map = actor.dolphin_config.get('hazelcast', {}).get('state_map', '')
assert 'GREEN' in state_map