Files
qlib/backend/app/quant/strategy_doc.py
T
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
2026-10-01 16:33:32 +08:00

701 lines
32 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.
"""策略说明书生成器:ResearchSpec(回测)/ SelectionStrategy(选股策略)→ 说明 + 公式 + 步骤 + 注意事项。
纯函数模块:无 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 SelectionStrategy, StrategyDefinition
from app.quant.condition_fields import get_field
from app.quant.factors import FactorDef, FactorError, get_factor
# 选股策略(SelectionStrategy)不含回测参数,走 _describe_selection_only 专用分支;
# 以下占位常量仅保留给历史 ResearchSpec 路径兼容(当前 describe_strategy 不再对
# SelectionStrategy 调用 to_research_spec)。
_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 | SelectionStrategy,
*,
factor_meta: Mapping[str, FactorDef] | None = None,
) -> StrategyDoc:
"""生成策略说明书。
`spec`:ResearchSpec(回测页参数,含 period/costs/selection)或 SelectionStrategy
(策略库资产,只有选股条件)。后者走 `_describe_selection_only`,只讲「怎么选」,
如实声明资金/持仓/调仓/成本/区间不在策略内(在回测组合里定)。
`factor_meta`:可选的因子元数据覆盖/补充(如内置注册表之外的实验因子)。
查找顺序为 `factor_meta` → `app.quant.factors.get_factor`;两处都没有则记入
warnings(说明该因子的含义/公式/方向未知,执行期会直接报错)。
"""
coerced = _coerce_spec(spec)
# 选股策略(无回测参数)走专用说明;回测 spec 走原有完整路径
if isinstance(coerced, (SelectionStrategy, StrategyDefinition)) and not isinstance(coerced, ResearchSpec):
return _describe_selection_only(coerced, factor_meta)
rspec, period_text, period_known = coerced
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 _describe_selection_only(st, factor_meta) -> StrategyDoc:
"""选股策略的说明:只讲「怎么选」(股票池 + 因子 + 条件),不涉及回测执行参数。
资金 / 持仓数 / 持仓时间 / 调仓时机 / 费率 / 复权 / 区间都不属于选股策略,
在回测组合里才确定 —— 这里如实声明,避免读者以为策略自带这些口径。
"""
warnings: list[str] = []
factors = _describe_factors_from_specs(st.factors, factor_meta, warnings)
pool = _universe_text(st.universe)
cond_lines = _condition_lines(st.conditions, warnings) if st.conditions else []
factor_names = "、".join(f["name"] for f in factors) or "(无)"
pool_desc = _short_pool(st.universe)
summary = (
f"选股策略「{st.name}」:在{pool_desc}内"
+ (f"先通过 {len(st.conditions)} 条过滤条件,再" if st.conditions else "")
+ f"按因子({factor_names})打分排序,供回测组合取 TopN 持仓。"
)
formula_lines = [
"【选股口径】(仅定义「怎么选」,不含回测执行参数)",
f" 股票池:{pool}",
" 打分:score = Σ 权重 × 因子值(截面 z-score 标准化后加权,越大越优先)",
]
for f in factors:
formula_lines.append(f" · {f['line']}")
if cond_lines:
formula_lines.append(" 过滤条件(AND,先于打分执行):")
formula_lines.extend(f" · {ln}" for ln in cond_lines)
formula_lines.append(
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
)
# 步骤文案**不带序号**:前端把它渲染进 <ol>(StrategyDocCard),编号由列表提供;
# 后端再写一遍「1. 2. 3.」会渲染成「1. 1. …」双重编号(2026-10 修正)。
steps = [
"按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else "未设过滤条件,候选 = universe。",
"对剩余股票按上述因子打分并降序排列 → 得到候选排名(TopN 在回测组合里截取)。",
]
warnings.append(
"本说明只覆盖选股口径;回测的资金/持仓/调仓/成本/区间由「回测组合」+「公共配置」决定,"
"此处无法给出收益公式与成交口径。"
)
return StrategyDoc(summary=summary, formula="\n".join(formula_lines), steps=steps, warnings=warnings)
def _short_pool(universe) -> str:
parts = []
if universe.index_code:
parts.append(f"{universe.index_code} 成分股")
elif universe.symbols:
parts.append(f"指定的 {len(universe.symbols)} 只白名单股票")
else:
parts.append("全市场 A 股")
if universe.exclude_st:
parts.append("剔除 ST ")
return "、".join(parts)
def _describe_factors_from_specs(factor_specs, factor_meta, warnings) -> list[dict]:
"""从 FactorSpec 列表生成 [{label, line}](复用既有因子元数据查找逻辑)。"""
out: list[dict] = []
for fs in factor_specs:
meta = _lookup_factor(fs.name, factor_meta)
label = fs.name
direction = "越高越好"
formula_hint = ""
if meta is None:
warnings.append(
f"因子 {fs.name} 未在注册表中找到元数据:含义/公式/方向未知,"
"执行期会报错;说明里只能给出名字与权重。"
)
else:
label = meta.brief or meta.description or fs.name
direction = "越低越好" if meta.direction == "lower_is_better" else "越高越好"
formula_hint = f"({meta.formula})" if meta.formula else ""
out.append({
"name": fs.name,
"label": label if label != fs.name else fs.name,
"line": f"{fs.name}{formula_hint},权重 {fs.weight},{direction}",
})
return out
def _unit_suffix(field: str, cond) -> str:
"""字面量条件的**基准单位**后缀(引擎就是按它比较的)。
字段间比较(ref)不加:两侧同一单位,写出来只会误导。字段不在注册表里 → 不加,
宁可少写也不猜单位(猜错就是静默的口径错误)。
"""
if getattr(cond, "ref", None) is not None:
return ""
d = get_field(field)
return f" {d.unit}" if d is not None and d.unit else ""
def _condition_lines(conditions, warnings) -> list[str]:
"""把 ConditionSpec 列表渲染成可读行(复用既有字段域校验逻辑)。"""
lines: list[str] = []
for c in conditions:
op = _OP_TEXT.get(c.op, c.op)
right = f"字段 {c.ref}" if c.ref else f"{c.value}"
lines.append(f"{c.field} {op} {right}{_unit_suffix(c.field, c)}")
return lines
# ---------- 入参归一化 ----------
def _coerce_spec(spec):
"""归一化输入。
- ResearchSpec(回测页参数,含 period/costs/selection)→ (ResearchSpec, 区间文本, True),
走完整回测说明书路径。
- SelectionStrategy(策略库资产,**只有选股条件**,无 period/costs/selection)→
直接返回该对象,由 `describe_selection_strategy` 生成「选股口径」说明。
重构后策略库不再持有回测执行参数,故不能也不应假装展开成 ResearchSpec。
"""
if isinstance(spec, ResearchSpec):
start, end = spec.period
return (spec, f"{start.isoformat()} ~ {end.isoformat()}", True)
if isinstance(spec, (SelectionStrategy, StrategyDefinition)):
return spec # 选股策略:单独处理
raise TypeError(
"describe_strategy 只接受 ResearchSpec 或 SelectionStrategy,"
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
suffix = ""
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) + "]"
suffix = _unit_suffix(cond.field, cond)
else:
right = _fmt_value(cond.value)
suffix = _unit_suffix(cond.field, cond)
return f"{cond.field} {op} {right}{suffix}"
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