功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层

说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
2026-10-05 11:57:13 +08:00
parent 14ec0c6c86
commit cf6d4d2c56
36 changed files with 7385 additions and 285 deletions
+557
View File
@@ -0,0 +1,557 @@
"""每日动态股票池回测(``backtest --mode daily``)。
**它回答的问题**:给定一个起点(如 2020-01-05),如果从那天起按这套规则
**每个交易日**重新选股、每个交易日判断买卖点,实际会发生什么。
**与 walk-forward 的区别(不是同一件事,也不互相替代)**:
============================ ========================================== ==========================================
``--mode walkforward`` ``--mode daily``
============================ ========================================== ==========================================
回答的问题 参数在样本外能否复现(过拟合检验) 从某天起连续实盘推演会怎样
时间结构 切多个 (train, test) 窗口 一条连续的 [start, latest]
阈值口径 测试段**冻结**训练段分布 一律 rolling(PIT 滚动窗口)
股票池 每个窗口/调仓日按 asof 重筛 **每个交易日**按 asof 重筛
持仓掉出股票池 ——(每个窗口独立重来) pool_exit_action:只减不加 / 清仓
产出 多窗口样本外统计 一条净值 + 逐日选股 + 逐笔信号(含画像留痕)
============================ ========================================== ==========================================
daily 模式**没有训练段**,因此不存在「冻结分布」;它检验的不是参数稳定性,
而是「动态股票池下这套规则的连续表现」。两者应当**都看**,不要只采信一个。
**实现要点**:筛选与模拟分成两趟,共用一个 :class:`~hdiv.universe.pit.PitRepo`:
1. **选股趟**:行情/每日指标按区块(默认一年)批量预载,逐交易日在内存里
跑**原有的** ``UniverseSelector`` 与四个 ``Filter``(口径一行未改),
记录每天的入选成员后释放区块 —— 内存峰值是一个区块,不随区间长度增长;
2. **模拟趟**:把「交易日 → 股票池」交给原有的
:class:`~hdiv.backtest.engine.BacktestEngine`,由它完成撮合、成本、分红、
公司行为、盯市与绩效。引擎在 daily 模式下只改了四件事:
股票池按交易日切换、信号按交易日评估、池外持仓只减不加、买卖决策附画像留痕。
**没有未来函数**:每个决策日的股票池只用 ``<= 该日`` 的行情、财报(
``ann_date <= 该日``)与已实施分红(``imp_ann_date <= 该日`` 且 ``ex_date <= 该日``)。
这一点由既有 PIT 纪律与滤网实现保证,daily 模式只是把它的**调用频率**提到每日。
"""
from __future__ import annotations
import json
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
import pandas as pd
from hdiv.core.config import BacktestConfig, DailyConfig, load_config
from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db
from hdiv.data.repo import data_version
from hdiv.data.sync.base import stable_id
from hdiv.strategy.registry import StrategyRegistry
from hdiv.universe.daily import DailyUniverseScreener, ScreenDay
from hdiv.universe.pit import PitRepo
__all__ = ["DailyRunner", "DailyProgress"]
#: 实测速率(本机、本数据集),仅用于「预计耗时」提示,不参与任何业务判定。
#:
#: 2026-10-05 用**同一区间**实测两次(2026-08-01 起 43 个交易日,
#: 池内 30~40 只、面板 41 只):
#:
#: 选股每 1 日 / 信号每 1 日 → 选股 114 秒 + 模拟 131 秒
#: 选股每 5 日 / 信号每 5 日 → 选股 43 秒 + 模拟 约 28 秒
#:
#: 由此把模拟拆成两项 —— **只有第二项随信号频率下降**:
#: ``BASE`` 每个交易日都要做的盯市 / 分红 / 持仓记账(很小)
#: ``SIGNAL`` **每次「评估买卖」**的开销(收益率序列 + 逐笔决策画像),
#: 与信号频率成反比
#: 早先把整段模拟都写成「随交易日数增长」,会把粗粒度方案的耗时**高估**约 1.6 倍。
#: 注意这组常数来自 2026 年窗口;全区间面板是 123 只(此处 41 只),实际会更慢。
_SEC_PER_SCREEN_DAY = 2.6
_SEC_PER_SIM_DAY_BASE = 0.05
_SEC_PER_SIM_SIGNAL = 3.0
_SEC_PER_CHUNK = 25.0
class DailyProgress:
"""逐日选股的进度打印(默认每 N 个交易日一行)。
为什么必须打印:每日全市场筛选是分钟级到小时级的操作,静默运行会让人
无法区分「在算」和「卡死」;而不打印时长的估计,用户也无从判断该不该
用更粗的 ``universe_refresh_days``。
"""
def __init__(self, total: int, every: int, *, enabled: bool = True) -> None:
self.total = total
self.every = max(1, int(every))
self.enabled = enabled
self.t0 = time.time()
self.done = 0
def tick(self, label: str = "") -> None:
self.done += 1
if not self.enabled:
return
if self.done % self.every and self.done != self.total:
return
el = time.time() - self.t0
rate = el / max(self.done, 1)
eta = rate * max(self.total - self.done, 0)
print(
f" 选股 {self.done}/{self.total} {el:,.0f}s"
f"({rate:.2f}s/日,预计剩余 {eta / 60:,.1f} 分钟){label}",
flush=True,
)
class DailyRunner:
"""每日动态股票池回测的执行器。"""
def __init__(
self,
strategy_path: str | Path = "config/strategy/high_dividend_v1.yml",
) -> None:
db.load_dotenv_once()
self.registry = StrategyRegistry()
self.strategy = self.registry.load(strategy_path)
self.bt: BacktestConfig = load_config("backtest")
self.daily: DailyConfig = self.bt.daily
@classmethod
def from_strategy(cls, path: str | Path) -> DailyRunner:
return cls(path)
# ------------------------------------------------------------------
# 主流程
# ------------------------------------------------------------------
def run(
self,
*,
start: date | None = None,
end: date | None = None,
persist: bool = True,
verbose: bool = True,
refresh_pools: bool = False,
every_n_days: int | None = None,
signal_every_n_days: int | None = None,
) -> dict[str, Any]:
"""执行每日(或每 N 日)动态股票池回测。
两个**频率**参数是仅有的速度旋钮,语义不同、代价不同:
- ``every_n_days``:股票池**重建**频率(交易日)。1 = 每个交易日重筛。
只影响「选股」那一趟的耗时(近似线性下降);两次重建之间池子不变。
- ``signal_every_n_days``:买卖**判断**频率(交易日)。1 = 每个交易日判断。
影响「模拟」那一趟(信号评估 + 逐笔决策画像),也直接影响交易机会数量。
两者都**不改变判定规则**,只改变「多久看一次」。所以调大它们得到的是
「粗粒度版本」,不是同一策略的加速版 —— 交易机会与换手都会下降,
结果不可与逐日口径直接比较。详见用户手册 §5.7b 的对照表。
"""
from hdiv.backtest.engine import BacktestEngine
repo = PitRepo()
days_all = self._trading_days(repo, start, end)
start, end = days_all[0], days_all[-1]
if len(days_all) < 2:
raise DataGapError(f"{start} ~ {end} 交易日不足,无法回测")
step = max(1, int(every_n_days or self.daily.universe_refresh_days))
sig_step = max(
1, int(signal_every_n_days or self.daily.signal_frequency_days)
)
screen_days = days_all[::step]
if verbose:
print(
f"每日动态股票池回测 {self.strategy.strategy.id} "
f"v{self.strategy.strategy.version}:"
f"{start} ~ {end}({len(days_all)} 个交易日)",
flush=True,
)
print(
f" 选股频率:每 {step} 个交易日"
f"(共 {len(screen_days)} 次筛选);"
f"信号频率:每 {sig_step} 个交易日;"
f"池外持仓:{self.daily.pool_exit_action}",
flush=True,
)
if step > 1 or sig_step > 1:
print(
f" 注意:粗粒度口径 —— 股票池每 {step} 个交易日才重建、"
f"买卖每 {sig_step} 个交易日才判断。\n"
f" 判定规则未变,但机会数量与换手低于逐日口径,"
f"结果不可与逐日口径直接比较。",
flush=True,
)
if len(screen_days) > 60:
# 成本要**先说清楚**:全市场筛选是分钟级到小时级操作,
# 让人先看到预计时长,再决定是否继续(或改用更粗的频率/更短区间)。
n_chunks = max(
1,
len({d.year for d in screen_days})
// max(1, int(self.daily.chunk_years)),
)
est = (
len(screen_days) * _SEC_PER_SCREEN_DAY
+ len(days_all) * _SEC_PER_SIM_DAY_BASE
+ len(days_all) * _SEC_PER_SIM_SIGNAL / sig_step
+ n_chunks * _SEC_PER_CHUNK
+ 20.0
)
print(
f" 预计耗时约 {est / 60:,.1f} 分钟"
f"(按实测 选股 {_SEC_PER_SCREEN_DAY:.1f}s/次、"
f"模拟 {_SEC_PER_SIM_DAY_BASE:.1f}s/交易日 + "
f"{_SEC_PER_SIM_SIGNAL:.1f}s/次信号评估 估算;"
f"机器与数据量不同会有出入)",
flush=True,
)
print(
" ⚠ 区间较长。可按需选择速度方案(详见手册 §5.7b):\n"
" --every-n-days 5 股票池每 5 个交易日重建一次(选股耗时 ~÷5)\n"
" --signal-every-n-days 5 买卖每 5 个交易日判断一次(模拟耗时大幅下降)\n"
" 两者可叠加;也可缩短 --start/--end,或改 config/backtest.yml 的 daily 段。",
flush=True,
)
# --- 参照数据(财务/分红/日历)一次载入,全程常驻 ---
t0 = time.time()
repo.load_reference(end=end)
if verbose:
print(f" 已载入参照数据(财报/分红/交易日历):{time.time() - t0:,.1f}s",
flush=True)
# --- 第一趟:逐区块选股(可按可复现的指纹复用缓存)---
#
# **为什么必须有缓存**:选股这一趟在 6.7 年区间上要 1 小时以上,而它完全
# 由(策略 + 区间 + 筛选配置 + 刷新频率)唯一决定。第二趟模拟若因任何原因
# 失败或需要重跑,重新筛一遍纯属浪费 —— 实测一次失败就白烧掉一小时。
# 缓存键只含**输入**(不含时间戳),所以「同样的输入 ⇒ 同样的选股结果」,
# 复用它不引入任何未来信息。
cache_key = self._pools_cache_key(start, end, step)
cached = None if refresh_pools else self._read_cache(cache_key)
if cached is not None:
pools, member_rows, prune_stats, screen_dates = cached
screens = []
if verbose:
print(
f" 复用已缓存的选股结果:{len(pools)} 个决策时点"
f"(省去逐日筛选;加 --refresh-pools 可强制重筛)",
flush=True,
)
else:
# --- 保守预剪枝 ---
screener = DailyUniverseScreener.from_strategy(
self.registry, self.strategy, repo, verbose=False
)
allowed = screener.build_prune_set(start, end)
if verbose:
p = screener.prune.as_dict()
print(
f" 候选集预剪枝:{p['total']} → {p['kept']} 只"
f"(剔除 交易所 {p['pruned_exchange']}、板块 {p['pruned_board']}、"
f"上市年限 {p['pruned_listing']}、市值 {p['pruned_market_cap']})"
f";被剔除者在本区间内不可能通过市场滤网,不影响最终入选",
flush=True,
)
screens = self._screen_all(repo, screener, screen_days, verbose=verbose)
pools = {s.trade_date: set(s.symbols) for s in screens}
prune_stats = screener.prune.as_dict()
screen_dates = sorted(pools)
member_rows = DailyUniverseScreener.member_rows(
"", screens, created_at=datetime.now()
)
self._write_cache(cache_key, pools, member_rows, prune_stats, verbose=verbose)
nonempty = sum(1 for v in pools.values() if v)
if verbose:
sizes = [len(v) for v in pools.values()]
print(
f" 选股完成:{nonempty}/{len(pools)} 个决策日选出非空股票池,"
f"成员数 {min(sizes) if sizes else 0}~{max(sizes) if sizes else 0},"
f"累计出现过的股票 {len(set().union(*pools.values())) if pools else 0} 只",
flush=True,
)
if not nonempty:
raise DataGapError(
"每日选股未产出任何非空股票池:请检查筛选条件与数据覆盖。"
)
# --- 第二趟:模拟(复用既有引擎)---
engine = BacktestEngine(
self.strategy,
backtest=self.bt,
repo=repo,
universe_by_refresh=pools,
signal_frequency_days=sig_step,
pool_exit_action=self.daily.pool_exit_action,
profile_on_trade=bool(self.daily.profile_on_trade),
)
result = engine.run(
start=start, end=end, persist=persist, mode="daily", verbose=verbose
)
# --- 每日选股留痕(行内容来自选股趟,run_id 来自本次模拟)---
result["daily_screening"] = {
"screen_count": len(pools),
"screen_days": [str(d) for d in screen_dates],
"pool_size_min": min(len(v) for v in pools.values()),
"pool_size_max": max(len(v) for v in pools.values()),
"pool_size_mean": sum(len(v) for v in pools.values()) / len(pools),
"distinct_symbols": len(set().union(*pools.values())),
"prune": prune_stats,
"refresh_days": step,
"signal_every_days": sig_step,
"pool_exit_action": self.daily.pool_exit_action,
"profile_on_trade": bool(self.daily.profile_on_trade),
"repo": repo.stats(),
"pools_from_cache": cached is not None,
}
if persist and self.daily.persist_daily_universe:
result["written_daily_universe"] = self._persist_members(
result["run_id"], member_rows, verbose=verbose
)
if verbose:
self._print_changes(pools)
return result
# ------------------------------------------------------------------
# 选股
# ------------------------------------------------------------------
def _screen_all(
self,
repo: PitRepo,
screener: DailyUniverseScreener,
screen_days: list[date],
*,
verbose: bool,
) -> list[ScreenDay]:
"""按年分块预载行情 → 区内逐日筛选 → 释放区块。"""
chunks = _chunks(screen_days, int(self.daily.chunk_years))
progress = DailyProgress(
len(screen_days), int(self.daily.progress_every_days), enabled=verbose
)
out: list[ScreenDay] = []
for ci, chunk in enumerate(chunks, start=1):
t0 = time.time()
repo.load_range(chunk[0], chunk[-1])
if verbose:
print(
f" 区块 {ci}/{len(chunks)} {chunk[0]} ~ {chunk[-1]}"
f"({len(chunk)} 个交易日,已载入行情 "
f"{repo.stats()['market_rows']:,} 行 / "
f"{time.time() - t0:,.1f}s)",
flush=True,
)
try:
for d in chunk:
sd = screener.screen_day(d)
out.append(sd)
progress.tick(f"{d} 池内 {sd.member_count} 只")
finally:
repo.release_range()
return out
# ------------------------------------------------------------------
# 选股结果缓存(让「选股一小时的成果」不会因模拟失败而丢失)
# ------------------------------------------------------------------
@staticmethod
def _cache_path(key: str) -> Path:
from hdiv.core.paths import output_dir
return output_dir("output") / "cache" / f"daily_pools_{key}.json"
def _pools_cache_key(self, start: date, end: date, step: int) -> str:
"""选股缓存的指纹:**只由输入决定**,不含时间戳。
``step``(股票池重建频率)必须在键里:用 1 日/5 日筛出的池子是不同的输入,
共用一份缓存会静默给出错的股票池。信号频率(``--signal-every-n-days``)
**不在**键里 —— 它只影响模拟,不影响选股结果。
"""
return stable_id(
"dailypools",
self.strategy.strategy.id,
self.strategy.strategy.version,
self.registry.hash_of(self.strategy),
str(start), str(end), str(int(step)),
)
def _read_cache(self, key: str) -> tuple[Any, list[dict], dict, list[date]] | None:
p = self._cache_path(key)
if not p.is_file():
return None
try:
blob = json.loads(p.read_text(encoding="utf-8"))
pools = {
date.fromisoformat(d): set(v)
for d, v in (blob.get("pools") or {}).items()
}
if not pools:
return None
return (
pools,
list(blob.get("member_rows") or []),
dict(blob.get("prune") or {}),
[date.fromisoformat(x) for x in (blob.get("screen_days") or [])],
)
except Exception:
# 缓存损坏一律视为「没有缓存」并重新筛选 —— 绝不因为一个坏文件而
# 用错的股票池回测(那会静默改变结果)。
return None
def _write_cache(
self, key: str, pools: dict[date, set[str]], member_rows: list[dict],
prune: dict, *, verbose: bool,
) -> None:
p = self._cache_path(key)
try:
p.parent.mkdir(parents=True, exist_ok=True)
rows = []
for r in member_rows:
r = dict(r)
r.pop("created_at", None) # 由落库时统一填充
r["trade_date"] = str(r["trade_date"])
rows.append(r)
p.write_text(
json.dumps(
{
"pools": {str(d): sorted(v) for d, v in pools.items()},
"member_rows": rows,
"prune": prune,
"screen_days": [str(d) for d in sorted(pools)],
},
ensure_ascii=False, default=str,
),
encoding="utf-8",
)
if verbose:
mb = p.stat().st_size / 1e6
print(f" 选股结果已缓存:{p}({mb:,.1f} MB,供重跑复用)", flush=True)
except Exception as exc: # pragma: no cover - 缓存失败不该影响回测
if verbose:
print(f" ⚠ 选股结果缓存写入失败(不影响本次运行):{exc}", flush=True)
# ------------------------------------------------------------------
# 落库
# ------------------------------------------------------------------
def _persist_members(
self, run_id: str, member_rows: list[dict], *, verbose: bool
) -> int:
cfg = load_config("datasource")
if not db.table_exists("hd_daily_universe", cfg):
if verbose:
print(
" ⚠ hd_daily_universe 不存在,跳过每日选股留痕。"
"请先执行 `python -m hdiv ddl apply`。",
flush=True,
)
return 0
if not member_rows:
return 0
now = datetime.now()
rows = []
for r in member_rows:
r = dict(r)
r["run_id"] = run_id
r["created_at"] = now
td = r.get("trade_date")
r["trade_date"] = (
td if isinstance(td, date) else date.fromisoformat(str(td))
)
rows.append(r)
n = 0
for i in range(0, len(rows), 2000):
n += db.upsert_dataframe(
"hd_daily_universe",
pd.DataFrame(rows[i : i + 2000]),
cfg=cfg,
update_columns=[
"name", "industry", "dividend_yield", "total_mv", "roe_avg",
"listed_count", "candidate_count", "values_json",
],
)
if verbose:
print(f" 每日选股留痕:hd_daily_universe {n} 行"
f"({len({r['trade_date'] for r in rows})} 个时点)", flush=True)
return n
# ------------------------------------------------------------------
# 辅助
# ------------------------------------------------------------------
def _trading_days(
self, repo: PitRepo, start: date | None, end: date | None
) -> list[date]:
"""确定回测区间(含 ``end=latest`` 与「起点对齐到交易日」)。"""
from hdiv.data.repo import Repo
plain = Repo()
s = start or self.bt.period.start
raw_end = end or (
plain.trading_day(None) if self.bt.period.end == "latest"
else self.bt.period.end
)
# 用直连 Repo 定位端点(PitRepo 此时尚未载入日历)
s = plain.trading_day(s)
e = plain.trading_day(raw_end)
if e <= s:
raise HdivError(f"回测区间非法:{s} ~ {e}")
days = plain.trading_days(s, e)
if len(days) < 2:
raise DataGapError(f"{s} ~ {e} 交易日不足,无法回测")
return days
def _print_changes(self, pools: dict[date, set[str]]) -> None:
"""打印股票池的进出(动态池最值得看的东西)。"""
prev: set[str] | None = None
enter = exit_ = 0
events: list[str] = []
for day in sorted(pools):
cur = pools[day]
if prev is not None:
add, drop = cur - prev, prev - cur
enter += len(add)
exit_ += len(drop)
if (add or drop) and len(events) < 8:
events.append(
f" {day}: +{len(add)} -{len(drop)}"
+ (f" 进 {'、'.join(sorted(add)[:4])}" if add else "")
+ (f" 出 {'、'.join(sorted(drop)[:4])}" if drop else "")
)
prev = cur
if not pools:
return
n = max(len(pools) - 1, 1)
print(
f" 股票池变动:累计进入 {enter} 次 / 移出 {exit_} 次"
f"(平均每日 {enter / n:.1f} 进 {exit_ / n:.1f} 出)",
flush=True,
)
for line in events:
print(line, flush=True)
def _chunks(days: list[date], chunk_years: int) -> list[list[date]]:
"""按自然年(或 chunk_years 年)切块。"""
if not days:
return []
n = max(1, int(chunk_years))
out: list[list[date]] = []
cur: list[date] = []
bucket = days[0].year
for d in days:
if d.year >= bucket + n:
out.append(cur)
cur = []
bucket = d.year
cur.append(d)
if cur:
out.append(cur)
return out
+396 -108
View File
@@ -146,6 +146,50 @@ class CostModel:
return float(dt.rates.get("gt1y", 0.0))
def dividend_handling_notes(bt_cfg: BacktestConfig) -> list[str]:
"""声明 ``backtest.yml`` 里「写了但引擎没实现」的分红/公司行为配置。
口径必须与 :meth:`BacktestEngine._apply_dividends` 与 :meth:`_execute`
的实际行为逐条对应 —— 配置承诺与实际行为不一致是本项目反复记录的一类缺陷:
run 记录看起来「一切正常」,使用者却以为某项规则生效了。
当前**已实现**的组合(不产生声明):
- ``cash_mode: reinvest`` + ``reinvest_rule: portfolio_rebalance``
—— 分红现金回落到**可投资现金池**,下次调仓按目标权重再配置;
这笔钱与初始资金同一个 ``cash`` 变量,可以直接用于买入(不会被隔离)。
- ``apply_dividend_tax``(总闸)+ ``cost.yml`` 的分档税率。
- ``handle_stock_dividend`` —— 送转股按 ``stk_div`` 调整股数、总成本不变。
其余取值都会逐条写入 ``hd_backtest_run.unimplemented_json``。
"""
d = bt_cfg.dividend
notes: list[str] = []
mode = str(d.cash_mode)
rule = str(d.reinvest_rule)
if mode != "reinvest":
notes.append(
f"未实现 cash_mode={mode}:引擎一律把分红现金回落到**可投资现金池**,"
f"在下次调仓按目标权重再配置(行为等价 reinvest + portfolio_rebalance)"
)
elif rule != "portfolio_rebalance":
notes.append(
f"未实现 reinvest_rule={rule}(按同一只股票再投资):实际行为等价 "
f"portfolio_rebalance —— 分红现金回落到可投资现金池,"
f"在下次调仓按目标权重再配置"
)
if not d.handle_stock_dividend:
notes.append(
"未处理送转股(handle_stock_dividend=false):股数不调整,"
"而价格是不复权价、除权日照常下跌 → 送转被记成虚假亏损"
)
if d.handle_rights_issue:
notes.append(
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
)
return notes
# ---------------------------------------------------------------------------
# 引擎
# ---------------------------------------------------------------------------
@@ -158,16 +202,26 @@ class BacktestEngine:
*,
cost: CostConfig | None = None,
backtest: BacktestConfig | None = None,
repo: Repo | None = None,
frozen_reference: tuple[date, date] | None = None,
universe_run_id: str | None = None,
allow_lookahead_universe: bool = False,
universe_by_refresh: dict[date, set[str]] | None = None,
universe_refresh_days: int | None = None,
signal_frequency_days: int | None = None,
pool_exit_action: str = "hold",
profile_on_trade: bool = False,
profile_window_years: int | None = None,
) -> None:
db.load_dotenv_once()
self.strategy = strategy
self.cost_cfg = cost or load_config("cost")
self.bt_cfg = backtest or load_config("backtest")
self.cost = CostModel(self.cost_cfg)
self.repo = Repo()
#: 取数出口。默认直连数据库;daily 模式注入已批量预载的 PitRepo,
#: 这样实时画像的逐 asof 财报面板来自内存而不是每天重查 4 张财务表
#: (后者约 5~6 秒/时点,几千个决策时点就废掉了)。
self.repo = repo if repo is not None else Repo()
self.registry = StrategyRegistry()
# frozen_reference 非空时,分位分布冻结在该区间(walk-forward 测试段必须)
self.frozen_reference = frozen_reference
@@ -183,6 +237,22 @@ class BacktestEngine:
self.gate_cfg = strategy.entry.profile_gate
self.pit: Any = None
# --- 每日动态股票池模式(--mode daily)的注入点,默认全部关闭 ---
#: 外部预先算好的「交易日 → 股票池」映射。给定时引擎**不再自行筛选**:
#: 每日选股由 DailyRunner 独立完成(它需要按区块预载行情才能跑得动),
#: 引擎只负责模拟。默认 None,行为与改造前逐字一致。
self.universe_by_refresh = universe_by_refresh
#: 股票池重建频率改以**交易日**计(1 = 每个交易日)。
#: 默认 None → 沿用 backtest.yml 的 universe_refresh_months(月)。
self.universe_refresh_days = universe_refresh_days
#: 信号评估频率改以**交易日**计(1 = 每个交易日)。
self.signal_frequency_days = signal_frequency_days
#: 持仓掉出当日股票池后的处置:hold = 只减不加(默认);sell = 清仓
self.pool_exit_action = pool_exit_action
#: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内)
self.profile_on_trade = profile_on_trade
self.profile_window_years = profile_window_years
@classmethod
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
reg = StrategyRegistry()
@@ -293,8 +363,10 @@ class BacktestEngine:
rc = result["reconciliation"]
print(
f" 期初 {result['initial_capital']:,.0f} → 期末 {result['final_capital']:,.0f}"
f" | 总收益 {result['total_return']:.2%} | CAGR {result['cagr']:.2%}"
f" | 最大回撤 {result['max_drawdown']:.2%} | Sharpe {result['sharpe']:.2f}"
f" | 总收益 {_pct(result['total_return'])}"
f" | CAGR {_pct(result['cagr'])}"
f" | 最大回撤 {_pct(result['max_drawdown'])}"
f" | Sharpe {_num(result['sharpe'])}"
f" | 成交 {result['trade_count']} 笔",
flush=True,
)
@@ -373,55 +445,91 @@ class BacktestEngine:
if self.frozen_reference
else bt.percentile_reference.lookback_years
)
data_start = date(max(days[0].year - max_years - 1, 2000), 1, 1)
# 取数起点见下面 P6 处:它要与实时画像的最长窗口取并集,
# 因此这里不再单独定义 data_start(避免出现两个"起点"口径)。
# --- 股票池:按 universe_refresh_months 周期重建(PIT)---
refresh_dates: list[date] = []
step = bt.schedule.universe_refresh_months
cur = days[0]
for d in days:
if not refresh_dates or _months_between(refresh_dates[-1], d) >= step:
refresh_dates.append(d)
del cur
universe_by_refresh: dict[date, set[str]] = {}
if self.universe_run_id:
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
# 把它套到更早的年份上就是用未来信息选股 —— 与 walk-forward
# 拒绝 --universe-run 是同一条理由,这里必须同样拒绝。
self._check_universe_asof(days[0])
# 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。
# 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。
dfu = db.read_sql(
"SELECT symbol FROM hd_universe_member "
"WHERE run_id = :r AND passed = 1 ORDER BY symbol",
{"r": self.universe_run_id}, cfg=load_config("datasource"),
)
frozen = set(dfu["symbol"].tolist()) if not dfu.empty else set()
if not frozen:
raise DataGapError(
f"股票池 {self.universe_run_id} 没有任何入选成员,无法回测"
)
for rd in refresh_dates:
universe_by_refresh[rd] = frozen
if self.universe_by_refresh is not None:
# 每日动态股票池:名单已由 DailyRunner 逐交易日算好(它必须先在
# 区块内批量预载行情才跑得动,因此不能在这里临时筛)。引擎不再筛选,
# 只按这些「决策时点」切换池子 —— 每个时点的名单仍是 PIT 的。
universe_by_refresh = {
d: set(v) for d, v in self.universe_by_refresh.items() if v
}
refresh_dates = sorted(universe_by_refresh)
if not refresh_dates:
raise DataGapError("每日选股未产出任何非空股票池,无法回测")
if verbose:
print(
f" 冻结股票池 {self.universe_run_id}:{len(frozen)} 只"
f"(不随调仓日重新筛选)",
f" 每日动态股票池:{len(refresh_dates)} 个决策时点,"
f"成员数 {min(len(v) for v in universe_by_refresh.values())}"
f"~{max(len(v) for v in universe_by_refresh.values())} 只",
flush=True,
)
else:
selector = self.registry.selector(s)
for rd in refresh_dates:
res = selector.run(asof=rd, persist=False, verbose=False)
universe_by_refresh[rd] = set(res["selected"]["symbol"].tolist())
step_days = self.universe_refresh_days
step = bt.schedule.universe_refresh_months
if step_days:
# 按交易日步进(daily 模式):每 N 个交易日重建一次
refresh_dates = days[:: max(1, int(step_days))]
else:
refresh_dates = []
for d in days:
if not refresh_dates or _months_between(refresh_dates[-1], d) >= step:
refresh_dates.append(d)
if self.universe_run_id:
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
# 把它套到更早的年份上就是用未来信息选股 —— 与 walk-forward
# 拒绝 --universe-run 是同一条理由,这里必须同样拒绝。
self._check_universe_asof(days[0])
# 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。
# 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。
dfu = db.read_sql(
"SELECT symbol FROM hd_universe_member "
"WHERE run_id = :r AND passed = 1 ORDER BY symbol",
{"r": self.universe_run_id}, cfg=load_config("datasource"),
)
frozen = set(dfu["symbol"].tolist()) if not dfu.empty else set()
if not frozen:
raise DataGapError(
f"股票池 {self.universe_run_id} 没有任何入选成员,无法回测"
)
for rd in refresh_dates:
universe_by_refresh[rd] = frozen
if verbose:
print(f" 股票池 {rd}: {len(universe_by_refresh[rd])} 只", flush=True)
print(
f" 冻结股票池 {self.universe_run_id}:{len(frozen)} 只"
f"(不随调仓日重新筛选)",
flush=True,
)
else:
selector = self.registry.selector(s)
for rd in refresh_dates:
res = selector.run(asof=rd, persist=False, verbose=False)
universe_by_refresh[rd] = set(res["selected"]["symbol"].tolist())
if verbose:
print(f" 股票池 {rd}: {len(universe_by_refresh[rd])} 只", flush=True)
all_syms = sorted(set().union(*universe_by_refresh.values())) if universe_by_refresh else []
# --- 价格(不复权)与股息率序列 ---
price = self.repo.price_history(all_syms, data_start, days[-1], adjust="none")
# --- P6:价格只取一次,覆盖「引擎需要的分位窗口」与「画像需要的最长窗口」的并集 ---
#
# 两处窗口本来是分开取的:引擎按 backtest.yml 的 lookback_years(如 5 年),
# 实时画像按 profile.yml 的 windows_years 最大值(如 10 年)。于是同一批股票
# 的价格会被查两遍,而且画像那遍起点更早。这里先把两遍并起来算起点,
# 取一次,再把同一份帧交给画像服务复用(见下面的 `price=` 参数)。
need_pit = self.gate_cfg.enabled or self.profile_on_trade
pit_years = 0
if need_pit:
from hdiv.profile.builder import ProfileBuilder
pit_years = max(ProfileBuilder.from_config().config.windows_years)
price_start = date(max(days[0].year - max(max_years, pit_years) - 1, 2000), 1, 1)
price = self.repo.price_history(all_syms, price_start, days[-1], adjust="none")
dividends = self.repo.dividend_records(days[-1], years_back=max_years + 3)
dividends = dividends[dividends["symbol"].isin(set(all_syms))]
events = build_dps_events(dividends)
@@ -434,6 +542,22 @@ class BacktestEngine:
g["trade_date"] = pd.to_datetime(g["trade_date"])
px_by_sym[sym] = g.set_index("trade_date")[["open", "close"]]
# --- P2:股息率序列**每只股票只算一次** ---
#
# 原实现每个评估日都对每只股票从零重算一遍 TTM 股息率序列
# (`ttm_dps_series(idx, events)`,idx 是从取数起点到当天的**前缀**),
# 于是同一段历史被反复计算 —— 典型 O(交易日数²):1600 天 × 池内几十只,
# 越到后期窗口越长、越慢。
#
# `ttm_dps_series` 的每一天取值只依赖「该日期 + 分红事件」,与传入的日期
# 序列里还有哪些其它日期无关(`ttm_dps_at` 与序列右端点一致就是这条性质
# 的现成证据,见 tests/test_dividend_smoothing.py)。因此可以先按股票算
# 整段序列,之后每天只是切片 —— 结果逐值相同。
_w, _g, _sm = ttm_params()
yield_by_sym: dict[str, pd.Series] = build_yield_series(
px_by_sym, events, ttm_days=_w, grace_days=_g, smooth_spikes=_sm
)
# --- 分红事件(含送转),用于持仓期间的现金与股数调整 ---
div_events = self.repo.dividend_events(days[0], days[-1])
div_events = div_events[div_events["symbol"].isin(set(all_syms))]
@@ -447,19 +571,29 @@ class BacktestEngine:
limits = self._load_limits(all_syms, days[0], days[-1])
# --- 实时画像闸门:预载跨决策日共享的面板(仅在启用时)---
if self.gate_cfg.enabled:
# profile_on_trade(daily 模式)也会用到画像:语义是「买卖决策发生时
# 计算并留痕」,不区分是否在当日池内,也不要求闸门开启。
if need_pit:
from hdiv.profile.pit import PitProfileService
self.pit = PitProfileService(window_years=self.gate_cfg.window_years)
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years),
# 与分位参照窗口(backtest.yml 的 lookback_years)是两个独立的量。
pit_start = date(max(days[0].year - self.pit.max_years - 1, 2000), 1, 1)
self.pit.prepare(all_syms, pit_start, days[-1])
self.pit.configure({r.metric for r in self.gate_cfg.rules})
window_years = (
self.profile_window_years
if self.profile_window_years is not None
else self.gate_cfg.window_years
)
self.pit = PitProfileService(window_years=window_years, repo=self.repo)
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years);
# price_start 已经把这个窗口考虑进来了,这里只需把**同一份价格帧**
# 交给它复用(P6),避免同一批股票被查两遍。
pit_start = date(max(days[0].year - pit_years - 1, 2000), 1, 1)
self.pit.prepare(all_syms, pit_start, days[-1], price=price)
if self.gate_cfg.enabled:
# 成本控制:闸门规则用不到的指标不必载入财报
self.pit.configure({r.metric for r in self.gate_cfg.rules})
if verbose:
print(
f" 实时画像闸门已启用:窗口 {self.gate_cfg.window_years} 年,"
f"{len(self.gate_cfg.rules)} 条规则,"
f" 实时画像:窗口 {window_years} 年,"
f"{'闸门 %d 条规则' % len(self.gate_cfg.rules) if self.gate_cfg.enabled else '仅留痕(闸门关闭)'},"
f"面板自 {pit_start} 起载入({len(all_syms)} 只)",
flush=True,
)
@@ -474,7 +608,8 @@ class BacktestEngine:
"div_by_date": div_by_date,
"suspend": suspend,
"limits": limits,
"data_start": data_start,
"data_start": price_start,
"yield_by_sym": yield_by_sym,
}
def _load_suspend(self, syms: list[str], start: date, end: date) -> set[tuple[str, date]]:
@@ -553,12 +688,14 @@ class BacktestEngine:
dividend_ledger: list[dict[str, Any]] = []
freq = bt.schedule.signal_frequency_months
# daily 模式:信号频率改以**交易日**计(1 = 每个交易日)
freq_days = self.signal_frequency_days
last_signal_month: tuple[int, int] | None = None
bench = self._benchmark(days)
current_universe: set[str] = set()
last_refresh: date | None = None
for day in days:
for day_index, day in enumerate(days):
# --- (0) 股票池切换 ---
if last_refresh is None or day in ctx["universe_by_refresh"]:
current_universe = ctx["universe_by_refresh"].get(
@@ -586,14 +723,22 @@ class BacktestEngine:
cash = self._apply_dividends(day, positions, ctx, cash, dividend_ledger)
# --- (3) 收盘:评估信号 ---
if (day.month, day.year) != last_signal_month:
if last_signal_month is None or _months_between(
date(last_signal_month[1], last_signal_month[0], 1), day
) >= freq:
new_signals = self._evaluate(day, cash, positions, current_universe, ctx)
pending = [x for x in new_signals if x.kind in {"BUY", "ADD", "SELL", "TRIM"}]
signals.extend([x for x in new_signals if x.kind not in {"BUY", "ADD", "SELL", "TRIM"}])
last_signal_month = (day.month, day.year)
if freq_days:
# 按交易日步进(daily 模式)。原「按月」判定的语义完全保留在
# else 分支里,两者互斥,freq_days=None 时行为与改造前一致。
do_signal = day_index % max(1, int(freq_days)) == 0
else:
do_signal = (day.month, day.year) != last_signal_month and (
last_signal_month is None
or _months_between(
date(last_signal_month[1], last_signal_month[0], 1), day
) >= freq
)
if do_signal:
new_signals = self._evaluate(day, cash, positions, current_universe, ctx)
pending = [x for x in new_signals if x.kind in {"BUY", "ADD", "SELL", "TRIM"}]
signals.extend([x for x in new_signals if x.kind not in {"BUY", "ADD", "SELL", "TRIM"}])
last_signal_month = (day.month, day.year)
# --- (4) 收盘:盯市(总市值 = 现金 + 持仓)---
pos_value = self._mark_to_market(day, positions, ctx)
@@ -643,7 +788,7 @@ class BacktestEngine:
unimplemented.add("涨跌停约束未生效(hd_limit 在回测区间内无数据,成交按可达价格近似)")
if not self._has_constraint_rows("hd_suspend", days[0], days[-1]):
unimplemented.add("停牌约束未生效(hd_suspend 在回测区间内无数据)")
# 以下三项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
# 以下各项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
# 「一切正常」,而使用者以为 backtest.yml 的 defer / reinvest 生效了。
# (配置承诺与实际行为不一致,是本项目反复记录的一类缺陷。)
# 注意字段归属:fill/dividend 在 backtest.yml;execution/risk 在策略 yml。
@@ -654,21 +799,15 @@ class BacktestEngine:
"未实现停牌/涨跌停顺延(suspended_rule / limit_up_down_rule 的 "
"defer 分支):未成交信号在当日被**丢弃**,不会顺延到下一个可成交日"
)
if str(bt.dividend.cash_mode) != "hold" or bt.dividend.reinvest_rule:
unimplemented.add(
"未实现分红再投资规则(cash_mode=reinvest / reinvest_rule):"
"现金分红按除权日入账后**留存为现金**,在下次调仓时按目标权重重新配置"
)
# 分红模式/送转/配股的声明口径集中在 dividend_handling_notes,
# 与 _apply_dividends 的实际行为一一对应(unit test 覆盖每一档取值)。
unimplemented.update(dividend_handling_notes(bt))
if s.execution.signal_to_execution != "next_open" or str(bt.fill.price) != "next_open":
unimplemented.add(
f"未实现 signal_to_execution/fill.price 的 "
f"{s.execution.signal_to_execution}/{bt.fill.price} 分支:"
f"成交固定按信号次日开盘价"
)
if bt.dividend.handle_rights_issue:
unimplemented.add(
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
)
if not bt.fill.partial_fill:
unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)")
if bt.fill.max_volume_pct is not None:
@@ -719,21 +858,14 @@ class BacktestEngine:
px_hist = ctx["px_by_sym"].get(sym)
if px_hist is None or px_hist.empty:
continue
close_hist = px_hist["close"].loc[: pd.Timestamp(day)]
if close_hist.empty:
# P2:整段股息率序列在 _prepare 里已按股票算好,这里只做切片。
# 每一天的取值只依赖「该日期 + 分红事件」,与前缀里还有哪些日期无关,
# 所以「先算整段再切前缀」与「每次按前缀重算」逐值相同 ——
# 早先的实现是后者,代价是 O(交易日数²)。
ser_all = ctx["yield_by_sym"].get(sym)
if ser_all is None or ser_all.empty:
continue
idx = pd.DatetimeIndex(close_hist.index)
# 参数从因子层的统一来源取,不再硬编码 ——
# 否则改了 profile.yml 的回测也不会变(曾如此)。
_w, _g, _sm = ttm_params()
dps = ttm_dps_series(
idx, ctx["events"].get(sym, pd.DataFrame()),
ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
)
with np.errstate(divide="ignore", invalid="ignore"):
y = np.where(close_hist.to_numpy(dtype="float64") > 0,
dps / close_hist.to_numpy(dtype="float64"), np.nan)
ser = pd.Series(y, index=idx).dropna()
ser = ser_all.loc[: pd.Timestamp(day)]
if ser.empty:
continue
current = float(ser.iloc[-1])
@@ -752,7 +884,17 @@ class BacktestEngine:
pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0)
held = sym in positions
# 成交/信号记的是**当日该股最后一个可得收盘价**(停牌时就是最近一次收盘)。
# 这与股息率序列的最后一点不是一回事:序列里只保留收益率非缺失的日期,
# 价格则必须有值才能下单,所以这里单独取一次(O(log n) 切片)。
close_hist = px_hist["close"].loc[: pd.Timestamp(day)]
if close_hist.empty:
continue
price = float(close_hist.iloc[-1])
# 动态股票池的语义核心:池子回答「今天能**买**什么」,
# 不自动回答「必须卖什么」。掉出池子的持仓在 pool_exit_action=hold
# 时只被禁止加仓,仍按股息率分位规则决定减仓/卖出。
in_pool = sym in universe
common = {
"dividend_yield": round(current, 6),
"yield_percentile": round(pct, 2),
@@ -764,6 +906,8 @@ class BacktestEngine:
"min_observations": int(
self.bt_cfg.percentile_reference.min_observations),
"close": price,
"in_universe": bool(in_pool),
"universe_size": len(universe),
}
# 统一阶梯:先算目标仓位,再决定动作。
@@ -779,48 +923,74 @@ class BacktestEngine:
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal(
sym, day, "BUY", target, current, pct, price,
{**common,
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
f"目标仓位 {target:.0%}",
"reason_cn": "股息率进入历史高位区间,达到买入阈值",
**({"profile_gate": gate} if gate else {})},
out.append(self._trade_signal(
Signal(
sym, day, "BUY", target, current, pct, price,
{**common,
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
f"目标仓位 {target:.0%}",
"reason_cn": "股息率进入历史高位区间,达到买入阈值",
**({"profile_gate": gate} if gate else {})},
), day,
))
else:
# --- 持仓掉出当日股票池 ---
if not in_pool and self.pool_exit_action == "sell":
out.append(self._trade_signal(Signal(
sym, day, "SELL", 0.0, current, pct, price,
{**common,
"rule": f"掉出当日股票池(池内 {len(universe)} 只),"
f"pool_exit_action=sell → 清仓",
"reason_cn": "动态股票池移出,按配置清仓"},
), day))
continue
if target is None:
continue # 死区:保持仓位
if abs(target) <= 1e-9:
out.append(Signal(
out.append(self._trade_signal(Signal(
sym, day, "SELL", 0.0, current, pct, price,
{**common,
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
))
), day))
elif target < 1.0:
out.append(Signal(
out.append(self._trade_signal(Signal(
sym, day, "TRIM", target, current, pct, price,
{**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位"},
))
), day))
else:
# ADD 也是买入 —— 同样要过实时画像闸门。
# 被拒时**不动已有仓位**(REJECT 不进入待成交队列),
# 因为闸门的语义是「不值得买」,不是「该卖」。
if not in_pool and self.pool_exit_action == "hold":
# 只减不加:留下一条 HOLD 记录,说明「想加但被池子挡住」。
# 不进入待成交队列(kind 不在 BUY/ADD/SELL/TRIM 里)。
out.append(self._trade_signal(Signal(
sym, day, "HOLD", 0.0, current, pct, price,
{**common,
"rule": f"分位 {pct:.1f}% 本应加仓至 {target:.0%},"
f"但该股已掉出当日股票池(pool_exit_action=hold)",
"reason_cn": "已掉出动态股票池,停止加仓(不清仓)",
"skip_reason": "OUT_OF_UNIVERSE",
"executed": False,
"rule_hit": "pool_exit_hold"},
), day))
continue
gate = self._gate(sym, day)
if gate is not None and gate["verdict"] != "PASS":
out.append(self._reject_signal(
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal(
out.append(self._trade_signal(Signal(
sym, day, "ADD", target, current, pct, price,
{**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位",
**({"profile_gate": gate} if gate else {})},
))
), day))
return out
# ------------------------------------------------------------------
@@ -855,6 +1025,40 @@ class BacktestEngine:
min_window_coverage=self.gate_cfg.min_window_coverage,
)
def _trade_signal(self, sig: Signal, day: date) -> Signal:
"""买卖信号的统一出口:在这里附加**个股画像留痕**。
``profile_on_trade``(daily 模式的默认)语义是「买卖决策发生时计算并留痕」:
买入/加仓、卖出/减仓都会附上该股在决策日的实时画像,**不区分它是否在
当日股票池内** —— 掉出池子的持仓被卖出时,更需要「当时它长什么样」的证据。
闸门(``profile_gate``)与此是两件事:闸门决定「买不买得到」,
留痕只记录「当时看到了什么」。闸门关闭时留痕照常工作。
"""
note = self._profile_note(sig.symbol, day)
if note is not None:
sig.reason["profile"] = note
return sig
def _profile_note(self, sym: str, day: date) -> dict[str, Any] | None:
"""该股在 ``day`` 的实时画像快照(供 reason_json 留痕)。"""
if not self.profile_on_trade or self.pit is None:
return None
snap = self.pit.snapshot(sym, day)
if snap is None:
return {"asof": str(day), "status": "NO_SNAPSHOT"}
return {
"asof": str(snap.asof),
"window_years": snap.window_years,
"values": {k: round(v, 8) for k, v in snap.values.items()
if isinstance(v, (int, float))},
"percentiles": {k: round(v, 4) for k, v in snap.percentiles.items()},
"status": dict(snap.status),
"n_obs": dict(snap.n_obs),
"coverage": {k: round(v, 4) for k, v in snap.coverage.items()},
"scores": {k: round(v, 4) for k, v in snap.scores.items()},
}
def _reject_signal(
self, sym: str, day: date, current: float, pct: float, price: float,
common: dict[str, Any], gate: dict[str, Any],
@@ -1068,38 +1272,62 @@ class BacktestEngine:
这是「不复权价 + 独立分红现金流」的关键实现 ——
与复权价配合会造成重复计算,因此价格一律用不复权。
**现金与送转必须各自独立判断**。纯送转(如 10 送 10:股价腰斩、
股数翻倍)的 ``cash_div_tax`` 是 NULL/0 而 ``stk_div`` > 0;早期实现
在算送股**之前**就按 ``per_share <= 0`` 整行 ``continue``,等于把纯送转
丢掉 —— 而价格是不复权价、除权日仍会下跌,于是凭空记出一笔亏损。
实测本库 2015-2026 区间内高股息池成员有 824 笔纯送转(0.3~1.2 股/股)。
分红**不计入成交流水**(``hd_backtest_trade`` 只记录买卖),
它直接改变现金与持股数量,并通过净值曲线体现。
"""
stock_div_on = bool(self.bt_cfg.dividend.handle_stock_dividend)
# 红利税总闸:backtest.yml 的 apply_dividend_tax 与 cost.yml 的
# dividend_tax.enabled 必须同时为真(前者曾是死字段,改了不起作用)。
tax_on = bool(self.bt_cfg.dividend.apply_dividend_tax)
for r in ctx["div_by_date"].get(day, []):
sym = r["symbol"]
pos = positions.get(sym)
if pos is None or pos.quantity <= 0:
continue
holding = pos.holding_days(day)
rate = self.cost.dividend_tax_rate(holding)
# 必须用 NaN 感知的转换:`float(x or 0.0)` 在 x=NaN 时会返回 NaN
# (NaN 是真值),导致后续 gross/tax/net 全为 NaN,
# 对账时 `NaN <= x` 为 False,表现为「税后大于税前」的假象。
per_share = _fnum(r.get("cash_div_tax"))
# 只处理**正的**现金分红:跳过 NULL / 0 / 负数(纯送转或数据异常)。
if per_share is None or per_share <= 0:
continue
gross = pos.quantity * per_share
tax = gross * rate
net = gross - tax
if net > 0:
cash += net
# 送转股:股数增加,成本不变(成本不变更符合税务口径)
stk = _fnum(r.get("stk_div")) or 0.0
has_cash = per_share is not None and per_share > 0
has_stock = stk > 0
# 既无现金也无送转:NULL / 0 / 负数(数据异常)—— 这才是该跳过的行
if not has_cash and not has_stock:
continue
holding = pos.holding_days(day)
rate = self.cost.dividend_tax_rate(holding) if tax_on else 0.0
gross = tax = net = 0.0
if has_cash:
gross = pos.quantity * per_share
tax = gross * rate
net = gross - tax
if net > 0:
cash += net
# 送转股:股数增加,**总成本不变** → 每股成本随之下降,与「不复权价
# 在除权日下跌、股数补上」严格相抵,市值不变。
# 不重算 avg_cost 会让后续卖出的 realized_pnl 按除权前的旧每股成本
# 多扣成本(1000 股 @10 送 0.3 后全卖 @10 会记成 0 而非 +3000)。
shares_added = 0.0
if stk > 0 and self.bt_cfg.dividend.handle_stock_dividend:
if has_stock and stock_div_on:
shares_added = pos.quantity * stk
pos.quantity += shares_added
if pos.quantity > 0:
pos.avg_cost = pos.cost_basis / pos.quantity
ledger.append({
"ex_date": day, "symbol": sym, "quantity": pos.quantity,
"cash_div_tax": per_share, "gross": gross, "tax": tax, "net": net,
"cash_div_tax": per_share if has_cash else 0.0,
"stk_div": stk,
"stk_bo_rate": _fnum(r.get("stk_bo_rate")) or 0.0,
"stk_co_rate": _fnum(r.get("stk_co_rate")) or 0.0,
"gross": gross, "tax": tax, "net": net,
"holding_days": holding, "tax_rate": rate, "shares_added": shares_added,
"stock_div_applied": bool(has_stock and stock_div_on),
"cash_mode": self.bt_cfg.dividend.cash_mode,
})
return cash
@@ -1350,6 +1578,47 @@ def _write(table: str, df: pd.DataFrame, cfg: Any, updates: list[str]) -> int:
return n
def build_yield_series(
px_by_sym: dict[str, pd.DataFrame],
events: dict[str, pd.DataFrame],
*,
ttm_days: int | None = None,
grace_days: int | None = None,
smooth_spikes: bool | None = None,
) -> dict[str, pd.Series]:
"""按股票预计算**整段**股息率序列(P2)。
引擎原先在每个评估日对每只股票从零重算一遍「到当天为止」的前缀序列,
代价是 O(交易日数²)。``ttm_dps_series`` 的每一天取值只依赖「该日期 + 分红事件」,
与传入的日期序列里还有哪些其它日期无关,所以「整段算一次再切片」与
「每天按前缀重算」逐值相同(由
``tests/test_daily.py::test_ttm_dps_series_prefix_equals_full`` 锁定)。
返回 ``{symbol: Series[close>0 的日期 -> 股息率]}``,索引为 ``DatetimeIndex``。
"""
w, g, sm = ttm_params()
ttm_days = w if ttm_days is None else ttm_days
grace_days = g if grace_days is None else grace_days
smooth_spikes = sm if smooth_spikes is None else smooth_spikes
out: dict[str, pd.Series] = {}
for sym, frame in px_by_sym.items():
close = frame["close"]
if close.empty:
continue
dps = ttm_dps_series(
pd.DatetimeIndex(close.index), events.get(sym, pd.DataFrame()),
ttm_days=ttm_days, grace_days=grace_days, smooth_spikes=smooth_spikes,
)
c = close.to_numpy(dtype="float64")
with np.errstate(divide="ignore", invalid="ignore"):
y = np.where(c > 0, dps / c, np.nan)
s = pd.Series(y, index=close.index).dropna()
if not s.empty:
out[sym] = s
return out
def _fnum(v: Any) -> float | None:
"""把值转成 float,None / NaN / 非数 一律返回 None。
@@ -1365,6 +1634,25 @@ def _fnum(v: Any) -> float | None:
return None if (f != f or np.isinf(f)) else f
def _pct(v: Any) -> str:
"""百分比格式化;不可计算时显示「—」。
**短区间下 Sharpe / CAGR 会不可计算**(样本不足,见
``analysis/performance.py``),而它们此前被直接 ``:.2%`` 格式化 ——
实测:15 个交易日的回测会抛 ``TypeError: unsupported format string
passed to NoneType`` 并以完整 traceback 结束。那不是「用户可理解的错误」,
而是把「指标不可计算」这一个正常状态说成了程序缺陷。
"""
f = _fnum(v)
return "—" if f is None else f"{f:.2%}"
def _num(v: Any, digits: int = 2) -> str:
"""数值格式化;不可计算时显示「—」。见 :func:`_pct`。"""
f = _fnum(v)
return "—" if f is None else f"{f:,.{digits}f}"
def _round_lot(qty: float, lot: int = 100) -> float:
"""A 股按手(100 股)取整。"""
if qty <= 0:
+10 -6
View File
@@ -163,16 +163,20 @@ class WalkForwardRunner:
)
if verbose:
# 用容忍 None 的格式化:短窗口下 CAGR / Sharpe 会不可计算,
# 直接 :.2% 会抛 TypeError 并以 traceback 结束(见 engine._pct)。
from hdiv.backtest.engine import _pct
print(
f" 训练: 收益 {train_res['total_return']:>8.2%} "
f"回撤 {train_res['max_drawdown']:>8.2%} "
f"CAGR {train_res['cagr']:>7.2%} 成交 {train_res['trade_count']:>3d}",
f" 训练: 收益 {_pct(train_res['total_return']):>8} "
f"回撤 {_pct(train_res['max_drawdown']):>8} "
f"CAGR {_pct(train_res['cagr']):>7} 成交 {train_res['trade_count']:>3d}",
flush=True,
)
print(
f" 测试: 收益 {test_res['total_return']:>8.2%} "
f"回撤 {test_res['max_drawdown']:>8.2%} "
f"CAGR {test_res['cagr']:>7.2%} 成交 {test_res['trade_count']:>3d}",
f" 测试: 收益 {_pct(test_res['total_return']):>8} "
f"回撤 {_pct(test_res['max_drawdown']):>8} "
f"CAGR {_pct(test_res['cagr']):>7} 成交 {test_res['trade_count']:>3d}",
flush=True,
)
+154 -8
View File
@@ -15,13 +15,13 @@
from __future__ import annotations
import argparse
from hdiv.core.errors import HdivError
import json
import logging
import sys
from datetime import date, datetime
from datetime import date
from hdiv import __version__
from hdiv.core.errors import HdivError
def _setup_logging(level: str) -> None:
@@ -63,6 +63,21 @@ def cmd_ddl(args: argparse.Namespace) -> int:
def cmd_sync(args: argparse.Namespace) -> int:
target = args.target
if target == "daily":
from hdiv.data.sync import daily
summary = daily.run(
asof=date.fromisoformat(args.asof) if args.asof else None,
lookback_days=args.lookback_days,
only=args.only,
include_financial=not args.no_financial,
financial_limit=args.financial_limit,
dry_run=args.dry_run,
verbose=not args.json,
)
if args.json:
print(json.dumps(summary, ensure_ascii=False, default=str, indent=2))
return 0 if summary.get("ok", True) else 1
if target == "dividend":
from hdiv.data.sync import dividend
@@ -289,6 +304,64 @@ def cmd_backtest(args: argparse.Namespace) -> int:
from hdiv.backtest.engine import BacktestEngine
from hdiv.backtest.walk_forward import WalkForwardRunner
if args.mode == "daily":
# 每日动态股票池:与 walk-forward 是两种不同的检验(见
# hdiv/backtest/daily.py 的模块文档)。同样拒绝 --universe-run ——
# 固定股票池与「每日动态」在定义上互斥,而且冻结名单自带未来信息。
if args.universe_run:
raise HdivError(
"daily 模式不支持 --universe-run。\n"
" 原因:daily 的定义就是「每个交易日按当时可见的数据重新选股」,\n"
" 而固定股票池自带一个 asof(例如 2025-01-21),把它套到\n"
" 更早的年份就是用未来信息选股(名单里含有回测起点时不可能\n"
" 知道的信息)。\n"
" 正确做法:去掉 --universe-run,让引擎每日重新筛选:\n"
" python -m hdiv backtest --mode daily --start 2020-01-05\n"
" 若确实要检验「固定股票池 + 月频调仓」,请用普通回测:\n"
" python -m hdiv backtest --universe-run <run_id>"
)
_reject_no_persist_with_html(args, "hdiv backtest --mode daily")
from hdiv.backtest.daily import DailyRunner
runner = DailyRunner.from_strategy(args.strategy)
res = runner.run(
start=date.fromisoformat(args.start) if args.start else None,
end=date.fromisoformat(args.end) if args.end else None,
persist=not args.no_persist,
refresh_pools=bool(args.refresh_pools),
every_n_days=args.every_n_days,
signal_every_n_days=args.signal_every_n_days,
)
if args.no_persist:
print()
print("注意:--no-persist 已启用,本次回测与每日选股**未写入数据库**,"
"不会出现在 Web 前端。")
from hdiv.backtest.engine import _num as _fnum, _pct as _fpct
ds = res.get("daily_screening") or {}
cadence = ""
if int(ds.get("refresh_days") or 1) > 1 or int(ds.get("signal_every_days") or 1) > 1:
cadence = (f"(粗粒度:选股每 {ds.get('refresh_days')} 日、"
f"信号每 {ds.get('signal_every_days')} 日,"
f"不可与逐日口径比较)")
print(
f"每日动态股票池回测 {res['run_id']} "
f"{res['start_date']} ~ {res['end_date']}\n"
f" 选股 {ds.get('screen_count')} 次(每 {ds.get('refresh_days')} 个交易日),"
f"池内 {ds.get('pool_size_min')}~{ds.get('pool_size_max')} 只,"
f"累计个股 {ds.get('distinct_symbols')} 只\n"
f" 期初 {res['initial_capital']:,.0f} → 期末 {res['final_capital']:,.0f}\n"
f" 总收益 {_fpct(res['total_return'])} CAGR {_fpct(res['cagr'])} "
f"最大回撤 {_fpct(res['max_drawdown'])} Sharpe {_fnum(res['sharpe'])}\n"
f" 成交 {res['trade_count']} 笔{cadence}"
)
_print_frontend_hint(res["run_id"])
if args.html:
from hdiv.report.build import build_backtest_report
print("HTML:", build_backtest_report(res["run_id"]))
return 0
if args.mode == "walkforward":
# 冻结股票池与 walk-forward 在时序上不兼容:
# 股票池有其自身的 asof(如 2025-01-21),而 walk-forward 的窗口从
@@ -326,13 +399,16 @@ def cmd_backtest(args: argparse.Namespace) -> int:
if args.no_persist:
print()
print("注意:--no-persist 已启用,本次回测**未写入数据库**,不会出现在 Web 前端。")
from hdiv.backtest.engine import _num as _fnum, _pct as _fpct
print(
f"回测 {res['run_id']} {res['start_date']} ~ {res['end_date']}\n"
f" 期初 {res['initial_capital']:,.0f} → 期末 {res['final_capital']:,.0f}\n"
f" 总收益 {res['total_return']:.2%} CAGR {res['cagr']:.2%} "
f"最大回撤 {res['max_drawdown']:.2%} Sharpe {res['sharpe']:.2f}\n"
f" 总收益 {_fpct(res['total_return'])} CAGR {_fpct(res['cagr'])} "
f"最大回撤 {_fpct(res['max_drawdown'])} Sharpe {_fnum(res['sharpe'])}\n"
f" 成交 {res['trade_count']} 笔"
)
_print_frontend_hint(res["run_id"])
if args.html:
from hdiv.report.build import build_backtest_report
@@ -340,6 +416,25 @@ def cmd_backtest(args: argparse.Namespace) -> int:
return 0
def _print_frontend_hint(run_id: str) -> None:
"""告诉用户「去哪里看明细」——结果已经入库,不需要再跑第二条命令。
这一步是刻意的:``--mode daily`` 一次运行会把完整明细写进
``hd_backtest_run/_equity/_position/_trade/_signal/_metric`` 与
``hd_daily_universe``,前端「回测记录」页能直接下钻。曾把「导出结果」
写成需要另跑一个脚本并手工传 run_id,那等于让用户猜 run_id 从哪来 ——
而它本来就在上面这一行里。
"""
print(
f"\n明细已入库,直接在前端查看(无需再执行其它命令):\n"
f" 打开 Web 前端 → 「回测记录」→ 选中 run_id "
f"{run_id[:12]}…(模式 daily)\n"
f" 页面内可看:净值曲线与基准、持仓明细、逐笔成交与理由、\n"
f" 成交个股的实时画像、每日动态股票池(逐日选股留痕)、\n"
f" 点任一成交个股 → 趋势与买卖点 + 决策时点实时画像"
)
def cmd_sensitivity(args: argparse.Namespace) -> int:
from hdiv.analysis.sensitivity import SensitivityRunner
@@ -384,7 +479,7 @@ def build_parser() -> argparse.ArgumentParser:
)
s.add_argument(
"target",
choices=["dividend", "financial", "index", "price", "trading", "backfill"],
choices=["daily", "dividend", "financial", "index", "price", "trading", "backfill"],
)
s.add_argument("--symbols", nargs="*")
s.add_argument("--only-missing", action="store_true")
@@ -405,6 +500,33 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--basic-end", default="2019-12-31", help="daily_basic 回补终点")
s.add_argument("--no-resume", action="store_true")
s.add_argument("--no-weight", action="store_true")
# ---- sync daily:按缺口增量补齐(缺几天抓几天)----
s.add_argument(
"--asof", default=None,
help="sync daily:以哪天为「今天」(YYYY-MM-DD,默认系统当天)",
)
s.add_argument(
"--lookback-days", type=int, default=45,
help="sync daily:向前回溯多少自然日找缺口(默认 45;更早的空洞属于回补)",
)
s.add_argument(
"--only", nargs="*", default=None,
help="sync daily:只同步指定目标(price/trading/index/dividend/financial,"
"或单个目标 daily/adj_factor/daily_basic/suspend/limit)",
)
s.add_argument(
"--no-financial", action="store_true",
help="sync daily:跳过财报四表(它们是按股票拉取,最耗时)",
)
s.add_argument(
"--financial-limit", type=int, default=500,
help="sync daily:单次最多重拉多少只股票的财报(按市值降序,默认 500)",
)
s.add_argument(
"--dry-run", action="store_true",
help="sync daily:只打印待抓清单,不调用接口、不写库",
)
s.add_argument("--json", action="store_true", help="sync daily:以 JSON 输出结果")
s.set_defaults(func=cmd_sync)
# audit
@@ -505,10 +627,18 @@ def build_parser() -> argparse.ArgumentParser:
# backtest
b = sub.add_parser(
"backtest", help="回测与 Walk-forward",
description="执行历史回测;--mode walkforward 执行滚动样本外验证。",
description=(
"执行历史回测。--mode walkforward 执行滚动样本外验证(过拟合检验);"
"--mode daily 执行「每日动态股票池」连续推演:从 --start 起每个交易日"
"重新选股、每个交易日判断买卖点。"
),
)
b.add_argument("-s", "--strategy", default="config/strategy/high_dividend_v1.yml")
b.add_argument("--mode", choices=["single", "walkforward"], default="single")
b.add_argument(
"--mode", choices=["single", "walkforward", "daily"], default="single",
help="single=单条路径回测;walkforward=滚动样本外;"
"daily=每日动态股票池(每个交易日重新选股)",
)
b.add_argument("--start", default=None)
b.add_argument("--end", default=None)
b.add_argument(
@@ -522,6 +652,22 @@ def build_parser() -> argparse.ArgumentParser:
"会如实写入 hd_backtest_run.unimplemented_json)",
)
b.add_argument("--no-persist", action="store_true")
b.add_argument(
"--refresh-pools", action="store_true",
help="daily 模式:忽略已缓存的每日选股结果,强制重新逐日筛选(默认复用缓存)",
)
b.add_argument(
"--every-n-days", type=int, default=None, metavar="N",
help="daily 模式:股票池每 N 个交易日重建一次(默认 1 = 每个交易日)。"
"N 越大选股耗时越少(近似 ÷N),代价是池子更新变粗;"
"判定规则不变,但结果不可与逐日口径直接比较",
)
b.add_argument(
"--signal-every-n-days", type=int, default=None, metavar="N",
help="daily 模式:买卖每 N 个交易日判断一次(默认 1 = 每个交易日)。"
"这是**模拟阶段**的主要速度旋钮(信号评估 + 逐笔决策画像都按 N 摊薄),"
"但交易机会与换手会随之下降",
)
# HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。
# --no-html 保留为空操作,避免历史命令与脚本报错。
b.add_argument("--no-html", action="store_true",
+46
View File
@@ -515,6 +515,51 @@ class PercentileReferenceConfig(StrictModel):
return self
class DailyConfig(StrictModel):
"""每日动态股票池模式(``backtest --mode daily``)。
与 walk-forward 的区别在**时间结构与目的**:walk-forward 切多个
(train, test) 窗口来检验过拟合;daily 只跑**一条连续路径**
``[start, latest]``,目的回答「从某天起按这套规则每天重新选股、每天判断
买卖,实际会怎样」。其中「股票池每日变化」是本模式的核心,因此:
- ``universe_refresh_days=1``:**每个交易日**按当日可见数据重建股票池
(PIT),而不是 walk-forward 的「每个窗口一次」;
- ``signal_frequency_days=1``:每个交易日评估买卖点;
- ``pool_exit_action``:持仓掉出当日股票池后的处置 ——
``hold`` = **只减不加**(默认,符合「池子决定能买什么,不决定必须卖」),
``sell`` = 掉出即清仓。
**刻意没有 ``enabled`` 开关**:模式由命令行 ``--mode daily`` 显式选择。
加一个「配置写了但没有任何代码路径会读」的字段,正是本项目反复记录的
「配置承诺与实际行为不一致」那一类缺陷。
"""
#: 股票池重建频率(交易日)。1 = 每个交易日重新筛选
universe_refresh_days: int = 1
#: 信号评估频率(交易日)。1 = 每个交易日评估
signal_frequency_days: int = 1
#: 持仓掉出当日股票池后的处置:hold = 只减不加;sell = 清仓
pool_exit_action: Literal["hold", "sell"] = "hold"
#: 买卖决策发生时计算并留痕个股画像(无论该股是否在当日池内)
profile_on_trade: bool = True
#: 是否把每日入选成员写入 hd_daily_universe
persist_daily_universe: bool = True
#: 批量预载的分块年数(内存控制)
chunk_years: int = 1
#: 每日股票池重建时打印进度的间隔(交易日);1 = 每天都打印
progress_every_days: int = 20
@model_validator(mode="after")
def _check(self) -> DailyConfig:
for name in ("universe_refresh_days", "signal_frequency_days", "chunk_years"):
if getattr(self, name) <= 0:
raise SchemaValidationError(f"daily.{name} 必须为正")
if self.progress_every_days <= 0:
raise SchemaValidationError("daily.progress_every_days 必须为正")
return self
class BacktestConfig(StrictModel):
version: int = 1
capital: CapitalConfig = Field(default_factory=CapitalConfig)
@@ -524,6 +569,7 @@ class BacktestConfig(StrictModel):
default_factory=PercentileReferenceConfig
)
walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig)
daily: DailyConfig = Field(default_factory=DailyConfig)
dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig)
benchmark: list[BenchmarkConfig] = Field(default_factory=list)
risk_free_rate: float = 0.02
+32
View File
@@ -497,6 +497,37 @@ CREATE TABLE IF NOT EXISTS `hd_universe_member` (
""",
)
T_DAILY_UNIVERSE = Table(
name="hd_daily_universe",
comment="每日动态股票池成员(daily 模式「每日选股」的留痕)",
ddl=f"""
CREATE TABLE IF NOT EXISTS `hd_daily_universe` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`run_id` VARCHAR(32) NOT NULL COMMENT '所属回测 run_id',
`trade_date` DATE NOT NULL COMMENT '该交易日的选股结果',
`symbol` VARCHAR(12) NOT NULL,
`name` VARCHAR(64) NULL,
`industry` VARCHAR(64) NULL,
`dividend_yield` DECIMAL(18,8) NULL COMMENT '入选当日股息率(池内排序口径)',
`total_mv` DECIMAL(24,4) NULL,
`roe_avg` DECIMAL(18,8) NULL,
`listed_count` INT NULL COMMENT '当日市场候选数(未预剪枝)',
`candidate_count` INT NULL COMMENT '当日实际参与筛选的候选数(已预剪枝)',
`values_json` TEXT NULL COMMENT '入选时的关键因子快照',
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_hd_daily_uni` (`run_id`,`trade_date`,`symbol`),
KEY `ix_hd_daily_uni_date` (`trade_date`),
KEY `ix_hd_daily_uni_sym` (`symbol`,`trade_date`)
) {CHARSET} COMMENT='每日动态股票池成员'
""",
# 建表时已含上述列;这里的 added_columns 是为了让**先建表、后加列**的库
# 也能幂等补上(CREATE TABLE IF NOT EXISTS 不会修改已存在的表)。
added_columns={
"listed_count": "INT NULL COMMENT '当日市场候选数(未预剪枝)'",
},
)
# ---------------------------------------------------------------------------
# C. 因子与画像层
# ---------------------------------------------------------------------------
@@ -1034,6 +1065,7 @@ ALL_TABLES: tuple[Table, ...] = (
# B. 股票池层
T_UNIVERSE_RUN,
T_UNIVERSE_MEMBER,
T_DAILY_UNIVERSE,
# C. 因子与画像层
T_FACTOR_SNAPSHOT,
T_PROFILE_RUN,
+729
View File
@@ -0,0 +1,729 @@
"""每日增量同步 —— 「缺几天就抓几天」。
本模块是 ``hdiv sync daily`` 的实现,用途只有一个:
**在每天收盘后(默认 17:00)把外部数据源里「本地还没有的那几天」补下来**,
不重拉已经完整的历史,也不碰已经存在的行。
外部数据源与本地表的对应关系(只有这些表是「抓来的」)::
本地表 数据源接口 分区方式 缺口判定
------------------ --------------- -------------------- ------------------------------
stock_daily daily trade_date 当日股票数 ≥ 当年规模阈值
adjust_factor adj_factor trade_date 同上
daily_basic daily_basic trade_date 同上
hd_suspend suspend_d trade_date 有行即视为已同步
hd_limit stk_limit trade_date 当日股票数 ≥ 当年规模阈值
hd_index_daily index_daily (指数, trade_date) 每个指数各自的最后一天
hd_dividend dividend ann/imp_ann/ex/record 四个日期列都查过才算同步
hd_fina_indicator fina_indicator ts_code(接口强制) 缺股票 / 报告期滞后
hd_cashflow cashflow ts_code(接口强制) 同上
hd_balancesheet balancesheet ts_code(接口强制) 同上
hd_income income ts_code(接口强制) 同上
index_weight index_weight 月度区间 最后一个权重日之后
**为什么财报四表不能按天**:实测 ``fina_indicator`` / ``income`` / ``balancesheet`` /
``cashflow`` 传 ``period`` / ``ann_date`` / ``start_date`` 而不传 ``ts_code`` 一律返回
``50101 必填参数, ts_code`` —— 服务端强制按股票拉取。所以这四张表只能按
「股票 × 报告期」补:先补完全没数据的股票,再按报告期水位补滞后的股票,
每次运行有上限(``--financial-limit``),积压会在随后的每天里自动排空。
**为什么分红可以按天**:``dividend`` 接口支持 ``ann_date`` / ``imp_ann_date`` /
``ex_date`` / ``record_date`` 四种日期参数(实测可用),因此不必像早期实现那样
逐只股票重拉全历史(5,900 次调用)。这里改为「缺哪天查哪天」——
每天最多 4 次调用,且四个日期列都查,避免漏掉「预案日已过、除权日未到」的记录。
**写入门槛**:``stock_daily`` / ``adjust_factor`` / ``daily_basic`` 是 qlib 的
**只读表**,写入需要 ``HDIV_ALLOW_BACKFILL=1``(StatementGuard 的安全开关)。
:func:`run` 会在进程内自动打开它 —— 这是本项目**唯一**被授权写入这些表的日常通道,
且一律使用 ``INSERT IGNORE``,冲突行完全不改动(见 ``price.sync_days``)。
``index_weight`` 已在 ``allow_write_tables`` 中,不受该开关限制。
**重复查询的取舍**:分红表里「一条记录都没有」的股票(实测 23 只,全是刚上市、
尚未分红的次新股)每天都会被重查一次 —— 这正是想要的:它们第一次分红公告要能
当天入库。财报四表则相反,已退市多年的标的必须排除(见 :data:`DELIST_GRACE_DAYS`),
否则每天白拉 800 次调用。
"""
from __future__ import annotations
import os
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any
from hdiv.core.config import DataSourceConfig, load_config
from hdiv.data import db
from hdiv.data.sync import dividend as dividend_sync
from hdiv.data.sync import financial as financial_sync
from hdiv.data.sync import index as index_sync
from hdiv.data.sync import price as price_sync
from hdiv.data.sync import trading as trading_sync
from hdiv.data.sync.base import symbols_by_priority, sync_job, to_date, upsert
from hdiv.data.tushare_client import TushareClient
#: 默认回溯窗口(自然日)。日频表只在这个窗口内找缺口 ——
#: 更早的历史空洞属于「回补」而不是「每日增量」,由 ``hdiv audit`` 报告、
#: 由 ``hdiv sync backfill`` 处理。窗口存在是为了「昨夜失败今晨自愈」。
DEFAULT_LOOKBACK_DAYS = 45
#: 单次运行最多重拉多少只股票的财报(按市值降序)。
DEFAULT_FINANCIAL_LIMIT = 500
#: 分红按日期补时向前多查几天:公告日与除权日之间常有几天差,
#: 单日查询会漏掉「昨天公告、今天才入库」的记录。
DIVIDEND_OVERLAP_DAYS = 7
#: 已退市超过这么多天的标的,财报不会再更新,不再进入每日队列。
#:
#: 为什么必须有这条规则:实测「报告期滞后」的股票里 **200+ 只已退市**
#: (最后一份财报停在退市前,如 1996-12-31)。不退市过滤的话,它们每天都会被
#: 重拉一次(4 个接口 × 200 只 = 800 次调用/天),而结果永远是同一批旧数据 ——
#: 实测一次全量重拉耗时约 2 分钟、返回 6.5 万行,全是无功而返。
#: 留下 90 天宽限期,是为了兜住「刚退市、最终财报还没入库」的情况。
#: 更早的历史缺口属于**一次性回补**:hdiv sync financial --only-missing
DELIST_GRACE_DAYS = 90
# ---------------------------------------------------------------------------
# 目标描述
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class DayTarget:
"""按交易日分区的同步目标。"""
key: str
table: str
api: str
label: str
which: str | None = None # price.SPECS 的键;仅行情三表有
min_symbols: int | None = None # 显式覆盖完整性阈值(停牌表用 1)
group: str = "price"
DAY_TARGETS: tuple[DayTarget, ...] = (
DayTarget("daily", "stock_daily", "daily", "日线行情", which="daily"),
DayTarget("adj_factor", "adjust_factor", "adj_factor", "复权因子", which="adj_factor"),
DayTarget("daily_basic", "daily_basic", "daily_basic", "每日指标", which="daily_basic"),
DayTarget("suspend", "hd_suspend", "suspend_d", "停牌记录", min_symbols=1, group="trading"),
DayTarget("limit", "hd_limit", "stk_limit", "涨跌停价", group="trading"),
)
DAY_TARGET_BY_KEY = {t.key: t for t in DAY_TARGETS}
#: 财报四表(接口强制按 ts_code 拉取)
FINANCIAL_TABLES = ("hd_fina_indicator", "hd_cashflow", "hd_balancesheet", "hd_income")
#: 分红可用的日期参数:四个都查,避免漏记录
DIVIDEND_DATE_PARAMS = ("ann_date", "imp_ann_date", "ex_date", "record_date")
#: ``--only`` 允许的组名
GROUPS = ("price", "trading", "index", "dividend", "financial")
# ---------------------------------------------------------------------------
# 计划(纯读,不写库)
# ---------------------------------------------------------------------------
@dataclass
class DailyPlan:
"""一次每日增量同步的「待抓清单」。"""
asof: date
window_start: date
day_gaps: dict[str, list[date]] = field(default_factory=dict)
index_days: dict[str, list[date]] = field(default_factory=dict)
dividend_days: list[date] = field(default_factory=list)
dividend_symbols: list[str] = field(default_factory=list)
financial_symbols: list[str] = field(default_factory=list)
financial_missing: dict[str, list[str]] = field(default_factory=dict)
financial_stale: dict[str, list[str]] = field(default_factory=dict)
financial_watermark: date | None = None
index_weight_window: tuple[date, date] | None = None
notes: list[str] = field(default_factory=list)
@property
def total_days(self) -> int:
"""所有按日分区的缺口天数合计(不含分红,分红按「四个日期列」计)。"""
return sum(len(v) for v in self.day_gaps.values()) + sum(
len(v) for v in self.index_days.values()
)
@property
def empty(self) -> bool:
return not (
any(self.day_gaps.values())
or any(self.index_days.values())
or self.dividend_days
or self.dividend_symbols
or self.financial_symbols
or self.index_weight_window
)
def _trading_days(start: date, end: date, cfg: DataSourceConfig) -> list[date]:
"""交易日列表;区间内没有交易日(长假)时返回空而不是抛错。"""
if start > end:
return []
try:
return list(price_sync.open_days(start, end, cfg))
except RuntimeError:
return []
def missing_days(
table: str,
start: date,
end: date,
cfg: DataSourceConfig,
*,
min_symbols: int | None = None,
) -> list[date]:
"""``[start, end]`` 内**数据不完整**的交易日。
「不完整」的口径与断点续传完全一致(``price.fetched_days``):
当日股票数 ≥ ``max(下限, 比例 × 当年应有上市股票数)``。
这样半成品日期(只填了几百只)也会被重抓,而不是被当成已完成跳过。
"""
days = _trading_days(start, end, cfg)
if not days:
return []
done = price_sync.fetched_days(table, start, end, cfg, min_symbols=min_symbols)
return [d for d in days if d not in done]
def index_daily_gaps(
start: date, end: date, cfg: DataSourceConfig
) -> dict[str, list[date]]:
"""每个指数各自缺失的交易日(指数接口一次调用返回整段区间)。"""
days = _trading_days(start, end, cfg)
if not days:
return {}
df = db.read_sql(
"SELECT index_code AS c, MAX(trade_date) AS m FROM hd_index_daily GROUP BY index_code",
cfg=cfg,
)
have = {str(r.c): to_date(r.m) for r in df.itertuples()}
out: dict[str, list[date]] = {}
for item in index_sync.default_indices(cfg):
code = item["code"]
last = have.get(code)
if last is None:
# 从未同步过的指数:只补窗口内的(全历史回补请用 hdiv sync index --start)
out[code] = list(days)
continue
gap = [d for d in days if d > last]
if gap:
out[code] = gap
return out
def dividend_gaps(
start: date, end: date, asof: date, cfg: DataSourceConfig
) -> tuple[list[date], date]:
"""分红表缺失的交易日。
分红没有「每天应有 N 条」的规模口径,因此退化为与 ``hd_suspend`` 相同的
「查过即算」:四个日期列里任何一列在该交易日有行,就认为那天已经查过。
代价是**真正没有分红记录的交易日会被重复查询**(每天 4 次调用、返回空),
收益是绝不漏记录 —— 与 ``trading.py`` 对停牌表的取舍一致。
返回 ``(待查交易日, 锚点日期)``;锚点用于把窗口收窄到「上次数据附近」,
避免每天把整个回溯窗口重查一遍。
"""
df = db.read_sql(
"SELECT MAX(d) AS m FROM ("
" SELECT MAX(ann_date) AS d FROM hd_dividend"
" UNION ALL SELECT MAX(imp_ann_date) FROM hd_dividend"
" UNION ALL SELECT MAX(ex_date) FROM hd_dividend"
" UNION ALL SELECT MAX(record_date) FROM hd_dividend"
") t",
cfg=cfg,
)
anchor = to_date(df["m"].iloc[0]) if not df.empty else None
# 未来日期(已公告但尚未除权)不能当锚点,否则窗口会落在未来
if anchor is None or anchor > asof:
anchor = asof
since = max(start, anchor - timedelta(days=DIVIDEND_OVERLAP_DAYS))
days = _trading_days(since, end, cfg)
if not days:
return [], anchor
have = db.read_sql(
"SELECT DISTINCT d FROM ("
" SELECT ann_date AS d FROM hd_dividend WHERE ann_date BETWEEN :s AND :e"
" UNION SELECT imp_ann_date FROM hd_dividend WHERE imp_ann_date BETWEEN :s AND :e"
" UNION SELECT ex_date FROM hd_dividend WHERE ex_date BETWEEN :s AND :e"
" UNION SELECT record_date FROM hd_dividend WHERE record_date BETWEEN :s AND :e"
") t",
{"s": since, "e": end},
cfg=cfg,
)
seen = {to_date(x) for x in have["d"].tolist()} if not have.empty else set()
return [d for d in days if d not in seen], anchor
def _active_since(asof: date) -> date:
"""退市宽限期的截止日:早于它退市的标的不再进入每日队列。"""
return asof - timedelta(days=DELIST_GRACE_DAYS)
def _symbols_without_rows(
table: str, cfg: DataSourceConfig, *, asof: date | None = None
) -> list[str]:
"""``stock`` 里有、目标表里一条记录都没有、且**未长期退市**的股票。"""
cutoff = _active_since(asof or datetime.now().date())
df = db.read_sql(
f"SELECT s.symbol AS symbol FROM stock s "
f"WHERE (s.delist_date IS NULL OR s.delist_date > :cutoff) "
f"AND NOT EXISTS (SELECT 1 FROM `{table}` x WHERE x.symbol = s.symbol)",
{"cutoff": cutoff},
cfg=cfg,
)
return [str(x) for x in df["symbol"].tolist()] if not df.empty else []
def _symbols_lagging(
table: str, watermark: date, cfg: DataSourceConfig, *, asof: date | None = None
) -> list[str]:
"""有数据但最新报告期早于水位、且仍可能出新财报的股票(按市值降序)。"""
cutoff = _active_since(asof or datetime.now().date())
df = db.read_sql(
f"SELECT s.symbol AS symbol, t.mx AS mx FROM stock s "
f"LEFT JOIN (SELECT symbol, MAX(end_date) AS mx FROM `{table}` GROUP BY symbol) t "
f"ON t.symbol = s.symbol "
f"WHERE s.delist_date IS NULL OR s.delist_date > :cutoff",
{"cutoff": cutoff},
cfg=cfg,
)
out: list[str] = []
for r in df.itertuples():
mx = to_date(r.mx)
if mx is None or mx < watermark:
out.append(str(r.symbol))
return symbols_by_priority(cfg, out)
def financial_watermark(asof: date) -> date:
"""按 A 股披露截止日推算「此刻理应已披露的最新报告期」。
年报 4/30、一季报 4/30、半年报 8/31、三季报 10/31。取**已过截止日**的
最近一期作为水位:最新报告期晚于水位的股票才算「滞后」,
这样财报季里刚披露的公司会立刻退出待抓队列,不必重拉全市场。
"""
y = asof.year
if asof >= date(y, 11, 1):
return date(y, 9, 30)
if asof >= date(y, 9, 1):
return date(y, 6, 30)
if asof >= date(y, 5, 1):
return date(y, 3, 31)
# 1~4 月是年报季(上一年 12-31 的年报 4/30 前披露)
return date(y - 1, 12, 31)
def _financial_candidates(
watermark: date, asof: date, cfg: DataSourceConfig
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
missing = {t: _symbols_without_rows(t, cfg, asof=asof) for t in FINANCIAL_TABLES}
stale = {t: _symbols_lagging(t, watermark, cfg, asof=asof) for t in FINANCIAL_TABLES}
return missing, stale
def plan(
cfg: DataSourceConfig | None = None,
*,
asof: date | None = None,
lookback_days: int = DEFAULT_LOOKBACK_DAYS,
only: Sequence[str] | None = None,
include_financial: bool = True,
financial_limit: int = DEFAULT_FINANCIAL_LIMIT,
) -> DailyPlan:
"""算出待抓清单(只读,不调用 Tushare、不写库)。"""
if cfg is None:
db.load_dotenv_once()
cfg = load_config("datasource")
asof = asof or datetime.now().date()
if isinstance(asof, datetime):
asof = asof.date()
window_start = asof - timedelta(days=max(1, lookback_days))
targets = _resolve_only(only)
out = DailyPlan(asof=asof, window_start=window_start)
if "price" in targets or "trading" in targets:
for t in DAY_TARGETS:
if t.group not in targets:
continue
out.day_gaps[t.key] = missing_days(
t.table, window_start, asof, cfg, min_symbols=t.min_symbols
)
if "index" in targets:
out.index_days = index_daily_gaps(window_start, asof, cfg)
out.index_weight_window = _index_weight_window(window_start, asof, cfg)
if out.index_weight_window and db.row_count("index_weight", cfg) == 0:
out.notes.append(
"index_weight 为空:本次只补最近窗口。全历史成分股权重是一次性回补,"
"请执行 hdiv sync index --start 20150101"
)
if "dividend" in targets:
out.dividend_days, _ = dividend_gaps(window_start, asof, asof, cfg)
out.dividend_symbols = _symbols_without_rows("hd_dividend", cfg, asof=asof)
if out.dividend_symbols:
out.notes.append(
f"{len(out.dividend_symbols)} 只股票在分红表里仍无任何记录"
"(多为新上市公司,尚未分红;首次公告后会自动入库)"
)
if "financial" in targets and include_financial:
wm = financial_watermark(asof)
out.financial_watermark = wm
missing, stale = _financial_candidates(wm, asof, cfg)
out.financial_missing = {k: v for k, v in missing.items() if v}
out.financial_stale = {k: v for k, v in stale.items() if v}
# 完全缺数据的股票优先(它们连一行都没有),再按市值补滞后股票
priority: list[str] = []
for table in FINANCIAL_TABLES:
for s in missing[table]:
if s not in priority:
priority.append(s)
rest: list[str] = []
for table in FINANCIAL_TABLES:
for s in stale[table]:
if s not in priority and s not in rest:
rest.append(s)
rest = symbols_by_priority(cfg, rest)
out.financial_symbols = (priority + rest)[: max(0, financial_limit)]
if len(priority) + len(rest) > len(out.financial_symbols):
out.notes.append(
f"财报待补 {len(priority) + len(rest)} 只,本次按上限只处理 "
f"{len(out.financial_symbols)} 只,其余在后续运行中自动排空"
)
return out
def _index_weight_window(
start: date, end: date, cfg: DataSourceConfig
) -> tuple[date, date] | None:
"""成分股权重的待补区间(月度接口,按最后一个权重日之后起算)。
右端点收到**最近一个交易日**:否则长假期间(如国庆 10-01 ~ 10-08)
每天都会重查一段没有任何数据的「未来」区间,白白消耗调用次数。
"""
days = _trading_days(start, end, cfg)
if not days:
return None
end = days[-1]
df = db.read_sql("SELECT MAX(trade_date) AS m FROM index_weight", cfg=cfg)
last = to_date(df["m"].iloc[0]) if not df.empty else None
if last is not None and last >= end:
return None
since = start if last is None else max(start, last + timedelta(days=1))
if since > end:
return None
return since, end
def _resolve_only(only: Sequence[str] | None) -> set[str]:
if not only:
return set(GROUPS)
out: set[str] = set()
for item in only:
key = str(item).strip()
if not key:
continue
if key in GROUPS:
out.add(key)
elif key in DAY_TARGET_BY_KEY:
out.add(DAY_TARGET_BY_KEY[key].group)
elif key == "index_weight":
out.add("index")
else:
raise ValueError(
f"未知的同步目标:{key}(可用:{'/'.join(GROUPS)}"
f" 或单个目标 {'/'.join(DAY_TARGET_BY_KEY)})"
)
return out or set(GROUPS)
# ---------------------------------------------------------------------------
# 执行
# ---------------------------------------------------------------------------
def authorize_qlib_writes() -> bool:
"""打开「写入 qlib 既有表」的进程内开关。
返回 True 表示本次由本函数打开(调用方应据此在结束时不回滚 —— 进程退出即失效)。
StatementGuard 在**引擎创建时**读取该开关,所以这里必须同时丢弃已缓存的引擎,
否则在一个已经建过引擎的进程里调用会拿到旧 guard。
"""
already = os.environ.get("HDIV_ALLOW_BACKFILL", "").strip().lower() in {"1", "true", "yes"}
if already:
return False
os.environ["HDIV_ALLOW_BACKFILL"] = "1"
db.reset_engine_cache()
return True
def sync_dividend_days(days: Sequence[date], cfg: DataSourceConfig) -> int:
"""按日期补分红:每个交易日查四个日期参数,幂等写入 ``hd_dividend``。"""
if not days:
return 0
written = 0
with TushareClient(cfg.tushare) as client, sync_job(
"dividend:dates",
api="dividend",
params={"days": [str(d) for d in days]},
table="hd_dividend",
cfg=cfg,
) as ctx:
ctx["data_start"], ctx["data_end"] = days[0], days[-1]
for i, d in enumerate(days, 1):
stamp = d.strftime("%Y%m%d")
for param in DIVIDEND_DATE_PARAMS:
rows = client.query("dividend", {param: stamp}, dividend_sync.FIELDS)
if not rows:
continue
df = dividend_sync.rows_to_frame(rows)
if df.empty:
continue
upsert("hd_dividend", df, cfg=cfg, ctx=ctx)
if i % 10 == 0:
print(f" [dividend] [{i}/{len(days)}] 累计入库 {ctx['rows_written']} 行", flush=True)
written = ctx["rows_written"]
print(
f"[dividend] 完成:{len(days)} 个交易日(每校 {len(DIVIDEND_DATE_PARAMS)} 个日期列),"
f"写入 {written} 行",
flush=True,
)
return written
def _print_plan(p: DailyPlan, targets: set[str]) -> None:
print(f"=== 每日增量同步计划(asof={p.asof},窗口自 {p.window_start})===", flush=True)
for t in DAY_TARGETS:
if t.group not in targets:
continue
days = p.day_gaps.get(t.key, [])
head = ", ".join(str(d) for d in days[:6])
tail = " …" if len(days) > 6 else ""
print(f" [{t.label:8s}] {t.table:16s} 缺 {len(days):>3d} 个交易日 {head}{tail}", flush=True)
if "index" in targets:
total = sum(len(v) for v in p.index_days.values())
print(f" [指数行情 ] hd_index_daily 缺 {total:>3d} 个交易日·指数", flush=True)
for code, days in p.index_days.items():
print(f" {code} 缺 {len(days)} 天({days[0]} ~ {days[-1]})", flush=True)
if p.index_weight_window:
print(
f" [成分权重 ] index_weight 待补 {p.index_weight_window[0]} ~ "
f"{p.index_weight_window[1]}",
flush=True,
)
if "dividend" in targets:
print(
f" [分红明细 ] hd_dividend 缺 {len(p.dividend_days)} 个交易日"
f"({len(DIVIDEND_DATE_PARAMS)} 个日期列/天);"
f"完全无记录的股票 {len(p.dividend_symbols)} 只",
flush=True,
)
if "financial" in targets:
if p.financial_watermark is None:
print(" [财报四表 ] 已跳过", flush=True)
else:
print(
f" [财报四表 ] 报告期水位 {p.financial_watermark},"
f"本次待补 {len(p.financial_symbols)} 只("
f"完全缺失 {sum(len(v) for v in p.financial_missing.values())} 只·表)",
flush=True,
)
for n in p.notes:
print(f" 注意:{n}", flush=True)
def run(
cfg: DataSourceConfig | None = None,
*,
asof: date | None = None,
lookback_days: int = DEFAULT_LOOKBACK_DAYS,
only: Sequence[str] | None = None,
include_financial: bool = True,
financial_limit: int = DEFAULT_FINANCIAL_LIMIT,
dry_run: bool = False,
verbose: bool = True,
) -> dict[str, Any]:
"""执行一次每日增量同步。
``dry_run=True`` 只打印计划、不调用 Tushare、不写库。
``verbose=False`` 不打任何进度文字,只返回结果(供 ``--json`` 使用)——
否则 stdout 会混进人类可读文本,机器解析不了。
"""
if cfg is None:
db.load_dotenv_once()
cfg = load_config("datasource")
targets = _resolve_only(only)
p = plan(
cfg,
asof=asof,
lookback_days=lookback_days,
only=only,
include_financial=include_financial,
financial_limit=financial_limit,
)
if verbose:
_print_plan(p, targets)
summary: dict[str, Any] = {
"asof": str(p.asof),
"window_start": str(p.window_start),
"dry_run": dry_run,
"targets": {},
"errors": [],
"ok": True,
}
if dry_run:
summary["planned"] = {
"day_gaps": {k: len(v) for k, v in p.day_gaps.items()},
"index_days": {k: len(v) for k, v in p.index_days.items()},
"dividend_days": len(p.dividend_days),
"dividend_symbols": len(p.dividend_symbols),
"financial_symbols": len(p.financial_symbols),
}
if verbose:
print("(--dry-run:未调用任何接口,也未写库)", flush=True)
return summary
if p.empty:
if verbose:
print("全部目标均已是最新,无需抓取。", flush=True)
return summary
# 行情三表落在 qlib 既有表上,写入需要显式授权(进程内生效)
needs_backfill = any(t.which for t in DAY_TARGETS if p.day_gaps.get(t.key))
if needs_backfill or p.index_weight_window:
authorize_qlib_writes()
def _record(key: str, label: str, fn) -> None: # type: ignore[no-untyped-def]
try:
result = fn()
summary["targets"][key] = {"label": label, "status": "ok", "result": result}
except Exception as exc: # 单个目标失败不应中断其余目标
summary["ok"] = False
summary["errors"].append(f"{key}: {type(exc).__name__}: {exc}")
summary["targets"][key] = {
"label": label,
"status": "fail",
"error": f"{type(exc).__name__}: {exc}",
}
if verbose:
print(f" [失败] {label}:{type(exc).__name__}: {exc}", flush=True)
# 1) 行情三表(内部按 fetched_days 再筛一次,真正抓的就是缺口那几天)
for t in DAY_TARGETS:
days = p.day_gaps.get(t.key)
if not days or not t.which:
continue
_record(
t.key,
t.label,
lambda t=t, days=days: price_sync.sync_days(
t.which, days[0], days[-1], resume=True, insert_ignore=True, cfg=cfg
),
)
# 2) 停牌 / 涨跌停(两者共用一次窗口计算,各自按自己的阈值判缺口)
trading_days = (p.day_gaps.get("suspend") or []) + (p.day_gaps.get("limit") or [])
if trading_days:
_record(
"trading",
"停牌/涨跌停",
lambda: trading_sync.sync_trading_constraints(
min(trading_days), max(trading_days), resume=True, cfg=cfg
),
)
# 3) 指数行情:逐个指数按自己的缺口起点拉,已最新的指数一次调用都不发
if "index" in targets:
for code, days in p.index_days.items():
item = next(
(i for i in index_sync.default_indices(cfg) if i["code"] == code),
{"code": code, "name": None},
)
_record(
f"index_daily:{code}",
f"指数行情 {code}",
lambda item=item, days=days: index_sync.sync_index_daily(
indices=[item],
start_date=days[0].strftime("%Y%m%d"),
end_date=days[-1].strftime("%Y%m%d"),
cfg=cfg,
),
)
if p.index_weight_window:
s, e = p.index_weight_window
_record(
"index_weight",
"指数成分权重",
lambda s=s, e=e: index_sync.sync_index_weight(
start_date=s.strftime("%Y%m%d"), end_date=e.strftime("%Y%m%d"), cfg=cfg
),
)
# 4) 分红:按日期补(新记录)+ 补齐完全无记录的股票
if "dividend" in targets:
if p.dividend_days:
_record(
"dividend:dates",
"分红明细(按日)",
lambda: {"written": sync_dividend_days(p.dividend_days, cfg)},
)
if p.dividend_symbols:
_record(
"dividend:symbols",
"分红明细(缺股票)",
lambda: dividend_sync.run(symbols=p.dividend_symbols, cfg=cfg),
)
# 5) 财报四表:按股票交错拉,一次把四张表补齐
if p.financial_symbols:
_record(
"financial",
"财报四表",
lambda: financial_sync.run_interleaved(
apis=list(financial_sync.SPECS),
symbols=p.financial_symbols,
only_missing=False,
cfg=cfg,
),
)
if verbose:
print("\n=== 本次增量同步结果 ===", flush=True)
for info in summary["targets"].values():
print(f" [{info['status']:4s}] {info['label']}", flush=True)
if summary["errors"]:
print(f" 失败 {len(summary['errors'])} 项:", flush=True)
for e in summary["errors"]:
print(f" - {e}", flush=True)
else:
print(" 全部成功。", flush=True)
return summary
def format_summary(summary: dict[str, Any]) -> str:
"""把 :func:`run` 的结果压成一行摘要(给 launchd 日志 / 告警用)。"""
if summary.get("dry_run"):
planned = summary.get("planned", {})
days = sum(planned.get("day_gaps", {}).values()) + sum(
planned.get("index_days", {}).values()
)
return f"计划:{days} 个交易日·指数缺口,分红 {planned.get('dividend_days', 0)} 天"
if summary.get("errors"):
return f"失败 {len(summary['errors'])} 项:" + ";".join(summary["errors"])
return "全部目标同步成功"
+7 -2
View File
@@ -32,9 +32,14 @@ def ttm_params() -> tuple[int, int, bool]:
walk-forward 与 Web 用函数默认值 —— 同一个「股息率」在不同环节定义不同,
改了配置只有画像会变。现在统一从这里取。
"""
from hdiv.core.config import load_config
# 必须走**带缓存**的 get_config:本函数在筛选器里是**逐股**调用的,
# 而 load_config 每次都重新读盘 + YAML 解析 + pydantic 校验(实测约 16ms)。
# 逐日全市场筛选时这会变成每天约 2.8 秒的纯开销 —— cProfile 实测:
# 6 个交易日里 load_config 被调用 1008 次、共 16.6 秒,占整个筛选时间的 22%,
# 而它每次返回的都是完全相同的一份配置。
from hdiv.core.config import get_config
c = load_config("profile").ttm_dividend
c = get_config("profile").ttm_dividend
return int(c.window_days), int(c.grace_days), bool(
getattr(c, "smooth_spikes", True)
)
+134 -21
View File
@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
import numpy as np
import pandas as pd
from hdiv.core.errors import HdivError
@@ -55,6 +56,21 @@ __all__ = [
# 配置校验(core.config)与画像实现(profile.pit)不允许出现两个清单。
def _with_dt(df: pd.DataFrame, col: str) -> pd.DataFrame:
"""把日期列一次性转成 ``datetime64``(P1)。
内部一律用 datetime64 比较、排序、切片;``date`` 对象只在对外返回时出现。
原因:object 的 ``datetime.date`` 列在 pandas 里做比较/排序会退化到 Python
逐元素循环 —— 实测这些转换与比较占模拟阶段约 28%(``DatetimeArray.__iter__``
一百万次调用、``pd.to_datetime`` 累计 7.4 秒)。
"""
if df.empty or col not in df.columns:
return df
if not pd.api.types.is_datetime64_any_dtype(df[col]):
df[col] = pd.to_datetime(df[col])
return df
def metrics_needing_financials(metrics: set[str]) -> bool:
"""是否需要财报面板。
@@ -162,6 +178,21 @@ class _AsOfContext:
expected_obs: dict[int, int] = field(default_factory=dict)
#: 时点面板缓存上限(``_ctx`` 的条目数)。
#:
#: **为什么必须设上限**:引擎逐日推进,**旧时点不会再被查询**;而每个时点面板
#: 持有该 asof 可见的**分红超集**(约 12 年 × 全市场,实测约 4 MB)、财务历史与
#: 财年表。不设上限时,一次 6.7 年(约 1600 个决策日)的每日回测会把 1600 份
#: 面板全部留在内存里 —— 约 6 GB,**必然 OOM**。
#: 保留 2 个(当前 + 上一个)是为了容忍调用方偶尔回看一天,代价可以忽略。
_MAX_ASOF_CONTEXTS = 2
#: ``(symbol, asof)`` 画像快照的缓存上限(``_snapshots`` 的条目数)。
#: 画像快照的复用只发生在**同一个 asof 内**(闸门算过一次、信号留痕再取一次),
#: 跨日必然 miss。按 asof 清空即可保住这份收益,同时把内存限制在「一天」的量级。
_SNAPSHOTS_PER_ASOF = 1
# ---------------------------------------------------------------------------
# 服务
# ---------------------------------------------------------------------------
@@ -207,6 +238,10 @@ class PitProfileService:
self._all_dividends = pd.DataFrame()
self._ctx: dict[date, _AsOfContext] = {}
self._snapshots: dict[tuple[str, date], ProfileSnapshot] = {}
#: 当前快照缓存所属的 asof(换日即清空,见 _SNAPSHOTS_PER_ASOF)
self._snapshot_asof: date | None = None
#: 累计构建过的时点面板数(与「当前缓存了几个」是两件事)
self._asof_built = 0
#: 闸门规则用到的指标集合;None = 未知,按「全都可能需要」处理(保守)
self._needed: set[str] | None = None
self._financial_required: bool | None = None
@@ -244,24 +279,51 @@ class PitProfileService:
# 批量预载(跨越整个回测区间、与 asof 无关的部分)
# ------------------------------------------------------------------
def prepare(self, symbols: list[str], start: date, end: date) -> None:
def prepare(
self,
symbols: list[str],
start: date,
end: date,
*,
price: pd.DataFrame | None = None,
) -> None:
"""载入跨决策日共享的面板。
只有 ``trade_date`` 范围过滤,没有 PIT 语义 —— 真正的 PIT 剪裁发生在
:meth:`snapshot` 里逐 asof 进行(与 ``ProfileBuilder.run`` 的取数起点
规则完全一致:``asof.year - max_years - 1`` 的 1 月 1 日)。
``price`` 允许调用方传入**已经取好的同一批不复权行情**(引擎就是这么做的)——
回测引擎本来就要取这段价格来算分位,画像再取一遍是纯粹的重复查询。
传入时必须已经覆盖 ``[start, end]``(调用方把两者的起点取并集)。
"""
self._symbols = sorted(set(symbols))
if not self._symbols:
self._prepared = True
return
self._price = self.repo.price_history(self._symbols, start, end, adjust="none")
if price is not None and not price.empty:
# 只保留面板里的股票,并**统一为 datetime64**(P1)
self._price = _with_dt(
price[price["symbol"].isin(set(self._symbols))].copy(), "trade_date"
)
else:
self._price = _with_dt(
self.repo.price_history(self._symbols, start, end, adjust="none"),
"trade_date",
)
self._counters["price_loaded"] += 1
if self.load_basics:
self._basics = self.builder._load_daily_basic(self._symbols, start, end)
# P1:日期列一次性转成 datetime64。原先每个 asof、每只股票都要
# 把 object 的 datetime.date 列再 `pd.to_datetime` / `.dt.date` 一遍,
# 实测这一族转换占模拟阶段约 28%(DatetimeArray.__iter__ 100 万次调用)。
self._basics = _with_dt(
self.builder._load_daily_basic(self._symbols, start, end), "trade_date"
)
self._counters["basics_loaded"] += 1
if self.load_index:
self._index = self.repo.index_history("000300.SH", start, end)
self._index = _with_dt(
self.repo.index_history("000300.SH", start, end), "trade_date"
)
# 分红:为**整个回测区间**取一次超集,逐 asof 再用与 repo.dividend_records
# 完全相同的三重 PIT 条件(imp_ann_date / ex_date / 回看窗口)在 pandas 里剪裁。
#
@@ -274,7 +336,16 @@ class PitProfileService:
if not self._all_dividends.empty:
self._all_dividends = self._all_dividends[
self._all_dividends["symbol"].isin(set(self._symbols))
]
].copy()
# P1:预存 datetime64 的公告日/除权日,逐 asof 的 PIT 剪裁直接用它们比较
# (原来每次都把 2 万行×2 列 `pd.to_datetime(...).dt.date` 转成 object
# 再逐元素比较 —— object 比较会退化到 Python 循环)。
self._all_dividends["_imp_dt"] = pd.to_datetime(
self._all_dividends["imp_ann_date"]
).to_numpy(dtype="datetime64[ns]")
self._all_dividends["_ex_dt"] = pd.to_datetime(
self._all_dividends["ex_date"]
).to_numpy(dtype="datetime64[ns]")
self._prepared = True
# ------------------------------------------------------------------
@@ -285,6 +356,11 @@ class PitProfileService:
"""计算(或取缓存)``symbol`` 在 ``asof`` 的实时画像。"""
if not self._prepared:
raise HdivError("PitProfileService 必须先 prepare(symbols, start, end)")
if self._snapshot_asof is not None and asof != self._snapshot_asof:
# 换日:跨日的 (symbol, asof) 键不可能再命中,直接释放。
# 见 _SNAPSHOTS_PER_ASOF 对「为什么这样不改变任何结果」的说明。
self._snapshots.clear()
self._snapshot_asof = asof
key = (symbol, asof)
hit = self._snapshots.get(key)
if hit is not None:
@@ -299,7 +375,9 @@ class PitProfileService:
def stats(self) -> dict[str, int]:
out = dict(self._counters)
out["distinct_asof"] = len(self._ctx)
# distinct_asof = **累计**构建过的时点数(缓存里当前只剩最近几个,
# 用 len(self._ctx) 会把「涉及多少决策时点」报成 1~2,直接误导)。
out["distinct_asof"] = self._asof_built
out["cached_symbols"] = len(self._snapshots)
return out
@@ -317,9 +395,17 @@ class PitProfileService:
div = d
else:
since = asof - timedelta(days=int((self.max_years + 2) * 365.25))
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
ex = pd.to_datetime(d["ex_date"]).dt.date
div = d[(imp <= asof) & (ex <= asof) & (ex >= since)]
# P1:用 prepare 里预存的 datetime64 列直接比较(原来每次都对 2 万行
# ×2 列做 `pd.to_datetime(...).dt.date`,再用 object 比较 —— 后者会
# 退化到 Python 逐元素循环)。日期都在零点,闭区间端点与 object 版一致。
a = np.datetime64(asof, "ns")
sn = np.datetime64(since, "ns")
if "_imp_dt" in d.columns:
imp, ex = d["_imp_dt"], d["_ex_dt"]
else: # 兼容:未走 prepare 的调用路径
imp = pd.to_datetime(d["imp_ann_date"]).to_numpy(dtype="datetime64[ns]")
ex = pd.to_datetime(d["ex_date"]).to_numpy(dtype="datetime64[ns]")
div = d[(imp <= a) & (ex <= a) & (ex >= sn)]
div_by_symbol: dict[str, list[dict[str, Any]]] = {}
if not div.empty:
for rec in div.to_dict("records"):
@@ -351,6 +437,14 @@ class PitProfileService:
}
self._counters["financial_loads"] += 1
self._ctx[asof] = ctx
self._asof_built += 1
# 只保留最近 _MAX_ASOF_CONTEXTS 个时点(按插入顺序淘汰最旧)。
# 见该常量的说明:不设上限会让长区间每日回测 OOM。
while len(self._ctx) > _MAX_ASOF_CONTEXTS:
oldest = next(iter(self._ctx))
if oldest == asof:
break
self._ctx.pop(oldest, None)
self._counters["asof_contexts"] += 1
return ctx
@@ -360,21 +454,40 @@ class PitProfileService:
"""调用 ``ProfileBuilder._profile_one`` —— **指标定义的单一口径来源**。"""
start = date(asof.year - self.max_years - 1, 1, 1)
def _slice(df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return df
td = pd.to_datetime(df["trade_date"]).dt.date
return df[(td >= start) & (td <= asof)]
def _slice(d: pd.DataFrame) -> pd.DataFrame:
"""按 ``[start, asof]`` 剪裁(**必须已先按 symbol 过滤**)。
price = _slice(self._price) if not self._price.empty else self._price
price = price[price["symbol"] == symbol] if not price.empty else price
用 ``datetime64`` 比较而不是 ``.dt.date`` 的 object 比较:后者在
pandas 里逐元素装箱,实测 50 万行约 150 毫秒,前者毫秒级。
两者语义相同(日期都在零点,闭区间端点一致)。
"""
if d.empty:
return d
td = d["trade_date"]
if not pd.api.types.is_datetime64_any_dtype(td):
td = pd.to_datetime(td)
return d[(td >= pd.Timestamp(start)) & (td <= pd.Timestamp(asof))]
def _of_symbol(d: pd.DataFrame) -> pd.DataFrame:
"""先取该股票的行,再剪裁日期区间。
**顺序不能反**:``_price`` / ``_basics`` 是**全部股票**的面板
(每日回测里 123 只 × 17 年 ≈ 50 万行)。原先「先剪裁整表、再筛 symbol」
让每次画像快照都要处理 50 万行 —— 实测 174 毫秒,而「先筛 symbol
再剪裁」只要 11 毫秒(**16 倍**)。两个过滤条件互相独立,交换顺序
不改变任何结果(``ProfileBuilder._profile_one`` 内部还会
``sort_values("trade_date")``,所以行序也不敏感)。
"""
if d.empty:
return d
hit = d[d["symbol"] == symbol]
return _slice(hit)
price = _of_symbol(self._price)
if price.empty:
return None
basics = _slice(self._basics) if not self._basics.empty else self._basics
if not basics.empty:
basics = basics[basics["symbol"] == symbol]
basics = _with_columns(basics, _EMPTY_BASICS)
index = _slice(self._index) if not self._index.empty else self._index
basics = _with_columns(_of_symbol(self._basics), _EMPTY_BASICS)
index = _slice(self._index)
sym_div = ctx.div_by_symbol.get(symbol, [])
# ProfileBuilder 期望的 events 是 {symbol: DataFrame}
events = build_dps_events(pd.DataFrame(sym_div)) if sym_div else {}
+347
View File
@@ -0,0 +1,347 @@
"""每日动态股票池筛选器(``backtest --mode daily`` 的选股环节)。
**它做什么**:从 ``start`` 起,对**每一个交易日**按当日可见数据重建股票池。
判定逻辑一行不改 —— 仍然调用 :class:`~hdiv.universe.selector.UniverseSelector`
与四个 ``Filter``;本模块只负责两件工程上的事:
1. **取数**:用 :class:`~hdiv.universe.pit.PitRepo` 按区块批量预载,
逐日切片在内存完成(单次筛选从 10~18 秒降到秒级);
2. **候选集预剪枝**:见下。
------------------------------------------------------------
候选集预剪枝:为什么是「精确」的,而不是「近似」
------------------------------------------------------------
市场滤网要逐行判断 5000 余只股票的交易所/板块/上市年限/市值/流动性,
这一段的 Python 开销与**候选数**成正比,是每日循环里最大的一项。
预剪枝只剔除「在整个回测区间内**不可能**通过市场滤网」的股票,
判据都是**上界**:
- **交易所 / 板块**:与日期无关,不在配置名单里的股票永远不可能通过;
- **上市年限**:当 ``list_date + min_listing_years`` 晚于区间**最后一天**时,
该股在区间内任何一天都不满足 ``listed_years >= min_listing_years``;
- **市值**:当该股在区间内的 ``MAX(total_mv)``(换算为元)仍低于
``min_market_cap`` 时,任何一天都不满足市值下限。取不到市值(NULL)时
**保留**,不剪。
被剪掉的股票在原流程里**必然**在第一个滤网(market)就被淘汰,因此:
最终入选集合逐只相同,`hd_daily_universe` 的内容也相同。
差别只在于「被剪掉的股票没有留下逐滤网的原因」—— 而每日选股模式
**不落库逐股淘汰原因**(只落库每日入选成员),所以这个差别不可观测。
正确性由 ``tests/test_daily.py::test_prune_does_not_change_selection`` 锁定:
同一批交易日,开/关剪枝必须选出**完全相同**的成员。
**流动性没有做预剪枝**:``stock_daily`` 的量价单位在 2015-2019 是「手/千元」、
2020 起是「股/元」,用 ``MAX(amount)`` 做上界会在早年低估 1000 倍,
误剪掉本该通过的股票。宁可少一项优化,也不接受一个会改变结果的上界。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
import numpy as np
import pandas as pd
from hdiv.core.config import UniverseConfig
from hdiv.core.errors import HdivError
from hdiv.data import db
from hdiv.universe.pit import PitRepo
from hdiv.universe.selector import UniverseSelector
__all__ = ["DailyUniverseScreener", "PruneReport", "ScreenDay"]
#: daily_basic 的市值列以**万元**存放(见 data/units.py)
_WAN = 1e4
@dataclass
class PruneReport:
"""预剪枝的规模,用于回答「为什么候选从 5000 变成 1000」。"""
total: int = 0
pruned_exchange: int = 0
pruned_board: int = 0
pruned_listing: int = 0
pruned_market_cap: int = 0
kept: int = 0
def as_dict(self) -> dict[str, int]:
return dict(self.__dict__)
@dataclass
class ScreenDay:
"""某一天的选股结果。"""
trade_date: date
candidate_count: int
member_count: int
symbols: list[str]
members: pd.DataFrame
stats: dict[str, int] = field(default_factory=dict)
#: 当日**市场候选数**(未预剪枝)。与 ``candidate_count``(已预剪枝)分开记录 ——
#: 预剪枝是纯性能开关,不该让页面上的「候选」含义随开关变化。
listed_count: int = 0
#: 入选股票在**决策日的因子取值**(来自 ``UniverseSelector.run`` 的 ``values``)。
#: 必须带上它:``selected`` 里没有 ``dividend_yield`` 列(它在滤网的 values 里),
#: 只从 ``selected`` 取列会让落库的股息率**整列为 NULL**。
values: dict[str, dict[str, Any]] = field(default_factory=dict)
class DailyUniverseScreener:
"""逐日重建股票池(PIT),复用既有滤网,不改判定口径。"""
def __init__(
self,
config: UniverseConfig,
repo: PitRepo,
*,
verbose: bool = True,
) -> None:
self.config = config
self.repo = repo
self.selector = UniverseSelector(config, repo=repo)
self.verbose = verbose
self.prune = PruneReport()
self._allowed: set[str] | None = None
@classmethod
def from_strategy(cls, registry: Any, strategy: Any, repo: PitRepo,
*, verbose: bool = True) -> DailyUniverseScreener:
cfg = registry.resolved_universe(strategy)
return cls(cfg, repo, verbose=verbose)
# ------------------------------------------------------------------
# 预剪枝
# ------------------------------------------------------------------
def build_prune_set(
self, window_start: date, window_end: date, *, use_market_cap: bool = True
) -> set[str]:
"""计算「在 ``[window_start, window_end]`` 内不可能通过市场滤网」的补集。
返回**允许保留**的 symbol 集合。
"""
cfg = self.config.market
master = self.repo.stock_master().copy()
rep = PruneReport(total=len(master))
keep = pd.Series(True, index=master.index)
def _drop(mask: pd.Series, counter: str) -> None:
nonlocal keep
hit = keep & mask
setattr(rep, counter, getattr(rep, counter) + int(hit.sum()))
keep = keep & ~mask
# --- 交易所 / 板块(与日期无关)---
if cfg.exchanges:
allowed = {str(x) for x in cfg.exchanges}
_drop(~master["exchange"].astype(str).isin(allowed), "pruned_exchange")
if cfg.markets:
allowed_m = {str(x) for x in cfg.markets}
_drop(~master["market"].astype(str).isin(allowed_m), "pruned_board")
# --- 上市年限:区间最后一天仍不足,则区间内永远不足 ---
if cfg.min_listing_years and cfg.min_listing_years > 0:
ld = pd.to_datetime(master["list_date"], errors="coerce")
need_days = cfg.min_listing_years * 365.25
can_pass = (window_end - ld.dt.date).apply(
lambda x: x.days if pd.notna(x) else -1
) >= need_days
_drop(~can_pass, "pruned_listing")
# --- 市值:区间内 MAX(total_mv) 仍低于下限 ---
if use_market_cap:
cap = self._max_market_cap(window_start, window_end)
if cap:
for col, limit, counter in (
("total_mv", cfg.min_market_cap, "pruned_market_cap"),
("circ_mv", cfg.min_float_market_cap, "pruned_market_cap"),
):
if limit is None:
continue
mx = master["symbol"].map(cap.get(col, {}))
# 取不到市值时不剪(保守):只有**确知**上限低于阈值才剔除
too_small = mx.notna() & (mx < float(limit))
_drop(too_small, counter)
allowed = set(master.loc[keep, "symbol"].astype(str).tolist())
rep.kept = len(allowed)
self._allowed = allowed
self.prune = rep
self.repo.set_candidate_scope(allowed)
return allowed
def _max_market_cap(
self, start: date, end: date
) -> dict[str, dict[str, float]]:
"""区间内逐股 ``MAX(total_mv)`` / ``MAX(circ_mv)``,单位为**元**。
返回 ``{"total_mv": {symbol: 元}, "circ_mv": {symbol: 元}}``。
单位:``daily_basic.total_mv`` / ``circ_mv`` 以**万元**存放(Tushare 口径),
因此换算为元后再与配置里的元阈值比较。与 ``normalize_market_panel``
的 ×1e4 是同一件事。
"""
cfg = self.repo.cfg
if not db.table_exists("daily_basic", cfg):
return {}
try:
df = db.read_sql(
"SELECT symbol, MAX(total_mv) AS mx_total_mv, "
" MAX(circ_mv) AS mx_circ_mv "
"FROM daily_basic WHERE trade_date BETWEEN :s AND :e "
"GROUP BY symbol",
{"s": start, "e": end}, cfg=cfg,
)
except Exception: # pragma: no cover - 取不到就退化为不剪枝
return {}
if df.empty:
return {}
out: dict[str, dict[str, float]] = {"total_mv": {}, "circ_mv": {}}
syms = df["symbol"].astype(str).tolist()
total = pd.to_numeric(df["mx_total_mv"], errors="coerce") * _WAN
circ = pd.to_numeric(df["mx_circ_mv"], errors="coerce") * _WAN
for sym, t, c in zip(syms, total, circ, strict=False):
if pd.notna(t):
out["total_mv"][sym] = float(t)
if pd.notna(c):
out["circ_mv"][sym] = float(c)
return out
# ------------------------------------------------------------------
# 逐日筛选
# ------------------------------------------------------------------
def screen_day(self, day: date) -> ScreenDay:
"""筛选单个交易日。"""
def _hook(stage: str, live: pd.DataFrame) -> None:
# 把取数范围收窄到本阶段真正要评估的股票(纯性能开关)
self.repo.restrict_to(live["symbol"].tolist())
try:
res = self.selector.run(
asof=day, persist=False, verbose=False, on_stage=_hook
)
finally:
self.repo.restrict_to(None)
members = res["selected"]
symbols = [str(s) for s in members["symbol"].tolist()]
listed_total, screened = self.repo.listed_counts(res["asof_date"])
vals = res.get("values") or {}
return ScreenDay(
trade_date=res["asof_date"],
candidate_count=screened,
member_count=int(res["member_count"]),
symbols=symbols,
members=members,
stats=dict(res["stats"]),
listed_count=listed_total,
# 只为入选股票保留因子取值(全市场 4000 余只 × 1600 天会白占内存)
values={s: dict(vals.get(s) or {}) for s in symbols},
)
def screen(self, days: list[date]) -> dict[date, set[str]]:
"""对 ``days`` 逐日筛选,返回 ``{交易日: 入选代码集合}``。"""
if self._allowed is None:
raise HdivError(
"DailyUniverseScreener 必须先 build_prune_set(...) 再 screen(...)。\n"
" 预剪枝是可选的性能优化;若不想剪枝,请显式传 use_market_cap=False\n"
" 并把候选范围设为「全部上市股票」。"
)
out: dict[date, set[str]] = {}
for day in days:
sd = self.screen_day(day)
out[sd.trade_date] = set(sd.symbols)
return out
# ------------------------------------------------------------------
# 落库行
# ------------------------------------------------------------------
@staticmethod
def member_rows(
run_id: str, screens: list[ScreenDay], *, created_at: Any
) -> list[dict[str, Any]]:
"""把逐日选股结果摊平成 ``hd_daily_universe`` 的行。
取值优先级:**滤网的 ``values`` → ``selected`` 的列**。
股息率、支付率、FCF 覆盖等只在 ``values`` 里(它们是滤网算出来的),
``selected`` 只有行情/年报均值那几列;反过来 ``total_mv``/``roe_avg``
只在列里。只取其中一边都会让某些列整列为 NULL。
"""
rows: list[dict[str, Any]] = []
for sd in screens:
m = sd.members
if m is None or m.empty:
continue
for rec in m.to_dict("records"):
sym = str(rec.get("symbol"))
vals = dict(sd.values.get(sym) or {})
merged = {**{k: rec.get(k) for k in rec}, **vals}
def pick(key: str) -> Any:
v = vals.get(key)
if v is None or (isinstance(v, float) and v != v):
v = rec.get(key)
return v
rows.append({
"run_id": run_id,
"trade_date": sd.trade_date,
"symbol": sym,
"name": _s(rec.get("name")),
"industry": _s(rec.get("industry")),
# 股息率:筛选口径(自算优先)在 values 里;dv_ttm 在列里
"dividend_yield": _f(pick("dividend_yield")),
"total_mv": _f(pick("total_mv")),
"roe_avg": _f(pick("roe_avg")),
"listed_count": int(sd.listed_count) if sd.listed_count else None,
"candidate_count": int(sd.candidate_count),
"values_json": _values_json(merged),
"created_at": created_at,
})
return rows
def _s(v: Any) -> str | None:
if v is None or (isinstance(v, float) and v != v):
return None
return str(v)[:64]
def _f(v: Any) -> float | None:
if v is None:
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return None if (x != x or np.isinf(x)) else x
def _values_json(rec: dict[str, Any]) -> str:
"""入选时的关键因子快照(供「为什么是这只」复核)。"""
import json
keys = (
"dividend_yield", "dividend_yield_computed", "dv_ttm", "ttm_dps",
"pe_ttm", "pb", "ps_ttm", "total_mv", "circ_mv", "avg_amount_20d",
"dividend_continuity_years", "dividend_years_in_window", "payout_ratio",
"fcf_dividend_cover", "dps_cagr_5y", "roe", "roe_avg", "roic", "roic_avg",
"debt_ratio", "ocf_to_netprofit", "ocf_to_profit_avg", "fin_years_count",
)
out: dict[str, Any] = {}
for k in keys:
if k not in rec:
continue
v = _f(rec.get(k))
if v is not None:
out[k] = v
return json.dumps(out, ensure_ascii=False)
+884
View File
@@ -0,0 +1,884 @@
"""Point-in-Time 批量取数层(每日动态股票池的性能基座)。
**为什么需要它**:``--mode daily`` 要在**每个交易日**按当时可见数据重建股票池,
而 :class:`~hdiv.universe.selector.UniverseSelector` 单次运行约 10~18 秒 ——
其中 ``financial_panel`` 独占约 6 秒。这个开销是**表量级**的(与查询哪一天无关),
于是 1600 个交易日 × 12 秒 ≈ 5~8 小时,逐日筛选根本跑不完。
本模块把「取数」与「派生」拆开:
- **取数**改为**按区间批量预载一次**,之后逐日切片在内存里完成;
- **派生**(最新一期财报的合并、ROE 年化、单位归一化、支付率口径……)
**一行都不重写** —— :class:`PitRepo` 继承 :class:`~hdiv.data.repo.Repo`,
只覆盖**最底层的那几个取数方法**。上层的
``financial_panel`` / ``annual_financial_averages`` 等一律沿用父类实现,
因此它们调用到的都是被覆盖后的底层方法。
这样做的直接后果:**筛选口径只有一份**。``UniverseSelector`` 与四个 ``Filter``
在 daily 模式下逐字未改,它们看到的 DataFrame 与直连数据库时逐值相同。
这一点有回归测试锁定(``tests/test_daily.py::test_pit_repo_matches_direct_repo``),
与 ``tests/test_profile_pit.py`` 锁定实时画像/批量画像一致性的做法相同。
**内存**:行情与每日指标按区块(默认一年)载入,用完即 ``release_range()`` 释放;
财务四表与分红明细体量小(合计约 120 万行),一次性常驻。
**越界即报错**:``market_panel`` / ``avg_amount`` 只对已载入区间内的时点有定义。
查询落在区间外时**抛错而不是返回空表** —— 空表会被上层当成「当天没有股票」,
静默产出一个空股票池,属于最难发现的一类错误。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import Any
import numpy as np
import pandas as pd
from hdiv.core.config import DataSourceConfig, load_config
from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db
from hdiv.data.repo import Repo
from hdiv.data.units import (
normalize_financial_panel,
normalize_market_panel,
normalize_ohlcv_units,
)
__all__ = ["PitRepo"]
# ---------------------------------------------------------------------------
# 取数列清单(与 Repo 里的 SQL 逐列一致,多一列少一列都会破坏等价性)
# ---------------------------------------------------------------------------
_MARKET_COLS = (
"symbol, trade_date, close, turnover_rate, pe, pe_ttm, pb, ps, ps_ttm, "
"dv_ratio, dv_ttm, total_share, float_share, free_share, total_mv, circ_mv"
)
_DAILY_COLS = "symbol, close, volume, amount, trade_date"
#: 财务表 → 需要的列。``report_type`` 只用于过滤,**不进入返回值**
#: (与 ``Repo._latest_financial`` 的 SELECT 列表保持一致)。
_FIN_COLS: dict[str, tuple[str, ...]] = {
"hd_fina_indicator": (
"roe", "roic", "debt_to_assets", "grossprofit_margin",
"netprofit_margin", "ocf_to_profit",
),
"hd_cashflow": (
"report_type", "n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp",
),
"hd_balancesheet": (
"report_type", "total_assets", "total_liab", "total_hldr_eqy_exc_min_int",
"money_cap", "goodwill",
),
"hd_income": (
"report_type", "total_revenue", "revenue", "n_income", "n_income_attr_p",
),
}
#: 需要 ``report_type = '1'``(合并报表)过滤的表 —— 与 Repo 中的判断一致
_REPORT_TYPE_TABLES = frozenset({"hd_cashflow", "hd_balancesheet", "hd_income"})
_DIVIDEND_COLS = (
"symbol, end_date, ann_date, imp_ann_date, div_proc, cash_div_tax, cash_div, "
"stk_div, stk_bo_rate, stk_co_rate, record_date, ex_date, pay_date, base_share"
)
# ---------------------------------------------------------------------------
# 诊断计数
# ---------------------------------------------------------------------------
@dataclass
class PitRepoStats:
"""取数与切片次数(用于回答「到底慢在哪」,而不是靠猜)。"""
ranges_loaded: int = 0
rows_loaded: int = 0
reference_loaded: int = 0
reference_rows: int = 0
market_slices: int = 0
amount_slices: int = 0
financial_slices: int = 0
dividend_slices: int = 0
def as_dict(self) -> dict[str, int]:
return dict(self.__dict__)
# ---------------------------------------------------------------------------
# PitRepo
# ---------------------------------------------------------------------------
class PitRepo(Repo):
"""``Repo`` 的批量预载版本:同样的接口、同样的口径,不同的取数方式。
用法::
repo = PitRepo()
repo.load_reference(end=date(2026, 9, 4)) # 财务/分红/日历(一次)
repo.load_range(date(2020, 1, 1), date(2020, 12, 31))
sel = UniverseSelector(cfg, repo=repo)
sel.run(asof=date(2020, 6, 3), persist=False)
repo.release_range() # 释放行情,换下一个区块
"""
#: 预载时向前多取的自然日数,保证区块首日也能算 20 日均额 / 5 日回看
OVERLAP_DAYS = 120
def __init__(self, cfg: DataSourceConfig | None = None) -> None:
super().__init__(cfg)
self.stats_ = PitRepoStats()
self._loaded = False
# 交易日历(常驻)
self._cal: np.ndarray = np.array([], dtype="datetime64[ns]")
self._cal_days: list[date] = []
self._today = date.today()
# 常驻参照数据
self._fin: dict[str, pd.DataFrame] = {}
self._dividend = pd.DataFrame()
# 区块数据(可释放)
self._market = pd.DataFrame()
self._daily = pd.DataFrame()
self._daily_pos: dict[str, np.ndarray] = {}
self._suspend = pd.DataFrame()
self._range: tuple[date, date] | None = None
#: 参照数据(财务/分红)的终点。常驻帧只覆盖到这一天,
#: 任何超出它的区间查询都必须退回直连,否则会静默少行。
self._ref_end: date | None = None
# 可见性指纹(每张财务表的已公告日集合)—— 缓存的键
self._vis_dates: dict[str, np.ndarray] = {}
self._memo: dict[tuple, Any] = {}
#: 只对**指定股票**回答 ``annual_financials``(见 :meth:`restrict_to`)
self._scope: set[str] | None = None
#: 候选集预剪枝(见 :meth:`set_candidate_scope`);None = 与 Repo 口径一致
self._candidate_scope: set[str] | None = None
# ------------------------------------------------------------------
# 载入
# ------------------------------------------------------------------
def load_reference(self, end: date) -> None:
"""一次性载入跨区块共享的数据:交易日历、财务四表、分红明细。
财务表按 ``ann_date <= end`` 取全量(**不加 end_date 下界**)——
``Repo._latest_financial`` 本身没有报告期下界,若在这里加了,
「最新一期财报很旧」的股票会被静默漏掉,与直连口径不一致。
"""
cfg = self.cfg
# --- 交易日历:从 2000 年起,保证任何 PIT 回看窗口都有交易日 ---
cal = db.read_sql(
"SELECT calendar_date FROM trading_calendar "
"WHERE is_open = 1 AND calendar_date <= :e ORDER BY calendar_date",
{"e": end}, cfg=cfg,
)
if cal.empty:
raise DataGapError("交易日历为空,无法进行每日选股")
self._cal = pd.to_datetime(cal["calendar_date"]).to_numpy(dtype="datetime64[ns]")
self._cal_days = [pd.Timestamp(x).date() for x in self._cal]
self.stats_.reference_rows += len(cal)
# --- 财务四表 ---
for table, cols in _FIN_COLS.items():
if not db.table_exists(table, cfg):
self._fin[table] = pd.DataFrame(
columns=["symbol", "end_date", "ann_date", *cols]
)
continue
sel = ", ".join(f"`{c}`" for c in ("symbol", "end_date", "ann_date", *cols))
df = db.read_sql(
f"SELECT {sel} FROM `{table}` WHERE ann_date <= :e "
"ORDER BY symbol, end_date, ann_date",
{"e": end}, cfg=cfg,
)
for c in ("end_date", "ann_date"):
if not df.empty:
df[c] = pd.to_datetime(df[c]).dt.date
# 内部再存一份 datetime64 版本:**判定与排序一律走它**。
# object dtype 的 datetime.date 列在 pandas 里做比较/排序会退化到
# Python 循环,实测一个 groupby(...).max() 就要 2.7 秒/次;
# 换成 datetime64 后是 Cython 路径,快两个数量级。
# 对外的 ann_date/end_date 仍是 date 对象,与 Repo 的返回类型一致。
df["_ann"] = pd.to_datetime(df["ann_date"]).to_numpy(dtype="datetime64[ns]")
df["_end"] = pd.to_datetime(df["end_date"]).to_numpy(dtype="datetime64[ns]")
df["_end_month"] = pd.to_datetime(df["end_date"]).dt.month.to_numpy()
if "report_type" in df.columns:
df["_is1"] = df["report_type"].astype(str).to_numpy() == "1"
else:
df["_is1"] = True
for c in cols:
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
# **一次性预排序**(symbol, 报告期, 公告日)升序。
# 这一步让每个查询都不必再对 30 万行排序:窗口函数要的
# 「每个 symbol 取 end_date 最大、并列时 ann_date 最大」恰好等价于
# 「在已按 (symbol, end_date, ann_date) 升序的帧上按 symbol 取最后一行」,
# 于是 drop_duplicates(keep="last") 就是 SQL 里
# ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY end_date DESC,
# ann_date DESC) = 1。
# 实测:每次查询省掉一次约 0.3~0.5 秒的全表排序;年报季缓存每天都会
# 失效(新公告改变了可见集合),这一项就是模拟阶段的主要开销。
df = df.sort_values(
["symbol", "_end", "_ann"], kind="mergesort", ignore_index=True
)
self._fin[table] = df
self.stats_.reference_rows += len(df)
if not df.empty:
self._vis_dates[table] = np.unique(df["_ann"])
# --- 分红(PIT 过滤留给查询时,因为窗口随 asof 滑动)---
#
# 预载条件必须是**超集**:`imp_ann_date <= :e OR ex_date <= :e`。
# 若只按 `imp_ann_date <= :e` 取,会漏掉两类行,而 `dividend_events`
# (持仓期间分红入账)需要它们:
# - 74 行 `imp_ann_date IS NULL` 但有 `ex_date`(Repo.dividend_events
# 本身没有 imp_ann_date 条件,是包含这些行的);
# - 9 行 `imp_ann_date > ex_date`(数据源瑕疵)。
# 漏掉就是**静默少算现金分红**,所以宁可取宽。
# `dividend_records` 的 PIT 语义不受影响:它的 `imp_ann_date <= asof`
# 会把这两类行排除(NaT 比较为 False)。
if db.table_exists("hd_dividend", cfg):
div = db.read_sql(
f"SELECT {_DIVIDEND_COLS} FROM hd_dividend "
"WHERE (imp_ann_date <= :e OR ex_date <= :e)",
{"e": end}, cfg=cfg,
)
for c in ("end_date", "ann_date", "imp_ann_date", "record_date",
"ex_date", "pay_date"):
if c in div.columns and not div.empty:
div[c] = pd.to_datetime(div[c]).dt.date
for c in ("cash_div_tax", "cash_div", "stk_div", "stk_bo_rate",
"stk_co_rate", "base_share"):
if c in div.columns:
div[c] = pd.to_numeric(div[c], errors="coerce")
else:
div = pd.DataFrame(columns=[c.strip() for c in _DIVIDEND_COLS.split(",")])
self._dividend = div
self.stats_.reference_rows += len(div)
self.stats_.reference_loaded += 1
self._ref_end = end
self._loaded = True
def load_range(self, start: date, end: date) -> None:
"""载入 ``[start, end]`` 的行情/每日指标/停牌数据(替换上一个区块)。
实际取数区间会向前扩 :attr:`OVERLAP_DAYS` 个自然日,使区块首日的
「5 日回看」「20 日均额」仍能算全。
"""
if not self._loaded:
raise HdivError("PitRepo 必须先 load_reference(end) 再 load_range(...)")
cfg = self.cfg
lo = start - timedelta(days=self.OVERLAP_DAYS)
mk = db.read_sql(
f"SELECT {_MARKET_COLS} FROM daily_basic "
"WHERE trade_date BETWEEN :s AND :e",
{"s": lo, "e": end}, cfg=cfg,
)
if not mk.empty:
mk["trade_date"] = pd.to_datetime(mk["trade_date"])
# 排序在 pandas 里做(约 0.5 秒),不用 SQL 的 ORDER BY ——
# 实测 MySQL 对 170 万行结果集做 filesort 要多花约 60 秒,
# 而后续的 searchsorted 切片只要求「按 trade_date 升序」。
mk = mk.sort_values(["trade_date", "symbol"], ignore_index=True)
for c in ("close", "turnover_rate", "pe", "pe_ttm", "pb", "ps", "ps_ttm",
"dv_ratio", "dv_ttm", "total_share", "float_share", "free_share",
"total_mv", "circ_mv"):
if c in mk.columns:
mk[c] = pd.to_numeric(mk[c], errors="coerce")
self._market = mk.reset_index(drop=True)
self.stats_.rows_loaded += len(mk)
dl = db.read_sql(
f"SELECT {_DAILY_COLS} FROM stock_daily "
"WHERE trade_date BETWEEN :s AND :e",
{"s": lo, "e": end}, cfg=cfg,
)
if not dl.empty:
dl["trade_date"] = pd.to_datetime(dl["trade_date"])
dl = dl.sort_values(["trade_date", "symbol"], ignore_index=True)
for c in ("close", "volume", "amount"):
if c in dl.columns:
dl[c] = pd.to_numeric(dl[c], errors="coerce")
self._daily = dl.reset_index(drop=True)
self._daily_pos = self._build_symbol_index(self._daily)
self.stats_.rows_loaded += len(dl)
if db.table_exists("hd_suspend", cfg):
sp = db.read_sql(
"SELECT symbol, trade_date FROM hd_suspend "
"WHERE trade_date BETWEEN :s AND :e AND suspend_type = 'S'",
{"s": lo, "e": end}, cfg=cfg,
)
if not sp.empty:
sp["trade_date"] = pd.to_datetime(sp["trade_date"]).dt.date
else:
sp = pd.DataFrame(columns=["symbol", "trade_date"])
self._suspend = sp
self.stats_.rows_loaded += len(sp)
self._range = (lo, end)
self.stats_.ranges_loaded += 1
def release_range(self) -> None:
"""释放区块数据(内存随区块数保持常数,而不是随回测长度增长)。"""
self._market = pd.DataFrame()
self._daily = pd.DataFrame()
self._daily_pos = {}
self._suspend = pd.DataFrame()
self._range = None
@staticmethod
def _build_symbol_index(df: pd.DataFrame) -> dict[str, np.ndarray]:
"""``symbol → 行位置数组``(行内保持日期升序)。
只存整数位置(每行 8 字节),不复制数据。财务/画像会对**单只股票**做
上万次 ``avg_amount`` 查询,逐次 ``df[df.symbol == s]`` 是全表扫描,
实测会占掉每日循环的大头。
"""
if df.empty:
return {}
codes, labels = pd.factorize(df["symbol"])
order = np.argsort(codes, kind="stable")
sorted_codes = codes[order]
bounds = np.searchsorted(sorted_codes, np.arange(len(labels)), side="left")
ends = np.searchsorted(sorted_codes, np.arange(len(labels)), side="right")
return {
str(label): order[bounds[i]:ends[i]] for i, label in enumerate(labels)
}
# ------------------------------------------------------------------
# 交易日历(覆盖父类的逐次 SQL 查询)
# ------------------------------------------------------------------
def _require_loaded(self) -> None:
if not self._loaded or self._cal.size == 0:
raise HdivError("PitRepo 未载入参照数据,请先调用 load_reference(end)")
def trading_day(self, asof: date | None = None) -> date:
self._require_loaded()
if asof is None:
asof = min(self._today, self._cal_days[-1])
i = int(np.searchsorted(self._cal, np.datetime64(asof, "ns"), side="right")) - 1
if i < 0:
raise DataGapError(f"交易日历中找不到 <= {asof} 的交易日")
return self._cal_days[i]
def prev_trading_day(self, d: date) -> date:
self._require_loaded()
i = int(np.searchsorted(self._cal, np.datetime64(d, "ns"), side="left")) - 1
if i < 0:
raise DataGapError(f"交易日历中找不到 < {d} 的交易日")
return self._cal_days[i]
def trading_days(self, start: date, end: date) -> list[date]:
self._require_loaded()
i0 = int(np.searchsorted(self._cal, np.datetime64(start, "ns"), side="left"))
i1 = int(np.searchsorted(self._cal, np.datetime64(end, "ns"), side="right"))
return self._cal_days[i0:i1]
# ------------------------------------------------------------------
# 区块内切片助手
# ------------------------------------------------------------------
def _assert_in_range(self, asof: date, what: str) -> None:
if self._range is None:
raise HdivError(f"PitRepo 尚未 load_range,无法查询 {what}")
lo, hi = self._range
if not (lo <= asof <= hi):
raise HdivError(
f"{what} 的时点 {asof} 落在已载入区间 [{lo}, {hi}] 之外。\n"
f" 这是保护性报错:返回空表会被上层当成「该日没有股票」,\n"
f" 静默产出一个空股票池。请调整 load_range 的区间。"
)
@staticmethod
def _slice_by_date(df: pd.DataFrame, lo: date, hi: date) -> pd.DataFrame:
"""按 ``trade_date``(datetime64)取闭区间切片(对数复杂度)。"""
if df.empty:
return df
col = df["trade_date"].to_numpy(dtype="datetime64[ns]")
i0 = int(np.searchsorted(col, np.datetime64(lo, "ns"), side="left"))
i1 = int(np.searchsorted(col, np.datetime64(hi, "ns"), side="right"))
return df.iloc[i0:i1]
def _window_days(self, d0: date, window: int) -> list[date]:
"""``<= d0`` 的最近 ``window`` 个交易日(与 Repo 的取值方式一致)。"""
i1 = int(np.searchsorted(self._cal, np.datetime64(d0, "ns"), side="right"))
# Repo 先按自然日 [d0 - 3*window, d0] 取全部交易日,再取末尾 window 个
lo = d0 - timedelta(days=window * 3)
i0 = int(np.searchsorted(self._cal, np.datetime64(lo, "ns"), side="left"))
return self._cal_days[i0:i1][-window:]
# ------------------------------------------------------------------
# 行情 / 每日指标
# ------------------------------------------------------------------
def set_candidate_scope(self, allowed: list[str] | set[str] | None) -> None:
"""把 :meth:`listed_universe` 的候选集限制在 ``allowed``(``None`` = 不限制)。
仅供每日选股器的**保守预剪枝**使用:``allowed`` 必须是「在整个回测区间内
不可能通过市场滤网」的补集(见 :mod:`hdiv.universe.daily`)。
设了范围之后 :meth:`listed_universe` 不再与 :class:`Repo` 逐值一致 ——
这是**故意的**,因此默认 ``None``,等价性测试在默认状态下进行。
"""
self._candidate_scope = None if allowed is None else {str(s) for s in allowed}
def listed_universe(self, asof: date) -> pd.DataFrame:
"""与 :meth:`Repo.listed_universe` 一致,另可叠加候选集预剪枝。"""
out = super().listed_universe(asof)
if self._candidate_scope is None or out.empty:
return out
keep = out["symbol"].astype(str).isin(self._candidate_scope)
return out[keep].reset_index(drop=True)
def listed_counts(self, asof: date) -> tuple[int, int]:
"""``(当日市场候选数, 预剪枝后候选数)``。
两个数都要留痕:只记一个会让页面上的「候选」在开/关预剪枝时含义不同,
而预剪枝是**纯性能开关**,不该改变任何可见数字的语义。
额外的这次 ``listed_universe`` 约 1 毫秒(``stock_master`` 已缓存)。
"""
out = Repo.listed_universe(self, asof)
total = int(len(out))
if self._candidate_scope is None or out.empty:
return total, total
kept = int(out["symbol"].astype(str).isin(self._candidate_scope).sum())
return total, kept
def market_panel(self, asof: date, *, lookback_days: int = 0) -> pd.DataFrame:
"""与 :meth:`Repo.market_panel` 逐值一致,只是数据来自内存区块。"""
self._assert_in_range(asof, "market_panel")
self.stats_.market_slices += 1
d0 = self.trading_day(asof)
if lookback_days <= 0:
sub = self._slice_by_date(self._market, d0, d0).copy()
if not sub.empty:
sub["trade_date"] = sub["trade_date"].dt.date
sub["is_fresh"] = True
sub["asof_trade_date"] = d0
return normalize_market_panel(sub)
days = self._window_days(d0, lookback_days)
if not days:
out = self._market.iloc[0:0].copy()
out["is_fresh"] = pd.Series(dtype=bool)
out["asof_trade_date"] = pd.Series(dtype="object")
return out
sub = self._slice_by_date(self._market, days[0], d0).copy()
if sub.empty:
sub["is_fresh"] = pd.Series(dtype=bool)
sub["asof_trade_date"] = pd.Series(dtype="object")
return sub
# Repo 把 trade_date 转成 datetime.date 后再排序取 last,此处保持一致
sub["trade_date"] = sub["trade_date"].dt.date
sub = sub.sort_values(["symbol", "trade_date"])
last = sub.groupby("symbol", as_index=False).last()
last["is_fresh"] = last["trade_date"].eq(d0)
last["asof_trade_date"] = d0
return normalize_market_panel(last.reset_index(drop=True))
def avg_amount(
self, asof: date, window: int = 20, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""与 :meth:`Repo.avg_amount` 逐值一致。
``symbols`` 非空时走**预建的 symbol→行位置索引**:画像会对单只股票
反复查询,逐次全表过滤会让每日循环退化成「天数 × 全表扫描」。
**区块外**:带 ``symbols`` 的窄查询回退到直连数据库 —— 它走
``symbol IN (...)`` 索引,是毫秒级;而全市场查询必须落在已载入区块内,
否则会返回空表(被上层当成「当天没有股票」)。画像路径在区块释放后
正是靠这条回退工作的。
"""
if self._range is None or not (self._range[0] <= asof <= self._range[1]):
if symbols:
return Repo.avg_amount(self, asof, window=window, symbols=symbols)
self._assert_in_range(asof, "avg_amount(全市场)")
self.stats_.amount_slices += 1
d0 = self.trading_day(asof)
days = self._window_days(d0, window)
if not days:
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
lo, hi = days[0], days[-1]
if symbols:
parts: list[pd.DataFrame] = []
for sym in symbols:
pos = self._daily_pos.get(str(sym))
if pos is None or pos.size == 0:
continue
sub = self._daily.take(pos)
sub = self._slice_by_date(sub, lo, hi)
if not sub.empty:
parts.append(sub)
df = pd.concat(parts, ignore_index=True) if parts else self._daily.iloc[0:0]
else:
df = self._slice_by_date(self._daily, lo, hi)
if df.empty:
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
df = df[["symbol", "close", "volume", "amount"]].copy()
for c in ("close", "volume", "amount"):
df[c] = pd.to_numeric(df[c], errors="coerce")
# 必须**逐切片**归一化:单位判定是逐行的,切片与全表的判定结果相同,
# 但如果在预载时统一换算,区块边界处会与 Repo 的窗口口径分叉。
df, _diag = normalize_ohlcv_units(df)
g = df.groupby("symbol", as_index=False).agg(
avg_amount=("amount", "mean"), n=("amount", "size")
)
return g
def suspended_on(self, asof: date) -> set[str]:
self._assert_in_range(asof, "suspended_on")
if self._suspend.empty:
return set()
d0 = self.trading_day(asof)
hit = self._suspend[self._suspend["trade_date"] == d0]
return set(hit["symbol"].tolist())
# ------------------------------------------------------------------
# 财务(只覆盖底层取数;上层派生一律沿用父类)
# ------------------------------------------------------------------
def _fin_table(self, table: str) -> pd.DataFrame:
df = self._fin.get(table)
if df is None:
return pd.DataFrame(columns=["symbol", "end_date", "ann_date"])
return df
# ------------------------------------------------------------------
# 可见性缓存:同一批「已公告财报」→ 同一个结果
# ------------------------------------------------------------------
def _visible_key(self, table: str, asof: date) -> int:
"""``asof`` 之前已公告的财报**条数**(该表已公告日的排序插入位置)。
这是「哪些财报当时可见」的**充分统计量**:条数相同,可见集合就相同,
因此以它为缓存键是**精确**的,不是近似 —— 与「按 asof 缓存」不同,
后者在年报季几乎每天都不命中,等于没缓存。
"""
arr = self._vis_dates.get(table)
if arr is None or arr.size == 0:
return 0
return int(np.searchsorted(arr, np.datetime64(asof, "ns"), side="right"))
def _memo_get(self, key: tuple, build: Any) -> Any:
hit = self._memo.get(key)
if hit is None:
hit = build()
# 键随 asof 单调变化,历史条目不会再被命中 —— 小容量即可
if len(self._memo) > 16:
self._memo.clear()
self._memo[key] = hit
return hit
def restrict_to(self, symbols: list[str] | None) -> None:
"""把 ``annual_financials`` 的回答范围收窄到 ``symbols``(``None`` = 全市场)。
为什么可以收窄:``annual_financials`` 唯一的消费者是分红滤网的
``_fy_table``,它把结果建成 ``{(symbol, 财年): 行}`` 后只按**候选股**查表,
非候选股的行永远不会被读取。而它默认会对全市场 5000 余只 × 10 个财年
构造约 5 万行再逐行 ``iterrows()`` —— 实测 2.8 秒/天,是每日选股里
最大的单项开销;收窄到市场/风险滤网的存活者(约 170 只)后降到 0.1 秒级。
这不是「近似」:被剔除的行在调用方从未被访问。收窄只在**筛选路径**上生效,
调用方必须在用完后显式 ``restrict_to(None)`` 复位(每日选股器就是这么做的),
否则画像路径的 ``annual_financials`` 会被误伤。
"""
self._scope = None if symbols is None else {str(s) for s in symbols}
def _scoped(self, df: pd.DataFrame) -> pd.DataFrame:
if self._scope is None or df.empty or "symbol" not in df.columns:
return df
return df[df["symbol"].isin(self._scope)]
@staticmethod
def _np(d: date) -> np.datetime64:
return np.datetime64(d, "ns")
def _latest_financial(
self, asof: date, table: str, cols: list[str], *,
symbols: list[str] | None = None,
) -> pd.DataFrame:
"""等价于 ``Repo._latest_financial`` 的窗口函数:
``ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC)``
,条件 ``ann_date <= asof AND ann_date >= end_date``(三张表另加
``report_type = '1'``)。父类的 :meth:`Repo.financial_panel` 会继续
调用本方法,因此合并与衍生指标的代码原封不动。
"""
self.stats_.financial_slices += 1
empty = pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols])
df = self._fin_table(table)
if df.empty:
return empty
def _build() -> pd.DataFrame:
a = self._np(asof)
m = (df["_ann"] <= a) & (df["_ann"] >= df["_end"])
if table in _REPORT_TYPE_TABLES:
m &= df["_is1"].to_numpy()
sub = df[m]
if sub.empty:
return empty
# 存储已按 (symbol, _end, _ann) 升序预排 → 取每个 symbol 的最后一行
# 就是 SQL 的 ROW_NUMBER(... ORDER BY end_date DESC, ann_date DESC) = 1
first = sub.drop_duplicates(subset=["symbol"], keep="last")
out = first[["symbol", "end_date", "ann_date", *cols]].reset_index(drop=True)
return normalize_financial_panel(out)
# 全市场结果按「可见财报条数」缓存;symbols 只是它的子集,不能反过来
# 用子集覆盖全量缓存(否则下一次全市场查询会拿到残缺的面板)。
key = ("latest", table, tuple(cols), self._visible_key(table, asof))
full = self._memo_get(key, _build)
if symbols:
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
return full
def annual_financial_history(
self, asof: date, *, years: int = 6, symbols: list[str] | None = None
) -> pd.DataFrame:
"""等价于 ``Repo.annual_financial_history``:只取年报,按
``(symbol, end_date)`` 取最大公告日那一行,再做百分数归一化。"""
df = self._fin_table("hd_fina_indicator")
if df.empty:
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
since = date(asof.year - years - 1, 12, 31)
def _build() -> pd.DataFrame:
a, sn = self._np(asof), self._np(since)
m = (
(df["_ann"] <= a)
& (df["_ann"] >= df["_end"])
& (df["_end_month"] == 12)
& (df["_end"] >= sn)
)
sub = df[m]
if sub.empty:
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
# 存储已按 (symbol, _end, _ann) 升序预排 → 每个 (symbol, 报告期)
# 取最后一行即「最大 ann_date」那一行(等价于 SQL 的 MAX(ann_date) 自连接;
# 前提是 (symbol, end_date, ann_date) 无重复,已由实测确认)。
fin = sub.drop_duplicates(subset=["symbol", "_end"], keep="last").copy()
fin["year"] = fin["_end"].dt.year
# 只保留 Repo.annual_financial_history 的 SELECT 列表 ——
# 多带一列(如 debt_to_assets)会让下游列集合与直连口径分叉。
keep = ["symbol", "end_date", "ann_date", "roe", "roic",
"grossprofit_margin", "netprofit_margin", "ocf_to_profit", "year"]
for c in ("roe", "roic", "grossprofit_margin", "netprofit_margin",
"ocf_to_profit"):
if c in fin.columns:
fin[c] = fin[c] / 100.0
fin = fin[keep]
ocf = self._annual_ocf_ratio(asof, since, symbols=None)
if not ocf.empty:
fin = fin.merge(ocf, on=["symbol", "year"], how="left")
return fin
key = ("afh", self._visible_key("hd_fina_indicator", asof), years, str(since))
full = self._memo_get(key, _build)
if symbols:
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
return full
def annual_financial_averages(
self, asof: date, *, years: int = 5, min_years: int = 3,
symbols: list[str] | None = None, hist: pd.DataFrame | None = None,
) -> pd.DataFrame:
"""缓存版本:全市场结果按可见财报条数复用,``symbols`` 只做子集。
逐股聚合与「是否只算这些股票」无关(每个 symbol 独立求均值),
因此「先算全市场再取子集」与直连口径逐值相同。
"""
if hist is not None:
return Repo.annual_financial_averages(
self, asof, years=years, min_years=min_years, symbols=symbols, hist=hist,
)
key = ("afa", self._visible_key("hd_fina_indicator", asof), years, min_years)
full = self._memo_get(
key,
lambda: Repo.annual_financial_averages(
self, asof, years=years, min_years=min_years, symbols=None,
),
)
if symbols:
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
return full
def _annual_ocf_ratio(
self, asof: date, since: date, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""等价于 ``Repo._annual_ocf_ratio``:现金流量表 ⋈ 利润表(同年报期同公告日)。"""
cf, inc = self._fin_table("hd_cashflow"), self._fin_table("hd_income")
if cf.empty or inc.empty:
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
def _build() -> pd.DataFrame:
a, sn = self._np(asof), self._np(since)
c = cf[cf["_is1"].to_numpy()]
i = inc[inc["_is1"].to_numpy()]
c = c[(c["_end_month"] == 12) & (c["_end"] >= sn) & (c["_ann"] <= a)]
if c.empty:
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
joined = c.merge(
i[["symbol", "_end", "_ann", "n_income_attr_p"]],
on=["symbol", "_end", "_ann"], how="inner",
suffixes=("", "_i"),
)
if joined.empty:
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
joined["year"] = joined["_end"].dt.year
ocf = pd.to_numeric(joined["n_cashflow_act"], errors="coerce")
ni = pd.to_numeric(joined["n_income_attr_p"], errors="coerce")
joined["ocf_to_netprofit_calc"] = ocf / ni.replace(0, pd.NA)
return joined[["symbol", "year", "ocf_to_netprofit_calc"]]
key = ("ocf", self._visible_key("hd_cashflow", asof),
self._visible_key("hd_income", asof), str(since))
full = self._memo_get(key, _build)
if symbols:
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
return full
def annual_financials(
self, asof: date, *, years: int = 12, symbols: list[str] | None = None
) -> pd.DataFrame:
"""等价于 ``Repo.annual_financials``(年报口径、按财年对齐)。
``symbols`` 为空时若已通过 :meth:`restrict_to` 设定了范围,则只返回
范围内的股票 —— 见 :meth:`restrict_to` 对「为什么这是精确的」的说明。
"""
inc, cf = self._fin_table("hd_income"), self._fin_table("hd_cashflow")
empty = pd.DataFrame(
columns=["symbol", "year", "n_income", "n_income_attr_p",
"n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp"]
)
if inc.empty:
return empty
since = date(asof.year - years - 1, 12, 31)
a, sn = self._np(asof), self._np(since)
want = set(symbols) if symbols else self._scope
n12 = inc["_end_month"].to_numpy() == 12
i = inc[inc["_is1"].to_numpy() & n12].copy()
i = i[(i["_end"] >= sn) & (i["_ann"] <= a) & (i["_ann"] >= i["_end"])]
if want:
i = i[i["symbol"].isin(want)]
if i.empty:
return empty
if cf.empty:
joined = i.copy()
for c in ("n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp"):
joined[c] = pd.NA
else:
cn12 = cf["_end_month"].to_numpy() == 12
f = cf[cf["_is1"].to_numpy() & cn12 & (cf["_end"].to_numpy() >= sn)][
["symbol", "_end", "_ann", "n_cashflow_act", "free_cashflow",
"c_pay_dist_dpcp_int_exp"]
]
joined = i.merge(f, on=["symbol", "_end", "_ann"], how="left")
# 利润表与现金流量表可能各有多行(重复公告),LEFT JOIN 会放大行数 ——
# 与 SQL 的 LEFT JOIN 行为一致,故此处不额外去重。
joined["year"] = joined["_end"].dt.year
for c in ("n_income", "n_income_attr_p", "n_cashflow_act", "free_cashflow",
"c_pay_dist_dpcp_int_exp"):
if c in joined.columns:
joined[c] = pd.to_numeric(joined[c], errors="coerce")
return joined[["symbol", "year", "n_income", "n_income_attr_p", "n_cashflow_act",
"free_cashflow", "c_pay_dist_dpcp_int_exp"]]
# ------------------------------------------------------------------
# 分红
# ------------------------------------------------------------------
def dividend_records(
self, asof: date, *, years_back: int = 12, implemented_only: bool = True
) -> pd.DataFrame:
"""等价于 ``Repo.dividend_records`` 的 PIT 三重条件(内存过滤)。
**P4:若已 ``restrict_to`` 设定范围,只返回范围内的股票。**
分红滤网拿到记录后先 ``_group()`` 建成 ``{symbol: [记录]}``,再**只按候选股**
查表;为全市场建表的那部分永远不会被读到。逐日筛选时这一步是每天对
2 万余行做一次 ``to_dict("records")``,而候选通常只有一两百只 ——
收窄范围把它降到百分之几,且不改变任何候选股的判定输入。
注意:画像路径(``PitProfileService._context`` 里的 ``self._all_dividends``)
调用本方法时范围必须是 ``None``;每日选股器用完后会显式复位。
"""
self.stats_.dividend_slices += 1
d = self._dividend
if d.empty:
return d
if self._scope is not None:
d = d[d["symbol"].isin(self._scope)]
if d.empty:
return d
since = asof - timedelta(days=int(years_back * 365.25))
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
ex = pd.to_datetime(d["ex_date"], errors="coerce").dt.date
m = (imp <= asof) & ex.notna() & (ex <= asof) & (ex >= since)
if implemented_only:
m &= d["div_proc"].astype(str) == "实施"
out = d[m].sort_values(["symbol", "ex_date"])
return out.reset_index(drop=True)
def dividend_events(
self, start: date, end: date, *, implemented_only: bool = True
) -> pd.DataFrame:
"""等价于 ``Repo.dividend_events``,但直接从常驻分红明细里筛(P6)。
引擎在 ``_prepare`` 里为「持仓期间的分红入账」取一次区间事件,
原先这会再查一次库;而参照数据阶段已经把**全部分红明细**常驻在内存里了。
过滤条件与 ``Repo.dividend_events`` 逐条对齐(区间按 ex_date、
现金或送转为正、可选只取已实施)。
"""
cols = ["symbol", "end_date", "imp_ann_date", "cash_div_tax", "cash_div",
"stk_div", "stk_bo_rate", "stk_co_rate", "record_date", "ex_date",
"pay_date"]
if self._ref_end is not None and end > self._ref_end:
# 区间超出参照数据的终点:常驻帧里可能缺行(超集只覆盖到 _ref_end)。
# 这种情况宁可退回直连查询,也不能返回一个**看起来正常但少了几行**的表
# —— 少一行就是少一笔现金分红。
return Repo.dividend_events(
self, start, end, implemented_only=implemented_only
)
d = self._dividend
if d.empty:
return pd.DataFrame(columns=cols)
ex = pd.to_datetime(d["ex_date"], errors="coerce")
cash = pd.to_numeric(d["cash_div_tax"], errors="coerce")
stk = pd.to_numeric(d["stk_div"], errors="coerce")
m = (
ex.notna()
& (ex >= pd.Timestamp(start))
& (ex <= pd.Timestamp(end))
& ((cash > 0) | (stk > 0))
)
if implemented_only:
m &= d["div_proc"].astype(str) == "实施"
out = d[m].sort_values(["ex_date", "symbol"])[cols].reset_index(drop=True)
# 可空日期列统一成「object,缺值为 None」—— 与 `Repo.dividend_events`
# (直接读 SQL)的形态一致,而不是 NaT。`NaT != None` 这种差别虽然不影响
# 当前调用方(引擎只用 cash_div_tax / stk_div),但会让逐值比对失败,
# 也会给后续消费者埋一个「判空写法依赖列类型」的坑。
for c in ("end_date", "imp_ann_date", "record_date", "ex_date", "pay_date"):
if c in out.columns:
s = pd.to_datetime(out[c], errors="coerce")
out[c] = s.dt.date.where(s.notna(), None).astype(object)
return out
# ------------------------------------------------------------------
# 诊断
# ------------------------------------------------------------------
def stats(self) -> dict[str, int]:
d = self.stats_.as_dict()
d["market_rows"] = int(len(self._market))
d["daily_rows"] = int(len(self._daily))
d["fin_rows"] = int(sum(len(v) for v in self._fin.values()))
d["dividend_rows"] = int(len(self._dividend))
return d
+15 -6
View File
@@ -41,9 +41,12 @@ FILTER_ORDER: tuple[str, ...] = ("market", "risk", "dividend", "quality")
class UniverseSelector:
def __init__(self, config: UniverseConfig) -> None:
def __init__(self, config: UniverseConfig, repo: Any | None = None) -> None:
self.config = config
self.repo = Repo()
#: 取数出口。默认直连数据库;``--mode daily`` 会注入
#: :class:`~hdiv.universe.pit.PitRepo`(批量预载版,口径相同)。
#: 注入点放在这里,是为了让滤网代码与口径**完全不需要改**。
self.repo = repo if repo is not None else Repo()
self._filters: dict[str, Filter] = {}
# ------------------------------------------------------------------
@@ -51,14 +54,14 @@ class UniverseSelector:
# ------------------------------------------------------------------
@classmethod
def from_config(cls, path: str | Path) -> UniverseSelector:
def from_config(cls, path: str | Path, repo: Any | None = None) -> UniverseSelector:
"""从 YAML 加载筛选配置(支持 ``strategy`` 段落里的 override)。"""
raw = _read_yaml(path)
return cls(UniverseConfig.model_validate(raw))
return cls(UniverseConfig.model_validate(raw), repo=repo)
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> UniverseSelector:
return cls(UniverseConfig.model_validate(raw))
def from_dict(cls, raw: dict[str, Any], repo: Any | None = None) -> UniverseSelector:
return cls(UniverseConfig.model_validate(raw), repo=repo)
@classmethod
def with_override(
@@ -97,6 +100,7 @@ class UniverseSelector:
*,
persist: bool = True,
verbose: bool = True,
on_stage: Any | None = None,
) -> dict[str, Any]:
cfg = load_config("datasource")
filters = self._build_filters()
@@ -126,6 +130,11 @@ class UniverseSelector:
if live.empty:
stats[fname] = 0
continue
# 可选钩子:把「本阶段将要评估的股票」告诉调用方。
# 每日选股用它把取数范围收窄到存活者(``PitRepo.restrict_to``),
# 这是纯性能开关,不改变任何判定 —— 默认 None 时行为与改造前一致。
if on_stage is not None:
on_stage(fname, live)
outcome = flt.compute(live, self.repo, effective)
# 注意:outcome.passed 的索引是 live 的 DataFrame 索引(不是 symbol),
# 必须经 live.at[i, "symbol"] 映射,否则会把索引当代码用。
+231 -8
View File
@@ -20,7 +20,7 @@
from __future__ import annotations
import json
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any
@@ -48,6 +48,10 @@ SERIES_KEYS = ("close", "dv_yield", "pe_ttm", "roe", "pb", "drawdown")
#: 只影响画图,不影响买卖点(买卖点单独返回且不降采样)。
MAX_POINTS = 3200
#: 缺省区间往前至少回看的年数:股息率分位是买入判据,
#: 成交之前那几年的历史正是「凭什么买」的依据。
MIN_LOOKBACK_YEARS = 5
def _v(x: Any) -> Any:
if x is None:
@@ -278,6 +282,146 @@ def run_stocks(run_id: str) -> list[dict[str, Any]]:
return out
def _reason_of(raw: Any) -> dict[str, Any]:
"""reason_json → dict;坏数据不该让整个接口挂掉。"""
if not raw:
return {}
try:
out = json.loads(raw)
except (TypeError, ValueError):
return {}
return out if isinstance(out, dict) else {}
def closed_positions(run_id: str) -> dict[str, Any]:
"""已清仓(期末不再持有)的个股清单。
「已清仓」以**持仓表**为准:在该回测中持有过、但最后一个持仓日已不在其中。
只按成交净额判断会漏掉「卖了又买回、期末仍持有」的股票。
每只票带上「卖出后至今」涨跌:清仓复盘真正要回答的是
「这笔卖对了没有」,只看成交明细是答不了的。
"""
cfg = load_config("datasource")
if db.read_sql("SELECT 1 AS x FROM hd_backtest_run WHERE run_id = :r LIMIT 1",
{"r": run_id}, cfg=cfg).empty:
raise HdivError(f"回测不存在:{run_id}")
pos = db.read_sql(
"SELECT symbol, COUNT(*) AS hold_days, MIN(trade_date) AS first_hold, "
" MAX(trade_date) AS last_hold "
"FROM hd_backtest_position WHERE run_id = :r GROUP BY symbol",
{"r": run_id}, cfg=cfg,
)
last_day = _v(db.read_sql(
"SELECT MAX(trade_date) AS d FROM hd_backtest_position WHERE run_id = :r",
{"r": run_id}, cfg=cfg)["d"].iloc[0])
empty: dict[str, Any] = {
"items": [],
"summary": {"count": 0, "realized_pnl": 0.0, "since_sell_up": 0,
"since_sell_down": 0, "asof": None},
"asof": last_day,
}
if last_day is None or pos.empty:
return empty
held = set(db.read_sql(
"SELECT DISTINCT symbol FROM hd_backtest_position "
"WHERE run_id = :r AND trade_date = :d",
{"r": run_id, "d": last_day}, cfg=cfg)["symbol"])
closed = [s for s in pos["symbol"] if s not in held]
if not closed:
return empty
# 成交汇总:买入/卖出金额、已实现盈亏、最后一笔卖出的日期与理由
tr = db.read_sql(
"SELECT symbol, side, execution_date, amount, realized_pnl, reason_json "
"FROM hd_backtest_trade WHERE run_id = :r ORDER BY execution_date, trade_id",
{"r": run_id}, cfg=cfg,
)
agg: dict[str, dict[str, Any]] = {}
for _, r in tr.iterrows():
a = agg.setdefault(r["symbol"], {"buy": 0.0, "sell": 0.0, "pnl": 0.0,
"last_sell": None, "reason": None})
if r["side"] == "BUY":
a["buy"] += _fnum(r["amount"]) or 0.0
else:
a["sell"] += _fnum(r["amount"]) or 0.0
a["pnl"] += _fnum(r["realized_pnl"]) or 0.0
a["last_sell"] = _v(r["execution_date"])
a["reason"] = _reason_text(_reason_of(r["reason_json"]))
ph = ", ".join(f":c{i}" for i in range(len(closed)))
cparams = {f"c{i}": s for i, s in enumerate(closed)}
meta = {r["symbol"]: r for _, r in db.read_sql(
f"SELECT symbol, MAX(name) AS name, MAX(industry) AS industry FROM stock "
f"WHERE symbol IN ({ph}) GROUP BY symbol", cparams, cfg=cfg).iterrows()}
# 清仓当日收盘(「卖出后至今」的基准)与最新收盘
pairs = [(s, agg[s]["last_sell"]) for s in closed
if agg.get(s, {}).get("last_sell")]
at_sell: dict[str, float | None] = {}
if pairs:
cond = ", ".join(f"(:p{i}s, :p{i}d)" for i in range(len(pairs)))
pparams: dict[str, Any] = {}
for i, (s, d) in enumerate(pairs):
pparams[f"p{i}s"], pparams[f"p{i}d"] = s, d
for _, r in db.read_sql(
f"SELECT symbol, close FROM daily_basic "
f"WHERE (symbol, trade_date) IN ({cond})", pparams, cfg=cfg,
).iterrows():
at_sell[r["symbol"]] = _fnum(r["close"])
latest: dict[str, tuple[float | None, str | None]] = {}
for _, r in db.read_sql(
f"SELECT d.symbol, d.trade_date, d.close FROM daily_basic d "
f"JOIN (SELECT symbol, MAX(trade_date) AS mx FROM daily_basic "
f" WHERE symbol IN ({ph}) GROUP BY symbol) t "
f" ON t.symbol = d.symbol AND t.mx = d.trade_date",
cparams, cfg=cfg,
).iterrows():
latest[r["symbol"]] = (_fnum(r["close"]), _v(r["trade_date"]))
items = []
for s in closed:
a = agg.get(s, {})
row = pos[pos["symbol"] == s].iloc[0]
pnl = _fnum(a.get("pnl")) or 0.0
buy = _fnum(a.get("buy")) or 0.0
c0 = at_sell.get(s)
c1, c1_date = latest.get(s, (None, None))
since = (c1 / c0 - 1.0) if (c0 and c1 and c0 > 0) else None
items.append({
"symbol": s,
"name": _v(meta[s]["name"]) if s in meta else None,
"industry": _v(meta[s]["industry"]) if s in meta else None,
"first_hold": _v(row["first_hold"]), "last_hold": _v(row["last_hold"]),
"hold_days": int(row["hold_days"]),
"last_sell": a.get("last_sell"),
"buy_amount": buy or None, "sell_amount": _fnum(a.get("sell")),
"realized_pnl": pnl,
# 已清仓,所以「已实现盈亏 ÷ 买入金额」就是这笔投资的收益率
"return_pct": (pnl / buy) if buy > 0 else None,
"close_at_sell": c0, "close_latest": c1, "price_asof": c1_date,
"since_sell_pct": since,
"sell_reason": a.get("reason"),
})
items.sort(key=lambda x: (x["last_sell"] or "", x["symbol"]), reverse=True)
asof = max((x["price_asof"] for x in items if x["price_asof"]), default=None)
return {
"items": items,
"summary": {
"count": len(items),
"realized_pnl": sum(x["realized_pnl"] or 0.0 for x in items),
"since_sell_up": sum(1 for x in items if (x["since_sell_pct"] or 0) > 0),
"since_sell_down": sum(1 for x in items
if x["since_sell_pct"] is not None
and x["since_sell_pct"] <= 0),
"asof": asof,
},
"asof": asof or last_day,
}
def _price_panel(symbol: str, start: date, end: date, cfg: Any) -> pd.DataFrame:
"""不复权收盘价 + PE/PB(同一张 daily_basic,一次查询)。
@@ -346,6 +490,46 @@ def _dividend_yield_series(
return out.astype(float)
def _to_date(x: Any) -> date | None:
"""DB 取出的日期/时间戳 → ``date``;空值或 NaN 返回 None。"""
if x is None:
return None
if isinstance(x, float) and x != x:
return None
if isinstance(x, date) and not isinstance(x, datetime):
return x
try:
return pd.Timestamp(x).date()
except (TypeError, ValueError):
return None
def _parse_date(s: str | None, field: str) -> date | None:
"""用户传入的日期参数;格式不对要报可读的 400,而不是 500。"""
if not s:
return None
try:
return pd.Timestamp(str(s)).date()
except (TypeError, ValueError):
raise HdivError(f"{field} 不是合法日期:{s}(应为 YYYY-MM-DD)") from None
def _lookback_years(run_row: pd.DataFrame) -> int:
"""买入判据的回看年数 = 股息率滚动分位的窗口长度(config: percentile_reference)。
至少要 ``MIN_LOOKBACK_YEARS`` 年:判据数据本身就在成交之前,
图上不带上它就没法回答「当时凭什么买」。
"""
years = MIN_LOOKBACK_YEARS
try:
cfg_json = run_row["backtest_config_json"].iloc[0]
pcfg = (json.loads(cfg_json) if cfg_json else {}).get("percentile_reference") or {}
years = max(MIN_LOOKBACK_YEARS, int(pcfg.get("lookback_years") or 0))
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
pass
return years
def _downsample(n: int, target: int) -> np.ndarray:
"""等间隔取索引,保留首尾。仅用于画图,买卖点不降采样。"""
if n <= target:
@@ -385,8 +569,14 @@ def stock_detail(
"FROM hd_backtest_position WHERE run_id = :r AND symbol = :s",
{"r": run_id, "s": symbol}, cfg=cfg,
)
first_trade = db.read_sql(
"SELECT MIN(execution_date) AS a FROM hd_backtest_trade "
"WHERE run_id = :r AND symbol = :s",
{"r": run_id, "s": symbol}, cfg=cfg,
)
run = db.read_sql(
"SELECT start_date, end_date FROM hd_backtest_run WHERE run_id = :r",
"SELECT start_date, end_date, backtest_config_json "
"FROM hd_backtest_run WHERE run_id = :r",
{"r": run_id}, cfg=cfg,
)
if run.empty:
@@ -394,16 +584,36 @@ def stock_detail(
run_a, run_b = run["start_date"].iloc[0], run["end_date"].iloc[0]
has_hold = not hold.empty and hold["n"].iloc[0]
d_a = pd.to_datetime(start).date() if start else (
hold["a"].iloc[0] if has_hold else run_a)
d_b = pd.to_datetime(end).date() if end else (
hold["b"].iloc[0] if has_hold else run_b)
# 该股行情能覆盖到的范围(daily_basic 是本图唯一价格源)
avail = db.read_sql(
"SELECT MIN(trade_date) AS a, MAX(trade_date) AS b "
"FROM daily_basic WHERE symbol = :s",
{"s": symbol}, cfg=cfg,
)
avail_a = _to_date(avail["a"].iloc[0]) if not avail.empty else None
avail_b = _to_date(avail["b"].iloc[0]) if not avail.empty else None
# 缺省区间:起点 = **首笔成交往前留够判据回看年数**(无成交则从回测起点往前留),
# 终点 = 行情最新日期 —— 卖出当天之后曲线就断,等于把「卖飞了没有」这个问题
# 从图上抹掉;而只画持仓期又把「当时凭什么买」的判据数据裁掉了。
anchor = (_to_date(first_trade["a"].iloc[0]) if not first_trade.empty else None) \
or (_to_date(hold["a"].iloc[0]) if has_hold else None) \
or _to_date(run_a)
def_a = (pd.Timestamp(anchor) - pd.DateOffset(years=_lookback_years(run))).date()
if avail_a and def_a < avail_a: # 别超出该股行情,否则输入框会给出选不到的日期
def_a = avail_a
def_b = avail_b or _to_date(run_b)
d_a = _parse_date(start, "start") or def_a
d_b = _parse_date(end, "end") or def_b
if d_a > d_b:
raise HdivError(f"开始日期晚于结束日期:{d_a} > {d_b}")
panel = _price_panel(symbol, d_a, d_b, cfg)
if panel.empty:
span = f"{avail_a} ~ {avail_b}" if avail_a else "无"
raise HdivError(
f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据。"
f"该股行情覆盖见 stock_daily/daily_basic。"
f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据;该股行情覆盖 {span}。"
)
dates = panel["trade_date"]
@@ -463,6 +673,14 @@ def stock_detail(
})
idx = _downsample(len(dates), MAX_POINTS)
# 降采样必须保留成交日:前端是按日期把买卖点对到横轴上的,
# 漏掉那一天,这笔成交就会从图上凭空消失(还会被误报成「不在所选区间内」)。
# 实测:区间放宽到 5 年判据 + 至今之后,13 只降采样股票里有 7 只会丢成交日。
keep = {_v(dates.iloc[i]): i for i in range(len(dates))}
hits = sorted(keep[t["execution_date"]] for t in trades
if t["execution_date"] in keep)
if hits:
idx = np.unique(np.concatenate([idx, np.asarray(hits, dtype=idx.dtype)]))
dates_out = [_v(dates.iloc[i]) for i in idx]
series_out = {k: [v[i] for i in idx] for k, v in series_out.items()}
@@ -476,6 +694,11 @@ def stock_detail(
"info": {k: _v(v) for k, v in info.iloc[0].items()},
"range": {"start": _v(dates.iloc[0]), "end": _v(dates.iloc[-1]),
"requested_start": d_a.isoformat(), "requested_end": d_b.isoformat(),
# 供前端日期选择器使用:缺省区间用于「重置」,
# available_* 是该股行情边界,用作输入框的 min/max
"default_start": def_a.isoformat(), "default_end": def_b.isoformat(),
"available_start": avail_a.isoformat() if avail_a else None,
"available_end": avail_b.isoformat() if avail_b else None,
"points": len(dates), "downsampled": len(idx) < len(dates)},
"available_series": list(SERIES_KEYS),
"series": series_out,
+14
View File
@@ -246,12 +246,26 @@ def _run_stock_detail(run_id: str, symbol: str, q: dict[str, list[str]],
)
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/closed-positions")
def _closed_positions(run_id: str, **_: Any) -> dict[str, Any]:
"""已清仓(期末不再持有)的个股清单,含清仓后至今涨跌。"""
return analysis.closed_positions(run_id)
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/signals")
def _backtest_signals(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return {"items": service.list_backtest_signals(
run_id, only_skipped=_one(q, "only_skipped") != "0")}
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/daily-universe")
def _backtest_daily_universe(
run_id: str, q: dict[str, list[str]], **_: Any
) -> dict[str, Any]:
"""每日动态股票池(``--mode daily``):时间线或某日成员明细。"""
return service.get_daily_universe(run_id, trade_date=_one(q, "date") or None)
# ---------------------------------------------------------------------------
# 请求辅助
# ---------------------------------------------------------------------------
+88 -1
View File
@@ -150,10 +150,38 @@ def describe_strategy(cfg: dict[str, Any]) -> dict[str, Any]:
"status": st.get("status"),
"description": st.get("description", "").strip(),
"conditions": lines,
# 个股画像闸门:触发买入后按**当日可见数据**重算画像再筛一遍。
# 只回结构化规则,展示名/单位由前端既有的 LABEL/UNIT 表渲染
# (profile.builder.METRIC_META 少 14 个指标,前端那张表反而更全)。
"profile_gate": _profile_gate(entry.get("profile_gate")),
}
except Exception as exc: # 不因说明生成失败而让接口 500
return {"id": None, "name": None, "version": None, "status": None,
"description": f"(条件说明生成失败:{exc})", "conditions": []}
"description": f"(条件说明生成失败:{exc})", "conditions": [],
"profile_gate": None}
def _profile_gate(gate: Any) -> dict[str, Any] | None:
"""把 entry.profile_gate 规整成前端可直接渲染的结构。"""
if not isinstance(gate, dict):
return None
rules = []
for r in gate.get("rules") or []:
if not isinstance(r, dict) or not r.get("metric"):
continue
rules.append({
"metric": r.get("metric"),
"stat": r.get("stat") or "current_value",
"op": r.get("op"),
"value": _num(r.get("value")),
})
return {
"enabled": bool(gate.get("enabled")),
"window_years": _num(gate.get("window_years")),
"on_unverifiable": gate.get("on_unverifiable"),
"min_window_coverage": _num(gate.get("min_window_coverage")),
"rules": rules,
}
def _yi(v: Any) -> str:
@@ -182,6 +210,11 @@ def summary() -> dict[str, Any]:
WHERE deleted_at IS NULL AND mode = 'single') AS backtests,
(SELECT COUNT(*) FROM hd_backtest_run
WHERE deleted_at IS NULL AND mode = 'single' AND archived_at IS NULL) AS backtests_active,
(SELECT COUNT(*) FROM hd_backtest_run
WHERE deleted_at IS NULL AND mode = 'daily') AS daily_backtests,
(SELECT COUNT(*) FROM hd_backtest_run
WHERE deleted_at IS NULL AND mode = 'daily' AND archived_at IS NULL)
AS daily_backtests_active,
(SELECT COUNT(*) FROM hd_walkforward_run) AS walkforwards,
(SELECT COUNT(*) FROM hd_sensitivity_run) AS sensitivities,
(SELECT COUNT(*) FROM hd_profile_run) AS profiles,
@@ -885,6 +918,57 @@ def list_backtest_signals(run_id: str, *, only_skipped: bool = True) -> list[dic
return out
def get_daily_universe(run_id: str, *, trade_date: str | None = None,
limit: int = 5000) -> dict[str, Any]:
"""每日动态股票池(``--mode daily`` 的逐日选股留痕)。
两个形态:
- 不给 ``trade_date``:返回**时间线**(每个决策日的成员数),用于看池子如何变化;
- 给 ``trade_date``:返回该日的成员明细(含入选时的因子快照)。
"""
cfg = load_config("datasource")
if not db.table_exists("hd_daily_universe", cfg):
return {"available": False, "timeline": [], "members": [], "trade_date": None}
if trade_date:
df = db.read_sql(
"SELECT trade_date, symbol, name, industry, dividend_yield, total_mv, "
" roe_avg, listed_count, candidate_count, values_json "
"FROM hd_daily_universe "
"WHERE run_id = :r AND trade_date = :d ORDER BY dividend_yield DESC "
"LIMIT :lim",
{"r": run_id, "d": trade_date, "lim": int(limit)}, cfg=cfg,
)
members = []
for _, row in df.iterrows():
m = _rec(row)
m["values"] = _json_field(m.pop("values_json", None)) or {}
members.append(m)
return {"available": True, "trade_date": trade_date,
"members": members, "timeline": []}
df = db.read_sql(
"SELECT trade_date, COUNT(*) AS member_count, "
" MAX(candidate_count) AS candidate_count, "
" MAX(listed_count) AS listed_count "
"FROM hd_daily_universe WHERE run_id = :r "
"GROUP BY trade_date ORDER BY trade_date",
{"r": run_id}, cfg=cfg,
)
timeline = [
{
"trade_date": str(_rec(r).get("trade_date")),
"member_count": int(r["member_count"]),
"candidate_count": int(r["candidate_count"])
if pd.notna(r["candidate_count"]) else None,
"listed_count": int(r["listed_count"])
if pd.notna(r["listed_count"]) else None,
}
for _, r in df.iterrows()
]
return {"available": True, "timeline": timeline, "members": [],
"trade_date": timeline[-1]["trade_date"] if timeline else None}
_SKIP_LABELS = {
"LIMIT_UP": "开盘涨停,无法买入",
"LIMIT_DOWN": "开盘跌停,无法卖出",
@@ -898,6 +982,9 @@ _SKIP_LABELS = {
# 实时画像闸门剔除(信号类型 REJECT):不是撮合失败,而是「按当日可见
# 数据重算画像后判定不值得买」。详情在 reason_json.profile_gate.checks。
"PROFILE_GATE": "实时画像未通过,主动放弃买入",
# 每日动态股票池(--mode daily):持仓已掉出当日股票池,
# 按 pool_exit_action=hold 只停止加仓,不清仓。
"OUT_OF_UNIVERSE": "已掉出当日动态股票池,停止加仓(不清仓)",
}
+38 -1
View File
@@ -168,6 +168,40 @@ def move_reports_to_subdir(*, verbose: bool = True) -> int:
return n
# ---------------------------------------------------------------------------
# 权限:nginx worker 不是文件属主
# ---------------------------------------------------------------------------
#: 站点文件 / 目录的发布权限。
#:
#: nginx 的 master 以 root 运行、worker 以 nobody 运行(Homebrew 默认),所以
#: **worker 不是站点文件的属主**:文件只要不是「所有人可读」就 403。
#: 而 ``shutil.copy2`` 会连权限一起复制,用 umask 077 的编辑器/工具存下来的
#: 600 文件会一路带进 output/ —— 症状是「HTML 打得开、CSS/JS 403、页面裸奔」,
#: 而 nginx 错误日志里只写 ``failed (13: Permission denied)``。
#: 这里在发布时统一收敛权限,别让单个文件的 umask 决定线上是否可用。
SITE_FILE_MODE = 0o644
SITE_DIR_MODE = 0o755
def ensure_readable(root: Path, *, verbose: bool = False) -> int:
"""把站点目录树收敛为「目录 755 / 文件 644」,返回被修正的条目数。"""
if not root.is_dir():
return 0
fixed = 0
for p in [root, *sorted(root.rglob("*"))]:
want = SITE_DIR_MODE if p.is_dir() else SITE_FILE_MODE
try:
if (p.stat().st_mode & 0o777) != want:
p.chmod(want)
fixed += 1
except OSError: # pragma: no cover - 权限不足/平台不支持
pass
if fixed and verbose:
print(f" 已修正 {fixed} 个发布产物的权限(nginx worker 为 nobody,需要 world-readable)")
return fixed
# ---------------------------------------------------------------------------
# 前端同步
# ---------------------------------------------------------------------------
@@ -206,9 +240,12 @@ def sync_frontend(*, verbose: bool = True) -> dict[str, Any]:
shutil.copy2(ec_src, assets_dst / "echarts.min.js")
copied.append(str((assets_dst / "echarts.min.js").relative_to(project_root())))
# 整棵站点树(含 reports/ 与 archive/)统一权限,避免 copy2 把 600 带进来
fixed = ensure_readable(out, verbose=verbose)
if verbose:
print(f" 已同步前端 {len(copied)} 个文件到 output/")
return {"copied": copied}
return {"copied": copied, "perm_fixed": fixed}
# ---------------------------------------------------------------------------