#!/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)