feat(minute): 分钟策略执行后端 + 分钟红7策略 + 盘中增量落盘 (Expert)

一期 · 分钟策略执行链路:
- 引擎新增 minute_filter 执行后端: 策略声明 filter_minute_history(df, params),
  timeframes 必须且仅为 ["1m"]; 输入为当日分钟K窗口, 命中行事后联表 enriched
  快照补基础过滤列 (name/total_shares/change_pct), close 用最新分钟价
- 内置策略「分钟红7」(minute_red_streak): 最近 N 根(默认7)分钟K至少 5 根
  close>open, 且按最高价排序的最高的 2 根全红; 全向量化, 671k 行约 230ms;
  参数 bars/min_red/top_red/rank_by_close, 不足 N 根不触发, 同值取更晚K线
- ScreenerService 1m context: 优先读 as_of 当日 kline_minute 分区, 缺失回退
  全市场最近分区; 单分区直读与全量 glob 解耦
- run_preset/run_all 分钟周期结果不写日线盘后缓存 (语义隔离)

二期 · 盘中分钟增量落盘 (Expert 专有):
- kline_sync.fetch_intraday_full_market_burst: intraday.batch 独立限流池,
  全市场 5546/200=28 块线程池一次打出, 轮内不重试
- MinuteRefreshService: 后台线程, 门控链 = 开关→自定义分钟源让位→INTRADAY_BATCH
  能力→连续竞价时段(9:30-11:30/13:00-15:00); 固定节奏 next=max(起点+间隔,完成),
  不补跑; 每轮单次合并落盘 (_write_minute_partition unique 幂等)
- 偏好 minute_refresh_enabled(默认关)/minute_refresh_interval([60,300]s 默认60);
  GET /api/settings/minute-refresh/status 状态端点

前端:
- 策略页日线/分钟周期切换 (1m 下 ETF 置灰、不触发盘后 runAll、prune 仅日线),
  策略卡片「分钟」徽章, 策略池对话框按周期拉取
- 数据页分钟K设置弹窗新增盘中增量区块: 开关/间隔(60-300s)/能力缺失置灰/
  服务状态行(运行中·时段暂停·最近一轮)

测试: 26 项新增 (形态7/引擎4/context4/服务11), 更新 3 个 matrix 不变量测试;
全量 1112 passed; 前端 build 通过; 浏览器端到端实测通过
This commit is contained in:
shy3130
2026-08-30 19:05:05 +08:00
parent 196af2fa06
commit 5e2358ce48
18 changed files with 1289 additions and 44 deletions
+21
View File
@@ -391,6 +391,10 @@ class MinuteSyncPrefs(BaseModel):
minute_sync_days: int = 5
# 单段大小(交易日),None 表示不修改现有值。范围 [5, 30],默认 20。
minute_sync_segment_days: int | None = None
# 盘中分钟增量刷新 (Expert 专有)。None 表示不修改现有值。
minute_refresh_enabled: bool | None = None
# 刷新间隔(秒),范围 [60, 300]。None 表示不修改现有值。
minute_refresh_interval: int | None = None
class DataProvidersIn(BaseModel):
@@ -478,6 +482,8 @@ def get_preferences() -> dict:
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
"minute_sync_segment_days": preferences.get_minute_sync_segment_days(),
"minute_refresh_enabled": preferences.get_minute_refresh_enabled(),
"minute_refresh_interval": preferences.get_minute_refresh_interval(),
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
@@ -833,14 +839,29 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict:
}
if req.minute_sync_segment_days is not None:
updates["minute_sync_segment_days"] = max(5, min(30, req.minute_sync_segment_days))
if req.minute_refresh_enabled is not None:
updates["minute_refresh_enabled"] = req.minute_refresh_enabled
if req.minute_refresh_interval is not None:
updates["minute_refresh_interval"] = max(60, min(300, req.minute_refresh_interval))
preferences.save(updates)
return {
"minute_sync_enabled": req.minute_sync_enabled,
"minute_sync_days": days,
"minute_sync_segment_days": preferences.get_minute_sync_segment_days(),
"minute_refresh_enabled": preferences.get_minute_refresh_enabled(),
"minute_refresh_interval": preferences.get_minute_refresh_interval(),
}
@router.get("/minute-refresh/status")
def minute_refresh_status(request: Request) -> dict:
"""盘中分钟增量刷新服务状态 (开关/能力门控/最近一轮/下一轮)。"""
svc = getattr(request.app.state, "minute_refresh", None)
if svc is None:
return {"available": False}
return {"available": True, **svc.status()}
class RealtimeQuotesPrefs(BaseModel):
realtime_quotes_enabled: bool