feat: add trading-day volume comparison

This commit is contained in:
shy3130
2026-07-18 17:46:58 +08:00
parent 44ab51e65d
commit ff12790042
3 changed files with 150 additions and 23 deletions
+94 -23
View File
@@ -60,6 +60,16 @@ export interface StockInfo {
ext?: Record<string, unknown>
}
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<HTMLDivElement>(null)
const chartRef = useRef<ECharts | null>(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(() => {
@@ -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<string[]>(['vol'])
const [showMarkers, setShowMarkers] = useState(true)
const [volumeCompare, setVolumeCompare] = useState<VolumeCompareConfig>(() =>
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<VolumeCompareConfig>) => {
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}
</button>
))}
{activeIndicators.includes('vol') && (
<div className="ml-0.5 flex h-5 items-center gap-1.5 border-l border-border/70 pl-2">
<span className="text-[10px] text-muted"></span>
<button
type="button"
role="switch"
aria-checked={volumeCompare.enabled}
aria-label="开启量能对比"
title={volumeCompare.enabled ? '关闭量能对比' : '开启量能对比'}
onClick={() => updateVolumeCompare({ enabled: !volumeCompare.enabled })}
className={`relative h-3.5 w-6 shrink-0 rounded-full transition-colors ${
volumeCompare.enabled ? 'bg-accent' : 'bg-elevated'
}`}
>
<span className={`absolute top-0.5 h-2.5 w-2.5 rounded-full bg-white transition-transform ${
volumeCompare.enabled ? 'translate-x-3' : 'translate-x-0.5'
}`} />
</button>
<select
aria-label="量能对比周期"
value={volumeCompare.days}
disabled={!volumeCompare.enabled}
onChange={event => updateVolumeCompare({ days: Number(event.target.value) })}
className="h-5 rounded border border-border bg-base px-1 text-[10px] text-secondary outline-none disabled:opacity-40"
>
{Array.from({ length: 20 }, (_, index) => index + 1).map(days => (
<option key={days} value={days}>{days}</option>
))}
</select>
</div>
)}
{showMarkerToggle && showLimitMarkers && (
<button
onClick={() => setShowMarkers(v => !v)}
@@ -229,6 +281,7 @@ export function StockDailyKChart({
onDateClick={onDateClick}
visibleBars={visibleBars}
activeIndicators={activeIndicators}
volumeCompare={volumeCompare}
/>
)}
</div>
+3
View File
@@ -33,6 +33,9 @@ export const storage = {
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 个股日K成交量对比设置 */
stockVolumeCompare: kv<{ enabled: boolean; days: number }>('stock_volume_compare'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),