From ed557cd7ace8c2c734c6ff5d6396f10385ba8565 Mon Sep 17 00:00:00 2001 From: laoXong Date: Mon, 27 Apr 2026 01:50:41 +0800 Subject: [PATCH] feat: add limit order support with AI context awareness - Add OpenOrderInfo struct and OpenOrders field to trading context - Support new AI actions: place_buy_limit, place_sell_limit, cancel_order, cancel_all_orders - Include existing open orders in AI prompt to avoid duplicate orders - Add open orders display table in trader dashboard - Fix Hyperliquid symbol conversion and order status parsing - Add i18n translations for open orders --- kernel/engine.go | 16 ++- kernel/engine_position.go | 44 ++++++-- kernel/engine_prompt.go | 21 +++- trader/auto_trader_loop.go | 55 +++++++++- trader/auto_trader_orders.go | 139 +++++++++++++++++++++++++ trader/hyperliquid/trader_account.go | 6 +- trader/hyperliquid/trader_orders.go | 44 ++++++-- web/src/i18n/translations.ts | 24 +++++ web/src/lib/api/data.ts | 39 +++++++ web/src/pages/TraderDashboardPage.tsx | 142 +++++++++++++++++++++++++- web/src/types/trading.ts | 12 +++ 11 files changed, 522 insertions(+), 20 deletions(-) diff --git a/kernel/engine.go b/kernel/engine.go index a5e1f0ce..bd94d6a6 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -6,13 +6,13 @@ import ( "fmt" "io" "net/http" - "os" "nofx/logger" "nofx/market" "nofx/provider/hyperliquid" "nofx/provider/nofxos" "nofx/security" "nofx/store" + "os" "strings" "time" ) @@ -99,6 +99,7 @@ type Context struct { PromptVariant string `json:"prompt_variant,omitempty"` TradingStats *TradingStats `json:"trading_stats,omitempty"` RecentOrders []RecentOrder `json:"recent_orders,omitempty"` + OpenOrders []OpenOrderInfo `json:"open_orders,omitempty"` MarketDataMap map[string]*market.Data `json:"-"` MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` @@ -111,6 +112,19 @@ type Context struct { Timeframes []string `json:"-"` } +// OpenOrderInfo describes an existing pending exchange order for AI context. +type OpenOrderInfo struct { + OrderID string `json:"order_id"` + Symbol string `json:"symbol"` + Side string `json:"side"` + PositionSide string `json:"position_side,omitempty"` + Type string `json:"type"` + Price float64 `json:"price,omitempty"` + StopPrice float64 `json:"stop_price,omitempty"` + Quantity float64 `json:"quantity"` + Status string `json:"status"` +} + // Decision AI trading decision type Decision struct { Symbol string `json:"symbol"` diff --git a/kernel/engine_position.go b/kernel/engine_position.go index 437aea1a..b3da17e4 100644 --- a/kernel/engine_position.go +++ b/kernel/engine_position.go @@ -20,12 +20,16 @@ func validateDecisions(decisions []Decision, accountEquity float64, btcEthLevera func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error { validActions := map[string]bool{ - "open_long": true, - "open_short": true, - "close_long": true, - "close_short": true, - "hold": true, - "wait": true, + "open_long": true, + "open_short": true, + "close_long": true, + "close_short": true, + "place_buy_limit": true, + "place_sell_limit": true, + "cancel_order": true, + "cancel_all_orders": true, + "hold": true, + "wait": true, } if !validActions[d.Action] { @@ -117,5 +121,33 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi } } + if d.Action == "place_buy_limit" || d.Action == "place_sell_limit" { + if d.Symbol == "" { + return fmt.Errorf("symbol is required for limit orders") + } + if d.Price <= 0 { + return fmt.Errorf("limit order price must be greater than 0") + } + if d.Quantity <= 0 && d.PositionSizeUSD <= 0 { + return fmt.Errorf("limit order requires quantity or position_size_usd") + } + if d.Leverage < 0 { + return fmt.Errorf("leverage cannot be negative") + } + } + + if d.Action == "cancel_order" { + if d.Symbol == "" { + return fmt.Errorf("symbol is required for cancel_order") + } + if d.OrderID == "" { + return fmt.Errorf("order_id is required for cancel_order") + } + } + + if d.Action == "cancel_all_orders" && d.Symbol == "" { + return fmt.Errorf("symbol is required for cancel_all_orders") + } + return nil } diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go index 9f1b7ee8..4d409eca 100644 --- a/kernel/engine_prompt.go +++ b/kernel/engine_prompt.go @@ -132,13 +132,17 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string examplePositionSize := accountEquity * btcEthPosValueRatio sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", riskControl.BTCETHMaxLeverage, examplePositionSize)) + sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"place_buy_limit\", \"price\": 3200, \"position_size_usd\": 500, \"leverage\": 3, \"confidence\": 75, \"reasoning\": \"Buy only on pullback; avoid chasing\"},\n") + sb.WriteString(" {\"symbol\": \"SOLUSDT\", \"action\": \"cancel_order\", \"order_id\": \"123456789\"},\n") sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") sb.WriteString("]\n```\n") sb.WriteString("\n\n") sb.WriteString("## Field Description\n\n") - sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | place_buy_limit | place_sell_limit | cancel_order | cancel_all_orders | hold | wait\n") sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence)) sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") + sb.WriteString("- Required when placing limit orders: symbol, action, price, and either quantity or position_size_usd. Use `place_buy_limit` for a bid below current price and `place_sell_limit` for an ask above current price.\n") + sb.WriteString("- Existing open orders are shown in the user prompt. Do not place duplicate orders at similar prices; use `cancel_order` with `order_id` or `cancel_all_orders` before replacing stale/conflicting orders.\n") sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n") // 8. Custom Prompt @@ -265,6 +269,21 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { sb.WriteString("\n") } + if len(ctx.OpenOrders) > 0 { + sb.WriteString("## Existing Open Orders\n") + for i, order := range ctx.OpenOrders { + price := order.Price + if price <= 0 { + price = order.StopPrice + } + sb.WriteString(fmt.Sprintf("%d. id=%s %s %s %s qty=%.6f price=%.4f status=%s\n", + i+1, order.OrderID, order.Symbol, order.Side, order.Type, order.Quantity, price, order.Status)) + } + sb.WriteString("Before placing a new limit order, check these orders and cancel stale/conflicting duplicates when needed.\n\n") + } else { + sb.WriteString("Existing Open Orders: None\n\n") + } + // Historical trading statistics (helps AI understand past performance) if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { // Get language from strategy config diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index c01b91f5..3b9a61b9 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -468,6 +468,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { CandidateCoins: candidateCoins, } + ctx.OpenOrders = at.collectOpenOrdersForAI(candidateCoins, positionInfos) + // 7. Add recent closed trades (if store is available) if at.store != nil { // Get recent 10 closed trades for AI context @@ -580,6 +582,51 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { return ctx, nil } +func (at *AutoTrader) collectOpenOrdersForAI(candidateCoins []kernel.CandidateCoin, positions []kernel.PositionInfo) []kernel.OpenOrderInfo { + symbolSet := make(map[string]struct{}) + for _, coin := range candidateCoins { + if coin.Symbol != "" { + symbolSet[coin.Symbol] = struct{}{} + } + } + for _, pos := range positions { + if pos.Symbol != "" { + symbolSet[pos.Symbol] = struct{}{} + } + } + + if len(symbolSet) == 0 { + return nil + } + + openOrders := make([]kernel.OpenOrderInfo, 0) + for symbol := range symbolSet { + orders, err := at.trader.GetOpenOrders(symbol) + if err != nil { + at.logWarnf("⚠️ Failed to get open orders for %s: %v", symbol, err) + continue + } + for _, order := range orders { + openOrders = append(openOrders, kernel.OpenOrderInfo{ + OrderID: order.OrderID, + Symbol: order.Symbol, + Side: order.Side, + PositionSide: order.PositionSide, + Type: order.Type, + Price: order.Price, + StopPrice: order.StopPrice, + Quantity: order.Quantity, + Status: order.Status, + }) + } + if len(openOrders) >= 20 { + break + } + } + + return openOrders +} + // sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait // This avoids position stacking overflow when changing positions func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision { @@ -590,12 +637,16 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision { // Define priority getActionPriority := func(action string) int { switch action { + case "cancel_order", "cancel_all_orders": + return 0 // Cancel stale/conflicting pending orders before new actions case "close_long", "close_short": return 1 // Highest priority: close positions first + case "place_buy_limit", "place_sell_limit": + return 2 // Place pending orders before market opens if both are present case "open_long", "open_short": - return 2 // Second priority: open positions later + return 3 // Market opens after pending-order maintenance case "hold", "wait": - return 3 // Lowest priority: wait + return 4 // Lowest priority: wait default: return 999 // Unknown actions at the end } diff --git a/trader/auto_trader_orders.go b/trader/auto_trader_orders.go index edfc1995..9c513b7f 100644 --- a/trader/auto_trader_orders.go +++ b/trader/auto_trader_orders.go @@ -2,10 +2,12 @@ package trader import ( "fmt" + "math" "nofx/kernel" "nofx/logger" "nofx/market" "nofx/store" + "strconv" "time" ) @@ -20,6 +22,14 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actio return at.executeCloseLongWithRecord(decision, actionRecord) case "close_short": return at.executeCloseShortWithRecord(decision, actionRecord) + case "place_buy_limit": + return at.executeLimitOrderWithRecord(decision, actionRecord, "BUY") + case "place_sell_limit": + return at.executeLimitOrderWithRecord(decision, actionRecord, "SELL") + case "cancel_order": + return at.executeCancelOrderWithRecord(decision, actionRecord) + case "cancel_all_orders": + return at.executeCancelAllOrdersWithRecord(decision, actionRecord) case "hold", "wait": // No execution needed, just record return nil @@ -28,6 +38,135 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actio } } +func (at *AutoTrader) executeLimitOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction, side string) error { + if decision.Price <= 0 { + return fmt.Errorf("limit order price must be greater than 0") + } + + quantity := decision.Quantity + if quantity <= 0 && decision.PositionSizeUSD > 0 { + quantity = decision.PositionSizeUSD / decision.Price + } + if quantity <= 0 { + return fmt.Errorf("limit order quantity must be greater than 0") + } + + gridTrader, ok := at.trader.(GridTrader) + if !ok { + gridTrader = NewGridTraderAdapter(at.trader) + } + + if duplicated, existingOrderID, err := at.hasSimilarOpenOrder(decision.Symbol, side, decision.Price); err != nil { + logger.Warnf(" ⚠️ Failed to check existing open orders: %v", err) + } else if duplicated { + actionRecord.OrderID = parseOrderIDInt64(existingOrderID) + actionRecord.Quantity = quantity + actionRecord.Price = decision.Price + logger.Infof(" ℹ️ Similar %s limit order already exists for %s at %.4f (orderID=%s), skipping duplicate", + side, decision.Symbol, decision.Price, existingOrderID) + return nil + } + + leverage := decision.Leverage + if leverage <= 0 { + leverage = at.defaultLeverageForSymbol(decision.Symbol) + } + + if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { + logger.Infof(" ⚠️ Failed to set margin mode: %v", err) + } + + req := &LimitOrderRequest{ + Symbol: decision.Symbol, + Side: side, + Price: decision.Price, + Quantity: quantity, + Leverage: leverage, + PostOnly: true, + ReduceOnly: false, + ClientID: fmt.Sprintf("ai-limit-%d", time.Now().UnixNano()%1000000000), + } + + result, err := gridTrader.PlaceLimitOrder(req) + if err != nil { + return err + } + + actionRecord.OrderID = parseOrderIDInt64(result.OrderID) + actionRecord.Quantity = result.Quantity + actionRecord.Price = result.Price + actionRecord.Leverage = leverage + + logger.Infof(" ✓ Limit order placed: %s %s %.6f @ %.4f orderID=%s", + decision.Symbol, side, result.Quantity, result.Price, result.OrderID) + return nil +} + +func (at *AutoTrader) executeCancelOrderWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { + gridTrader, ok := at.trader.(GridTrader) + if !ok { + gridTrader = NewGridTraderAdapter(at.trader) + } + + if err := gridTrader.CancelOrder(decision.Symbol, decision.OrderID); err != nil { + return err + } + + actionRecord.OrderID = parseOrderIDInt64(decision.OrderID) + logger.Infof(" ✓ Cancelled order: %s %s", decision.Symbol, decision.OrderID) + return nil +} + +func (at *AutoTrader) executeCancelAllOrdersWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { + if err := at.trader.CancelAllOrders(decision.Symbol); err != nil { + return err + } + + logger.Infof(" ✓ Cancelled all open orders for %s", decision.Symbol) + return nil +} + +func (at *AutoTrader) hasSimilarOpenOrder(symbol, side string, price float64) (bool, string, error) { + orders, err := at.trader.GetOpenOrders(symbol) + if err != nil { + return false, "", err + } + + tolerance := price * 0.001 + if tolerance < 0.01 { + tolerance = 0.01 + } + for _, order := range orders { + if order.Side == side && math.Abs(order.Price-price) <= tolerance { + return true, order.OrderID, nil + } + } + + return false, "", nil +} + +func (at *AutoTrader) defaultLeverageForSymbol(symbol string) int { + if at.config.StrategyConfig == nil { + return 3 + } + risk := at.config.StrategyConfig.RiskControl + if symbol == "BTCUSDT" || symbol == "ETHUSDT" { + if risk.BTCETHMaxLeverage > 0 { + return risk.BTCETHMaxLeverage + } + return 3 + } + if risk.AltcoinMaxLeverage > 0 { + return risk.AltcoinMaxLeverage + } + return 3 +} + +func parseOrderIDInt64(orderID string) int64 { + id, _ := strconv.ParseInt(orderID, 10, 64) + return id +} + // executeOpenLongWithRecord executes open long position and records detailed information func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📈 Open long: %s", decision.Symbol) diff --git a/trader/hyperliquid/trader_account.go b/trader/hyperliquid/trader_account.go index f556455a..8a20ed5c 100644 --- a/trader/hyperliquid/trader_account.go +++ b/trader/hyperliquid/trader_account.go @@ -526,6 +526,8 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.T // GetOpenOrders gets all open/pending orders for a symbol func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) { + coin := convertSymbolToHyperliquid(symbol) + openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) if err != nil { return nil, fmt.Errorf("failed to get open orders: %w", err) @@ -533,7 +535,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err var result []types.OpenOrder for _, order := range openOrders { - if order.Coin != symbol { + if order.Coin != coin { continue } @@ -544,7 +546,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, err result = append(result, types.OpenOrder{ OrderID: fmt.Sprintf("%d", order.Oid), - Symbol: order.Coin, + Symbol: symbol, Side: side, PositionSide: "", Type: "LIMIT", diff --git a/trader/hyperliquid/trader_orders.go b/trader/hyperliquid/trader_orders.go index 3543d655..c99f0ed7 100644 --- a/trader/hyperliquid/trader_orders.go +++ b/trader/hyperliquid/trader_orders.go @@ -995,10 +995,23 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu // PlaceLimitOrder places a limit order for grid trading // Implements GridTrader interface func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { + if req == nil { + return nil, fmt.Errorf("limit order request is nil") + } + if req.Price <= 0 { + return nil, fmt.Errorf("limit order price must be greater than 0") + } + if req.Quantity <= 0 { + return nil, fmt.Errorf("limit order quantity must be greater than 0") + } + coin := convertSymbolToHyperliquid(req.Symbol) // Set leverage if specified and not xyz dex isXyz := strings.HasPrefix(coin, "xyz:") + if isXyz { + return nil, fmt.Errorf("hyperliquid xyz dex limit orders are not supported yet") + } if req.Leverage > 0 && !isXyz { if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil { logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err) @@ -1013,6 +1026,10 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type // Determine if buy or sell isBuy := req.Side == "BUY" + tif := hyperliquid.TifGtc + if req.PostOnly { + tif = hyperliquid.TifAlo + } logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity) @@ -1023,21 +1040,21 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type Price: roundedPrice, OrderType: hyperliquid.OrderType{ Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders + Tif: tif, }, }, ReduceOnly: req.ReduceOnly, } - _, err := t.exchange.Order(t.ctx, order, defaultBuilder) + status, err := t.exchange.Order(t.ctx, order, defaultBuilder) if err != nil { return nil, fmt.Errorf("failed to place limit order: %w", err) } - // Note: Hyperliquid's Order response doesn't return the order ID directly - // We would need to query open orders to get it, but for grid trading - // we can track orders by price level instead - orderID := fmt.Sprintf("%d", time.Now().UnixNano()) + orderID, statusText, err := parseHyperliquidOrderStatus(status) + if err != nil { + return nil, err + } logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f", coin, req.Side, roundedPrice) @@ -1050,10 +1067,23 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*type PositionSide: req.PositionSide, Price: roundedPrice, Quantity: roundedQuantity, - Status: "NEW", + Status: statusText, }, nil } +func parseHyperliquidOrderStatus(status hyperliquid.OrderStatus) (orderID string, statusText string, err error) { + if status.Resting != nil { + return fmt.Sprintf("%d", status.Resting.Oid), "NEW", nil + } + if status.Filled != nil { + return fmt.Sprintf("%d", status.Filled.Oid), "FILLED", nil + } + if status.Error != nil { + return "", "", fmt.Errorf("hyperliquid order rejected: %s", *status.Error) + } + return "", "", fmt.Errorf("hyperliquid order response did not include order status") +} + // CancelOrder cancels a specific order by ID // Implements GridTrader interface func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error { diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index 57bc63ee..c95c954e 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -54,6 +54,10 @@ export const translations = { short: 'SHORT', noPositions: 'No Positions', noActivePositions: 'No active trading positions', + currentOpenOrders: 'Current Open Orders', + pending: 'Pending', + noOpenOrders: 'No Open Orders', + noPendingOrders: 'No pending exchange orders', // Recent Decisions recentDecisions: 'Recent Decisions', @@ -983,7 +987,10 @@ export const translations = { entry: 'Entry', exit: 'Exit', qty: 'Qty', + type: 'Type', + price: 'Price', value: 'Value', + orderId: 'Order ID', lev: 'Lev', pnl: 'P&L', duration: 'Duration', @@ -1173,6 +1180,7 @@ export const translations = { perPage: 'Per page', accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection', positionsFetchFailed: 'Position data unavailable', + openOrdersFetchFailed: 'Open order data unavailable', decisionsFetchFailed: 'Decision data unavailable', }, @@ -1416,6 +1424,10 @@ export const translations = { short: '空头', noPositions: '无持仓', noActivePositions: '当前没有活跃的交易持仓', + currentOpenOrders: '当前挂单', + pending: '挂单', + noOpenOrders: '无挂单', + noPendingOrders: '当前没有未成交挂单', // Recent Decisions recentDecisions: '最近决策', @@ -2470,7 +2482,10 @@ export const translations = { entry: '入场价', mark: '标记价', qty: '数量', + type: '类型', + price: '价格', value: '价值', + orderId: '订单ID', lev: '杠杆', uPnL: '未实现盈亏', liq: '强平价', @@ -2480,6 +2495,7 @@ export const translations = { perPage: '每页', accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接', positionsFetchFailed: '持仓数据请求失败', + openOrdersFetchFailed: '挂单数据请求失败', decisionsFetchFailed: '决策记录请求失败', }, @@ -2718,6 +2734,10 @@ export const translations = { short: 'SHORT', noPositions: 'Tidak Ada Posisi', noActivePositions: 'Tidak ada posisi trading yang aktif', + currentOpenOrders: 'Order Terbuka Saat Ini', + pending: 'Pending', + noOpenOrders: 'Tidak Ada Order Terbuka', + noPendingOrders: 'Tidak ada order exchange yang tertunda', // Recent Decisions recentDecisions: 'Keputusan Terbaru', @@ -3580,7 +3600,10 @@ export const translations = { entry: 'Entry', mark: 'Mark', qty: 'Qty', + type: 'Tipe', + price: 'Harga', value: 'Nilai', + orderId: 'ID Order', lev: 'Lev.', uPnL: 'uPnL', liq: 'Liq.', @@ -3590,6 +3613,7 @@ export const translations = { perPage: 'Per halaman', accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi', positionsFetchFailed: 'Data posisi tidak tersedia', + openOrdersFetchFailed: 'Data order terbuka tidak tersedia', decisionsFetchFailed: 'Data keputusan tidak tersedia', }, diff --git a/web/src/lib/api/data.ts b/web/src/lib/api/data.ts index cd7900b4..3a3864ca 100644 --- a/web/src/lib/api/data.ts +++ b/web/src/lib/api/data.ts @@ -2,6 +2,7 @@ import type { SystemStatus, AccountInfo, Position, + OpenOrder, DecisionRecord, Statistics, CompetitionData, @@ -40,6 +41,44 @@ export const dataApi = { return result.data! }, + async getOpenOrders( + traderId: string, + symbol: string, + silent?: boolean + ): Promise { + const params = new URLSearchParams() + params.append('trader_id', traderId) + params.append('symbol', symbol) + + const result = await httpClient.request( + `${API_BASE}/open-orders?${params}`, + { silent } + ) + if (!result.success) throw new Error('Failed to fetch open orders') + return Array.isArray(result.data) ? result.data : [] + }, + + async getOpenOrdersForSymbols( + traderId: string, + symbols: string[], + silent?: boolean + ): Promise { + const uniqueSymbols = Array.from(new Set(symbols.filter(Boolean))) + if (uniqueSymbols.length === 0) return [] + + const results = await Promise.allSettled( + uniqueSymbols.map((symbol) => dataApi.getOpenOrders(traderId, symbol, silent)) + ) + + const orders: OpenOrder[] = [] + results.forEach((result) => { + if (result.status === 'fulfilled') { + orders.push(...result.value) + } + }) + return orders + }, + async getDecisions(traderId?: string): Promise { const url = traderId ? `${API_BASE}/decisions?trader_id=${traderId}` diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index 53f545d8..9b0e7e5f 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useMemo, useState, useRef } from 'react' import { mutate } from 'swr' +import useSWR from 'swr' import { api } from '../lib/api' import { ChartTabs } from '../components/charts/ChartTabs' import { DecisionCard } from '../components/trader/DecisionCard' @@ -16,6 +17,7 @@ import type { SystemStatus, AccountInfo, Position, + OpenOrder, DecisionRecord, Statistics, TraderInfo, @@ -143,6 +145,41 @@ export function TraderDashboardPage({ const [showWalletAddress, setShowWalletAddress] = useState(false) const [copiedAddress, setCopiedAddress] = useState(false) + const openOrderSymbols = useMemo(() => { + const symbols = new Set() + positions?.forEach((pos) => { + if (pos.symbol) symbols.add(pos.symbol) + }) + decisions?.forEach((record) => { + record.decisions?.forEach((decision) => { + if (decision.symbol) symbols.add(decision.symbol) + }) + }) + if (status?.grid_symbol) symbols.add(status.grid_symbol) + if (selectedChartSymbol) symbols.add(selectedChartSymbol) + return Array.from(symbols) + }, [decisions, positions, selectedChartSymbol, status?.grid_symbol]) + const openOrderSymbolsKey = useMemo( + () => [...openOrderSymbols].sort().join(','), + [openOrderSymbols] + ) + + const { + data: openOrders, + error: openOrdersError, + isLoading: openOrdersLoading, + } = useSWR( + selectedTraderId && openOrderSymbols.length > 0 + ? `open-orders-${selectedTraderId}-${openOrderSymbolsKey}` + : null, + () => api.getOpenOrdersForSymbols(selectedTraderId!, openOrderSymbols, true), + { + refreshInterval: 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + } + ) + // Current positions pagination const [positionsPageSize, setPositionsPageSize] = useState(20) const [positionsCurrentPage, setPositionsCurrentPage] = useState(1) @@ -740,6 +777,109 @@ export function TraderDashboardPage({ )} + + {/* Current Open Orders */} +
+
+
+
+
+

+ {t('currentOpenOrders', language)} +

+ {openOrders && openOrders.length > 0 && ( +
+ {openOrders.length} {t('pending', language)} +
+ )} +
+ + {openOrders && openOrders.length > 0 ? ( +
+ + + + + + + + + + + + + + {openOrders.map((order) => { + const orderPrice = order.price > 0 ? order.price : order.stop_price + const isBuy = order.side?.toUpperCase() === 'BUY' + return ( + { + setSelectedChartSymbol(order.symbol) + setChartUpdateKey(Date.now()) + chartSectionRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + }} + > + + + + + + + + + ) + })} + +
{t('symbol', language)}{t('side', language)}{t('traderDashboard.type', language)}{t('traderDashboard.price', language)}{t('traderDashboard.qty', language)}{t('traderDashboard.value', language)}{t('traderDashboard.orderId', language)}
+ {order.symbol} + + + {isBuy ? 'BUY' : 'SELL'} + + + + {order.type || '--'} + + + {orderPrice > 0 ? formatPrice(orderPrice) : '--'} + + {formatQuantity(order.quantity)} + + {orderPrice > 0 ? (orderPrice * order.quantity).toFixed(2) : '--'} + + {order.order_id} +
+
+ ) : openOrdersError ? ( +
+
⚠️
+
{t('traderDashboard.openOrdersFetchFailed', language)}
+
+ ) : openOrdersLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : ( +
+
+
{t('noOpenOrders', language)}
+
{t('noPendingOrders', language)}
+
+ )} +
{/* Right Column: Recent Decisions */} diff --git a/web/src/types/trading.ts b/web/src/types/trading.ts index c8d6a38f..66aececf 100644 --- a/web/src/types/trading.ts +++ b/web/src/types/trading.ts @@ -42,6 +42,18 @@ export interface Position { margin_used: number } +export interface OpenOrder { + order_id: string + symbol: string + side: string + position_side?: string + type: string + price: number + stop_price: number + quantity: number + status: string +} + export interface DecisionAction { action: string symbol: string