mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
refactor(settings): 盘中分钟增量刷新开关迁至实时监控设置
该功能是交易时段常驻运行的实时服务, 心智模型归实时行情设置更合理; 数据页分钟K弹窗还原为纯盘后批量同步语义。 - 配置端点归属迁移: minute_refresh_enabled/interval 由 PUT /api/settings/preferences/realtime-monitor 拥有 (set_realtime_monitor_config 写入 + getter 返回, clamp [60,300] 不变); minute-sync 端点还原原状 - 前端 Monitoring.tsx 新增「盘中分钟增量」卡片 (开关/间隔滑杆 60-300s/ 运行轮次状态行), 沿用 Card/ToggleRow/滑杆防抖既有模式 - MinuteSyncConfig 移除盘中增量区块; api.ts updateMinuteSync 还原 3 参签名 - 新增归属测试: realtime_monitor_config 拥有 refresh 键且越界 clamp 验证: 28 项分钟相关测试通过; UI 开关双向回路 (curl 置开→UI 显示开, UI 点击→偏好翻转 False 且服务 gate_reason=disabled); 数据页弹窗无残留
This commit is contained in:
@@ -391,10 +391,6 @@ 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):
|
||||
@@ -839,17 +835,11 @@ 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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1002,6 +992,9 @@ class RealtimeMonitorConfigIn(BaseModel):
|
||||
screener_auto_run: bool | None = None
|
||||
minute_intraday_refresh: bool | None = None
|
||||
minute_intraday_refresh_interval: int | None = None
|
||||
# 盘中分钟增量落盘 (Expert 专有) — 交易时段常驻服务, 归实时监控配置
|
||||
minute_refresh_enabled: bool | None = None
|
||||
minute_refresh_interval: int | None = None
|
||||
monitor_ext_fields: dict | None = None
|
||||
|
||||
|
||||
|
||||
@@ -940,6 +940,13 @@ def set_realtime_monitor_config(cfg: dict) -> dict:
|
||||
updates["minute_intraday_refresh_interval"] = max(
|
||||
_INTRADAY_REFRESH_INTERVAL_MIN,
|
||||
min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"])))
|
||||
if "minute_refresh_enabled" in cfg:
|
||||
updates["minute_refresh_enabled"] = bool(cfg["minute_refresh_enabled"])
|
||||
if "minute_refresh_interval" in cfg:
|
||||
# clamp 到 [60, 300] (下限保证 60s 窗口至多一个全市场脉冲), 与 getter 一致
|
||||
updates["minute_refresh_interval"] = max(
|
||||
_MINUTE_REFRESH_INTERVAL_MIN,
|
||||
min(_MINUTE_REFRESH_INTERVAL_MAX, int(cfg["minute_refresh_interval"])))
|
||||
if "monitor_ext_fields" in cfg:
|
||||
raw = cfg["monitor_ext_fields"] or {}
|
||||
updates["monitor_ext_fields"] = {
|
||||
@@ -961,6 +968,8 @@ def get_realtime_monitor_config() -> dict:
|
||||
"screener_auto_run": get_screener_auto_run(),
|
||||
"minute_intraday_refresh": get_minute_intraday_refresh(),
|
||||
"minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(),
|
||||
"minute_refresh_enabled": get_minute_refresh_enabled(),
|
||||
"minute_refresh_interval": get_minute_refresh_interval(),
|
||||
"monitor_ext_fields": get_monitor_ext_fields(),
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,20 @@ def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch):
|
||||
preferences.save({"minute_refresh_interval": 90})
|
||||
assert preferences.get_minute_refresh_interval() == 90
|
||||
|
||||
def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch):
|
||||
"""盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [60,300]。"""
|
||||
_isolated_prefs(tmp_path, monkeypatch)
|
||||
saved = preferences.set_realtime_monitor_config({
|
||||
"minute_refresh_enabled": True,
|
||||
"minute_refresh_interval": 10, # 越界 → clamp 到下限
|
||||
})
|
||||
assert saved["minute_refresh_enabled"] is True
|
||||
assert saved["minute_refresh_interval"] == 60
|
||||
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 400})
|
||||
assert saved["minute_refresh_interval"] == 300
|
||||
saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 120})
|
||||
assert saved["minute_refresh_interval"] == 120
|
||||
|
||||
|
||||
def test_status_endpoint_without_service():
|
||||
from fastapi import FastAPI
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, Trash2, Download, Calendar, Zap } from 'lucide-react'
|
||||
import { Loader2, Trash2, Download, Calendar } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { MissingCapChip } from '@/lib/capability-labels'
|
||||
@@ -12,59 +12,20 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
|
||||
queryFn: api.preferences,
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ enabled, days, segmentDays, refresh }: {
|
||||
enabled: boolean; days: number; segmentDays?: number
|
||||
refresh?: { enabled?: boolean; interval?: number }
|
||||
}) =>
|
||||
api.updateMinuteSync(enabled, days, segmentDays, refresh),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
qc.invalidateQueries({ queryKey: ['minute-refresh-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
// 盘中增量刷新状态 (轮询 15s, 仅弹窗挂载期间)
|
||||
const refreshStatus = useQuery({
|
||||
queryKey: ['minute-refresh-status'],
|
||||
queryFn: api.minuteRefreshStatus,
|
||||
refetchInterval: 15000,
|
||||
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 refreshEnabled = prefs.data?.minute_refresh_enabled ?? false
|
||||
const refreshInterval = prefs.data?.minute_refresh_interval ?? 60
|
||||
const [localDays, setLocalDays] = useState(days)
|
||||
const [localSegment, setLocalSegment] = useState(segmentDays)
|
||||
const [localRefreshInterval, setLocalRefreshInterval] = useState(refreshInterval)
|
||||
|
||||
useEffect(() => { setLocalDays(days) }, [days])
|
||||
useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays])
|
||||
useEffect(() => { setLocalRefreshInterval(refreshInterval) }, [refreshInterval])
|
||||
|
||||
// 盘中增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池
|
||||
const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch']
|
||||
const rs = refreshStatus.data
|
||||
const refreshGateText = rs?.custom_provider_active
|
||||
? '已配置自定义分钟源, 盘中增量由插件自管'
|
||||
: rs && rs.available && !rs.capability_ok
|
||||
? '需要日内分时批量能力 (Expert)'
|
||||
: !rs?.in_trading_hours ? '非连续竞价时段, 暂停中'
|
||||
: rs?.last_error ? `最近错误: ${rs.last_error}`
|
||||
: null
|
||||
|
||||
const handleRefreshToggle = () => {
|
||||
if (!hasIntradayBatchCap) return
|
||||
update.mutate({ enabled, days: localDays, refresh: { enabled: !refreshEnabled } })
|
||||
}
|
||||
|
||||
const setRefreshInterval = (v: number) => {
|
||||
const clamped = Math.max(60, Math.min(300, Math.round(v / 30) * 30))
|
||||
setLocalRefreshInterval(clamped)
|
||||
update.mutate({ enabled, days: localDays, refresh: { interval: clamped } })
|
||||
}
|
||||
|
||||
useEffect(() => { setLocalDays(days) }, [days])
|
||||
useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays])
|
||||
@@ -189,67 +150,6 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 区块 A2: 盘中增量刷新 (Expert 专有, intraday.batch 独立限流池) */}
|
||||
<div className="pt-3 border-t border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={handleRefreshToggle}
|
||||
disabled={!hasIntradayBatchCap}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 shrink-0 ${
|
||||
refreshEnabled ? 'bg-amber-400 shadow-[0_0_6px_rgba(245,158,11,0.3)]' : 'bg-elevated'
|
||||
} ${!hasIntradayBatchCap ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
||||
refreshEnabled ? 'translate-x-[18px]' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
盘中增量刷新{refreshEnabled ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setRefreshInterval(localRefreshInterval - 30)}
|
||||
disabled={!hasIntradayBatchCap || !refreshEnabled || localRefreshInterval <= 60}
|
||||
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-10 flex items-center justify-center border-y border-border text-[11px] font-mono tabular-nums ${refreshEnabled ? 'text-foreground bg-base' : 'text-muted bg-elevated/50'}`}>
|
||||
{Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60}m` : `${localRefreshInterval}s`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRefreshInterval(localRefreshInterval + 30)}
|
||||
disabled={!hasIntradayBatchCap || !refreshEnabled || localRefreshInterval >= 300}
|
||||
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>
|
||||
{!hasIntradayBatchCap && <MissingCapChip capKey="intraday.batch" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted leading-relaxed">
|
||||
交易时段内用日内分时批量 (独立配额) 每 {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60} 分钟` : `${localRefreshInterval} 秒`} 全市场脉冲落盘一次,
|
||||
分钟策略读到最新K线; 不占用盘后分钟同步的限流配额。
|
||||
</div>
|
||||
{/* 运行状态一行: 门控原因 / 下一轮 / 最近一轮 */}
|
||||
{rs?.available && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-muted">
|
||||
<span className={rs.running ? 'text-emerald-400' : ''}>
|
||||
● {rs.running ? '服务运行中' : '服务未运行'}
|
||||
</span>
|
||||
{refreshGateText && <span className="text-amber-400/80">{refreshGateText}</span>}
|
||||
{rs.rounds != null && rs.rounds > 0 && (
|
||||
<span>已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求{rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
|
||||
<div className="pt-3 border-t border-border space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -1648,15 +1648,13 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
),
|
||||
updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number, refresh?: { enabled?: boolean; interval?: number }) =>
|
||||
updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) =>
|
||||
request<Preferences>('/api/settings/preferences/minute-sync', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
minute_sync_enabled: enabled,
|
||||
minute_sync_days: days,
|
||||
...(segmentDays != null ? { minute_sync_segment_days: segmentDays } : {}),
|
||||
...(refresh?.enabled != null ? { minute_refresh_enabled: refresh.enabled } : {}),
|
||||
...(refresh?.interval != null ? { minute_refresh_interval: refresh.interval } : {}),
|
||||
}),
|
||||
}),
|
||||
|
||||
|
||||
@@ -54,9 +54,21 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6
|
||||
// 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致)
|
||||
const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval)
|
||||
// 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [60,300] clamp 对齐; 默认 60
|
||||
const minuteRefreshInterval = prefs?.minute_refresh_interval ?? 60
|
||||
const [minuteRefreshIntervalDraft, setMinuteRefreshIntervalDraft] = useState(minuteRefreshInterval)
|
||||
// 盘中增量服务状态 (15s 轮询; 无服务时 available=false)
|
||||
const refreshStatus = useQuery({
|
||||
queryKey: ['minute-refresh-status'],
|
||||
queryFn: api.minuteRefreshStatus,
|
||||
refetchInterval: 15000,
|
||||
})
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
||||
// 盘中分钟增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池
|
||||
const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch']
|
||||
const rs = refreshStatus.data
|
||||
// 新建监控规则时默认勾选的推送渠道 (全局默认值数组, 单条规则可独立修改)
|
||||
const webhookDefaultChannels = prefs?.webhook_default_channels ?? []
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
|
||||
@@ -262,6 +274,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
return () => window.clearTimeout(t)
|
||||
}, [intradayIntervalDraft, intradayInterval, save])
|
||||
|
||||
// 盘中增量间隔: 服务端值变化时同步本地草稿
|
||||
useEffect(() => {
|
||||
setMinuteRefreshIntervalDraft(minuteRefreshInterval)
|
||||
}, [minuteRefreshInterval])
|
||||
|
||||
// 盘中增量间隔: 草稿与已保存值不同时, 2s 防抖落库
|
||||
useEffect(() => {
|
||||
if (minuteRefreshIntervalDraft === minuteRefreshInterval) return
|
||||
const t = window.setTimeout(() => {
|
||||
save({ minute_refresh_interval: minuteRefreshIntervalDraft })
|
||||
}, 2000)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [minuteRefreshIntervalDraft, minuteRefreshInterval, save])
|
||||
|
||||
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
|
||||
const [flash, setFlash] = useState(false)
|
||||
const flashedRef = useRef(false)
|
||||
@@ -282,6 +308,57 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
{/* ========== 左列 ========== */}
|
||||
<div className="space-y-6">
|
||||
{/* 行情状态 — 开关 + 间隔 */}
|
||||
{/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */}
|
||||
<Card icon={Zap} title="盘中分钟增量">
|
||||
<ToggleRow
|
||||
label="盘中分钟增量落盘"
|
||||
desc={
|
||||
!hasIntradayBatchCap ? '需要日内分时批量能力 (Expert)'
|
||||
: rs?.custom_provider_active ? '已配置自定义分钟源, 盘中增量由插件自管'
|
||||
: rs?.running ? (rs?.in_trading_hours ? '服务运行中' : '运行中 · 非连续竞价时段暂停')
|
||||
: '已关闭'
|
||||
}
|
||||
checked={prefs?.minute_refresh_enabled ?? false}
|
||||
onChange={(v) => save({ minute_refresh_enabled: v })}
|
||||
disabled={!hasIntradayBatchCap || !!rs?.custom_provider_active}
|
||||
/>
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<div className="flex items-center justify-between gap-4 py-1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">刷新间隔</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
交易时段内全市场脉冲落盘一轮的间隔; 下限 60s 保证不超 intraday.batch 配额
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
||||
{minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<input
|
||||
type="range"
|
||||
min={60}
|
||||
max={300}
|
||||
step={30}
|
||||
value={minuteRefreshIntervalDraft}
|
||||
disabled={!hasIntradayBatchCap}
|
||||
onChange={(e) => setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))}
|
||||
className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-[10px] text-muted shrink-0">
|
||||
{minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '60s — 300s'}
|
||||
</span>
|
||||
</div>
|
||||
{rs?.available && rs.rounds != null && rs.rounds > 0 && (
|
||||
<div className="mt-2 text-[10px] text-muted">
|
||||
已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求
|
||||
{rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''}
|
||||
{rs.last_error ? ` · ${rs.last_error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card icon={Activity} title="行情轮询">
|
||||
<ToggleRow
|
||||
label="实时行情"
|
||||
|
||||
Reference in New Issue
Block a user