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, SUB_CHARTS, type ChartMarker, type ChartPriceLine, 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[] rawRows: KlineRow[] stockInfo?: StockInfo name?: string } 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 onDataChange?: (result: StockDailyKChartResult) => 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 } } function rangeDays(range: { start: string; end: string }): number { const start = new Date(range.start) const end = new Date(range.end) return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS) } 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, onDataChange, extColumns, }: 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]) // extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数) const kline = useQuery({ queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns), queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns), enabled: !!symbol, placeholderData: (prev) => prev, }) 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) => { 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 useEffect(() => { onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name }) }, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo]) if (!symbol) return null return (
{showIndicatorControls && rows.length > 0 && (
{SUB_CHARTS.map(ind => ( ))} {OVERLAY_INDICATORS.map(ind => ( ))} {activeIndicators.includes('vol') && (
量比
)} {showMarkerToggle && showLimitMarkers && ( )}
)} {kline.isLoading &&
加载中…
} {kline.isError &&
日K加载失败
} {!kline.isLoading && !kline.isError && (kline.data?.rows?.length ?? 0) > 0 && rows.length === 0 && (
数据格式异常,请刷新页面
)} {rows.length > 0 && ( )}
) }