"""LocalEngine —— 默认研究引擎(纯 pandas,AGENT.md §40 简单可替换优先)。 无未来函数纪律: - 择股日 s 的选股只使用 <= s 的因子值、条件字段与收盘价 - 成交发生在调仓日 t 收盘(价格 = close[t] ± 滑点);t 当日组合收益用 t-1 收盘持仓结算, 调仓在 t 收盘生效、自 t+1 起计收益 —— 不存在「当日买入当日计收益」的未来函数 - 顺延买入(defer_buy)只在**之后的交易日**补成交,绝不回溯到择股日之前 - 涨跌停 / 停牌约束按可达信息近似建模,未建模部分显式写入结果 unimplemented 周期模型(本次扩展,见 ResearchSpec): - 择股日集合 S:每 m 个月(selection_interval_months),锚定回测起始月 - 调仓日集合 R:每 y 个月(rebalance_interval_months,缺省 = m) - m 未给 → S = R(每次调仓都重新择股,与历史行为一致) - 候选池 = S 日按因子分排序的 Top n(top_n);实际持仓 = 池内前 x(hold_top_x) """ from __future__ import annotations import math from dataclasses import dataclass from datetime import date import pandas as pd from app.domain.entities.research import ( ActionRecord, BacktestResult, BacktestSummary, CurvePoint, FactorTestReport, MonthlyReturn, Position, RankedPick, ResearchSpec, SymbolCurve, Trade, YearlyReturn, ) from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用) build_factor_panels, composite_score, cross_sectional_zscore, ) from app.quant.evaluation import run_factor_test from app.quant.portfolio import ( allocate_with_max_position, equal_weight_budget, unimplemented_notes, ) TRADING_DAYS = 252 # 个股收益曲线数量上限: # None(默认)= 不截断,期内持有的每只都输出(「完整存档」;体积由归档侧的 # 字节预算兜底,见 app/application/services/experiment_archive.py); # 数字 = 按 |期末收益| 降序截断,且如实写入 unimplemented 说明。 # 取值优先级:本模块变量(测试 monkeypatch 用)> config research.archive_curve_limit。 _MAX_SYMBOL_CURVES: int | None = None def _resolved_curve_limit() -> int | None: """当前生效的曲线数量上限(None = 完整输出)。""" if _MAX_SYMBOL_CURVES is not None: return _MAX_SYMBOL_CURVES from app.core.config import get_settings return get_settings().research_archive_curve_limit # 恒定的未建模说明(AGENT.md §24:未实现项必须在结果中显式标注) _DEFAULT_UNIMPLEMENTED = [ "涨跌停按收盘价相对上一有效收盘近似判定(未建模开盘一字 / 集合竞价路径)", "成交假设发生在调仓日收盘(未建模盘中价格路径与流动性冲击)", ( "调仓为「全部卖出 → 按目标等权重新买入」,未做权重漂移微调:" "保留在目标名单中的股票也会产生一次完整买卖往返,交易成本估计偏保守" ), ( "股票池来自本地行情表(已含退市股:stock.status='D' 且带 delist_date," "退市日之后自动退出池子)。残余偏差:库里仅有 2019-12 之后退市的标的," "更早退市者无行情数据" ), ( "exclude_st 的名称口径见 config_snapshot.price_basis.name_basis:时点口径依赖 " "stock_name_history(sync namechange),未同步时回退最新名称快照," "会漏掉「曾是高股息、后来才变 ST」的股息陷阱样本" ), ] def _limit_up_ratio(symbol: str) -> float: """按板块近似涨跌停幅度。""" code = symbol[:3] if code in {"300", "301", "688"}: return 1.199 if code.startswith(("8", "4", "92")): return 1.299 return 1.099 def _month_firsts(index: pd.Index) -> list[pd.Timestamp]: """每个自然月的首个交易日(按 index 顺序)。""" periods = index.to_period("M") seen: dict = {} order: list[pd.Timestamp] = [] for ts, per in zip(index, periods, strict=True): if per not in seen: seen[per] = ts order.append(ts) return order def _week_firsts(index: pd.Index) -> list[pd.Timestamp]: """每个自然周的首个交易日。""" periods = index.to_period("W") seen: dict = {} order: list[pd.Timestamp] = [] for ts, per in zip(index, periods, strict=True): if per not in seen: seen[per] = ts order.append(ts) return order def _month_seq(ts: pd.Timestamp) -> int: """月序号(year*12+month),用于「每 m 个月」的锚定计算。""" return int(ts.year) * 12 + int(ts.month) def rebalance_dates( index: pd.Index, rebalance: str, start: date, end: date | None = None, every_months: int | None = None, ) -> list[pd.Timestamp]: """调仓/择股日集合(按频率取首个交易日,>= start)。 every_months=n(n>0):忽略 rebalance 频率,改用「每 n 个月」—— 锚定 **首个 >= start 的交易日所在月**(锚点月 t0),取月序号满足 `(t - t0) % n == 0` 的月份的首个交易日。 这样 2020-01-01 起、n=6 → 2020-01、2020-07、2021-01 …; 起始日改为 2020-03-15(该月首个交易日 03-02 早于 start)→ **2020-03-16** (03 月内首个 >= start 的交易日)、2020-09、2021-03 …。 ⚠️ 刻意**不丢弃锚点月**:若把锚点月整体过滤掉,m=y=6 且起始日非月初时 会白等 6 个月才首次建仓(净值在前期恒等于初始资金,指标明显失真)。 every_months=None:沿用 weekly / monthly / **daily** 频率。 - daily:区间内**每个交易日**都是调仓日(回测组合的「日频调仓」)。 - weekly / monthly:原行为,保持向后兼容。 """ if every_months and every_months > 0: firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index) # 锚点 = start 所在月内首个 >= start 的交易日(可能不是该月首个交易日) days = pd.DatetimeIndex(index) after_start = days[days >= pd.Timestamp(start)] if len(after_start) == 0: return [] anchor_ts = after_start[0] anchor = _month_seq(anchor_ts) # 后续月份:月序号与锚点月相差整数倍 m,取该月首个交易日 out = [anchor_ts] + [ ts for ts in firsts if _month_seq(ts) > anchor and (_month_seq(ts) - anchor) % every_months == 0 and ts.date() >= start and ts != anchor_ts ] elif rebalance == "daily": # 日频:区间内每个交易日 days = pd.DatetimeIndex(index) out = [ts for ts in days if ts.date() >= start] else: firsts = _week_firsts(index) if rebalance == "weekly" else _month_firsts(index) out = [ts for ts in firsts if ts.date() >= start] if end is not None: out = [ts for ts in out if ts.date() <= end] return out @dataclass class PendingBuy: """顺延买单:调仓日买不进(涨停/停牌)时挂起,之后逐日重试。 仅当 SelectionSpec.defer_buy=True 时产生;到下一次调仓日仍未成交则作废。 `budget` 是调仓日按等权/上限为该标的预留的资金,成交时按 min(budget, 可用现金) 执行。 """ symbol: str budget: float since: date @dataclass class EngineResult: equity: pd.Series # index=date -> equity trades: list[Trade] positions: list[Position] rebalance_notional: list[float] class TopKBacktestRunner: """TopK 等权、固定调仓频率的低频回测(支持择股/调仓双周期与顺延买入)。""" def __init__( self, spec: ResearchSpec, score: pd.DataFrame, close: pd.DataFrame, eligibility_fn=None, ) -> None: self.spec = spec close = close.copy() close.index = pd.to_datetime(close.index) self.close = close.sort_index() self.score = score.reindex(self.close.index).sort_index() self.costs = spec.costs # 上一有效收盘(用于涨跌停与收益结算,处理停牌日) self.prev_close = self.close.ffill().shift(1) # 条件过滤(可选):(as_of: date) -> set[symbol] | None # 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑 self.eligibility_fn = eligibility_fn # M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3) self.selection_history: list[RankedPick] = [] self.signal_history: list[ActionRecord] = [] # 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n self.current_ranked: list[str] = [] self.current_pool: list[str] = [] # 本次回测期内被持有过的股票(用于个股收益曲线) self.traded_symbols: list[str] = [] self._traded: set[str] = set() # 无前收导致涨停无法判定、按可买处理并**实际成交**的标的集合(结果中如实标注) self._no_prev_close_symbols: set[str] = set() # ---- 主流程 ---- def run(self) -> BacktestResult: end_date = self.spec.period[1] dates = [d for d in self.close.index if self.spec.period[0] <= d.date() <= end_date] if not dates: raise ValueError( f"回测区间 {self.spec.period[0]}~{end_date} 内没有任何行情数据,无法回测" ) m = self.spec.effective_selection_months y = self.spec.effective_rebalance_months rebal = set( rebalance_dates( self.close.index, self.spec.rebalance, self.spec.period[0], end_date, every_months=y ) ) if m is None: # 未给 m:每次调仓都重新择股(与历史行为一致) select = set(rebal) else: select = set( rebalance_dates( self.close.index, self.spec.rebalance, self.spec.period[0], end_date, every_months=m, ) ) cash = float(self.spec.initial_capital) shares: dict[str, float] = {} entry_date: dict[str, date] = {} entry_price: dict[str, float] = {} equity_rows: dict[pd.Timestamp, float] = {} trades: list[Trade] = [] positions: list[Position] = [] notional: list[float] = [] pending: list[PendingBuy] = [] # 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌) cum: dict[str, float] = {} curve_rows: dict[str, list[CurvePoint]] = {} def _value(d: pd.Timestamp) -> float: total = cash for s, qty in shares.items(): if qty <= 0: continue px = self.close.at[d, s] if d in self.close.index else None if px is None or (isinstance(px, float) and math.isnan(px)): continue # 无行情日不计该仓(停牌近似,见 unimplemented) total += float(qty * px) return total for d in dates: # 1) 先用「上一交易日收盘持仓」结算当日个股收益(与组合净值同一时序口径: # 当日收益来自昨日持仓)→ 建仓当日不计收益、卖出当日仍有收益 self._accrue_symbol_returns(d, shares, cum, curve_rows) # 2) 择股 / 调仓(成交发生在当日收盘) if d in select: self.current_ranked, self.current_pool = self._select(d) if d in rebal: # 上一次调仓挂起的顺延单作废(只在两次调仓之间有效) pending = [] cash = self._rebalance( d, cash, shares, entry_date, entry_price, trades, positions, notional, pending, ) elif pending: cash = self._fill_pending(d, cash, shares, entry_date, entry_price, pending, notional) equity_rows[d] = _value(d) # 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注 # 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates) self._mark_curve_dates(d, shares, cum, curve_rows) equity = pd.Series(equity_rows).sort_index() return self._to_result(equity, trades, positions, notional, cum, curve_rows) # ---- 择股(择股日 s:只用 <= s 的数据) ---- def _select(self, d: pd.Timestamp) -> tuple[list[str], list[str]]: """返回 (全市场可评分排序, 候选池 top n),并记录 selection_history。""" score_d = self.score.loc[d].dropna() eligible = None if self.eligibility_fn is not None: eligible = self.eligibility_fn(d.date()) if eligible is not None: score_d = score_d[score_d.index.isin(eligible)] ranked = score_d.sort_values(ascending=False).index.tolist() n = self.spec.selection.top_n pool = ranked[:n] day = d.date() for rank, sym in enumerate(pool, start=1): self.selection_history.append( RankedPick(date=day, symbol=sym, rank=rank, score=round(float(score_d[sym]), 6)) ) return ranked, pool # ---- 调仓(t 收盘执行,自 t+1 生效) ---- def _rebalance( self, d, cash, shares, entry_date, entry_price, trades, positions, notional, pending ): close_d = self.close.loc[d] prev_d = self.prev_close.loc[d] day = d.date() # 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明) for s in [s for s in shares if shares[s] > 0]: c, p = close_d[s], prev_d[s] if _nan(c): self.signal_history.append( ActionRecord(date=day, symbol=s, signal="SELL", filled=False, reject_reason="无行情(停牌),保留持仓") ) continue # 停牌无价:保留 if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0): self.signal_history.append( ActionRecord(date=day, symbol=s, signal="SELL", filled=False, reject_reason="跌停无法卖出,保留到下一调仓") ) continue # 跌停无法卖出:保留到下一调仓 qty = shares[s] proceeds = qty * float(c) * (1 - self.costs.slippage_rate) commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission) fee = commission + proceeds * self.costs.stamp_tax_rate cash += proceeds - fee self.signal_history.append( ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c)) ) trades.append( Trade( entry_date=entry_date[s], exit_date=day, symbol=s, entry_price=entry_price[s], exit_price=float(c), return_pct=(float(c) / entry_price[s] - 1.0) * 100, ) ) shares[s] = 0.0 entry_date.pop(s, None) entry_price.pop(s, None) # 2) 买入意图:候选池(= selection_history 记录的那批) picks = list(self.current_pool) x = min(self.spec.selection.x, len(picks)) sel = self.spec.selection if x < self.spec.selection.x: self.signal_history.append( ActionRecord( date=day, symbol="", signal="BUY", filled=False, reject_reason=( f"候选池仅 {len(picks)} 只(< 目标持仓 x={self.spec.selection.x})," "按池内数量持仓" ), ) ) def _buyable(sym) -> tuple[bool, str | None]: c, p = close_d[sym], prev_d[sym] if _nan(c): return False, "无行情(停牌),无法买入" if _nan(p) or p <= 0: # 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。 # 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的), # 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。 return True, None if c / p >= _limit_up_ratio(sym): return False, "涨停,无法追买" return True, None # 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找 targets: list[str] = [] if sel.allow_substitute: for sym in self.current_ranked: if len(targets) >= self.spec.selection.x: break ok, _ = _buyable(sym) if ok: targets.append(sym) else: targets = picks[:x] pending_specs: list[tuple[str, str | None]] = [] spends: dict[str, float] = {} if targets: cap = self.spec.portfolio.max_position_pct if cap is None: # 默认等权:按「目标持仓数」均分可用现金(顺延未成交的部分留作现金) budget = equal_weight_budget(cash, len(targets)) spends = {s: budget for s in targets} else: # Portfolio v1.1:按单股上限(相对当日组合市值)分配,超出部分留现金 equity_now = cash + sum( float(self.close.at[d, s] * qty) for s, qty in shares.items() if qty > 0 and not _nan(self.close.at[d, s]) ) spends = allocate_with_max_position(cash, targets, equity_now, cap) for s in targets: budget = spends[s] if budget <= 1e-9: # 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕 self.signal_history.append( ActionRecord( date=day, symbol=s, signal="BUY", filled=False, reject_reason="分配额不足(可用现金≈0),未成交", ) ) continue ok, reason = _buyable(s) if not ok: if sel.defer_buy: # 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金) pending_specs.append((s, reason)) self.signal_history.append( ActionRecord( date=day, symbol=s, signal="BUY", filled=False, reject_reason=f"{reason},顺延到之后首个可成交日买入", ) ) else: self.signal_history.append( ActionRecord( date=day, symbol=s, signal="BUY", filled=False, reject_reason=reason or "不可买入", ) ) continue if not self._execute_buy( s, budget, d, close_d[s], shares, entry_date, entry_price, notional ): self.signal_history.append( ActionRecord( date=day, symbol=s, signal="BUY", filled=False, reject_reason="预算不足以覆盖最低佣金,未成交", ) ) continue cash -= budget # 替补模式下目标名单取自 n 名之外,池内被跳过的标的也要记录意图, # 否则「信号有了却没买」无法解释(v3 §20.3 Signal↔Fill 透明化)。 # 非替补模式下 targets == picks[:x],池内标的都已在上面留痕,无需再遍历。 if sel.allow_substitute: for sym in picks: if sym in set(targets): continue _ok, reason = _buyable(sym) self.signal_history.append( ActionRecord(date=day, symbol=sym, signal="BUY", filled=False, reject_reason=reason or "资金不足(未成交)") ) # 3) 记录调仓后仓位 total = cash + sum( float(self.close.at[d, s] * qty) for s, qty in shares.items() if qty > 0 and not _nan(self.close.at[d, s]) ) if total > 0: for s, qty in shares.items(): if qty > 0 and not _nan(self.close.at[d, s]): positions.append( Position( date=day, symbol=s, weight=float(qty * self.close.at[d, s] / total) ) ) # 顺延单登记:预留额度 = 调仓日的等权/上限分配额(不因后续价格变化而变) for sym, _reason in pending_specs: pending.append(PendingBuy(symbol=sym, budget=spends.get(sym, 0.0), since=day)) return cash def _execute_buy( self, s, budget, d, close_value, shares, entry_date, entry_price, notional ) -> bool: """按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。 现金支出恒为 budget:shares = (budget - 佣金) / (收盘价 × (1 + 滑点))。 返回是否成交(预算不足以覆盖最低佣金时不成交,调用方不得扣减现金)。 """ c = float(close_value) price_in = c * (1 + self.costs.slippage_rate) commission = max(budget * self.costs.commission_rate, self.costs.min_commission) invest = budget - commission if invest <= 0: return False # 累加而非覆盖:避免「跌停/停牌未卖出而保留的旧仓位」被静默清零 shares[s] = shares.get(s, 0.0) + invest / price_in entry_date[s] = d.date() entry_price[s] = price_in prev = self.prev_close.at[d, s] if d in self.prev_close.index else float("nan") if _nan(prev) or prev <= 0: self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注 notional.append(budget) self.signal_history.append( ActionRecord(date=d.date(), symbol=s, signal="BUY", filled=True, price=round(price_in, 4)) ) if s not in self._traded: self._traded.add(s) self.traded_symbols.append(s) return True # ---- 顺延买入(defer_buy):之后逐日重试 ---- def _fill_pending(self, d, cash, shares, entry_date, entry_price, pending, notional): close_d = self.close.loc[d] prev_d = self.prev_close.loc[d] remaining: list[PendingBuy] = [] for order in pending: if order.symbol in shares and shares[order.symbol] > 0: continue # 期间已通过其他路径持有 → 撤销该顺延单 c, p = close_d.get(order.symbol), prev_d.get(order.symbol) if _nan(c) or _nan(p) or p <= 0: remaining.append(order) continue if c / p >= _limit_up_ratio(order.symbol): remaining.append(order) # 仍涨停 → 继续顺延 continue budget = min(order.budget, cash) if budget <= 1e-9: remaining.append(order) # 无可用现金(理论上不会发生) continue if not self._execute_buy( order.symbol, budget, d, c, shares, entry_date, entry_price, notional ): remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化) continue cash -= budget pending[:] = remaining return cash # ---- 个股收益曲线 ---- def _accrue_symbol_returns(self, d, shares, cum, curve_rows) -> None: """逐日累计各持仓股的「持仓期收益」(以建仓日收盘为 0% 基准)。 口径:cum 以 1.0 起算,仅在该股**持有期间**按日复利(close/prev_close)。 本方法在当日调仓**之前**调用,因此: - 建仓当日不计收益(成交发生在当日收盘)→ 不存在当日买入当日计收益的未来函数 - 卖出当日仍计收益(当日收益来自昨日持仓) 未持有期间不产生数据点(曲线不落点),多段持仓则以 cum 连乘衔接; 前端以买卖点标注区分各段持仓区间。 """ prev_d = self.prev_close.loc[d] close_d = self.close.loc[d] for s, qty in shares.items(): if qty <= 0: continue c, p = close_d.get(s), prev_d.get(s) if _nan(c) or _nan(p) or p <= 0: continue # 停牌/无前收:无有效收益 cum[s] = cum.get(s, 1.0) * (float(c) / float(p)) # 只为「当日持有」的股票落点(未持有期间不落点,显著压缩结果体积) for s, qty in shares.items(): if qty <= 0: continue curve_rows.setdefault(s, []).append( CurvePoint(date=d.date(), value=round((cum.get(s, 1.0) - 1.0) * 100, 4)) ) def _mark_curve_dates(self, d, shares, cum, curve_rows) -> None: """为当日持有但尚未落点的股票补一个基准点(建仓当日 / 顺延成交当日)。 值为该股当前的 `cum`(新标的为 1.0 → 0%,复买标的延续上一段的累计值), 因此曲线总能在买卖点当日取到数值,前端标注不会因缺数据点而被丢弃。 """ day = d.date() for s, qty in shares.items(): if qty <= 0: continue points = curve_rows.setdefault(s, []) if points and points[-1].date == day: continue points.append( CurvePoint(date=day, value=round((cum.get(s, 1.0) - 1.0) * 100, 4)) ) # ---- 指标 ---- def _to_result(self, equity, trades, positions, notional, cum, curve_rows) -> BacktestResult: start, end = equity.index[0].date(), equity.index[-1].date() init = float(self.spec.initial_capital) final = float(equity.iloc[-1]) rets = equity.pct_change().dropna() n = len(rets) total_ret = (final / init - 1.0) * 100 if init else 0.0 annual = ( ((final / init) ** (TRADING_DAYS / max(n, 1)) - 1.0) * 100 if final > 0 and init > 0 else -100.0 ) mean_r, std_r = (float(rets.mean()), float(rets.std(ddof=1))) if n else (0.0, 0.0) sharpe = mean_r / std_r * math.sqrt(TRADING_DAYS) if std_r and mean_r else 0.0 vol = std_r * math.sqrt(TRADING_DAYS) * 100 dd = (equity / equity.cummax() - 1.0).min() * 100 wins = [t for t in trades if t.return_pct > 0] win_rate = len(wins) / len(trades) * 100 if trades else 0.0 avg_turn = (sum(notional) / len(notional) / ((init + final) / 2)) * 100 if notional else 0.0 eq_pts = [CurvePoint(date=d.date(), value=round(float(v), 2)) for d, v in equity.items()] dd_series = (equity / equity.cummax() - 1.0) * 100 drawdown = [ CurvePoint(date=d.date(), value=round(float(v), 3)) for d, v in dd_series.items() ] monthly: list[MonthlyReturn] = [] yearly: list[YearlyReturn] = [] if len(equity) > 1: m = equity.resample("ME").last().pct_change().dropna() monthly = [ MonthlyReturn( year=int(d.year), month=int(d.month), return_pct=round(float(v) * 100, 3) ) for d, v in m.items() ] y = equity.resample("YE").last().pct_change().dropna() yearly = [ YearlyReturn(year=int(d.year), return_pct=round(float(v) * 100, 3)) for d, v in y.items() ] summary = BacktestSummary( start=start, end=end, initial_capital=round(init, 2), final_equity=round(final, 2), total_return_pct=round(total_ret, 3), annual_return_pct=round(annual, 3), sharpe=round(sharpe, 3), max_drawdown_pct=round(float(dd), 3), volatility_pct=round(vol, 3), win_rate_pct=round(win_rate, 2), total_trades=len(trades), avg_turnover_pct=round(avg_turn, 2), ) curves, curve_note = self._symbol_curves(curve_rows, cum) return BacktestResult( summary=summary, equity_curve=eq_pts, drawdown=drawdown, monthly_returns=monthly, yearly_returns=yearly, positions=positions, trades=trades, selection_history=self.selection_history, signal_history=self.signal_history, fills=[a for a in self.signal_history if a.filled], symbol_curves=curves, turnover_pct=round(sum(notional) / max(init, 1) * 100, 2), unimplemented=self._unimplemented(curve_note), config_snapshot=self.spec.model_dump(mode="json"), ) def _symbol_curves(self, curve_rows, cum) -> tuple[list[SymbolCurve], str | None]: """按「期末收益绝对值」降序输出个股曲线(前端默认展示前若干只)。 返回 (曲线列表, 截断说明)。默认**不截断**(`config research.archive_curve_limit` 为 null):期内持有的每只都输出,保证归档完整;体积由归档侧的字节预算兜底 (见 experiment_archive)。仅当配置了数字上限时才截断,并如实标注哪一部分 被丢弃、为什么(AGENT §24:不静默降级,绝不假装完整)。 """ marks: dict[str, list[ActionRecord]] = {} for a in self.signal_history: if a.filled and a.symbol: marks.setdefault(a.symbol, []).append(a) out: list[SymbolCurve] = [] for s, points in curve_rows.items(): if not points: continue out.append( SymbolCurve( symbol=s, points=points, marks=marks.get(s, []), final_return_pct=round((cum.get(s, 1.0) - 1.0) * 100, 4), ) ) out.sort(key=lambda c: abs(c.final_return_pct), reverse=True) note = None limit = _resolved_curve_limit() if limit is not None and len(out) > limit: note = ( f"个股收益曲线仅输出收益绝对值最大的 {limit} 只" f"(期内共持有 {len(out)} 只):完整明细见 trades / signal_history" ) out = out[:limit] return out, note def _unimplemented(self, curve_note: str | None = None) -> list[str]: notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio) if self._no_prev_close_symbols: notes.append( f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价," "无法判定涨停(数据窗口起点或长期停牌后复牌),按可买处理" ) if curve_note: notes.append(curve_note) sel = self.spec.selection m = self.spec.effective_selection_months y = self.spec.effective_rebalance_months if m is not None and y is not None and y < m: notes.append( f"调仓间隔 y={y} 个月 < 择股间隔 m={m} 个月:两次择股之间会复用同一候选池" "(池子陈旧),并非每次调仓都重新择股" ) if sel.defer_buy: notes.append( "顺延买入:调仓日涨停/停牌无法买入的标的挂单至之后首个可成交交易日," "按该日收盘价成交;到下一次调仓仍未成交则作废并留作现金" ) if self.spec.price_adjustment == "none": notes.append( "行情口径为不复权:现金分红未计入收益,除权日的价格下移会被计为亏损。" "股息类策略建议使用 price_adjustment=hfq(后复权)" ) if not self.spec.conditions: notes.append("未配置选股过滤条件(conditions),候选池仅由 universe + 因子排序决定") return notes def run_spec_factor_test( daily: pd.DataFrame, spec: ResearchSpec, horizon_days: int = 21, ) -> tuple[FactorTestReport, dict[str, pd.DataFrame]]: """单因子测试:因子面板 + 未来 horizon 收益 → FactorTestReport。""" assert spec.type == "factor_test" factor_name = spec.factors[0].name panels = build_factor_panels(daily, spec.factors) panel = panels[0][1] close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index() close.index = pd.to_datetime(close.index) forward = close.shift(-horizon_days) / close - 1.0 report = run_factor_test(panel, forward, factor_name=factor_name) return report, {factor_name: panel} def _nan(v) -> bool: """缺失判定:None / NaN / 不可转 float 一律视为「无有效值」。 注意 Series.get(key) 对不存在的键返回 None(而非 NaN),故必须把 None 判为缺失。 """ if v is None: return True try: return bool(math.isnan(float(v))) except (TypeError, ValueError): return True