262 lines
12 KiB
Python
262 lines
12 KiB
Python
|
|
"""
|
||
|
|
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)
|