mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat(minute-k): 分钟K重做 — 前复权 + 向前扩展 + 清空 + 单独拉取 + 进度
- kline_sync: adjust="forward" 前复权; sync_minute_batch 按月分段(57天/段) 突破 count=10000 上限; on_chunk_done 回调带日期段标签 - kline.py: 删除 extend_minute_history; 新增 sync_minute_single(单股拉取落库) + clear_minute(仅清分钟K二次确认) + sync_minute 可选 days/extend 参数 - preferences/settings: 默认 5 天上限 30 (恢复原始); 超时强制释放 _heavy_run_lock - MinuteSyncConfig: 自动同步(5天/关闭) + 往前获取(单次拉满) + 获取1年(分段) + 清空按钮 + 进度回调 onJobStart - StockIntradayChart: 单股获取改用 syncMinuteSingle (落库) - daily_pipeline: _minute_chunk_progress 兼容 seg_label 参数
This commit is contained in:
@@ -32,11 +32,10 @@ export function StockIntradayChart({
|
||||
})
|
||||
|
||||
const fetchMinute = useMutation({
|
||||
mutationFn: () => api.extendMinuteHistory(5, 'day'),
|
||||
mutationFn: () => api.syncMinuteSingle(symbol),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['kline-minute', symbol] })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
|
||||
qc.invalidateQueries({ queryKey: QK.klineMinute(symbol, date ?? '') })
|
||||
setMinuteDismissed(false)
|
||||
},
|
||||
})
|
||||
@@ -61,7 +60,7 @@ export function StockIntradayChart({
|
||||
{fetchMinute.isPending ? (
|
||||
<div className="flex items-center justify-center h-full gap-2 text-xs text-accent">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span>正在获取最近5日分钟K…</span>
|
||||
<span>正在获取分钟K数据…</span>
|
||||
</div>
|
||||
) : sourceIsNone ? (
|
||||
// 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { isExpertOrAbove } from '@/lib/capability-labels'
|
||||
|
||||
export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; isRunning: boolean; onStart: () => void }) {
|
||||
export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; capabilities: Record<string, { rpm: number | null; batch: number | null; subscribe: number | null }> } | undefined; onJobStart?: (jobId: string) => void }) {
|
||||
const qc = useQueryClient()
|
||||
const prefs = useQuery({
|
||||
queryKey: QK.preferences,
|
||||
@@ -29,8 +28,40 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
|
||||
update.mutate({ enabled: !enabled, days: localDays })
|
||||
}
|
||||
|
||||
const setDays = (v: number) => {
|
||||
const clamped = Math.max(1, Math.min(30, v))
|
||||
setLocalDays(clamped)
|
||||
update.mutate({ enabled, days: clamped })
|
||||
}
|
||||
|
||||
// 清空分钟K数据 (二次确认)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => api.clearMinute(),
|
||||
onSuccess: () => {
|
||||
setConfirmClear(false)
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
},
|
||||
})
|
||||
|
||||
// 手动获取 (两个独立按钮, 各自指定天数, 不影响自动同步偏好)
|
||||
const [fetchingMode, setFetchingMode] = useState<'' | '40d' | '1y'>('')
|
||||
const handleFetch = (mode: '40d' | '1y') => {
|
||||
if (!hasMinuteCap) return
|
||||
// 两个按钮都用向前扩展模式: 从本地最早数据往前补, 叠加避免缺口
|
||||
const fetchDays = mode === '40d' ? 40 : 365
|
||||
setFetchingMode(mode)
|
||||
api.syncMinute(fetchDays, true).then((res) => {
|
||||
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
// 通知主页面跟踪 job 进度 (ActiveJobCard 会显示实时进度+日志)
|
||||
if (res.job_id && onJobStart) onJobStart(res.job_id)
|
||||
}).finally(() => setFetchingMode(''))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-3 border-t border-accent/20 space-y-3">
|
||||
{/* 第 1 行: 自动同步开关 + 天数 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
@@ -47,173 +78,96 @@ export function MinuteSyncConfig({ caps, isRunning, onStart }: { caps: { label:
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
{enabled ? '自动同步' : '已关闭'}
|
||||
{enabled ? '盘后自动同步' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
{!hasMinuteCap && (
|
||||
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">
|
||||
需 Pro+
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-secondary">同步天数</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => { const v = Math.max(1, localDays - 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
|
||||
onClick={() => setDays(localDays - 1)}
|
||||
disabled={!hasMinuteCap || !enabled || localDays <= 1}
|
||||
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 ${
|
||||
enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'
|
||||
}`}
|
||||
>
|
||||
>−</button>
|
||||
<div className={`h-6 w-8 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums ${enabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'}`}>
|
||||
{localDays}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { const v = Math.min(15, localDays + 1); setLocalDays(v); update.mutate({ enabled, days: v }) }}
|
||||
disabled={!hasMinuteCap || !enabled || localDays >= 15}
|
||||
onClick={() => setDays(localDays + 1)}
|
||||
disabled={!hasMinuteCap || !enabled || localDays >= 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>
|
||||
>+</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted">天</span>
|
||||
{!hasMinuteCap && (
|
||||
<span className="text-[10px] text-warning/80 bg-warning/8 rounded px-1.5 py-px font-medium">需 Pro+</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2.5">
|
||||
<div className="text-[10px] text-secondary">向前扩展历史数据</div>
|
||||
<MinuteExtendControls hasMinuteCap={hasMinuteCap} tierLabel={caps?.label ?? ''} isRunning={isRunning} onStart={onStart} />
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-muted">
|
||||
A股标的 · 原始数据存储(查询时实时复权)
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MinuteExtendControls({ hasMinuteCap, tierLabel, isRunning, onStart }: { hasMinuteCap: boolean; tierLabel: string; isRunning: boolean; onStart: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
// 月单位(按月扩展更长的分钟K历史)仅 Expert+ 开放;Pro 仅可用"天"(1~15 天)
|
||||
const canUseMonth = isExpertOrAbove(tierLabel)
|
||||
const [unit, setUnit] = useState<'day' | 'month'>('day')
|
||||
const [value, setValue] = useState(5)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const dataStatus = useQuery({
|
||||
queryKey: QK.dataStatus,
|
||||
queryFn: api.dataStatus,
|
||||
})
|
||||
// 判断本地是否已有分钟K数据:后端 _safe_aggregate_minute 为避免全表扫描,
|
||||
// rows 恒为 0,改用 trading_days(分区目录数,真实统计)判断。
|
||||
const hasMinuteData = !!(dataStatus.data?.minute?.trading_days)
|
||||
|
||||
const extend = useMutation({
|
||||
mutationFn: () => api.extendMinuteHistory(value, unit),
|
||||
onSuccess: () => {
|
||||
onStart()
|
||||
qc.invalidateQueries({ queryKey: QK.pipelineJobs })
|
||||
qc.invalidateQueries({ queryKey: QK.dataStatus })
|
||||
},
|
||||
})
|
||||
|
||||
// 各单位上限:day 15 天,month 6 月(180 天)
|
||||
const maxValue = unit === 'month' ? 6 : 15
|
||||
|
||||
const handleFetch = () => {
|
||||
if (!hasMinuteData) {
|
||||
setConfirmOpen(true)
|
||||
} else {
|
||||
extend.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单位时把 value clamp 到新单位的上限
|
||||
const switchUnit = (u: 'day' | 'month') => {
|
||||
if (u === unit) return
|
||||
setUnit(u)
|
||||
const max = u === 'month' ? 6 : 15
|
||||
setValue(v => Math.min(v, max))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setValue(Math.max(1, value - 1))}
|
||||
disabled={!hasMinuteCap || isRunning || extend.isPending}
|
||||
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 text-foreground bg-base">
|
||||
{value}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setValue(Math.min(maxValue, value + 1))}
|
||||
disabled={!hasMinuteCap || isRunning || extend.isPending || value >= maxValue}
|
||||
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>
|
||||
|
||||
{canUseMonth ? (
|
||||
<div className="flex rounded-btn border border-border overflow-hidden">
|
||||
{(['day', 'month'] as const).map(u => (
|
||||
<button
|
||||
key={u}
|
||||
onClick={() => switchUnit(u)}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium transition-colors ${
|
||||
unit === u ? 'bg-accent/15 text-accent' : 'text-secondary hover:bg-elevated'
|
||||
}`}
|
||||
>{u === 'day' ? '天' : '月'}</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted">天</span>
|
||||
)}
|
||||
{/* 第 2 行: 两个手动获取按钮 (40天快速 / 1年分段) */}
|
||||
<div className="pt-2 border-t border-border grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleFetch('40d')}
|
||||
disabled={!hasMinuteCap || fetchingMode !== ''}
|
||||
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn bg-accent/90 text-foreground text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
|
||||
>
|
||||
{fetchingMode === '40d' ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span>获取中…</span></>
|
||||
) : (
|
||||
<><Download className="h-3.5 w-3.5" /><span>往前获取 (单次拉满)</span></>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFetch('1y')}
|
||||
disabled={!hasMinuteCap || fetchingMode !== ''}
|
||||
className="inline-flex flex-col items-center justify-center gap-0.5 px-2 py-2 rounded-btn border border-amber-400/40 bg-amber-400/10 text-amber-400 text-xs font-medium hover:bg-amber-400/20 disabled:opacity-40 transition-colors duration-150"
|
||||
>
|
||||
{fetchingMode === '1y' ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin" /><span>分段获取中…</span></>
|
||||
) : (
|
||||
<><Calendar className="h-3.5 w-3.5" /><span>获取最近 1 年</span><span className="text-[9px] opacity-70">分段拉取</span></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 第 3 行: 清空 */}
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={!hasMinuteCap || isRunning || extend.isPending}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 disabled:pointer-events-none transition-colors duration-150"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clearMutation.isPending}
|
||||
title="清空分钟K数据"
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-btn border border-danger/30 text-danger/80 text-xs font-medium hover:bg-danger/10 disabled:opacity-40 transition-colors duration-150"
|
||||
>
|
||||
{extend.isPending ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin" />请求中…</>
|
||||
) : (
|
||||
<>获取数据</>
|
||||
)}
|
||||
<Trash2 className="h-3 w-3" />
|
||||
清空分钟K数据
|
||||
</button>
|
||||
|
||||
{confirmOpen && (
|
||||
{/* 说明 */}
|
||||
<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">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmOpen(false)} />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !clearMutation.isPending && setConfirmClear(false)} />
|
||||
<div className="relative rounded-card border border-border bg-surface shadow-2xl mx-4 px-6 py-5 max-w-sm w-full space-y-4">
|
||||
<div className="text-sm text-foreground text-center">本地暂无分钟K数据,是否立即获取最近 {value} {unit === 'month' ? '月' : '天'}的分钟K?</div>
|
||||
<div className="text-sm text-foreground text-center font-medium">确认清空分钟K数据?</div>
|
||||
<div className="text-[11px] text-muted text-center leading-relaxed">
|
||||
此操作仅删除分钟K (kline_minute) 数据, <span className="text-foreground/80">不影响</span>日K、复权因子、指标等其他数据。清空后可重新获取。
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => { setConfirmOpen(false); extend.mutate() }}
|
||||
disabled={extend.isPending}
|
||||
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent disabled:opacity-40 transition-colors duration-150"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
|
||||
>
|
||||
取消
|
||||
<button onClick={() => setConfirmClear(false)} disabled={clearMutation.isPending}
|
||||
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150">取消</button>
|
||||
<button onClick={() => clearMutation.mutate()} disabled={clearMutation.isPending}
|
||||
className="px-4 py-1.5 rounded-btn bg-danger/90 text-foreground text-xs font-medium hover:bg-danger disabled:opacity-40 transition-colors duration-150">
|
||||
{clearMutation.isPending ? <span className="inline-flex items-center gap-1.5"><Loader2 className="h-3 w-3 animate-spin" />清空中…</span> : '确认清空'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-7
@@ -1236,8 +1236,21 @@ export const api = {
|
||||
`/api/kline/sync?symbol=${encodeURIComponent(symbol)}&days=${days}`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
syncMinute: () =>
|
||||
request<{ status: string; job_id: string }>('/api/kline/sync_minute', { method: 'POST' }),
|
||||
syncMinute: (days?: number, extend?: boolean) =>
|
||||
request<{ status: string; job_id: string }>('/api/kline/sync_minute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...(days ? { days } : {}), ...(extend ? { extend: true } : {}) }),
|
||||
}),
|
||||
syncMinuteSingle: (symbol: string) =>
|
||||
request<{ status: string; symbol: string; rows: number }>('/api/kline/sync_minute_single', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ symbol }),
|
||||
}),
|
||||
clearMinute: () =>
|
||||
request<{ status: string; removed: number }>('/api/kline/clear_minute', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
}),
|
||||
extendHistory: (value: number, unit: 'day' | 'month' | 'year') =>
|
||||
request<{ status: string; job_id: string }>('/api/kline/extend_history', {
|
||||
method: 'POST',
|
||||
@@ -1248,11 +1261,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ start_date: startDate }),
|
||||
}),
|
||||
extendMinuteHistory: (value: number, unit: 'day' | 'month') =>
|
||||
request<{ status: string; job_id: string }>('/api/kline/extend_minute_history', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value, unit }),
|
||||
}),
|
||||
rebuildEnriched: () =>
|
||||
request<{ status: string; job_id: string }>('/api/kline/rebuild_enriched', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1085,7 +1085,7 @@ export function Data() {
|
||||
<AnimatePresence>
|
||||
{openSettings === 'minute' && (
|
||||
<SettingsModal title="分钟 K · 同步设置" onClose={() => setOpenSettings(null)}>
|
||||
<MinuteSyncConfig caps={caps.data} isRunning={!!activeJobId} onStart={() => setOpenSettings(null)} />
|
||||
<MinuteSyncConfig caps={caps.data} onJobStart={(jobId) => { setActiveJobId(jobId); setOpenSettings(null) }} />
|
||||
</SettingsModal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
Reference in New Issue
Block a user