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.
814 lines
35 KiB
Python
Executable File
814 lines
35 KiB
Python
Executable File
"""test_dolphin_actor.py — DolphinActor lifecycle and correctness tests.
|
|
|
|
Tests cover:
|
|
- Champion parameter invariants (frozen champion config → correct defaults)
|
|
- ACB pending-flag thread safety (no lost updates, no race on clear)
|
|
- HIBERNATE posture guard (on_bar returns immediately, engine not called)
|
|
- Date change handling (begin_day / end_day transition)
|
|
- Replay mode data loading (bar_idx increments, engine step_bar called)
|
|
- HZ-unavailable graceful degradation (posture defaults APEX, no crash)
|
|
- Stale-state snapshot guard (detects mid-eval state changes)
|
|
- on_stop cleanup (processed_dates cleared, stale events reset)
|
|
|
|
All tests use unittest.mock to avoid requiring live HZ or parquet files.
|
|
Run with:
|
|
source /home/dolphin/siloqy_env/bin/activate
|
|
cd /mnt/dolphinng5_predict
|
|
python -m pytest nautilus_dolphin/tests/test_dolphin_actor.py -v
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
|
|
HCM_DIR = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(HCM_DIR / "nautilus_dolphin"))
|
|
|
|
try:
|
|
from nautilus_dolphin.nautilus.dolphin_actor import DolphinActor
|
|
_HAS_ACTOR = True
|
|
except ImportError as _e:
|
|
_HAS_ACTOR = False
|
|
_IMPORT_ERR = str(_e)
|
|
|
|
import pytest
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────────
|
|
|
|
def _make_blue_config(**overrides) -> dict:
|
|
"""Minimal champion-frozen config dict (matches blue.yml)."""
|
|
cfg = {
|
|
"strategy_name": "blue",
|
|
"direction": "short_only",
|
|
"live_mode": False,
|
|
"engine": {
|
|
"boost_mode": "baseline",
|
|
"vel_div_threshold": -0.02,
|
|
"vel_div_extreme": -0.05,
|
|
"fixed_tp_pct": 0.0095,
|
|
"max_hold_bars": 120,
|
|
"fraction": 0.20,
|
|
"min_leverage": 0.5,
|
|
"max_leverage": 5.0,
|
|
"abs_max_leverage": 6.0,
|
|
"leverage_convexity": 3.0,
|
|
"dc_lookback_bars": 7,
|
|
"dc_min_magnitude_bps": 0.75,
|
|
"min_irp_alignment": 0.45,
|
|
"sp_maker_entry_rate": 0.62,
|
|
"sp_maker_exit_rate": 0.50,
|
|
"seed": 42,
|
|
# required but non-champion
|
|
"stop_pct": 1.0,
|
|
"use_direction_confirm": True,
|
|
"dc_skip_contradicts": True,
|
|
"dc_leverage_boost": 1.0,
|
|
"dc_leverage_reduce": 0.5,
|
|
"use_asset_selection": True,
|
|
"use_sp_fees": True,
|
|
"use_sp_slippage": True,
|
|
"use_ob_edge": True,
|
|
"ob_edge_bps": 5.0,
|
|
"ob_confirm_rate": 0.40,
|
|
"lookback": 100,
|
|
"use_alpha_layers": True,
|
|
"use_dynamic_leverage": True,
|
|
},
|
|
"paper_trade": {"initial_capital": 25000.0},
|
|
"hazelcast": {"imap_pnl": "DOLPHIN_PNL_BLUE"},
|
|
}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
def _make_actor_no_hz() -> "DolphinActor":
|
|
"""Return a DolphinActor with HZ patched to None and Strategy.__init__ mocked."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
return actor
|
|
|
|
|
|
def _make_synthetic_bar(date_str: str = "2026-01-15", bar_seconds: int = 5):
|
|
"""Build a minimal Bar-like object with ts_event in nanoseconds."""
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d").replace(
|
|
hour=0, minute=0, second=bar_seconds, tzinfo=timezone.utc
|
|
)
|
|
bar = MagicMock()
|
|
bar.ts_event = int(dt.timestamp() * 1e9)
|
|
return bar
|
|
|
|
|
|
# ── Test: Champion parameter invariants ──────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason=f"DolphinActor import failed: {locals().get('_IMPORT_ERR','')}")
|
|
class TestChampionParamInvariants(unittest.TestCase):
|
|
"""Champion params must survive round-trip through DolphinActor.__init__."""
|
|
|
|
CHAMPION = {
|
|
"vel_div_threshold": -0.02,
|
|
"vel_div_extreme": -0.05,
|
|
"fixed_tp_pct": 0.0095,
|
|
"max_hold_bars": 120,
|
|
"fraction": 0.20,
|
|
"min_leverage": 0.5,
|
|
"max_leverage": 5.0,
|
|
"abs_max_leverage": 6.0,
|
|
"leverage_convexity": 3.0,
|
|
"dc_lookback_bars": 7,
|
|
"dc_min_magnitude_bps": 0.75,
|
|
"min_irp_alignment": 0.45,
|
|
"sp_maker_entry_rate": 0.62,
|
|
"sp_maker_exit_rate": 0.50,
|
|
"seed": 42,
|
|
}
|
|
|
|
def test_champion_config_round_trip(self):
|
|
"""Actor init stores config dict without mutation."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
cfg = _make_blue_config()
|
|
DolphinActor.__init__(actor, config=cfg)
|
|
eng_cfg = actor.dolphin_config["engine"]
|
|
for k, v in self.CHAMPION.items():
|
|
self.assertAlmostEqual(eng_cfg[k], v, places=9,
|
|
msg=f"Champion param {k} mismatch: {eng_cfg[k]} != {v}")
|
|
|
|
def test_initial_posture_is_apex(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
self.assertEqual(actor.posture, "APEX")
|
|
|
|
def test_initial_engine_is_none(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
self.assertIsNone(actor.engine)
|
|
|
|
def test_initial_hz_client_is_none(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
self.assertIsNone(actor.hz_client)
|
|
|
|
def test_pending_acb_is_none(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
self.assertIsNone(actor._pending_acb)
|
|
|
|
def test_acb_lock_is_rlock_compatible(self):
|
|
"""_acb_lock must be a threading.Lock (or RLock) — acquire/release must work."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
acquired = actor._acb_lock.acquire(timeout=0.1)
|
|
self.assertTrue(acquired)
|
|
actor._acb_lock.release()
|
|
|
|
|
|
# ── Test: ACB pending-flag thread safety ─────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestACBPendingFlagThreadSafety(unittest.TestCase):
|
|
"""Verify the pending-flag pattern prevents lost ACB updates."""
|
|
|
|
def _make_actor(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
# Attach a minimal mock engine that tracks update_acb_boost calls
|
|
actor.engine = MagicMock()
|
|
actor.engine._day_base_boost = 1.0
|
|
actor.engine._day_beta = 0.0
|
|
actor.engine._mc_gate_open = True
|
|
return actor
|
|
|
|
def test_on_acb_event_stores_pending(self):
|
|
"""_on_acb_event must set _pending_acb under the lock."""
|
|
actor = self._make_actor()
|
|
event = MagicMock()
|
|
event.value = json.dumps({"boost": 1.5, "beta": 0.3})
|
|
actor._on_acb_event(event)
|
|
with actor._acb_lock:
|
|
pending = actor._pending_acb
|
|
self.assertIsNotNone(pending)
|
|
self.assertAlmostEqual(pending["boost"], 1.5)
|
|
self.assertAlmostEqual(pending["beta"], 0.3)
|
|
|
|
def test_on_acb_event_empty_value_ignored(self):
|
|
"""Empty/None event value must not set pending."""
|
|
actor = self._make_actor()
|
|
event = MagicMock()
|
|
event.value = None
|
|
actor._on_acb_event(event)
|
|
self.assertIsNone(actor._pending_acb)
|
|
|
|
def test_on_acb_event_malformed_json_ignored(self):
|
|
"""Malformed JSON must be caught; pending stays None."""
|
|
actor = self._make_actor()
|
|
event = MagicMock()
|
|
event.value = "{not-json"
|
|
actor._on_acb_event(event)
|
|
self.assertIsNone(actor._pending_acb)
|
|
|
|
def test_concurrent_write_then_on_bar_apply(self):
|
|
"""Simulate concurrent HZ listener write + on_bar read — no lost update.
|
|
|
|
Thread 1 writes boost=2.0 via _on_acb_event().
|
|
Thread 2 (main) calls the on_bar ACB-apply section and asserts engine updated.
|
|
"""
|
|
actor = self._make_actor()
|
|
|
|
# Simulate _on_acb_event from a background thread
|
|
def writer():
|
|
event = MagicMock()
|
|
event.value = json.dumps({"boost": 2.0, "beta": 0.5})
|
|
actor._on_acb_event(event)
|
|
|
|
t = threading.Thread(target=writer)
|
|
t.start()
|
|
t.join(timeout=1.0)
|
|
|
|
# Simulate the ACB-apply section of on_bar()
|
|
with actor._acb_lock:
|
|
pending = actor._pending_acb
|
|
actor._pending_acb = None
|
|
|
|
self.assertIsNotNone(pending, "ACB update was lost")
|
|
actor.engine.update_acb_boost(float(pending["boost"]), float(pending["beta"]))
|
|
actor.engine.update_acb_boost.assert_called_once_with(2.0, 0.5)
|
|
|
|
# After consumption, pending must be None
|
|
with actor._acb_lock:
|
|
self.assertIsNone(actor._pending_acb)
|
|
|
|
def test_multiple_rapid_events_last_wins(self):
|
|
"""Multiple rapid HZ events — last write wins (expected behaviour)."""
|
|
actor = self._make_actor()
|
|
for boost in [1.1, 1.2, 1.3, 1.4, 1.5]:
|
|
event = MagicMock()
|
|
event.value = json.dumps({"boost": boost, "beta": 0.0})
|
|
actor._on_acb_event(event)
|
|
with actor._acb_lock:
|
|
pending = actor._pending_acb
|
|
self.assertAlmostEqual(pending["boost"], 1.5)
|
|
|
|
|
|
# ── Test: HIBERNATE posture guard ────────────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestHibernatePostureGuard(unittest.TestCase):
|
|
"""When posture=='HIBERNATE', on_bar must return immediately."""
|
|
|
|
def _make_actor_with_engine(self, posture: str = "HIBERNATE"):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.posture = posture
|
|
actor.current_date = "2026-01-15" # pre-set so no begin_day triggers
|
|
actor.engine = MagicMock()
|
|
actor.engine._day_base_boost = 1.0
|
|
actor.engine._day_beta = 0.0
|
|
actor.engine._mc_gate_open = True
|
|
actor.hz_client = None
|
|
actor._day_data = None
|
|
return actor
|
|
|
|
def test_hibernate_skips_step_bar(self):
|
|
"""step_bar must not be called when posture==HIBERNATE."""
|
|
actor = self._make_actor_with_engine("HIBERNATE")
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
actor.engine.step_bar.assert_not_called()
|
|
|
|
def test_hibernate_does_not_increment_bar_idx(self):
|
|
"""_bar_idx_today must not change on HIBERNATE."""
|
|
actor = self._make_actor_with_engine("HIBERNATE")
|
|
actor._bar_idx_today = 42
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
self.assertEqual(actor._bar_idx_today, 42)
|
|
|
|
def test_apex_does_attempt_step_bar(self):
|
|
"""APEX posture must reach the step_bar call (given valid data)."""
|
|
actor = self._make_actor_with_engine("APEX")
|
|
# Provide fake day data so on_bar can pull a row
|
|
import pandas as pd
|
|
import numpy as np
|
|
rows = {
|
|
"vel_div": np.full(200, -0.03),
|
|
"BTCUSDT": np.full(200, 95000.0),
|
|
"v50_lambda_max_velocity": np.zeros(200),
|
|
"v750_lambda_max_velocity": np.zeros(200),
|
|
"instability_50": np.zeros(200),
|
|
}
|
|
df = pd.DataFrame(rows)
|
|
actor._day_data = (df, ["BTCUSDT"])
|
|
actor._bar_idx_today = 0
|
|
actor.engine.step_bar = MagicMock(return_value={})
|
|
actor._write_result_to_hz = MagicMock()
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
actor.engine.step_bar.assert_called_once()
|
|
|
|
|
|
# ── Test: Date change handling ────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestDateChangeHandling(unittest.TestCase):
|
|
"""Verify begin_day / end_day lifecycle across date boundaries."""
|
|
|
|
def _make_actor(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
actor.posture = "APEX"
|
|
actor.engine = MagicMock()
|
|
actor.engine._day_base_boost = 1.0
|
|
actor.engine._day_beta = 0.0
|
|
actor.engine._mc_gate_open = True
|
|
actor._day_data = None
|
|
actor._write_result_to_hz = MagicMock()
|
|
return actor
|
|
|
|
def test_first_bar_calls_begin_day_not_end_day(self):
|
|
"""First bar of the session must call begin_day but not end_day."""
|
|
actor = self._make_actor()
|
|
actor.current_date = None
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
# Stub _load_parquet_data to return empty (HIBERNATE-safe; no step_bar needed here)
|
|
with patch.object(actor, "_load_parquet_data", return_value=(MagicMock(empty=True), [], None)):
|
|
actor.on_bar(bar)
|
|
actor.engine.begin_day.assert_called_once_with("2026-01-15", posture="APEX", direction=-1)
|
|
actor.engine.end_day.assert_not_called()
|
|
|
|
def test_date_change_calls_end_day_then_begin_day(self):
|
|
"""Date rollover must call end_day() on old date then begin_day() on new."""
|
|
actor = self._make_actor()
|
|
actor.current_date = "2026-01-14"
|
|
actor.engine.end_day = MagicMock(return_value={})
|
|
|
|
bar_new = _make_synthetic_bar("2026-01-15")
|
|
with patch.object(actor, "_load_parquet_data", return_value=(MagicMock(empty=True), [], None)):
|
|
actor.on_bar(bar_new)
|
|
|
|
actor.engine.end_day.assert_called_once()
|
|
actor.engine.begin_day.assert_called_once_with("2026-01-15", posture="APEX", direction=-1)
|
|
|
|
def test_same_date_no_redundant_begin_day(self):
|
|
"""Bars arriving on same date must not re-call begin_day."""
|
|
actor = self._make_actor()
|
|
actor.current_date = "2026-01-15"
|
|
|
|
import pandas as pd
|
|
import numpy as np
|
|
rows = {
|
|
"vel_div": np.full(200, -0.03),
|
|
"BTCUSDT": np.full(200, 95000.0),
|
|
"v50_lambda_max_velocity": np.zeros(200),
|
|
"v750_lambda_max_velocity": np.zeros(200),
|
|
"instability_50": np.zeros(200),
|
|
}
|
|
actor._day_data = (pd.DataFrame(rows), ["BTCUSDT"])
|
|
actor._bar_idx_today = 0
|
|
actor.engine.step_bar = MagicMock(return_value={})
|
|
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
bar2 = _make_synthetic_bar("2026-01-15", bar_seconds=10)
|
|
actor.on_bar(bar2)
|
|
|
|
actor.engine.begin_day.assert_not_called()
|
|
|
|
def test_direction_short_only_maps_to_minus_one(self):
|
|
"""direction='short_only' must pass direction=-1 to begin_day."""
|
|
actor = self._make_actor()
|
|
actor.current_date = None
|
|
actor.dolphin_config["direction"] = "short_only"
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
with patch.object(actor, "_load_parquet_data", return_value=(MagicMock(empty=True), [], None)):
|
|
actor.on_bar(bar)
|
|
_, kwargs = actor.engine.begin_day.call_args
|
|
self.assertEqual(kwargs["direction"], -1)
|
|
|
|
def test_direction_long_maps_to_plus_one(self):
|
|
"""direction='long' must pass direction=+1 to begin_day."""
|
|
actor = self._make_actor()
|
|
actor.current_date = None
|
|
actor.dolphin_config["direction"] = "long"
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
with patch.object(actor, "_load_parquet_data", return_value=(MagicMock(empty=True), [], None)):
|
|
actor.on_bar(bar)
|
|
_, kwargs = actor.engine.begin_day.call_args
|
|
self.assertEqual(kwargs["direction"], 1)
|
|
|
|
|
|
# ── Test: HZ-unavailable graceful degradation ────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestHZUnavailableDegradation(unittest.TestCase):
|
|
"""Actor must function (APEX posture, no crash) when HZ is unreachable."""
|
|
|
|
def test_connect_hz_returns_none_on_failure(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
with patch("hazelcast.HazelcastClient", side_effect=Exception("refused")):
|
|
result = actor._connect_hz()
|
|
self.assertIsNone(result)
|
|
|
|
def test_read_posture_returns_apex_when_hz_none(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
posture = actor._read_posture()
|
|
self.assertEqual(posture, "APEX")
|
|
|
|
def test_write_result_no_hz_is_noop(self):
|
|
"""_write_result_to_hz with hz_client=None must silently return."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
# Must not raise
|
|
actor._write_result_to_hz("2026-01-15", {"pnl": 1.0})
|
|
|
|
def test_get_latest_hz_scan_returns_none_when_no_client(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
result = actor._get_latest_hz_scan()
|
|
self.assertIsNone(result)
|
|
|
|
|
|
# ── Test: Replay mode bar index tracking ─────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestReplayModeBarTracking(unittest.TestCase):
|
|
"""Verify _bar_idx_today increments correctly in replay mode."""
|
|
|
|
def _make_actor_with_data(self, n_rows: int = 10):
|
|
import pandas as pd
|
|
import numpy as np
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
actor.posture = "APEX"
|
|
actor.current_date = "2026-01-15"
|
|
actor.engine = MagicMock()
|
|
actor.engine._day_base_boost = 1.0
|
|
actor.engine._day_beta = 0.0
|
|
actor.engine._mc_gate_open = True
|
|
actor.engine.step_bar = MagicMock(return_value={})
|
|
actor._write_result_to_hz = MagicMock()
|
|
rows = {
|
|
"vel_div": np.full(n_rows, -0.03),
|
|
"BTCUSDT": np.full(n_rows, 95000.0),
|
|
"v50_lambda_max_velocity": np.zeros(n_rows),
|
|
"v750_lambda_max_velocity": np.zeros(n_rows),
|
|
"instability_50": np.zeros(n_rows),
|
|
}
|
|
actor._day_data = (pd.DataFrame(rows), ["BTCUSDT"])
|
|
actor._bar_idx_today = 0
|
|
return actor
|
|
|
|
def test_bar_idx_increments_per_bar(self):
|
|
actor = self._make_actor_with_data(10)
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
self.assertEqual(actor._bar_idx_today, 1)
|
|
actor.on_bar(bar)
|
|
self.assertEqual(actor._bar_idx_today, 2)
|
|
|
|
def test_step_bar_called_with_correct_bar_idx(self):
|
|
"""step_bar's bar_idx kwarg must match the pre-increment counter."""
|
|
actor = self._make_actor_with_data(10)
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
call_kwargs = actor.engine.step_bar.call_args[1]
|
|
self.assertEqual(call_kwargs["bar_idx"], 0)
|
|
|
|
def test_past_end_of_data_returns_silently(self):
|
|
"""When _bar_idx_today >= len(df), on_bar must return without stepping."""
|
|
actor = self._make_actor_with_data(3)
|
|
actor._bar_idx_today = 3 # already past end
|
|
bar = _make_synthetic_bar("2026-01-15")
|
|
actor.on_bar(bar)
|
|
actor.engine.step_bar.assert_not_called()
|
|
|
|
|
|
# ── Test: on_stop cleanup ────────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestOnStopCleanup(unittest.TestCase):
|
|
"""on_stop must clear state and shut down HZ client."""
|
|
|
|
def test_on_stop_clears_processed_dates(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor._processed_dates = {"2026-01-13", "2026-01-14"}
|
|
actor.hz_client = None
|
|
actor.on_stop()
|
|
self.assertEqual(len(actor._processed_dates), 0)
|
|
|
|
def test_on_stop_resets_stale_state_events(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor._stale_state_events = 7
|
|
actor.hz_client = None
|
|
actor.on_stop()
|
|
self.assertEqual(actor._stale_state_events, 0)
|
|
|
|
def test_on_stop_shuts_down_hz_client(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
mock_hz = MagicMock()
|
|
actor.hz_client = mock_hz
|
|
actor.on_stop()
|
|
mock_hz.shutdown.assert_called_once()
|
|
|
|
def test_on_stop_no_hz_no_crash(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.hz_client = None
|
|
actor.on_stop() # must not raise
|
|
|
|
|
|
# ── Test: Stale-state snapshot ────────────────────────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestStaleStateGuard(unittest.TestCase):
|
|
"""_GateSnap before/after comparison must detect mid-eval state changes."""
|
|
|
|
def test_gate_snap_equality_no_change(self):
|
|
"""Identical pre/post snapshots → no stale event logged."""
|
|
from nautilus_dolphin.nautilus.dolphin_actor import _GateSnap
|
|
before = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
after = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
self.assertEqual(before, after)
|
|
|
|
def test_gate_snap_detects_boost_change(self):
|
|
from nautilus_dolphin.nautilus.dolphin_actor import _GateSnap
|
|
before = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
after = _GateSnap(acb_boost=1.5, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
self.assertNotEqual(before, after)
|
|
|
|
def test_gate_snap_detects_posture_change(self):
|
|
from nautilus_dolphin.nautilus.dolphin_actor import _GateSnap
|
|
before = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
after = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="STALKER", mc_gate_open=True)
|
|
self.assertNotEqual(before, after)
|
|
|
|
def test_gate_snap_detects_gate_close(self):
|
|
from nautilus_dolphin.nautilus.dolphin_actor import _GateSnap
|
|
before = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=True)
|
|
after = _GateSnap(acb_boost=1.0, acb_beta=0.0, posture="APEX", mc_gate_open=False)
|
|
self.assertNotEqual(before, after)
|
|
|
|
def test_gate_snap_fields_list(self):
|
|
"""GateSnap must have exactly 4 named fields."""
|
|
from nautilus_dolphin.nautilus.dolphin_actor import _GateSnap
|
|
self.assertEqual(set(_GateSnap._fields),
|
|
{"acb_boost", "acb_beta", "posture", "mc_gate_open"})
|
|
|
|
|
|
# ── Test: Capital persistence (_save_capital / _restore_capital) ─────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestCapitalPersistence(unittest.TestCase):
|
|
"""DolphinActor._save_capital / _restore_capital — HZ checkpoint roundtrip."""
|
|
|
|
def _make_actor_with_state_map(self, capital: float = 31_500.0):
|
|
"""Actor with a mock state_map backed by a real dict."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.engine = MagicMock()
|
|
actor.engine.capital = capital
|
|
|
|
saved = {}
|
|
mock_map = MagicMock()
|
|
mock_map.blocking.return_value.put = lambda k, v: saved.update({k: v})
|
|
mock_map.blocking.return_value.get = lambda k: saved.get(k)
|
|
actor.state_map = mock_map
|
|
return actor, saved
|
|
|
|
def test_save_writes_checkpoint_key(self):
|
|
actor, saved = self._make_actor_with_state_map(31_500.0)
|
|
DolphinActor._save_capital(actor)
|
|
self.assertIn('capital_checkpoint', saved,
|
|
"_save_capital must write 'capital_checkpoint' to state_map")
|
|
|
|
def test_save_persists_correct_value(self):
|
|
actor, saved = self._make_actor_with_state_map(99_123.45)
|
|
DolphinActor._save_capital(actor)
|
|
data = json.loads(saved['capital_checkpoint'])
|
|
self.assertAlmostEqual(data['capital'], 99_123.45, places=2)
|
|
|
|
def test_save_nan_not_written(self):
|
|
actor, saved = self._make_actor_with_state_map(float('nan'))
|
|
DolphinActor._save_capital(actor)
|
|
self.assertNotIn('capital_checkpoint', saved,
|
|
"NaN capital must not be persisted")
|
|
|
|
def test_save_zero_not_written(self):
|
|
actor, saved = self._make_actor_with_state_map(0.0)
|
|
DolphinActor._save_capital(actor)
|
|
self.assertNotIn('capital_checkpoint', saved,
|
|
"Zero capital must not be persisted")
|
|
|
|
def test_save_sub_dollar_not_written(self):
|
|
actor, saved = self._make_actor_with_state_map(0.50)
|
|
DolphinActor._save_capital(actor)
|
|
self.assertNotIn('capital_checkpoint', saved,
|
|
"Sub-$1 capital must not be persisted")
|
|
|
|
def test_save_no_state_map_no_crash(self):
|
|
actor, _ = self._make_actor_with_state_map(25_000.0)
|
|
actor.state_map = None
|
|
DolphinActor._save_capital(actor) # must not raise
|
|
|
|
def test_restore_recovers_saved_capital(self):
|
|
actor, _ = self._make_actor_with_state_map(77_777.77)
|
|
DolphinActor._save_capital(actor)
|
|
# Fresh actor, same mock map
|
|
actor2 = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor2, config=_make_blue_config())
|
|
actor2.engine = MagicMock()
|
|
actor2.engine.capital = 25_000.0
|
|
actor2.state_map = actor.state_map
|
|
DolphinActor._restore_capital(actor2)
|
|
self.assertAlmostEqual(actor2.engine.capital, 77_777.77, places=2,
|
|
msg="Restored capital must match saved value")
|
|
|
|
def test_restore_stale_72h_ignored(self):
|
|
"""Checkpoint older than 72 h must not be restored."""
|
|
import time as _time
|
|
saved = {}
|
|
mock_map = MagicMock()
|
|
old_ts = _time.time() - (73 * 3600)
|
|
saved['capital_checkpoint'] = json.dumps({'capital': 55_000.0, 'ts': old_ts})
|
|
mock_map.blocking.return_value.get = lambda k: saved.get(k)
|
|
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.engine = MagicMock()
|
|
actor.engine.capital = 25_000.0
|
|
actor.state_map = mock_map
|
|
DolphinActor._restore_capital(actor)
|
|
# Capital must remain at initial value — stale checkpoint must not be applied
|
|
self.assertAlmostEqual(actor.engine.capital, 25_000.0, places=2,
|
|
msg="Stale (73h) checkpoint must not restore capital")
|
|
|
|
def test_restore_sub_dollar_ignored(self):
|
|
"""Checkpoint with capital < $1 must not be restored."""
|
|
import time as _time
|
|
saved = {}
|
|
mock_map = MagicMock()
|
|
saved['capital_checkpoint'] = json.dumps({'capital': 0.001, 'ts': _time.time()})
|
|
mock_map.blocking.return_value.get = lambda k: saved.get(k)
|
|
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.engine = MagicMock()
|
|
actor.engine.capital = 25_000.0
|
|
actor.state_map = mock_map
|
|
DolphinActor._restore_capital(actor)
|
|
# Sub-$1 checkpoint must not be applied
|
|
self.assertAlmostEqual(actor.engine.capital, 25_000.0, places=2,
|
|
msg="Sub-$1 checkpoint must not restore capital")
|
|
|
|
def test_restore_no_state_map_no_crash(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config())
|
|
actor.engine = MagicMock()
|
|
actor.state_map = None
|
|
DolphinActor._restore_capital(actor) # must not raise
|
|
|
|
def test_connect_hz_reads_host_from_config(self):
|
|
"""_connect_hz must use config hazelcast.host, not hardcoded localhost."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
cfg = _make_blue_config(
|
|
live_mode=True,
|
|
hazelcast={"host": "10.0.0.99:5701", "cluster": "testnet",
|
|
"imap_pnl": "DOLPHIN_PNL_BLUE", "state_map": "DOLPHIN_STATE_BLUE"}
|
|
)
|
|
DolphinActor.__init__(actor, config=cfg)
|
|
with patch("hazelcast.HazelcastClient") as mock_hz:
|
|
mock_client = MagicMock()
|
|
mock_hz.return_value = mock_client
|
|
actor._connect_hz()
|
|
_, kwargs = mock_hz.call_args
|
|
self.assertIn("10.0.0.99:5701", kwargs.get("cluster_members", []),
|
|
"_connect_hz must pass config host to HazelcastClient")
|
|
self.assertEqual(kwargs.get("cluster_name"), "testnet")
|
|
|
|
def test_connect_hz_sets_state_map(self):
|
|
"""_connect_hz must assign self.state_map from DOLPHIN_STATE_BLUE."""
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
cfg = _make_blue_config(live_mode=True,
|
|
hazelcast={"host": "localhost:5701", "cluster": "dolphin",
|
|
"imap_pnl": "DOLPHIN_PNL_BLUE"})
|
|
DolphinActor.__init__(actor, config=cfg)
|
|
with patch("hazelcast.HazelcastClient") as mock_hz:
|
|
mock_client = MagicMock()
|
|
mock_hz.return_value = mock_client
|
|
actor._connect_hz()
|
|
self.assertIsNotNone(actor.state_map,
|
|
"state_map must be set after successful _connect_hz()")
|
|
|
|
|
|
# ── Test: Stablecoin filter + NaN vel_div guard ──────────────────────────────────
|
|
|
|
@pytest.mark.skipif(not _HAS_ACTOR, reason="DolphinActor not available")
|
|
class TestLiveBarGuards(unittest.TestCase):
|
|
"""Stablecoin hard-block and NaN/spike vel_div clamp in on_bar() live path."""
|
|
|
|
def _make_live_actor(self):
|
|
actor = DolphinActor.__new__(DolphinActor)
|
|
DolphinActor.__init__(actor, config=_make_blue_config(live_mode=True))
|
|
actor.hz_client = MagicMock()
|
|
actor.posture = "APEX"
|
|
actor.current_date = "2026-01-15"
|
|
actor.engine = MagicMock()
|
|
actor.engine._day_base_boost = 1.0
|
|
actor.engine._day_beta = 0.0
|
|
actor.engine._mc_gate_open = True
|
|
actor.engine.step_bar = MagicMock(return_value={})
|
|
actor._write_result_to_hz = MagicMock()
|
|
return actor
|
|
|
|
def _inject_scan(self, actor, scan: dict):
|
|
"""Push scan directly into the push cache (bypasses HZ reactor thread)."""
|
|
with actor._scan_cache_lock:
|
|
actor._latest_scan_cache = scan
|
|
actor.last_scan_number = -1
|
|
|
|
def test_stablecoin_absent_from_prices_passed_to_engine(self):
|
|
"""USDCUSDT in scan must be absent from prices dict passed to step_bar."""
|
|
actor = self._make_live_actor()
|
|
scan = {
|
|
'scan_number': 1, 'vel_div': -0.03,
|
|
'w50_velocity': -0.03, 'w750_velocity': -0.005, 'instability_50': 0.1,
|
|
'assets': ['BTCUSDT', 'ETHUSDT', 'USDCUSDT'],
|
|
'asset_prices': [95000.0, 2500.0, 1.0001],
|
|
}
|
|
self._inject_scan(actor, scan)
|
|
bar = _make_synthetic_bar("2026-01-15", bar_seconds=5)
|
|
actor.on_bar(bar)
|
|
call_kwargs = actor.engine.step_bar.call_args[1]
|
|
prices_passed = call_kwargs.get('prices', {})
|
|
self.assertNotIn('USDCUSDT', prices_passed,
|
|
"USDCUSDT must be hard-blocked before step_bar")
|
|
self.assertIn('BTCUSDT', prices_passed)
|
|
self.assertIn('ETHUSDT', prices_passed)
|
|
|
|
def test_nan_vel_div_clamped_to_zero(self):
|
|
"""NaN vel_div in scan must be clamped to 0.0 before step_bar."""
|
|
actor = self._make_live_actor()
|
|
scan = {
|
|
'scan_number': 2, 'vel_div': float('nan'),
|
|
'w50_velocity': 0.0, 'w750_velocity': 0.0, 'instability_50': 0.0,
|
|
'assets': ['BTCUSDT'], 'asset_prices': [95000.0],
|
|
}
|
|
self._inject_scan(actor, scan)
|
|
bar = _make_synthetic_bar("2026-01-15", bar_seconds=5)
|
|
actor.on_bar(bar)
|
|
call_kwargs = actor.engine.step_bar.call_args[1]
|
|
self.assertEqual(call_kwargs['vel_div'], 0.0,
|
|
"NaN vel_div must be clamped to 0.0")
|
|
|
|
def test_spike_vel_div_clamped_to_zero(self):
|
|
"""|vel_div| > 0.20 (NG7 restart spike) must be clamped to 0.0."""
|
|
actor = self._make_live_actor()
|
|
for spike in [-12.76, +12.98, -0.21, +0.21]:
|
|
scan = {
|
|
'scan_number': 10 + int(abs(spike)), 'vel_div': spike,
|
|
'w50_velocity': spike, 'w750_velocity': 0.0, 'instability_50': 0.0,
|
|
'assets': ['BTCUSDT'], 'asset_prices': [95000.0],
|
|
}
|
|
self._inject_scan(actor, scan)
|
|
actor.last_scan_number = -1
|
|
bar = _make_synthetic_bar("2026-01-15", bar_seconds=5)
|
|
actor.on_bar(bar)
|
|
call_kwargs = actor.engine.step_bar.call_args[1]
|
|
self.assertEqual(call_kwargs['vel_div'], 0.0,
|
|
f"vel_div spike={spike} must be clamped to 0.0")
|
|
|
|
def test_duplicate_scan_number_skipped(self):
|
|
"""Same scan_number arriving twice must call step_bar only once."""
|
|
actor = self._make_live_actor()
|
|
scan = {
|
|
'scan_number': 99, 'vel_div': -0.03,
|
|
'w50_velocity': -0.03, 'w750_velocity': -0.005, 'instability_50': 0.0,
|
|
'assets': ['BTCUSDT'], 'asset_prices': [95000.0],
|
|
}
|
|
self._inject_scan(actor, scan)
|
|
bar = _make_synthetic_bar("2026-01-15", bar_seconds=5)
|
|
actor.on_bar(bar)
|
|
actor.on_bar(bar) # second call with same scan_number — must be deduped
|
|
self.assertEqual(actor.engine.step_bar.call_count, 1,
|
|
"Duplicate scan_number must be deduplicated")
|
|
|
|
|
|
# ── Standalone runner ─────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v", "--tb=short"])
|