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.
This commit is contained in:
947
prod/tests/test_finance_fuzz.py
Executable file
947
prod/tests/test_finance_fuzz.py
Executable file
@@ -0,0 +1,947 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
test_finance_fuzz.py
|
||||
====================
|
||||
Exhaustive E2E fuzzing suite for financial/portfolio invariants.
|
||||
|
||||
Covers:
|
||||
FinancialInvariants — capital always finite+positive; notional finite;
|
||||
net_pnl finite; no free-money on zero-price fill
|
||||
PortfolioStateConsistency — open position count; trade_id uniqueness;
|
||||
entry/exit paired; no orphan exits
|
||||
CapitalMonotonicity — DD within spec; capital never exceeds theoretical max
|
||||
FuzzInputPoison — NaN, Inf, -Inf, None, empty string, zero, negative
|
||||
price in every financial field → no capital corruption
|
||||
FuzzVelDivExtremes — ±20x spikes, step functions, alternating sign,
|
||||
all below threshold → no trades, no corruption
|
||||
FuzzAssetUniverse — stablecoins, duplicates, empty universe, single asset,
|
||||
500-asset universe, Unicode names → picker invariants
|
||||
FuzzMultiDayPnL — 30-day simulation, capital compounds correctly,
|
||||
begin_day never resets capital
|
||||
FuzzRestartPersistence — save/restore checkpoint round-trip across 50 random
|
||||
capital values including edge cases
|
||||
FuzzConcurrentFinancial — 20 threads simultaneous entry signals → exactly one
|
||||
position opened (lock protects engine state)
|
||||
|
||||
All tests run with full production engine (no mocks on NDAlphaEngine internals).
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, '/mnt/dolphinng5_predict')
|
||||
sys.path.insert(0, '/mnt/dolphinng5_predict/prod')
|
||||
sys.path.insert(0, '/mnt/dolphinng5_predict/nautilus_dolphin')
|
||||
|
||||
from nautilus_event_trader import (
|
||||
DolphinLiveTrader,
|
||||
ENGINE_KWARGS,
|
||||
VOL_P60_THRESHOLD,
|
||||
BTC_VOL_WINDOW,
|
||||
_STABLECOIN_SYMBOLS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ASSETS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT"]
|
||||
BASE_PRICES = [84_230.5, 2_143.2, 612.4, 145.8, 2.41]
|
||||
RNG = random.Random(0xDEADBEEF)
|
||||
_NPNG = np.random.default_rng(42)
|
||||
|
||||
VEL_THRESHOLD = ENGINE_KWARGS['vel_div_threshold'] # -0.02
|
||||
INITIAL_CAP = ENGINE_KWARGS['initial_capital'] # 25_000
|
||||
MAX_LEV = ENGINE_KWARGS['max_leverage'] # 8.0
|
||||
ABS_LEV = 9.0 # D_LIQ abs_max
|
||||
FRAC = ENGINE_KWARGS['fraction'] # 0.20
|
||||
MAX_NOTIONAL = INITIAL_CAP * ABS_LEV # $225k theoretical ceiling
|
||||
|
||||
|
||||
def _build_trader() -> DolphinLiveTrader:
|
||||
"""Full production trader including ACBv6 + MC-Forewarner (~10s build)."""
|
||||
trader = DolphinLiveTrader()
|
||||
trader._build_engine()
|
||||
trader.cached_posture = "APEX"
|
||||
trader.posture_cache_time = time.time() + 3600
|
||||
trader._push_state = MagicMock()
|
||||
trader._save_capital = MagicMock()
|
||||
_orig = trader._process_scan
|
||||
trader.on_scan = lambda ev: _orig(ev, time.time())
|
||||
return trader
|
||||
|
||||
|
||||
def _build_trader_fast() -> DolphinLiveTrader:
|
||||
"""Fast trader for fuzz tests — skips ACBv6 SMB read + MC-Forewarner model load.
|
||||
|
||||
ACBv6 and MC are signal *modifiers*, not capital accounting components.
|
||||
Fuzz tests verify capital/portfolio invariants; full signal stack not required.
|
||||
Falls back to _build_trader() if fast build fails.
|
||||
"""
|
||||
try:
|
||||
from nautilus_dolphin.nautilus.proxy_boost_engine import create_d_liq_engine
|
||||
trader = DolphinLiveTrader()
|
||||
# Build engine directly, bypassing slow components
|
||||
trader.eng = create_d_liq_engine(**ENGINE_KWARGS)
|
||||
trader.cached_posture = "APEX"
|
||||
trader.posture_cache_time = time.time() + 3600
|
||||
trader._push_state = MagicMock()
|
||||
trader._save_capital = MagicMock()
|
||||
_orig = trader._process_scan
|
||||
trader.on_scan = lambda ev: _orig(ev, time.time())
|
||||
return trader
|
||||
except Exception:
|
||||
return _build_trader()
|
||||
|
||||
|
||||
def _make_event(scan: dict) -> MagicMock:
|
||||
ev = MagicMock()
|
||||
ev.value = json.dumps(scan, allow_nan=True)
|
||||
return ev
|
||||
|
||||
|
||||
def _make_scan(scan_number: int, vel_div: float,
|
||||
assets=None, prices=None,
|
||||
file_mtime=None,
|
||||
v50: float = -0.025, v750: float = -0.005) -> dict:
|
||||
ts = time.time()
|
||||
assets = list(assets or ASSETS)
|
||||
prices = list(prices or BASE_PRICES[:len(assets)])
|
||||
return {
|
||||
"scan_number": scan_number,
|
||||
"timestamp_ns": int(ts * 1e9),
|
||||
"timestamp_iso": datetime.now(timezone.utc).isoformat(),
|
||||
"schema_version": "5.0.0",
|
||||
"vel_div": vel_div,
|
||||
"w50_velocity": v50,
|
||||
"w750_velocity": v750,
|
||||
"instability_50": max(0.0, v50 - v750),
|
||||
"assets": assets,
|
||||
"asset_prices": prices,
|
||||
"asset_loadings": [1.0 / len(assets)] * len(assets),
|
||||
"file_mtime": file_mtime if file_mtime is not None else ts,
|
||||
"bridge_ts": datetime.now(timezone.utc).isoformat(),
|
||||
"data_quality_score": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _volatile_btc(n=BTC_VOL_WINDOW + 5, sigma=300.0):
|
||||
prices = [84_230.0]
|
||||
for _ in range(n - 1):
|
||||
prices.append(prices[-1] + _NPNG.normal(0, sigma))
|
||||
return prices
|
||||
|
||||
|
||||
def _warmup(trader, n=110, base_mtime=None):
|
||||
"""Feed n below-threshold scans to build vol history."""
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
bm = base_mtime if base_mtime is not None else time.time()
|
||||
for i in range(n):
|
||||
s = _make_scan(i, -0.005, file_mtime=bm + i * 0.001)
|
||||
trader._process_scan(_make_event(s), bm + i * 0.001)
|
||||
return bm + n * 0.001
|
||||
|
||||
|
||||
def _assert_capital_healthy(test, trader, label=""):
|
||||
with trader.eng_lock:
|
||||
cap = trader.eng.capital
|
||||
test.assertTrue(math.isfinite(cap),
|
||||
f"{label} capital={cap} is non-finite — NaN/Inf poison detected")
|
||||
test.assertGreater(cap, 0,
|
||||
f"{label} capital={cap} ≤ 0 — complete loss or sign flip bug")
|
||||
test.assertLessEqual(cap, INITIAL_CAP * 20,
|
||||
f"{label} capital={cap} implausibly large — free-money bug")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. Financial Invariants
|
||||
# ===========================================================================
|
||||
|
||||
class TestFinancialInvariants(unittest.TestCase):
|
||||
"""Capital, notional and net_pnl must remain finite after any sequence."""
|
||||
|
||||
def setUp(self):
|
||||
self.trader = _build_trader()
|
||||
self.base = _warmup(self.trader)
|
||||
|
||||
def _fire(self, vd, n=1, extra_offset=0):
|
||||
for i in range(n):
|
||||
mtime = self.base + extra_offset + i * 0.001
|
||||
s = _make_scan(200 + extra_offset + i, vd, file_mtime=mtime)
|
||||
self.trader._process_scan(_make_event(s), mtime)
|
||||
|
||||
def test_capital_finite_after_single_entry(self):
|
||||
self._fire(-0.05, extra_offset=1000)
|
||||
_assert_capital_healthy(self, self.trader, "post single-entry")
|
||||
|
||||
def test_capital_finite_after_max_hold_exit(self):
|
||||
self._fire(-0.05, extra_offset=2000)
|
||||
# Drive 300 bars to force MAX_HOLD exit
|
||||
self._fire(-0.001, n=300, extra_offset=3000)
|
||||
_assert_capital_healthy(self, self.trader, "post max_hold exit")
|
||||
|
||||
def test_notional_finite_on_entry(self):
|
||||
entries = []
|
||||
orig = self.trader.eng.step_bar
|
||||
def capture(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('entry'):
|
||||
entries.append(r['entry'])
|
||||
return r
|
||||
self.trader.eng.step_bar = capture
|
||||
self._fire(-0.06, n=5, extra_offset=4000)
|
||||
self.trader.eng.step_bar = orig
|
||||
for e in entries:
|
||||
self.assertTrue(
|
||||
math.isfinite(e.get('notional', float('nan'))),
|
||||
f"notional={e.get('notional')} not finite in entry {e}")
|
||||
|
||||
def test_net_pnl_finite_on_exit(self):
|
||||
exits = []
|
||||
orig = self.trader.eng.step_bar
|
||||
def capture(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('exit'):
|
||||
exits.append(r['exit'])
|
||||
return r
|
||||
self.trader.eng.step_bar = capture
|
||||
self._fire(-0.06, extra_offset=5000)
|
||||
self._fire(-0.001, n=300, extra_offset=5100)
|
||||
self.trader.eng.step_bar = orig
|
||||
for x in exits:
|
||||
pnl = x.get('net_pnl', float('nan'))
|
||||
self.assertTrue(math.isfinite(pnl),
|
||||
f"net_pnl={pnl} not finite in exit {x}")
|
||||
|
||||
def test_zero_price_asset_cannot_open_position(self):
|
||||
"""Zero-price asset → notional=0 → engine must skip entry silently."""
|
||||
prices = [0.0] * len(ASSETS) # all zero
|
||||
mtime = self.base + 9000
|
||||
s = _make_scan(9001, -0.10, prices=prices, file_mtime=mtime)
|
||||
s['asset_prices'] = prices
|
||||
cap_before = self.trader.eng.capital
|
||||
self.trader._process_scan(_make_event(s), mtime)
|
||||
cap_after = self.trader.eng.capital
|
||||
# Either no trade (capital unchanged) or trade with zero notional → capital same
|
||||
self.assertEqual(cap_before, cap_after,
|
||||
"Zero-price scan must not change capital")
|
||||
|
||||
def test_capital_never_negative_after_500_random_bars(self):
|
||||
rng = random.Random(1)
|
||||
mtime = self.base + 20000
|
||||
for i in range(500):
|
||||
vd = rng.uniform(-0.15, 0.05)
|
||||
# ±5% realistic price noise — not degenerate extremes
|
||||
px = [p * (1 + rng.uniform(-0.05, 0.05)) for p in BASE_PRICES]
|
||||
s = _make_scan(10000 + i, vd, prices=px, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
_assert_capital_healthy(self, self.trader, "after 500 random bars")
|
||||
|
||||
def test_max_notional_bounded_by_capital_times_abs_leverage(self):
|
||||
"""Largest possible notional = capital × abs_max_leverage × fraction."""
|
||||
entries = []
|
||||
orig = self.trader.eng.step_bar
|
||||
def cap_(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('entry'):
|
||||
entries.append(r['entry'])
|
||||
return r
|
||||
self.trader.eng.step_bar = cap_
|
||||
self._fire(-0.10, n=10, extra_offset=30000)
|
||||
self.trader.eng.step_bar = orig
|
||||
cap = self.trader.eng.capital
|
||||
for e in entries:
|
||||
n = e.get('notional', 0)
|
||||
if math.isfinite(n):
|
||||
self.assertLessEqual(n, cap * ABS_LEV * FRAC * 1.01,
|
||||
f"notional={n} exceeds cap×abs_lev×frac={cap*ABS_LEV*FRAC:.2f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. Portfolio State Consistency
|
||||
# ===========================================================================
|
||||
|
||||
class TestPortfolioStateConsistency(unittest.TestCase):
|
||||
"""At most one open position; trade_ids unique; entries paired with exits."""
|
||||
|
||||
def setUp(self):
|
||||
self.trader = _build_trader()
|
||||
self.base = _warmup(self.trader)
|
||||
|
||||
def test_at_most_one_open_position_at_any_time(self):
|
||||
"""Engine is single-position — SHORT-only, one at a time."""
|
||||
mtime = self.base + 50000
|
||||
for i in range(200):
|
||||
vd = -0.06 if i % 20 == 0 else -0.001
|
||||
s = _make_scan(50000 + i, vd, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
with self.trader.eng_lock:
|
||||
pos = getattr(self.trader.eng, 'position', None)
|
||||
# position is either None or a single object
|
||||
# Verify no list/dict of multiple positions
|
||||
self.assertFalse(isinstance(pos, (list, tuple)),
|
||||
"Engine returned multiple-position structure — single-position invariant violated")
|
||||
|
||||
def test_trade_ids_unique_across_100_trades(self):
|
||||
ids = []
|
||||
orig = self.trader.eng.step_bar
|
||||
def cap(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('entry'):
|
||||
ids.append(r['entry'].get('trade_id'))
|
||||
return r
|
||||
self.trader.eng.step_bar = cap
|
||||
mtime = self.base + 60000
|
||||
for i in range(500):
|
||||
vd = -0.06 if i % 5 == 0 else -0.001
|
||||
s = _make_scan(60000 + i, vd, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
self.trader.eng.step_bar = orig
|
||||
self.assertEqual(len(ids), len(set(ids)),
|
||||
f"Duplicate trade_ids found: {len(ids) - len(set(ids))} duplicates")
|
||||
|
||||
def test_every_exit_has_matching_entry_trade_id(self):
|
||||
opened, closed = set(), set()
|
||||
orig = self.trader.eng.step_bar
|
||||
def cap(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('entry'):
|
||||
opened.add(r['entry'].get('trade_id'))
|
||||
if r.get('exit'):
|
||||
closed.add(r['exit'].get('trade_id'))
|
||||
return r
|
||||
self.trader.eng.step_bar = cap
|
||||
mtime = self.base + 70000
|
||||
for i in range(600):
|
||||
vd = -0.06 if i % 6 == 0 else -0.001
|
||||
s = _make_scan(70000 + i, vd, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
self.trader.eng.step_bar = orig
|
||||
orphan_exits = closed - opened
|
||||
self.assertEqual(orphan_exits, set(),
|
||||
f"Exits with no matching entry: {orphan_exits}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. Capital Monotonicity / Drawdown
|
||||
# ===========================================================================
|
||||
|
||||
class TestCapitalMonotonicity(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.trader = _build_trader()
|
||||
self.base = _warmup(self.trader)
|
||||
|
||||
def test_max_drawdown_bounded_by_gold_spec(self):
|
||||
"""
|
||||
Max observed DD over 500 bars must not exceed a stress-test bound.
|
||||
Uses ±2% price moves — realistic intraday range.
|
||||
Wildly non-physical prices test a different failure mode (FuzzInputPoison).
|
||||
"""
|
||||
peak = INITIAL_CAP
|
||||
max_dd_pct = 0.0
|
||||
mtime = self.base + 80000
|
||||
rng = random.Random(2)
|
||||
for i in range(500):
|
||||
vd = rng.uniform(-0.08, 0.02)
|
||||
# Realistic ±2% price noise per bar (not wild 0.5x–1.5x range)
|
||||
px = [p * (1 + rng.uniform(-0.02, 0.02)) for p in BASE_PRICES]
|
||||
s = _make_scan(80000 + i, vd, prices=px, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
cap = self.trader.eng.capital
|
||||
if math.isfinite(cap):
|
||||
peak = max(peak, cap)
|
||||
dd = (peak - cap) / peak
|
||||
max_dd_pct = max(max_dd_pct, dd)
|
||||
# 3× gold spec (21.31% × 3 ≈ 64%) is the stress-test ceiling
|
||||
self.assertLess(max_dd_pct, 0.65,
|
||||
f"Max drawdown {max_dd_pct:.1%} exceeded stress-test 65% bound")
|
||||
_assert_capital_healthy(self, self.trader, "after DD test")
|
||||
|
||||
def test_capital_cannot_increase_without_a_trade(self):
|
||||
"""Feeding below-threshold scans (no entries) must leave capital unchanged."""
|
||||
cap_before = self.trader.eng.capital
|
||||
mtime = self.base + 90000
|
||||
for i in range(100):
|
||||
s = _make_scan(90000 + i, -0.005, file_mtime=mtime + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + i * 0.001)
|
||||
cap_after = self.trader.eng.capital
|
||||
self.assertEqual(cap_before, cap_after,
|
||||
"Capital changed without any trades — accounting leak")
|
||||
|
||||
def test_begin_day_never_resets_capital(self):
|
||||
"""Calling begin_day() repeatedly across 10 'days' must not reset capital."""
|
||||
# Open a position to give non-initial capital
|
||||
mtime = self.base + 95000
|
||||
s = _make_scan(95000, -0.10, file_mtime=mtime)
|
||||
self.trader._process_scan(_make_event(s), mtime)
|
||||
# Force through exits to accumulate P&L
|
||||
for i in range(300):
|
||||
s = _make_scan(95001 + i, -0.001, file_mtime=mtime + 1 + i * 0.001)
|
||||
self.trader._process_scan(_make_event(s), mtime + 1 + i * 0.001)
|
||||
|
||||
cap_after_trades = self.trader.eng.capital
|
||||
# Now simulate 10 day rollovers
|
||||
for d in range(10):
|
||||
day = (datetime.now(timezone.utc) + timedelta(days=d + 1)).strftime('%Y-%m-%d')
|
||||
self.trader.eng.begin_day(day, posture='APEX')
|
||||
cap_after_rollovers = self.trader.eng.capital
|
||||
self.assertAlmostEqual(cap_after_trades, cap_after_rollovers, delta=0.01,
|
||||
msg=f"begin_day reset capital from {cap_after_trades:.2f} to "
|
||||
f"{cap_after_rollovers:.2f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. Poison Input Fuzzing
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzInputPoison(unittest.TestCase):
|
||||
"""Every financial field poisoned → capital must stay finite and positive."""
|
||||
|
||||
POISON_VALUES = [
|
||||
float('nan'), float('inf'), float('-inf'),
|
||||
None, '', 0, -1, -1e18, 1e18, 'BADSTRING',
|
||||
]
|
||||
|
||||
def _run_poison(self, scan_override: dict, label: str):
|
||||
trader = _build_trader_fast()
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
base = time.time() + RNG.uniform(1e5, 9e5)
|
||||
|
||||
# Warmup
|
||||
for i in range(110):
|
||||
s = _make_scan(i, -0.005, file_mtime=base + i * 0.001)
|
||||
trader._process_scan(_make_event(s), base + i * 0.001)
|
||||
|
||||
# Poison scan
|
||||
s = _make_scan(9999, -0.10, file_mtime=base + 200)
|
||||
s.update(scan_override)
|
||||
try:
|
||||
ev = MagicMock()
|
||||
ev.value = json.dumps(s, allow_nan=True)
|
||||
trader._process_scan(ev, base + 200)
|
||||
except Exception:
|
||||
pass # process errors are fine; capital must still be valid
|
||||
|
||||
_assert_capital_healthy(self, trader, f"poison[{label}]")
|
||||
|
||||
def test_nan_vel_div(self):
|
||||
self._run_poison({'vel_div': float('nan')}, 'vel_div=nan')
|
||||
|
||||
def test_inf_vel_div(self):
|
||||
self._run_poison({'vel_div': float('inf')}, 'vel_div=inf')
|
||||
|
||||
def test_neg_inf_vel_div(self):
|
||||
self._run_poison({'vel_div': float('-inf')}, 'vel_div=-inf')
|
||||
|
||||
def test_extreme_positive_vel_div(self):
|
||||
self._run_poison({'vel_div': 999.9}, 'vel_div=999.9')
|
||||
|
||||
def test_extreme_negative_vel_div(self):
|
||||
self._run_poison({'vel_div': -999.9}, 'vel_div=-999.9')
|
||||
|
||||
def test_nan_w50_velocity(self):
|
||||
self._run_poison({'w50_velocity': float('nan')}, 'w50=nan')
|
||||
|
||||
def test_nan_w750_velocity(self):
|
||||
self._run_poison({'w750_velocity': float('nan')}, 'w750=nan')
|
||||
|
||||
def test_all_prices_zero(self):
|
||||
self._run_poison({'asset_prices': [0.0] * len(ASSETS)}, 'prices=0')
|
||||
|
||||
def test_all_prices_nan(self):
|
||||
self._run_poison(
|
||||
{'asset_prices': [float('nan')] * len(ASSETS)}, 'prices=nan')
|
||||
|
||||
def test_all_prices_negative(self):
|
||||
self._run_poison(
|
||||
{'asset_prices': [-100.0] * len(ASSETS)}, 'prices=-100')
|
||||
|
||||
def test_empty_assets_list(self):
|
||||
self._run_poison({'assets': [], 'asset_prices': []}, 'assets=empty')
|
||||
|
||||
def test_assets_prices_length_mismatch(self):
|
||||
self._run_poison(
|
||||
{'assets': ASSETS[:3], 'asset_prices': BASE_PRICES}, 'len_mismatch')
|
||||
|
||||
def test_null_assets(self):
|
||||
self._run_poison({'assets': None, 'asset_prices': None}, 'assets=null')
|
||||
|
||||
def test_ng7_all_velocities_nan(self):
|
||||
"""NG7 format with NaN velocities in multi_window_results."""
|
||||
scan = {
|
||||
'version': 'NG7',
|
||||
'result': {
|
||||
'multi_window_results': {
|
||||
'50': {'tracking_data': {'lambda_max_velocity': float('nan')}},
|
||||
'150': {'tracking_data': {'lambda_max_velocity': float('nan')}},
|
||||
'750': {'tracking_data': {'lambda_max_velocity': float('nan')}},
|
||||
},
|
||||
'pricing_data': {
|
||||
'current_prices': {a: p for a, p in zip(ASSETS, BASE_PRICES)}
|
||||
},
|
||||
'regime_prediction': {'instability_score': float('nan')},
|
||||
},
|
||||
'scan_number': 8888,
|
||||
'timestamp_ns': int(time.time() * 1e9),
|
||||
'file_mtime': time.time() + 1e5,
|
||||
}
|
||||
trader = _build_trader_fast()
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
trader._process_scan(_make_event(scan), time.time())
|
||||
_assert_capital_healthy(self, trader, "NG7 all-nan velocities")
|
||||
|
||||
def test_ng7_null_pricing_data(self):
|
||||
scan = {
|
||||
'version': 'NG7',
|
||||
'result': {
|
||||
'multi_window_results': {},
|
||||
'pricing_data': None,
|
||||
'regime_prediction': None,
|
||||
},
|
||||
'scan_number': 7777,
|
||||
'timestamp_ns': int(time.time() * 1e9),
|
||||
'file_mtime': time.time() + 2e5,
|
||||
}
|
||||
trader = _build_trader_fast()
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
trader._process_scan(_make_event(scan), time.time())
|
||||
_assert_capital_healthy(self, trader, "NG7 null pricing_data")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. vel_div Extremes
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzVelDivExtremes(unittest.TestCase):
|
||||
|
||||
def _run_seq(self, vd_sequence, label):
|
||||
trader = _build_trader_fast()
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
base = time.time() + RNG.uniform(1e6, 9e6)
|
||||
for i, vd in enumerate(vd_sequence):
|
||||
s = _make_scan(i, vd, file_mtime=base + i * 0.001)
|
||||
trader._process_scan(_make_event(s), base + i * 0.001)
|
||||
_assert_capital_healthy(self, trader, label)
|
||||
|
||||
def test_spike_positive_20x(self):
|
||||
seq = [0.0] * 50 + [20.0] + [0.0] * 50
|
||||
self._run_seq(seq, "spike +20x")
|
||||
|
||||
def test_spike_negative_20x(self):
|
||||
seq = [0.0] * 50 + [-20.0] + [0.0] * 50
|
||||
self._run_seq(seq, "spike -20x")
|
||||
|
||||
def test_alternating_spikes(self):
|
||||
seq = [(-1) ** i * 15.0 for i in range(200)]
|
||||
self._run_seq(seq, "alternating ±15")
|
||||
|
||||
def test_slow_drift_below_threshold(self):
|
||||
seq = [VEL_THRESHOLD + 0.005] * 500
|
||||
self._run_seq(seq, "constant just-above threshold → no entry")
|
||||
|
||||
def test_step_function_entry_then_recovery(self):
|
||||
seq = [-0.005] * 50 + [-0.08] * 5 + [-0.005] * 200
|
||||
self._run_seq(seq, "step function entry")
|
||||
|
||||
def test_random_walk_vel_div_1000_bars(self):
|
||||
rng = random.Random(99)
|
||||
vd = 0.0
|
||||
seq = []
|
||||
for _ in range(1000):
|
||||
vd += rng.gauss(0, 0.01)
|
||||
vd = max(-0.30, min(0.30, vd))
|
||||
seq.append(vd)
|
||||
self._run_seq(seq, "random walk 1000 bars")
|
||||
|
||||
def test_sustained_extreme_entry(self):
|
||||
"""Sustained extreme vel_div → engine enters once, holds, exits — no corruption."""
|
||||
seq = [-0.10] * 300
|
||||
self._run_seq(seq, "sustained extreme -0.10")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. Asset Universe Fuzzing
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzAssetUniverse(unittest.TestCase):
|
||||
|
||||
def _fire_scan(self, trader, sn, vd, assets, prices, base):
|
||||
s = _make_scan(sn, vd, assets=assets, prices=prices, file_mtime=base + sn * 0.001)
|
||||
trader._process_scan(_make_event(s), base + sn * 0.001)
|
||||
|
||||
def _fresh_trader(self):
|
||||
trader = _build_trader_fast()
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
return trader, time.time() + RNG.uniform(1e7, 9e7)
|
||||
|
||||
def test_stablecoin_only_universe_no_trade(self):
|
||||
"""All assets are stablecoins → prices_dict empty → no entry ever."""
|
||||
stable_assets = ['USDCUSDT', 'BUSDUSDT', 'FDUSDUSDT', 'TUSDUSDT', 'DAIUSDT']
|
||||
stable_prices = [1.0001, 0.9999, 1.0002, 1.0000, 0.9998]
|
||||
trader, base = self._fresh_trader()
|
||||
for i in range(150):
|
||||
self._fire_scan(trader, i, -0.10, stable_assets, stable_prices, base)
|
||||
self.assertEqual(trader.trades_executed, 0,
|
||||
"Stablecoin-only universe must never execute a trade")
|
||||
_assert_capital_healthy(self, trader, "stablecoin-only universe")
|
||||
|
||||
def test_stablecoin_mixed_universe_picker_blocks(self):
|
||||
"""Mix of real + stablecoin assets: stablecoins must not appear as trade asset."""
|
||||
mixed_assets = ASSETS + ['USDCUSDT', 'BUSDUSDT']
|
||||
mixed_prices = list(BASE_PRICES) + [1.0001, 0.9999]
|
||||
trader, base = self._fresh_trader()
|
||||
trade_assets = []
|
||||
orig = trader.eng.step_bar
|
||||
def cap(*a, **kw):
|
||||
r = orig(*a, **kw)
|
||||
if r.get('entry'):
|
||||
trade_assets.append(r['entry'].get('asset'))
|
||||
return r
|
||||
trader.eng.step_bar = cap
|
||||
for i in range(150):
|
||||
self._fire_scan(trader, i, -0.08, mixed_assets, mixed_prices, base)
|
||||
trader.eng.step_bar = orig
|
||||
for a in trade_assets:
|
||||
self.assertNotIn(a, _STABLECOIN_SYMBOLS,
|
||||
f"Stablecoin {a} reached engine as trade asset")
|
||||
|
||||
def test_single_asset_universe(self):
|
||||
trader, base = self._fresh_trader()
|
||||
for i in range(200):
|
||||
self._fire_scan(trader, i, -0.08, ['BTCUSDT'], [84_000.0], base)
|
||||
_assert_capital_healthy(self, trader, "single-asset universe")
|
||||
|
||||
def test_large_500_asset_universe(self):
|
||||
"""500-asset universe: prices_dict stays sane, no crash."""
|
||||
n = 500
|
||||
assets = [f"ASSET{i:04d}USDT" for i in range(n)]
|
||||
prices = [RNG.uniform(0.001, 50_000) for _ in range(n)]
|
||||
# Ensure BTC present as last
|
||||
assets[-1] = 'BTCUSDT'
|
||||
prices[-1] = 84_000.0
|
||||
trader, base = self._fresh_trader()
|
||||
for i in range(50):
|
||||
self._fire_scan(trader, i, -0.08, assets, prices, base)
|
||||
_assert_capital_healthy(self, trader, "500-asset universe")
|
||||
|
||||
def test_duplicate_assets_in_scan(self):
|
||||
"""Duplicate asset names: dict(zip(...)) deduplicates silently — no crash."""
|
||||
dup_assets = ASSETS + ASSETS # 10 items
|
||||
dup_prices = BASE_PRICES + BASE_PRICES
|
||||
trader, base = self._fresh_trader()
|
||||
for i in range(50):
|
||||
self._fire_scan(trader, i, -0.08, dup_assets, dup_prices, base)
|
||||
_assert_capital_healthy(self, trader, "duplicate assets")
|
||||
|
||||
def test_changing_universe_between_scans(self):
|
||||
"""Asset list changes every 10 bars — engine must not crash or corrupt capital."""
|
||||
trader, base = self._fresh_trader()
|
||||
universes = [
|
||||
(['BTCUSDT', 'ETHUSDT'], [84_000.0, 2_100.0]),
|
||||
(['BTCUSDT', 'SOLUSDT', 'XRPUSDT'], [84_000.0, 145.0, 2.4]),
|
||||
(['BTCUSDT', 'BNBUSDT'], [84_000.0, 600.0]),
|
||||
]
|
||||
for i in range(150):
|
||||
assets, prices = universes[i % len(universes)]
|
||||
self._fire_scan(trader, i, -0.06, assets, prices, base)
|
||||
_assert_capital_healthy(self, trader, "changing universe")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. Multi-Day P&L Compounding
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzMultiDayPnL(unittest.TestCase):
|
||||
|
||||
def test_30_day_capital_compounds_not_resets(self):
|
||||
"""Simulate 30 days: capital must compound, never reset to $25k mid-run."""
|
||||
trader = _build_trader()
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
rng = random.Random(7)
|
||||
base = time.time() + 1e8
|
||||
sn = 0
|
||||
capital_snapshots = []
|
||||
|
||||
for day_offset in range(30):
|
||||
day = (datetime.now(timezone.utc) + timedelta(days=day_offset)).strftime('%Y-%m-%d')
|
||||
posture = rng.choice(['APEX', 'CAUTION'])
|
||||
trader.eng.begin_day(day, posture=posture)
|
||||
trader.current_day = day
|
||||
trader.bar_idx = 0
|
||||
|
||||
for bar in range(200):
|
||||
vd = rng.uniform(-0.08, 0.03)
|
||||
px = [rng.uniform(0.8, 1.2) * p for p in BASE_PRICES]
|
||||
s = _make_scan(sn, vd, prices=px, file_mtime=base + sn * 0.001)
|
||||
trader._process_scan(_make_event(s), base + sn * 0.001)
|
||||
sn += 1
|
||||
|
||||
cap = trader.eng.capital
|
||||
if math.isfinite(cap):
|
||||
capital_snapshots.append((day_offset, cap))
|
||||
# Must never silently reset to exactly $25,000 after day 0
|
||||
if day_offset > 0 and len(capital_snapshots) >= 2:
|
||||
prev_cap = capital_snapshots[-2][1]
|
||||
if abs(cap - INITIAL_CAP) < 0.01 and abs(prev_cap - INITIAL_CAP) > 10:
|
||||
self.fail(
|
||||
f"Capital reset to ${INITIAL_CAP} on day {day_offset} "
|
||||
f"(was ${prev_cap:.2f}) — begin_day is resetting capital!")
|
||||
|
||||
_assert_capital_healthy(self, trader, "after 30-day simulation")
|
||||
|
||||
def test_capital_after_posture_switches(self):
|
||||
"""CAUTION → APEX → TURTLE → APEX transitions must not alter capital."""
|
||||
trader = _build_trader()
|
||||
trader.btc_prices = deque(_volatile_btc(), maxlen=BTC_VOL_WINDOW + 2)
|
||||
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(today, posture='APEX')
|
||||
trader.current_day = today
|
||||
base = time.time() + 2e8
|
||||
|
||||
# Accumulate some P&L
|
||||
for i in range(200):
|
||||
vd = -0.06 if i % 15 == 0 else -0.005
|
||||
s = _make_scan(i, vd, file_mtime=base + i * 0.001)
|
||||
trader._process_scan(_make_event(s), base + i * 0.001)
|
||||
|
||||
cap_before_posture = trader.eng.capital
|
||||
# Switch postures
|
||||
for posture in ['CAUTION', 'APEX', 'TURTLE', 'APEX']:
|
||||
tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
trader.eng.begin_day(tomorrow, posture=posture)
|
||||
cap_after = trader.eng.capital
|
||||
self.assertAlmostEqual(cap_before_posture, cap_after, delta=0.01,
|
||||
msg=f"Posture switch reset capital: {cap_before_posture:.2f} → {cap_after:.2f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 8. Checkpoint Save / Restore Fuzzing
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzRestartPersistence(unittest.TestCase):
|
||||
|
||||
EDGE_CAPITALS = [
|
||||
25_000.00, # exactly initial
|
||||
25_000.01, # just above initial
|
||||
24_999.99, # just below initial
|
||||
1.00, # near-zero
|
||||
1_000_000.00, # large win
|
||||
0.001, # micro (below 1 dollar) — should NOT be restored
|
||||
]
|
||||
|
||||
def _roundtrip(self, capital_value: float) -> float:
|
||||
"""Save capital_value, restore into fresh trader, return restored value."""
|
||||
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)
|
||||
|
||||
# Trader 1: save — use the REAL _save_capital (not the mock from _build_trader_fast)
|
||||
t1 = _build_trader_fast()
|
||||
t1.eng.capital = capital_value
|
||||
t1.state_map = mock_map
|
||||
DolphinLiveTrader._save_capital(t1) # bypass instance mock, call real method
|
||||
|
||||
# Trader 2: restore — same: call real _restore_capital
|
||||
t2 = _build_trader_fast()
|
||||
t2.state_map = mock_map
|
||||
DolphinLiveTrader._restore_capital(t2)
|
||||
return t2.eng.capital
|
||||
|
||||
def test_roundtrip_initial_capital(self):
|
||||
restored = self._roundtrip(25_000.0)
|
||||
self.assertAlmostEqual(restored, 25_000.0, delta=0.01)
|
||||
|
||||
def test_roundtrip_large_capital(self):
|
||||
restored = self._roundtrip(1_000_000.0)
|
||||
self.assertAlmostEqual(restored, 1_000_000.0, delta=0.01)
|
||||
|
||||
def test_roundtrip_near_zero_capital(self):
|
||||
restored = self._roundtrip(1.0)
|
||||
self.assertAlmostEqual(restored, 1.0, delta=0.001)
|
||||
|
||||
def test_micro_capital_not_restored(self):
|
||||
"""Capital < $1 is suspicious; restore guard must not apply it."""
|
||||
restored = self._roundtrip(0.001)
|
||||
# Should fall back to initial_capital since 0.001 fails the guard
|
||||
self.assertGreaterEqual(restored, INITIAL_CAP - 0.01,
|
||||
"Sub-$1 checkpoint should not be restored (likely corrupted)")
|
||||
|
||||
def test_nan_capital_not_persisted(self):
|
||||
"""NaN capital must not be written to checkpoint."""
|
||||
saved = {}
|
||||
mock_map = MagicMock()
|
||||
mock_map.blocking.return_value.put = lambda k, v: saved.update({k: v})
|
||||
|
||||
t = _build_trader_fast()
|
||||
t.eng.capital = float('nan')
|
||||
t.state_map = mock_map
|
||||
t._save_capital()
|
||||
self.assertNotIn('capital_checkpoint', saved,
|
||||
"NaN capital must not be written to checkpoint")
|
||||
|
||||
def test_50_random_capitals_roundtrip(self):
|
||||
"""50 random capital values all survive save/restore accurately."""
|
||||
rng = random.Random(55)
|
||||
for _ in range(50):
|
||||
cap = rng.uniform(1.0, 500_000.0)
|
||||
restored = self._roundtrip(cap)
|
||||
self.assertAlmostEqual(restored, cap, delta=cap * 1e-6,
|
||||
msg=f"Roundtrip failed for capital={cap:.2f}")
|
||||
|
||||
def test_stale_checkpoint_ignored(self):
|
||||
"""Checkpoint older than 72h must be ignored (could be from old session)."""
|
||||
import json as _json
|
||||
saved = {}
|
||||
mock_map = MagicMock()
|
||||
old_ts = time.time() - (73 * 3600) # 73h ago
|
||||
saved['capital_checkpoint'] = _json.dumps({'capital': 99_999.0, 'ts': old_ts})
|
||||
mock_map.blocking.return_value.get = lambda k: saved.get(k)
|
||||
|
||||
t = _build_trader_fast()
|
||||
t.state_map = mock_map
|
||||
t._restore_capital()
|
||||
# Should NOT restore stale checkpoint — capital stays at initial
|
||||
self.assertAlmostEqual(t.eng.capital, INITIAL_CAP, delta=0.01,
|
||||
msg="Stale (73h) checkpoint must not be restored")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 9. Concurrent Financial Safety
|
||||
# ===========================================================================
|
||||
|
||||
class TestFuzzConcurrentFinancial(unittest.TestCase):
|
||||
|
||||
def test_concurrent_entry_signals_single_position(self):
|
||||
"""
|
||||
20 threads all fire strong entry signals simultaneously.
|
||||
Engine lock must ensure exactly one position is opened (not 20).
|
||||
Capital must remain finite.
|
||||
"""
|
||||
trader = _build_trader_fast()
|
||||
base = _warmup(trader)
|
||||
barrier = threading.Barrier(20)
|
||||
errors = []
|
||||
|
||||
def fire(idx):
|
||||
try:
|
||||
barrier.wait(timeout=5)
|
||||
mtime = base + 1_000_000 + idx * 1e-6
|
||||
s = _make_scan(1_000_000 + idx, -0.10, file_mtime=mtime)
|
||||
trader._process_scan(_make_event(s), mtime)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=fire, args=(i,)) for i in range(20)]
|
||||
for t in threads: t.start()
|
||||
for t in threads: t.join(timeout=10)
|
||||
|
||||
self.assertEqual(errors, [], f"Thread errors: {errors}")
|
||||
_assert_capital_healthy(self, trader, "post concurrent entries")
|
||||
|
||||
def test_concurrent_mixed_entry_exit(self):
|
||||
"""
|
||||
10 threads fire entries, 10 fire exits for non-existent positions.
|
||||
Engine must not corrupt capital.
|
||||
"""
|
||||
trader = _build_trader_fast()
|
||||
base = _warmup(trader)
|
||||
# Pre-open a position
|
||||
s = _make_scan(999999, -0.10, file_mtime=base + 500_000)
|
||||
trader._process_scan(_make_event(s), base + 500_000)
|
||||
|
||||
barrier = threading.Barrier(20)
|
||||
errors = []
|
||||
|
||||
def fire_entry(idx):
|
||||
try:
|
||||
barrier.wait(timeout=5)
|
||||
mtime = base + 2_000_000 + idx * 1e-6
|
||||
s = _make_scan(2_000_000 + idx, -0.10, file_mtime=mtime)
|
||||
trader._process_scan(_make_event(s), mtime)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def fire_neutral(idx):
|
||||
try:
|
||||
barrier.wait(timeout=5)
|
||||
mtime = base + 3_000_000 + idx * 1e-6
|
||||
s = _make_scan(3_000_000 + idx, -0.001, file_mtime=mtime)
|
||||
trader._process_scan(_make_event(s), mtime)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=fire_entry, args=(i,)) for i in range(10)]
|
||||
threads += [threading.Thread(target=fire_neutral, args=(i,)) for i in range(10)]
|
||||
for t in threads: t.start()
|
||||
for t in threads: t.join(timeout=10)
|
||||
|
||||
self.assertEqual(errors, [], f"Thread errors: {errors}")
|
||||
_assert_capital_healthy(self, trader, "post mixed concurrent")
|
||||
|
||||
def test_capital_checkpoint_concurrent_writes(self):
|
||||
"""Concurrent _save_capital calls must not corrupt the stored value."""
|
||||
trader = _build_trader_fast()
|
||||
trader.eng.capital = 42_000.0
|
||||
saved = {}
|
||||
mock_map = MagicMock()
|
||||
lock = threading.Lock()
|
||||
def safe_put(k, v):
|
||||
with lock:
|
||||
saved[k] = v
|
||||
mock_map.blocking.return_value.put = safe_put
|
||||
trader.state_map = mock_map
|
||||
# Re-enable save_capital (was mocked in _build_trader)
|
||||
trader._save_capital = DolphinLiveTrader._save_capital.__get__(trader, DolphinLiveTrader)
|
||||
|
||||
errors = []
|
||||
def save():
|
||||
try:
|
||||
trader._save_capital()
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [threading.Thread(target=save) for _ in range(20)]
|
||||
for t in threads: t.start()
|
||||
for t in threads: t.join(timeout=5)
|
||||
|
||||
self.assertEqual(errors, [], f"Save errors: {errors}")
|
||||
if 'capital_checkpoint' in saved:
|
||||
data = json.loads(saved['capital_checkpoint'])
|
||||
self.assertAlmostEqual(data['capital'], 42_000.0, delta=0.01,
|
||||
msg="Concurrent checkpoint writes corrupted capital value")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Runner
|
||||
# ===========================================================================
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user