"""策略说明书生成器: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.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): """策略说明书(前端 `
` 直接展示 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(
" ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
"均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
)
steps = [
"1. 按股票池口径筛出候选 universe(市场 / 剔 ST / 上市天数 / 指数成分)。",
"2." + (" 逐条求值过滤条件(AND),剔除不满足者。" if st.conditions else " (未设过滤条件,候选 = universe。)"),
"3. 对剩余股票按上述因子打分并降序排列 → 得到候选排名(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 _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}")
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
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