Files
sanguo_vnpy_v2/tests/factor/test_analyzer.py
T

283 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Test analyzer module - Factor analysis with alphalens."""
import sys
import os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
sys.path.insert(0, _VNPY_SRC)
from unittest.mock import Mock, patch, MagicMock
import tempfile
from datetime import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
_SH = ZoneInfo("Asia/Shanghai")
def _fake_bars(symbol, days, price=100.0):
"""read_db_daily 假 bars。analyzer 会单独调 read_db_daily 取 close 建 prices
且 prices 日期须与 factor 日期对齐(aware, Asia/Shanghai)——不喂 bars 会触发
DBG empty 守卫直接 continuetears/IC 永不被调到(2026-08-15 前这批测试在
任何环境都没跑过:Mac 缺依赖 skip/容器缺 pytest/CI 只跑 data_platform)。"""
return [
SimpleNamespace(datetime=datetime(2024, 1, d), vt_symbol=symbol, close_price=price)
for d in days
]
def test_run_factor_analysis_returns_report():
"""Test that run_factor_analysis returns FactorReport with correct structure."""
from pathlib import Path
from sanguo_factor.analyzer import run_factor_analysis, FactorReport
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = str(Path(tmpdir) / "factor_analysis")
# Patch imports to avoid ImportError when alphalens missing
with patch("sanguo_factor.analyzer.AlphaLabSession") as MockSession, \
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns"), \
patch("sanguo_factor.analyzer.create_full_tear_sheet"):
# Mock the AlphaLabSession
mock_session_instance = Mock()
MockSession.return_value = mock_session_instance
mock_session_instance.load_symbols = Mock()
mock_session_instance.compute_factors = Mock(return_value=Mock(to_pandas=Mock(return_value=Mock(
set_index=Mock(return_value=Mock(__getitem__=Mock(return_value=Mock())))),
pivot=Mock(return_value=Mock())
)))
# Mock get_factor to avoid registry call
with patch("sanguo_factor.registry.get_factor", return_value={"expression": "ts_mean(close, 5)"}):
# Run the analysis
result = run_factor_analysis(
symbols=["600000"],
factor_names=["ma5"],
start="2024-01-01",
end="2024-06-30",
cfg=Mock(),
output_dir=output_dir
)
# Verify the result is a FactorReport with expected structure
assert isinstance(result, FactorReport)
assert result.factor_names == ["ma5"]
assert result.output_dir == output_dir
assert isinstance(result.ic_summary, dict)
def test_run_factor_analysis_calls_load_symbols():
"""Test that run_factor_analysis handles missing alphalens gracefully."""
from pathlib import Path
from sanguo_factor.analyzer import run_factor_analysis
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = str(Path(tmpdir) / "factor_analysis")
# Patch module-level variables to simulate missing alphalens
with patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns", None), \
patch("sanguo_factor.analyzer.create_full_tear_sheet", None):
# alphalens missing - should return skeleton report with error
result = run_factor_analysis(
symbols=["600000", "000001"],
factor_names=["ma5"],
start="2024-01-01",
end="2024-06-30",
cfg=Mock(),
output_dir=output_dir
)
# Verify skeleton report returned
assert "error" in result.ic_summary
def test_run_factor_analysis_adds_features():
"""Test that run_factor_analysis returns correct structure when alphalens missing."""
from pathlib import Path
from sanguo_factor.analyzer import run_factor_analysis
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = str(Path(tmpdir) / "factor_analysis")
# Patch module-level variables to simulate missing alphalens
with patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns", None), \
patch("sanguo_factor.analyzer.create_full_tear_sheet", None):
# alphalens missing - verify structure
result = run_factor_analysis(
symbols=["600000"],
factor_names=["ma5"],
start="2024-01-01",
end="2024-06-30",
cfg=Mock(),
output_dir=output_dir
)
# Verify factor_names preserved even when alphalens missing
assert result.factor_names == ["ma5"]
assert result.output_dir == output_dir
def test_run_factor_analysis_calls_tears(tmp_path):
"""Test that run_factor_analysis calls alphalens tears pipeline.
Requires polars - runs in container, skips locally.
"""
import pytest
pytest.importorskip("polars")
pytest.importorskip("alphalens")
from pathlib import Path
from sanguo_factor.analyzer import run_factor_analysis
with patch("sanguo_factor.analyzer.AlphaLabSession") as MS, \
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as MC, \
patch("sanguo_factor.analyzer.create_full_tear_sheet") as MT, \
patch("sanguo_data.datareader.read_db_daily",
return_value=_fake_bars("600000", (2, 3, 4, 5, 8))):
import polars as pl
# 非空 + aware 日期(生产 compute_factors 会 localize;naive 会被 prices 对齐
# isin 过滤成空触发 DBG 守卫)
_days = (2, 3, 4, 5, 8)
MS.return_value.compute_factors.return_value = pl.DataFrame({
"datetime": [datetime(2024, 1, d, tzinfo=_SH) for d in _days],
"vt_symbol": ["600000"] * len(_days),
"ma5": [0.5] * len(_days),
})
MC.return_value = MagicMock()
report = run_factor_analysis(
["600000"], ["ma5"], "2024-01-01", "2024-06-30",
cfg=MagicMock(), output_dir=str(tmp_path)
)
assert report.factor_names == ["ma5"]
MC.assert_called_once()
MT.assert_called_once()
def test_run_factor_analysis_extracts_ic_values(tmp_path):
"""Test that run_factor_analysis extracts IC values from alphalens.
Requires polars/alphalens - runs in container, skips locally.
"""
import pytest
pytest.importorskip("polars")
pytest.importorskip("alphalens")
import pandas as pd
from datetime import datetime
from sanguo_factor.analyzer import run_factor_analysis
# Create mock factor_data with MultiIndex (datetime, asset) and IC columns
dates = pd.date_range("2024-01-01", periods=10, freq="D", tz="Asia/Shanghai")
assets = ["AAPL", "GOOGL"]
index = pd.MultiIndex.from_product([dates, assets], names=["datetime", "asset"])
# Mock factor_data with forward returns
mock_factor_data = pd.DataFrame(index=index)
mock_factor_data["factor"] = [0.5] * 20 # Factor values
mock_factor_data["1D"] = [0.01] * 20 # 1-day forward returns
mock_factor_data["5D"] = [0.05] * 20 # 5-day forward returns
mock_factor_data["10D"] = [0.10] * 20 # 10-day forward returns
# Mock IC DataFrame returned by factor_information_coefficient
mock_ic_df = pd.DataFrame({
"1D": [0.05, 0.03, 0.07, 0.04, 0.06, 0.05, 0.04, 0.06, 0.05, 0.04],
"5D": [0.08, 0.06, 0.09, 0.07, 0.08, 0.07, 0.08, 0.06, 0.07, 0.08],
"10D": [0.12, 0.10, 0.13, 0.11, 0.12, 0.11, 0.12, 0.10, 0.11, 0.12]
}, index=dates)
# Mock polars DataFramedatetime 用带时区 ISO 串,与 _fake_bars localize 后对齐)
mock_pl_df = MagicMock()
mock_pl_df.to_pandas.return_value = pd.DataFrame({
"datetime": [d.isoformat() for d in dates for _ in assets],
"vt_symbol": assets * len(dates),
"ma5": [0.5] * 20,
"close": [100.0] * 20
})
with patch("sanguo_factor.analyzer.AlphaLabSession") as MS, \
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as MC, \
patch("sanguo_factor.analyzer.create_full_tear_sheet") as MT, \
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
patch("sanguo_data.datareader.read_db_daily",
return_value=_fake_bars("AAPL", range(1, 11)) + _fake_bars("GOOGL", range(1, 11))):
# Mock compute_factors to return polars DataFrame
MS.return_value.compute_factors.return_value = mock_pl_df
# Mock get_clean_factor_and_forward_returns to return our factor_data
MC.return_value = mock_factor_data
# Mock IC function to return our IC DataFrame
MIC.return_value = mock_ic_df
report = run_factor_analysis(
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
cfg=MagicMock(), output_dir=str(tmp_path)
)
# Verify IC values were extracted
assert "ma5" in report.ic_summary
assert "ic" in report.ic_summary["ma5"]
# Check IC structure contains expected periods
ic_data = report.ic_summary["ma5"]["ic"]
assert "1D" in ic_data
assert "5D" in ic_data
assert "10D" in ic_data
# Verify IC statistics are computed
assert "mean" in ic_data["1D"]
assert "icir" in ic_data["1D"]
assert "std" in ic_data["1D"]
# Verify approximate values (mean should be around 0.05 for 1D)
assert abs(ic_data["1D"]["mean"] - 0.05) < 0.01 # Allow small rounding errors
def test_run_factor_analysis_ic_extraction_fails_gracefully(tmp_path):
"""Test that IC extraction failures don't crash the pipeline.
Requires polars/alphalens - runs in container, skips locally.
"""
import pytest
pytest.importorskip("polars")
pytest.importorskip("alphalens")
from sanguo_factor.analyzer import run_factor_analysis
import pandas as pd
# Mock polars DataFramedatetime 带时区,与 _fake_bars 对齐防 DBG 空守卫)
mock_pl_df = MagicMock()
mock_pl_df.to_pandas.return_value = pd.DataFrame({
"datetime": ["2024-01-01T00:00:00+08:00"],
"vt_symbol": ["AAPL"],
"ma5": [0.5],
"close": [100.0]
})
with patch("sanguo_factor.analyzer.AlphaLabSession") as MS, \
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as MC, \
patch("sanguo_factor.analyzer.create_full_tear_sheet") as MT, \
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
patch("sanguo_data.datareader.read_db_daily",
return_value=_fake_bars("AAPL", (1, 2))):
MS.return_value.compute_factors.return_value = mock_pl_df
# Mock get_clean_factor_and_forward_returns to return valid data
mock_factor_data = MagicMock()
MC.return_value = mock_factor_data
# Mock IC function to raise an exception
MIC.side_effect = Exception("IC calculation failed")
report = run_factor_analysis(
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
cfg=MagicMock(), output_dir=str(tmp_path)
)
# Verify IC error is captured but status/report still exist
assert "ma5" in report.ic_summary
assert "ic" in report.ic_summary["ma5"]
assert "error" in report.ic_summary["ma5"]["ic"]
# Status and report should still be present
assert "status" in report.ic_summary["ma5"]