948 lines
38 KiB
Python
948 lines
38 KiB
Python
|
|
#!/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)
|