feat(minute-k): 分钟K同步流式落盘 + 段大小可配 + 回测成交修复 + 卡死超时调整

1. 分钟K流式落盘 (修复1年全市场卡死)
   - sync_minute_batch 加 on_segment 回调,每段拉完立即写盘
   - 抽出 _write_minute_partition 公共函数,消除重复
   - 内存峰值从全量(~25GB)降到单段,避免 OOM

2. 段大小可配 (默认20交易日,范围[5,30])
   - 新增 minute_sync_segment_days 偏好项
   - 「单次获取」与「获取1年」共用此分段设置
   - 前端设置弹窗新增分段大小步进器

3. 回测分钟K精确成交修复 (从未成功跑通过)
   - _resolve_minute_fill 改接受 DataFrame,避免 to_numpy() 退化为
     object 数组后字段名索引抛 IndexError
   - _load_minute_for_fills 用 partition_by 向量化分组替换逐行循环
   - 新增 test_minute_fill.py 回归测试 (6 用例)

4. 卡死 watchdog 超时阈值按任务类型区分
   - 普通任务 600s → 1200s;分钟K长任务 → 1800s
   - create(timeout_s=) 支持按 job 存阈值,reap_stale 读 job 自身值
   - 修复分钟K正常拉取被误杀导致僵尸线程继续写盘的问题

5. UI 优化
   - 设置弹窗按 自动同步/手动获取/清空 三区块分组
   - 「单次获取」改为按分段大小拉一段 (天数=分段设置)
   - 状态文案: 盘后自动同步 → 自动同步已开启/已关闭
This commit is contained in:
shy3130
2026-07-12 14:23:22 +08:00
parent a6e9987be6
commit 365f1ccbfb
9 changed files with 328 additions and 90 deletions
@@ -11,17 +11,20 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
queryFn: api.preferences,
})
const update = useMutation({
mutationFn: ({ enabled, days }: { enabled: boolean; days: number }) =>
api.updateMinuteSync(enabled, days),
mutationFn: ({ enabled, days, segmentDays }: { enabled: boolean; days: number; segmentDays?: number }) =>
api.updateMinuteSync(enabled, days, segmentDays),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch']
const enabled = prefs.data?.minute_sync_enabled ?? false
const days = prefs.data?.minute_sync_days ?? 5
const segmentDays = prefs.data?.minute_sync_segment_days ?? 20
const [localDays, setLocalDays] = useState(days)
const [localSegment, setLocalSegment] = useState(segmentDays)
useEffect(() => { setLocalDays(days) }, [days])
useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays])
const handleToggle = () => {
if (!hasMinuteCap) return
@@ -34,6 +37,13 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
update.mutate({ enabled, days: clamped })
}
// 段大小: 交易日/段, 步进 ±5, 范围 [5, 30]。越小越省内存但越慢。
const setSegment = (v: number) => {
const clamped = Math.max(5, Math.min(30, v))
setLocalSegment(clamped)
update.mutate({ enabled, days: localDays, segmentDays: clamped })
}
// 清空分钟K数据 (二次确认)
const [confirmClear, setConfirmClear] = useState(false)
const clearMutation = useMutation({
@@ -48,8 +58,8 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
const [fetchingMode, setFetchingMode] = useState<'' | '40d' | '1y'>('')
const handleFetch = (mode: '40d' | '1y') => {
if (!hasMinuteCap) return
// 两个按钮都用向前扩展模式: 从本地最早数据往前补, 叠加避免缺口
const fetchDays = mode === '40d' ? 40 : 365
// 单次获取 = 按「分段大小」拉一段 (向前扩展); 1年 = 拉365天按分段切多段
const fetchDays = mode === '40d' ? localSegment : 365
setFetchingMode(mode)
api.syncMinute(fetchDays, true).then((res) => {
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
@@ -61,7 +71,8 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
return (
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
{/* 第 1 行: 自动同步开关 + 天数 */}
{/* 区块 A: 自动同步 (盘后定时拉取的偏好设置) */}
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<button
@@ -78,7 +89,7 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
/>
</button>
<span className="text-xs text-foreground font-medium">
{enabled ? '盘后自动同步' : '已关闭'}
{enabled ? '已开启' : '已关闭'}
</span>
</div>
<div className="flex items-center gap-2">
@@ -104,8 +115,45 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
</div>
</div>
{/* 第 2 行: 两个手动获取按钮 (40天快速 / 1年分段) */}
<div className="pt-2 border-t border-border grid grid-cols-2 gap-2">
{/* 分段大小: 控制单次 SDK 请求覆盖的交易日数。每段拉完即落盘,避免全量攒内存 OOM。
「往前获取」与「获取 1 年」共用此设置 (两者都经过 sync_and_persist_minute)。 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-foreground font-medium"></span>
<span className="text-[10px] text-muted px-1 py-px rounded bg-warning/8 text-warning/80"></span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center">
<button
onClick={() => setSegment(localSegment - 5)}
disabled={!hasMinuteCap || localSegment <= 5}
className="h-6 w-6 flex items-center justify-center rounded-l-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
></button>
<div className="h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums bg-base text-foreground">
{localSegment}
</div>
<button
onClick={() => setSegment(localSegment + 5)}
disabled={!hasMinuteCap || localSegment >= 30}
className="h-6 w-6 flex items-center justify-center rounded-r-btn bg-elevated border border-border text-secondary hover:bg-border/50 disabled:opacity-30 transition-colors text-xs"
>+</button>
</div>
<span className="text-[10px] text-muted">/</span>
</div>
</div>
<div className="text-[10px] text-muted leading-relaxed -mt-1">
,, 20
</div>
</div>
{/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
<div className="pt-3 border-t border-border space-y-2">
<div className="flex items-center gap-1.5">
<Download className="h-3 w-3 text-secondary" />
<span className="text-[11px] text-secondary font-medium"></span>
<span className="text-[10px] text-muted"></span>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleFetch('40d')}
disabled={!hasMinuteCap || fetchingMode !== ''}
@@ -114,7 +162,7 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
{fetchingMode === '40d' ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span></span></>
) : (
<><Download className="h-3.5 w-3.5" /><span> ()</span></>
<><Download className="h-3.5 w-3.5" /><span> {localSegment} </span></>
)}
</button>
<button
@@ -128,9 +176,14 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
<><Calendar className="h-3.5 w-3.5" /><span> 1 </span><span className="text-[9px] opacity-70"></span></>
)}
</button>
</div>
<div className="text-[10px] text-muted leading-relaxed">
A股标的 · · ·{' '}
</div>
</div>
{/* 第 3 行: 清空 */}
{/* 区块 C: 清空 (危险操作, 独立分隔) */}
<button
onClick={() => setConfirmClear(true)}
disabled={clearMutation.isPending}
@@ -141,13 +194,6 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
K数据
</button>
{/* 说明 */}
<div className="text-[10px] text-muted leading-relaxed">
A股标的 · · ·{' '}
<span className="text-accent"></span> 40 ,{' '}
<span className="text-amber-400">1 </span> ()
</div>
{/* 清空确认弹窗 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">