汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
175 lines
6.9 KiB
Python
175 lines
6.9 KiB
Python
"""复权折算(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)
|