Files
qlib/backend/tests/test_factor_catalog.py
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

145 lines
5.9 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.
"""M7.1 因子目录测试:factor_definition 落库(幂等 upsert)+ /api/factors 读库 + seed。
repo 测试走 tmp SQLite;API 测试 override get_session 到 tmp sqlite 种子库。
"""
from __future__ import annotations
import pytest
from app.api import deps
from app.domain.entities.factor import FactorDefinition
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import (
SqlAlchemyFactorRepository,
)
from app.main import app
from app.quant.factors import list_factors
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture()
def session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'factor.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
with Session() as s:
yield s
class TestFactorRepository:
def test_upsert_idempotent_and_roundtrip(self, session) -> None:
repo = SqlAlchemyFactorRepository(session)
d = FactorDefinition(
name="test_momentum", description="测试", formula="x", brief="b",
lookback=10, requires=["close", "high"],
)
assert repo.upsert_many([d]) == 1
session.commit()
assert len(repo.list()) == 1
got = repo.get("test_momentum")
assert got is not None and got.requires == ["close", "high"]
# 幂等更新
repo.upsert_many([d.model_copy(update={"description": "更新"})])
session.commit()
assert repo.get("test_momentum").description == "更新"
assert len(repo.list()) == 1
def test_seed_from_registry(self, session) -> None:
repo = SqlAlchemyFactorRepository(session)
defs = [FactorDefinition.from_registry_def(d) for d in list_factors()]
assert repo.upsert_many(defs) == len(defs)
session.commit()
names = {f.name for f in repo.list()}
assert len(names) == len(defs)
# 与注册表一致
assert names == {d.name for d in list_factors()}
assert repo.get("momentum_60").direction == "higher_is_better"
@pytest.fixture()
def client(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
def _session_override():
with Session() as s:
yield s
app.dependency_overrides[deps.get_session] = _session_override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
class TestFactorsApi:
def test_list_seeds_and_reads_db(self, client) -> None:
resp = client.get("/api/factors")
assert resp.status_code == 200
rows = resp.json()
assert isinstance(rows, list) and len(rows) >= 9
first = next(r for r in rows if r["name"] == "momentum_60")
# DB 契约源字段齐全(与前端 FactorMeta 匹配 + version)
assert set(first.keys()) >= {
"name", "description", "brief", "formula", "frequency",
"lookback", "direction", "requires", "version",
}
assert "close" in first["requires"]
def test_list_matches_registry_after_seed(self, client) -> None:
"""seed 后目录 == 代码注册表集合(无额外未知项)。"""
client.get("/api/factors") # 首次访问触发 seed
client.get("/api/factors") # 幂等:二次访问不报错、不重复
resp = client.get("/api/factors")
names = {r["name"] for r in resp.json()}
assert names == {d.name for d in list_factors()}
class TestRegistrySyncRegression:
"""回归:表非空时也必须补齐「注册表有、库里没有」的因子。
历史 bug:seed 只在表为空时触发,导致 `dividend_yield` 等后加的因子永远不进目录
(实测真实库表里 9 条、注册表 11 条),前端因子下拉与归档说明都取不到它们。
"""
def test_missing_registry_factor_is_seeded_when_table_not_empty(self, client, tmp_path) -> None:
from sqlalchemy import text
# 1) 先正常读一次 → 目录完整(含股息率因子)
full = {r["name"] for r in client.get("/api/factors").json()}
assert "dividend_yield" in full, "注册表里的股息率因子必须出现在目录中"
# 2) 人为删除一行,复现「表非空但缺因子」的历史状态
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
with engine.begin() as conn:
conn.execute(text("DELETE FROM factor_definition WHERE name = 'dividend_yield'"))
rows = client.get("/api/factors").json()
# 3) 再读 → 缺失因子被当场补齐
names = {r["name"] for r in rows}
assert "dividend_yield" in names, "表非空时也必须补齐缺失的注册表因子"
assert names == {d.name for d in list_factors()}
# 4) 用户登记的自定义因子元数据不被覆盖/删除(只补不删)
Session = sessionmaker(bind=engine, expire_on_commit=False)
with Session() as s2:
SqlAlchemyFactorRepository(s2).upsert_many(
[
FactorDefinition(
name="my_custom_factor",
description="自定义因子",
formula="x",
brief="b",
frequency="daily",
lookback=5,
direction="higher_is_better",
requires=[],
version="1",
)
]
)
s2.commit()
names2 = {r["name"] for r in client.get("/api/factors").json()}
assert "my_custom_factor" in names2, "只补不删:自定义因子必须保留"
assert "dividend_yield" in names2