feat: add pending orders (SL/TP) display on chart

- Add GetOpenOrders method to Trader interface
- Implement for Binance (legacy + Algo), Bybit, Hyperliquid
- Add stub implementations for OKX, Bitget, Aster, Lighter
- Add /api/open-orders endpoint
- Display price lines for SL (red) and TP (green) orders
- Refresh open orders every 60 seconds (separate from 5s kline refresh)
This commit is contained in:
tinkle-community
2026-01-07 00:50:29 +08:00
parent 5e65ae7077
commit b36ab27b65
11 changed files with 349 additions and 0 deletions

View File

@@ -202,6 +202,7 @@ func (s *Server) setupRoutes() {
protected.GET("/trades", s.handleTrades)
protected.GET("/orders", s.handleOrders) // Order list (all orders)
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
protected.GET("/decisions", s.handleDecisions)
protected.GET("/decisions/latest", s.handleLatestDecisions)
protected.GET("/statistics", s.handleStatistics)
@@ -2341,6 +2342,40 @@ func (s *Server) handleOrderFills(c *gin.Context) {
c.JSON(http.StatusOK, fills)
}
// handleOpenOrders Get open orders (pending SL/TP) from exchange
func (s *Server) handleOpenOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get symbol parameter (required for exchange query)
symbol := c.Query("symbol")
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
return
}
// Normalize symbol
symbol = market.Normalize(symbol)
// Get open orders from exchange
openOrders, err := trader.GetOpenOrders(symbol)
if err != nil {
SafeInternalError(c, "Get open orders", err)
return
}
c.JSON(http.StatusOK, openOrders)
}
// handleKlines K-line data (supports multiple exchanges via coinank)
func (s *Server) handleKlines(c *gin.Context) {
// Get query parameters

View File

@@ -1414,3 +1414,9 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
return result, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Aster open orders
return []OpenOrder{}, nil
}

View File

@@ -2218,3 +2218,8 @@ func getSideFromAction(action string) string {
}
}
// GetOpenOrders returns open orders (pending SL/TP) from exchange
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return at.trader.GetOpenOrders(symbol)
}

View File

@@ -776,6 +776,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
return nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Type: string(order.Type),
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: string(order.Status),
})
}
// 2. Get Algo orders (new API for stop-loss/take-profit)
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
PositionSide: string(algoOrder.PositionSide),
Type: string(algoOrder.OrderType),
Price: 0, // Algo orders use stop price
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// GetMarketPrice gets market price
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())

View File

@@ -1096,3 +1096,9 @@ func genBitgetClientOid() string {
rand := time.Now().Nanosecond() % 100000
return fmt.Sprintf("nofx%d%05d", timestamp, rand)
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Bitget open orders
return []OpenOrder{}, nil
}

View File

@@ -1044,3 +1044,64 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
return records, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderFilter": "StopOrder",
}
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
if resp.RetCode == 0 {
resultData, ok := resp.Result.(map[string]interface{})
if ok {
list, _ := resultData["list"].([]interface{})
for _, item := range list {
order, ok := item.(map[string]interface{})
if !ok {
continue
}
orderId, _ := order["orderId"].(string)
sym, _ := order["symbol"].(string)
side, _ := order["side"].(string)
orderType, _ := order["orderType"].(string)
stopOrderType, _ := order["stopOrderType"].(string)
triggerPrice, _ := order["triggerPrice"].(string)
qty, _ := order["qty"].(string)
price, _ := strconv.ParseFloat(triggerPrice, 64)
quantity, _ := strconv.ParseFloat(qty, 64)
// Determine type based on stopOrderType
displayType := orderType
if stopOrderType != "" {
displayType = stopOrderType
}
result = append(result, OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
PositionSide: "", // Bybit doesn't use positionSide for UTA
Type: displayType,
Price: 0,
StopPrice: price,
Quantity: quantity,
Status: "NEW",
})
}
}
}
return result, nil
}

View File

@@ -2085,3 +2085,37 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
// Fee: 10,
// }
var defaultBuilder *hyperliquid.BuilderInfo = nil
// GetOpenOrders gets all open/pending orders for a symbol
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result []OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
}
side := "BUY"
if order.Side == "A" {
side = "SELL"
}
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
PositionSide: "",
Type: "LIMIT",
Price: order.LimitPx,
StopPrice: 0,
Quantity: order.Size,
Status: "NEW",
})
}
return result, nil
}

View File

@@ -94,4 +94,21 @@ type Trader interface {
// limit: max number of records to return
// Returns accurate exit price, fees, and close reason for positions closed externally
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
// GetOpenOrders Get open/pending orders from exchange
// Returns stop-loss, take-profit, and limit orders that haven't been filled
GetOpenOrders(symbol string) ([]OpenOrder, error)
}
// OpenOrder represents a pending order on the exchange
type OpenOrder struct {
OrderID string `json:"order_id"`
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
Price float64 `json:"price"` // Order price (for limit orders)
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW
}

View File

@@ -686,3 +686,9 @@ func pow10(n int) int64 {
}
return result
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Lighter open orders
return []OpenOrder{}, nil
}

View File

@@ -1387,3 +1387,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
return records, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement OKX open orders
return []OpenOrder{}, nil
}

View File

@@ -31,6 +31,19 @@ interface OrderMarker {
symbol: string
}
// 挂单接口定义 (交易所的止盈止损订单)
interface OpenOrder {
order_id: string
symbol: string
side: string // BUY/SELL
position_side: string // LONG/SHORT
type: string // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
price: number // 限价单价格
stop_price: number // 触发价格 (止损/止盈)
quantity: number
status: string
}
interface AdvancedChartProps {
symbol: string
interval?: string
@@ -101,6 +114,7 @@ export function AdvancedChart({
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据
const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据
const priceLinesRef = useRef<any[]>([]) // 存储挂单价格线
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -307,6 +321,26 @@ export function AdvancedChart({
}
}
// 获取交易所挂单 (止盈止损订单)
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
try {
console.log('[AdvancedChart] Fetching open orders for trader:', traderID, 'symbol:', symbol)
const result = await httpClient.get(`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`)
console.log('[AdvancedChart] Open orders API response:', result)
if (!result.success || !result.data) {
console.warn('[AdvancedChart] No open orders found')
return []
}
return result.data as OpenOrder[]
} catch (err) {
console.error('[AdvancedChart] Error fetching open orders:', err)
return []
}
}
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current) return
@@ -706,6 +740,87 @@ export function AdvancedChart({
return () => clearInterval(refreshInterval)
}, [symbol, interval, traderID, exchange])
// 单独刷新挂单价格线 (60秒刷新一次避免频繁调用交易所API)
useEffect(() => {
if (!traderID || !candlestickSeriesRef.current) return
// 加载挂单并显示价格线
const loadOpenOrders = async () => {
try {
// 先清除旧的价格线
priceLinesRef.current.forEach(line => {
try {
candlestickSeriesRef.current?.removePriceLine(line)
} catch (e) {
// 忽略清除错误
}
})
priceLinesRef.current = []
const openOrders = await fetchOpenOrders(traderID, symbol)
console.log('[AdvancedChart] Open orders for price lines:', openOrders)
if (openOrders.length > 0 && candlestickSeriesRef.current) {
openOrders.forEach(order => {
// 获取触发价格 (止损/止盈用 stop_price限价单用 price)
const linePrice = order.stop_price > 0 ? order.stop_price : order.price
if (linePrice <= 0) return
// 判断订单类型
const isStopLoss = order.type.includes('STOP') || order.type.includes('SL')
const isTakeProfit = order.type.includes('TAKE_PROFIT') || order.type.includes('TP')
const isLimit = order.type === 'LIMIT'
// 设置价格线样式
let lineColor = '#F0B90B' // 默认黄色
const lineStyle = 2 // 虚线
let title = ''
if (isStopLoss) {
lineColor = '#F6465D' // 红色 - 止损
title = `SL ${order.quantity}`
} else if (isTakeProfit) {
lineColor = '#0ECB81' // 绿色 - 止盈
title = `TP ${order.quantity}`
} else if (isLimit) {
lineColor = '#F0B90B' // 黄色 - 限价单
title = `Limit ${order.side} ${order.quantity}`
} else {
title = `${order.type} ${order.quantity}`
}
const priceLine = candlestickSeriesRef.current?.createPriceLine({
price: linePrice,
color: lineColor,
lineWidth: 1,
lineStyle: lineStyle,
axisLabelVisible: true,
title: title,
})
if (priceLine) {
priceLinesRef.current.push(priceLine)
}
})
console.log('[AdvancedChart] ✅ Created', priceLinesRef.current.length, 'price lines for pending orders')
}
} catch (err) {
console.error('[AdvancedChart] Error loading open orders:', err)
}
}
// 初始加载 (延迟1秒等待图表初始化完成)
const initialTimeout = setTimeout(loadOpenOrders, 1000)
// 60秒刷新一次挂单
const openOrdersInterval = setInterval(loadOpenOrders, 60000)
return () => {
clearTimeout(initialTimeout)
clearInterval(openOrdersInterval)
}
}, [symbol, traderID])
// 单独处理订单标记的显示/隐藏,避免重新加载数据
useEffect(() => {
if (!seriesMarkersRef.current) return