""" test_rt_exit_manager.py — Unit and integration tests for RealTimeExitManager. Tests cover: - No positions → empty result - SHORT TP trigger (price falls through tp_price) - SHORT no trigger (price above tp_price) - SHORT SL trigger (price rises through sl_price) - LONG TP and SL triggers - SL disabled (sl_price=0.0) — never fires - Max-hold trigger (time-based, no price lookup) - Max-hold disabled - Multiple positions, only one triggers - Register / unregister lifecycle - Exact fill-side convention (SHORT exit uses ask, LONG uses bid) - None price → no trigger (feed unavailable) - make_position threshold arithmetic - Immutability of OpenPosition / ExitSignal """ import sys import time import unittest sys.path.insert(0, 'nautilus_dolphin') from nautilus_dolphin.nautilus.live_price_feed import ConstantPriceFeed, NullPriceFeed from nautilus_dolphin.nautilus.rt_exit_manager import ( ExitSignal, OpenPosition, RealTimeExitManager, ) # ── Helpers ─────────────────────────────────────────────────────────────────── def _make_mgr(prices: dict | None = None, tp_pct: float = 0.0095, sl_pct: float = 0.0): feed = ConstantPriceFeed(prices or {}) return RealTimeExitManager(feed, tp_pct=tp_pct, sl_pct=sl_pct), feed def _pos( trade_id='T1', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=81221.0, sl_price=0.0, entry_ns=None, max_hold_ns=0, ): return OpenPosition( trade_id=trade_id, asset=asset, direction=direction, entry_price=entry_price, tp_price=tp_price, sl_price=sl_price, entry_ns=entry_ns if entry_ns is not None else time.monotonic_ns(), max_hold_ns=max_hold_ns, ) # ── Basic lifecycle ─────────────────────────────────────────────────────────── class TestLifecycle(unittest.TestCase): def test_no_positions_returns_empty_list(self): mgr, _ = _make_mgr() result = mgr.check_all() self.assertIsInstance(result, list) self.assertEqual(result, []) def test_open_count_starts_zero(self): mgr, _ = _make_mgr() self.assertEqual(mgr.open_count(), 0) def test_register_increments_count(self): mgr, _ = _make_mgr({'BTCUSDT': (82000.0, 82010.0)}) mgr.register(_pos()) self.assertEqual(mgr.open_count(), 1) def test_unregister_decrements_count(self): mgr, _ = _make_mgr() mgr.register(_pos('T1')) mgr.unregister('T1') self.assertEqual(mgr.open_count(), 0) def test_unregister_unknown_is_noop(self): mgr, _ = _make_mgr() mgr.unregister('NONEXISTENT') # must not raise self.assertEqual(mgr.open_count(), 0) def test_is_registered_true_after_register(self): mgr, _ = _make_mgr() mgr.register(_pos('T42')) self.assertTrue(mgr.is_registered('T42')) def test_is_registered_false_after_unregister(self): mgr, _ = _make_mgr() mgr.register(_pos('T42')) mgr.unregister('T42') self.assertFalse(mgr.is_registered('T42')) # ── SHORT TP ────────────────────────────────────────────────────────────────── class TestShortTP(unittest.TestCase): def test_short_tp_fires_when_ask_below_tp_price(self): # SHORT entry 82000, tp_pct=0.0095 → tp_price = 82000*(1-0.0095) = 81221 mgr, feed = _make_mgr(tp_pct=0.0095, sl_pct=0.0) feed.update('BTCUSDT', bid=81200.0, ask=81210.0) # ask 81210 < tp 81221 → TP p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) sig = signals[0] self.assertEqual(sig.trade_id, 'T1') self.assertEqual(sig.asset, 'BTCUSDT') self.assertEqual(sig.reason, 'RT_TP') self.assertAlmostEqual(sig.exit_price, 81210.0) # ask price used for short exit def test_short_tp_does_not_fire_when_ask_above_tp_price(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=81900.0, ask=81910.0) # ask 81910 > tp 81221 → no signal p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) self.assertEqual(mgr.check_all(), []) def test_short_tp_fires_at_exact_tp_price(self): tp_price = 82000.0 * (1.0 - 0.0095) # = 81221.0 mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=tp_price - 0.01, ask=tp_price) # ask == tp_price exactly p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_TP') # ── SHORT SL ────────────────────────────────────────────────────────────────── class TestShortSL(unittest.TestCase): def test_short_sl_fires_when_ask_above_sl_price(self): # Entry 82000, sl_pct=0.01 → sl_price = 82000*1.01 = 82820 mgr, feed = _make_mgr(tp_pct=0.0095, sl_pct=0.01) feed.update('BTCUSDT', bid=82900.0, ask=82920.0) # ask 82920 > sl 82820 → SL p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095, sl_pct=0.01) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_SL') self.assertAlmostEqual(signals[0].exit_price, 82920.0) def test_short_sl_disabled_at_zero(self): # sl_pct=0.0 → sl_price=0.0 → never fires regardless of price mgr, feed = _make_mgr(tp_pct=0.0095, sl_pct=0.0) feed.update('BTCUSDT', bid=999999.0, ask=1000000.0) # absurd price p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095, sl_pct=0.0) mgr.register(p) self.assertEqual(mgr.check_all(), []) def test_short_sl_does_not_fire_below_sl_price(self): mgr, feed = _make_mgr(tp_pct=0.0095, sl_pct=0.01) feed.update('BTCUSDT', bid=82700.0, ask=82710.0) # ask 82710 < sl 82820 → no signal p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095, sl_pct=0.01) mgr.register(p) self.assertEqual(mgr.check_all(), []) # ── LONG TP / SL ────────────────────────────────────────────────────────────── class TestLongTPSL(unittest.TestCase): def test_long_tp_fires_when_bid_above_tp_price(self): # LONG entry 1600, tp_pct=0.0095 → tp_price = 1600*1.0095 = 1615.2 mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('ETHUSDT', bid=1616.0, ask=1616.5) # bid 1616 > tp 1615.2 → TP p = RealTimeExitManager.make_position('T2', 'ETHUSDT', +1, 1600.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_TP') self.assertAlmostEqual(signals[0].exit_price, 1616.0) # bid for LONG exit def test_long_tp_does_not_fire_when_bid_below_tp_price(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('ETHUSDT', bid=1610.0, ask=1610.5) # bid 1610 < tp 1615.2 → no signal p = RealTimeExitManager.make_position('T2', 'ETHUSDT', +1, 1600.0, tp_pct=0.0095) mgr.register(p) self.assertEqual(mgr.check_all(), []) def test_long_sl_fires_when_bid_below_sl_price(self): # LONG entry 1600, sl_pct=0.01 → sl_price = 1600*0.99 = 1584 mgr, feed = _make_mgr(tp_pct=0.0095, sl_pct=0.01) feed.update('ETHUSDT', bid=1583.0, ask=1583.5) # bid 1583 < sl 1584 → SL p = RealTimeExitManager.make_position('T2', 'ETHUSDT', +1, 1600.0, tp_pct=0.0095, sl_pct=0.01) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_SL') # ── Max-hold ────────────────────────────────────────────────────────────────── class TestMaxHold(unittest.TestCase): def test_max_hold_fires_when_elapsed(self): mgr, feed = _make_mgr() # entry_ns far in the past — hold limit already exceeded old_ns = time.monotonic_ns() - int(300 * 1e9) # 5 minutes ago pos = OpenPosition( trade_id='T_MAXHOLD', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=81000.0, # price not crossed sl_price=0.0, entry_ns=old_ns, max_hold_ns=int(60 * 1e9), # 60s limit — clearly exceeded ) feed.update('BTCUSDT', bid=81900.0, ask=81910.0) # price not at TP mgr.register(pos) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_MAX_HOLD') def test_max_hold_not_fired_before_limit(self): mgr, feed = _make_mgr() pos = OpenPosition( trade_id='T_FRESH', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=81000.0, sl_price=0.0, entry_ns=time.monotonic_ns(), # just now max_hold_ns=int(3600 * 1e9), # 1 hour limit ) feed.update('BTCUSDT', bid=81900.0, ask=81910.0) mgr.register(pos) self.assertEqual(mgr.check_all(), []) def test_max_hold_disabled_at_zero(self): mgr, feed = _make_mgr() old_ns = time.monotonic_ns() - int(86400 * 1e9) # 1 day ago pos = OpenPosition( trade_id='T_NOLIMIT', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=81000.0, sl_price=0.0, entry_ns=old_ns, max_hold_ns=0, # disabled ) feed.update('BTCUSDT', bid=81900.0, ask=81910.0) mgr.register(pos) self.assertEqual(mgr.check_all(), []) # no TP cross, no max_hold → silent def test_max_hold_uses_live_price_when_available(self): mgr, feed = _make_mgr() old_ns = time.monotonic_ns() - int(300 * 1e9) pos = OpenPosition( trade_id='T_MH_PX', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=80000.0, sl_price=0.0, entry_ns=old_ns, max_hold_ns=int(60 * 1e9), ) feed.update('BTCUSDT', bid=81500.0, ask=81510.0) mgr.register(pos) signals = mgr.check_all() self.assertEqual(signals[0].reason, 'RT_MAX_HOLD') # SHORT max-hold uses ask price self.assertAlmostEqual(signals[0].exit_price, 81510.0) def test_max_hold_fallback_to_entry_price_when_no_quote(self): mgr, _ = _make_mgr(prices={}) # no prices old_ns = time.monotonic_ns() - int(300 * 1e9) pos = OpenPosition( trade_id='T_MH_NOPX', asset='BTCUSDT', direction=-1, entry_price=82000.0, tp_price=80000.0, sl_price=0.0, entry_ns=old_ns, max_hold_ns=int(60 * 1e9), ) mgr.register(pos) signals = mgr.check_all() self.assertEqual(signals[0].reason, 'RT_MAX_HOLD') self.assertAlmostEqual(signals[0].exit_price, 82000.0) # fallback to entry # ── None price (feed unavailable) ───────────────────────────────────────────── class TestNullFeed(unittest.TestCase): def test_null_feed_no_signal_for_price_based_exits(self): feed = NullPriceFeed() mgr = RealTimeExitManager(feed, tp_pct=0.0095, sl_pct=0.01) pos = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095, sl_pct=0.01) mgr.register(pos) self.assertEqual(mgr.check_all(), []) # ── Multiple positions ──────────────────────────────────────────────────────── class TestMultiplePositions(unittest.TestCase): def test_only_triggered_position_in_result(self): mgr, feed = _make_mgr(tp_pct=0.0095) # BTC: price crossed TP — entry 82000, tp=81221, ask=81210 (< tp) → fires feed.update('BTCUSDT', bid=81200.0, ask=81210.0) # ETH: price NOT at TP — entry 1620, tp=1604.61, ask=1610 (> tp) → no fire feed.update('ETHUSDT', bid=1609.0, ask=1610.0) p_btc = RealTimeExitManager.make_position('T_BTC', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) p_eth = RealTimeExitManager.make_position('T_ETH', 'ETHUSDT', -1, 1620.0, tp_pct=0.0095) mgr.register(p_btc) mgr.register(p_eth) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].trade_id, 'T_BTC') def test_both_trigger_returns_two_signals(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=81200.0, ask=81210.0) feed.update('ETHUSDT', bid=1595.0, ask=1595.5) # below tp(1604.6) p_btc = RealTimeExitManager.make_position('T_BTC', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) p_eth = RealTimeExitManager.make_position('T_ETH', 'ETHUSDT', -1, 1620.0, tp_pct=0.0095) mgr.register(p_btc) mgr.register(p_eth) signals = mgr.check_all() self.assertEqual(len(signals), 2) def test_check_all_does_not_auto_unregister(self): """Caller is responsible for unregistering — check_all is read-only.""" mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=81200.0, ask=81210.0) p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) _ = mgr.check_all() # Position still registered — caller must unregister self.assertTrue(mgr.is_registered('T1')) def test_unregister_after_signal_stops_repeat_firing(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=81200.0, ask=81210.0) p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) mgr.unregister('T1') self.assertEqual(mgr.check_all(), []) # ── Fill-side convention ────────────────────────────────────────────────────── class TestFillSide(unittest.TestCase): """Exit price must be ask for SHORT (cost to close) and bid for LONG (proceeds).""" def test_short_exit_price_is_ask(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('BTCUSDT', bid=81210.0, ask=81220.0) # ask(81220) <= tp(81221) p = RealTimeExitManager.make_position('T1', 'BTCUSDT', -1, 82000.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertAlmostEqual(signals[0].exit_price, 81220.0) # ask, not bid def test_long_exit_price_is_bid(self): mgr, feed = _make_mgr(tp_pct=0.0095) feed.update('ETHUSDT', bid=1616.0, ask=1616.5) # bid(1616) >= tp(1615.2) p = RealTimeExitManager.make_position('T2', 'ETHUSDT', +1, 1600.0, tp_pct=0.0095) mgr.register(p) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertAlmostEqual(signals[0].exit_price, 1616.0) # bid, not ask # ── make_position factory ───────────────────────────────────────────────────── class TestMakePosition(unittest.TestCase): def test_short_tp_price_is_below_entry(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095) expected_tp = 82000.0 * (1.0 - 0.0095) self.assertAlmostEqual(pos.tp_price, expected_tp, places=4) def test_short_tp_price_less_than_entry(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095) self.assertLess(pos.tp_price, pos.entry_price) def test_long_tp_price_is_above_entry(self): pos = RealTimeExitManager.make_position('T', 'ETH', +1, 1600.0, tp_pct=0.0095) expected_tp = 1600.0 * (1.0 + 0.0095) self.assertAlmostEqual(pos.tp_price, expected_tp, places=4) def test_long_tp_price_greater_than_entry(self): pos = RealTimeExitManager.make_position('T', 'ETH', +1, 1600.0, tp_pct=0.0095) self.assertGreater(pos.tp_price, pos.entry_price) def test_short_sl_price_is_above_entry(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095, sl_pct=0.01) expected_sl = 82000.0 * 1.01 self.assertAlmostEqual(pos.sl_price, expected_sl, places=4) def test_long_sl_price_is_below_entry(self): pos = RealTimeExitManager.make_position('T', 'ETH', +1, 1600.0, tp_pct=0.0095, sl_pct=0.01) expected_sl = 1600.0 * 0.99 self.assertAlmostEqual(pos.sl_price, expected_sl, places=4) def test_sl_disabled_when_sl_pct_zero(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095, sl_pct=0.0) self.assertAlmostEqual(pos.sl_price, 0.0) def test_max_hold_ns_computed_correctly(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095, max_hold_bars=250, bar_seconds=60) expected_ns = 250 * 60 * 1_000_000_000 self.assertEqual(pos.max_hold_ns, expected_ns) def test_max_hold_disabled_when_zero_bars(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095, max_hold_bars=0) self.assertEqual(pos.max_hold_ns, 0) def test_open_position_is_frozen(self): pos = RealTimeExitManager.make_position('T', 'BTC', -1, 82000.0, tp_pct=0.0095) with self.assertRaises((AttributeError, TypeError)): pos.trade_id = 'MUTATED' # type: ignore def test_exit_signal_is_frozen(self): sig = ExitSignal('T', 'BTC', 'RT_TP', 81200.0, 123456) with self.assertRaises((AttributeError, TypeError)): sig.reason = 'MUTATED' # type: ignore # ── E2E: full lifecycle ─────────────────────────────────────────────────────── class TestEndToEnd(unittest.TestCase): """Simulate a full trade cycle: entry → mid-trade price movement → RT exit.""" def test_short_trade_full_cycle(self): """ Entry SHORT BTCUSDT @ 82000. TP = 81221 (0.95% below entry). Price moves down to 81100 (below TP) → RT_TP fires. Unregister → subsequent check returns empty. """ feed = ConstantPriceFeed({'BTCUSDT': (82000.0, 82010.0)}) mgr = RealTimeExitManager(feed, tp_pct=0.0095) pos = RealTimeExitManager.make_position( 'T_CYCLE', 'BTCUSDT', direction=-1, entry_price=82000.0, tp_pct=0.0095, ) mgr.register(pos) # Price not yet at TP self.assertEqual(mgr.check_all(), []) self.assertEqual(mgr.open_count(), 1) # Price moves to TP feed.update('BTCUSDT', bid=81100.0, ask=81110.0) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].reason, 'RT_TP') self.assertAlmostEqual(signals[0].exit_price, 81110.0) # Caller unregisters after handling signal mgr.unregister('T_CYCLE') self.assertEqual(mgr.open_count(), 0) self.assertEqual(mgr.check_all(), []) def test_multiple_symbols_independent_lifecycle(self): feed = ConstantPriceFeed({ 'BTCUSDT': (82000.0, 82010.0), 'ETHUSDT': (1600.0, 1600.5), 'SOLUSDT': (120.0, 120.05), }) mgr = RealTimeExitManager(feed, tp_pct=0.0095) for tid, sym, ep in [ ('T1', 'BTCUSDT', 82000.0), ('T2', 'ETHUSDT', 1600.0), ('T3', 'SOLUSDT', 120.0), ]: mgr.register(RealTimeExitManager.make_position(tid, sym, -1, ep, tp_pct=0.0095)) self.assertEqual(mgr.open_count(), 3) self.assertEqual(mgr.check_all(), []) # no TP crossed yet # BTC hits TP feed.update('BTCUSDT', bid=81100.0, ask=81110.0) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].trade_id, 'T1') mgr.unregister('T1') self.assertEqual(mgr.open_count(), 2) # ETH hits TP feed.update('ETHUSDT', bid=1582.0, ask=1582.5) signals = mgr.check_all() self.assertEqual(len(signals), 1) self.assertEqual(signals[0].trade_id, 'T2') if __name__ == '__main__': unittest.main()