feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
@@ -12,9 +12,15 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.entities.market import (
|
||||
DAILY_BAR_NUMERIC_FIELDS,
|
||||
DAILY_BASIC_NUMERIC_FIELDS,
|
||||
FinancialIndicator,
|
||||
)
|
||||
from app.domain.entities.research import (
|
||||
BacktestResult,
|
||||
FactorCorrelationReport,
|
||||
@@ -28,7 +34,12 @@ from app.domain.repositories.market import (
|
||||
from app.quant.composite import build_factor_panels
|
||||
from app.quant.engine import QuantEngine
|
||||
from app.quant.evaluation import factor_correlation_report
|
||||
from app.quant.universe import filter_stocks, resolve_members # noqa: F401 —— 范围过滤
|
||||
from app.quant.selection import build_condition_fields, eligible_symbols
|
||||
from app.quant.universe import ( # noqa: F401 —— 范围过滤
|
||||
filter_stocks,
|
||||
names_as_of,
|
||||
resolve_members,
|
||||
)
|
||||
|
||||
# 流式路径每攒多少行落一个 DataFrame 分片(控制 concat 峰值)
|
||||
_FRAME_CHUNK_ROWS = 50_000
|
||||
@@ -79,22 +90,44 @@ def load_daily_df(
|
||||
end: date,
|
||||
columns: list[str],
|
||||
adjust: str = "none",
|
||||
price_adjust: str = "none",
|
||||
) -> pd.DataFrame:
|
||||
"""从 Repository 装配行情长表(供研究/选股共用)。
|
||||
|
||||
两个 adjust 语义不同(v3 §20.5):
|
||||
- `adjust`:行集过滤(stock_daily.adjust 主口径 none / 新浪兜底 qfq 行)
|
||||
- `price_adjust`:复权折算(none/qfq/hfq,按 adjust_factor 在 SQL 侧折算价格列)
|
||||
|
||||
优先走流式列裁剪(stream_range_many_columns,SQL 侧转 REAL、分批),
|
||||
失败或实现缺失时回退 get_range_many / 逐只 get_range。
|
||||
失败或实现缺失时回退 get_range_many / 逐只 get_range(回退路径不支持 price_adjust,
|
||||
此时由调用方保证 price_adjust=none,避免静默混入未折算价格)。
|
||||
"""
|
||||
if not symbols:
|
||||
return pd.DataFrame()
|
||||
streamer = getattr(daily_repo, "stream_range_many_columns", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
return _frame_from_stream(
|
||||
streamer(
|
||||
symbols, start, end, sorted(columns),
|
||||
adjust=adjust, price_adjust=price_adjust,
|
||||
),
|
||||
sorted(columns),
|
||||
)
|
||||
except TypeError: # 实现未升级(无 price_adjust 形参)→ 回退
|
||||
if price_adjust != "none":
|
||||
raise
|
||||
return _frame_from_stream(
|
||||
streamer(symbols, start, end, sorted(columns), adjust=adjust), sorted(columns)
|
||||
)
|
||||
except Exception: # noqa: BLE001 —— 流式路径失败回退旧路径(兼容非 SQL 实现)
|
||||
if price_adjust != "none":
|
||||
raise # 复权路径失败必须显式报错,禁止静默退回不复权
|
||||
pass
|
||||
elif price_adjust != "none":
|
||||
raise ValueError(
|
||||
f"行情仓储不支持流式列裁剪,无法按 {price_adjust} 复权装配(拒绝静默退回不复权)"
|
||||
)
|
||||
get_many = getattr(daily_repo, "get_range_many", None)
|
||||
if get_many is not None:
|
||||
bars = list(get_many(symbols, start, end, adjust=adjust))
|
||||
@@ -105,6 +138,127 @@ def load_daily_df(
|
||||
return bars_to_daily_df(bars)
|
||||
|
||||
|
||||
def split_factor_columns(columns: Iterable[str]) -> tuple[set[str], set[str]]:
|
||||
"""把因子所需列拆成 (行情列, 每日指标列)。
|
||||
|
||||
行情列来自 stock_daily(含 close 等);每日指标列来自 daily_basic(dv_ratio 等)。
|
||||
未识别的列按行情列处理(由仓储的列白名单兜底报错,错误信息更贴近调用点)。
|
||||
"""
|
||||
bars: set[str] = set()
|
||||
basics: set[str] = set()
|
||||
for col in columns:
|
||||
if col in DAILY_BASIC_NUMERIC_FIELDS and col not in DAILY_BAR_NUMERIC_FIELDS:
|
||||
basics.add(col)
|
||||
else:
|
||||
bars.add(col)
|
||||
return bars, basics
|
||||
|
||||
|
||||
def load_basic_df(
|
||||
basic_repo,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str],
|
||||
) -> pd.DataFrame:
|
||||
"""装配每日指标长表(daily_basic)—— 仅取所需列。
|
||||
|
||||
与 load_daily_df 同构(流式优先、回退批量/逐只)。
|
||||
"""
|
||||
if not symbols or not columns:
|
||||
return pd.DataFrame()
|
||||
streamer = getattr(basic_repo, "stream_range_many_columns", None)
|
||||
if streamer is not None:
|
||||
try:
|
||||
return _frame_from_stream(
|
||||
streamer(symbols, start, end, sorted(columns)), sorted(columns)
|
||||
)
|
||||
except Exception: # noqa: BLE001 —— 回退旧路径
|
||||
pass
|
||||
get_many = getattr(basic_repo, "get_range_many", None)
|
||||
if get_many is not None:
|
||||
rows = [
|
||||
{"symbol": r.symbol, "trade_date": r.trade_date, **{c: getattr(r, c) for c in columns}}
|
||||
for r in get_many(symbols, start, end)
|
||||
]
|
||||
else: # 兜底:逐只查询
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
for r in basic_repo.get_range(sym, start, end):
|
||||
rows.append(
|
||||
{
|
||||
"symbol": r.symbol,
|
||||
"trade_date": r.trade_date,
|
||||
**{c: getattr(r, c) for c in columns},
|
||||
}
|
||||
)
|
||||
df = pd.DataFrame(rows)
|
||||
if df.empty:
|
||||
return df
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
for col in columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
return df
|
||||
|
||||
|
||||
def merge_basic_into_daily(daily: pd.DataFrame, basic: pd.DataFrame) -> pd.DataFrame:
|
||||
"""把每日指标列并入行情长表(按 symbol + trade_date 左连接)。
|
||||
|
||||
命名冲突保护:daily_basic 也有 close 列,若与行情 close 冲突则**丢弃指标侧**列
|
||||
(成交/估值口径以 stock_daily 不复权 close 为准,避免静默改口径,AGENT.md §8)。
|
||||
"""
|
||||
if basic is None or basic.empty:
|
||||
return daily
|
||||
if daily is None or daily.empty:
|
||||
return daily
|
||||
add_cols = [
|
||||
c for c in basic.columns if c not in ("symbol", "trade_date") and c not in daily.columns
|
||||
]
|
||||
if not add_cols:
|
||||
return daily
|
||||
left = daily.copy()
|
||||
left["trade_date"] = pd.to_datetime(left["trade_date"])
|
||||
right = basic[["symbol", "trade_date", *add_cols]].copy()
|
||||
right["trade_date"] = pd.to_datetime(right["trade_date"])
|
||||
merged = left.merge(right, on=["symbol", "trade_date"], how="left")
|
||||
return merged
|
||||
|
||||
|
||||
def _fill_names(result: BacktestResult, stocks) -> BacktestResult:
|
||||
"""把股票池的 `symbol → name` 回填进回测结果的各展示结构(就地修改并返回)。
|
||||
|
||||
为什么放在服务层而不是引擎里:名称只是展示增强,两个引擎(LocalEngine / QlibEngine)
|
||||
都经本方法返回,改一处即同时覆盖;引擎保持「只认 symbol」的纯计算职责,不引入
|
||||
名称查询/IO,也就不必在引擎内部为零散字段各写一次映射。
|
||||
|
||||
`stocks` 为空(universe 过滤后无标的、或调用方未装配)时**静默跳过**,不抛错:
|
||||
名称缺失只影响展示,不应让一个已经算完的回测失败(AGENT.md §24 的诚实标注
|
||||
由引擎的 unimplemented 承担,不由名称缺失承担)。
|
||||
"""
|
||||
if not stocks:
|
||||
return result
|
||||
name_map = {s.symbol: s.name for s in stocks if getattr(s, "name", None)}
|
||||
if not name_map:
|
||||
return result
|
||||
|
||||
def _fill(rows) -> None:
|
||||
for row in rows:
|
||||
if row.name is None:
|
||||
row.name = name_map.get(row.symbol)
|
||||
|
||||
_fill(result.symbol_curves)
|
||||
# marks 与 signal_history 在 LocalEngine 中共享同一批 ActionRecord 对象,
|
||||
# 这里仍显式回填一次:对独立实现(如 Qlib 适配器)也成立,幂等无副作用。
|
||||
for curve in result.symbol_curves:
|
||||
_fill(curve.marks)
|
||||
_fill(result.positions)
|
||||
_fill(result.trades)
|
||||
_fill(result.signal_history)
|
||||
_fill(result.fills)
|
||||
_fill(result.selection_history)
|
||||
return result
|
||||
|
||||
|
||||
class ResearchService:
|
||||
"""研究用例入口(因子测试 / 回测)。依赖注入 Repository 与引擎。"""
|
||||
|
||||
@@ -114,11 +268,31 @@ class ResearchService:
|
||||
daily_repo: DailyBarRepository,
|
||||
engine: QuantEngine,
|
||||
index_repo=None,
|
||||
basic_repo=None,
|
||||
financial_repo=None,
|
||||
name_repo=None,
|
||||
) -> None:
|
||||
self._stock_repo = stock_repo
|
||||
self._daily_repo = daily_repo
|
||||
self._engine = engine
|
||||
self._index_repo = index_repo
|
||||
# 名称变更历史仓储:universe.exclude_st 的**时点**口径依据。
|
||||
# 未注入 → 回退 stock.name 最新快照(旧行为,结果页会如实标注残余偏差)。
|
||||
self._name_repo = name_repo
|
||||
# 每日指标仓储(daily_basic):仅当 spec 因子引用 dv_ratio 等列时使用;
|
||||
# 未注入而在 spec 中引用 → 明确报错,不做静默降级(否则因子会全是 NaN)。
|
||||
self._basic_repo = basic_repo
|
||||
# 注:复权(qfq/hfq)折算在行情仓储 `stream_range_many_columns` 的 SQL 内完成
|
||||
# (与成交价同源,v3 §20.5),业务层不需要 adjust_factor 仓储;缺口统计走
|
||||
# `self._daily_repo.count_price_adjust_gaps`。
|
||||
# 财务仓储:spec.conditions 引用 fundamental.* 时使用
|
||||
self._financial_repo = financial_repo
|
||||
# 最近一次装配的复权因子缺口报告(未复权时为 None),用于结果如实标注
|
||||
self.last_adjust_gaps: dict | None = None
|
||||
# 最近一次装配的 exclude_st 名称口径(None = 未启用 ST 过滤)
|
||||
self.last_name_basis: dict | None = None
|
||||
# 最近一次装配的 universe 股票列表(条件求值需要 static.* 元数据)
|
||||
self._last_stocks: list = []
|
||||
|
||||
def run_factor_test(
|
||||
self, spec: ResearchSpec, horizon_days: int = 21, on_stage=None
|
||||
@@ -140,9 +314,12 @@ class ResearchService:
|
||||
_stage(on_stage, "data_loading")
|
||||
daily = self._load_daily(spec)
|
||||
_stage(on_stage, "backtesting")
|
||||
result = self._engine.run_backtest(daily, spec)
|
||||
eligibility = self._build_eligibility(spec, daily)
|
||||
result = self._engine.run_backtest(daily, spec, eligibility_fn=eligibility)
|
||||
_stage(on_stage, "analysis")
|
||||
return result
|
||||
self._annotate_price_basis(result, spec)
|
||||
# 两个引擎都在此汇合:名称回填只做一次(`_last_stocks` 在 _load_daily 中装配)
|
||||
return _fill_names(result, self._last_stocks)
|
||||
|
||||
def run_factor_correlation(self, spec: ResearchSpec) -> FactorCorrelationReport:
|
||||
"""多因子两两相关(v3 §12):同 universe/period 装配 → 横截面相关矩阵。"""
|
||||
@@ -156,20 +333,230 @@ class ResearchService:
|
||||
start, end = spec.period
|
||||
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
|
||||
data_start = start - timedelta(days=300)
|
||||
all_stocks = self._stock_repo.list()
|
||||
name_at, applied = names_as_of(all_stocks, start, self._name_repo)
|
||||
stocks = filter_stocks(
|
||||
self._stock_repo.list(), spec.universe, as_of=start,
|
||||
all_stocks, spec.universe, as_of=start,
|
||||
members=resolve_members(self._index_repo, spec.universe, start),
|
||||
name_at=name_at,
|
||||
)
|
||||
# 供结果如实标注:exclude_st 是否用了时点名称、覆盖了多少只
|
||||
self.last_name_basis = (
|
||||
{"point_in_time": applied[0], "covered": applied[1], "total": len(all_stocks)}
|
||||
if spec.universe.exclude_st
|
||||
else None
|
||||
)
|
||||
self._last_stocks = stocks
|
||||
# 引擎所需列裁剪(LocalEngine 只取 close + 因子字段;Qlib 回测取全 OHLCV)
|
||||
required = self._engine.required_columns(spec)
|
||||
return load_daily_df(
|
||||
bar_cols, basic_cols = split_factor_columns(required)
|
||||
symbols = [s.symbol for s in stocks]
|
||||
# 行集口径恒为 none(主口径);复权折算由 price_adjust 承担(v3 §20.5 严格分离)
|
||||
daily = load_daily_df(
|
||||
self._daily_repo,
|
||||
[s.symbol for s in stocks],
|
||||
symbols,
|
||||
data_start,
|
||||
end,
|
||||
sorted(required),
|
||||
adjust=spec.price_adjustment,
|
||||
sorted(bar_cols),
|
||||
adjust="none",
|
||||
price_adjust=spec.price_adjustment,
|
||||
)
|
||||
if spec.price_adjustment != "none":
|
||||
self.last_adjust_gaps = self._adjust_gap_report(symbols, data_start, end)
|
||||
if basic_cols:
|
||||
daily = self._attach_basic(daily, symbols, data_start, end, sorted(basic_cols))
|
||||
return daily
|
||||
|
||||
def _adjust_gap_report(self, symbols: list[str], start: date, end: date) -> dict:
|
||||
"""复权因子缺口统计(如实上报「按 1.0 兜底未折算」的行占比)。"""
|
||||
counter = getattr(self._daily_repo, "count_price_adjust_gaps", None)
|
||||
if counter is None:
|
||||
return {"checked": False, "reason": "仓储未实现 count_price_adjust_gaps"}
|
||||
total, missing = counter(symbols, start, end)
|
||||
return {
|
||||
"checked": True,
|
||||
"rows": total,
|
||||
"rows_without_factor": missing,
|
||||
"missing_pct": round(missing / total * 100, 4) if total else 0.0,
|
||||
}
|
||||
|
||||
def _build_eligibility(self, spec: ResearchSpec, daily: pd.DataFrame):
|
||||
"""按 spec.conditions 构造「择股日 → 合格股票集合」的求值闭包(可选)。
|
||||
|
||||
与选股路径共用 `build_condition_fields` / `eligible_symbols`,因此
|
||||
「历史某日 /api/selections 的候选」与「回测在该日的候选池」由同一实现产出
|
||||
(v2 §25 / v3 §28 一致性要求)。逐择股日结果缓存,避免重复计算。
|
||||
|
||||
未来函数红线:条件字段只取 <= as_of 的截面;fundamental.* 只取
|
||||
announce_date <= as_of 的已公告值(由仓储 list_announced_many 保证)。
|
||||
"""
|
||||
if not self._last_stocks:
|
||||
if not spec.conditions:
|
||||
return None
|
||||
raise ValueError("universe 过滤结果为空,无法构造选股条件求值器")
|
||||
statics = {s.symbol: s.model_dump() for s in self._last_stocks}
|
||||
candidates = sorted(statics)
|
||||
# 时点 ST 过滤:exclude_st 必须在**每个择股日**按当时名称重判,否则
|
||||
# 「2020 年入池、2022 年才变 ST」的标的会一直被当作合格候选(与
|
||||
# /api/selections 的单时点语义不一致,v2 §25)。名称历史缺失时降级为
|
||||
# 池子基准日口径(旧行为),由结果 name_basis 如实标注。
|
||||
st_fn = self._build_st_filter(spec, candidates)
|
||||
if not spec.conditions:
|
||||
if st_fn is None:
|
||||
return None
|
||||
# 只有 ST 约束时,eligible 必须是「候选 − 该日 ST」,不能直接返回 st_fn
|
||||
# (st_fn 返回的是**不合格**集合,直接返回会把语义取反)
|
||||
allowed_cache: dict[date, set[str]] = {}
|
||||
|
||||
def _st_only(as_of: date) -> set[str]:
|
||||
if as_of not in allowed_cache:
|
||||
allowed_cache[as_of] = set(candidates) - st_fn(as_of)
|
||||
return allowed_cache[as_of]
|
||||
|
||||
return _st_only
|
||||
uses_fundamental = any(
|
||||
f.startswith("fundamental.")
|
||||
for c in spec.conditions
|
||||
for f in (c.field, c.ref or "")
|
||||
)
|
||||
cache: dict[date, set[str]] = {}
|
||||
|
||||
def _fn(as_of: date) -> set[str]:
|
||||
if as_of in cache:
|
||||
return cache[as_of]
|
||||
financial = (
|
||||
self._load_financial(candidates, as_of) if uses_fundamental else {}
|
||||
)
|
||||
fields = build_condition_fields(daily, spec.conditions, pd.Timestamp(as_of))
|
||||
if not fields:
|
||||
cache[as_of] = set()
|
||||
return cache[as_of]
|
||||
passed = set(
|
||||
eligible_symbols(candidates, spec.conditions, statics, fields, financial)
|
||||
)
|
||||
if st_fn is not None:
|
||||
passed -= st_fn(as_of) # 该日名称含 ST → 不合格
|
||||
cache[as_of] = passed
|
||||
return cache[as_of]
|
||||
|
||||
return _fn
|
||||
|
||||
def _build_st_filter(self, spec: ResearchSpec, candidates: list[str]):
|
||||
"""「择股日 → 该日名称含 ST 的股票集合」;不可用(未启用/未注入)时返回 None。
|
||||
|
||||
与 `filter_stocks` 共用 `names_as_of`,因此回测在任一择股日的
|
||||
`exclude_st` 结果与该日 `/api/selections` 的候选池同口径(v2 §25)。
|
||||
逐日缓存;每次查询只取该日生效的名称区间。
|
||||
"""
|
||||
if not spec.universe.exclude_st or self._name_repo is None:
|
||||
return None
|
||||
cache: dict[date, set[str]] = {}
|
||||
|
||||
def _fn(as_of: date) -> set[str]:
|
||||
if as_of not in cache:
|
||||
name_at, applied = names_as_of(self._last_stocks, as_of, self._name_repo)
|
||||
if not applied[0]:
|
||||
# 无时点名称可用(表为空/查询失败):整段时间都退回池子基准日口径,
|
||||
# 不逐日过滤 —— 与 filter_stocks 的回退一致,且 name_basis 会标注 false
|
||||
cache[as_of] = set()
|
||||
else:
|
||||
# 逐股优先时点名称、缺失回退最新名称快照 —— 必须与
|
||||
# filter_stocks 的 `(name_at or {}).get(sym) or s.name` 完全同口径,
|
||||
# 否则同一择股日「回测入选」与「/api/selections 排除」会打架(v2 §25)
|
||||
st_syms: set[str] = set()
|
||||
for st in self._last_stocks:
|
||||
nm = (name_at or {}).get(st.symbol) or st.name
|
||||
if nm and "ST" in nm.upper():
|
||||
st_syms.add(st.symbol)
|
||||
cache[as_of] = st_syms
|
||||
return cache[as_of]
|
||||
|
||||
return _fn
|
||||
|
||||
def _load_financial(self, symbols: list[str], as_of: date) -> dict[str, Any]:
|
||||
"""按 announce_date <= as_of 批量取财务,每 symbol 保留最新一版。"""
|
||||
if self._financial_repo is None:
|
||||
raise ValueError(
|
||||
"回测条件引用了 fundamental.* 字段,但未注入 FinancialRepository。"
|
||||
"请检查 API/CLI 的依赖装配。"
|
||||
)
|
||||
getter = getattr(self._financial_repo, "list_announced_many", None)
|
||||
if getter is not None:
|
||||
rows = list(getter(symbols, as_of))
|
||||
else: # 兜底:逐只取最新已公告
|
||||
rows = []
|
||||
for sym in symbols:
|
||||
latest = self._financial_repo.latest_announced(sym, as_of)
|
||||
if latest is not None:
|
||||
rows.append(latest)
|
||||
out: dict[str, FinancialIndicator] = {}
|
||||
for r in rows: # 约定升序返回 → 后者覆盖前者即「最新一版」
|
||||
out[r.symbol] = r
|
||||
return out
|
||||
|
||||
def _annotate_price_basis(self, result, spec: ResearchSpec) -> None:
|
||||
"""把价格口径写入结果(v3 §20.5:adjust_mode / price_basis / execution_price_basis)。
|
||||
|
||||
同时如实标注复权因子缺口(AGENT.md §24:未覆盖部分必须显式说明,禁止假装支持)。
|
||||
"""
|
||||
mode = spec.price_adjustment
|
||||
result.config_snapshot["price_basis"] = {
|
||||
"adjust_mode": mode,
|
||||
"price_basis": "adjust_factor" if mode != "none" else "raw_close",
|
||||
"execution_price_basis": "close_adj" if mode != "none" else "close_raw",
|
||||
"adjust_gaps": self.last_adjust_gaps,
|
||||
# exclude_st 的名称口径:时点(stock_name_history)还是最新快照
|
||||
"name_basis": self.last_name_basis,
|
||||
}
|
||||
nb = self.last_name_basis
|
||||
if nb is not None:
|
||||
if nb["point_in_time"]:
|
||||
result.unimplemented.append(
|
||||
f"exclude_st 采用**时点名称**(stock_name_history,覆盖 {nb['covered']} 只生效名称):"
|
||||
"「曾为高股息、后变 ST」的标的在其非 ST 期间会被正常纳入(股息陷阱可见)"
|
||||
)
|
||||
else:
|
||||
result.unimplemented.append(
|
||||
"exclude_st 回退**最新名称快照**(名称变更历史表未同步/未注入):"
|
||||
"「曾为高股息、后变 ST/退市」的标的会被整段排除,收益可能被高估;"
|
||||
"修复:python -m app.cli.sync namechange"
|
||||
)
|
||||
if mode == "none":
|
||||
return
|
||||
label = "后复权(hfq)" if mode == "hfq" else "前复权(qfq)"
|
||||
result.unimplemented.append(
|
||||
f"价格口径为{label}:价格列 = 原始价 × adjust_factor"
|
||||
+ (";现金分红按复权因子隐含再投资处理" if mode == "hfq" else "(以前复权归一)")
|
||||
+ ";volume/amount 不做复权折算"
|
||||
)
|
||||
gaps = self.last_adjust_gaps or {}
|
||||
if gaps.get("checked") and gaps.get("rows_without_factor", 0) > 0:
|
||||
result.unimplemented.append(
|
||||
f"复权因子覆盖不全:{gaps['rows_without_factor']} / {gaps['rows']} 行"
|
||||
f"({gaps['missing_pct']}%)无对应 adjust_factor,已按系数 1.0 兜底(未折算)"
|
||||
)
|
||||
|
||||
def _attach_basic(
|
||||
self,
|
||||
daily: pd.DataFrame,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
columns: list[str],
|
||||
) -> pd.DataFrame:
|
||||
"""把 daily_basic 列并入行情长表(dv_ratio 等因子依赖)。"""
|
||||
if self._basic_repo is None:
|
||||
raise ValueError(
|
||||
f"因子需要每日指标列 {columns}(daily_basic),但未注入 DailyBasicRepository。"
|
||||
"请检查 API/CLI 的依赖装配。"
|
||||
)
|
||||
basic = load_basic_df(self._basic_repo, symbols, start, end, columns)
|
||||
if basic.empty:
|
||||
raise ValueError(
|
||||
f"daily_basic 表在 {start}~{end} 无数据,无法计算需要 {columns} 的因子。"
|
||||
"请先运行:python -m app.cli.sync daily_basic --start 20200101"
|
||||
)
|
||||
return merge_basic_into_daily(daily, basic)
|
||||
|
||||
|
||||
def _stage(cb, name: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user