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 逐类验证归档页)。
This commit is contained in:
@@ -17,6 +17,29 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
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。"""
|
||||
@@ -78,6 +101,73 @@ class AdjustFactor(BaseModel):
|
||||
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):
|
||||
"""核心财务指标(快照)。
|
||||
|
||||
|
||||
@@ -42,10 +42,90 @@ class FactorSpec(BaseModel):
|
||||
weight: float = Field(default=1.0, gt=0)
|
||||
|
||||
|
||||
class SelectionSpec(BaseModel):
|
||||
"""选股方式。MVP:按加权因子得分取 Top N 等权。"""
|
||||
class ConditionSpec(BaseModel):
|
||||
"""结构化选股条件(回测与选股共用)。
|
||||
|
||||
top_n: int = Field(default=30, ge=1, le=1000)
|
||||
字段域:
|
||||
- static.*:股票基础字段(industry / market / area / exchange / status…)
|
||||
- 行情/技术字段:close / ma20 / ma60 / volume 及全部已注册因子名(momentum_60 等),
|
||||
以及每日指标列(dv_ratio / dv_ttm / pe / pb / total_mv …)
|
||||
- fundamental.*:财务字段(eps / roe / total_revenue / net_profit / gross_margin),
|
||||
仅取 announce_date <= as_of 的最新已公告值(防未来函数)
|
||||
|
||||
右操作数取 value(字面量)或 ref(另一字段名),二者二选一。
|
||||
|
||||
定义位置说明:本模型被 ResearchSpec(回测)与 SelectionQuery(选股)共用,
|
||||
故落在 research.py(被 selection.py 依赖的低层模块),selection.py 再 re-export,
|
||||
避免循环导入。
|
||||
"""
|
||||
|
||||
field: str
|
||||
op: str = Field(pattern="^(gt|gte|lt|lte|eq|ne|in|not_in)$")
|
||||
value: float | int | str | list | None = None
|
||||
ref: str | None = None # 与另一字段比较(如 close vs ma60)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_operand(self) -> ConditionSpec:
|
||||
if self.value is None and self.ref is None:
|
||||
raise ValueError("value 与 ref 必须提供一个")
|
||||
if self.value is not None and self.ref is not None:
|
||||
raise ValueError("value 与 ref 只能提供一个")
|
||||
if self.op in ("in", "not_in") and not isinstance(self.value, list):
|
||||
raise ValueError("in/not_in 的 value 必须是列表")
|
||||
return self
|
||||
|
||||
|
||||
class SelectionSpec(BaseModel):
|
||||
"""选股方式(两级截断)。
|
||||
|
||||
口径(用户案例「选 n 只 → 持仓前 x 只」):
|
||||
- `top_n` = n:**候选池**大小。universe ∩ conditions 过滤后,按复合因子分降序取前 n
|
||||
只 → 这就是「择股条件选出来的股数」(写入 selection_history)。
|
||||
- `hold_top_x` = x:**实际持仓数**,取候选池前 x 只等权。x 必须 ≤ n;
|
||||
另受「池内实际可买股票数」约束(过滤/缺数据会让实际池子小于 n)。
|
||||
None → 等于 top_n(此时与旧行为一致:选出多少就持多少)。
|
||||
|
||||
`allow_substitute` 与 `defer_buy` 决定「买不进」时的处理(两者互斥,只能选一个):
|
||||
- `allow_substitute=True`(**默认**,保持历史语义不变):从 n 名**之外**继续往下找
|
||||
可买标的补足 x 只 —— 引擎既有行为,见 v3 §20.3 的 Signal↔Fill 测试。
|
||||
- `defer_buy=True`(本项目「只买选出来的前 x 只」口径,推荐显式开启):
|
||||
**不替补**,把这只股票的买单**顺延到之后第一个可成交的交易日**(涨停/停牌解除后
|
||||
按当日收盘价买入);到下一次调仓仍未成交则作废,未投入资金留作现金。
|
||||
- 两者都 False:意图被拒后直接放弃,资金留现金(不替补也不顺延)。
|
||||
|
||||
默认值刻意保持「向后兼容」:既有 Strategy / Experiment 的语义不因本次扩展而静默改变
|
||||
(AGENT.md §35)。高股息案例在前端与 spec 中显式设置 defer_buy=True。
|
||||
"""
|
||||
|
||||
top_n: int = Field(default=30, ge=1, le=1000, description="n:候选池大小")
|
||||
hold_top_x: int | None = Field(
|
||||
default=None, ge=1, le=1000, description="x:实际持仓数;None → = top_n"
|
||||
)
|
||||
allow_substitute: bool = Field(
|
||||
default=True,
|
||||
description="True(默认,历史语义):从 n 名之外替补补足;False:不引入计划外标的",
|
||||
)
|
||||
defer_buy: bool = Field(
|
||||
default=False,
|
||||
description="True:买不进(涨停/停牌)时顺延到之后首个可成交日的收盘买入",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_x_le_n(self) -> SelectionSpec:
|
||||
if self.hold_top_x is not None and self.hold_top_x > self.top_n:
|
||||
raise ValueError(
|
||||
f"hold_top_x(持仓 x={self.hold_top_x})不能大于 top_n(候选池 n={self.top_n})"
|
||||
)
|
||||
if self.allow_substitute and self.defer_buy:
|
||||
raise ValueError(
|
||||
"allow_substitute=True(往下替补)与 defer_buy=True(顺延买入)语义互斥,只能选一个"
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def x(self) -> int:
|
||||
"""实际持仓目标数(未显式给 x 时等于 n)。"""
|
||||
return self.hold_top_x if self.hold_top_x is not None else self.top_n
|
||||
|
||||
|
||||
class PortfolioSpec(BaseModel):
|
||||
@@ -63,14 +143,22 @@ class PortfolioSpec(BaseModel):
|
||||
|
||||
|
||||
class CostSpec(BaseModel):
|
||||
"""交易成本模型(单边比例)。
|
||||
"""交易成本模型。
|
||||
|
||||
buy = commission + slippage;sell = commission + stamp_tax + slippage。
|
||||
买:commission(≥ min_commission)+ slippage
|
||||
卖:commission(≥ min_commission)+ stamp_tax + slippage
|
||||
|
||||
`min_commission` 为**单笔最低佣金**(A 股常见 5 元)。默认 0.0 = 不启用,
|
||||
以保持既有回测数值不变(AGENT.md §35);高股息等实盘贴近场景建议显式设 5.0。
|
||||
注意:最低佣金对**小额单**影响显著,x 越多、单笔越小,成本占比越高。
|
||||
"""
|
||||
|
||||
commission_rate: float = Field(default=0.0003, ge=0, le=0.01)
|
||||
stamp_tax_rate: float = Field(default=0.0005, ge=0, le=0.01)
|
||||
slippage_rate: float = Field(default=0.001, ge=0, le=0.05)
|
||||
min_commission: float = Field(
|
||||
default=0.0, ge=0, le=100.0, description="单笔最低佣金(元);0 = 不启用"
|
||||
)
|
||||
benchmark: str = Field(default="000300.SH", description="对照基准指数代码")
|
||||
|
||||
|
||||
@@ -80,12 +168,37 @@ class ResearchSpec(BaseModel):
|
||||
type: str = Field(default="backtest", pattern="^(factor_test|backtest)$")
|
||||
universe: UniverseSpec = UniverseSpec()
|
||||
price_adjustment: str = Field(
|
||||
default="none", pattern="^(none|qfq)$",
|
||||
description="研究行情口径:none 不复权(默认)/ qfq 前复权(result 与 config_snapshot 中显式)",
|
||||
default="none", pattern="^(none|qfq|hfq)$",
|
||||
description=(
|
||||
"研究行情口径:none 不复权(默认)/ qfq 前复权 / hfq 后复权。"
|
||||
"qfq/hfq 基于 adjust_factor 折算(v3 §20.5);结果与 config_snapshot 中显式记录。"
|
||||
),
|
||||
)
|
||||
factors: list[FactorSpec] = Field(min_length=1)
|
||||
conditions: list[ConditionSpec] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"选股过滤条件(AND,可选):universe 之后、因子排序之前执行。"
|
||||
"字段域同 SelectionQuery.conditions(static.* / 行情列 / 已注册因子 / "
|
||||
"fundamental.*),回测与 /api/selections 共用同一求值器(v2 §25 一致性)。"
|
||||
),
|
||||
)
|
||||
selection: SelectionSpec = SelectionSpec()
|
||||
rebalance: str = Field(default="monthly", pattern="^(weekly|monthly)$")
|
||||
selection_interval_months: int | None = Field(
|
||||
default=None, ge=1, le=60,
|
||||
description=(
|
||||
"m:择股间隔(月)。None → 每次调仓都重新择股(等价于 rebalance 频率)。"
|
||||
"择股日 = 起始月锚定,月序号 % m == 0 的月份的首个交易日。"
|
||||
),
|
||||
)
|
||||
rebalance_interval_months: int | None = Field(
|
||||
default=None, ge=1, le=60,
|
||||
description=(
|
||||
"y:调仓间隔(月)。None → 等于 selection_interval_months(未给则按 rebalance 频率)。"
|
||||
"y < m 时池子在下一次择股前保持不变(结果中会标注池子陈旧)。"
|
||||
),
|
||||
)
|
||||
period: tuple[date, date]
|
||||
costs: CostSpec = CostSpec()
|
||||
portfolio: PortfolioSpec = PortfolioSpec()
|
||||
@@ -105,6 +218,33 @@ class ResearchSpec(BaseModel):
|
||||
raise ValueError("factors 存在重复因子名")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_intervals(self) -> ResearchSpec:
|
||||
m = self.selection_interval_months
|
||||
y = self.rebalance_interval_months
|
||||
# 未给 m 却给了 y:语义不完整(y 无锚点可依)→ 明确拒绝而非猜
|
||||
if m is None and y is not None and y != 1:
|
||||
raise ValueError(
|
||||
"只给了 rebalance_interval_months(y) 而没给 selection_interval_months(m):"
|
||||
"请同时给出 m,否则无法确定择股日集合"
|
||||
)
|
||||
if m is not None and y is not None and y < m and y != 1:
|
||||
# 允许但不静默:池子会在多个调仓日复用(陈旧),交由结果 unimplemented 标注
|
||||
return self
|
||||
return self
|
||||
|
||||
@property
|
||||
def effective_selection_months(self) -> int | None:
|
||||
"""实际择股间隔(月);None 表示「每次调仓都择股」。"""
|
||||
return self.selection_interval_months
|
||||
|
||||
@property
|
||||
def effective_rebalance_months(self) -> int | None:
|
||||
"""实际调仓间隔(月);None 表示按 rebalance 频率(周/月)。"""
|
||||
if self.rebalance_interval_months is not None:
|
||||
return self.rebalance_interval_months
|
||||
return self.selection_interval_months
|
||||
|
||||
|
||||
# ---------- 回测结果 ----------
|
||||
|
||||
@@ -145,6 +285,7 @@ class Trade(BaseModel):
|
||||
entry_date: date
|
||||
exit_date: date
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
return_pct: float
|
||||
@@ -153,6 +294,7 @@ class Trade(BaseModel):
|
||||
class Position(BaseModel):
|
||||
date: date
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
weight: float
|
||||
|
||||
|
||||
@@ -161,6 +303,7 @@ class RankedPick(BaseModel):
|
||||
|
||||
date: date
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
rank: int
|
||||
score: float
|
||||
|
||||
@@ -170,16 +313,41 @@ class ActionRecord(BaseModel):
|
||||
|
||||
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
|
||||
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
|
||||
|
||||
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
|
||||
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
|
||||
"""
|
||||
|
||||
date: date
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
signal: str = Field(pattern="^(BUY|SELL)$")
|
||||
filled: bool
|
||||
reject_reason: str | None = None
|
||||
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
|
||||
|
||||
|
||||
class SymbolCurve(BaseModel):
|
||||
"""个股收益率趋势曲线 + 该股买卖点标注(回测结果可视化用)。
|
||||
|
||||
`points[].value` 语义:该股**持仓期间**的累计收益率(%,以建仓日收盘为 0% 基准,
|
||||
按日复利)。只在该股被持有的交易日落点(未持有期间不落点,以压缩结果体积);
|
||||
建仓当日会补一个基准点,保证买卖点标注总能在曲线上取到数值。多段持仓以累计值
|
||||
连乘衔接,读图时以 marks 中的 BUY/SELL 区分各段持仓区间。
|
||||
|
||||
`marks` 为该股实际成交(BUY/SELL fill)的日期与价格,与 `signal_history`
|
||||
中 filled=True 的记录一致(v3 §20.3 的成交口径)。
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
points: list[CurvePoint] = Field(default_factory=list)
|
||||
marks: list[ActionRecord] = Field(default_factory=list)
|
||||
final_return_pct: float = Field(
|
||||
default=0.0, description="该股持仓期累计收益率(%,多段持仓连乘)"
|
||||
)
|
||||
|
||||
|
||||
class BacktestResult(BaseModel):
|
||||
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
|
||||
|
||||
@@ -199,12 +367,27 @@ class BacktestResult(BaseModel):
|
||||
fills: list[ActionRecord] = Field(
|
||||
default_factory=list, description="实际成交(signal_history 中 filled=True 的子集)"
|
||||
)
|
||||
symbol_curves: list[SymbolCurve] = Field(
|
||||
default_factory=list,
|
||||
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
|
||||
)
|
||||
turnover_pct: float
|
||||
unimplemented: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="本结果中未建模的约束(AGENT §24:必须显式标注,禁止假装支持)",
|
||||
)
|
||||
config_snapshot: dict = Field(default_factory=dict, description="复现用完整配置快照")
|
||||
archive_meta: dict = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"归档元数据(由 experiment_archive 在落库时写入):curves_stored / "
|
||||
"curves_total / truncated / budget_chars / budget_bytes / result_chars / "
|
||||
"result_bytes。用于说明归档是否因体积预算被裁剪(AGENT §24 不静默)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
SymbolCurve.model_rebuild()
|
||||
|
||||
|
||||
# ---------- 因子测试结果 ----------
|
||||
@@ -289,3 +472,23 @@ class ExperimentRecord(BaseModel):
|
||||
data_version: str | None = None
|
||||
job_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ExperimentSummary(BaseModel):
|
||||
"""归档列表项(**不含 result_json**)。
|
||||
|
||||
列表接口一次可能返回上百条归档,而 `result_json` 是 MEDIUMTEXT(完整存档后
|
||||
单条可达数 MB):为避免把上百 MB 拉进内存,仓储的列表查询只取元数据列,
|
||||
`result_bytes` 由 SQL 的字符长度函数(MySQL CHAR_LENGTH / SQLite length)
|
||||
在库侧算出,不取回大字段本身。
|
||||
"""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
spec_json: str
|
||||
summary_text: str | None = None
|
||||
code_version: str | None = None
|
||||
data_version: str | None = None
|
||||
job_id: str | None = None
|
||||
created_at: datetime | None = None
|
||||
result_bytes: int = 0 # 归档 JSON 的字符数(SQL 侧计算,不拉大字段)
|
||||
|
||||
@@ -18,7 +18,7 @@ from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from app.domain.entities.research import FactorSpec, UniverseSpec
|
||||
from app.domain.entities.research import ConditionSpec, FactorSpec, UniverseSpec # noqa: F401
|
||||
|
||||
|
||||
class SelectionQuery(BaseModel):
|
||||
@@ -26,8 +26,11 @@ class SelectionQuery(BaseModel):
|
||||
|
||||
universe: UniverseSpec = UniverseSpec()
|
||||
price_adjustment: str = Field(
|
||||
default="none", pattern="^(none|qfq)$",
|
||||
description="行情口径:none 不复权(默认)/ qfq 前复权(结果 config_snapshot 中显式)",
|
||||
default="none", pattern="^(none|qfq|hfq)$",
|
||||
description=(
|
||||
"行情口径:none 不复权(默认)/ qfq 前复权 / hfq 后复权(按 adjust_factor 折算)。"
|
||||
"与回测(ResearchSpec.price_adjustment)同一口径域,结果 config_snapshot 中显式记录"
|
||||
),
|
||||
)
|
||||
# 研究时点:None → 引擎用 <= 今天最近可用交易日;显式给历史日期即做历史选股
|
||||
as_of: date | None = Field(
|
||||
@@ -64,37 +67,21 @@ class SelectionQuery(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class ConditionSpec(BaseModel):
|
||||
"""结构化选股条件(M6.2 使用)。
|
||||
|
||||
field 域:
|
||||
- static.*:股票基础字段(industry / market / area / exchange / status…)
|
||||
- 行情/技术字段:close / ma20 / ma60 / volume 及全部已注册因子名(momentum_60 等)
|
||||
- fundamental.*:财务字段(eps / roe / total_revenue / net_profit / gross_margin),
|
||||
仅取 announce_date <= as_of 的最新已公告值(防未来函数)
|
||||
右操作数取 value(字面量)或 ref(另一字段名),二者二选一。
|
||||
"""
|
||||
|
||||
field: str
|
||||
op: str = Field(pattern="^(gt|gte|lt|lte|eq|ne|in|not_in)$")
|
||||
value: float | int | str | list | None = None
|
||||
ref: str | None = None # 与另一字段比较(如 close vs ma60)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_operand(self) -> ConditionSpec:
|
||||
if self.value is None and self.ref is None:
|
||||
raise ValueError("value 与 ref 必须提供一个")
|
||||
if self.value is not None and self.ref is not None:
|
||||
raise ValueError("value 与 ref 只能提供一个")
|
||||
if self.op in ("in", "not_in") and not isinstance(self.value, list):
|
||||
raise ValueError("in/not_in 的 value 必须是列表")
|
||||
return self
|
||||
# ConditionSpec 的实体定义已上移到 research.py(ResearchSpec 回测条件与
|
||||
# SelectionQuery 选股条件共用同一模型)。此处 re-export:`selection.ConditionSpec`
|
||||
# 就是 research.ConditionSpec 这**同一个类对象**,因此两侧 isinstance 判断一致。
|
||||
# (见文件顶部 import 处的引用)
|
||||
|
||||
|
||||
class SelectionCandidate(BaseModel):
|
||||
"""单只候选股(v2 §14.3/§21.1)。"""
|
||||
"""单只候选股(v2 §14.3/§21.1)。
|
||||
|
||||
`name` 为展示增强字段(可选默认 None):由 SelectionService 用已装配的股票池
|
||||
统一回填,引擎/选股算法本身不感知名称 —— 避免在每个出口零散 join。
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
name: str | None = Field(default=None, description="股票名称(展示用)")
|
||||
rank: int
|
||||
score: float
|
||||
factor_values: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import date, datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.domain.entities.research import (
|
||||
ConditionSpec,
|
||||
CostSpec,
|
||||
FactorSpec,
|
||||
PortfolioSpec,
|
||||
@@ -26,10 +27,19 @@ class StrategyDefinition(BaseModel):
|
||||
description: str = ""
|
||||
spec_type: str = Field(default="backtest", pattern="^(backtest|factor_test)$")
|
||||
universe: UniverseSpec = UniverseSpec()
|
||||
price_adjustment: str = Field(default="none", pattern="^(none|qfq)$")
|
||||
price_adjustment: str = Field(default="none", pattern="^(none|qfq|hfq)$")
|
||||
factors: list[FactorSpec] = Field(min_length=1)
|
||||
selection: SelectionSpec = SelectionSpec()
|
||||
rebalance: str = Field(default="monthly", pattern="^(weekly|monthly)$")
|
||||
conditions: list[ConditionSpec] = Field(
|
||||
default_factory=list, description="选股过滤条件(与 ResearchSpec.conditions 一致)"
|
||||
)
|
||||
selection_interval_months: int | None = Field(
|
||||
default=None, ge=1, le=60, description="m:择股间隔(月)"
|
||||
)
|
||||
rebalance_interval_months: int | None = Field(
|
||||
default=None, ge=1, le=60, description="y:调仓间隔(月);缺省 = m"
|
||||
)
|
||||
costs: CostSpec = CostSpec()
|
||||
portfolio: PortfolioSpec = PortfolioSpec()
|
||||
version: str = "1"
|
||||
@@ -44,8 +54,11 @@ class StrategyDefinition(BaseModel):
|
||||
universe=self.universe,
|
||||
price_adjustment=self.price_adjustment,
|
||||
factors=self.factors,
|
||||
conditions=self.conditions,
|
||||
selection=self.selection,
|
||||
rebalance=self.rebalance,
|
||||
selection_interval_months=self.selection_interval_months,
|
||||
rebalance_interval_months=self.rebalance_interval_months,
|
||||
period=period,
|
||||
costs=self.costs,
|
||||
portfolio=self.portfolio,
|
||||
|
||||
@@ -13,8 +13,10 @@ from app.domain.entities.index import IndexWeight
|
||||
from app.domain.entities.market import (
|
||||
AdjustFactor,
|
||||
DailyBar,
|
||||
DailyBasic,
|
||||
FinancialIndicator,
|
||||
Stock,
|
||||
StockNameHistory,
|
||||
TradingCalendar,
|
||||
)
|
||||
|
||||
@@ -30,7 +32,13 @@ class MarketDataProvider(Protocol):
|
||||
|
||||
name: str
|
||||
|
||||
def get_stock_basic(self) -> list[Stock]: ...
|
||||
def get_stock_basic(self, list_status: str = "L") -> list[Stock]:
|
||||
"""股票基础信息。list_status: L=上市 / D=退市 / P=暂停上市(tushare 口径)。
|
||||
|
||||
默认 "L" 保持既有行为不变;研究侧的**幸存者偏差**修正依赖 "D"(已退市)
|
||||
——退市股的历史行情与 delist_date 缺失会让回测系统性高估收益。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_trade_cal(self, start: date, end: date) -> list[TradingCalendar]: ...
|
||||
|
||||
@@ -56,3 +64,23 @@ class MarketDataProvider(Protocol):
|
||||
|
||||
供 index_weight 同步与历史成分 Universe(v3 §9)。"""
|
||||
|
||||
def get_name_changes(self, start: date, end: date) -> list[StockNameHistory]:
|
||||
"""区间内全市场股票名称变更(时点 ST / 风险警示判定依据)。
|
||||
|
||||
仅 Tushare 提供;备用源应抛 `DataSourceNotSupported`(不得静默返回空列表,
|
||||
否则名称历史会「看起来同步成功但一条没有」,导致时点 ST 静默降级)。
|
||||
"""
|
||||
...
|
||||
|
||||
def get_daily_basic(self, trade_date: date) -> list[DailyBasic]:
|
||||
"""单交易日全市场每日指标(估值 / 股息率 / 市值)。
|
||||
|
||||
按**交易日**整表拉取(Tushare daily_basic 支持 trade_date 参数一次返回全市场),
|
||||
幂等键 (symbol, trade_date)。
|
||||
|
||||
实现约定:
|
||||
- 只能返回该 trade_date 当天已可得的值(dv_ratio 为时点值,天然无未来函数);
|
||||
- 不支持本接口的数据源(如新浪)必须抛 DataSourceNotSupported,
|
||||
禁止返回空列表冒充成功(AGENT.md §7:禁止静默切换)。
|
||||
"""
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.entities.research import ExperimentRecord, JobRecord
|
||||
from app.domain.entities.research import ExperimentRecord, ExperimentSummary, JobRecord
|
||||
|
||||
|
||||
class JobRepository(Protocol):
|
||||
@@ -23,6 +23,35 @@ class JobRepository(Protocol):
|
||||
class ExperimentRepository(Protocol):
|
||||
def save(self, experiment: ExperimentRecord) -> ExperimentRecord: ...
|
||||
|
||||
def upsert(self, experiment: ExperimentRecord) -> ExperimentRecord:
|
||||
"""按 id 插入或**覆盖**(重建归档用:同一 id 已存在时替换整行)。
|
||||
|
||||
与 `save` 的区别:`save` 只插入(同 id 会主键冲突),用于新归档;
|
||||
`upsert` 用于灾备/重建场景 —— 例如从 Job 副本重建一条被删除的历史归档,
|
||||
此时归档 id 必须保持不变(外部链接、对比记录仍指向它)。
|
||||
"""
|
||||
...
|
||||
|
||||
def get(self, experiment_id: str) -> ExperimentRecord | None: ...
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[ExperimentRecord]: ...
|
||||
|
||||
def list_filtered(
|
||||
self,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[ExperimentSummary]:
|
||||
"""按 kind 精确过滤 + q 模糊过滤(id / 因子名 / summary_text,大小写不敏感)。
|
||||
|
||||
只返回元数据(ExperimentSummary,**不含 result_json**),过滤与分页在
|
||||
SQL 层完成;排序为 created_at 倒序 + id 倒序(同秒创建时保证分页稳定)。
|
||||
"""
|
||||
|
||||
def count_filtered(self, *, kind: str | None = None, q: str | None = None) -> int:
|
||||
"""与 `list_filtered` 同口径的过滤总数(列表接口 X-Total-Count 用)。"""
|
||||
|
||||
def delete(self, experiment_id: str) -> bool:
|
||||
"""删除归档本身,返回是否存在。**不触碰**关联的 Job 记录。"""
|
||||
|
||||
@@ -13,8 +13,10 @@ from typing import Protocol
|
||||
from app.domain.entities.market import (
|
||||
AdjustFactor,
|
||||
DailyBar,
|
||||
DailyBasic,
|
||||
FinancialIndicator,
|
||||
Stock,
|
||||
StockNameHistory,
|
||||
SyncLog,
|
||||
TradingCalendar,
|
||||
)
|
||||
@@ -76,6 +78,69 @@ class AdjustFactorRepository(Protocol):
|
||||
def get_range(self, symbol: str, start: date, end: date) -> list[AdjustFactor]: ...
|
||||
|
||||
|
||||
class DailyBasicRepository(Protocol):
|
||||
"""每日指标(估值 / 股息率 / 市值)仓储。
|
||||
|
||||
幂等键 (symbol, trade_date)。研究侧一律按 trade_date <= as_of 取用,
|
||||
禁止用未来时点的指标回填历史(AGENT.md §9)。
|
||||
"""
|
||||
|
||||
def upsert_many(self, rows: Sequence[DailyBasic]) -> int: ...
|
||||
|
||||
def get_range(self, symbol: str, start: date, end: date) -> list[DailyBasic]: ...
|
||||
|
||||
def get_range_many(
|
||||
self,
|
||||
symbols: Sequence[str],
|
||||
start: date,
|
||||
end: date,
|
||||
) -> list[DailyBasic]:
|
||||
"""批量区间查询(接口对齐 DailyBarRepository)。"""
|
||||
|
||||
def stream_range_many_columns(
|
||||
self,
|
||||
symbols: Sequence[str],
|
||||
start: date,
|
||||
end: date,
|
||||
columns: Sequence[str],
|
||||
) -> Iterator[tuple]:
|
||||
"""流式返回 symbol, trade_date(iso str), *数值列(float)。
|
||||
|
||||
研究装配面板专用(避免 Decimal / ORM 对象全量物化)。
|
||||
"""
|
||||
|
||||
def latest_date(self) -> date | None:
|
||||
"""本表全局最新交易日(增量同步断点)。"""
|
||||
|
||||
def missing_dates(self, start: date, end: date) -> list[date]:
|
||||
"""区间内「交易日历为开市、但本表无任何行」的交易日(补齐用)。"""
|
||||
|
||||
|
||||
class StockNameHistoryRepository(Protocol):
|
||||
"""股票名称变更历史仓储(时点 ST / 风险警示判定)。
|
||||
|
||||
用于把 `universe.exclude_st` 从「最新名称快照」升级为**时点名称**:
|
||||
研究侧必须按 as_of 取当时生效的名称,否则「曾为高股息、后来才 ST/退市」的
|
||||
股息陷阱样本会被整段排除(实测影响约 3.70pp 收益)。
|
||||
"""
|
||||
|
||||
def upsert_many(self, rows: Sequence[StockNameHistory]) -> int: ...
|
||||
|
||||
def names_as_of(
|
||||
self, symbols: Sequence[str], as_of: date
|
||||
) -> dict[str, str]:
|
||||
"""返回 as_of 时点生效的名称(缺该股记录则不返回该键,由调用方回退最新名称)。"""
|
||||
|
||||
def name_spans(self, symbols: Sequence[str]) -> dict[str, list[tuple[date, date | None, str]]]:
|
||||
"""返回各股票的名称生效区间列表 [(start, end, name)](批量时点查询复用)。"""
|
||||
|
||||
def count_rows(self) -> int:
|
||||
"""本表总行数(未同步时用于降级回退最新名称)。"""
|
||||
|
||||
def namechange_dates(self) -> tuple[date | None, date | None]:
|
||||
"""已同步的 (最早 start_date, 最晚 start_date)(增量断点)。"""
|
||||
|
||||
|
||||
class FinancialRepository(Protocol):
|
||||
def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int: ...
|
||||
|
||||
|
||||
Reference in New Issue
Block a user