8f4f564733
痛点:因子分析分钟级运行,进度条停着看不出进行中还是死掉;240因子罗列乱。
①跨进程心跳链路:analyzer._report_progress 写 {output_dir}/{tid}.progress
(stage/detail/ts;IO故障静默——锦上添花不能伤主流程),埋点=行情加载i/N逐只
+因子特征+逐因子i/M+tears✓+完成;runner._factor_worker 透传 task_id
(默认空串兼容旧调用);GET /task/{id} 合并 _read_factor_progress(factor_
前缀才读,age=距上次活动秒数)。
②Progress.vue终端风重构:心跳区=呼吸灯(绿≤30s/琥珀≤180s静默期/红更久,
prefers-reduced-motion停动画)+「Xs前·detail」+运行时长秒表+步骤%大数字;
因子步骤=行情加载→因子计算→逐因子分析→完成(心跳stage驱动);回测/优化
保持原5步推导+进度条;useTask/TaskStatus 透传 progress。
③FactorPicker默认折叠:groups max-height 128px两行预览+渐隐底边+
「展开全部N个因子▾」按钮,搜索时自动展开。
+5测试(_report_progress写/静默/心跳读roundtrip/非factor/损坏JSON),323绿+build绿
404 lines
17 KiB
Python
404 lines
17 KiB
Python
"""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 守卫直接 continue,tears/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 DataFrame(datetime 用带时区 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_writes_tears_json(tmp_path):
|
||
"""tears JSON(方案A)写盘 + FactorReport.tears_paths 记录 + factor/generated_at 补齐."""
|
||
import pytest
|
||
pytest.importorskip("polars")
|
||
pytest.importorskip("alphalens")
|
||
|
||
import json
|
||
import pandas as pd
|
||
from sanguo_factor.analyzer import run_factor_analysis
|
||
|
||
dates = pd.date_range("2024-01-01", periods=6, freq="D", tz="Asia/Shanghai")
|
||
mock_factor_data = pd.DataFrame(index=pd.MultiIndex.from_product(
|
||
[dates, ["AAPL"]], names=["datetime", "asset"]))
|
||
mock_factor_data["factor"] = [0.5] * 6
|
||
mock_factor_data["1D"] = [0.01] * 6
|
||
mock_factor_data["5D"] = [0.05] * 6
|
||
mock_factor_data["10D"] = [0.10] * 6
|
||
|
||
mock_pl_df = MagicMock()
|
||
mock_pl_df.to_pandas.return_value = pd.DataFrame({
|
||
"datetime": [d.isoformat() for d in dates],
|
||
"vt_symbol": ["AAPL"] * 6,
|
||
"ma5": [0.5] * 6,
|
||
})
|
||
|
||
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"), \
|
||
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
|
||
patch("sanguo_factor.tears_data.build_tears_data",
|
||
return_value={"factor_autocorr": 0.9, "periods": {"1D": {"ic_mean": 0.1}}}) as MB, \
|
||
patch("sanguo_data.datareader.read_db_daily",
|
||
return_value=_fake_bars("AAPL", range(1, 7))):
|
||
MS.return_value.compute_factors.return_value = mock_pl_df
|
||
MC.return_value = mock_factor_data
|
||
MIC.return_value = pd.DataFrame(
|
||
{"1D": [0.05, 0.04, 0.06, 0.05, 0.04, 0.06]}, index=dates)
|
||
|
||
report = run_factor_analysis(
|
||
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
|
||
cfg=MagicMock(), output_dir=str(tmp_path)
|
||
)
|
||
MB.assert_called_once()
|
||
assert "ma5" in report.tears_paths
|
||
assert report.tears_paths["ma5"].endswith("ma5_tears.json")
|
||
assert os.path.exists(report.tears_paths["ma5"])
|
||
with open(report.tears_paths["ma5"], encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
assert data["factor"] == "ma5"
|
||
assert data["periods"]["1D"]["ic_mean"] == 0.1
|
||
assert data["generated_at"]
|
||
|
||
|
||
def test_run_factor_analysis_tears_json_error_not_fatal(tmp_path):
|
||
"""build_tears_data 抛错 → 只标注 tears_json_error,IC 结果保留."""
|
||
import pytest
|
||
pytest.importorskip("polars")
|
||
pytest.importorskip("alphalens")
|
||
|
||
import pandas as pd
|
||
from sanguo_factor.analyzer import run_factor_analysis
|
||
|
||
dates = pd.date_range("2024-01-01", periods=4, freq="D", tz="Asia/Shanghai")
|
||
mock_factor_data = pd.DataFrame(index=pd.MultiIndex.from_product(
|
||
[dates, ["AAPL"]], names=["datetime", "asset"]))
|
||
for c in ("factor", "1D", "5D", "10D"):
|
||
mock_factor_data[c] = [0.5] * 4
|
||
|
||
mock_pl_df = MagicMock()
|
||
mock_pl_df.to_pandas.return_value = pd.DataFrame({
|
||
"datetime": [d.isoformat() for d in dates],
|
||
"vt_symbol": ["AAPL"] * 4,
|
||
"ma5": [0.5] * 4,
|
||
})
|
||
|
||
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"), \
|
||
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
|
||
patch("sanguo_factor.tears_data.build_tears_data",
|
||
side_effect=RuntimeError("boom")), \
|
||
patch("sanguo_data.datareader.read_db_daily",
|
||
return_value=_fake_bars("AAPL", range(1, 5))):
|
||
MS.return_value.compute_factors.return_value = mock_pl_df
|
||
MC.return_value = mock_factor_data
|
||
MIC.return_value = pd.DataFrame({"1D": [0.05, 0.04, 0.06, 0.05]}, index=dates)
|
||
|
||
report = run_factor_analysis(
|
||
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
|
||
cfg=MagicMock(), output_dir=str(tmp_path)
|
||
)
|
||
assert report.ic_summary["ma5"]["status"] == "success"
|
||
assert "tears_json_error" in report.ic_summary["ma5"]
|
||
assert report.tears_paths == {}
|
||
|
||
|
||
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 DataFrame(datetime 带时区,与 _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"]
|
||
|
||
|
||
# —— 跨进程进度心跳(2026-08-30:progress 页判活) ——
|
||
|
||
def test_report_progress_writes_file(tmp_path):
|
||
"""task_id 空=不写;正常写 JSON(stage/detail/ts)."""
|
||
import json
|
||
from sanguo_factor.analyzer import _report_progress
|
||
|
||
_report_progress("", str(tmp_path), "data", "不应写入")
|
||
assert not (tmp_path / ".progress").exists()
|
||
|
||
_report_progress("factor_ab12", str(tmp_path), "analyze", "因子 2/5: kmid")
|
||
d = json.loads((tmp_path / "factor_ab12.progress").read_text(encoding="utf-8"))
|
||
assert d["stage"] == "analyze"
|
||
assert d["detail"] == "因子 2/5: kmid"
|
||
assert d["ts"] > 0
|
||
|
||
|
||
def test_report_progress_never_raises(tmp_path, monkeypatch):
|
||
"""进度是锦上添花:IO 故障必须静默(不影响分析主流程)."""
|
||
from sanguo_factor.analyzer import _report_progress
|
||
|
||
monkeypatch.setattr("builtins.open", lambda *a, **k: (_ for _ in ()).throw(OSError("boom")))
|
||
_report_progress("factor_x", str(tmp_path), "data", "x") # 不抛即过
|