diff --git a/sanguo_factor/__init__.py b/sanguo_factor/__init__.py index d8bb735..b1909aa 100644 --- a/sanguo_factor/__init__.py +++ b/sanguo_factor/__init__.py @@ -1,3 +1,4 @@ """Sanguo factor module for vnpy alpha strategies.""" from . import library # noqa: F401 (triggers _register_all to register built-in factors) from . import alpha_datasets # noqa: F401 挂载 Alpha101/158(导入即注册) +from . import fundamental_library # noqa: F401 财务因子 P0 批(导入即注册) diff --git a/sanguo_factor/batch_eval.py b/sanguo_factor/batch_eval.py index b578031..5187a05 100644 --- a/sanguo_factor/batch_eval.py +++ b/sanguo_factor/batch_eval.py @@ -22,6 +22,8 @@ from .registry import get_factor from . import eval_store from .metrics import summarize_factor from .fast_ops import register_fast_ops +from . import fundamental_library # noqa: F401 财务因子 import 即注册(alpha_datasets 同模式) +from .fundamental_adapter import build_fundamental_features, DEFAULT_STATIC_DIR def _forward_return_matrices(close_wide: pd.DataFrame, periods=(1, 5, 10)) -> dict[int, pd.DataFrame]: @@ -43,9 +45,15 @@ def run_batch_eval( cfg=None, progress_cb=None, vnpy_db_override: str | None = None, + fund_data_dir: str | None = None, run_id: str | None = None, ) -> dict: - """跑一轮批量评估,结果增量写入 eval_db,返回摘要.""" + """跑一轮批量评估,结果增量写入 eval_db,返回摘要. + + fund_data_dir: 财务静态域根目录(None → cfg.data_paths["static_dir"] → + NAS 默认 /volume1/stock/sanguo_vnpy_v2/data/static);仅当因子列表含 + category="fundamental" 时才读取并 join(量价批零开销)。 + """ from vnpy.alpha.dataset.utility import calculate_by_expression # Register fast polars operators (idempotent) @@ -63,6 +71,17 @@ def run_batch_eval( alpha_df = bars.select(["vt_symbol", "datetime", "open", "high", "low", "close", "volume", "turnover", "vwap"]) + # 财务因子批: 特征列 join 进 alpha_df(表达式引擎按列名直接消费;bars 释放前完成) + fund_names = [n for n in factor_names + if (get_factor(n) or {}).get("category") == "fundamental"] + if fund_names: + static_dir = fund_data_dir or cfg.data_paths.get("static_dir") or DEFAULT_STATIC_DIR + feat_df = build_fundamental_features( + codes=bars["vt_symbol"].unique().to_list(), + start=start, end=end, data_dir=static_dir, + trading_dates=bars["datetime"].unique().sort(), + ) + alpha_df = alpha_df.join(feat_df, on=["vt_symbol", "datetime"], how="left") # Pre-compute per-symbol warmup cutoff dates (bar_idx >= WARMUP_BARS 的首日) # 用于替代 per-factor hash join,改为 pivot 后 pandas 广播掩码置 NaN cutoffs = ( diff --git a/tests/factor/test_fundamental_batch.py b/tests/factor/test_fundamental_batch.py new file mode 100644 index 0000000..03a0364 --- /dev/null +++ b/tests/factor/test_fundamental_batch.py @@ -0,0 +1,101 @@ +# tests/factor/test_fundamental_batch.py +"""财务因子批端到端: 合成行情库 + 合成静态域 → batch_eval 出 IC 指标.""" +import sqlite3 +import sys, os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))) + +import pytest + +from sanguo_factor import alpha_datasets # 挂载量价因子 +from sanguo_factor import fundamental_library # noqa: F401 挂载财务因子(import 即注册) +from sanguo_factor.batch_eval import run_batch_eval +from sanguo_factor import eval_store + +@pytest.fixture(autouse=True) +def _ensure_fundamental_registered(): + """其它测试模块清空 _REGISTRY 后只重挂 alpha/builtin(顺序依赖前科), + 这里逐测试幂等重注册财务因子,保证本模块与顺序无关.""" + fundamental_library._register_all() + + +_DDL = """ +CREATE TABLE dbbardata( + symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT, + volume REAL, turnover REAL, open_interest REAL, + open_price REAL, high_price REAL, low_price REAL, close_price REAL) +""" + +_STOCKS = [("600000", "SSE"), ("000001", "SZSE"), ("300001", "SZSE")] + + +@pytest.fixture(scope="module") +def db(tmp_path_factory): + """3 只 × 2022-06~2024-01 合成日线(与合成财务域同代码;趋势构造截面差异).""" + alpha_datasets.mount_all() + p = tmp_path_factory.mktemp("fb") + db = str(p / "qt.db") + conn = sqlite3.connect(db) + conn.execute(_DDL) + import pandas as pd + days = pd.bdate_range("2022-06-01", "2024-01-05") + drift = {"600000": 0.0012, "000001": -0.0006, "300001": 0.0004} + base = {"600000": 10.0, "000001": 5.0, "300001": 20.0} + for i, day in enumerate(days): + d = day.strftime("%Y-%m-%d") + rows = [] + for sym, ex in _STOCKS: + px = base[sym] * (1.0 + drift[sym]) ** i + rows.append((sym, ex, f"{d} 00:00:00", "d", 100.0, px * 100.0, 0, + px, px, px, px)) + conn.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows) + conn.commit() + conn.close() + return db + + +def test_fundamental_end_to_end(db, synthetic_static, tmp_path): + eval_db = str(tmp_path / "fund_eval.db") + out = run_batch_eval( + factor_names=["fund_roe_ttm", "fund_tacc", "fund_ep_ttm", + "fund_forecast_type"], + start="2023-02-01", end="2023-12-31", + eval_db=eval_db, label="fund_t", cfg=None, vnpy_db_override=db, + fund_data_dir=synthetic_static, + ) + assert out["factors_done"] == 4 + assert out["errors"] == [] + for name in ("fund_roe_ttm", "fund_tacc", "fund_ep_ttm", "fund_forecast_type"): + m = eval_store.get_detail(eval_db, out["run_id"], name)["metrics"] + assert "error" not in m, f"{name}: {m.get('error')}" + # 截面 3 只 → 逐日 rank IC 可算(预告因子 2023-07-15 后才有效, count 少但非零) + assert isinstance(m["1"]["ic_mean"], float) or m["1"]["ic_mean"] is None + assert m["1"]["count"] >= 0 + + +def test_mixed_batch_price_plus_fundamental(db, synthetic_static, tmp_path): + """量价+财务同批混跑: 白名单 join 只在含财务因子时发生,互不干扰.""" + eval_db = str(tmp_path / "mix_eval.db") + out = run_batch_eval( + factor_names=["ma_20", "fund_sue_np"], + start="2023-02-01", end="2023-06-30", + eval_db=eval_db, label="mix_t", cfg=None, vnpy_db_override=db, + fund_data_dir=synthetic_static, + ) + assert out["errors"] == [] + rows = eval_store.get_rows(eval_db, out["run_id"]) + assert {r["factor"] for r in rows} == {"ma_20", "fund_sue_np"} + # SUE 在 2023-06-30 前可见的报告期均无完整 8 期窗 → 全 NaN → eliminated 非 error + m = eval_store.get_detail(eval_db, out["run_id"], "fund_sue_np")["metrics"] + assert "error" not in m + + +def test_pure_price_batch_untouched(db, tmp_path): + """纯量价批不传静态域照常跑(回归: 未新增强依赖).""" + eval_db = str(tmp_path / "px_eval.db") + out = run_batch_eval( + factor_names=["roc_5"], start="2023-02-01", end="2023-03-31", + eval_db=eval_db, label="px_t", cfg=None, vnpy_db_override=db, + ) + assert out["errors"] == [] + assert out["factors_done"] == 1