mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 20:11:13 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user