Files
tick-stock-panel/frontend/src/components/StockDailyKChart.tsx
T

266 lines
9.9 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { type KlineRow } from '@/lib/api'
import { klineDailyQueryOptions } from '@/lib/kline'
import { storage } from '@/lib/storage'
import {
EChartsCandlestick,
OVERLAY_INDICATORS,
SUB_CHARTS,
type ChartMarker,
type ChartPriceLine,
type ChartRange,
type OHLC,
type VolumeCompareConfig,
} from '@/components/EChartsCandlestick'
const SUB_INFO_H = 16
const SUB_GAP = 4
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))),
}
}
interface Props {
symbol: string
height?: number
className?: string
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showIndicatorControls?: boolean
showMarkerToggle?: boolean
showMA?: boolean
showInfoBar?: boolean
visibleBars?: number
linkedPrice?: number | null
onDateClick?: (date: string) => void
onPriceDoubleClick?: (price: number, currentPrice: number) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
}
function isValidRow(r: any): boolean {
return r && r.date != null && r.open != null && r.close != null
}
export function toOHLC(rows: KlineRow[]): OHLC[] {
return rows
.filter(isValidRow)
.map(r => ({
date: typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date),
open: Number(r.open),
high: Number(r.high),
low: Number(r.low),
close: Number(r.close),
volume: Number(r.volume ?? 0),
ma5: r.ma5 != null ? Number(r.ma5) : null,
ma10: r.ma10 != null ? Number(r.ma10) : null,
ma20: r.ma20 != null ? Number(r.ma20) : null,
ma60: r.ma60 != null ? Number(r.ma60) : null,
macd_dif: r.macd_dif != null ? Number(r.macd_dif) : null,
macd_dea: r.macd_dea != null ? Number(r.macd_dea) : null,
macd_hist: r.macd_hist != null ? Number(r.macd_hist) : null,
rsi_6: r.rsi_6 != null ? Number(r.rsi_6) : null,
rsi_14: r.rsi_14 != null ? Number(r.rsi_14) : null,
rsi_24: r.rsi_24 != null ? Number(r.rsi_24) : null,
kdj_k: r.kdj_k != null ? Number(r.kdj_k) : null,
kdj_d: r.kdj_d != null ? Number(r.kdj_d) : null,
kdj_j: r.kdj_j != null ? Number(r.kdj_j) : null,
boll_upper: r.boll_upper != null ? Number(r.boll_upper) : null,
boll_lower: r.boll_lower != null ? Number(r.boll_lower) : null,
}))
}
function buildLimitUpMarkers(rows: KlineRow[]): ChartMarker[] {
const markers: ChartMarker[] = []
for (const r of rows) {
const date = typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)
if (r.signal_broken_limit_up) {
markers.push({ date, kind: 'neutral', above: true, color: '#8B5CF6', label: '炸' })
} else if (r.signal_limit_up) {
const boards: number = r.consecutive_limit_ups ?? 1
markers.push({ date, kind: 'buy', above: true, color: '#FACC15', label: boards <= 1 ? '板' : String(boards) })
}
}
return markers
}
export function getDefaultRange(): { start: string; end: string } {
const now = new Date()
const end = now.toISOString().slice(0, 10)
const s = new Date(now)
s.setMonth(s.getMonth() - 6)
const start = s.toISOString().slice(0, 10)
return { start, end }
}
export function StockDailyKChart({
symbol,
height = 520,
className,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showIndicatorControls = true,
showMarkerToggle = true,
showMA = true,
showInfoBar = true,
visibleBars = 60,
linkedPrice,
onDateClick,
onPriceDoubleClick,
extColumns,
}: 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()
// 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
const limitMarkers = useMemo(() => buildLimitUpMarkers(kline.data?.rows ?? []), [kline.data?.rows])
const allMarkers = useMemo(() => [
...(markers ?? []),
...(showLimitMarkers ? limitMarkers : []),
], [limitMarkers, markers, showLimitMarkers])
const toggleIndicator = useCallback((key: string) => {
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)
let subExtraH = 0
activeSubDefs.forEach(def => { subExtraH += SUB_INFO_H + def.height })
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
const chartHeight = height + subExtraH
if (!symbol) return null
return (
<div className={className} style={{ minHeight: chartHeight }}>
{showIndicatorControls && rows.length > 0 && (
<div className="flex items-center gap-1.5 px-1 pb-0.5">
{SUB_CHARTS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{ind.label}
</button>
))}
{OVERLAY_INDICATORS.map(ind => (
<button
key={ind.key}
onClick={() => toggleIndicator(ind.key)}
className={`px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
activeIndicators.includes(ind.key)
? 'bg-accent/20 text-accent'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
{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 left-0 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)}
className={`ml-auto px-2 py-0.5 rounded text-[10px] font-mono cursor-pointer transition-colors ${
showMarkers
? 'text-[#FACC15] bg-[#FACC15]/10'
: 'bg-elevated text-muted hover:text-secondary'
}`}
>
异动
</button>
)}
</div>
)}
{kline.isLoading && <div className="text-sm text-muted py-4">加载中…</div>}
{kline.isError && <div className="text-sm text-danger py-2">K加载失败</div>}
{!kline.isLoading && !kline.isError && (kline.data?.rows?.length ?? 0) > 0 && rows.length === 0 && (
<div className="text-sm text-danger py-2">数据格式异常,请刷新页面</div>
)}
{rows.length > 0 && (
<EChartsCandlestick
data={rows}
markers={allMarkers}
ranges={ranges}
priceLines={priceLines}
height={chartHeight - 22}
showMA={showMA}
showInfoBar={showInfoBar}
showMarkers={showMarkers}
stockInfo={stockInfo}
symbol={symbol}
linkedPrice={linkedPrice}
onDateClick={onDateClick}
onPriceDoubleClick={onPriceDoubleClick}
visibleBars={visibleBars}
activeIndicators={activeIndicators}
volumeCompare={volumeCompare}
/>
)}
</div>
)
}