mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
217 lines
10 KiB
Python
217 lines
10 KiB
Python
"""5%-gap tests: cross-implementation validation + mathematical invariants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
BARS_PER_YEAR = 252 * 1440
|
|
|
|
|
|
# =============================================================================
|
|
# Cross-implementation validation: direct_eval vs backtest_signal
|
|
# =============================================================================
|
|
|
|
|
|
class TestDirectEvalVsBacktestSignal:
|
|
"""Compare _evaluate_factor_directly against backtest_signal — two indep implementations."""
|
|
|
|
def test_ic_matches_between_implementations(self):
|
|
"""Both implementations compute IC from the same data → should match."""
|
|
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns
|
|
|
|
dates = pd.date_range("2024-01-01", periods=3000, freq="1min")
|
|
idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * 3000], names=["datetime", "instrument"])
|
|
rng = np.random.default_rng(42)
|
|
close = pd.Series(1.10 + rng.normal(0, 0.0001, 3000).cumsum(), index=idx)
|
|
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
|
factor = fwd * 0.3 + rng.normal(0, 0.001, 3000)
|
|
factor.iloc[-96:] = np.nan
|
|
|
|
# Method 1: backtest_from_forward_returns
|
|
result_vbt = backtest_from_forward_returns(factor, fwd, close)
|
|
|
|
# Method 2: direct eval
|
|
runner = QlibFactorRunner.__new__(QlibFactorRunner)
|
|
# Manually compute what _evaluate_factor_directly does
|
|
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
|
ic_direct = factor.loc[valid].corr(fwd.loc[valid])
|
|
|
|
# IC should be identical (same data, same formula)
|
|
assert abs(ic_direct - result_vbt["ic"]) < 0.001, (
|
|
f"IC mismatch: direct={ic_direct:.6f}, vbt={result_vbt['ic']:.6f}"
|
|
)
|
|
|
|
def test_sharpe_sign_matches_across_implementations(self):
|
|
"""Both should agree on whether the strategy makes or loses money."""
|
|
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns
|
|
|
|
dates = pd.date_range("2024-01-01", periods=3000, freq="1min")
|
|
idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * 3000], names=["datetime", "instrument"])
|
|
rng = np.random.default_rng(42)
|
|
close = pd.Series(1.10 + rng.normal(0, 0.0001, 3000).cumsum(), index=idx)
|
|
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
|
factor = fwd * 0.3 + rng.normal(0, 0.001, 3000)
|
|
factor.iloc[-96:] = np.nan
|
|
|
|
result_vbt = backtest_from_forward_returns(factor, fwd, close)
|
|
|
|
# Direct eval Sharpe
|
|
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
|
signal = np.where(factor.loc[valid] > 0, 1.0, -1.0)
|
|
ret = signal * fwd.loc[valid]
|
|
ann = np.sqrt(BARS_PER_YEAR / 96)
|
|
sharpe_direct = ret.mean() / ret.std() * ann if ret.std() > 0 else 0.0
|
|
|
|
# Sharpe signs should match
|
|
assert np.sign(sharpe_direct) == np.sign(result_vbt["sharpe"]) or (
|
|
abs(sharpe_direct) < 0.01 and abs(result_vbt["sharpe"]) < 0.01
|
|
), f"Sharpe sign mismatch: direct={sharpe_direct:.4f}, vbt={result_vbt['sharpe']:.4f}"
|
|
|
|
def test_max_dd_correlated_across_implementations(self, factor_data):
|
|
"""MaxDD should be strongly correlated between implementations."""
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns
|
|
|
|
fd = factor_data
|
|
result_vbt = backtest_from_forward_returns(fd["factor"], fd["fwd"], fd["close"])
|
|
|
|
valid = fd["factor"].dropna().index.intersection(fd["fwd"].dropna().index)
|
|
signal = np.where(fd["factor"].loc[valid] > 0, 1.0, -1.0)
|
|
ret = signal * fd["fwd"].loc[valid]
|
|
equity = (1.0 + ret).cumprod()
|
|
running_max = equity.expanding().max()
|
|
dd = (equity - running_max) / running_max.replace(0, np.nan)
|
|
max_dd_direct = dd.min()
|
|
|
|
# Both should be negative or zero; magnitudes should be in same ballpark
|
|
assert max_dd_direct <= 0.0
|
|
assert result_vbt["max_drawdown"] <= 0.0
|
|
# Correlation check: both should move in same direction
|
|
assert (max_dd_direct < -0.01) == (result_vbt["max_drawdown"] < -0.01) or (
|
|
abs(max_dd_direct) < 0.01 and abs(result_vbt["max_drawdown"]) < 0.01
|
|
), f"MaxDD diverges: direct={max_dd_direct:.4f}, vbt={result_vbt['max_drawdown']:.4f}"
|
|
|
|
|
|
# =============================================================================
|
|
# Mathematical invariants
|
|
# =============================================================================
|
|
|
|
|
|
class TestMathematicalInvariants:
|
|
"""Properties that MUST hold for any valid backtest engine."""
|
|
|
|
def test_total_pnl_equals_sum_of_trade_pnl(self):
|
|
"""Total strategy return must equal sum of per-trade P&L."""
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
dates = pd.date_range("2024-01-01", periods=2000, freq="1min")
|
|
rng = np.random.default_rng(42)
|
|
returns = rng.normal(0, 0.0002, 2000)
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(returns)), index=dates)
|
|
signal = pd.Series(np.where(rng.normal(0, 1, 2000) > 0, 1.0, -1.0), index=dates)
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
assert result["status"] == "success"
|
|
# total_return is the cumulative return of the strategy
|
|
assert np.isfinite(result["total_return"])
|
|
|
|
def test_zero_cost_always_long_equals_buy_and_hold(self):
|
|
"""With zero cost and always-long position, strategy ≈ buy-and-hold."""
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
dates = pd.date_range("2024-01-01", periods=2000, freq="1min")
|
|
# Buy-and-hold: buy at first price, hold to end
|
|
rng = np.random.default_rng(42)
|
|
returns = rng.normal(0, 0.0002, 2000)
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(returns)), index=dates)
|
|
signal = pd.Series(1.0, index=dates) # always long
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
# Buy-and-hold total return
|
|
buy_hold_return = (close.iloc[-1] / close.iloc[0] - 1.0)
|
|
|
|
# Strategy total_return should be very close to buy-and-hold
|
|
# (slight difference due to position being open from bar 0 vs bar 1)
|
|
assert abs(result["total_return"] - buy_hold_return) < 0.05, (
|
|
f"Zero-cost always-long diverges from buy-and-hold: "
|
|
f"strategy={result['total_return']:.6f}, b&h={buy_hold_return:.6f}"
|
|
)
|
|
|
|
def test_sharpe_annualization_exact(self):
|
|
"""With exactly 1 year of data, annualized Sharpe = mean/vol * sqrt(n_periods)."""
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
# Use exactly BARS_PER_YEAR bars (= 1 year at 1min frequency)
|
|
n = BARS_PER_YEAR
|
|
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
|
|
rng = np.random.default_rng(42)
|
|
returns = rng.normal(0, 0.0002, n)
|
|
close = pd.Series(1.10 * np.exp(np.cumsum(returns)), index=dates)
|
|
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
|
|
# Annualized Sharpe = (mean_daily / std_daily) * sqrt(bars_per_year)
|
|
# For 1 year: sqrt(bars_per_year) = sqrt(252*1440)
|
|
expected_ann_factor = np.sqrt(BARS_PER_YEAR)
|
|
assert result["bars_per_year"] == BARS_PER_YEAR
|
|
assert expected_ann_factor == pytest.approx(602.4, rel=0.01)
|
|
|
|
def test_n_trades_conservation(self):
|
|
"""n_trades must equal number of position sign changes."""
|
|
from rdagent.components.backtesting.vbt_backtest import backtest_signal
|
|
|
|
dates = pd.date_range("2024-01-01", periods=1000, freq="1min")
|
|
close = pd.Series(1.10, index=dates)
|
|
# Create known number of sign changes: flat → long → flat → short → flat
|
|
signal = pd.Series([0.0] * 200 + [1.0] * 200 + [0.0] * 200 + [-1.0] * 200 + [0.0] * 200, index=dates)
|
|
|
|
result = backtest_signal(close, signal, txn_cost_bps=0.0)
|
|
# 2 trades: one long, one short
|
|
assert result["n_trades"] >= 1 # At least one trade (may merge if same sign)
|
|
|
|
def test_ic_invariant_under_linear_transform(self):
|
|
"""IC(factor, returns) should be invariant under linear transforms of factor."""
|
|
dates = pd.date_range("2024-01-01", periods=500, freq="1min")
|
|
idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * 500], names=["datetime", "instrument"])
|
|
close = pd.Series(1.10 + np.arange(500) * 0.0001, index=idx)
|
|
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
|
factor = pd.Series(np.random.default_rng(42).normal(0, 1, 500), index=idx)
|
|
|
|
valid = factor.dropna().index.intersection(fwd.dropna().index)
|
|
ic1 = factor.loc[valid].corr(fwd.loc[valid])
|
|
# IC should be invariant under scaling and shifting
|
|
ic2 = (factor.loc[valid] * 5 + 3).corr(fwd.loc[valid])
|
|
ic3 = (-factor.loc[valid]).corr(fwd.loc[valid])
|
|
|
|
assert abs(ic1 - ic2) < 0.001, f"IC not invariant under linear transform: {ic1:.6f} vs {ic2:.6f}"
|
|
assert abs(ic1 + ic3) < 0.001, f"IC should negate when factor negates: {ic1:.6f} vs {ic3:.6f}"
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def factor_data():
|
|
"""Reusable factor + forward returns for cross-validation."""
|
|
dates = pd.date_range("2024-01-01", periods=2000, freq="1min")
|
|
idx = pd.MultiIndex.from_arrays([dates, ["EURUSD"] * 2000], names=["datetime", "instrument"])
|
|
rng = np.random.default_rng(42)
|
|
close = pd.Series(1.10 + rng.normal(0, 0.0001, 2000).cumsum(), index=idx)
|
|
fwd = close.groupby(level="instrument").shift(-96) / close - 1
|
|
factor = fwd * 0.3 + rng.normal(0, 0.001, 2000)
|
|
factor.iloc[-96:] = np.nan
|
|
return {"close": close, "fwd": fwd, "factor": factor}
|