""" 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)