mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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
This commit is contained in:
+15
-1
@@ -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"`
|
||||
|
||||
@@ -24,6 +24,10 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
"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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+20
-1
@@ -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("</decision>\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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
|
||||
@@ -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<OpenOrder[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.append('trader_id', traderId)
|
||||
params.append('symbol', symbol)
|
||||
|
||||
const result = await httpClient.request<OpenOrder[]>(
|
||||
`${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<OpenOrder[]> {
|
||||
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<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
const [copiedAddress, setCopiedAddress] = useState<boolean>(false)
|
||||
|
||||
const openOrderSymbols = useMemo(() => {
|
||||
const symbols = new Set<string>()
|
||||
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<OpenOrder[]>(
|
||||
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<number>(20)
|
||||
const [positionsCurrentPage, setPositionsCurrentPage] = useState<number>(1)
|
||||
@@ -740,6 +777,109 @@ export function TraderDashboardPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Open Orders */}
|
||||
<div
|
||||
className="nofx-glass p-6 animate-slide-in relative overflow-hidden group"
|
||||
style={{ animationDelay: '0.18s' }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<div className="w-24 h-24 rounded-full bg-nofx-gold blur-3xl" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-5 relative z-10">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2 text-nofx-text-main uppercase tracking-wide">
|
||||
<span className="text-nofx-gold">◇</span> {t('currentOpenOrders', language)}
|
||||
</h2>
|
||||
{openOrders && openOrders.length > 0 && (
|
||||
<div className="text-xs px-2 py-1 rounded bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 font-mono">
|
||||
{openOrders.length} {t('pending', language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openOrders && openOrders.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-left border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('traderDashboard.type', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right">{t('traderDashboard.price', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right">{t('traderDashboard.qty', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell">{t('traderDashboard.value', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left hidden md:table-cell">{t('traderDashboard.orderId', language)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{openOrders.map((order) => {
|
||||
const orderPrice = order.price > 0 ? order.price : order.stop_price
|
||||
const isBuy = order.side?.toUpperCase() === 'BUY'
|
||||
return (
|
||||
<tr
|
||||
key={`${order.symbol}-${order.order_id}`}
|
||||
className="border-b border-white/5 last:border-0 transition-all hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedChartSymbol(order.symbol)
|
||||
setChartUpdateKey(Date.now())
|
||||
chartSectionRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left text-nofx-text-main">
|
||||
{order.symbol}
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${isBuy ? 'bg-nofx-green/10 text-nofx-green' : 'bg-nofx-red/10 text-nofx-red'}`}
|
||||
>
|
||||
{isBuy ? 'BUY' : 'SELL'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-mono text-nofx-gold bg-nofx-gold/10 border border-nofx-gold/10">
|
||||
{order.type || '--'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">
|
||||
{orderPrice > 0 ? formatPrice(orderPrice) : '--'}
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">
|
||||
{formatQuantity(order.quantity)}
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">
|
||||
{orderPrice > 0 ? (orderPrice * order.quantity).toFixed(2) : '--'}
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-left text-nofx-text-muted hidden md:table-cell">
|
||||
{order.order_id}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : openOrdersError ? (
|
||||
<div className="text-center py-12 text-nofx-text-muted opacity-60">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-semibold mb-2">{t('traderDashboard.openOrdersFetchFailed', language)}</div>
|
||||
</div>
|
||||
) : openOrdersLoading ? (
|
||||
<div className="py-12 space-y-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-8 rounded bg-white/5 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-nofx-text-muted opacity-60">
|
||||
<div className="text-5xl mb-4 opacity-40 grayscale">⌁</div>
|
||||
<div className="text-lg font-semibold mb-2">{t('noOpenOrders', language)}</div>
|
||||
<div className="text-sm">{t('noPendingOrders', language)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Recent Decisions */}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user