Files
DOLPHIN/nautilus_dolphin/tests/test_subday_exit_fix.py

262 lines
12 KiB
Python
Raw Normal View History

"""
test_subday_exit_fix.py Regression tests for the ghost trade / subday exit fix.
Root cause: update_acb_boost() called evaluate_subday_exits() _execute_exit()
directly from a non-scan thread, bypassing _process_scan result processing.
Position was cleared silently with no EXIT log ("ghost trade").
Fix: update_acb_boost() calls evaluate_subday_exits() immediately and returns
the exit dict. Caller (nautilus_event_trader.py / dolphin_actor.py) logs it
at the call site no scan-cadence delay, no flag deferral.
Tests verify:
1. Exit returned immediately (not deferred) on boost drop
2. Exit NOT returned when boost doesn't meet the trigger condition
3. update_acb_boost() return value contains correct reason + trade_id
4. Position cleared immediately after update_acb_boost() on trigger
5. No double-exit: second call after exit already done returns None
6. TP/SL still fire via _manage_position unaffected
7. reset() leaves engine clean (no stale state)
8. No-position noop: evaluate_subday_exits() returns None when flat
9. MAX_HOLD still fires at correct bar
10. No re-entry on same bar as immediate exit
Run with:
source /home/dolphin/siloqy_env/bin/activate
cd /mnt/dolphinng5_predict
python -m pytest nautilus_dolphin/tests/test_subday_exit_fix.py -v
"""
import sys
import unittest
from pathlib import Path
HCM_DIR = Path(__file__).parent.parent.parent
sys.path.insert(0, str(HCM_DIR / "nautilus_dolphin"))
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
def _make_engine_with_position(entry_price: float = 100.0, vel_div: float = -0.05):
"""Build a live D_LIQ engine, run enough bars to be past lookback, then enter a SHORT."""
eng = create_d_liq_engine(
max_leverage=8.0,
abs_max_leverage=9.0,
fraction=0.20,
fixed_tp_pct=0.0095,
stop_pct=1.0, # effectively disabled
max_hold_bars=250,
vel_div_threshold=-0.020,
vel_div_extreme=-0.050,
use_asset_selection=False,
use_ob_edge=False,
use_alpha_layers=False,
use_dynamic_leverage=False,
lookback=10, # small lookback for fast test setup
seed=42,
)
eng.begin_day('2026-04-12', posture='APEX')
prices = {'BTCUSDT': entry_price}
# Run enough bars to pass lookback with no-signal vel_div
for i in range(12):
eng.step_bar(bar_idx=i, vel_div=0.005, prices=prices, vol_regime_ok=True)
# Force entry bar
result = eng.step_bar(bar_idx=12, vel_div=vel_div, prices=prices, vol_regime_ok=True)
assert result.get('entry') is not None, "Setup failed: no entry on trigger bar"
assert eng.position is not None, "Setup failed: position not set after entry"
return eng
class TestSubdayImmediateExit(unittest.TestCase):
def test_exit_returned_immediately_on_boost_drop(self):
"""update_acb_boost: old>=1.25, new<1.10 → exit dict returned immediately, position cleared."""
eng = _make_engine_with_position()
eng._day_base_boost = 1.30 # simulate stressed day
exit_info = eng.update_acb_boost(boost=0.95, beta=0.0)
self.assertIsNotNone(exit_info, "Exit dict must be returned immediately on boost drop")
self.assertEqual(exit_info['reason'], 'SUBDAY_ACB_NORMALIZATION')
self.assertIsNotNone(exit_info.get('trade_id'), "Exit must carry trade_id for caller logging")
# Position must be cleared immediately — no deferred flag
self.assertIsNone(eng.position, "Position must be cleared immediately by update_acb_boost")
def test_no_exit_when_condition_not_met(self):
"""update_acb_boost: condition not met → None returned, position intact."""
eng = _make_engine_with_position()
# old_boost < 1.25 → condition fails
eng._day_base_boost = 1.0
result = eng.update_acb_boost(boost=0.95, beta=0.0)
self.assertIsNone(result, "None must be returned when old_boost < 1.25")
self.assertIsNotNone(eng.position, "Position must be intact when condition not met")
# new_boost >= 1.10 → condition fails
eng._day_base_boost = 1.30
result = eng.update_acb_boost(boost=1.15, beta=0.0)
self.assertIsNone(result, "None must be returned when new_boost >= 1.10")
self.assertIsNotNone(eng.position, "Position must be intact when condition not met")
def test_exit_dict_has_expected_fields(self):
"""Exit dict must carry all fields callers need for logging and CH write."""
eng = _make_engine_with_position()
eng._day_base_boost = 1.30
exit_info = eng.update_acb_boost(boost=0.95, beta=0.0)
self.assertIsNotNone(exit_info)
for field in ('trade_id', 'reason', 'pnl_pct', 'net_pnl', 'bars_held'):
self.assertIn(field, exit_info, f"Exit dict missing field: {field}")
self.assertEqual(exit_info['reason'], 'SUBDAY_ACB_NORMALIZATION')
def test_no_double_exit_second_call(self):
"""After immediate exit, a second boost drop call returns None (position already gone)."""
eng = _make_engine_with_position()
eng._day_base_boost = 1.30
first = eng.update_acb_boost(boost=0.95, beta=0.0)
self.assertIsNotNone(first)
# Second call — no position remaining
eng._day_base_boost = 1.30
second = eng.update_acb_boost(boost=0.90, beta=0.0)
self.assertIsNone(second, "No second exit possible when no position open")
def test_step_bar_after_immediate_exit_has_no_exit(self):
"""After update_acb_boost clears position, next step_bar has no exit in result."""
eng = _make_engine_with_position()
eng._day_base_boost = 1.30
eng.update_acb_boost(boost=0.95, beta=0.0)
prices = {'BTCUSDT': 99.0}
result = eng.step_bar(bar_idx=13, vel_div=0.0, prices=prices, vol_regime_ok=True)
self.assertIsNone(result.get('exit'), "No duplicate exit on next bar after immediate exit")
self.assertIsNone(eng.position)
def test_evaluate_subday_exits_no_position_returns_none(self):
"""evaluate_subday_exits() returns None when no position is open — no crash."""
eng = create_d_liq_engine(
max_leverage=8.0, abs_max_leverage=9.0,
fraction=0.20, fixed_tp_pct=0.0095, stop_pct=1.0,
max_hold_bars=250, vel_div_threshold=-0.020,
vel_div_extreme=-0.050, use_asset_selection=False,
use_ob_edge=False, use_alpha_layers=False,
use_dynamic_leverage=False, lookback=10, seed=42,
)
eng.begin_day('2026-04-12', posture='APEX')
self.assertIsNone(eng.position)
result = eng.evaluate_subday_exits()
self.assertIsNone(result, "Must return None when no position open")
def test_reset_leaves_engine_clean(self):
"""reset() after immediate exit leaves engine in clean state."""
eng = _make_engine_with_position()
eng._day_base_boost = 1.30
eng.update_acb_boost(boost=0.95, beta=0.0)
eng.reset()
self.assertIsNone(eng.position)
# No _pending_subday_exit attribute should exist (flag removed)
self.assertFalse(hasattr(eng, '_pending_subday_exit') and getattr(eng, '_pending_subday_exit', False),
"_pending_subday_exit flag must not be set after reset()")
def test_normal_tp_exit_unaffected(self):
"""FIXED_TP still fires via _manage_position — immediate exit path does not interfere."""
eng = _make_engine_with_position(entry_price=100.0)
# Drive price below TP level for SHORT (entry=100, tp_pct=0.0095 → tp=99.05)
prices = {'BTCUSDT': 99.0} # 1% below entry → above tp threshold
result = eng.step_bar(bar_idx=13, vel_div=0.0, prices=prices, vol_regime_ok=True)
self.assertIsNotNone(result.get('exit'))
self.assertEqual(result['exit']['reason'], 'FIXED_TP',
"TP exit must still fire normally via _manage_position")
self.assertIsNone(eng.position)
def test_max_hold_exit_unaffected(self):
"""MAX_HOLD still fires at the correct bar — not affected by the fix."""
eng = create_d_liq_engine(
max_leverage=8.0, abs_max_leverage=9.0,
fraction=0.20, fixed_tp_pct=0.0095, stop_pct=1.0,
max_hold_bars=5, # tiny max_hold for fast test
vel_div_threshold=-0.020, vel_div_extreme=-0.050,
use_asset_selection=False, use_ob_edge=False,
use_alpha_layers=False, use_dynamic_leverage=False,
lookback=10, seed=42,
)
eng.begin_day('2026-04-12', posture='APEX')
prices = {'BTCUSDT': 100.0}
# Warmup
for i in range(12):
eng.step_bar(bar_idx=i, vel_div=0.005, prices=prices, vol_regime_ok=True)
# Entry
entry_result = eng.step_bar(bar_idx=12, vel_div=-0.05, prices=prices, vol_regime_ok=True)
self.assertIsNotNone(entry_result.get('entry'))
# Advance to max_hold bar (price stays flat — no TP)
exit_result = None
for i in range(13, 13 + 6):
r = eng.step_bar(bar_idx=i, vel_div=0.0, prices=prices, vol_regime_ok=True)
if r.get('exit'):
exit_result = r
break
self.assertIsNotNone(exit_result, "MAX_HOLD must fire within max_hold_bars")
self.assertEqual(exit_result['exit']['reason'], 'MAX_HOLD')
def test_no_reentry_on_same_logical_bar_after_immediate_exit(self):
"""After immediate exit via update_acb_boost, step_bar must not immediately re-enter."""
eng = _make_engine_with_position(entry_price=100.0)
eng._day_base_boost = 1.30
eng.update_acb_boost(boost=0.95, beta=0.0)
# Trigger bar with a strong signal
prices = {'BTCUSDT': 100.0}
result = eng.step_bar(bar_idx=13, vel_div=-0.06, prices=prices, vol_regime_ok=True)
# No position and no re-entry on the bar immediately following immediate exit
self.assertIsNone(result.get('entry'),
"Must not re-enter on the bar immediately after immediate subday exit")
class TestSubdayExitScanCadence(unittest.TestCase):
"""Verify TP/SL are never delayed — they are always in _manage_position path.
SUBDAY_ACB_NORMALIZATION now exits immediately via update_acb_boost() return value.
TP/SL/MAX_HOLD exit via _manage_position on every scan completely unaffected.
"""
def test_tp_fires_at_scan_cadence_no_delay(self):
"""TP fires at scan cadence normally — no interference from subday path."""
eng = _make_engine_with_position(entry_price=100.0)
prices = {'BTCUSDT': 99.0} # hits TP for SHORT
r = eng.step_bar(bar_idx=13, vel_div=0.0, prices=prices, vol_regime_ok=True)
self.assertEqual(r['exit']['reason'], 'FIXED_TP')
def test_tp_fires_normally_when_no_boost_drop(self):
"""TP fires when boost condition is not met — no interference."""
eng = _make_engine_with_position(entry_price=100.0)
eng._day_base_boost = 1.0 # below 1.25 threshold — no subday exit triggered
# Update boost without triggering subday exit
exit_info = eng.update_acb_boost(boost=0.95, beta=0.0)
self.assertIsNone(exit_info, "No subday exit when old_boost < 1.25")
self.assertIsNotNone(eng.position, "Position still open")
# TP fires normally on next bar
prices = {'BTCUSDT': 99.0}
r = eng.step_bar(bar_idx=13, vel_div=0.0, prices=prices, vol_regime_ok=True)
self.assertEqual(r['exit']['reason'], 'FIXED_TP')
if __name__ == '__main__':
unittest.main(verbosity=2)