汇总三轮未提交的开发(每轮均在本机 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 逐类验证归档页)。
584 lines
27 KiB
Python
584 lines
27 KiB
Python
"""策略说明书生成器:ResearchSpec / StrategyDefinition → 一句话说明 + 计算公式 + 步骤 + 注意事项。
|
||
|
||
纯函数模块:无 IO、无 DB、不调用引擎,因而可被 API 复用(含**未保存**的策略即时预览)
|
||
并被单测直接覆盖。
|
||
|
||
为什么要有它(需求背景):
|
||
- 策略必须「能被解释」:前端回测页要在一句话里说清「用什么因子/条件、取多少只、
|
||
多久择股一次、多久调仓一次、什么口径」(AGENT.md §26 不暴露引擎内部配置);
|
||
- 保存策略时说明不得为空:说明由 spec **真实推导**,而非让调用方手写或让 Agent 编造
|
||
(AGENT.md §24 禁止假装支持、§28 Agent 只走 Tool)。
|
||
|
||
两条纪律:
|
||
1. 所有文本只能来自 spec 与因子注册表元数据(`FactorDef.description/formula/brief/direction`,
|
||
AGENT.md §22 因子元数据要求);元数据缺失、字段未知、约束未建模一律进 `warnings`,
|
||
**绝不臆造公式**,也绝不把未建模的东西写成已支持。
|
||
2. 条件渲染必须与 `app.quant.selection` 的真实求值语义一致(`_eval_condition` 的字段域与
|
||
运算符分支),步骤必须与 `app.quant.local_engine` 的真实行为一致。两处都以代码为准。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Mapping, Sequence
|
||
from datetime import date
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.domain.entities.market import (
|
||
DAILY_BAR_NUMERIC_FIELDS,
|
||
DAILY_BASIC_NUMERIC_FIELDS,
|
||
)
|
||
from app.domain.entities.research import ResearchSpec
|
||
from app.domain.entities.strategy import StrategyDefinition
|
||
from app.quant.factors import FactorDef, FactorError, get_factor
|
||
|
||
# StrategyDefinition 没有回测区间字段(区间在回测时补全),但 to_research_spec 的
|
||
# period 是必填的 —— 用一个不可能被误读为真实区间的占位区间满足校验,
|
||
# 真正展示时以 `period_known=False` 走占位文案(不抛错、也不假装知道区间)。
|
||
_PLACEHOLDER_PERIOD: tuple[date, date] = (date(1900, 1, 1), date(1900, 1, 2))
|
||
_PERIOD_UNKNOWN_TEXT = "(回测区间未指定:策略定义本身不含 period,展开回测后才确定)"
|
||
|
||
# 运算符的展示文本(键集合由 ConditionSpec 的 pattern 保证)
|
||
_OP_TEXT: dict[str, str] = {
|
||
"gt": ">",
|
||
"gte": ">=",
|
||
"lt": "<",
|
||
"lte": "<=",
|
||
"eq": "==",
|
||
"ne": "!=",
|
||
"in": "∈",
|
||
"not_in": "∉",
|
||
}
|
||
|
||
# 与 selection.build_condition_fields 一致的技术派生字段(非行情原列,需滚动窗口)
|
||
_TECH_DERIVED: tuple[str, ...] = ("ma20", "ma60")
|
||
_STATIC_PREFIX = "static."
|
||
_FUNDAMENTAL_PREFIX = "fundamental."
|
||
|
||
_ADJUST_TEXT: dict[str, str] = {
|
||
"none": "不复权(原始收盘价;现金分红未计入收益,除权日价格下移会被计为亏损)",
|
||
"qfq": "前复权(价格 = 原始价 × adjust_factor,以前复权归一)",
|
||
"hfq": "后复权(价格 = 原始价 × adjust_factor,现金分红按因子隐含再投资)",
|
||
}
|
||
|
||
|
||
class StrategyDoc(BaseModel):
|
||
"""策略说明书(前端 `<pre>` 直接展示 formula;summary 兼作策略默认 description)。"""
|
||
|
||
summary: str = Field(description="一句话功能说明(由 spec 真实推导)")
|
||
formula: str = Field(description="计算公式(多行文本:打分/过滤/截断/成本与成交口径)")
|
||
steps: list[str] = Field(default_factory=list, description="执行步骤(按回测引擎真实行为)")
|
||
warnings: list[str] = Field(
|
||
default_factory=list,
|
||
description="需要注意的点(未建模约束 / 未知因子 / 口径近似,诚实标注)",
|
||
)
|
||
|
||
|
||
def describe_strategy(
|
||
spec: ResearchSpec | StrategyDefinition,
|
||
*,
|
||
factor_meta: Mapping[str, FactorDef] | None = None,
|
||
) -> StrategyDoc:
|
||
"""生成策略说明书。
|
||
|
||
`spec`:ResearchSpec(回测页参数,含 period)或 StrategyDefinition(策略库资产,
|
||
无 period)。后者经 `to_research_spec(period=占位)` 展开 —— 区间缺失只影响展示文案
|
||
(走 `_PERIOD_UNKNOWN_TEXT`),不影响其余推导,更不抛错。
|
||
|
||
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
|
||
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
|
||
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
|
||
"""
|
||
rspec, period_text, period_known = _coerce_spec(spec)
|
||
warnings: list[str] = []
|
||
factors = _describe_factors(rspec, factor_meta, warnings)
|
||
conditions = _describe_conditions(rspec, warnings)
|
||
|
||
summary = _build_summary(rspec, factors)
|
||
formula = _build_formula(rspec, factors, conditions, period_text)
|
||
steps = _build_steps(rspec, period_text)
|
||
warnings.extend(_collect_warnings(rspec, period_known))
|
||
return StrategyDoc(summary=summary, formula=formula, steps=steps, warnings=warnings)
|
||
|
||
|
||
# ---------- 入参归一化 ----------
|
||
|
||
|
||
def _coerce_spec(spec) -> tuple[ResearchSpec, str, bool]:
|
||
"""归一化为 (ResearchSpec, 区间展示文本, 区间是否已知)。
|
||
|
||
StrategyDefinition 无 period:按需求用占位区间展开并如实标记「区间未知」,
|
||
而不是抛错(策略库列表/详情页也要能看说明)。
|
||
"""
|
||
if isinstance(spec, StrategyDefinition):
|
||
return spec.to_research_spec(period=_PLACEHOLDER_PERIOD), _PERIOD_UNKNOWN_TEXT, False
|
||
if isinstance(spec, ResearchSpec):
|
||
start, end = spec.period
|
||
return spec, f"{start.isoformat()} ~ {end.isoformat()}", True
|
||
raise TypeError(
|
||
"describe_strategy 只接受 ResearchSpec 或 StrategyDefinition,"
|
||
f"收到 {type(spec).__name__}"
|
||
)
|
||
|
||
|
||
# ---------- 因子元数据 ----------
|
||
|
||
|
||
def _lookup_factor(
|
||
name: str, factor_meta: Mapping[str, FactorDef] | None
|
||
) -> FactorDef | None:
|
||
"""按 name 取因子元数据:先查传入覆盖,再查内置注册表;都取不到返回 None。"""
|
||
if factor_meta is not None and name in factor_meta:
|
||
return factor_meta[name]
|
||
try:
|
||
defn, _fn = get_factor(name)
|
||
except FactorError:
|
||
return None
|
||
return defn
|
||
|
||
|
||
def _describe_factors(
|
||
spec: ResearchSpec,
|
||
factor_meta: Mapping[str, FactorDef] | None,
|
||
warnings: list[str],
|
||
) -> list[tuple[str, float, FactorDef | None]]:
|
||
"""返回 [(因子名, 权重, 元数据|None)];元数据缺失的因子记入 warnings。"""
|
||
out: list[tuple[str, float, FactorDef | None]] = []
|
||
for fs in spec.factors:
|
||
defn = _lookup_factor(fs.name, factor_meta)
|
||
if defn is None:
|
||
warnings.append(
|
||
f"未知因子 {fs.name}(权重 {fs.weight:g}):未在因子注册表中登记,"
|
||
"无法给出含义/公式/方向;回测执行时会直接报错(不会静默忽略该因子)"
|
||
)
|
||
out.append((fs.name, float(fs.weight), defn))
|
||
return out
|
||
|
||
|
||
def _direction_text(defn: FactorDef | None) -> str:
|
||
if defn is None:
|
||
return "方向未知"
|
||
return "越高越好" if defn.direction == "higher_is_better" else "越低越好"
|
||
|
||
|
||
# ---------- summary ----------
|
||
|
||
|
||
def _build_summary(
|
||
spec: ResearchSpec, factors: list[tuple[str, float, FactorDef | None]]
|
||
) -> str:
|
||
"""一句话说明:股票池 + 排序依据 + 两级截断 + 择股/调仓周期 + 口径与成本。"""
|
||
sel = spec.selection
|
||
x = sel.x
|
||
pool = _universe_text(spec.universe)
|
||
cond_text = f",并先通过 {len(spec.conditions)} 条过滤条件" if spec.conditions else ""
|
||
rank_text = _rank_text(factors)
|
||
cycle_select, cycle_rebal = _cycle_text(spec)
|
||
hold_text = (
|
||
f"先筛出前 {sel.top_n} 只候选池、再持有其中前 {x} 只等权"
|
||
if x != sel.top_n
|
||
else f"按复合分取出前 {sel.top_n} 只等权持有"
|
||
)
|
||
# 买不进的处置直接决定「实际持有什么」,必须进一句话说明(不能只写在公式里)
|
||
hold_text += f"({_buy_mode_short(sel)})"
|
||
adjust = spec.price_adjustment
|
||
adjust_text = {"none": "不复权", "qfq": "前复权", "hfq": "后复权"}[adjust]
|
||
type_prefix = "" if spec.type == "backtest" else "单因子测试(非组合回测):"
|
||
return (
|
||
f"{type_prefix}{pool}{cond_text},{rank_text},{hold_text},"
|
||
f"{cycle_select}、{cycle_rebal},{adjust_text}口径、按调仓日收盘价成交"
|
||
f"(含佣金 {_pct(spec.costs.commission_rate)}/印花税 {_pct(spec.costs.stamp_tax_rate)}"
|
||
f"/滑点 {_pct(spec.costs.slippage_rate)})。"
|
||
)
|
||
|
||
|
||
def _universe_text(universe) -> str:
|
||
"""股票池口径(只写**实际生效**的过滤;未建模项只进 warnings,不在这里冒充生效)。"""
|
||
if universe.index_code:
|
||
base = f"{universe.index_code} 指数成分(按择股日历史成分)"
|
||
else:
|
||
base = "全市场"
|
||
filters: list[str] = []
|
||
if universe.symbols:
|
||
filters.append(f"限定白名单 {len(universe.symbols)} 只")
|
||
if universe.exclude_st:
|
||
filters.append("剔除名称含 ST 的股票")
|
||
if universe.min_listing_days:
|
||
filters.append(f"上市满 {universe.min_listing_days} 个自然日")
|
||
# 括号内只放结束句号外的短过滤项,避免出现「(…(…)…)」的嵌套括号
|
||
return f"{base}({','.join(filters)})" if filters else base
|
||
|
||
|
||
def _rank_text(factors: list[tuple[str, float, FactorDef | None]]) -> str:
|
||
"""排序依据文案:单因子按该因子方向,多因子按复合分(截面 z-score 加权和)。"""
|
||
names = "、".join(name for name, _w, _defn in factors)
|
||
if len(factors) == 1:
|
||
name, _w, defn = factors[0]
|
||
label = defn.description if defn is not None else name
|
||
# 方向未知时不猜方向:按「复合分从高到低」描述(复合分本身就是降序取头部)
|
||
lower = defn is not None and defn.direction == "lower_is_better"
|
||
direction = "从低到高" if lower else "从高到低"
|
||
return f"按 {label}{direction}排序(因子 {name})"
|
||
return f"按多因子复合分(截面 z-score 加权和)从高到低排序(因子 {names})"
|
||
|
||
|
||
def _cycle_text(spec: ResearchSpec) -> tuple[str, str]:
|
||
"""(择股周期文案, 调仓周期文案),与 ResearchSpec.effective_* / local_engine 一致。"""
|
||
m = spec.effective_selection_months
|
||
y = spec.effective_rebalance_months
|
||
select_text = f"每 {m} 个月重新择股" if m is not None else "每次调仓都重新择股"
|
||
if y is not None:
|
||
rebal_text = f"每 {y} 个月调仓"
|
||
else:
|
||
rebal_text = "按月度调仓" if spec.rebalance == "monthly" else "按周度调仓"
|
||
return select_text, rebal_text
|
||
|
||
|
||
def _months_text(months: int | None, fallback: str) -> str:
|
||
"""「每 n 个月」/ 回退文案(月数为 None 时用 fallback),避免散落的 % 格式化。"""
|
||
return f"每 {months} 个月" if months is not None else fallback
|
||
|
||
|
||
# ---------- formula ----------
|
||
|
||
|
||
def _build_formula(
|
||
spec: ResearchSpec,
|
||
factors: list[tuple[str, float, FactorDef | None]],
|
||
conditions: list[str],
|
||
period_text: str,
|
||
) -> str:
|
||
lines: list[str] = [f"# 策略计算公式(由 spec 真实推导;回测区间:{period_text})", ""]
|
||
lines += _formula_scoring(spec, factors)
|
||
lines += _formula_conditions(conditions)
|
||
lines += _formula_truncation(spec)
|
||
lines += _formula_costs(spec)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _formula_scoring(
|
||
spec: ResearchSpec, factors: list[tuple[str, float, FactorDef | None]]
|
||
) -> list[str]:
|
||
lines = [
|
||
"【1】候选池打分(每个择股日在股票池内做横截面标准化,加权求和)",
|
||
" z_f,i = (x_f,i - mean_i(x_f)) / std_i(x_f) # 因子 f 在当日截面上的 z-score",
|
||
" d_f = -1(方向 lower_is_better)/ +1(higher_is_better)",
|
||
" score_i = Σ_f w_f × d_f × z_f,i # 按 score 降序取候选池",
|
||
" 权重与因子明细:",
|
||
]
|
||
for name, weight, defn in factors:
|
||
if defn is None:
|
||
lines.append(f" - {name} w = {weight:g} 方向:未知(元数据缺失,见 warnings)")
|
||
continue
|
||
lines += [
|
||
f" - {name} w = {weight:g} 方向:{_direction_text(defn)}"
|
||
f"({defn.direction}) 频率:{defn.frequency} 回看:{defn.lookback} 个交易日"
|
||
f" 输入列:{', '.join(defn.requires)}",
|
||
f" 含义:{defn.description}",
|
||
f" 公式:{defn.formula}",
|
||
]
|
||
if defn.brief:
|
||
lines.append(f" 用法:{defn.brief}")
|
||
lines += [
|
||
" 说明:因子值缺失(NaN)的标的当日不可评分,不进入候选池;",
|
||
" 单只股票池(截面不足 2 只)时 z-score 退化为 0,仍保留为可候选值。",
|
||
"",
|
||
]
|
||
return lines
|
||
|
||
|
||
def _formula_conditions(conditions: list[str]) -> list[str]:
|
||
lines = ["【2】过滤条件(全部 AND;universe 之后、因子排序之前逐股求值)"]
|
||
if not conditions:
|
||
lines += [" (无)候选池仅由 universe + 因子排序决定。"]
|
||
else:
|
||
lines += [f" {text}" for text in conditions]
|
||
lines += [
|
||
" 字段域(与选股 /api/selections 使用同一求值器,逐字段语义如下):",
|
||
" · static.<字段>:股票基础信息(industry / market / area / exchange / status …)",
|
||
" · fundamental.<字段>:announce_date <= 择股日 的最新**已公告**值(防未来函数)",
|
||
" · 其它:当日行情列(close/open/high/low/volume/amount)、ma20 / ma60、",
|
||
" 每日指标列(dv_ratio / pe / pb / total_mv …)或已注册因子",
|
||
" 缺失值语义:字段取不到(None/NaN)时,除 != 外一律判为「未通过」;",
|
||
" != 在缺失时按「不等于字面量」通过(真实求值器行为)。",
|
||
"",
|
||
]
|
||
return lines
|
||
|
||
|
||
def _formula_truncation(spec: ResearchSpec) -> list[str]:
|
||
sel = spec.selection
|
||
x = sel.x
|
||
m = spec.effective_selection_months
|
||
y = spec.effective_rebalance_months
|
||
lines = [
|
||
"【3】两级截断与择股 / 调仓周期",
|
||
f" 候选池:n = {sel.top_n}(按 score 降序取前 n;池内可评分股票不足 n 时以实际数量为准)",
|
||
f" 实际持仓:x = {x}"
|
||
+ ("(未显式给出 hold_top_x,取 x = n)" if sel.hold_top_x is None else ""),
|
||
f" 择股日:{_months_text(m, '每次调仓日')}(月序号锚定回测起始月)",
|
||
f" 调仓日:{_months_text(y, '每月' if spec.rebalance == 'monthly' else '每周')}的首个交易日",
|
||
" 资金分配:" + (
|
||
f"等权(可用现金 / 目标持仓数);设置了单股上限 {spec.portfolio.max_position_pct:.0%},"
|
||
"超出部分按上限分配并留作现金"
|
||
if spec.portfolio.max_position_pct is not None
|
||
else "等权(可用现金 / 目标持仓数)"
|
||
),
|
||
" 买不进的处理:" + _buy_fallback_text(sel),
|
||
"",
|
||
]
|
||
return lines
|
||
|
||
|
||
def _buy_fallback_text(sel) -> str:
|
||
"""与 local_engine._rebalance/_fill_pending 的真实分支一一对应。"""
|
||
if sel.defer_buy:
|
||
return (
|
||
"不替补(顺延买入):涨停/停牌买不进的标的把买单顺延到之后首个可成交交易日、"
|
||
"按当日收盘价买入;到下一次调仓仍未成交则作废,资金留作现金"
|
||
)
|
||
if sel.allow_substitute:
|
||
return (
|
||
"替补买入:从候选池之外继续按复合分往下找可买标的,补足 x 只"
|
||
"(池内被跳过的标的也会在 signal_history 中留下未成交原因)"
|
||
)
|
||
return "不替补也不顺延:买不进则放弃该标的,资金留作现金"
|
||
|
||
|
||
def _buy_mode_short(sel) -> str:
|
||
"""一句话说明里的买不进处置短语(与 _buy_fallback_text 同源、同分支)。"""
|
||
if sel.defer_buy:
|
||
return "买不进则顺延买入"
|
||
if sel.allow_substitute:
|
||
return "买不进时从候选池之外替补"
|
||
return "买不进则放弃"
|
||
|
||
|
||
def _target_text(sel) -> str:
|
||
"""调仓日「目标名单」如何产生 —— 与 local_engine._rebalance 的分支一一对应。"""
|
||
if sel.allow_substitute:
|
||
# 引擎从 current_ranked(全市场排序)往下扫描,而不是 picks[:x]
|
||
return (
|
||
f"替补买入:按复合分排序从头扫描(含候选池之外),"
|
||
f"买得进的依次入选直到凑满 {sel.x} 只"
|
||
)
|
||
return f"取候选池前 {sel.x} 只为目标名单({_buy_mode_short(sel)})"
|
||
|
||
|
||
def _universe_step(spec: ResearchSpec) -> str:
|
||
"""股票池装配步骤(与 service._load_daily / universe.filter_stocks 的真实口径一致)。
|
||
|
||
时点必须写准:universe 过滤只在**回测起始日**执行一次(filter_stocks(as_of=start)),
|
||
之后每个择股日仅按**当日名称**重判 exclude_st;不能写成「每日对全池重新过滤」。
|
||
"""
|
||
u = spec.universe
|
||
filters = ["已退市标的排除"]
|
||
if u.exclude_st:
|
||
filters.append("名称含 ST 的标的排除")
|
||
if u.min_listing_days:
|
||
filters.append(f"上市不足 {u.min_listing_days} 个自然日的排除")
|
||
if u.symbols:
|
||
filters.append(f"限定为白名单 {len(u.symbols)} 只")
|
||
if u.index_code:
|
||
filters.append(f"限定为 {u.index_code} 的当日历史成分")
|
||
text = "股票池:在回测起始日按 universe 过滤一次(" + ",".join(filters) + ")。"
|
||
if u.exclude_st:
|
||
text += (
|
||
"此后每个择股日再按**当日名称**判定 exclude_st"
|
||
"(名称变更历史未同步时回退最新名称快照)。"
|
||
)
|
||
return text
|
||
|
||
|
||
def _formula_costs(spec: ResearchSpec) -> list[str]:
|
||
c = spec.costs
|
||
min_comm = (
|
||
f"最低佣金 {c.min_commission:g} 元/笔"
|
||
if c.min_commission > 0
|
||
else "最低佣金 0(未启用)"
|
||
)
|
||
return [
|
||
"【4】成本与成交价口径",
|
||
f" 行情口径:{_ADJUST_TEXT[spec.price_adjustment]}",
|
||
" 成交时点:调仓日收盘(调仓在当日收盘生效,自次日起计收益)",
|
||
f" 买入:成交价 = 收盘价 × (1 + 滑点 {_pct(c.slippage_rate)});"
|
||
f"佣金 = max(投入资金 × {_pct(c.commission_rate)}, {min_comm}),从投入资金中扣除",
|
||
f" 卖出:成交金额 = 数量 × 收盘价 × (1 - 滑点 {_pct(c.slippage_rate)});"
|
||
f"费用 = max(成交金额 × {_pct(c.commission_rate)}, {min_comm})"
|
||
f" + 成交金额 × 印花税 {_pct(c.stamp_tax_rate)}(仅卖出)",
|
||
f" 初始资金:{spec.initial_capital:,.0f} 元;对照基准:{c.benchmark}(仅展示,不参与交易)",
|
||
]
|
||
|
||
|
||
# ---------- steps ----------
|
||
|
||
|
||
def _build_steps(spec: ResearchSpec, period_text: str) -> list[str]:
|
||
"""按 local_engine.TopKBacktestRunner.run 的真实流程写(顺序、时序口径都以代码为准)。"""
|
||
sel = spec.selection
|
||
m = spec.effective_selection_months
|
||
y = spec.effective_rebalance_months
|
||
warmup = (
|
||
"数据装配:自回测起始日往前 300 个自然日起装配行情/指标(覆盖因子预热窗口);"
|
||
f"回测区间 {period_text}。所有截面只使用 <= 当日的数据(无未来函数)。"
|
||
)
|
||
cond_step = (
|
||
f"先跑 {len(spec.conditions)} 条过滤条件(AND)"
|
||
if spec.conditions
|
||
else "无过滤条件,直接按复合分排序"
|
||
)
|
||
steps = [
|
||
warmup,
|
||
_universe_step(spec),
|
||
(
|
||
f"择股日(每 {m} 个月的首个交易日,锚定起始月)" if m is not None
|
||
else "择股日(未配置 m:每次调仓日都重新择股)"
|
||
)
|
||
+ f":{cond_step},再按复合分降序取前 "
|
||
+ f"{sel.top_n} 只作为候选池,逐条写入 selection_history(含 rank 与 score)。",
|
||
(
|
||
f"调仓日(每 {y} 个月的首个交易日)" if y is not None
|
||
else ("调仓日(每月首个交易日)" if spec.rebalance == "monthly" else "调仓日(每周首个交易日)")
|
||
)
|
||
+ ":在**当日收盘**执行调仓。",
|
||
"调仓第一步:卖出全部当前持仓(跌停、无行情/停牌时不成交并保留该持仓到下一次调仓),"
|
||
"逐笔记入 signal_history。",
|
||
f"调仓第二步:{_target_text(sel)};"
|
||
"把可用现金等权分配到目标名单,按收盘价 + 滑点买入,佣金(含最低佣金)从投入资金中扣除。",
|
||
"买入约束:涨停(收盘价/上一有效收盘 >= 板块涨停系数)或当日无行情(停牌)时不可买入。",
|
||
"收益结算:每日先用**上一交易日收盘持仓**结算个股收益"
|
||
"(建仓当日不计收益、卖出当日仍有收益),因此调仓日收盘生效、次日起计收益。",
|
||
"期末:不强制平仓 —— 最后一次调仓之后的持仓一直保留,按最后一个交易日收盘估值;"
|
||
"Trade 只记录「已卖出」的完整买卖往返。",
|
||
]
|
||
if spec.type != "backtest":
|
||
steps.insert(0, "本 spec 的 type 为 factor_test:只计算因子 IC/分层收益,"
|
||
"不执行选股、调仓与成本(下列步骤仅在转为 backtest 后生效)。")
|
||
return steps
|
||
|
||
|
||
# ---------- conditions ----------
|
||
|
||
|
||
def _describe_conditions(spec: ResearchSpec, warnings: list[str]) -> list[str]:
|
||
"""渲染过滤条件为可读表达式,并对未识别字段给出 warnings(不静默)。"""
|
||
out: list[str] = []
|
||
for cond in spec.conditions:
|
||
out.append(_render_condition(cond))
|
||
for field in (cond.field, cond.ref):
|
||
if field and not _is_known_field(field):
|
||
warnings.append(
|
||
f"条件字段 {field} 未识别(不在行情列 / ma20 / ma60 / 每日指标列 / "
|
||
"已注册因子 / static.* / fundamental.* 之内):回测装配列时会直接报错,"
|
||
"该条件不会生效"
|
||
)
|
||
return out
|
||
|
||
|
||
def _render_condition(cond) -> str:
|
||
"""单条条件 → 可读表达式(语义与 selection._eval_condition 一一对应)。
|
||
|
||
`ref` 非空时右操作数是**另一个字段**(同一股票同一日的取值),否则是字面量;
|
||
in/not_in 的 value 是列表。
|
||
"""
|
||
op = _OP_TEXT.get(cond.op, cond.op)
|
||
if cond.ref is not None:
|
||
right = cond.ref
|
||
elif cond.op in ("in", "not_in"):
|
||
items = cond.value if isinstance(cond.value, Sequence) else [cond.value]
|
||
right = "[" + ", ".join(_fmt_value(v) for v in items) + "]"
|
||
else:
|
||
right = _fmt_value(cond.value)
|
||
return f"{cond.field} {op} {right}"
|
||
|
||
|
||
def _is_known_field(field: str) -> bool:
|
||
"""字段是否属于已知域(与 selection.condition_needed_columns 的识别范围一致)。"""
|
||
if field.startswith((_STATIC_PREFIX, _FUNDAMENTAL_PREFIX)):
|
||
return True
|
||
if field in DAILY_BAR_NUMERIC_FIELDS or field in DAILY_BASIC_NUMERIC_FIELDS:
|
||
return True
|
||
if field in _TECH_DERIVED:
|
||
return True
|
||
try:
|
||
get_factor(field)
|
||
except FactorError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _fmt_value(value) -> str:
|
||
if isinstance(value, bool): # bool 是 int 子类,先判
|
||
return "true" if value else "false"
|
||
if isinstance(value, (int, float)):
|
||
return f"{value:g}"
|
||
if isinstance(value, str):
|
||
return f'"{value}"'
|
||
return str(value)
|
||
|
||
|
||
def _pct(rate: float) -> str:
|
||
return f"{rate * 100:g}%"
|
||
|
||
|
||
# ---------- warnings ----------
|
||
|
||
|
||
def _collect_warnings(spec: ResearchSpec, period_known: bool) -> list[str]:
|
||
"""诚实标注「未建模 / 近似 / 口径依赖」项(AGENT.md §24:禁止假装支持)。"""
|
||
warnings: list[str] = []
|
||
if not period_known:
|
||
warnings.append(
|
||
"入参为策略定义(无回测区间):说明中的区间为占位文本,"
|
||
"实际回测区间在展开为 ResearchSpec 时确定"
|
||
)
|
||
if spec.type != "backtest":
|
||
warnings.append(
|
||
f"spec.type={spec.type}:这是因子测试而非组合回测,"
|
||
"择股/调仓/成本/成交价等说明不适用于本次执行"
|
||
)
|
||
if spec.universe.exclude_st:
|
||
warnings.append(
|
||
"exclude_st 依据股票名称判定:若未同步名称变更历史(stock_name_history,"
|
||
"python -m app.cli.sync namechange),则回退**最新名称快照**,"
|
||
"「曾为高股息、后来才变 ST」的标的会被整段排除(收益可能被高估);"
|
||
"实际口径见回测结果 config_snapshot.price_basis.name_basis"
|
||
)
|
||
if spec.universe.exclude_suspended:
|
||
warnings.append(
|
||
"exclude_suspended=True 但**停牌数据未建模**:universe 过滤不会剔除停牌股"
|
||
"(仅有「当日无行情时不成交」的撮合近似)"
|
||
)
|
||
if spec.universe.index_code:
|
||
warnings.append(
|
||
f"指数成分过滤({spec.universe.index_code})依赖本地指数成分表:"
|
||
"该指数历史成分未同步时,候选池可能为空或偏小"
|
||
)
|
||
if spec.portfolio.max_industry_weight_pct is not None:
|
||
warnings.append(
|
||
f"最大行业权重 {spec.portfolio.max_industry_weight_pct:.0%} **未建模**"
|
||
"(需行业元数据注入),结果中会列在 unimplemented"
|
||
)
|
||
if spec.price_adjustment == "none":
|
||
warnings.append(
|
||
"行情口径为不复权:现金分红未计入收益,除权日价格下移会被计为亏损;"
|
||
"股息类策略建议 price_adjustment=hfq"
|
||
)
|
||
if not spec.conditions:
|
||
warnings.append("未配置过滤条件(conditions):候选池仅由 universe + 因子排序决定")
|
||
m = spec.effective_selection_months
|
||
y = spec.effective_rebalance_months
|
||
if m is not None and y is not None and y < m:
|
||
warnings.append(
|
||
f"调仓间隔 y={y} 个月 < 择股间隔 m={m} 个月:两次择股之间复用同一候选池"
|
||
"(池子陈旧),并非每次调仓都重新择股"
|
||
)
|
||
warnings += [
|
||
"涨跌停按「收盘价 / 上一有效收盘」近似判定,未建模开盘一字板、集合竞价与盘中价格路径",
|
||
"成交假设发生在调仓日收盘,未建模流动性冲击与冲击成本",
|
||
"调仓为「全部卖出 → 按目标等权重新买入」:保留在目标名单中的股票也会产生一次完整买卖往返,成本估计偏保守",
|
||
"股票池来自本地行情表(含 2019-12 之后退市的标的):更早退市者无行情数据,存在残余幸存者偏差",
|
||
"期末不强制平仓:最后持仓按最后一个交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录",
|
||
]
|
||
return warnings
|