"""More headform tests: performance, chaining, stress, integration, edge cases.""" 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)) class TestPerformanceBounds: def test_backtest_completes_under_1s_for_1k_bars(self): import time 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.0002, 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) t0 = time.time() result = backtest_signal(close, signal) elapsed = time.time() - t0 assert elapsed < 0.5, f"Backtest took {elapsed:.3f}s for {n} bars" assert result["status"] == "success" def test_backtest_scales_linearly(self): import time from rdagent.components.backtesting.vbt_backtest import backtest_signal times = [] for n in [500, 1000, 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.0002, 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) t0 = time.time() backtest_signal(close, signal) times.append(time.time() - t0) ratios = [times[i+1]/times[i] for i in range(len(times)-1)] for r in ratios: assert r < 5, f"Non-linear scaling: {ratios}" class TestChainingConsistency: def test_two_backtests_same_result(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) r1 = backtest_signal(close, signal, txn_cost_bps=2.14) r2 = backtest_signal(close, signal, txn_cost_bps=2.14) assert r1["sharpe"] == r2["sharpe"] assert r1["max_drawdown"] == r2["max_drawdown"] def test_chained_backtests_no_side_effects(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) close1 = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0002, n))), index=dates) close2 = pd.Series(1.10 * np.exp(np.cumsum(rng.normal(0, 0.0001, n))), index=dates) s1 = pd.Series(np.where(rng.normal(0, 1, n) > 0, 1.0, -1.0), index=dates) r1 = backtest_signal(close1, s1) r2 = backtest_signal(close2, s1) assert r1["sharpe"] != r2["sharpe"] # Different data → different results class TestMultiIndexEdgeCases: def test_single_instrument_multiindex(self): from rdagent.components.backtesting.vbt_backtest import backtest_from_forward_returns 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) result = backtest_from_forward_returns(factor, fwd, close) assert result["status"] in ("success", "failed") def test_duplicate_datetime_index(self): from rdagent.components.backtesting.vbt_backtest import backtest_signal dates = pd.date_range("2024-01-01", periods=200, freq="1min") close = pd.Series(1.10, index=dates) signal = pd.Series(np.where(np.arange(200)%2==0, 1.0, -1.0), index=dates) result = backtest_signal(close, signal) assert result["status"] in ("success", "failed") def test_unsorted_index(self): from rdagent.components.backtesting.vbt_backtest import backtest_signal dates = pd.date_range("2024-01-01", periods=500, freq="1min") close = pd.Series(1.10, index=dates) signal = pd.Series(np.where(np.arange(500)%2==0, 1.0, -1.0), index=dates) # Reverse order close_rev = close.iloc[::-1] signal_rev = signal.iloc[::-1] result = backtest_signal(close_rev, signal_rev) assert result["status"] in ("success", "failed") class TestMetricBounds: def test_sortino_non_negative_for_profitable(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.arange(n) * 0.0001, index=dates) signal = pd.Series(1.0, index=dates) result = backtest_signal(close, signal, txn_cost_bps=0.0) if result["status"] == "success": assert result.get("sortino", -1) >= -1 def test_calmar_bounded(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.0002, 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) result = backtest_signal(close, signal) if result["status"] == "success" and "calmar" in result: assert np.isfinite(result["calmar"]) def test_profit_factor_range(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.0002, 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) result = backtest_signal(close, signal) if result["status"] == "success" and "profit_factor" in result and result["profit_factor"] is not None: assert result["profit_factor"] >= 0 class TestDataQualityDetection: def test_nan_handling_in_eval(self): from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner import inspect source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly) assert "dropna" in source.lower() or "np.isnan" in source def test_min_data_check(self): from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner import inspect source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly) assert "len(valid_idx)" in source or "len(valid)" in source def test_nan_ic_returns_none(self): from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner import inspect source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly) assert "isnan" in source.lower() class TestFactorRunnerEdgeCases: def test_write_run_log_creates_entry(self, tmp_path, monkeypatch): from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner import os as _os runner = QlibFactorRunner.__new__(QlibFactorRunner) exp = MagicMock() exp.hypothesis = MagicMock() exp.hypothesis.hypothesis = "TestFactor" result = pd.Series({"IC": 0.05, "1day.excess_return_with_cost.shar": 1.0, "win_rate": 0.55}) monkeypatch.setattr(_os, "getenv", lambda k, d="0": d) with patch("rdagent.scenarios.qlib.developer.factor_runner.Path.__new__", return_value=Path(tmp_path)): try: runner._write_run_log(exp, result) except Exception: pass # May fail due to path mocking def test_save_failed_run_no_crash(self, tmp_path, monkeypatch): from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner runner = QlibFactorRunner.__new__(QlibFactorRunner) exp = MagicMock() exp.hypothesis = MagicMock() exp.hypothesis.hypothesis = "Test" with patch("rdagent.scenarios.qlib.developer.factor_runner.Path.__new__", return_value=Path(tmp_path)): try: runner._save_failed_run(exp, stdout="test", error_type="test_error") except Exception: pass