Files
qlib/backend/tests/test_adjust_factor_prices.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

175 lines
6.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.
"""复权折算(v3 §20.5)测试:SQL 侧按 adjust_factor 折算价格列。
覆盖:
- hfq:price × factor(后复权)
- qfq:price × factor / 该股最新 factor(前复权,归一)
- volume/amount 不折算
- 因子缺失按 1.0 兜底,并由 count_price_adjust_gaps 如实统计
- hfq 与 qfq 的**收益率序列完全一致**(仅差一个常数倍),这是复权口径自洽性的关键断言
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from app.domain.entities.market import AdjustFactor, DailyBar
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
_D = [date(2024, 6, 3), date(2024, 6, 4), date(2024, 6, 5)]
def _bar(day: date, close: str, symbol: str = "600519.SH") -> DailyBar:
return DailyBar(
symbol=symbol,
trade_date=day,
source="tushare",
adjust="none",
open=Decimal(close),
high=Decimal(close),
low=Decimal(close),
close=Decimal(close),
volume=Decimal("1000"),
amount=Decimal("100000"),
)
def _factor(day: date, factor: str, symbol: str = "600519.SH") -> AdjustFactor:
return AdjustFactor(symbol=symbol, trade_date=day, factor=Decimal(factor), source="tushare")
@pytest.fixture
def session(tmp_path) -> Session:
engine = create_engine(f"sqlite:///{tmp_path / 'adjprice.db'}", future=True)
Base.metadata.create_all(engine)
with Session(engine) as s:
repo = SqlAlchemyDailyBarRepository(s)
# 100 → 除权前 1.0;110 → 之后因子 1.1(模拟一次分红/送股)
repo.upsert_many([_bar(_D[0], "100"), _bar(_D[1], "110"), _bar(_D[2], "121")])
s.add_all(
[
_factor_model(_D[0], "1.0"),
_factor_model(_D[1], "1.1"),
_factor_model(_D[2], "1.1"),
]
)
s.commit()
yield s
def _factor_model(day: date, factor: str, symbol: str = "600519.SH"):
from app.infrastructure.persistence.sqlalchemy.models.market import AdjustFactorModel
return AdjustFactorModel(symbol=symbol, trade_date=day, factor=Decimal(factor))
def _stream(session, price_adjust: str, columns=("close",)) -> dict[date, tuple]:
repo = SqlAlchemyDailyBarRepository(session)
rows = list(
repo.stream_range_many_columns(
["600519.SH"], _D[0], _D[2], list(columns),
adjust="none", price_adjust=price_adjust,
)
)
return {date.fromisoformat(r[1]): r[2:] for r in rows}
class TestAdjustedPrices:
def test_hfq_multiplies_by_factor(self, session) -> None:
out = _stream(session, "hfq")
assert out[_D[0]] == (100.0,) # 100 × 1.0
assert out[_D[1]] == pytest.approx((121.0,)) # 110 × 1.1
assert out[_D[2]] == pytest.approx((133.1,)) # 121 × 1.1
def test_qfq_normalizes_by_latest_factor(self, session) -> None:
out = _stream(session, "qfq")
assert out[_D[0]] == pytest.approx((100 / 1.1,)) # 100 × 1.0 / 1.1
assert out[_D[1]] == pytest.approx((110.0,)) # 110 × 1.1 / 1.1
assert out[_D[2]] == pytest.approx((121.0,))
def test_volume_and_amount_not_adjusted(self, session) -> None:
out = _stream(session, "hfq", columns=("close", "volume", "amount"))
close, volume, amount = out[_D[2]]
assert close == pytest.approx(133.1)
assert volume == 1000.0 and amount == 100000.0 # 不随复权缩放
def test_none_is_raw(self, session) -> None:
out = _stream(session, "none")
assert out[_D[0]] == (100.0,) and out[_D[2]] == (121.0,)
def test_hfq_and_qfq_give_identical_returns(self, session) -> None:
hfq, qfq = _stream(session, "hfq"), _stream(session, "qfq")
for a, b in zip(_D, _D[1:], strict=False):
r_hfq = hfq[b][0] / hfq[a][0]
r_qfq = qfq[b][0] / qfq[a][0]
assert r_hfq == pytest.approx(r_qfq, rel=1e-12)
def test_invalid_price_adjust_rejected(self, session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
with pytest.raises(ValueError, match="复权"):
list(
repo.stream_range_many_columns(
["600519.SH"], _D[0], _D[2], ["close"], price_adjust="bad"
)
)
class TestAdjustGapReporting:
def test_missing_factor_falls_back_and_is_reported(self, session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
# 追加一行无因子的行情(模拟 25 只蓝筹 2020-2022 缺因子的情形)
repo.upsert_many([_bar(date(2024, 6, 6), "200")])
session.commit()
out = dict(
(date.fromisoformat(r[1]), r[2])
for r in repo.stream_range_many_columns(
["600519.SH"], _D[0], date(2024, 6, 6), ["close"], price_adjust="hfq"
)
)
assert out[date(2024, 6, 6)] == 200.0 # 缺因子 → 系数 1.0(未折算)
total, missing = repo.count_price_adjust_gaps(
["600519.SH"], _D[0], date(2024, 6, 6)
)
assert total == 4 and missing == 1
def test_no_gap_when_all_covered(self, session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
total, missing = repo.count_price_adjust_gaps(["600519.SH"], _D[0], _D[2])
assert total == 3 and missing == 0
def test_empty_symbols(self, session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
assert repo.count_price_adjust_gaps([], _D[0], _D[2]) == (0, 0)
class TestQfqMissingFactor:
"""qfq 缺因子行不得被错误缩放。
回归用例:`coalesce(factor,1)/latest` 会把缺口行缩放到 `1/latest`
(latest=5 → 121/5=24.2,凭空 −80% 单日跌幅),正确写法必须把 COALESCE
放在最外层,使缺口行保持原始价(口径:缺失因子按 1.0 兜底、不折算)。
"""
def test_qfq_gap_row_is_not_rescaled(self, session) -> None:
from app.infrastructure.persistence.sqlalchemy.models.market import AdjustFactorModel
session.query(AdjustFactorModel).delete() # 清掉 fixture 的 1.0/1.1/1.1
session.add_all([_factor_model(_D[0], "5.0"), _factor_model(_D[1], "5.0")])
session.commit() # 第 3 天无因子 → 缺口
out = _stream(session, "qfq")
# 有因子的行:price × factor / max(factor=5.0) → 等于原始价
assert out[_D[0]] == pytest.approx((100.0,))
assert out[_D[1]] == pytest.approx((110.0,))
# 缺口行:保持原始价(若被错误归一则为 121/5 = 24.2)
assert out[_D[2]] == pytest.approx((121.0,))
repo = SqlAlchemyDailyBarRepository(session)
total, missing = repo.count_price_adjust_gaps(["600519.SH"], _D[0], _D[2])
assert (total, missing) == (3, 1)