feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化

汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+192 -4
View File
@@ -8,6 +8,7 @@
uv run python -m app.cli.sync financial --all # 财务指标(增量)
uv run python -m app.cli.sync financial --all --full # 财务指标(强制全量重拉)
uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证
uv run python -m app.cli.sync daily_basic --start 20200101 # 每日指标(股息率等)
增量与兜底:
- daily --resume:从本地最新交易日续传(已有);Tushare 失败时走新浪校验兜底,
@@ -31,8 +32,10 @@ from datetime import date, datetime, timedelta
from sqlalchemy import select
from app.application.services.data_sync import (
DailyBasicSyncer,
DailySymbolResult,
FinancialSymbolResult,
NameHistorySyncer,
VerifiedDailySyncer,
VerifiedFinancialSyncer,
)
@@ -47,7 +50,9 @@ from app.infrastructure.persistence.sqlalchemy.repositories.index_impl import (
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyAdjustFactorRepository,
SqlAlchemyDailyBarRepository,
SqlAlchemyDailyBasicRepository,
SqlAlchemyFinancialRepository,
SqlAlchemyStockNameHistoryRepository,
SqlAlchemyStockRepository,
SqlAlchemySyncLogRepository,
SqlAlchemyTradingCalendarRepository,
@@ -80,13 +85,44 @@ def _session_ctx():
def cmd_basic(args) -> int:
"""股票基础信息。--include-delisted 同时拉取已退市/暂停上市(幸存者偏差修正)。"""
statuses = ["L", "P", "D"] if getattr(args, "include_delisted", False) else ["L"]
with _session_ctx() as session:
provider = _failover_provider(session)
stocks = provider.get_stock_basic()
repo = SqlAlchemyStockRepository(session)
touched = repo.upsert_many(stocks)
session.commit()
print(f"[basic] 拉取 {len(stocks)} 只,落库 {touched} 条")
total = 0
failed: list[str] = []
for st in statuses:
try:
stocks = provider.get_stock_basic(st)
except DataSourceError as exc:
# failover 会把备用源(新浪)的 NotSupported 包装成 DataSourceError,
# 因此这里必须捕获 DataSourceError 而不是 DataSourceNotSupported,
# 否则 --include-delisted 在主源抖动时会整体抛栈退出(L 已写、P/D 静默缺失)。
print(f"[error] list_status={st} 拉取失败:{exc}", file=sys.stderr)
failed.append(st)
continue
touched = repo.upsert_many(stocks)
session.commit()
total += touched
n_delisted = sum(1 for s in stocks if s.delist_date is not None)
print(
f"[basic] list_status={st} 拉取 {len(stocks)} 只(含 delist_date {n_delisted} 只),"
f"落库 {touched} 条"
)
if len(statuses) > 1:
print(
"[basic] 已退市股票已入库;其历史行情需另行同步(否则回测仍无法使用):\n"
" 请执行 sync daily --symbols <退市代码逗号分隔> --start 20200101"
)
print(f"[basic] 合计落库 {total} 条")
if failed:
print(
f"[error] 以下 list_status 拉取失败:{','.join(failed)};"
"退市股缺失会让回测重新出现幸存者偏差,请重跑",
file=sys.stderr,
)
return 1
return 0
@@ -104,6 +140,48 @@ def cmd_calendar(args) -> int:
return 0
def cmd_namechange(args) -> int:
"""股票名称变更历史同步(时点 ST 判定依据;按自然年分片)。"""
start = _parse_day(args.start) if args.start else date(1990, 1, 1)
end = _parse_day(args.end) if args.end else date.today()
if start > end:
print("[error] --start 不能晚于 --end", file=sys.stderr)
return 2
with _session_ctx() as session:
audit_repo = SqlAlchemySyncLogRepository(session)
primary = TushareProvider(token=get_settings().tushare_token)
repo = SqlAlchemyStockNameHistoryRepository(session)
syncer = NameHistorySyncer(primary=primary, repo=repo, audit=audit_repo.add)
total_span = (end.year - start.year) + 1
print(f"[namechange] {start}~{end} 共 {total_span} 个年度分片")
ok = failed = written = fetched = 0
t0 = time.time()
def _on(idx: int, total: int, chunk_start: date) -> None:
_progress(idx, total, t0, f"{chunk_start.year} 年")
results = syncer.sync_range(start, end, on_progress=_on)
for res in results:
if res.status == "ok":
ok += 1
written += res.rows_written
fetched += res.rows_fetched
else:
failed += 1
for note in res.notes:
print(f"\n[warn] {res.start}~{res.end}: {note}", file=sys.stderr)
session.commit()
lo, hi = repo.namechange_dates()
print(
f"\n[namechange] 分片成功 {ok} / 失败 {failed};"
f"拉取 {fetched} 行、写入 {written} 行;"
f"本地生效起点 {lo} ~ {hi}"
)
return 1 if failed else 0
def _symbols_of(args) -> list[str]:
if getattr(args, "all", False):
with _session_ctx() as session:
@@ -382,11 +460,102 @@ def cmd_export(args) -> int:
return 0
_DAILY_BASIC_DEFAULT_START = date(2020, 1, 1)
def cmd_daily_basic(args) -> int:
"""每日指标同步(按交易日整表;新浪不支持 → 失败如实记录,不留静默缺口)。"""
start = _parse_day(args.start) if args.start else _DAILY_BASIC_DEFAULT_START
end = _parse_day(args.end) if args.end else date.today()
if start > end:
print("[error] --start 不能晚于 --end", file=sys.stderr)
return 2
with _session_ctx() as session:
audit_repo = SqlAlchemySyncLogRepository(session)
primary = TushareProvider(token=get_settings().tushare_token)
repo = SqlAlchemyDailyBasicRepository(session)
syncer = DailyBasicSyncer(primary=primary, repo=repo, audit=audit_repo.add)
if args.full:
cal = SqlAlchemyTradingCalendarRepository(session)
days = [d.calendar_date for d in cal.list_range(start, end) if d.is_open]
else:
days = repo.missing_dates(start, end)
total = len(days)
if total == 0:
print(f"[daily_basic] {start}~{end} 无待补交易日(本地已完整)")
return 0
print(f"[daily_basic] {start}~{end} 待同步 {total} 个交易日")
ok = failed = written = 0
failures: list[date] = []
t0 = time.time()
for idx, day in enumerate(days, start=1):
try:
res = syncer.sync_day(day)
except DataSourceError as exc:
print(f"\n[error] 第 {idx}/{total} 日 {day} 权限/凭证故障,中止:{exc}", file=sys.stderr)
session.commit()
return 1
if res.status == "ok":
ok += 1
written += res.rows_written
else:
failed += 1
failures.append(day)
_progress(idx, total, t0, f"{day} 行数={res.rows_fetched}")
if idx % 20 == 0 or idx == total:
session.commit() # 分批提交:中断时已完成的部分保持有效
if args.sleep:
time.sleep(args.sleep)
session.commit()
span = time.time() - t0
print(
f"\n[daily_basic] 完成:成功 {ok} 日 / 失败 {failed} 日,累计写入 {written} 行,"
f"耗时 {span:.1f}s"
)
if failures:
print(
"[daily_basic] 失败交易日(可重跑本命令补齐):"
+ ", ".join(d.isoformat() for d in failures[:20])
+ (" …" if len(failures) > 20 else ""),
file=sys.stderr,
)
return 1
return 0
def _progress(idx: int, total: int, t0: float, extra: str = "") -> None:
"""单行进度条(同步长任务可读性)。"""
elapsed = time.time() - t0
rate = idx / elapsed if elapsed > 0 else 0.0
eta = (total - idx) / rate if rate > 0 else 0.0
pct = idx / total * 100 if total else 100.0
end = "\n" if idx >= total else "\r"
print(
f" 进度 {idx}/{total} ({pct:5.1f}%) 已用 {elapsed:6.1f}s 预计剩余 {eta:6.1f}s {extra}",
end=end,
flush=True,
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="app.cli.sync", description="Tushare 数据同步 CLI")
sub = parser.add_subparsers(dest="command", required=True)
p_nc = sub.add_parser("namechange", help="同步股票名称变更历史(时点 ST 判定)")
p_nc.add_argument("--start", help="起始日 YYYYMMDD(默认 19900101,覆盖全历史)")
p_nc.add_argument("--end", help="结束日 YYYYMMDD(默认今天)")
p_nc.set_defaults(func=cmd_namechange)
p_basic = sub.add_parser("basic", help="同步股票基础信息")
p_basic.add_argument(
"--include-delisted",
action="store_true",
help="同时拉取已退市(D)与暂停上市(P),填充 delist_date(幸存者偏差修正)",
)
p_basic.set_defaults(func=cmd_basic)
p_cal = sub.add_parser("calendar", help="同步交易日历")
@@ -394,6 +563,25 @@ def build_parser() -> argparse.ArgumentParser:
p_cal.add_argument("--end", required=True, help="YYYYMMDD")
p_cal.set_defaults(func=cmd_calendar)
p_db = sub.add_parser(
"daily_basic",
help="同步每日指标(估值/股息率/市值,按交易日整表;新浪不支持本接口)",
)
p_db.add_argument("--start", default="20200101", help="YYYYMMDD(默认 20200101)")
p_db.add_argument("--end", default="", help="YYYYMMDD(默认今天)")
p_db.add_argument(
"--full",
action="store_true",
help="忽略本地已有日期,重拉区间内全部开市日(默认只补缺失日)",
)
p_db.add_argument(
"--sleep",
type=float,
default=0,
help="每个交易日请求间隔秒数(限速时加大,如 0.2 或 1)",
)
p_db.set_defaults(func=cmd_daily_basic)
p_daily = sub.add_parser("daily", help="同步日线与复权因子(Tushare 失败 → 新浪校验兜底补缺)")
p_daily.add_argument("--symbols", default="", help="600519.SH,000001.SZ")
p_daily.add_argument("--all", action="store_true", help="遍历 stock 表全部股票")