"""策略说明书生成器: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): """策略说明书(前端 `
` 直接展示 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(
        "  ⚠ 持仓数量 / 持仓天数区间 / 调仓时机 / 起始资金 / 费率 / 复权口径 / 回测区间"
        "均不在本策略内 —— 它们在「回测组合」中指定,运行时与公共配置合并。"
    )
    # 步骤文案**不带序号**:前端把它渲染进 
    (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