Files

236 lines
10 KiB
Python
Raw Permalink Normal View History

"""Headform-level tests: Docker integration mocks, spread, rollover, regression."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import pytest
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# =============================================================================
# Docker Integration Mock Tests
# =============================================================================
class TestDockerIntegrationMocks:
def test_factor_execute_flow_mocked(self):
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
from rdagent.scenarios.qlib.experiment.factor_experiment import QlibFactorExperiment
from rdagent.core.experiment import Task
exp = QlibFactorExperiment(sub_tasks=[Task(name="test")])
exp.hypothesis = MagicMock()
exp.hypothesis.hypothesis = "TestFactor"
exp.base_features = {}
exp.base_feature_codes = {}
exp.based_experiments = []
exp.sub_workspace_list = [MagicMock()]
exp.sub_workspace_list[0].workspace_path = Path("/tmp")
exp.experiment_workspace = MagicMock()
exp.experiment_workspace.workspace_path = Path("/tmp")
runner = QlibFactorRunner.__new__(QlibFactorRunner)
# Mock the execute to return a valid result
with patch.object(exp.experiment_workspace, "execute", return_value=(pd.Series({"IC": 0.05}), "ok")):
result = runner.develop(exp)
assert result is not None
def test_result_validation_flow(self):
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
runner = QlibFactorRunner.__new__(QlibFactorRunner)
exp = MagicMock()
exp.hypothesis = MagicMock()
exp.hypothesis.hypothesis = "Test"
result = pd.Series({"IC": 0.05, "1day.excess_return_with_cost.shar": 1.5, "1day.pos": 100})
validation = runner._validate_result(exp, result)
assert isinstance(validation, dict)
assert "has_issues" in validation
# =============================================================================
# Spread / Rollover / Partial-Fill Robustness
# =============================================================================
class TestSpreadWidening:
def test_spread_doubling(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
n = 2000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
rng = np.random.default_rng(42)
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
r_normal = backtest_signal(close, signal, txn_cost_bps=2.14)
r_wide = backtest_signal(close, signal, txn_cost_bps=5.0) # News spread
if r_normal["status"] == "success" and r_wide["status"] == "success":
assert -1.0 <= r_wide["max_drawdown"] <= 0.0
assert np.isfinite(r_wide["sharpe"])
assert np.isfinite(r_wide["total_return"])
def test_extreme_spread_no_crash(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
n = 1000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
close = pd.Series(1.10, index=dates)
signal = pd.Series([1.0, -1.0] * (n // 2), index=dates)
# Extreme 10 bps cost — should handle gracefully
result = backtest_signal(close, signal, txn_cost_bps=10.0)
assert result["status"] in ("success", "failed")
assert np.isfinite(result["total_return"])
class TestPartialFills:
def test_signal_with_gaps_handled(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
n = 1000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
close = pd.Series(1.10 + np.random.default_rng(42).normal(0, 0.001, n).cumsum(), index=dates)
# Signal with "holes" (NaN) simulating partial fills
signal = pd.Series(np.where(np.random.default_rng(43).normal(0, 1, n) > 0, 1.0, np.nan), index=dates)
signal.iloc[:10] = 0.0
signal.iloc[-10:] = 0.0
result = backtest_signal(close, signal, txn_cost_bps=2.14)
assert result["status"] in ("success", "failed")
class TestRolloverSwap:
def test_wednesday_triple_swap_no_crash(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
n = 2000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
close = pd.Series(1.10 + np.random.default_rng(42).normal(0, 0.0001, n).cumsum(), index=dates)
signal = pd.Series(np.where(np.random.default_rng(43).normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
# Higher cost on Wednesdays (simulating triple swap)
result = backtest_signal(close, signal, txn_cost_bps=2.14)
assert result["status"] in ("success", "failed")
assert np.isfinite(result["total_return"])
def test_overnight_hold_cost(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
n = 5000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
close = pd.Series(1.10 + np.random.default_rng(42).normal(0, 0.0001, n).cumsum(), index=dates)
signal = pd.Series(1.0, index=dates) # Always long → incurs overnight costs
result = backtest_signal(close, signal, txn_cost_bps=2.14)
if result["status"] == "success":
assert np.isfinite(result["sharpe"])
assert np.isfinite(result["total_return"])
# =============================================================================
# Regression: previously fixed bugs must stay fixed
# =============================================================================
class TestRegressionFixedBugs:
def test_sys_import_in_save_factor_values(self):
"""Bug fix: _save_factor_values had missing `import sys`."""
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
import inspect
source = inspect.getsource(QlibFactorRunner._save_factor_values)
assert "import sys" in source
def test_acc_rate_default_in_evaluator(self):
"""Bug fix: acc_rate was undefined after except clause."""
from rdagent.components.coder.factor_coder.eva_utils import FactorEqualValueRatioEvaluator
evaluator = FactorEqualValueRatioEvaluator()
# Trigger the except path: pass None as gt_df via mock
gt_ws = MagicMock()
imp_ws = MagicMock()
gt_ws.execute.return_value = ("", None)
imp_ws.execute.return_value = ("", pd.DataFrame({"x": [1.0]}))
result = evaluator.evaluate(imp_ws, gt_ws)
assert isinstance(result, tuple)
assert len(result) == 2
def test_sharpe_uses_equity_not_factor_raw(self):
"""Bug fix: Sharpe was factor_mean/factor_std, now strategy_ret based."""
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
import inspect
source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly)
assert "strategy_ret" in source
assert "bars_per_year" in source
def test_max_dd_uses_equity_curve(self):
"""Bug fix: MaxDD was on cumsum(factor), now on equity curve."""
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
import inspect
source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly)
assert "equity" in source.lower() or "cumprod" in source
def test_win_rate_on_trade_pnl(self):
"""Bug fix: WinRate was (factor>0).sum(), now (strategy_ret>0).sum()."""
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
import inspect
source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly)
assert "strategy_ret > 0" in source or "(strategy_ret > 0)" in source
def test_path_injection_fix(self):
"""Bug fix: path-injection in safe_resolve_path."""
from rdagent.core.utils import safe_resolve_path
path = safe_resolve_path(Path("/tmp/test"), Path("/tmp"))
assert str(path).startswith("/tmp/test")
def test_oos_default_enabled(self):
"""Feature: OOS/WF is now default."""
from rdagent.components.backtesting.vbt_backtest import backtest_signal_risk
import inspect
source = inspect.signature(backtest_signal_risk)
assert source.parameters["wf_rolling"].default is True
# =============================================================================
# Integration: Cross-system consistency
# =============================================================================
class TestCrossSystemConsistency:
def test_backtest_signal_risk_consistency(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal, backtest_signal_risk
n = 2000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
rng = np.random.default_rng(42)
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
r1 = backtest_signal(close, signal, txn_cost_bps=2.14)
r2 = backtest_signal_risk(close, signal, txn_cost_bps=2.14, wf_rolling=False)
if r1["status"] == "success" and r2.get("status") == "success":
assert "sharpe" in r1 and "sharpe" in r2
assert -1.0 <= r1["max_drawdown"] <= 0.0
assert -1.0 <= r2["max_drawdown"] <= 0.0
def test_backtest_and_verify_consistency(self):
from rdagent.components.backtesting.vbt_backtest import backtest_signal
from rdagent.components.backtesting.verify import verify_backtest_result
n = 2000
dates = pd.date_range("2024-01-01", periods=n, freq="1min")
rng = np.random.default_rng(42)
close = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates)
signal = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates)
result = backtest_signal(close, signal)
if result["status"] == "success":
warnings = verify_backtest_result(result)
assert warnings == [], f"Verifier found issues: {warnings}"