Files
DOLPHIN/prod/tests/test_silent_exit_bug.py

346 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
test_silent_exit_bug.py
=======================
Regression tests for the "silent exit" bug: positions closed by
update_acb_boost() (subday ACB normalization) have their exit dict
discarded by the caller (on_exf_update), so the exit is never logged.
Production manifest:
- 173 entries logged, only 67 exits
- $2,885.77 in unaccounted capital losses
- Root cause: on_exf_update discards the exit dict from update_acb_boost()
Tests in TestTraderSilentExitRegression MUST FAIL before the fix
and PASS after.
"""
import json
import math
import sys
import threading
import time
import unittest
from unittest.mock import MagicMock
import numpy as np
sys.path.insert(0, '/mnt/dolphinng5_predict')
sys.path.insert(0, '/mnt/dolphinng5_predict/prod')
sys.path.insert(0, '/mnt/dolphinng5_predict/nautilus_dolphin')
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import AdaptiveCircuitBreaker
ASSETS_15 = [
"BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT",
"ADAUSDT", "DOGEUSDT", "TRXUSDT", "DOTUSDT", "MATICUSDT",
"LTCUSDT", "AVAXUSDT", "LINKUSDT", "UNIUSDT", "ATOMUSDT",
]
BASE_PRICES_15 = [
84230.5, 2143.2, 612.4, 145.8, 2.41,
0.68, 0.38, 0.27, 7.2, 0.92,
85.3, 38.5, 15.2, 9.8, 8.5,
]
def _simple_engine():
return 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=False,
use_sp_fees=True, use_sp_slippage=True,
sp_maker_entry_rate=0.62, sp_maker_exit_rate=0.50,
use_ob_edge=False,
lookback=10, use_alpha_layers=True, use_dynamic_leverage=True, seed=42,
)
def _full_blue_engine():
"""Build the exact production BLUE engine with ACB + MC-Forewarner."""
eng = 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,
)
eng.set_esoteric_hazard_multiplier(0.0)
eng.set_acb(AdaptiveCircuitBreaker())
MC_MODELS_DIR = '/mnt/dolphinng5_predict/nautilus_dolphin/mc_results/models'
MC_BASE_CFG = {
'trial_id': 0, 'vel_div_threshold': -0.020, 'vel_div_extreme': -0.050,
'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,
'vd_trend_lookback': 10, 'min_leverage': 0.50, 'max_leverage': 8.00,
'leverage_convexity': 3.00, 'fraction': 0.20, 'use_alpha_layers': True,
'use_dynamic_leverage': True, 'fixed_tp_pct': 0.0095, 'stop_pct': 1.00,
'max_hold_bars': 250, '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,
'ob_imbalance_bias': -0.09, 'ob_depth_scale': 1.00,
'use_asset_selection': True, 'min_irp_alignment': 0.0, 'lookback': 100,
'acb_beta_high': 0.80, 'acb_beta_low': 0.20, 'acb_w750_threshold_pct': 60,
}
from pathlib import Path
if Path(MC_MODELS_DIR).exists():
try:
from mc.mc_ml import DolphinForewarner
eng.set_mc_forewarner(DolphinForewarner(models_dir=MC_MODELS_DIR), MC_BASE_CFG)
except Exception:
pass
return eng
def _open_position_simple(eng, entry_bar=9):
prices_dict = dict(zip(ASSETS_15[:5], BASE_PRICES_15[:5]))
for i in range(entry_bar):
eng.step_bar(bar_idx=i, vel_div=-0.015, prices=prices_dict,
vol_regime_ok=True, v50_vel=-0.015, v750_vel=-0.005)
result = eng.step_bar(bar_idx=entry_bar, vel_div=-0.04, prices=prices_dict,
vol_regime_ok=True, v50_vel=-0.04, v750_vel=-0.005)
return result
def _open_position_full(eng):
rng = np.random.default_rng(42)
for i in range(200):
prices = [p + rng.normal(0, p * 0.003) for p in BASE_PRICES_15]
prices_dict = dict(zip(ASSETS_15, prices))
vel = -0.04 if (i > 100 and i % 25 < 3) else float(rng.normal(0, 0.01))
r = eng.step_bar(bar_idx=i, vel_div=vel, prices=prices_dict,
vol_regime_ok=True, v50_vel=vel, v750_vel=-0.005)
if r.get('entry'):
return r, i
return None, None
# ─── Engine-level tests ─────────────────────────────────────────────
class TestSubdayACBExitIsReturned(unittest.TestCase):
"""Verify that update_acb_boost() returns exit dict when subday exit fires."""
def setUp(self):
self.eng = _simple_engine()
self.eng.begin_day('2026-04-16', posture='APEX')
r = _open_position_simple(self.eng)
self.assertIsNotNone(r.get('entry'))
self.eng._day_base_boost = 1.35
def test_returns_exit_dict_on_boost_drop(self):
exit_r = self.eng.update_acb_boost(boost=1.0, beta=0.2)
self.assertIsNotNone(exit_r)
def test_position_closed(self):
self.eng.update_acb_boost(boost=1.0, beta=0.2)
self.assertIsNone(self.eng.position)
def test_capital_adjusted(self):
cap_before = self.eng.capital
self.eng.update_acb_boost(boost=1.0, beta=0.2)
self.assertNotEqual(self.eng.capital, cap_before)
def test_exit_dict_has_required_fields(self):
exit_r = self.eng.update_acb_boost(boost=1.0, beta=0.2)
self.assertIn('trade_id', exit_r)
self.assertIn('reason', exit_r)
self.assertEqual(exit_r['reason'], 'SUBDAY_ACB_NORMALIZATION')
self.assertIn('net_pnl', exit_r)
self.assertIn('pnl_pct', exit_r)
class TestSubdayExitConditions(unittest.TestCase):
"""Verify the exact conditions that trigger / suppress subday exits."""
def setUp(self):
self.eng = _simple_engine()
self.eng.begin_day('2026-04-16', posture='APEX')
def _open(self):
r = _open_position_simple(self.eng)
self.assertIsNotNone(r.get('entry'))
return r
def test_no_exit_when_new_boost_above_1_10(self):
self._open()
self.eng._day_base_boost = 1.35
self.assertIsNone(self.eng.update_acb_boost(boost=1.20, beta=0.5))
def test_no_exit_when_old_boost_below_1_25(self):
self._open()
self.eng._day_base_boost = 1.10
self.assertIsNone(self.eng.update_acb_boost(boost=0.9, beta=0.5))
def test_exit_fires_on_boost_crash(self):
self._open()
self.eng._day_base_boost = 1.50
self.assertIsNotNone(self.eng.update_acb_boost(boost=1.0, beta=0.2))
def test_no_exit_when_no_position(self):
self.eng._day_base_boost = 1.50
self.assertIsNone(self.eng.update_acb_boost(boost=1.0, beta=0.2))
# ─── Trader-level regression tests (MUST FAIL before fix) ───────────
class TestTraderSilentExitRegression(unittest.TestCase):
"""
These tests reproduce the production bug where on_exf_update()
silently closes positions via update_acb_boost() but discards
the exit dict, causing invisible capital losses.
ALL TESTS IN THIS CLASS SHOULD FAIL BEFORE THE FIX.
"""
def _make_trader_with_position(self):
"""Build a trader with a simple engine and an open position."""
from nautilus_event_trader import DolphinLiveTrader
trader = DolphinLiveTrader()
trader.eng = _simple_engine()
trader.acb = AdaptiveCircuitBreaker()
trader.eng.set_acb(trader.acb)
trader.current_day = '2026-04-16'
trader.eng.begin_day('2026-04-16', posture='APEX')
trader.cached_posture = "APEX"
trader.posture_cache_time = time.time() + 3600
trader._push_state = MagicMock()
trader._save_capital = MagicMock()
trader._exf_log_time = 0.0
trader._pending_entries = {}
trader.eng_lock = threading.Lock()
return trader
def _open_and_register(self, trader):
r = _open_position_simple(trader.eng)
self.assertIsNotNone(r.get('entry'))
tid = r['entry']['trade_id']
trader._pending_entries[tid] = {
'asset': r['entry']['asset'],
'entry_price': r['entry']['entry_price'],
}
trader.eng._day_base_boost = 1.50
return tid
def _fire_exf_drop(self, trader):
trader.acb.get_dynamic_boost_from_hz = MagicMock(return_value={
'boost': 1.0, 'beta': 0.2, 'signals': 0.5, 'source': 'test',
})
exf = {"funding_btc": 0.0, "dvol_btc": 20.0, "fng": 75.0, "taker": 1.0}
event = MagicMock()
event.value = json.dumps(exf)
trader.on_exf_update(event)
def test_subday_exit_is_logged_not_silent(self):
"""
FAILS BEFORE FIX: on_exf_update closes the position via
update_acb_boost but doesn't log the exit.
"""
trader = self._make_trader_with_position()
tid = self._open_and_register(trader)
self.assertIsNotNone(trader.eng.position)
self._fire_exf_drop(trader)
# After fix: position should be None (exit was processed)
self.assertIsNone(trader.eng.position,
"BUG: on_exf_update silently closed position %s without logging exit" % tid)
def test_pending_entry_consumed_on_subday_exit(self):
"""
FAILS BEFORE FIX: pending entry is never consumed because
the exit is discarded.
"""
trader = self._make_trader_with_position()
tid = self._open_and_register(trader)
self._fire_exf_drop(trader)
self.assertNotIn(tid, trader._pending_entries,
"BUG: pending entry for %s not consumed — exit was silently discarded" % tid)
def test_no_fabricated_trades_from_exf_update(self):
"""
on_exf_update should NOT increment trades_executed.
It should only LOG the exit, not create new entries.
"""
trader = self._make_trader_with_position()
tid = self._open_and_register(trader)
trades_before = trader.trades_executed
self._fire_exf_drop(trader)
self.assertEqual(trader.trades_executed, trades_before)
class TestFullBlueEngineSubdayExit(unittest.TestCase):
"""
Test with the FULL production BLUE engine (ACB + MC-Forewarner + OB edge).
Verifies subday exit is captured with real production wiring.
"""
def test_full_engine_subday_exit_returned(self):
eng = _full_blue_engine()
eng.begin_day('2026-04-16', posture='APEX')
result, entry_bar = _open_position_full(eng)
self.assertIsNotNone(result, "Full engine should enter a trade within 200 bars")
self.assertIsNotNone(eng.position)
eng._day_base_boost = 1.50
exit_r = eng.update_acb_boost(boost=1.0, beta=0.2)
self.assertIsNotNone(exit_r,
"Full BLUE engine must return exit dict from subday ACB exit")
self.assertEqual(exit_r['reason'], 'SUBDAY_ACB_NORMALIZATION')
self.assertIsNone(eng.position)
class TestEntryExitParityOverMultiDay(unittest.TestCase):
"""Stress test: 3 days with interspersed ACB subday updates."""
def test_parity_with_acb_updates(self):
eng = _simple_engine()
prices_dict = dict(zip(ASSETS_15[:5], BASE_PRICES_15[:5]))
all_entries = []
all_exits = []
bar = 0
for day in range(3):
eng.begin_day(f'2026-04-{16 + day}', posture='APEX')
for i in range(300):
vel = -0.04 if (i % 60 < 3) else 0.005
result = eng.step_bar(
bar_idx=bar, vel_div=vel, prices=prices_dict,
vol_regime_ok=True, v50_vel=vel, v750_vel=-0.005,
)
bar += 1
if result.get('exit'):
all_exits.append(result['exit']['trade_id'])
if result.get('entry'):
all_entries.append(result['entry']['trade_id'])
if i == 150 and eng.position is not None:
eng._day_base_boost = 1.50
subday = eng.update_acb_boost(boost=1.0, beta=0.2)
if subday is not None:
all_exits.append(subday['trade_id'])
end_summary = eng.end_day()
if eng.position is not None:
all_exits.append(eng.position.trade_id)
orphans = set(all_entries) - set(all_exits)
self.assertEqual(len(orphans), 0,
f"{len(orphans)} orphan trades after 3-day stress test. "
f"Entries: {len(all_entries)}, Exits: {len(all_exits)}")
if __name__ == '__main__':
unittest.main(verbosity=2)