initial: import DOLPHIN baseline 2026-04-21 from dolphinng5_predict working tree
Includes core prod + GREEN/BLUE subsystems: - prod/ (BLUE harness, configs, scripts, docs) - nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved) - adaptive_exit/ (AEM engine + models/bucket_assignments.pkl) - Observability/ (EsoF advisor, TUI, dashboards) - external_factors/ (EsoF producer) - mc_forewarning_qlabs_fork/ (MC regime/envelope) Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
This commit is contained in:
533
nautilus_dolphin/tests/test_rt_exit_manager.py
Executable file
533
nautilus_dolphin/tests/test_rt_exit_manager.py
Executable file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user