From ff12790042461495ee13760c61bb7b6afd08bc2b Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sat, 18 Jul 2026 17:46:58 +0800 Subject: [PATCH] feat: add trading-day volume comparison --- .../src/components/EChartsCandlestick.tsx | 117 ++++++++++++++---- frontend/src/components/StockDailyKChart.tsx | 53 ++++++++ frontend/src/lib/storage.ts | 3 + 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 20b8daa..269b26e 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -60,6 +60,16 @@ export interface StockInfo { ext?: Record } +export interface VolumeCompareConfig { + enabled: boolean + days: number +} + +interface SubChartContext { + compact: boolean + volumeCompare: VolumeCompareConfig +} + /** 子图定义 */ export interface SubChartDef { key: string @@ -67,7 +77,7 @@ export interface SubChartDef { /** 子图固定高度 px */ height: number /** 构建 series 数组 */ - buildSeries: (data: OHLC[]) => any[] + buildSeries: (data: OHLC[], context: SubChartContext) => any[] /** 构建信息栏文字 (当前数据行 -> 显示内容) */ buildInfo: (d: OHLC | null) => { label: string; color: string; value: string }[] /** Y 轴特殊配置 */ @@ -93,26 +103,60 @@ function fmtVol(v: number | null | undefined): string { return v.toFixed(0) } +function volumeRatioAt(data: OHLC[], index: number, days: number): number | null { + const window = Math.max(1, Math.min(20, Math.round(days))) + if (index < window) return null + let sum = 0 + for (let offset = 1; offset <= window; offset++) { + const volume = data[index - offset]?.volume + if (volume == null || !Number.isFinite(volume)) return null + sum += volume + } + const average = sum / window + const current = data[index]?.volume + if (current == null || !Number.isFinite(current) || average <= 0) return null + return current / average +} + +function fmtVolumeRatio(value: number | null, digits = 2): string { + return value == null ? '—' : `${value.toFixed(digits)}x` +} + export const SUB_CHARTS: SubChartDef[] = [ { key: 'vol', label: '成交量', height: 84, yAxisConfig: { min: 0 }, - buildSeries: (data) => { + buildSeries: (data, context) => { const ma5Data = volMaN(data, 5) const ma10Data = volMaN(data, 10) + const compareDays = context.volumeCompare.days return [ { name: '成交量', type: 'bar', - data: data.map(d => ({ - value: d.volume ?? 0, - itemStyle: { - color: d.close >= d.open ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)', - }, - })), + data: data.map((d, index) => { + const ratio = volumeRatioAt(data, index, compareDays) + return { + value: d.volume ?? 0, + volumeRatioLabel: ratio == null ? '' : fmtVolumeRatio(ratio, 1), + itemStyle: { + color: d.close >= d.open ? 'rgba(240,68,56,0.6)' : 'rgba(18,183,106,0.6)', + }, + } + }), barWidth: '60%', + label: { + show: context.volumeCompare.enabled && !context.compact, + position: 'top', + distance: 2, + color: CT().text, + fontSize: 8, + fontFamily: 'JetBrains Mono, monospace', + formatter: (params: any) => params.data?.volumeRatioLabel ?? '', + }, + labelLayout: { hideOverlap: true }, animation: false, }, { @@ -292,6 +336,8 @@ interface Props { visibleBars?: number /** 已激活的子图 key 列表 (含 vol, 按点击顺序) */ activeIndicators?: string[] + /** 成交量柱相对前 N 个交易日均量的显示设置 */ + volumeCompare?: VolumeCompareConfig } // 序列颜色 (双主题通用); 画布轴/网格/文字等主题相关色走 CT() 动态取 @@ -323,6 +369,7 @@ function buildSubInfoGraphics( infoIdx: number, activeIndicators: string[], subStartTop: number, + volumeCompare: VolumeCompareConfig, ): any[] { const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null const graphics: any[] = [] @@ -344,6 +391,14 @@ function buildSubInfoGraphics( const vol10 = calcVolMa(10) items.push({ label: 'VOL5', color: '#FACC15', value: fmtVol(vol5) }) items.push({ label: 'VOL10', color: '#8B5CF6', value: fmtVol(vol10) }) + if (volumeCompare.enabled) { + const ratio = volumeRatioAt(data, infoIdx, volumeCompare.days) + items.push({ + label: `量比${volumeCompare.days}`, + color: ratio != null && ratio >= 1 ? '#C74040' : '#2D9B65', + value: fmtVolumeRatio(ratio), + }) + } } // 每个元素加固定 id,确保 ECharts 增量更新时能正确匹配 @@ -416,6 +471,7 @@ function buildOption( containerHeight: number, infoIdx: number, linkedPrice: number | null | undefined, + volumeCompare: VolumeCompareConfig, ): EChartsOption { const candleData = data.map(d => [d.open, d.close, d.low, d.high]) @@ -671,7 +727,7 @@ function buildOption( xAxisIndices.push(xAxisIdx) - const subSeries = def.buildSeries(data) + const subSeries = def.buildSeries(data, { compact, volumeCompare }) subSeries.forEach((s: any) => { series.push({ ...s, xAxisIndex: xAxisIdx, yAxisIndex: yAxisIdx }) }) @@ -681,7 +737,7 @@ function buildOption( // 子图信息栏 graphic const subStartTop = topPad + candleAvail + candleBottomPad - const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop) + const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop, volumeCompare) return { animation: false, @@ -737,6 +793,7 @@ export function EChartsCandlestick({ onDateClick, visibleBars = 60, activeIndicators = [], + volumeCompare = { enabled: true, days: 1 }, }: Props) { const containerRef = useRef(null) const chartRef = useRef(null) @@ -755,6 +812,8 @@ export function EChartsCandlestick({ // 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值 const activeIndicatorsRef = useRef(activeIndicators) activeIndicatorsRef.current = activeIndicators + const volumeCompareRef = useRef(volumeCompare) + volumeCompareRef.current = volumeCompare const chartHeightRef = useRef(300) const subTotalHRef = useRef(0) const getInfoBarHTMLRef = useRef<() => string>(() => '') @@ -769,7 +828,13 @@ export function EChartsCandlestick({ const chart = chartRef.current if (!chart) return const subStartTop = chartHeightRef.current - subTotalHRef.current - const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop) + const infoGraphics = buildSubInfoGraphics( + curData, + idx, + activeIndicatorsRef.current, + subStartTop, + volumeCompareRef.current, + ) if (infoGraphics.length > 0) { chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true }) } @@ -931,9 +996,7 @@ export function EChartsCandlestick({ const newCompact = visibleCount > COMPACT_THRESHOLD if (newCompact !== compactRef.current) { compactRef.current = newCompact - // compact 变了需要更新 markPoint,但只更新 markPoint series - // 通过 dispatch 自定义事件来增量更新 - updateMarkPoints() + updateCompactPresentation() } }) @@ -950,15 +1013,15 @@ export function EChartsCandlestick({ } }, [chartHeight]) // eslint-disable-line react-hooks/exhaustive-deps - // 增量更新 markPoint (compact 切换时) - function updateMarkPoints() { + // 缩放跨过紧凑阈值时,仅增量更新标签,不重建整张图。 + function updateCompactPresentation() { const chart = chartRef.current if (!chart) return const mkrs = showMarkersProp ? markers : undefined - if (!mkrs || mkrs.length === 0) return const compact = compactRef.current + const seriesUpdates: any[] = [] const markPointData: any[] = [] - for (const m of mkrs) { + for (const m of mkrs ?? []) { const idx = dateIndexMap.get(m.date) if (idx == null) continue const d = data[idx] @@ -1003,12 +1066,19 @@ export function EChartsCandlestick({ }) } } - chart.setOption({ - series: [{ + if (mkrs?.length) { + seriesUpdates.push({ name: 'K', markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined, - }] - }) + }) + } + if (activeIndicatorsRef.current.includes('vol')) { + seriesUpdates.push({ + name: '成交量', + label: { show: volumeCompareRef.current.enabled && !compact }, + }) + } + if (seriesUpdates.length > 0) chart.setOption({ series: seriesUpdates }) } // ===== 核心: 仅在数据/配置变更时全量 setOption ===== @@ -1025,6 +1095,7 @@ export function EChartsCandlestick({ activeIndicators, chartHeight, infoIdxRef.current, linkedPrice, + volumeCompare, ) chart.setOption(option, true) @@ -1042,7 +1113,7 @@ export function EChartsCandlestick({ if (infoEl) { infoEl.innerHTML = getInfoBarHTML() } - }, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme]) + }, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, volumeCompare, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme]) // 渲染信息栏容器 (内容由 JS 直接写入) const initialHTML = useMemo(() => { diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 5a57487..032059d 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { api, type KlineRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { storage } from '@/lib/storage' import { EChartsCandlestick, OVERLAY_INDICATORS, @@ -11,11 +12,20 @@ import { type ChartRange, type OHLC, type StockInfo, + type VolumeCompareConfig, } from '@/components/EChartsCandlestick' const SUB_INFO_H = 16 const SUB_GAP = 4 const MAX_DAYS = 2000 +const DEFAULT_VOLUME_COMPARE: VolumeCompareConfig = { enabled: true, days: 1 } + +function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfig { + return { + enabled: config.enabled !== false, + days: Math.max(1, Math.min(20, Math.round(Number(config.days) || 1))), + } +} export interface StockDailyKChartResult { rows: OHLC[] @@ -127,6 +137,9 @@ export function StockDailyKChart({ }: Props) { const [activeIndicators, setActiveIndicators] = useState(['vol']) const [showMarkers, setShowMarkers] = useState(true) + const [volumeCompare, setVolumeCompare] = useState(() => + normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)), + ) const dateRange = externalDateRange ?? getDefaultRange() const days = useMemo(() => rangeDays(dateRange), [dateRange]) @@ -150,6 +163,14 @@ export function StockDailyKChart({ setActiveIndicators(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]) }, []) + const updateVolumeCompare = useCallback((patch: Partial) => { + setVolumeCompare(prev => { + const next = normalizeVolumeCompare({ ...prev, ...patch }) + storage.stockVolumeCompare.set(next) + return next + }) + }, []) + const activeSubDefs = activeIndicators .map(key => SUB_CHARTS.find(s => s.key === key)) .filter((d): d is typeof SUB_CHARTS[number] => !!d) @@ -194,6 +215,37 @@ export function StockDailyKChart({ {ind.label} ))} + {activeIndicators.includes('vol') && ( +
+ 量比 + + +
+ )} {showMarkerToggle && showLimitMarkers && (