Files
qlib/backend/app/domain/entities/market.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

210 lines
8.6 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.
"""市场数据领域实体(Phase 1)。
约定(AGENT.md §8/§9):
- 行情时间用 trade_date;财务数据同时区分 report_date(报告期)与 announce_date(公告日)
- 禁止以 report_date 作可见性依据 —— 只允许 announce_date 已过的数据进入研究
- 复权一律通过独立 AdjustFactor 表达,不在此层偷偷改前/后复权口径
"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
# 常见精度:价格 4 位小数;成交量(股) 2 位;金额(元) 2 位
PRICE_PLACES = Decimal("0.0001")
AMOUNT_PLACES = Decimal("0.01")
# ---- 研究面板数值列白名单(单一事实来源) ----
# 说明:研究装配(quant 层)与持久化列裁剪(infrastructure 层)必须用同一套列名,
# 否则会出现「因子需要某列、仓储却拒绝」的隐蔽不一致。故在此统一定义,两处引用。
# 只影响数值列;symbol / trade_date 恒返回。
DAILY_BAR_NUMERIC_FIELDS: tuple[str, ...] = ("open", "high", "low", "close", "volume", "amount")
DAILY_BASIC_NUMERIC_FIELDS: tuple[str, ...] = (
"close",
"turnover_rate",
"volume_ratio",
"pe",
"pe_ttm",
"pb",
"ps",
"ps_ttm",
"dv_ratio",
"dv_ttm",
"total_share",
"float_share",
"free_share",
"total_mv",
"circ_mv",
)
class Stock(BaseModel):
"""A 股基础信息。symbol 统一为 Tushare 风格,如 600519.SH。"""
model_config = ConfigDict(str_strip_whitespace=True)
symbol: str = Field(pattern=r"^\d{6}\.(SH|SZ|BJ)$", description="如 600519.SH")
name: str
industry: str | None = None
area: str | None = None
market: str | None = Field(default=None, description="主板/创业板/科创板/北交所")
exchange: str | None = None
list_date: date
delist_date: date | None = None
status: str = Field(default="L", description="L 上市 / D 退市 / P 暂停")
class TradingCalendar(BaseModel):
"""交易日历。"""
calendar_date: date
is_open: bool = True
class DailyBar(BaseModel):
"""日线。默认不复权(source=tushare, adjust=none)。
备用源兜底行会标记 source=sina、adjust=qfq(新浪返回前复权价)。
字段统一、可区分、可追溯(AGENT §5.2/§8):研究侧应优先消费
source=tushare 且 adjust=none 的行;新浪行仅在 Tushare 不可用期间作为兜底,
Tushare 恢复后重跑 --resume 会按日覆盖回不复权口径。
"""
symbol: str
trade_date: date
source: str = Field(default="tushare", description="tushare | sina")
adjust: str = Field(default="none", description="none 不复权 | qfq 前复权")
open: Decimal | None = None
high: Decimal | None = None
low: Decimal | None = None
close: Decimal | None = None
volume: Decimal | None = Field(default=None, description="成交量(股)")
amount: Decimal | None = Field(default=None, description="成交额(元)")
@property
def is_complete(self) -> bool:
"""基础行情字段是否齐全(供校验器使用)。"""
return all(
v is not None
for v in (self.open, self.high, self.low, self.close, self.volume, self.amount)
)
class AdjustFactor(BaseModel):
"""复权因子。因子原始口径由数据源决定,必须与数据源文档一致地存取。"""
symbol: str
trade_date: date
factor: Decimal
class DailyBasic(BaseModel):
"""每日指标快照(Tushare daily_basic)—— 估值 / 股息率 / 市值。
时点性说明(防未来函数,AGENT.md §9):
- 本表每一行都是**该交易日收盘后**即可得的横截面指标(dv_ratio 由
「过去 12 个月现金分红 / 当日总市值」逐日重算),属时点值;
- 研究侧一律按 trade_date <= as_of_date 取值,不存在未来信息。
列语义:
- dv_ratio 股息率(%):近 12 个月现金分红 / 总市值 × 100
- dv_ttm 股息率(TTM,%):滚动 12 个月口径
- 两者均可能因**特别分红**出现畸高值(实测 600738 在 2020-01-02 为 37.2%),
使用时建议配合上限过滤。
"""
symbol: str
trade_date: date
close: Decimal | None = Field(default=None, description="当日收盘价(不复权,与 stock_daily 一致)")
turnover_rate: Decimal | None = Field(default=None, description="换手率(%)")
volume_ratio: Decimal | None = Field(default=None, description="量比")
pe: Decimal | None = None
pe_ttm: Decimal | None = None
pb: Decimal | None = None
ps: Decimal | None = None
ps_ttm: Decimal | None = None
dv_ratio: Decimal | None = Field(default=None, description="股息率(%),近 12 个月现金分红/总市值")
dv_ttm: Decimal | None = Field(default=None, description="股息率 TTM(%)")
total_share: Decimal | None = Field(default=None, description="总股本(万股)")
float_share: Decimal | None = Field(default=None, description="流通股本(万股)")
free_share: Decimal | None = Field(default=None, description="自由流通股本(万股)")
total_mv: Decimal | None = Field(default=None, description="总市值(万元)")
circ_mv: Decimal | None = Field(default=None, description="流通市值(万元)")
source: str = Field(default="tushare", description="tushare | sina(新浪不提供本接口)")
class StockNameHistory(BaseModel):
"""股票名称变更历史(Tushare namechange)—— 时点 ST / 风险警示判定的依据。
为什么需要它(实测背景):`stock.name` 只是**最新名称快照**,用它做
`universe.exclude_st` 会把「曾为高股息、后来才变 ST/退市」的标的在**整段历史**里
都排除掉 —— 而那正是「股息陷阱」样本。实测 `600565.SH` 2020 年叫「迪马股份」
(dv_ratio 7.9%,当年高股息候选),2024-05-06 才变「ST迪马」,用最新名称判定
会在 2020 年就把它排除,导致高股息回测收益被高估(对照组实测约 3.70pp)。
一行 = 一个「名称生效区间」:
- `name` 在 `[start_date, end_date]` 内有效(`end_date` 为空表示至今有效)
- `change_reason` 为 tushare 口径:ST / *ST / 撤销ST / 撤销*ST / 从ST变为*ST / 其他
- 时点取值按**生效区间**:`start_date <= as_of <= end_date`(实现口径,无前视:
名称自 `start_date` 起即对市场可见)。`ann_date` 为公告日,仅作留痕/审计,
**不参与**判定 —— 实测数据中 `ann_date` 恒早于或等于 `start_date`,
若改用「公告即改名」会让 `ann_date` 为空的记录整体丢失。
"""
symbol: str
name: str
start_date: date
end_date: date | None = None
ann_date: date | None = None
change_reason: str | None = None
source: str = "tushare"
@property
def is_risk_warned(self) -> bool:
"""该区间名称是否含风险警示(ST / *ST)。"""
return "ST" in self.name.upper()
class FinancialIndicator(BaseModel):
"""核心财务指标(快照)。
可见性红线:研究侧查询一律按 announce_date <= as_of_date 过滤,
report_date 只表示报告所属期间,不代表公开时间。
source 标记数据来源:tushare(首选,字段全)| sina(兜底,字段
可能不全——新浪关键指标只含 eps/roe/gross_margin 等少数项)。
新浪兜底行只在「该股票本地历史与新浪重叠部分两边一致」通过校验后
才导入(见 application/services/data_sync.py),且只补本地缺失键。
研究侧对同一报告期应优先消费 source=tushare 的行。
"""
symbol: str
report_date: date
announce_date: date
source: str = Field(default="tushare", description="tushare | sina")
eps: Decimal | None = None
roe: Decimal | None = None
total_revenue: Decimal | None = None
net_profit: Decimal | None = None
gross_margin: Decimal | None = None
def announced_by(self, as_of_date: date) -> bool:
"""as_of_date(含当日)是否已可见。防未来函数的核心判断。"""
return self.announce_date <= as_of_date
class SyncLog(BaseModel):
"""数据拉取审计记录(AGENT.md §7:来源必须可追踪,禁止静默切换)。"""
source: str
api: str
request_time: datetime = Field(default_factory=datetime.utcnow)
success: bool
failure_reason: str | None = None
row_count: int = 0
data_start: date | None = None
data_end: date | None = None