296 lines
11 KiB
Python
296 lines
11 KiB
Python
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
ROOT = Path("/mnt/dolphinng5_predict")
|
||
|
|
sys.path.insert(0, str(ROOT / "nautilus_dolphin"))
|
||
|
|
sys.path.insert(1, str(ROOT))
|
||
|
|
if "nautilus_dolphin" in sys.modules:
|
||
|
|
pkg = sys.modules["nautilus_dolphin"]
|
||
|
|
pkg_file = str(getattr(pkg, "__file__", "") or "")
|
||
|
|
if not pkg_file.endswith("nautilus_dolphin/nautilus_dolphin/__init__.py"):
|
||
|
|
del sys.modules["nautilus_dolphin"]
|
||
|
|
|
||
|
|
from nautilus_dolphin.nautilus.esf_alpha_orchestrator import NDAlphaEngine, NDPosition
|
||
|
|
from nautilus_dolphin.nautilus.alpha_exit_v7_engine import AlphaExitEngineV7, AlphaExitV7Config
|
||
|
|
from prod.nautilus_event_trader import DolphinLiveTrader
|
||
|
|
|
||
|
|
|
||
|
|
class _DummyCtx:
|
||
|
|
def __init__(self, entry_price: float, entry_bar: int, side: int) -> None:
|
||
|
|
self.entry_price = entry_price
|
||
|
|
self.entry_bar = entry_bar
|
||
|
|
self.side = side
|
||
|
|
self.exf = None
|
||
|
|
|
||
|
|
def set_exf(self, funding: float = 0.0, dvol: float = 0.0, fear_greed: float = 0.0, taker: float = 0.0) -> None:
|
||
|
|
self.exf = {
|
||
|
|
"funding": funding,
|
||
|
|
"dvol": dvol,
|
||
|
|
"fear_greed": fear_greed,
|
||
|
|
"taker": taker,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class _DummyV7Engine:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.make_calls = []
|
||
|
|
self.evaluate_calls = []
|
||
|
|
|
||
|
|
def make_context(self, entry_price: float, entry_bar: int, side: int) -> _DummyCtx:
|
||
|
|
self.make_calls.append((entry_price, entry_bar, side))
|
||
|
|
return _DummyCtx(entry_price=entry_price, entry_bar=entry_bar, side=side)
|
||
|
|
|
||
|
|
def evaluate(self, ctx, current_price: float, current_bar: int, ob_imbalance: float, asset: str = "default") -> dict:
|
||
|
|
self.evaluate_calls.append(
|
||
|
|
{
|
||
|
|
"ctx": ctx,
|
||
|
|
"current_price": current_price,
|
||
|
|
"current_bar": current_bar,
|
||
|
|
"ob_imbalance": ob_imbalance,
|
||
|
|
"asset": asset,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return {
|
||
|
|
"action": "EXIT",
|
||
|
|
"reason": "V7_COMPOSITE_PRESSURE",
|
||
|
|
"pnl_pct": 1.25,
|
||
|
|
"bars_held": current_bar - ctx.entry_bar,
|
||
|
|
"mfe": 0.02,
|
||
|
|
"mae": 0.01,
|
||
|
|
"mfe_risk": 0.0,
|
||
|
|
"mae_risk": 0.0,
|
||
|
|
"exit_pressure": 2.81,
|
||
|
|
"rv_comp": 0.001,
|
||
|
|
"mae_thresh1": 0.002,
|
||
|
|
"bounce_score": 0.1,
|
||
|
|
"bounce_risk": 0.2,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class _DummyOBSignal:
|
||
|
|
def __init__(self, imbalance_ma5: float) -> None:
|
||
|
|
self.imbalance_ma5 = imbalance_ma5
|
||
|
|
|
||
|
|
|
||
|
|
class _DummyOBEngine:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.calls = []
|
||
|
|
|
||
|
|
def get_signal(self, asset: str, bar_idx: float):
|
||
|
|
self.calls.append((asset, bar_idx))
|
||
|
|
return _DummyOBSignal(0.42)
|
||
|
|
|
||
|
|
|
||
|
|
def test_ndalphaengine_prefers_exit_decision_provider_before_base_manager():
|
||
|
|
engine = NDAlphaEngine(
|
||
|
|
initial_capital=1000.0,
|
||
|
|
use_sp_fees=False,
|
||
|
|
use_sp_slippage=False,
|
||
|
|
use_ob_edge=False,
|
||
|
|
use_asset_selection=False,
|
||
|
|
use_direction_confirm=False,
|
||
|
|
use_alpha_layers=False,
|
||
|
|
use_dynamic_leverage=False,
|
||
|
|
)
|
||
|
|
pos = NDPosition(
|
||
|
|
trade_id="tid-1",
|
||
|
|
asset="DASHUSDT",
|
||
|
|
direction=-1,
|
||
|
|
entry_price=100.0,
|
||
|
|
entry_bar=0,
|
||
|
|
notional=100.0,
|
||
|
|
leverage=1.0,
|
||
|
|
fraction=0.2,
|
||
|
|
entry_vel_div=-0.03,
|
||
|
|
bucket_idx=4,
|
||
|
|
current_price=90.0,
|
||
|
|
)
|
||
|
|
engine.position = pos
|
||
|
|
engine._day_posture = "APEX"
|
||
|
|
engine.regime_dd_halt = False
|
||
|
|
provider_called = {}
|
||
|
|
|
||
|
|
def provider(**kwargs):
|
||
|
|
provider_called.update(kwargs)
|
||
|
|
return {
|
||
|
|
"action": "EXIT",
|
||
|
|
"reason": "V7_COMPOSITE_PRESSURE",
|
||
|
|
"pnl_pct": 1.25,
|
||
|
|
"bars_held": 7,
|
||
|
|
}
|
||
|
|
|
||
|
|
engine.exit_decision_provider = provider
|
||
|
|
|
||
|
|
def _should_not_run(*args, **kwargs):
|
||
|
|
raise AssertionError("base exit_manager should not be consulted when provider returns a decision")
|
||
|
|
|
||
|
|
engine.exit_manager.evaluate = _should_not_run
|
||
|
|
executed = {}
|
||
|
|
|
||
|
|
def _fake_execute_exit(reason: str, bar_idx: int, pnl_pct_raw: float = 0.0, bars_held: int = 0):
|
||
|
|
executed.update(
|
||
|
|
{
|
||
|
|
"reason": reason,
|
||
|
|
"bar_idx": bar_idx,
|
||
|
|
"pnl_pct_raw": pnl_pct_raw,
|
||
|
|
"bars_held": bars_held,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
engine.position = None
|
||
|
|
return executed
|
||
|
|
|
||
|
|
engine._execute_exit = _fake_execute_exit
|
||
|
|
|
||
|
|
out = engine._manage_position(
|
||
|
|
bar_idx=17,
|
||
|
|
prices={"DASHUSDT": 89.0},
|
||
|
|
vel_div=-0.12,
|
||
|
|
v50_vel=0.03,
|
||
|
|
v750_vel=0.01,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert provider_called["pos"] is pos
|
||
|
|
assert provider_called["bar_idx"] == 17
|
||
|
|
assert out["reason"] == "V7_COMPOSITE_PRESSURE"
|
||
|
|
assert executed["reason"] == "V7_COMPOSITE_PRESSURE"
|
||
|
|
assert executed["bar_idx"] == 17
|
||
|
|
|
||
|
|
|
||
|
|
def test_blue_live_v7_provider_records_journal_and_uses_ob_signal():
|
||
|
|
trader = DolphinLiveTrader.__new__(DolphinLiveTrader)
|
||
|
|
trader._v7_exit_engine = _DummyV7Engine()
|
||
|
|
trader._pending_entries = {
|
||
|
|
"tid-2": {
|
||
|
|
"entry_price": 100.0,
|
||
|
|
"entry_bar": 4,
|
||
|
|
"side": "SHORT",
|
||
|
|
"quantity": 2.0,
|
||
|
|
"leverage": 3.0,
|
||
|
|
"notional": 200.0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
trader._v7_contexts = {}
|
||
|
|
trader._v7_decision_seq = {}
|
||
|
|
trader._v7_decisions = {}
|
||
|
|
trader._last_exf = {
|
||
|
|
"funding": 1.0,
|
||
|
|
"dvol": 2.0,
|
||
|
|
"fear_greed": 3.0,
|
||
|
|
"taker": 4.0,
|
||
|
|
}
|
||
|
|
trader.ob_eng = _DummyOBEngine()
|
||
|
|
captured = {}
|
||
|
|
|
||
|
|
def _capture_record(**kwargs):
|
||
|
|
captured.update(kwargs)
|
||
|
|
|
||
|
|
trader._record_v7_decision = _capture_record
|
||
|
|
|
||
|
|
pos = SimpleNamespace(trade_id="tid-2", asset="DASHUSDT", current_price=97.0)
|
||
|
|
decision = trader._v7_live_exit_decision(
|
||
|
|
pos=pos,
|
||
|
|
bar_idx=10,
|
||
|
|
prices={"DASHUSDT": 97.5},
|
||
|
|
vel_div=-0.3,
|
||
|
|
v50_vel=0.1,
|
||
|
|
v750_vel=0.2,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert decision["action"] == "EXIT"
|
||
|
|
assert trader._v7_contexts["tid-2"].exf == {
|
||
|
|
"funding": 1.0,
|
||
|
|
"dvol": 2.0,
|
||
|
|
"fear_greed": 3.0,
|
||
|
|
"taker": 4.0,
|
||
|
|
}
|
||
|
|
assert trader.ob_eng.calls == [("DASHUSDT", 9.0)]
|
||
|
|
assert trader._v7_exit_engine.evaluate_calls[0]["current_bar"] == 9
|
||
|
|
assert trader._v7_exit_engine.evaluate_calls[0]["ob_imbalance"] == pytest.approx(0.42)
|
||
|
|
assert captured["source"] == "live_exit"
|
||
|
|
assert captured["bar_idx"] == 9
|
||
|
|
assert captured["trade_id"] == "tid-2"
|
||
|
|
assert captured["asset"] == "DASHUSDT"
|
||
|
|
|
||
|
|
|
||
|
|
def test_alpha_exit_v7_is_mechanically_side_aware_for_long_and_short():
|
||
|
|
engine = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||
|
|
|
||
|
|
long_ctx = engine.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||
|
|
long_favorable = engine.evaluate(long_ctx, current_price=101.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
assert long_favorable["pnl_pct"] == pytest.approx(1.0)
|
||
|
|
assert long_favorable["mfe"] == pytest.approx(0.01)
|
||
|
|
assert long_favorable["mae"] == pytest.approx(0.0)
|
||
|
|
|
||
|
|
long_adverse = engine.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||
|
|
long_adverse_out = engine.evaluate(long_adverse, current_price=99.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
assert long_adverse_out["pnl_pct"] == pytest.approx(-1.0)
|
||
|
|
assert long_adverse_out["mfe"] == pytest.approx(0.0)
|
||
|
|
assert long_adverse_out["mae"] == pytest.approx(0.01)
|
||
|
|
|
||
|
|
short_ctx = engine.make_context(entry_price=100.0, entry_bar=0, side=1)
|
||
|
|
short_favorable = engine.evaluate(short_ctx, current_price=99.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
assert short_favorable["pnl_pct"] == pytest.approx(1.0)
|
||
|
|
assert short_favorable["mfe"] == pytest.approx(0.01)
|
||
|
|
assert short_favorable["mae"] == pytest.approx(0.0)
|
||
|
|
|
||
|
|
short_adverse = engine.make_context(entry_price=100.0, entry_bar=0, side=1)
|
||
|
|
short_adverse_out = engine.evaluate(short_adverse, current_price=101.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
assert short_adverse_out["pnl_pct"] == pytest.approx(-1.0)
|
||
|
|
assert short_adverse_out["mfe"] == pytest.approx(0.0)
|
||
|
|
assert short_adverse_out["mae"] == pytest.approx(0.01)
|
||
|
|
|
||
|
|
|
||
|
|
def test_alpha_exit_v7_default_config_matches_legacy_threshold_surface():
|
||
|
|
engine = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||
|
|
cfg = engine.config
|
||
|
|
|
||
|
|
assert cfg.rvol_w15 == pytest.approx(0.50)
|
||
|
|
assert cfg.rvol_w30 == pytest.approx(0.30)
|
||
|
|
assert cfg.rvol_w50 == pytest.approx(0.20)
|
||
|
|
assert cfg.mae_tier1_k == pytest.approx(3.5)
|
||
|
|
assert cfg.mae_tier2_k == pytest.approx(7.0)
|
||
|
|
assert cfg.mae_tier3_k == pytest.approx(12.0)
|
||
|
|
assert cfg.mae_tier1_floor == pytest.approx(0.005)
|
||
|
|
assert cfg.mae_tier2_floor == pytest.approx(0.012)
|
||
|
|
assert cfg.mae_tier3_floor == pytest.approx(0.025)
|
||
|
|
assert cfg.mae_tier1_risk == pytest.approx(0.5)
|
||
|
|
assert cfg.mae_tier2_risk == pytest.approx(0.8)
|
||
|
|
assert cfg.mae_tier3_risk == pytest.approx(1.2)
|
||
|
|
assert cfg.mae_accel_min_bars == 3
|
||
|
|
assert cfg.mae_accel_peak_floor == pytest.approx(0.003)
|
||
|
|
assert cfg.mae_recovery_peak_floor == pytest.approx(0.004)
|
||
|
|
assert cfg.mae_recovery_prev_min == pytest.approx(0.25)
|
||
|
|
assert cfg.mae_recovery_snapback_max == pytest.approx(0.10)
|
||
|
|
assert cfg.mae_late_floor == pytest.approx(0.003)
|
||
|
|
assert cfg.mae_late_start_frac == pytest.approx(0.60)
|
||
|
|
assert cfg.mae_late_risk_max == pytest.approx(0.4)
|
||
|
|
assert cfg.mfe_convexity_decay_exit == pytest.approx(0.35)
|
||
|
|
assert cfg.mfe_convexity_decay_soft == pytest.approx(0.20)
|
||
|
|
assert cfg.bounce_dir_w == pytest.approx(0.15)
|
||
|
|
assert cfg.bounce_risk_w == pytest.approx(0.35)
|
||
|
|
assert cfg.exit_pressure_threshold == pytest.approx(2.69)
|
||
|
|
assert cfg.retract_pressure_threshold == pytest.approx(1.0)
|
||
|
|
assert cfg.extend_pressure_threshold == pytest.approx(-0.5)
|
||
|
|
|
||
|
|
|
||
|
|
def test_alpha_exit_v7_custom_threshold_config_is_per_instance():
|
||
|
|
strict = AlphaExitEngineV7(
|
||
|
|
bar_duration_sec=11.0,
|
||
|
|
bounce_model_path="/tmp/nonexistent-bounce-model.pkl",
|
||
|
|
config=AlphaExitV7Config(exit_pressure_threshold=0.1, retract_pressure_threshold=0.05),
|
||
|
|
)
|
||
|
|
default = AlphaExitEngineV7(bar_duration_sec=11.0, bounce_model_path="/tmp/nonexistent-bounce-model.pkl")
|
||
|
|
|
||
|
|
strict_ctx = strict.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||
|
|
default_ctx = default.make_context(entry_price=100.0, entry_bar=0, side=0)
|
||
|
|
strict_decision = strict.evaluate(strict_ctx, current_price=100.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
default_decision = default.evaluate(default_ctx, current_price=100.0, current_bar=1, ob_imbalance=0.0)
|
||
|
|
|
||
|
|
assert strict_decision["action"] == "EXIT"
|
||
|
|
assert default_decision["action"] == "HOLD"
|
||
|
|
assert strict.config.exit_pressure_threshold == pytest.approx(0.1)
|
||
|
|
assert default.config.exit_pressure_threshold == pytest.approx(2.69)
|