346 lines
13 KiB
Python
346 lines
13 KiB
Python
|
|
#!/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)
|