290 lines
12 KiB
Python
290 lines
12 KiB
Python
|
|
"""TP profit-floor (TP_FLOOR) + TP-threshold diagnostics — regression suite.
|
||
|
|
|
||
|
|
Incident: LINKUSDT 5e05eeeb (2026-06-11). The OB tail-avoidance layer
|
||
|
|
silently widened the "fixed" 0.20% TP by x1.40 during a cascade
|
||
|
|
(alpha_exit_manager.evaluate, cascade branch). The trade peaked at +0.265%
|
||
|
|
(between base 0.19998% and widened 0.27998%), held four consecutive scans,
|
||
|
|
reversed, and died at STOP_LOSS -$1,248.71.
|
||
|
|
|
||
|
|
This suite pins:
|
||
|
|
1. Default-OFF parity: with tp_floor_enabled=False (the class default),
|
||
|
|
behavior is bit-identical to the pre-change engine, INCLUDING the
|
||
|
|
cascade-widened HOLD that caused the incident.
|
||
|
|
2. The golden LINK replay: with the floor ON, the trade exits TP_FLOOR
|
||
|
|
on the first regression scan below base TP (+0.1617%), not STOP_LOSS.
|
||
|
|
3. Arming and edge rules, modulation interactions, LONG symmetry,
|
||
|
|
STOP_LOSS / MAX_HOLD untouched, diagnostics present on every decision.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
sys.path.insert(0, "/mnt/dolphinng5_predict/nautilus_dolphin")
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from nautilus_dolphin.nautilus.alpha_exit_manager import AlphaExitManager
|
||
|
|
|
||
|
|
|
||
|
|
# ── OB engine mock — exactly the surface evaluate() consumes ─────────────────
|
||
|
|
|
||
|
|
class _Sig:
|
||
|
|
def __init__(self, imbalance_ma5=0.0, withdrawal_velocity=0.0):
|
||
|
|
self.imbalance_ma5 = imbalance_ma5
|
||
|
|
self.withdrawal_velocity = withdrawal_velocity
|
||
|
|
|
||
|
|
|
||
|
|
class _Macro:
|
||
|
|
def __init__(self, cascade_count=0, regime_signal=0):
|
||
|
|
self.cascade_count = cascade_count
|
||
|
|
self.regime_signal = regime_signal
|
||
|
|
|
||
|
|
|
||
|
|
class MockOBEngine:
|
||
|
|
def __init__(self, cascade_count=0, regime_signal=0, imbalance_ma5=0.0,
|
||
|
|
withdrawal_velocity=0.0):
|
||
|
|
self._sig = _Sig(imbalance_ma5, withdrawal_velocity)
|
||
|
|
self._macro = _Macro(cascade_count, regime_signal)
|
||
|
|
|
||
|
|
def get_signal(self, asset, ts):
|
||
|
|
return self._sig
|
||
|
|
|
||
|
|
def get_macro(self):
|
||
|
|
return self._macro
|
||
|
|
|
||
|
|
|
||
|
|
# ── LINK 5e05eeeb constants (from the live tape) ─────────────────────────────
|
||
|
|
|
||
|
|
LINK_ENTRY = 7.729
|
||
|
|
LINK_TP = 0.0019998464 # tp_effective_pct as recorded
|
||
|
|
LINK_DIR = -1 # SHORT
|
||
|
|
# (price, expected pnl_pct fraction) sequence from dolphin.v7_decision_events
|
||
|
|
LINK_TAPE = [
|
||
|
|
(7.7225, 0.00084), # bars 0-1
|
||
|
|
(7.7185, 0.00136), # bars 4-7
|
||
|
|
(7.7175, 0.00149), # bars 5-9
|
||
|
|
(7.7085, 0.00265), # bars 10-12 — ABOVE base TP, below widened TP
|
||
|
|
(7.7125, 0.00213), # bar 12-13 — still above base TP
|
||
|
|
(7.7165, 0.00162), # bar 13-14 — REGRESSED below base TP
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _mgr(floor=False, ob=None, tp=LINK_TP, stop=1.0, max_hold=250):
|
||
|
|
m = AlphaExitManager(fixed_tp_pct=tp, stop_pct=stop, max_hold_bars=max_hold,
|
||
|
|
tp_floor_enabled=floor)
|
||
|
|
if ob is not None:
|
||
|
|
m.ob_engine = ob
|
||
|
|
return m
|
||
|
|
|
||
|
|
|
||
|
|
def _short(m, trade_id="t", entry=LINK_ENTRY, bar=0):
|
||
|
|
m.setup_position(trade_id, entry, LINK_DIR, bar)
|
||
|
|
return trade_id
|
||
|
|
|
||
|
|
|
||
|
|
# ── 1. Default-off parity (incident behavior preserved bit-exact) ───────────
|
||
|
|
|
||
|
|
def test_default_is_off():
|
||
|
|
assert AlphaExitManager().tp_floor_enabled is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_cascade_widened_hold_unchanged_when_floor_off():
|
||
|
|
"""The incident, replayed: floor OFF + cascade ON -> HOLD through the
|
||
|
|
whole profitable window and no TP_FLOOR ever — pre-change behavior."""
|
||
|
|
m = _mgr(floor=False, ob=MockOBEngine(cascade_count=3))
|
||
|
|
t = _short(m)
|
||
|
|
bar = 0
|
||
|
|
for price, _pnl in LINK_TAPE:
|
||
|
|
bar += 1
|
||
|
|
r = m.evaluate(t, price, bar, asset="LINKUSDT")
|
||
|
|
assert r["action"] == "HOLD", (price, r)
|
||
|
|
# diagnostics still present even when floor is off
|
||
|
|
assert r["dynamic_tp_pct"] == pytest.approx(LINK_TP * 1.40, rel=1e-9)
|
||
|
|
assert r["tp_mod_factor"] == pytest.approx(1.40, rel=1e-9)
|
||
|
|
assert r["cascade_count"] == 3
|
||
|
|
assert r["tp_floor_armed"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_ob_engine_fixed_tp_fires_at_base():
|
||
|
|
"""Without an OB engine there is no modulation: first scan at +0.265%
|
||
|
|
fires plain FIXED_TP (sanity that base behavior is intact)."""
|
||
|
|
m = _mgr(floor=False)
|
||
|
|
t = _short(m)
|
||
|
|
r = m.evaluate(t, 7.7085, 10, asset="LINKUSDT")
|
||
|
|
assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP")
|
||
|
|
assert r["dynamic_tp_pct"] == pytest.approx(LINK_TP)
|
||
|
|
assert r["tp_mod_factor"] == pytest.approx(1.0)
|
||
|
|
|
||
|
|
|
||
|
|
# ── 2. Golden LINK replay with the floor ON ─────────────────────────────────
|
||
|
|
|
||
|
|
def test_link_golden_replay_floor_exits_on_regression():
|
||
|
|
"""THE fix: cascade widens TP to 0.27998%; the tape peaks at 0.265%
|
||
|
|
(HOLD, matching live); on the first scan back below base TP the floor
|
||
|
|
fires TP_FLOOR at +0.1617% — instead of riding to -1.26% STOP_LOSS."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=3))
|
||
|
|
t = _short(m)
|
||
|
|
actions = []
|
||
|
|
bar = 0
|
||
|
|
result = None
|
||
|
|
for price, _pnl in LINK_TAPE:
|
||
|
|
bar += 1
|
||
|
|
result = m.evaluate(t, price, bar, asset="LINKUSDT")
|
||
|
|
actions.append(result["action"])
|
||
|
|
if result["action"] == "EXIT":
|
||
|
|
break
|
||
|
|
assert result["action"] == "EXIT"
|
||
|
|
assert result["reason"] == "TP_FLOOR"
|
||
|
|
# exits on the LAST tape row (the regression scan), not earlier
|
||
|
|
assert actions == ["HOLD"] * (len(LINK_TAPE) - 1) + ["EXIT"]
|
||
|
|
assert result["pnl_pct"] == pytest.approx(
|
||
|
|
LINK_DIR * (7.7165 - LINK_ENTRY) / LINK_ENTRY) # +0.16173%
|
||
|
|
assert result["pnl_pct"] > 0.0 # banked a WIN
|
||
|
|
assert result["tp_floor_armed"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_floor_does_not_fire_while_above_base():
|
||
|
|
"""pnl at 0.2135% (above base 0.19998%) must NOT trigger the floor —
|
||
|
|
the widened FIXED_TP logic stays in charge of capturing more."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=1))
|
||
|
|
t = _short(m)
|
||
|
|
m.evaluate(t, 7.7085, 1, asset="LINKUSDT") # arm (0.265%)
|
||
|
|
r = m.evaluate(t, 7.7125, 2, asset="LINKUSDT") # 0.2135% > base
|
||
|
|
assert r["action"] == "HOLD"
|
||
|
|
|
||
|
|
|
||
|
|
# ── 3. Arming rules ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_floor_unarmed_below_base_never_fires():
|
||
|
|
"""Excursion never reached base TP -> dips can not trigger TP_FLOOR."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2))
|
||
|
|
t = _short(m)
|
||
|
|
r1 = m.evaluate(t, 7.7185, 1, asset="LINKUSDT") # +0.136% < base
|
||
|
|
assert (r1["action"], r1["tp_floor_armed"]) == ("HOLD", False)
|
||
|
|
r2 = m.evaluate(t, 7.7350, 2, asset="LINKUSDT") # negative excursion
|
||
|
|
assert r2["action"] == "HOLD"
|
||
|
|
r3 = m.evaluate(t, 7.760, 3, asset="LINKUSDT") # -0.40% — still HOLD
|
||
|
|
assert r3["action"] == "HOLD"
|
||
|
|
|
||
|
|
|
||
|
|
def test_marginal_cross_then_reverse_exits_near_base():
|
||
|
|
"""Cross base TP by a hair (1.0001x), reverse: floor exits ~at base —
|
||
|
|
economically a 0.20% TP (the operator's stated intent). An EXACT-ulp
|
||
|
|
touch is allowed not to arm (float round-trip); crossing must arm."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2))
|
||
|
|
t = _short(m)
|
||
|
|
cross = LINK_ENTRY * (1.0 - LINK_TP * 1.0001) # just through base
|
||
|
|
r1 = m.evaluate(t, cross, 1, asset="LINKUSDT")
|
||
|
|
# armed on the crossing bar; pnl marginally ABOVE base -> no fire yet
|
||
|
|
# (pnl <= base is false by the 1.0001 margin)
|
||
|
|
assert r1["action"] == "HOLD" and r1["tp_floor_armed"] is True
|
||
|
|
back = LINK_ENTRY * (1.0 - LINK_TP * 0.95) # regression below base
|
||
|
|
r2 = m.evaluate(t, back, 2, asset="LINKUSDT")
|
||
|
|
assert (r2["action"], r2["reason"]) == ("EXIT", "TP_FLOOR")
|
||
|
|
assert r2["pnl_pct"] == pytest.approx(LINK_TP * 0.95, rel=1e-6)
|
||
|
|
|
||
|
|
|
||
|
|
def test_set_live_tp_pct_rebases_floor():
|
||
|
|
"""The soft-leverage sync re-bases the floor each scan."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), tp=0.0020)
|
||
|
|
t = _short(m)
|
||
|
|
m.evaluate(t, 7.7085, 1, asset="LINKUSDT") # armed vs 0.20%
|
||
|
|
m.set_live_tp_pct(0.0030) # TP widened to 0.30%
|
||
|
|
# 0.265% max_favorable is now BELOW the new base -> floor disarmed
|
||
|
|
r = m.evaluate(t, 7.7125, 2, asset="LINKUSDT") # 0.2135%
|
||
|
|
assert r["action"] == "HOLD"
|
||
|
|
assert r["tp_floor_armed"] is False
|
||
|
|
|
||
|
|
|
||
|
|
# ── 4. Other exits untouched ─────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_stop_loss_unaffected():
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), stop=0.012)
|
||
|
|
t = _short(m)
|
||
|
|
r = m.evaluate(t, LINK_ENTRY * 1.013, 1, asset="LINKUSDT") # -1.3% (short)
|
||
|
|
assert (r["action"], r["reason"]) == ("EXIT", "STOP_LOSS")
|
||
|
|
|
||
|
|
|
||
|
|
def test_max_hold_unaffected():
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), max_hold=5)
|
||
|
|
t = _short(m)
|
||
|
|
r = None
|
||
|
|
for bar in range(1, 7):
|
||
|
|
r = m.evaluate(t, LINK_ENTRY * 1.0005, bar, asset="LINKUSDT") # small loss
|
||
|
|
if r["action"] == "EXIT":
|
||
|
|
break
|
||
|
|
assert (r["action"], r["reason"]) == ("EXIT", "MAX_HOLD")
|
||
|
|
|
||
|
|
|
||
|
|
def test_widened_fixed_tp_still_fires_above_widened():
|
||
|
|
"""Cascade ON, pnl ABOVE the widened threshold -> FIXED_TP (continuation
|
||
|
|
capture preserved; the floor must not pre-empt it)."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2))
|
||
|
|
t = _short(m)
|
||
|
|
deep = LINK_ENTRY * (1.0 - LINK_TP * 1.40 * 1.1) # 10% past widened
|
||
|
|
r = m.evaluate(t, deep, 1, asset="LINKUSDT")
|
||
|
|
assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP")
|
||
|
|
assert r["pnl_pct"] > LINK_TP * 1.40
|
||
|
|
|
||
|
|
|
||
|
|
def test_withdrawal_tightening_fires_fixed_tp_not_floor():
|
||
|
|
"""regime_signal=1 with profit tightens TP x0.60 -> FIXED_TP fires below
|
||
|
|
base; the floor never engages (pnl above dynamic but below base is
|
||
|
|
impossible here because dynamic < base)."""
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(regime_signal=1))
|
||
|
|
t = _short(m)
|
||
|
|
px = LINK_ENTRY * (1.0 - LINK_TP * 0.8) # pnl = 0.8x base
|
||
|
|
r = m.evaluate(t, px, 1, asset="LINKUSDT")
|
||
|
|
assert (r["action"], r["reason"]) == ("EXIT", "FIXED_TP")
|
||
|
|
assert r["tp_mod_factor"] == pytest.approx(0.60, rel=1e-9)
|
||
|
|
|
||
|
|
|
||
|
|
# ── 5. LONG symmetry ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_long_floor_symmetry():
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=2), tp=0.0020)
|
||
|
|
m.setup_position("L", 100.0, +1, 0)
|
||
|
|
r1 = m.evaluate("L", 100.25, 1, asset="X") # +0.25% in band -> HOLD
|
||
|
|
assert r1["action"] == "HOLD" and r1["tp_floor_armed"] is True
|
||
|
|
r2 = m.evaluate("L", 100.15, 2, asset="X") # regression below base
|
||
|
|
assert (r2["action"], r2["reason"]) == ("EXIT", "TP_FLOOR")
|
||
|
|
assert r2["pnl_pct"] == pytest.approx(0.0015, rel=1e-6)
|
||
|
|
|
||
|
|
|
||
|
|
# ── 6. Diagnostics contract ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
DIAG_KEYS = ("tp_base_pct", "dynamic_tp_pct", "tp_mod_factor",
|
||
|
|
"cascade_count", "ob_regime_signal", "tp_floor_armed")
|
||
|
|
|
||
|
|
|
||
|
|
def test_diagnostics_on_every_decision_and_last_eval():
|
||
|
|
m = _mgr(floor=True, ob=MockOBEngine(cascade_count=4, regime_signal=0))
|
||
|
|
t = _short(m)
|
||
|
|
r = m.evaluate(t, 7.7225, 1, asset="LINKUSDT")
|
||
|
|
for k in DIAG_KEYS:
|
||
|
|
assert k in r, k
|
||
|
|
assert r["cascade_count"] == 4
|
||
|
|
le = m.last_eval
|
||
|
|
assert le["trade_id"] == t and le["bar"] == 1
|
||
|
|
for k in DIAG_KEYS:
|
||
|
|
assert k in le, k
|
||
|
|
|
||
|
|
|
||
|
|
def test_diagnostics_defaults_without_ob_engine():
|
||
|
|
m = _mgr(floor=True)
|
||
|
|
t = _short(m)
|
||
|
|
r = m.evaluate(t, 7.7225, 1, asset="LINKUSDT")
|
||
|
|
assert r["cascade_count"] == 0
|
||
|
|
assert r["ob_regime_signal"] == 0
|
||
|
|
assert r["tp_mod_factor"] == pytest.approx(1.0)
|
||
|
|
|
||
|
|
|
||
|
|
def test_no_state_return_unchanged():
|
||
|
|
m = _mgr(floor=True)
|
||
|
|
r = m.evaluate("ghost", 1.0, 1)
|
||
|
|
assert (r["action"], r["reason"]) == ("HOLD", "NO_STATE")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(pytest.main([__file__, "-v"]))
|