feat: migrate to CoinAnk API and improve chart UI

- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend
- Data source migration: use CoinAnk API exclusively for all kline data
- Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go)
- Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold
- Lighter integration: add order sync functionality, fix market order precision
- Remove ticker merge logic for simplicity
This commit is contained in:
tinkle-community
2025-12-26 00:58:12 +08:00
parent 54b24167a7
commit 1744e7f38e
38 changed files with 6498 additions and 964 deletions

View File

@@ -0,0 +1,401 @@
import { useEffect, useRef, useState } from 'react'
import {
createChart,
IChartApi,
ISeriesApi,
Time,
UTCTimestamp,
CandlestickSeries,
createSeriesMarkers,
} from 'lightweight-charts'
import { useLanguage } from '../contexts/LanguageContext'
import { httpClient } from '../lib/httpClient'
// 订单接口定义
interface OrderMarker {
time: number // Unix timestamp (seconds)
price: number
side: string // BUY, SELL
orderAction: string // OPEN_LONG, CLOSE_LONG, STOP_LOSS, TAKE_PROFIT, etc.
status: string // NEW, FILLED, CANCELED, etc.
symbol: string
}
// K线数据接口
interface KlineData {
time: UTCTimestamp
open: number
high: number
low: number
close: number
volume?: number
}
interface ChartWithOrdersProps {
symbol: string
interval?: string // 1m, 5m, 15m, 1h, 4h, 1d
traderID?: string // 用于获取该trader的订单
height?: number
exchange?: string // 交易所类型binance, bybit, okx, bitget, hyperliquid, aster, lighter
}
export function ChartWithOrders({
symbol = 'BTCUSDT',
interval = '5m',
traderID,
height = 500,
exchange = 'binance', // 默认使用 binance
}: ChartWithOrdersProps) {
const { language } = useLanguage()
const chartContainerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 解析时间:支持 Unix 时间戳(数字)或字符串格式
const parseCustomTime = (time: any): number => {
if (!time) {
console.warn('[ChartWithOrders] Empty time value')
return 0
}
// 如果已经是数字Unix 时间戳),直接返回
if (typeof time === 'number') {
console.log('[ChartWithOrders] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
return time
}
const timeStr = String(time)
console.log('[ChartWithOrders] Parsing time string:', timeStr)
// 尝试标准ISO格式
const isoTime = new Date(timeStr).getTime()
if (!isNaN(isoTime) && isoTime > 0) {
const timestamp = Math.floor(isoTime / 1000)
console.log('[ChartWithOrders] ✅ Parsed as ISO:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
return timestamp
}
// 解析自定义格式 "MM-DD HH:mm UTC" (兼容旧数据)
const match = timeStr.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/)
if (match) {
const currentYear = new Date().getFullYear()
const [_, month, day, hour, minute] = match
const date = new Date(Date.UTC(
currentYear,
parseInt(month) - 1,
parseInt(day),
parseInt(hour),
parseInt(minute)
))
const timestamp = Math.floor(date.getTime() / 1000)
console.log('[ChartWithOrders] ✅ Parsed as custom format:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
return timestamp
}
console.error('[ChartWithOrders] ❌ Failed to parse time:', timeStr)
return 0
}
// 从我们的服务获取K线数据
const fetchKlineData = async (symbol: string, interval: string): Promise<KlineData[]> => {
try {
const limit = 2000 // 获取最近2000根K线 (更多历史数据)
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
const result = await httpClient.get(klineUrl)
if (!result.success || !result.data) {
throw new Error('Failed to fetch kline data from our service')
}
const data = result.data
// 转换后端数据格式到 lightweight-charts 格式
// 后端返回的是 market.Kline 格式: {OpenTime, Open, High, Low, Close, Volume, ...}
return data.map((candle: any) => ({
time: Math.floor(candle.openTime / 1000) as UTCTimestamp, // 毫秒转秒
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
}))
} catch (err) {
console.error('Error fetching kline data:', err)
throw err
}
}
// 获取订单数据
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
try {
// 从后端 API 获取该 trader 的订单记录(只获取已成交的订单)
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
if (!result.success || !result.data) {
console.warn('Failed to fetch orders:', result.message)
return []
}
const orders = result.data
const markers: OrderMarker[] = []
// 转换订单数据为标记格式
orders.forEach((order: any) => {
const createdAt = order.created_at || order.CreatedAt
const filledAt = order.filled_at || order.FilledAt
const avgPrice = order.avg_fill_price || order.AvgFillPrice
const price = order.price || order.Price
const orderAction = order.order_action || order.OrderAction
const side = order.side || order.Side
const status = order.status || order.Status
const symbol = order.symbol || order.Symbol
// 使用成交时间(如果有)或创建时间
const orderTime = filledAt || createdAt
if (!orderTime) return
const timeSeconds = parseCustomTime(orderTime)
if (timeSeconds === 0) return
// 使用平均成交价(如果有)或订单价格
const orderPrice = avgPrice || price
if (!orderPrice || orderPrice === 0) return
markers.push({
time: timeSeconds,
price: orderPrice,
side: side || 'BUY',
orderAction: orderAction || 'UNKNOWN',
status: status || 'FILLED',
symbol: symbol || '',
})
})
console.log(`[ChartWithOrders] Loaded ${markers.length} order markers for ${symbol}`)
return markers
} catch (err) {
console.error('Error fetching orders:', err)
return []
}
}
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current) {
console.error('[ChartWithOrders] Container ref is null')
return
}
console.log('[ChartWithOrders] Initializing chart for', symbol, interval)
try {
// 创建图表
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: height,
layout: {
background: { color: '#0B0E11' },
textColor: '#EAECEF',
},
grid: {
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
},
crosshair: {
mode: 1, // Normal crosshair
},
rightPriceScale: {
borderColor: '#2B3139',
},
timeScale: {
borderColor: '#2B3139',
timeVisible: true,
secondsVisible: false,
},
})
chartRef.current = chart
// 创建K线系列 (使用 v5 API)
const candlestickSeries = chart.addSeries(CandlestickSeries, {
upColor: '#0ECB81',
downColor: '#F6465D',
borderUpColor: '#0ECB81',
borderDownColor: '#F6465D',
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
})
candlestickSeriesRef.current = candlestickSeries as any
// 响应式调整
const handleResize = () => {
if (chartContainerRef.current && chartRef.current) {
chartRef.current.applyOptions({
width: chartContainerRef.current.clientWidth,
})
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
}
} catch (err) {
console.error('[ChartWithOrders] Failed to initialize chart:', err)
setError('Failed to initialize chart')
}
}, [height])
// 加载数据
useEffect(() => {
const loadData = async () => {
if (!candlestickSeriesRef.current) {
console.log('[ChartWithOrders] Candlestick series not ready yet')
return
}
console.log('[ChartWithOrders] Loading data for', symbol, interval, 'trader:', traderID)
setLoading(true)
setError(null)
try {
// 1. 获取K线数据
console.log('[ChartWithOrders] Fetching kline data...')
const klineData = await fetchKlineData(symbol, interval)
console.log('[ChartWithOrders] Kline data received:', klineData.length, 'candles')
candlestickSeriesRef.current.setData(klineData)
// 2. 获取订单数据并添加标记
if (traderID) {
console.log('[ChartWithOrders] Fetching orders for trader:', traderID, 'symbol:', symbol)
const orders = await fetchOrders(traderID, symbol)
console.log('[ChartWithOrders] Received orders:', orders.length, 'orders')
if (orders.length === 0) {
console.log('[ChartWithOrders] No orders to display')
}
// 过滤掉无效时间戳的订单小于2024年的时间戳
const minValidTimestamp = new Date('2024-01-01').getTime() / 1000
const validOrders = orders.filter(order => {
if (order.time < minValidTimestamp) {
console.warn('[ChartWithOrders] ⚠️ Skipping order with invalid timestamp:', order.time, '(', new Date(order.time * 1000).toISOString(), ')')
return false
}
return true
})
console.log('[ChartWithOrders] Valid orders:', validOrders.length, 'out of', orders.length)
// 转换订单为图表标记 - 简洁版:只用 B/S
const markers = validOrders.map((order) => {
// 使用 side 字段判断买卖(更准确)
// side = BUY → 绿色 B
// side = SELL → 红色 S
const isBuy = order.side === 'BUY'
return {
time: order.time as Time,
position: 'belowBar' as const,
color: isBuy ? '#0ECB81' : '#F6465D',
shape: 'circle' as const,
text: isBuy ? 'B' : 'S',
price: order.price,
size: 1,
}
})
console.log('[ChartWithOrders] Setting', markers.length, 'markers on chart')
try {
// 使用 v5 API: createSeriesMarkers
if (seriesMarkersRef.current) {
// 如果已经存在,更新标记
seriesMarkersRef.current.setMarkers(markers)
} else {
// 首次创建标记
seriesMarkersRef.current = createSeriesMarkers(candlestickSeriesRef.current, markers)
}
console.log('[ChartWithOrders] ✅ Markers set successfully!')
} catch (err) {
console.error('[ChartWithOrders] ❌ Failed to set markers:', err)
}
}
// 自动适配视图
chartRef.current?.timeScale().fitContent()
setLoading(false)
} catch (err) {
console.error('Error loading chart data:', err)
setError(language === 'zh' ? '加载图表数据失败' : 'Failed to load chart data')
setLoading(false)
}
}
loadData()
// 自动刷新 - 每30秒更新一次K线数据
const refreshInterval = setInterval(() => {
loadData()
}, 30000) // 30秒
return () => {
clearInterval(refreshInterval)
}
}, [symbol, interval, traderID, language])
return (
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden' }}>
{/* 标题栏 */}
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
<div className="flex items-center gap-3">
<span className="text-xl">📈</span>
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{symbol} {interval}
</h3>
</div>
{loading && (
<div className="text-sm" style={{ color: '#848E9C' }}>
{language === 'zh' ? '加载中...' : 'Loading...'}
</div>
)}
</div>
{/* 图表容器 */}
<div ref={chartContainerRef} style={{ position: 'relative' }} />
{/* 错误提示 */}
{error && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
>
<div className="text-center">
<div className="text-2xl mb-2"></div>
<div style={{ color: '#F6465D' }}>{error}</div>
</div>
</div>
)}
{/* 图例说明 */}
<div className="flex items-center gap-4 p-4 text-xs" style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}>
<div className="flex items-center gap-2">
<span className="font-bold" style={{ color: '#0ECB81' }}>B</span>
<span>{language === 'zh' ? 'BUY (买入)' : 'BUY'}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-bold" style={{ color: '#F6465D' }}>S</span>
<span>{language === 'zh' ? 'SELL (卖出)' : 'SELL'}</span>
</div>
</div>
</div>
)
}