mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: add trading-day volume comparison
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user