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)