| |
|
|
| import React, { useEffect, useRef } from 'react'; |
| import { createChart, ColorType } from 'lightweight-charts'; |
| import type { IChartApi, SeriesMarker, UTCTimestamp } from 'lightweight-charts'; |
| import type { LedgerItem } from './LedgerTable'; |
|
|
| interface CandleData { |
| time: number; |
| open: number; |
| high: number; |
| low: number; |
| close: number; |
| volume: number; |
| vwap: number | null; |
| ema_9: number | null; |
| ema_21: number | null; |
| ema_50: number | null; |
| } |
|
|
| interface IntradayZoomChartProps { |
| candles: CandleData[]; |
| tradeItem: LedgerItem; |
| onClose: () => void; |
| } |
|
|
| export const IntradayZoomChart: React.FC<IntradayZoomChartProps> = ({ candles, tradeItem, onClose }) => { |
| const chartContainerRef = useRef<HTMLDivElement>(null); |
| const chartRef = useRef<IChartApi | null>(null); |
| const [renderError, setRenderError] = React.useState<string | null>(null); |
|
|
| useEffect(() => { |
| if (!chartContainerRef.current || candles.length === 0) return; |
| setRenderError(null); |
|
|
| let chart: IChartApi | null = null; |
| const handleResize = () => { |
| if (chartContainerRef.current && chartRef.current) { |
| chartRef.current.applyOptions({ |
| width: chartContainerRef.current.clientWidth, |
| }); |
| } |
| }; |
|
|
| try { |
| |
| chart = createChart(chartContainerRef.current, { |
| width: chartContainerRef.current.clientWidth, |
| height: 350, |
| layout: { |
| background: { type: ColorType.Solid, color: '#09090b' }, |
| textColor: '#8e8e93', |
| }, |
| grid: { |
| vertLines: { visible: false }, |
| horzLines: { visible: false }, |
| }, |
| timeScale: { |
| timeVisible: true, |
| secondsVisible: false, |
| borderVisible: false, |
| }, |
| rightPriceScale: { |
| borderVisible: false, |
| }, |
| }); |
|
|
| chartRef.current = chart; |
|
|
| |
| const candlestickSeries = chart.addCandlestickSeries({ |
| upColor: '#00c805', |
| downColor: '#ff3b30', |
| borderDownColor: '#ff3b30', |
| borderUpColor: '#00c805', |
| wickDownColor: '#ff3b30', |
| wickUpColor: '#00c805', |
| }); |
|
|
| |
| const vwapSeries = chart.addLineSeries({ |
| color: '#ffd700', |
| lineWidth: 2, |
| title: 'VWAP', |
| priceLineVisible: false, |
| }); |
|
|
| const ema9Series = chart.addLineSeries({ |
| color: '#2196f3', |
| lineWidth: 1, |
| title: 'EMA 9', |
| priceLineVisible: false, |
| }); |
|
|
| |
| const candleData = candles.map((c) => ({ |
| time: c.time as UTCTimestamp, |
| open: c.open, |
| high: c.high, |
| low: c.low, |
| close: c.close, |
| })); |
| candlestickSeries.setData(candleData); |
|
|
| const vwapData = candles |
| .filter((c) => c.vwap !== null) |
| .map((c) => ({ time: c.time as UTCTimestamp, value: c.vwap as number })); |
| vwapSeries.setData(vwapData); |
|
|
| const ema9Data = candles |
| .filter((c) => c.ema_9 !== null) |
| .map((c) => ({ time: c.time as UTCTimestamp, value: c.ema_9 as number })); |
| ema9Series.setData(ema9Data); |
|
|
| |
| |
| let markerTime: UTCTimestamp = candles[0].time as UTCTimestamp; |
| |
| |
| |
| for (const c of candles) { |
| const dateObj = new Date(c.time * 1000); |
| |
| |
| |
| |
| |
| const hour = dateObj.getHours(); |
| const min = dateObj.getMinutes(); |
| if (hour === 9 && min === 30) { |
| markerTime = c.time as UTCTimestamp; |
| break; |
| } |
| } |
|
|
| const pnl = tradeItem.realized_pnl; |
| const isBuy = tradeItem.action === 'BUY'; |
| const color = isBuy ? '#00c805' : (pnl !== undefined && pnl < 0 ? '#ff3b30' : '#00c805'); |
| const shape = isBuy ? 'arrowUp' : 'arrowDown'; |
| const position = isBuy ? 'belowBar' : 'aboveBar'; |
| const text = isBuy |
| ? `BUY ${tradeItem.shares}股 @ ${tradeItem.execution_price.toFixed(2)}` |
| : `SELL ${tradeItem.shares}股 @ ${tradeItem.execution_price.toFixed(2)} (${pnl !== undefined && pnl >= 0 ? '+' : ''}${pnl?.toFixed(2)})`; |
|
|
| const chartMarkers: SeriesMarker<UTCTimestamp>[] = [{ |
| time: markerTime, |
| position: position as any, |
| color: color, |
| shape: shape as any, |
| text: text, |
| size: 1.5, |
| }]; |
| candlestickSeries.setMarkers(chartMarkers); |
|
|
| |
| chart.timeScale().fitContent(); |
|
|
| window.addEventListener('resize', handleResize); |
| } catch (e: any) { |
| console.error("Intraday chart rendering error:", e); |
| setRenderError(e.message || String(e)); |
| } |
|
|
| return () => { |
| window.removeEventListener('resize', handleResize); |
| if (chart) { |
| chart.remove(); |
| } |
| }; |
| }, [candles, tradeItem]); |
|
|
| if (renderError) { |
| return ( |
| <div className="loader-container" style={{ color: 'var(--color-red)', padding: '1rem', border: '1px dashed var(--color-border)' }}> |
| <h4 style={{ margin: 0 }}>日内图表加载错误</h4> |
| <div style={{ fontSize: '0.8rem', fontFamily: 'monospace' }}>{renderError}</div> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="card" style={{ marginTop: '1.5rem', border: '1px solid rgba(0, 200, 5, 0.3)', background: '#09090b' }}> |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}> |
| <div> |
| <h3 className="card-title" style={{ margin: 0 }}>🔍 日内交易 1分钟微观透视 (Intraday Trade Inspector)</h3> |
| <span style={{ fontSize: '0.78rem', color: 'var(--color-text-secondary)' }}> |
| 标的:<strong style={{ color: '#fff' }}>{tradeItem.ticker}</strong> | |
| 交易日期:<strong style={{ color: '#fff' }}>{tradeItem.timestamp.split(' ')[0]}</strong> | |
| 成交动作:<strong style={{ color: tradeItem.action === 'BUY' ? 'var(--color-green)' : 'var(--color-red)' }}> |
| {tradeItem.action === 'BUY' ? '买入' : '平仓'} |
| </strong> |
| </span> |
| </div> |
| <button |
| onClick={onClose} |
| style={{ |
| background: 'rgba(255,255,255,0.06)', |
| border: 'none', |
| color: '#fff', |
| borderRadius: '50%', |
| width: '26px', |
| height: '26px', |
| cursor: 'pointer', |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| fontSize: '0.9rem' |
| }} |
| > |
| ✕ |
| </button> |
| </div> |
| |
| <div style={{ position: 'relative', width: '100%' }}> |
| <div |
| style={{ |
| position: 'absolute', |
| top: '8px', |
| left: '10px', |
| zIndex: 10, |
| display: 'flex', |
| gap: '12px', |
| fontSize: '0.75rem', |
| fontWeight: 600, |
| pointerEvents: 'none', |
| }} |
| > |
| <span style={{ color: '#ffd700' }}>● VWAP</span> |
| <span style={{ color: '#2196f3' }}>● EMA 9</span> |
| </div> |
| <div ref={chartContainerRef} style={{ width: '100%', height: '350px' }} /> |
| </div> |
| </div> |
| ); |
| }; |
|
|