"""策略说明书生成器: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