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:
@@ -241,6 +241,31 @@ class DailySymbolResult:
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyBasicDayResult:
|
||||
"""单个交易日的每日指标同步结果(status: skip|ok|failed)。"""
|
||||
|
||||
trade_date: date
|
||||
status: str
|
||||
source: str | None = None # tushare(新浪不支持本接口)
|
||||
rows_fetched: int = 0
|
||||
rows_written: int = 0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NameHistoryChunkResult:
|
||||
"""单个时间分片的名称变更同步结果(status: ok|failed)。"""
|
||||
|
||||
start: date
|
||||
end: date
|
||||
status: str
|
||||
source: str | None = None
|
||||
rows_fetched: int = 0
|
||||
rows_written: int = 0
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 财务同步服务
|
||||
|
||||
class VerifiedFinancialSyncer:
|
||||
@@ -577,6 +602,181 @@ class VerifiedDailySyncer:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 每日指标同步服务
|
||||
|
||||
class DailyBasicSyncer:
|
||||
"""每日指标(估值 / 股息率 / 市值)同步:按交易日整表拉取。
|
||||
|
||||
设计(与财务/日线的「逐股校验兜底」不同):
|
||||
- daily_basic 是**横截面整表**接口,Tushare 一次返回当日全市场,无法逐股兜底;
|
||||
- 新浪不提供本接口 → 主源失败时**如实失败并写 sync_log**,禁止静默留缺口;
|
||||
- 幂等键 (symbol, trade_date):重跑同日无副作用(upsert)。
|
||||
- 交易日集合由日历仓储给出:只同步 is_open 且本地缺失的日期
|
||||
(`missing_dates`),因此增量与断点续跑天然安全。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: MarketDataProvider,
|
||||
repo,
|
||||
audit: Callable[[SyncLog], None],
|
||||
batch_flush: int = 5,
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.repo = repo
|
||||
self.audit = audit
|
||||
self.batch_flush = max(batch_flush, 1)
|
||||
|
||||
def sync_day(self, trade_date: date) -> DailyBasicDayResult:
|
||||
try:
|
||||
rows = self.primary.get_daily_basic(trade_date)
|
||||
except DataSourceAuthenticationError:
|
||||
raise # 凭证/权限故障 → 快速失败(不要逐日重试把配额烧光)
|
||||
except Exception as exc: # noqa: BLE001 —— 逐日失败不中断整段
|
||||
_audit_sync(
|
||||
self.audit,
|
||||
source=self.primary.name,
|
||||
api="get_daily_basic",
|
||||
success=False,
|
||||
reason=str(exc),
|
||||
start=trade_date,
|
||||
end=trade_date,
|
||||
)
|
||||
return DailyBasicDayResult(
|
||||
trade_date=trade_date,
|
||||
status="failed",
|
||||
source=self.primary.name,
|
||||
notes=[f"拉取失败: {exc}"],
|
||||
)
|
||||
# 过滤掉非法行(ts_code 缺失)—— 避免脏键污染幂等
|
||||
rows = [r for r in rows if r.symbol and r.symbol != "None"]
|
||||
written = self.repo.upsert_many(rows)
|
||||
_audit_sync(
|
||||
self.audit,
|
||||
source=self.primary.name,
|
||||
api="get_daily_basic",
|
||||
success=True,
|
||||
row_count=len(rows),
|
||||
start=trade_date,
|
||||
end=trade_date,
|
||||
)
|
||||
return DailyBasicDayResult(
|
||||
trade_date=trade_date,
|
||||
status="ok",
|
||||
source=self.primary.name,
|
||||
rows_fetched=len(rows),
|
||||
rows_written=written,
|
||||
)
|
||||
|
||||
def sync_range(
|
||||
self,
|
||||
start: date,
|
||||
end: date,
|
||||
*,
|
||||
on_progress: Callable[[int, int, date], None] | None = None,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> list[DailyBasicDayResult]:
|
||||
"""补齐 [start, end] 内开市但本地缺失的交易日。
|
||||
|
||||
on_progress(done, total, trade_date):进度回调(CLI 打印 / 日志)。
|
||||
should_stop():返回 True 时提前收尾(优雅中断,已落库的行保持有效)。
|
||||
"""
|
||||
days = self.repo.missing_dates(start, end)
|
||||
results: list[DailyBasicDayResult] = []
|
||||
total = len(days)
|
||||
for idx, day in enumerate(days, start=1):
|
||||
if should_stop is not None and should_stop():
|
||||
break
|
||||
results.append(self.sync_day(day))
|
||||
if on_progress is not None:
|
||||
on_progress(idx, total, day)
|
||||
return results
|
||||
|
||||
|
||||
class NameHistorySyncer:
|
||||
"""股票名称变更历史同步(Tushare namechange)—— 时点 ST 判定的数据基础。
|
||||
|
||||
设计要点:
|
||||
- **按年分片**:namechange 支持区间批量查询(2020+ 仅 4031 行),但全历史
|
||||
(1990 起)会触及单次 6000 行上限被**静默截断**,因此按自然年分片调用,
|
||||
每片独立审计与计数,超限时 Provider 会告警。
|
||||
- **幂等键 (symbol, start_date)**:重跑无副作用(upsert),可安全续跑。
|
||||
- 新浪不提供本接口 → 主源失败如实写 sync_log(禁止静默留缺口,AGENT.md §24)。
|
||||
- 为什么值得同步:`stock.name` 只是最新名称快照,用最新名称做 `exclude_st`
|
||||
会把「曾为高股息、后变 ST/退市」的股息陷阱样本整段排除 ——
|
||||
实测影响约 3.70pp 收益(见 docs/DEV_PLAN_DIVIDEND_BACKTEST.md §10.5)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: MarketDataProvider,
|
||||
repo,
|
||||
audit: Callable[[SyncLog], None],
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.repo = repo
|
||||
self.audit = audit
|
||||
|
||||
def sync_chunk(self, start: date, end: date) -> NameHistoryChunkResult:
|
||||
try:
|
||||
rows = self.primary.get_name_changes(start, end)
|
||||
except DataSourceAuthenticationError:
|
||||
raise # 凭证/权限故障 → 快速失败,不要逐片重试烧配额
|
||||
except Exception as exc: # noqa: BLE001 —— 单片失败不中断整段
|
||||
_audit_sync(
|
||||
self.audit,
|
||||
source=self.primary.name,
|
||||
api="get_namechange",
|
||||
success=False,
|
||||
reason=str(exc),
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
return NameHistoryChunkResult(
|
||||
start=start, end=end, status="failed",
|
||||
source=self.primary.name, notes=[f"拉取失败: {exc}"],
|
||||
)
|
||||
rows = [r for r in rows if r.symbol and r.symbol != "None"]
|
||||
written = self.repo.upsert_many(rows)
|
||||
_audit_sync(
|
||||
self.audit,
|
||||
source=self.primary.name,
|
||||
api="get_namechange",
|
||||
success=True,
|
||||
row_count=len(rows),
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
return NameHistoryChunkResult(
|
||||
start=start, end=end, status="ok", source=self.primary.name,
|
||||
rows_fetched=len(rows), rows_written=written,
|
||||
)
|
||||
|
||||
def sync_range(
|
||||
self,
|
||||
start: date,
|
||||
end: date,
|
||||
*,
|
||||
on_progress: Callable[[int, int, date], None] | None = None,
|
||||
) -> list[NameHistoryChunkResult]:
|
||||
"""按自然年分片同步 [start, end](每片 1 次 API 调用)。"""
|
||||
chunks: list[tuple[date, date]] = []
|
||||
cursor = date(start.year, 1, 1)
|
||||
while cursor <= end:
|
||||
chunk_end = min(date(cursor.year, 12, 31), end)
|
||||
chunks.append((max(cursor, start), chunk_end))
|
||||
cursor = date(cursor.year + 1, 1, 1)
|
||||
results: list[NameHistoryChunkResult] = []
|
||||
total = len(chunks)
|
||||
for idx, (cs, ce) in enumerate(chunks, start=1):
|
||||
results.append(self.sync_chunk(cs, ce))
|
||||
if on_progress is not None:
|
||||
on_progress(idx, total, cs)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 小工具
|
||||
|
||||
def _fin_key(row: FinancialIndicator) -> tuple:
|
||||
|
||||
Reference in New Issue
Block a user