534 lines
22 KiB
Python
534 lines
22 KiB
Python
|
|
"""
|
||
|
|
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()
|