Files
laoxong ed557cd7ac 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
2026-04-27 01:50:41 +08:00

1106 lines
33 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hyperliquid
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
"github.com/sonirico/go-hyperliquid"
)
// OpenLong opens a long position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this coin
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err)
}
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
// Set leverage (skip for xyz dex as it may not support leverage adjustment)
if !isXyz {
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
} else {
logger.Infof(" xyz dex asset %s - using default leverage", coin)
}
// Get current price (for market order)
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 1.01)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex order
if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, false); err != nil {
return nil, fmt.Errorf("failed to open long position on xyz dex: %w", err)
}
} else {
// Standard crypto order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: true,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
}
logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, quantity)
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// OpenShort opens a short position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this coin
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err)
}
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
// Set leverage (skip for xyz dex)
if !isXyz {
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
} else {
logger.Infof(" xyz dex asset %s - using default leverage", coin)
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 0.99)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex order
if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, false); err != nil {
return nil, fmt.Errorf("failed to open short position on xyz dex: %w", err)
}
} else {
// Standard crypto order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: false,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: false,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
}
logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, quantity)
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CloseLong closes a long position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
isXyz := strings.HasPrefix(coin, "xyz:")
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
// For xyz dex, also check xyz: prefixed symbols
searchSymbol := symbol
if isXyz {
searchSymbol = coin // Use xyz:SYMBOL format for comparison
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no long position found for %s", symbol)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 0.99)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex close order
if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, true); err != nil {
return nil, fmt.Errorf("failed to close long position on xyz dex: %w", err)
}
} else {
// Standard crypto close order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: false,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
}
logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, quantity)
// Cancel all pending orders for this coin after closing position
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CloseShort closes a short position (supports both crypto and xyz dex)
func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// Hyperliquid symbol format
coin := convertSymbolToHyperliquid(symbol)
isXyz := strings.HasPrefix(coin, "xyz:")
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
// For xyz dex, also check xyz: prefixed symbols
searchSymbol := symbol
if isXyz {
searchSymbol = coin
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no short position found for %s", symbol)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Price needs to be processed to 5 significant figures
aggressivePrice := t.roundPriceToSigfigs(price * 1.01)
logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice)
// Handle xyz dex assets differently
if isXyz {
// xyz dex close order
if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, true); err != nil {
return nil, fmt.Errorf("failed to close short position on xyz dex: %w", err)
}
} else {
// Standard crypto close order
roundedQuantity := t.roundToSzDecimals(coin, quantity)
logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin))
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: true,
Size: roundedQuantity,
Price: aggressivePrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifIoc,
},
},
ReduceOnly: true,
}
_, err = t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
}
logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, quantity)
// Cancel all pending orders for this coin after closing position
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = 0
result["symbol"] = symbol
result["status"] = "FILLED"
return result, nil
}
// CancelStopLossOrders only cancels stop loss orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all)
func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error {
// Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin
logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders")
return t.CancelStopOrders(symbol)
}
// CancelTakeProfitOrders only cancels take profit orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all)
func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error {
// Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin
logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders")
return t.CancelStopOrders(symbol)
}
// CancelAllOrders cancels all pending orders for this coin
func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
if isXyz {
// xyz dex orders - use direct API call
return t.cancelXyzOrders(coin)
}
// Standard crypto orders
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return fmt.Errorf("failed to get pending orders: %w", err)
}
// Cancel all pending orders for this coin
for _, order := range openOrders {
if order.Coin == coin {
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
if err != nil {
logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err)
}
}
}
logger.Infof(" ✓ Cancelled all pending orders for %s", symbol)
return nil
}
// CancelStopOrders cancels take profit/stop loss orders for this coin (used to adjust TP/SL positions)
func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
isXyz := strings.HasPrefix(coin, "xyz:")
if isXyz {
// xyz dex orders - use direct API call
return t.cancelXyzOrders(coin)
}
// Get all pending orders for standard crypto
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return fmt.Errorf("failed to get pending orders: %w", err)
}
// Note: Hyperliquid SDK's OpenOrder structure does not expose trigger field
// Therefore temporarily cancel all pending orders for this coin (including TP/SL orders)
// This is safe because all old orders should be cleaned up before setting new TP/SL
canceledCount := 0
for _, order := range openOrders {
if order.Coin == coin {
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
if err != nil {
logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err)
continue
}
canceledCount++
}
}
if canceledCount == 0 {
logger.Infof(" No pending orders to cancel for %s", symbol)
} else {
logger.Infof(" ✓ Cancelled %d pending orders for %s (including TP/SL orders)", canceledCount, symbol)
}
return nil
}
// cancelXyzOrders cancels all pending orders for xyz dex assets (stocks, forex, commodities)
func (t *HyperliquidTrader) cancelXyzOrders(coin string) error {
// Query xyz dex open orders
reqBody := map[string]interface{}{
"type": "openOrders",
"user": t.walletAddr,
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := "https://api.hyperliquid.xyz/info"
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("xyz dex openOrders API error (status %d): %s", resp.StatusCode, string(body))
}
// Parse open orders
var openOrders []struct {
Coin string `json:"coin"`
Oid int64 `json:"oid"`
}
if err := json.Unmarshal(body, &openOrders); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Filter orders for this coin and cancel them
canceledCount := 0
for _, order := range openOrders {
if order.Coin == coin {
if err := t.cancelXyzOrder(order.Oid); err != nil {
logger.Infof(" ⚠ Failed to cancel xyz dex order (oid=%d): %v", order.Oid, err)
continue
}
canceledCount++
}
}
if canceledCount == 0 {
logger.Infof(" No pending xyz dex orders to cancel for %s", coin)
} else {
logger.Infof(" ✓ Cancelled %d xyz dex orders for %s", canceledCount, coin)
}
return nil
}
// cancelXyzOrder cancels a single xyz dex order by oid
func (t *HyperliquidTrader) cancelXyzOrder(oid int64) error {
// Get asset index for this order (we need it for cancel action)
// For cancel, we construct a cancel action with the oid
action := map[string]interface{}{
"type": "cancel",
"cancels": []map[string]interface{}{
{
"a": oid, // asset index not needed for cancel by oid in xyz dex
"o": oid,
},
},
}
// Sign the action
nonce := time.Now().UnixMilli()
isMainnet := !t.isTestnet
vaultAddress := ""
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
if err != nil {
return fmt.Errorf("failed to sign cancel action: %w", err)
}
payload := map[string]any{
"action": action,
"nonce": nonce,
"signature": sig,
}
apiURL := hyperliquid.MainnetAPIURL
if t.isTestnet {
apiURL = hyperliquid.TestnetAPIURL
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
// Check response
var result struct {
Status string `json:"status"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if result.Status != "ok" {
return fmt.Errorf("cancel failed: %s", string(body))
}
return nil
}
// floatToWireStr converts a float to wire format string (8 decimal places, trimmed zeros)
// This matches the SDK's floatToWire function
func floatToWireStr(x float64) string {
// Format to 8 decimal places
result := fmt.Sprintf("%.8f", x)
// Remove trailing zeros
result = strings.TrimRight(result, "0")
// Remove trailing decimal point if no decimals left
result = strings.TrimRight(result, ".")
return result
}
// placeXyzOrder places an order on the xyz dex (stocks, forex, commodities)
// Note: xyz dex orders use builder-deployed perpetuals and require different handling
// xyz dex asset indices start from 10000 (10000 + meta_index)
// This implementation bypasses the SDK's NameToAsset lookup and directly constructs the order
func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64, price float64, reduceOnly bool) error {
// Fetch xyz meta if not cached
t.xyzMetaMutex.RLock()
hasMeta := t.xyzMeta != nil
t.xyzMetaMutex.RUnlock()
if !hasMeta {
if err := t.fetchXyzMeta(); err != nil {
return fmt.Errorf("failed to fetch xyz meta: %w", err)
}
}
// Get asset index from xyz meta (returns 0-based index)
metaIndex := t.getXyzAssetIndex(coin)
if metaIndex < 0 {
return fmt.Errorf("xyz asset %s not found in meta", coin)
}
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1 (verified from perpDexs API: [null, {name:"xyz",...}])
// So xyz asset index = 100000 + 1 * 10000 + metaIndex = 110000 + metaIndex
const xyzPerpDexIndex = 1
assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex
// Round size to correct precision
szDecimals := t.getXyzSzDecimals(coin)
multiplier := 1.0
for i := 0; i < szDecimals; i++ {
multiplier *= 10.0
}
roundedSize := float64(int(size*multiplier+0.5)) / multiplier
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(price)
logger.Infof("📝 Placing xyz dex order (direct): %s %s size=%.4f price=%.4f metaIndex=%d assetIndex=%d (formula: 100000 + 1*10000 + %d) reduceOnly=%v",
map[bool]string{true: "BUY", false: "SELL"}[isBuy],
coin, roundedSize, roundedPrice, metaIndex, assetIndex, metaIndex, reduceOnly)
// Construct OrderWire directly with correct asset index (bypassing SDK's NameToAsset)
orderWire := hyperliquid.OrderWire{
Asset: assetIndex,
IsBuy: isBuy,
LimitPx: floatToWireStr(roundedPrice),
Size: floatToWireStr(roundedSize),
ReduceOnly: reduceOnly,
OrderType: hyperliquid.OrderWireType{
Limit: &hyperliquid.OrderWireTypeLimit{
Tif: hyperliquid.TifIoc,
},
},
}
// Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{
Type: "order",
Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na",
Builder: nil,
}
// Sign the action
nonce := time.Now().UnixMilli()
isMainnet := !t.isTestnet
vaultAddress := "" // No vault for personal account
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
if err != nil {
return fmt.Errorf("failed to sign xyz dex order: %w", err)
}
// Construct payload for /exchange endpoint
payload := map[string]any{
"action": action,
"nonce": nonce,
"signature": sig,
}
// Determine API URL
apiURL := hyperliquid.MainnetAPIURL
if t.isTestnet {
apiURL = hyperliquid.TestnetAPIURL
}
// POST to /exchange
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
logger.Infof("📤 Sending xyz dex order to %s/exchange", apiURL)
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Parse response
var result struct {
Status string `json:"status"`
Response struct {
Type string `json:"type"`
Data struct {
Statuses []struct {
Resting *struct {
Oid int64 `json:"oid"`
} `json:"resting,omitempty"`
Filled *struct {
TotalSz string `json:"totalSz"`
AvgPx string `json:"avgPx"`
Oid int `json:"oid"`
} `json:"filled,omitempty"`
Error *string `json:"error,omitempty"`
} `json:"statuses"`
} `json:"data"`
} `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
// Try to parse as error response
logger.Infof("⚠️ Failed to parse response as success, raw body: %s", string(body))
return fmt.Errorf("xyz dex order failed, status=%d, body=%s", resp.StatusCode, string(body))
}
// Check for errors in response
if result.Status != "ok" {
return fmt.Errorf("xyz dex order failed: status=%s, body=%s", result.Status, string(body))
}
// Check order statuses
if len(result.Response.Data.Statuses) > 0 {
status := result.Response.Data.Statuses[0]
if status.Error != nil {
return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error)
}
if status.Filled != nil {
logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d",
status.Filled.TotalSz, status.Filled.AvgPx, status.Filled.Oid)
} else if status.Resting != nil {
logger.Infof("✅ xyz dex order resting: oid=%d", status.Resting.Oid)
}
}
logger.Infof("✅ xyz dex order placed successfully: %s (response: %s)", coin, string(body))
return nil
}
// placeXyzTriggerOrder places a trigger order (stop loss / take profit) on the xyz dex
// tpsl: "sl" for stop loss, "tp" for take profit
func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size float64, triggerPrice float64, tpsl string) error {
// Fetch xyz meta if not cached
t.xyzMetaMutex.RLock()
hasMeta := t.xyzMeta != nil
t.xyzMetaMutex.RUnlock()
if !hasMeta {
if err := t.fetchXyzMeta(); err != nil {
return fmt.Errorf("failed to fetch xyz meta: %w", err)
}
}
// Get asset index from xyz meta (returns 0-based index)
metaIndex := t.getXyzAssetIndex(coin)
if metaIndex < 0 {
return fmt.Errorf("xyz asset %s not found in meta", coin)
}
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1
const xyzPerpDexIndex = 1
assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex
// Round size to correct precision
szDecimals := t.getXyzSzDecimals(coin)
multiplier := 1.0
for i := 0; i < szDecimals; i++ {
multiplier *= 10.0
}
roundedSize := float64(int(size*multiplier+0.5)) / multiplier
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(triggerPrice)
logger.Infof("📝 Placing xyz dex %s order: %s %s size=%.4f triggerPrice=%.4f assetIndex=%d",
tpsl,
map[bool]string{true: "BUY", false: "SELL"}[isBuy],
coin, roundedSize, roundedPrice, assetIndex)
// Construct OrderWire with trigger type for stop loss / take profit
orderWire := hyperliquid.OrderWire{
Asset: assetIndex,
IsBuy: isBuy,
LimitPx: floatToWireStr(roundedPrice),
Size: floatToWireStr(roundedSize),
ReduceOnly: true, // TP/SL orders are always reduce-only
OrderType: hyperliquid.OrderWireType{
Trigger: &hyperliquid.OrderWireTypeTrigger{
TriggerPx: floatToWireStr(roundedPrice),
IsMarket: true,
Tpsl: hyperliquid.Tpsl(tpsl), // "sl" or "tp" - convert string to Tpsl type
},
},
}
// Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{
Type: "order",
Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na",
Builder: nil,
}
// Sign the action
nonce := time.Now().UnixMilli()
isMainnet := !t.isTestnet
vaultAddress := ""
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
if err != nil {
return fmt.Errorf("failed to sign xyz dex trigger order: %w", err)
}
// Construct payload for /exchange endpoint
payload := map[string]any{
"action": action,
"nonce": nonce,
"signature": sig,
}
// Determine API URL
apiURL := hyperliquid.MainnetAPIURL
if t.isTestnet {
apiURL = hyperliquid.TestnetAPIURL
}
// POST to /exchange
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
logger.Infof("📤 Sending xyz dex %s order to %s/exchange", tpsl, apiURL)
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Parse response
var result struct {
Status string `json:"status"`
Response struct {
Type string `json:"type"`
Data struct {
Statuses []struct {
Resting *struct {
Oid int64 `json:"oid"`
} `json:"resting,omitempty"`
Error *string `json:"error,omitempty"`
} `json:"statuses"`
} `json:"data"`
} `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
logger.Infof("⚠️ Failed to parse response, raw body: %s", string(body))
return fmt.Errorf("xyz dex %s order failed, status=%d, body=%s", tpsl, resp.StatusCode, string(body))
}
// Check for errors in response
if result.Status != "ok" {
return fmt.Errorf("xyz dex %s order failed: status=%s, body=%s", tpsl, result.Status, string(body))
}
// Check order statuses
if len(result.Response.Data.Statuses) > 0 {
status := result.Response.Data.Statuses[0]
if status.Error != nil {
return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error)
}
if status.Resting != nil {
logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid)
}
}
logger.Infof("✅ xyz dex %s order placed successfully: %s", tpsl, coin)
return nil
}
// SetStopLoss sets stop loss order
func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
coin := convertSymbolToHyperliquid(symbol)
isBuy := positionSide == "SHORT" // Short position stop loss = buy, long position stop loss = sell
// Price needs to be processed to 5 significant figures
roundedStopPrice := t.roundPriceToSigfigs(stopPrice)
// Check if this is an xyz dex asset (stocks, forex, commodities)
isXyz := strings.HasPrefix(coin, "xyz:")
if isXyz {
// xyz dex stop loss order - use direct API call similar to placeXyzOrder
if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedStopPrice, "sl"); err != nil {
return fmt.Errorf("failed to set xyz dex stop loss: %w", err)
}
} else {
// Standard crypto stop loss order
// Round quantity according to coin precision requirements
roundedQuantity := t.roundToSzDecimals(coin, quantity)
// Create stop loss order (Trigger Order)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // Use rounded quantity
Price: roundedStopPrice, // Use processed price
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedStopPrice,
IsMarket: true,
Tpsl: "sl", // stop loss
},
},
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
}
logger.Infof(" Stop loss price set: %.4f", roundedStopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
coin := convertSymbolToHyperliquid(symbol)
isBuy := positionSide == "SHORT" // Short position take profit = buy, long position take profit = sell
// Price needs to be processed to 5 significant figures
roundedTakeProfitPrice := t.roundPriceToSigfigs(takeProfitPrice)
// Check if this is an xyz dex asset (stocks, forex, commodities)
isXyz := strings.HasPrefix(coin, "xyz:")
if isXyz {
// xyz dex take profit order - use direct API call similar to placeXyzOrder
if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedTakeProfitPrice, "tp"); err != nil {
return fmt.Errorf("failed to set xyz dex take profit: %w", err)
}
} else {
// Standard crypto take profit order
// Round quantity according to coin precision requirements
roundedQuantity := t.roundToSzDecimals(coin, quantity)
// Create take profit order (Trigger Order)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // Use rounded quantity
Price: roundedTakeProfitPrice, // Use processed price
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedTakeProfitPrice,
IsMarket: true,
Tpsl: "tp", // take profit
},
},
ReduceOnly: true,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
}
logger.Infof(" Take profit price set: %.4f", roundedTakeProfitPrice)
return nil
}
// 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)
}
}
// Round quantity to allowed decimals
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(req.Price)
// 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)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity,
Price: roundedPrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: tif,
},
},
ReduceOnly: req.ReduceOnly,
}
status, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
orderID, statusText, err := parseHyperliquidOrderStatus(status)
if err != nil {
return nil, err
}
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
coin, req.Side, roundedPrice)
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: roundedPrice,
Quantity: roundedQuantity,
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 {
coin := convertSymbolToHyperliquid(symbol)
// Parse order ID
oid, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.exchange.Cancel(t.ctx, coin, oid)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
return nil
}