Files
DOLPHIN/nautilus_dolphin/tests/test_rt_exit_manager.py

534 lines
22 KiB
Python
Raw Permalink Normal View History

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