Files
DOLPHIN/nautilus_dolphin/tests/test_dolphin_actor.py
hjnormey 01c19662cb 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.
2026-04-21 16:58:38 +02:00

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"])