diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 1187c66..15f7fea 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -1056,7 +1056,7 @@ async def sync_minute_single(request: Request, body: dict): loop = asyncio.get_event_loop() def _run(): - return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days) + return kline_sync.sync_and_persist_minute([symbol], repo, capset, days=days, force_full_days=True) written = await loop.run_in_executor(_long_task_executor, _run) diff --git a/backend/app/services/kline_sync.py b/backend/app/services/kline_sync.py index 8db0f11..3aa00ca 100644 --- a/backend/app/services/kline_sync.py +++ b/backend/app/services/kline_sync.py @@ -998,11 +998,13 @@ def sync_and_persist_minute( days: int = 5, on_chunk_done: Callable[[int, int, str], None] | None = None, extend_backward: bool = False, + force_full_days: bool = False, ) -> int: """同步分钟 K 并存到 Parquet(前复权价格, SDK 端 adjust=qfq)。返回写入行数。 使用 start_time / end_time 区间拉取, 确保所有标的覆盖同一时间段。 on_chunk_done(current, total) 每个 chunk 完成后回调。 + force_full_days=True 时强制回溯 days 自然日 (不增量补, 用于个股补齐历史)。 """ minute_provider = preferences.get_minute_data_provider() # resolver 调用统一走 _resolve_minute_provider, 与 _try_custom_minute 共用异常边界。 @@ -1042,8 +1044,13 @@ def sync_and_persist_minute( end_time = now else: # 默认增量模式: 首次拉取回溯 N 天, 已有数据则从最新时间增量补到今天 + # force_full_days=True: 强制回溯 days 自然日 (个股补齐历史, 不增量) last_dt = _latest_minute_datetime(repo) - if last_dt: + if force_full_days: + # 按交易日换算自然日 (7/5 系数), 确保覆盖足够交易日 + calendar_days = int(days * 7 / 5) + 5 + start_time = now - timedelta(days=calendar_days) + elif last_dt: start_time = last_dt else: start_time = now - timedelta(days=days) @@ -1063,7 +1070,10 @@ def sync_and_persist_minute( written_box = [0] # list 闭包, 绕过 Python 闭包外层赋值 def _persist(seg_df: pl.DataFrame) -> None: - written_box[0] += _write_minute_partition(seg_df, minute_dir) + # 单股自动补齐可能与另一个补齐请求同时写同一日期分区。Windows 不允许 + # 替换仍被另一写入占用的临时文件,因此读-改-写必须复用仓库写锁。 + with repo._write_lock: + written_box[0] += _write_minute_partition(seg_df, minute_dir) segment_days = preferences.get_minute_sync_segment_days() sync_minute_batch( diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index c2ca060..e61c0d9 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -355,12 +355,20 @@ class KlineRepository: def execute_all(self, sql: str, params: list | None = None) -> list[tuple]: """线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。""" with self._lock: - return self.db.execute(sql, params or []).fetchall() + cursor = self.db.cursor() + try: + return cursor.execute(sql, params or []).fetchall() + finally: + cursor.close() def execute_one(self, sql: str, params: list | None = None) -> tuple | None: """线程安全的 SELECT → fetchone。""" with self._lock: - return self.db.execute(sql, params or []).fetchone() + cursor = self.db.cursor() + try: + return cursor.execute(sql, params or []).fetchone() + finally: + cursor.close() # ================================================================ # Polars 缓存管理 diff --git a/backend/tests/test_minute_range_api.py b/backend/tests/test_minute_range_api.py index 9f46a4b..52a7712 100644 --- a/backend/tests/test_minute_range_api.py +++ b/backend/tests/test_minute_range_api.py @@ -95,7 +95,7 @@ def test_sync_minute_single_uses_requested_days(monkeypatch): )) assert result["rows"] == 2400 - sync.assert_called_once_with(["600000.SH"], repo, capset, days=10) + sync.assert_called_once_with(["600000.SH"], repo, capset, days=10, force_full_days=True) refresh.assert_called_once_with(repo, "kline_minute") diff --git a/backend/tests/test_minute_routing.py b/backend/tests/test_minute_routing.py index 6792d35..1e30e27 100644 --- a/backend/tests/test_minute_routing.py +++ b/backend/tests/test_minute_routing.py @@ -12,6 +12,7 @@ mock 范式沿用 test_stocksdk_provider.py (monkeypatch 模块属性)。 from __future__ import annotations from datetime import date, datetime +from threading import Lock from unittest.mock import MagicMock import httpx @@ -411,6 +412,38 @@ def test_sync_and_persist_minute_custom_persists(monkeypatch, tmp_path): get_client_spy.assert_not_called() +def test_sync_and_persist_minute_holds_repository_write_lock(monkeypatch, tmp_path): + expected_df = _mock_minute_df() + mock_provider = MagicMock() + mock_provider.get_minute.return_value = expected_df + _setup_custom_provider(monkeypatch, mock_provider, has_dataset=True) + + monkeypatch.setattr(kline_sync, "_cleanup_null_datetime_minute", lambda repo: None) + monkeypatch.setattr(kline_sync, "_migrate_symbol_to_date_partition", lambda repo: None) + monkeypatch.setattr(kline_sync, "_latest_minute_datetime", lambda repo: None) + monkeypatch.setattr(kline_sync, "resolve_limit", lambda *a, **kw: MagicMock(batch=100, rpm=30)) + monkeypatch.setattr(kline_sync.preferences, "get_minute_sync_segment_days", lambda: 20) + + write_lock = Lock() + + def assert_locked(df, minute_dir): + assert not write_lock.acquire(blocking=False) + return df.height + + monkeypatch.setattr(kline_sync, "_write_minute_partition", assert_locked) + + mock_repo = MagicMock() + mock_repo.store.data_dir = tmp_path + mock_repo.db.execute = MagicMock() + mock_repo._write_lock = write_lock + + written = kline_sync.sync_and_persist_minute( + ["600519.SH"], mock_repo, MagicMock(), + ) + + assert written == expected_df.height + + # ---------- 测试 13: get_provider 异常时 fall through TickFlow (Issue 2) ---------- def test_get_provider_exception_falls_back_to_tickflow(monkeypatch): diff --git a/backend/tests/test_repository_index.py b/backend/tests/test_repository_index.py index 28f12ef..3e546d3 100644 --- a/backend/tests/test_repository_index.py +++ b/backend/tests/test_repository_index.py @@ -1,4 +1,6 @@ """指数资产路由 — repository 层测试。""" +import os + import polars as pl import pytest @@ -47,6 +49,25 @@ def test_name_map_stock_beats_index(repo): import datetime as _dt +def test_execute_one_releases_parquet_file(repo): + minute_dir = repo.store.data_dir / "kline_minute" / "date=2026-07-23" + minute_dir.mkdir(parents=True, exist_ok=True) + part = minute_dir / "part.parquet" + replacement = minute_dir / "part.parquet.tmp" + minute = pl.DataFrame({ + "symbol": ["600000.SH"], + "datetime": [_dt.datetime(2026, 7, 23, 9, 30)], + "close": [10.0], + }) + minute.write_parquet(part) + repo.rebuild_views() + + assert repo.execute_one("SELECT max(datetime) FROM kline_minute")[0] == _dt.datetime(2026, 7, 23, 9, 30) + + minute.write_parquet(replacement) + os.replace(replacement, part) + + def _write_index_enriched(repo, dates_rows): for ds, rows in dates_rows.items(): d = repo.store.data_dir / "kline_index_enriched" / f"date={ds}" diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 269b26e..63035fd 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -332,6 +332,7 @@ interface Props { symbol?: string linkedPrice?: number | null onDateClick?: (date: string) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void /** 默认可见蜡烛根数, 默认 60 */ visibleBars?: number /** 已激活的子图 key 列表 (含 vol, 按点击顺序) */ @@ -551,6 +552,24 @@ function buildOption( const series: any[] = [] const xAxisIndices: number[] = [] + const priceLineValues = (priceLines ?? []) + .map(line => line.value) + .filter(value => Number.isFinite(value) && value > 0) + const axisMin = priceLineValues.length > 0 + ? ({ min, max }: { min: number; max: number }) => { + const nextMin = Math.min(min, ...priceLineValues) + const nextMax = Math.max(max, ...priceLineValues) + return nextMin - Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001) + } + : undefined + const axisMax = priceLineValues.length > 0 + ? ({ min, max }: { min: number; max: number }) => { + const nextMin = Math.min(min, ...priceLineValues) + const nextMax = Math.max(max, ...priceLineValues) + return nextMax + Math.max((nextMax - nextMin) * 0.03, nextMax * 0.001) + } + : undefined + // ===== grid 0: K线主图 ===== grids.push({ left, right, top: topPad, height: candleAvail }) xAxes.push({ @@ -562,6 +581,8 @@ function buildOption( }) yAxes.push({ scale: true, + min: axisMin, + max: axisMax, // 上下各留 3% 边距: 防止最高/最低点的蜡烛贴边, 涨停/炸板标签被遮挡 boundaryGap: [0.03, 0.03], splitArea: { show: false }, @@ -791,6 +812,7 @@ export function EChartsCandlestick({ symbol: _symbol, linkedPrice, onDateClick, + onPriceDoubleClick, visibleBars = 60, activeIndicators = [], volumeCompare = { enabled: true, days: 1 }, @@ -801,6 +823,8 @@ export function EChartsCandlestick({ dataRef.current = data const onDateClickRef = useRef(onDateClick) onDateClickRef.current = onDateClick + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick // 主题: buildOption/信息栏内部通过 CT() 动态取调色板, 这里只负责切换时触发重建 const theme = useTheme() @@ -982,6 +1006,18 @@ export function EChartsCandlestick({ } }) + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const currentPrice = dataRef.current[dataRef.current.length - 1]?.close + if (Number.isFinite(price) && price > 0 && Number.isFinite(currentPrice) && currentPrice > 0) { + onPriceDoubleClickRef.current?.(price, currentPrice) + } + } + chart.getZr().on('dblclick', handlePriceDoubleClick) + // dataZoom → 只更新 ref,不触发 React re-render // compact 变化时需要增量更新 markPoint chart.on('dataZoom', () => { @@ -1007,6 +1043,7 @@ export function EChartsCandlestick({ chart.off('updateAxisPointer') chart.off('click') chart.off('dataZoom') + chart.getZr().off('dblclick', handlePriceDoubleClick) ro.disconnect() chart.dispose() chartRef.current = null diff --git a/frontend/src/components/EChartsIntraday.tsx b/frontend/src/components/EChartsIntraday.tsx index c308f01..ad86406 100644 --- a/frontend/src/components/EChartsIntraday.tsx +++ b/frontend/src/components/EChartsIntraday.tsx @@ -23,6 +23,9 @@ interface Props { date?: string priceLimit?: PriceLimitInfo onPriceHover?: (price: number | null) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void + currentPrice?: number + priceLines?: { value: number; label?: string; color?: string }[] showLimitLines?: boolean showAvgLine?: boolean } @@ -59,7 +62,7 @@ function getLimitPrices(prevClose: number, priceLimit?: PriceLimitInfo): { return { limitUp, limitDown, upPct, downPct } } -function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true): EChartsOption { +function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, ct: ChartTheme, priceLimit?: PriceLimitInfo, showLimitLines = true, showAvgLine = true, priceLines: Props['priceLines'] = []): EChartsOption { // 将数据映射到全天时间轴上的正确位置 const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] @@ -106,6 +109,25 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP symbol: 'none', }) } + for (const line of priceLines) { + if (!Number.isFinite(line.value) || line.value <= 0) continue + markLineData.push({ + yAxis: line.value, + lineStyle: { color: line.color ?? ct.text, type: 'dashed', width: 1, opacity: 0.92 }, + label: { + show: !!line.label, + formatter: line.label ?? '', + position: 'insideEndTop', + color: line.color ?? ct.text, + backgroundColor: ct.tooltipBg, + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + symbol: 'none', + }) + } let yMin: number | undefined let yMax: number | undefined @@ -120,13 +142,19 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP } } + const monitoredDiff = priceLines.reduce((largest, line) => ( + Number.isFinite(line.value) && line.value > 0 + ? Math.max(largest, Math.abs(line.value - prevClose)) + : largest + ), 0) * 1.05 + if (showLimitLines && yMode === 'limit') { const { limitUp, limitDown } = getLimitPrices(prevClose, priceLimit) const limitDiffUp = limitUp - prevClose const limitDiffDown = prevClose - limitDown const limitDiff = Math.max(limitDiffUp, limitDiffDown) // 涨跌停模式: Y 轴按实际涨跌停价 - maxDiff = limitDiff + maxDiff = Math.max(limitDiff, monitoredDiff) yMin = prevClose - maxDiff yMax = prevClose + maxDiff // 加 markLine 标注涨停价和跌停价 (仅虚线, 不显示文字) @@ -157,6 +185,7 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP // 至少保证一个可视范围 (防止数据平时 maxDiff=0)。指数不使用涨跌停范围,最小范围要更紧,否则低波动指数会被压成横线。 const minDiff = showLimitLines ? prevClose * 0.01 : prevClose * 0.001 if (maxDiff < minDiff) maxDiff = minDiff + maxDiff = Math.max(maxDiff, monitoredDiff) yMin = prevClose - maxDiff yMax = prevClose + maxDiff } @@ -204,8 +233,8 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP link: [{ xAxisIndex: 'all' }], }, grid: [ - { left: 60, right: 55, top: 24, bottom: '28%' }, - { left: 60, right: 55, top: '74%', bottom: 20 }, + { left: 60, right: 55, top: 24, bottom: '34%' }, + { left: 60, right: 55, top: '69%', bottom: 20 }, ], xAxis: [ { @@ -357,15 +386,32 @@ function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgP } } -export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimit, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) { +export function EChartsIntraday({ + data, + height = 320, + prevClose, + date, + priceLimit, + onPriceHover, + onPriceDoubleClick, + currentPrice, + priceLines, + showLimitLines = true, + showAvgLine = true, +}: Props) { const containerRef = useRef(null) const chartRef = useRef(null) const roRef = useRef(null) const moRef = useRef(null) + const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null) const dataRef = useRef(data) dataRef.current = data + const currentPriceRef = useRef(currentPrice) + currentPriceRef.current = currentPrice const onPriceHoverRef = useRef(onPriceHover) onPriceHoverRef.current = onPriceHover + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick // 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart) const fullDayToDataIdx = useRef>(new Map()) @@ -431,6 +477,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi chart.on('globalout', () => { onPriceHoverRef.current?.(null) }) + + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const clickedPrice = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const latestPrice = currentPriceRef.current ?? dataRef.current[dataRef.current.length - 1]?.close + if (Number.isFinite(clickedPrice) && clickedPrice > 0 && Number.isFinite(latestPrice) && latestPrice > 0) { + onPriceDoubleClickRef.current?.(clickedPrice, latestPrice) + } + } + priceDoubleClickHandlerRef.current = handlePriceDoubleClick + chart.getZr().on('dblclick', handlePriceDoubleClick) } if (data.length > 0) { @@ -446,16 +505,19 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, priceLimi } fullDayToDataIdx.current = mapping - chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine), true) + chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines), true) } else { chart.clear() } - }, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine]) + }, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines]) useEffect(() => { return () => { chartRef.current?.off('updateAxisPointer') chartRef.current?.off('globalout') + if (priceDoubleClickHandlerRef.current) { + chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current) + } moRef.current?.disconnect() roRef.current?.disconnect() chartRef.current?.dispose() diff --git a/frontend/src/components/EChartsMultiDayIntraday.tsx b/frontend/src/components/EChartsMultiDayIntraday.tsx index 32f8a22..63e0cf5 100644 --- a/frontend/src/components/EChartsMultiDayIntraday.tsx +++ b/frontend/src/components/EChartsMultiDayIntraday.tsx @@ -18,6 +18,8 @@ const COLORS = { interface Props { sessions: MinuteKlineSession[] height?: number + onPriceDoubleClick?: (price: number, currentPrice: number) => void + priceLines?: { value: number; label?: string; color?: string }[] } interface InfoPoint { @@ -132,10 +134,18 @@ function buildModel(sessions: MinuteKlineSession[]) { } } -export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { +export function EChartsMultiDayIntraday({ + sessions, + height = 420, + onPriceDoubleClick, + priceLines = [], +}: Props) { const containerRef = useRef(null) const chartRef = useRef(null) const resizeObserverRef = useRef(null) + const priceDoubleClickHandlerRef = useRef<((event: { offsetX: number; offsetY: number }) => void) | null>(null) + const onPriceDoubleClickRef = useRef(onPriceDoubleClick) + onPriceDoubleClickRef.current = onPriceDoubleClick const model = useMemo(() => buildModel(sessions), [sessions]) const modelRef = useRef(model) modelRef.current = model @@ -169,10 +179,33 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { if (point) setInfo(point) }) chart.on('globalout', () => setInfo(modelRef.current.latest)) + + const handlePriceDoubleClick = (event: { offsetX: number; offsetY: number }) => { + const pixel: [number, number] = [event.offsetX, event.offsetY] + if (!chart!.containPixel({ gridIndex: 0 }, pixel)) return + const coordinate = chart!.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, pixel) + const price = Array.isArray(coordinate) ? Number(coordinate[1]) : NaN + const currentPrice = modelRef.current.latest?.row.close + if ( + Number.isFinite(price) + && price > 0 + && typeof currentPrice === 'number' + && Number.isFinite(currentPrice) + && currentPrice > 0 + ) { + onPriceDoubleClickRef.current?.(price, currentPrice) + } + } + priceDoubleClickHandlerRef.current = handlePriceDoubleClick + chart.getZr().on('dblclick', handlePriceDoubleClick) } - const minPrice = model.priceValues.length > 0 ? Math.min(...model.priceValues) : 0 - const maxPrice = model.priceValues.length > 0 ? Math.max(...model.priceValues) : 1 + const monitoredPrices = priceLines + .map(line => line.value) + .filter(value => Number.isFinite(value) && value > 0) + const allPriceValues = [...model.priceValues, ...monitoredPrices] + const minPrice = allPriceValues.length > 0 ? Math.min(...allPriceValues) : 0 + const maxPrice = allPriceValues.length > 0 ? Math.max(...allPriceValues) : 1 const padding = Math.max((maxPrice - minPrice) * 0.08, maxPrice * 0.002) const totalLength = model.categories.length const priceSeries: any[] = model.dayRanges.map(({ start, session, values }) => { @@ -198,11 +231,31 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { lineStyle: { color: theme.grid, width: 1 }, label: { show: false }, })) - if (priceSeries.length > 0 && boundaryData.length > 0) { + const monitorLineData = priceLines.flatMap(line => { + if (!Number.isFinite(line.value) || line.value <= 0) return [] + return [{ + yAxis: line.value, + lineStyle: { color: line.color ?? theme.text, type: 'dashed', width: 1, opacity: 0.92 }, + label: { + show: !!line.label, + formatter: line.label ?? '', + position: 'insideEndTop', + color: line.color ?? theme.text, + backgroundColor: theme.tooltipBg, + borderRadius: 4, + padding: [2, 6], + fontSize: 10, + fontFamily: 'JetBrains Mono, monospace', + }, + }] + }) + const markLineData = [...boundaryData, ...monitorLineData] + if (priceSeries.length > 0 && markLineData.length > 0) { priceSeries[0].markLine = { symbol: 'none', silent: true, - data: boundaryData, + animation: false, + data: markLineData, } } const averageSeries: any[] = model.dayRanges.map(({ start, session, averages }) => { @@ -243,8 +296,8 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { }, axisPointer: { link: [{ xAxisIndex: 'all' }] }, grid: [ - { left: 58, right: 18, top: 16, bottom: '28%' }, - { left: 58, right: 18, top: '76%', bottom: 22 }, + { left: 58, right: 18, top: 16, bottom: '34%' }, + { left: 58, right: 18, top: '69%', bottom: 22 }, ], xAxis: [ { @@ -329,11 +382,14 @@ export function EChartsMultiDayIntraday({ sessions, height = 420 }: Props) { ], } chart.setOption(option, true) - }, [height, model, theme]) + }, [height, model, priceLines, theme]) useEffect(() => () => { chartRef.current?.off('updateAxisPointer') chartRef.current?.off('globalout') + if (priceDoubleClickHandlerRef.current) { + chartRef.current?.getZr().off('dblclick', priceDoubleClickHandlerRef.current) + } resizeObserverRef.current?.disconnect() chartRef.current?.dispose() chartRef.current = null diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index c58747d..9f87e67 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -50,6 +50,7 @@ interface Props { visibleBars?: number linkedPrice?: number | null onDateClick?: (date: string) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void onDataChange?: (result: StockDailyKChartResult) => void /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ extColumns?: string @@ -132,6 +133,7 @@ export function StockDailyKChart({ visibleBars = 60, linkedPrice, onDateClick, + onPriceDoubleClick, onDataChange, extColumns, }: Props) { @@ -279,6 +281,7 @@ export function StockDailyKChart({ symbol={symbol} linkedPrice={linkedPrice} onDateClick={onDateClick} + onPriceDoubleClick={onPriceDoubleClick} visibleBars={visibleBars} activeIndicators={activeIndicators} volumeCompare={volumeCompare} diff --git a/frontend/src/components/StockIntradayChart.tsx b/frontend/src/components/StockIntradayChart.tsx index 438c699..95d5ca0 100644 --- a/frontend/src/components/StockIntradayChart.tsx +++ b/frontend/src/components/StockIntradayChart.tsx @@ -12,6 +12,9 @@ interface Props { prevClose?: number className?: string onPriceHover?: (price: number | null) => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void + currentPrice?: number + priceLines?: { value: number; label?: string; color?: string }[] /** 自动刷新间隔(ms)。undefined/0 = 不轮询(默认)。个股对话框盘中实时刷新时传入。 */ refetchIntervalMs?: number } @@ -23,6 +26,9 @@ export function StockIntradayChart({ prevClose, className, onPriceHover, + onPriceDoubleClick, + currentPrice, + priceLines, refetchIntervalMs, }: Props) { const qc = useQueryClient() @@ -121,6 +127,9 @@ export function StockIntradayChart({ date={date} priceLimit={minute.data?.price_limit ?? undefined} onPriceHover={onPriceHover} + onPriceDoubleClick={onPriceDoubleClick} + currentPrice={currentPrice} + priceLines={priceLines} /> )} diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 26e790e..ef910b0 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Download, Loader2, RefreshCw } from 'lucide-react' import { api, type MinuteKlineSession } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { toast } from '@/components/Toast' import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday' interface Props { @@ -10,6 +11,8 @@ interface Props { days: number height?: number refetchIntervalMs?: number + onPriceDoubleClick?: (price: number, currentPrice: number) => void + priceLines?: { value: number; label?: string; color?: string }[] } function errorMessage(error: unknown): string { @@ -21,12 +24,16 @@ export function StockMultiDayIntradayChart({ days, height = 420, refetchIntervalMs, + onPriceDoubleClick, + priceLines, }: Props) { const queryClient = useQueryClient() const history = useQuery({ queryKey: QK.klineMinuteRange(symbol, days), queryFn: () => api.klineMinuteRange(symbol, days), enabled: !!symbol, + placeholderData: (previous, previousQuery) => + previousQuery?.queryKey[1] === symbol ? previous : undefined, }) const latest = useQuery({ queryKey: QK.klineMinute(symbol, ''), @@ -63,14 +70,37 @@ export function StockMultiDayIntradayChart({ queryClient.invalidateQueries({ queryKey: ['kline-minute', symbol] }), ]) }, + onError: (e: Error) => { + const msg = e.message || '' + if (msg.includes('403') || msg.includes('Pro')) { + toast('分钟K数据需要 Pro+ 权限', 'error') + } else { + toast(`补齐数据失败: ${msg}`, 'error') + } + }, }) const loading = sessions.length === 0 && (history.isLoading || latest.isLoading) const queryError = sessions.length === 0 ? history.error ?? latest.error : null const isIndex = history.data?.asset_type === 'index' || latest.data?.asset_type === 'index' const missingDays = Math.max(0, days - sessions.length) - const showCoverage = sessions.length > 0 && missingDays > 0 && !isIndex - const chartHeight = Math.max(260, height - (showCoverage ? 32 : 0)) + const showCoverage = !history.isPlaceholderData && sessions.length > 0 && missingDays > 0 && !isIndex + + // 自动补齐: 数据不足且非指数时, 自动触发同步 + // 用 ref 记录已触发的 symbol:days, 避免重复 + const autoSyncRef = useRef(null) + useEffect(() => { + // 后端没运行时 history 会 error, 此时 missingDays 计算无意义, 跳过 + if (history.error || history.isPlaceholderData || loading || isIndex || sessions.length >= days) return + if (syncMinute.isPending) return + + const key = `${symbol}:${days}` + if (autoSyncRef.current === key) return // 本组合已触发过 + autoSyncRef.current = key + syncMinute.mutate() + }, [symbol, days, sessions.length, loading, isIndex, history.error, history.isPlaceholderData, syncMinute.isPending]) + + const chartHeight = Math.max(260, height - (showCoverage || syncMinute.isPending ? 32 : 0)) if (loading) { return ( @@ -127,23 +157,39 @@ export function StockMultiDayIntradayChart({ return (
- {showCoverage && ( + {(showCoverage || (syncMinute.isPending && !isIndex)) && (
- 当前有 {sessions.length} 个交易日数据,目标 {days} 日 - + {syncMinute.isPending ? ( + + + 正在补齐最近 {days} 日分时数据… + + ) : syncMinute.isError ? ( + 当前 {sessions.length} 日,目标 {days} 日 — 补齐失败 + ) : ( + 当前 {sessions.length} 个交易日数据,目标 {days} 日 + )} + {!syncMinute.isPending && ( + + )}
)} - + {syncMinute.isError && (
{errorMessage(syncMinute.error)}
)} diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 2da0476..cb1172a 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -30,6 +30,7 @@ interface Props { showMarkerToggle?: boolean /** 加监控回调 (传入后信息条显示 RadioTower 图标) */ onMonitor?: () => void + onPriceDoubleClick?: (price: number, currentPrice: number) => void /** 自选操作(传入后信息条显示 Star 图标) */ inWatchlist?: boolean onAddToWatchlist?: (groupId: string | null) => void @@ -37,6 +38,8 @@ interface Props { watchlistPending?: boolean /** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */ refetchIntervalMs?: number + /** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */ + infoBarOnly?: boolean } export { getDefaultRange } @@ -54,11 +57,13 @@ export function StockPanel({ showLimitMarkers = true, showMarkerToggle = true, onMonitor, + onPriceDoubleClick, inWatchlist, onAddToWatchlist, onRemoveFromWatchlist, watchlistPending, refetchIntervalMs, + infoBarOnly = false, }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) @@ -144,6 +149,7 @@ export function StockPanel({ watchlistPending={watchlistPending} /> + {infoBarOnly ? null : (
)}
+ )} ) } diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 828a30e..2f49403 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2 } from 'lucide-react' @@ -11,6 +11,8 @@ import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { StockMultiDayIntradayChart } from '@/components/StockMultiDayIntradayChart' import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' +import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog' +import { buildMonitorPriceLines } from '@/lib/price-alerts' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' @@ -39,6 +41,11 @@ const PRESETS: { label: string; months: number }[] = [ ] type PreviewView = 'daily' | 'intraday' +interface PriceAlertDraft { + id: number + targetPrice: number + currentPrice: number +} const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const function loadIntradayDays(): number { @@ -60,6 +67,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props const [intradayDays, setIntradayDays] = useState(loadIntradayDays) const [dateRange, setDateRange] = useState(getDefaultRange) const [showMonitorEditor, setShowMonitorEditor] = useState(false) + const [priceAlertDraft, setPriceAlertDraft] = useState(null) const [maximized, setMaximized] = useState(false) const qc = useQueryClient() const backdrop = useDialogBackdrop(onClose) @@ -69,6 +77,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props queryFn: api.watchlistList, enabled: !!symbol, }) + const monitorRules = useQuery({ + queryKey: QK.monitorRules, + queryFn: api.monitorRulesList, + enabled: !!symbol, + }) + const monitorPriceLines = useMemo( + () => symbol ? buildMonitorPriceLines(monitorRules.data?.rules ?? [], symbol) : [], + [monitorRules.data?.rules, symbol], + ) const inWatchlist = (watchlist.data?.symbols ?? []).some((s: any) => s.symbol === symbol) const toggleWatchlist = useMutation({ @@ -91,14 +108,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props useEffect(() => { if (!symbol) return const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape' && !priceAlertDraft) onClose() } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [symbol, onClose]) + }, [symbol, onClose, priceAlertDraft]) useEffect(() => { if (symbol) setView('daily') + setPriceAlertDraft(null) }, [symbol]) // 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K, @@ -135,6 +153,10 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props storage.stockPreviewIntradayDays.set(days) } + const openPriceAlert = (targetPrice: number, currentPrice: number) => { + setPriceAlertDraft({ id: Date.now(), targetPrice, currentPrice }) + } + return ( {symbol && ( @@ -379,14 +401,25 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props height={420} showIntraday dateRange={dateRange} + priceLines={monitorPriceLines} + onPriceDoubleClick={openPriceAlert} /> ) : ( + <> + + )} @@ -420,6 +453,16 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props )} + {symbol && priceAlertDraft && ( + setPriceAlertDraft(null)} + /> + )} ) } diff --git a/frontend/src/components/stock-analysis/PriceAlertDialog.tsx b/frontend/src/components/stock-analysis/PriceAlertDialog.tsx index 7e3976c..55de246 100644 --- a/frontend/src/components/stock-analysis/PriceAlertDialog.tsx +++ b/frontend/src/components/stock-analysis/PriceAlertDialog.tsx @@ -5,6 +5,12 @@ import { ArrowDown, ArrowUp, Bell, Check, ExternalLink, Loader2, Trash2, X } fro import { toast } from '@/components/Toast' import { LEVEL_GROUPS } from './AnalysisKChart' import { api, genRuleId, type MonitorRule, type PriceLevel } from '@/lib/api' +import { + buildPriceAlertMessage, + inferPriceAlertDirection, + parsePointPriceAlert, + type PriceAlertDirection, +} from '@/lib/price-alerts' import { QK } from '@/lib/queryKeys' import { usePreferences } from '@/lib/useSharedQueries' import { useDialogBackdrop } from '@/lib/useDialogBackdrop' @@ -13,10 +19,10 @@ interface Props { symbol: string name: string onClose: () => void + initialTarget?: number + initialCurrentPrice?: number | null } -type AlertDirection = 'up' | 'down' - const COOLDOWNS = [ { value: 600, label: '10 分钟' }, { value: 1800, label: '30 分钟' }, @@ -24,19 +30,17 @@ const COOLDOWNS = [ { value: 86400, label: '当日一次' }, ] -function pointCondition(rule: MonitorRule) { - if (rule.type !== 'price' || rule.conditions.length !== 1) return null - const condition = rule.conditions[0] - if (condition.field !== 'close' || !['>=', '<='].includes(condition.op)) return null - if (typeof condition.value !== 'number') return null - return condition -} - function levelGroupLabel(level: PriceLevel) { return LEVEL_GROUPS.find(group => group.key === level.type)?.label ?? level.type } -export function PriceAlertDialog({ symbol, name, onClose }: Props) { +export function PriceAlertDialog({ + symbol, + name, + onClose, + initialTarget, + initialCurrentPrice, +}: Props) { const qc = useQueryClient() const backdrop = useDialogBackdrop(onClose) const { data: prefs } = usePreferences() @@ -46,17 +50,28 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { staleTime: 60_000, }) const rulesQuery = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList }) + const initialTargetValue = initialTarget != null && Number.isFinite(initialTarget) && initialTarget > 0 + ? Math.round(initialTarget * 100) / 100 + : null + const initialDirection = initialTargetValue == null + ? 'up' + : inferPriceAlertDirection(initialTargetValue, initialCurrentPrice) const [tab, setTab] = useState<'create' | 'existing'>('create') - const [direction, setDirection] = useState('up') - const [target, setTarget] = useState('') + const [direction, setDirection] = useState(initialDirection) + const [target, setTarget] = useState(initialTargetValue?.toFixed(2) ?? '') const [selectedLabel, setSelectedLabel] = useState('') const [cooldown, setCooldown] = useState(3600) - const [message, setMessage] = useState('') + const [message, setMessage] = useState(initialTargetValue == null + ? '' + : buildPriceAlertMessage(name, symbol, initialDirection, initialTargetValue)) + const [messageEdited, setMessageEdited] = useState(false) const [channels, setChannels] = useState([]) const [confirmDelete, setConfirmDelete] = useState(null) const channelsInitialized = useRef(false) - const currentPrice = levelsQuery.data?.close ?? null + const currentPrice = initialCurrentPrice != null && Number.isFinite(initialCurrentPrice) && initialCurrentPrice > 0 + ? initialCurrentPrice + : levelsQuery.data?.close ?? null const recommended = useMemo(() => { if (currentPrice == null) return { above: [] as PriceLevel[], below: [] as PriceLevel[] } const seen = new Set() @@ -103,24 +118,23 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { }, [onClose]) const pointRules = useMemo( - () => (rulesQuery.data?.rules ?? []).filter(rule => - rule.scope === 'symbols' - && rule.symbols.length === 1 - && rule.symbols[0] === symbol - && pointCondition(rule), - ), + () => (rulesQuery.data?.rules ?? []).filter(rule => parsePointPriceAlert(rule, symbol)), [rulesQuery.data?.rules, symbol], ) const targetValue = Number(target) const targetValid = Number.isFinite(targetValue) && targetValue > 0 + useEffect(() => { + if (initialTargetValue == null || messageEdited || !targetValid) return + setMessage(buildPriceAlertMessage(name, symbol, direction, targetValue)) + }, [direction, initialTargetValue, messageEdited, name, symbol, targetValid, targetValue]) + const alreadyReached = targetValid && currentPrice != null && ( direction === 'up' ? currentPrice >= targetValue : currentPrice <= targetValue ) const duplicate = targetValid && pointRules.some(rule => { - const condition = pointCondition(rule)! - return condition.op === (direction === 'up' ? '>=' : '<=') - && Math.abs((condition.value ?? 0) - targetValue) < 0.005 + const alert = parsePointPriceAlert(rule, symbol)! + return alert.direction === direction && Math.abs(alert.target - targetValue) < 0.005 }) const save = useMutation({ @@ -171,7 +185,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { const selectLevel = (level: PriceLevel) => { setTarget(level.value.toFixed(2)) - setDirection(currentPrice != null && level.value < currentPrice ? 'down' : 'up') + setDirection(inferPriceAlertDirection(level.value, currentPrice)) setSelectedLabel(level.label) } @@ -179,11 +193,16 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { setTarget(value) setSelectedLabel('') const parsed = Number(value) - if (currentPrice != null && Number.isFinite(parsed)) { - setDirection(parsed < currentPrice ? 'down' : 'up') + if (Number.isFinite(parsed)) { + setDirection(inferPriceAlertDirection(parsed, currentPrice)) } } + const updateMessage = (value: string) => { + setMessage(value) + setMessageEdited(true) + } + const toggleChannel = (channel: string) => { setChannels(current => current.includes(channel) ? current.filter(item => item !== channel) @@ -298,7 +317,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { @@ -340,8 +359,8 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { ) : (
{pointRules.map(rule => { - const condition = pointCondition(rule)! - const isUp = condition.op === '>=' + const alert = parsePointPriceAlert(rule, symbol)! + const isUp = alert.direction === 'up' return (
@@ -349,7 +368,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) { {rule.name} - {isUp ? '涨至' : '跌至'} {condition.value!.toFixed(2)} · {COOLDOWNS.find(item => item.value === rule.cooldown_seconds)?.label ?? `${rule.cooldown_seconds} 秒`} + {isUp ? '涨至' : '跌至'} {alert.target.toFixed(2)} · {COOLDOWNS.find(item => item.value === rule.cooldown_seconds)?.label ?? `${rule.cooldown_seconds} 秒`}
-
-
-
- -
-

数据任务超时

-

- 后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。 -

-
-
- -
- -
- - - -
-
- {/* ===== 下方: 编辑区 ===== */} = { + second: 1, + minute: 60, + hour: 3600, +} + +function preferredTimeoutUnit(seconds: number): TimeoutUnit { + if (seconds >= 3600 && seconds % 1800 === 0) return 'hour' + if (seconds % 60 === 0) return 'minute' + return 'second' +} + +function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string { + if (!Number.isFinite(seconds)) return '' + const value = seconds / TIMEOUT_UNIT_SECONDS[unit] + return String(Number(value.toFixed(4))) +} + +export function JobTimeoutCard() { + const qc = useQueryClient() + const prefs = usePreferences() + const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null) + const [regularUnitOverride, setRegularUnitOverride] = useState(null) + const [longUnitOverride, setLongUnitOverride] = useState(null) + + const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200 + const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800 + const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout) + const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout) + const regularTimeoutInput = timeoutDraft?.regular + ?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit) + const longTimeoutInput = timeoutDraft?.long + ?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit) + const regularInputNumber = Number(regularTimeoutInput) + const longInputNumber = Number(longTimeoutInput) + const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit]) + const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit]) + const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0 + && Number.isFinite(longInputNumber) && longInputNumber > 0 + && regularTimeout >= 60 && longTimeout >= 60 + const timeoutValuesChanged = regularTimeout !== currentRegularTimeout + || longTimeout !== currentLongTimeout + + const saveJobTimeouts = useMutation({ + mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout), + onSuccess: (saved) => { + qc.setQueryData(QK.preferences, current => ( + current ? { ...current, ...saved } : current + )) + setTimeoutDraft(null) + toast('任务超时配置已保存', 'success') + }, + onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'), + }) + + return ( +
+
+
+ +
+

超时设置

+

+ 后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。 +

+
+
+ +
+ +
+ + + +
+
+ ) +} diff --git a/frontend/src/pages/settings/Timeout.tsx b/frontend/src/pages/settings/Timeout.tsx new file mode 100644 index 0000000..1401678 --- /dev/null +++ b/frontend/src/pages/settings/Timeout.tsx @@ -0,0 +1,8 @@ +/** + * 超时设置面板 — 数据任务超时配置。 + */ +import { JobTimeoutCard } from './JobTimeoutCard' + +export function SettingsTimeoutPanel() { + return +} diff --git a/gui-test-screenshots/price-alert-01-prefill.png b/gui-test-screenshots/price-alert-01-prefill.png new file mode 100644 index 0000000..95437cd Binary files /dev/null and b/gui-test-screenshots/price-alert-01-prefill.png differ diff --git a/gui-test-screenshots/price-alert-02-dashed-lines.png b/gui-test-screenshots/price-alert-02-dashed-lines.png new file mode 100644 index 0000000..8820145 Binary files /dev/null and b/gui-test-screenshots/price-alert-02-dashed-lines.png differ diff --git a/gui-test-screenshots/price-alert-03-cleanup.png b/gui-test-screenshots/price-alert-03-cleanup.png new file mode 100644 index 0000000..c9b9e5f Binary files /dev/null and b/gui-test-screenshots/price-alert-03-cleanup.png differ diff --git a/gui-test-screenshots/range-switch-01-before.png b/gui-test-screenshots/range-switch-01-before.png new file mode 100644 index 0000000..cb96fad Binary files /dev/null and b/gui-test-screenshots/range-switch-01-before.png differ diff --git a/gui-test-screenshots/range-switch-02-loading.png b/gui-test-screenshots/range-switch-02-loading.png new file mode 100644 index 0000000..d49cd72 Binary files /dev/null and b/gui-test-screenshots/range-switch-02-loading.png differ diff --git a/gui-test-screenshots/range-switch-03-after.png b/gui-test-screenshots/range-switch-03-after.png new file mode 100644 index 0000000..63a8ef6 Binary files /dev/null and b/gui-test-screenshots/range-switch-03-after.png differ