import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Activity, Loader2, Lock, RefreshCw, Search } from 'lucide-react' import { api, type IndexInstrument, type KlineRow, type MinuteKlineRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useCapabilities } from '@/lib/useSharedQueries' import { EChartsCandlestick, type OHLC } from '@/components/EChartsCandlestick' import { EChartsIntraday } from '@/components/EChartsIntraday' function defaultRange() { const now = new Date() const end = now.toISOString().slice(0, 10) const s = new Date(now) s.setMonth(s.getMonth() - 6) return { start: s.toISOString().slice(0, 10), end } } function toOHLC(rows: KlineRow[]): OHLC[] { return rows .filter(r => r?.date != null && r.open != null && r.close != null) .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 fmtPct(v: number | null | undefined) { if (v == null || Number.isNaN(Number(v))) return '--' return `${Number(v).toFixed(2)}%` } function fmtNum(v: number | null | undefined, digits = 2) { if (v == null || Number.isNaN(Number(v))) return '--' return Number(v).toFixed(digits) } const PINNED_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, { symbol: '399001.SZ', name: '深证成指' }, { symbol: '399006.SZ', name: '创业板指' }, { symbol: '000680.SH', name: '科创综指' }, ] function pinnedRank(item: IndexInstrument) { return PINNED_INDEXES.findIndex(p => item.symbol === p.symbol || item.name === p.name) } export function Indices() { const qc = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const [keyword, setKeyword] = useState('') const symbolParam = searchParams.get('symbol') ?? '' const [selected, setSelected] = useState(symbolParam) const [range, setRange] = useState(defaultRange) const [selectedDate, setSelectedDate] = useState(null) const [linkedPrice, setLinkedPrice] = useState(null) // 分时数据需 Pro+ (kline.minute.batch) 能力 const caps = useCapabilities() const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch'] const list = useQuery({ queryKey: QK.indexList, queryFn: api.indexList, }) const search = useQuery({ queryKey: ['index-search', keyword], queryFn: () => api.indexSearch(keyword, 50), enabled: keyword.trim().length > 0, }) const rows: IndexInstrument[] = keyword.trim() ? (search.data?.results ?? []) : (list.data?.results ?? []) const topRows = useMemo(() => { const all = list.data?.results ?? [] return PINNED_INDEXES.map(p => ( all.find(item => item.symbol === p.symbol || item.name === p.name) ?? { symbol: p.symbol, name: p.name, asset_type: 'index' as const } )) }, [list.data?.results]) const listRows = useMemo(() => rows.filter(item => pinnedRank(item) < 0), [rows]) const selectedSymbol = selected || topRows[0]?.symbol || listRows[0]?.symbol || '' useEffect(() => { if (symbolParam && symbolParam !== selected) setSelected(symbolParam) }, [selected, symbolParam]) const selectIndex = (symbol: string) => { setSelected(symbol) setSearchParams({ symbol }) } const quotes = useQuery({ queryKey: QK.indexQuotes, queryFn: () => api.indexQuotes(), placeholderData: (prev) => prev, }) const daily = useQuery({ queryKey: QK.indexDaily(selectedSymbol, range.start, range.end), queryFn: () => api.indexDaily(selectedSymbol, 180, range), enabled: !!selectedSymbol, placeholderData: (prev) => prev, }) const minute = useQuery({ queryKey: QK.indexMinute(selectedSymbol, selectedDate ?? ''), queryFn: () => api.indexMinute(selectedSymbol, selectedDate ?? undefined), enabled: !!selectedSymbol && !!selectedDate && hasMinuteCap, placeholderData: (prev) => prev, }) const syncInstruments = useMutation({ mutationFn: api.syncIndexInstruments, onSuccess: () => { qc.invalidateQueries({ queryKey: QK.indexList }) qc.invalidateQueries({ queryKey: QK.indexQuotes }) }, }) const syncDaily = useMutation({ mutationFn: () => api.syncIndexDaily(365), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.indexList }) qc.invalidateQueries({ queryKey: QK.indexQuotes }) qc.invalidateQueries({ queryKey: ['index-daily'] }) }, }) const quoteBySymbol = useMemo(() => { const m = new Map() for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q) return m }, [quotes.data?.rows]) const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows]) const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? [] const selectedIdx = selectedDate ? chartRows.findIndex(r => r.date === selectedDate) : -1 const prevClose = selectedIdx > 0 ? chartRows[selectedIdx - 1].close : chartRows.length >= 2 ? chartRows[chartRows.length - 2].close : undefined useEffect(() => { setSelectedDate(null) setLinkedPrice(null) }, [selectedSymbol]) useEffect(() => { if ((!selectedDate || !chartRows.some(r => r.date === selectedDate)) && chartRows.length > 0 && daily.data?.symbol === selectedSymbol) { setSelectedDate(chartRows[chartRows.length - 1].date) } }, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol]) const renderIndexItem = (item: IndexInstrument) => { const q = quoteBySymbol.get(item.symbol) const pct = q?.change_pct ?? q?.pct const current = q?.last_price ?? q?.price ?? q?.close const active = item.symbol === selectedSymbol return ( ) } return (

指数

指数使用独立 kline_index_* parquet,不进入股票选股和策略链路。

{selectedInfo?.name || selectedSymbol || '未选择指数'}

{selectedSymbol && {selectedSymbol}} {selectedSymbol && {fmtNum(selectedQuoteValue)}} {selectedSymbol && = 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}}
实时缓存 {quotes.data?.count ?? 0} 只指数 · 日K来源 {daily.data?.source ?? '--'}
setRange(r => ({ ...r, start: e.target.value }))} className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent" /> setRange(r => ({ ...r, end: e.target.value }))} className="rounded-btn border border-border bg-base px-2 py-1 text-secondary outline-none focus:border-accent" />
{daily.isLoading &&
日K加载中…
} {daily.isError &&
指数日K加载失败
} {!daily.isLoading && !daily.isError && chartRows.length === 0 && (
暂无日K数据。可以先同步指数日K,或选择其他指数。
)} {chartRows.length > 0 && (
{!hasMinuteCap ? (
分时数据权限需 Pro+
升级套餐后可查看指数分时走势
) : ( <> {minute.isLoading &&
分时加载中…
} {!minute.isLoading && minuteRows.length === 0 && (
暂无分时数据
)} {minuteRows.length > 0 && ( )} )}
)}
) }