Files
nofx/trader/binance_futures.go
T
tinkle-community 319ccb8ca3 fix: initial balance calculation and UI improvements
- Fix initial balance using available_balance instead of total_equity
- Fix WSMonitor nil pointer by starting market monitor before loading traders
- Add strategy name display on traders list and dashboard pages
- Various position sync and trading improvements
2025-12-10 14:40:08 +08:00

1073 lines
33 KiB
Go
Raw 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 trader
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"nofx/hook"
"nofx/logger"
"strconv"
"strings"
"sync"
"time"
"github.com/adshao/go-binance/v2/futures"
)
// getBrOrderID generates unique order ID (for futures contracts)
// Format: x-{BR_ID}{TIMESTAMP}{RANDOM}
// Futures limit is 32 characters, use this limit consistently
// Uses nanosecond timestamp + random number to ensure global uniqueness (collision probability < 10^-20)
func getBrOrderID() string {
brID := "KzrpZaP9" // Futures br ID
// Calculate available space: 32 - len("x-KzrpZaP9") = 32 - 11 = 21 characters
// Allocation: 13-digit timestamp + 8-digit random = 21 characters (perfect utilization)
timestamp := time.Now().UnixNano() % 10000000000000 // 13-digit nanosecond timestamp
// Generate 4-byte random number (8 hex digits)
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
randomHex := hex.EncodeToString(randomBytes)
// Format: x-KzrpZaP9{13-digit timestamp}{8-digit random}
// Example: x-KzrpZaP91234567890123abcdef12 (exactly 31 characters)
orderID := fmt.Sprintf("x-%s%d%s", brID, timestamp, randomHex)
// Ensure not exceeding 32-character limit (theoretically exactly 31 characters)
if len(orderID) > 32 {
orderID = orderID[:32]
}
return orderID
}
// FuturesTrader Binance futures trader
type FuturesTrader struct {
client *futures.Client
// Balance cache
cachedBalance map[string]interface{}
balanceCacheTime time.Time
balanceCacheMutex sync.RWMutex
// Position cache
cachedPositions []map[string]interface{}
positionsCacheTime time.Time
positionsCacheMutex sync.RWMutex
// Cache validity period (15 seconds)
cacheDuration time.Duration
}
// NewFuturesTrader creates futures trader
func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader {
client := futures.NewClient(apiKey, secretKey)
hookRes := hook.HookExec[hook.NewBinanceTraderResult](hook.NEW_BINANCE_TRADER, userId, client)
if hookRes != nil && hookRes.GetResult() != nil {
client = hookRes.GetResult()
}
// Sync time to avoid "Timestamp ahead" error
syncBinanceServerTime(client)
trader := &FuturesTrader{
client: client,
cacheDuration: 15 * time.Second, // 15-second cache
}
// Set dual-side position mode (Hedge Mode)
// This is required because the code uses PositionSide (LONG/SHORT)
if err := trader.setDualSidePosition(); err != nil {
logger.Infof("⚠️ Failed to set dual-side position mode: %v (ignore this warning if already in dual-side mode)", err)
}
return trader
}
// setDualSidePosition sets dual-side position mode (called during initialization)
func (t *FuturesTrader) setDualSidePosition() error {
// Try to set dual-side position mode
err := t.client.NewChangePositionModeService().
DualSide(true). // true = dual-side position (Hedge Mode)
Do(context.Background())
if err != nil {
// If error message contains "No need to change", it means already in dual-side position mode
if strings.Contains(err.Error(), "No need to change position side") {
logger.Infof(" ✓ Account is already in dual-side position mode (Hedge Mode)")
return nil
}
// Other errors are returned (but won't interrupt initialization in the caller)
return err
}
logger.Infof(" ✓ Account has been switched to dual-side position mode (Hedge Mode)")
logger.Infof(" ️ Dual-side position mode allows holding both long and short positions simultaneously")
return nil
}
// syncBinanceServerTime syncs Binance server time to ensure request timestamps are valid
func syncBinanceServerTime(client *futures.Client) {
serverTime, err := client.NewServerTimeService().Do(context.Background())
if err != nil {
logger.Infof("⚠️ Failed to sync Binance server time: %v", err)
return
}
now := time.Now().UnixMilli()
offset := now - serverTime
client.TimeOffset = offset
logger.Infof("⏱ Binance server time synced, offset %dms", offset)
}
// GetBalance gets account balance (with cache)
func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) {
// First check if cache is valid
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cacheAge := time.Since(t.balanceCacheTime)
t.balanceCacheMutex.RUnlock()
logger.Infof("✓ Using cached account balance (cache age: %.1f seconds ago)", cacheAge.Seconds())
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
// Cache expired or doesn't exist, call API
logger.Infof("🔄 Cache expired, calling Binance API to get account balance...")
account, err := t.client.NewGetAccountService().Do(context.Background())
if err != nil {
logger.Infof("❌ Binance API call failed: %v", err)
return nil, fmt.Errorf("failed to get account info: %w", err)
}
result := make(map[string]interface{})
result["totalWalletBalance"], _ = strconv.ParseFloat(account.TotalWalletBalance, 64)
result["availableBalance"], _ = strconv.ParseFloat(account.AvailableBalance, 64)
result["totalUnrealizedProfit"], _ = strconv.ParseFloat(account.TotalUnrealizedProfit, 64)
logger.Infof("✓ Binance API returned: total balance=%s, available=%s, unrealized PnL=%s",
account.TotalWalletBalance,
account.AvailableBalance,
account.TotalUnrealizedProfit)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions gets all positions (with cache)
func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) {
// First check if cache is valid
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
cacheAge := time.Since(t.positionsCacheTime)
t.positionsCacheMutex.RUnlock()
logger.Infof("✓ Using cached position information (cache age: %.1f seconds ago)", cacheAge.Seconds())
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
// Cache expired or doesn't exist, call API
logger.Infof("🔄 Cache expired, calling Binance API to get position information...")
positions, err := t.client.NewGetPositionRiskService().Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
posAmt, _ := strconv.ParseFloat(pos.PositionAmt, 64)
if posAmt == 0 {
continue // Skip positions with zero amount
}
posMap := make(map[string]interface{})
posMap["symbol"] = pos.Symbol
posMap["positionAmt"], _ = strconv.ParseFloat(pos.PositionAmt, 64)
posMap["entryPrice"], _ = strconv.ParseFloat(pos.EntryPrice, 64)
posMap["markPrice"], _ = strconv.ParseFloat(pos.MarkPrice, 64)
posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64)
posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64)
posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64)
// Note: Binance SDK doesn't expose updateTime field, will fallback to local tracking
// Determine direction
if posAmt > 0 {
posMap["side"] = "long"
} else {
posMap["side"] = "short"
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// SetMarginMode sets margin mode
func (t *FuturesTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
var marginType futures.MarginType
if isCrossMargin {
marginType = futures.MarginTypeCrossed
} else {
marginType = futures.MarginTypeIsolated
}
// Try to set margin mode
err := t.client.NewChangeMarginTypeService().
Symbol(symbol).
MarginType(marginType).
Do(context.Background())
marginModeStr := "Cross Margin"
if !isCrossMargin {
marginModeStr = "Isolated Margin"
}
if err != nil {
// If error message contains "No need to change", margin mode is already set to target value
if contains(err.Error(), "No need to change margin type") {
logger.Infof(" ✓ %s margin mode is already %s", symbol, marginModeStr)
return nil
}
// If there is an open position, margin mode cannot be changed, but this doesn't affect trading
if contains(err.Error(), "Margin type cannot be changed if there exists position") {
logger.Infof(" ⚠️ %s has open positions, cannot change margin mode, continuing with current mode", symbol)
return nil
}
// Detect Multi-Assets mode (error code -4168)
if contains(err.Error(), "Multi-Assets mode") || contains(err.Error(), "-4168") || contains(err.Error(), "4168") {
logger.Infof(" ⚠️ %s detected Multi-Assets mode, forcing Cross Margin mode", symbol)
logger.Infof(" 💡 Tip: To use Isolated Margin mode, please disable Multi-Assets mode in Binance")
return nil
}
// Detect Unified Account API (Portfolio Margin)
if contains(err.Error(), "unified") || contains(err.Error(), "portfolio") || contains(err.Error(), "Portfolio") {
logger.Infof(" ❌ %s detected Unified Account API, unable to trade futures", symbol)
return fmt.Errorf("please use 'Spot & Futures Trading' API permission, do not use 'Unified Account API'")
}
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Don't return error, let trading continue
return nil
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, marginModeStr)
return nil
}
// SetLeverage sets leverage (with smart detection and cooldown period)
func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error {
// First try to get current leverage (from position information)
currentLeverage := 0
positions, err := t.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == symbol {
if lev, ok := pos["leverage"].(float64); ok {
currentLeverage = int(lev)
break
}
}
}
}
// If current leverage is already the target leverage, skip
if currentLeverage == leverage && currentLeverage > 0 {
logger.Infof(" ✓ %s leverage is already %dx, no need to change", symbol, leverage)
return nil
}
// Change leverage
_, err = t.client.NewChangeLeverageService().
Symbol(symbol).
Leverage(leverage).
Do(context.Background())
if err != nil {
// If error message contains "No need to change", leverage is already the target value
if contains(err.Error(), "No need to change") {
logger.Infof(" ✓ %s leverage is already %dx", symbol, leverage)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" ✓ %s leverage changed to %dx", symbol, leverage)
// Wait 5 seconds after changing leverage (to avoid cooldown period errors)
logger.Infof(" ⏱ Waiting 5 seconds for cooldown period...")
time.Sleep(5 * time.Second)
return nil
}
// OpenLong opens a long position
func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err)
}
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
// Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Check if formatted quantity is 0 (prevent rounding errors)
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
if parseErr != nil || quantityFloat <= 0 {
return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr)
}
// Check minimum notional value (Binance requires at least 10 USDT)
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
return nil, err
}
// Create market buy order (using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeBuy).
PositionSide(futures.PositionSideTypeLong).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
logger.Infof("✓ Opened long position successfully: %s quantity: %s", symbol, quantityStr)
logger.Infof(" Order ID: %d", order.OrderID)
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// OpenShort opens a short position
func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err)
}
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
// Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Check if formatted quantity is 0 (prevent rounding errors)
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
if parseErr != nil || quantityFloat <= 0 {
return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr)
}
// Check minimum notional value (Binance requires at least 10 USDT)
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
return nil, err
}
// Create market sell order (using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeSell).
PositionSide(futures.PositionSideTypeShort).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
logger.Infof("✓ Opened short position successfully: %s quantity: %s", symbol, quantityStr)
logger.Infof(" Order ID: %d", order.OrderID)
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CloseLong closes a long position
func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no long position found for %s", symbol)
}
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Create market sell order (close long, using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeSell).
PositionSide(futures.PositionSideTypeLong).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
logger.Infof("✓ Closed long position successfully: %s quantity: %s", symbol, quantityStr)
// After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CloseShort closes a short position
func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = -pos["positionAmt"].(float64) // Short position quantity is negative, take absolute value
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no short position found for %s", symbol)
}
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Create market buy order (close short, using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeBuy).
PositionSide(futures.PositionSideTypeShort).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
logger.Infof("✓ Closed short position successfully: %s quantity: %s", symbol, quantityStr)
// After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CancelStopLossOrders cancels only stop-loss orders (doesn't affect take-profit orders)
func (t *FuturesTrader) CancelStopLossOrders(symbol string) error {
// Get all open orders for this symbol
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
// Filter out stop-loss orders and cancel them (cancel all directions including LONG and SHORT)
canceledCount := 0
var cancelErrors []error
for _, order := range orders {
orderType := order.Type
// Only cancel stop-loss orders (don't cancel take-profit orders)
if orderType == futures.OrderTypeStopMarket || orderType == futures.OrderTypeStop {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel stop-loss order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s has no stop-loss orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d stop-loss order(s) for %s", canceledCount, symbol)
}
// If all cancellations failed, return error
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel stop-loss orders: %v", cancelErrors)
}
return nil
}
// CancelTakeProfitOrders cancels only take-profit orders (doesn't affect stop-loss orders)
func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error {
// Get all open orders for this symbol
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
// Filter out take-profit orders and cancel them (cancel all directions including LONG and SHORT)
canceledCount := 0
var cancelErrors []error
for _, order := range orders {
orderType := order.Type
// Only cancel take-profit orders (don't cancel stop-loss orders)
if orderType == futures.OrderTypeTakeProfitMarket || orderType == futures.OrderTypeTakeProfit {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel take-profit order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s has no take-profit orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d take-profit order(s) for %s", canceledCount, symbol)
}
// If all cancellations failed, return error
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel take-profit orders: %v", cancelErrors)
}
return nil
}
// CancelAllOrders cancels all pending orders for this symbol
func (t *FuturesTrader) CancelAllOrders(symbol string) error {
err := t.client.NewCancelAllOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel pending orders: %w", err)
}
logger.Infof(" ✓ Canceled all pending orders for %s", symbol)
return nil
}
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
// Get all open orders for this symbol
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
// Filter out take-profit and stop-loss orders and cancel them
canceledCount := 0
for _, order := range orders {
orderType := order.Type
// Only cancel stop-loss and take-profit orders
if orderType == futures.OrderTypeStopMarket ||
orderType == futures.OrderTypeTakeProfitMarket ||
orderType == futures.OrderTypeStop ||
orderType == futures.OrderTypeTakeProfit {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
logger.Infof(" ⚠ Failed to cancel order %d: %v", order.OrderID, err)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled take-profit/stop-loss order for %s (Order ID: %d, Type: %s)",
symbol, order.OrderID, orderType)
}
}
if canceledCount == 0 {
logger.Infof(" %s has no take-profit/stop-loss orders to cancel", symbol)
} else {
logger.Infof(" ✓ Canceled %d take-profit/stop-loss order(s) for %s", canceledCount, symbol)
}
return nil
}
// GetMarketPrice gets market price
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
if len(prices) == 0 {
return 0, fmt.Errorf("price not found")
}
price, err := strconv.ParseFloat(prices[0].Price, 64)
if err != nil {
return 0, err
}
return price, nil
}
// CalculatePositionSize calculates position size
func (t *FuturesTrader) CalculatePositionSize(balance, riskPercent, price float64, leverage int) float64 {
riskAmount := balance * (riskPercent / 100.0)
positionValue := riskAmount * float64(leverage)
quantity := positionValue / price
return quantity
}
// SetStopLoss sets stop-loss order
func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
var side futures.SideType
var posSide futures.PositionSideType
if positionSide == "LONG" {
side = futures.SideTypeSell
posSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeBuy
posSide = futures.PositionSideTypeShort
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return err
}
_, err = t.client.NewCreateOrderService().
Symbol(symbol).
Side(side).
PositionSide(posSide).
Type(futures.OrderTypeStopMarket).
StopPrice(fmt.Sprintf("%.8f", stopPrice)).
Quantity(quantityStr).
WorkingType(futures.WorkingTypeContractPrice).
ClosePosition(true).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set stop-loss: %w", err)
}
logger.Infof(" Stop-loss price set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take-profit order
func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
var side futures.SideType
var posSide futures.PositionSideType
if positionSide == "LONG" {
side = futures.SideTypeSell
posSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeBuy
posSide = futures.PositionSideTypeShort
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return err
}
_, err = t.client.NewCreateOrderService().
Symbol(symbol).
Side(side).
PositionSide(posSide).
Type(futures.OrderTypeTakeProfitMarket).
StopPrice(fmt.Sprintf("%.8f", takeProfitPrice)).
Quantity(quantityStr).
WorkingType(futures.WorkingTypeContractPrice).
ClosePosition(true).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set take-profit: %w", err)
}
logger.Infof(" Take-profit price set: %.4f", takeProfitPrice)
return nil
}
// GetMinNotional gets minimum notional value (Binance requirement)
func (t *FuturesTrader) GetMinNotional(symbol string) float64 {
// Use conservative default value of 10 USDT to ensure order passes exchange validation
return 10.0
}
// CheckMinNotional checks if order meets minimum notional value requirement
func (t *FuturesTrader) CheckMinNotional(symbol string, quantity float64) error {
price, err := t.GetMarketPrice(symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
notionalValue := quantity * price
minNotional := t.GetMinNotional(symbol)
if notionalValue < minNotional {
return fmt.Errorf(
"order amount %.2f USDT is below minimum requirement %.2f USDT (quantity: %.4f, price: %.4f)",
notionalValue, minNotional, quantity, price,
)
}
return nil
}
// GetSymbolPrecision gets the quantity precision for a trading pair
func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) {
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get trading rules: %w", err)
}
for _, s := range exchangeInfo.Symbols {
if s.Symbol == symbol {
// Get precision from LOT_SIZE filter
for _, filter := range s.Filters {
if filter["filterType"] == "LOT_SIZE" {
stepSize := filter["stepSize"].(string)
precision := calculatePrecision(stepSize)
logger.Infof(" %s quantity precision: %d (stepSize: %s)", symbol, precision, stepSize)
return precision, nil
}
}
}
}
logger.Infof(" ⚠ %s precision information not found, using default precision 3", symbol)
return 3, nil // Default precision is 3
}
// calculatePrecision calculates precision from stepSize
func calculatePrecision(stepSize string) int {
// Remove trailing zeros
stepSize = trimTrailingZeros(stepSize)
// Find decimal point
dotIndex := -1
for i := 0; i < len(stepSize); i++ {
if stepSize[i] == '.' {
dotIndex = i
break
}
}
// If no decimal point or decimal point is at the end, precision is 0
if dotIndex == -1 || dotIndex == len(stepSize)-1 {
return 0
}
// Return number of digits after decimal point
return len(stepSize) - dotIndex - 1
}
// trimTrailingZeros removes trailing zeros
func trimTrailingZeros(s string) string {
// If no decimal point, return directly
if !stringContains(s, ".") {
return s
}
// Iterate backwards to remove trailing zeros
for len(s) > 0 && s[len(s)-1] == '0' {
s = s[:len(s)-1]
}
// If last character is decimal point, remove it too
if len(s) > 0 && s[len(s)-1] == '.' {
s = s[:len(s)-1]
}
return s
}
// FormatQuantity formats quantity to correct precision
func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
precision, err := t.GetSymbolPrecision(symbol)
if err != nil {
// If retrieval fails, use default format
return fmt.Sprintf("%.3f", quantity), nil
}
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, quantity), nil
}
// Helper functions
func contains(s, substr string) bool {
return len(s) >= len(substr) && stringContains(s, substr)
}
func stringContains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// GetOrderStatus gets order status
func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
// Convert orderID to int64
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid order ID: %s", orderID)
}
order, err := t.client.NewGetOrderService().
Symbol(symbol).
OrderID(orderIDInt).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
// Parse execution price
avgPrice, _ := strconv.ParseFloat(order.AvgPrice, 64)
executedQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
result := map[string]interface{}{
"orderId": order.OrderID,
"symbol": order.Symbol,
"status": string(order.Status),
"avgPrice": avgPrice,
"executedQty": executedQty,
"side": string(order.Side),
"type": string(order.Type),
"time": order.Time,
"updateTime": order.UpdateTime,
}
// Binance futures commission fee needs to be obtained through GetUserTrades, not retrieved here for now
// Can be obtained later through WebSocket or separate query
result["commission"] = 0.0
return result, nil
}
// GetClosedPnL retrieves closed position PnL records from Binance Futures
// Binance API: /fapi/v1/income with incomeType=REALIZED_PNL
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
// Use income history API to get realized PnL
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(startTime.UnixMilli()).
Limit(int64(limit)).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get income history: %w", err)
}
records := make([]ClosedPnLRecord, 0, len(incomes))
for _, income := range incomes {
record := ClosedPnLRecord{
Symbol: income.Symbol,
ExchangeID: fmt.Sprintf("%d", income.TranID),
}
// Parse realized PnL
record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64)
// Parse time
record.ExitTime = time.UnixMilli(income.Time)
// Income API doesn't provide entry/exit price directly
// We need to get these from trade history if needed
// For now, leave them as 0 (will be matched with local DB records)
// Determine side from PnL sign (approximate)
// Note: This is not 100% accurate; actual side comes from position tracking
record.Side = "unknown"
record.CloseType = "unknown"
records = append(records, record)
}
// Enrich with trade history for more details (if needed)
// This requires additional API calls per symbol, so we do it only for important records
if len(records) > 0 {
t.enrichClosedPnLWithTrades(records, startTime)
}
return records, nil
}
// enrichClosedPnLWithTrades adds entry/exit price details from trade history
func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) {
// Group by symbol
symbolSet := make(map[string]bool)
for _, r := range records {
symbolSet[r.Symbol] = true
}
// Get trade history for each symbol
for symbol := range symbolSet {
trades, err := t.client.NewListAccountTradeService().
Symbol(symbol).
StartTime(startTime.UnixMilli()).
Limit(100).
Do(context.Background())
if err != nil {
continue
}
// Build a map of trades by time for quick lookup
for i := range records {
if records[i].Symbol != symbol {
continue
}
// Find matching trade(s) near the income time
for _, trade := range trades {
tradeTime := time.UnixMilli(trade.Time)
// Match if within 1 second of the PnL record
if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second {
// Found matching trade
records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64)
records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64)
commission, _ := strconv.ParseFloat(trade.Commission, 64)
records[i].Fee += commission
// Determine side
if trade.PositionSide == futures.PositionSideTypeLong {
records[i].Side = "long"
} else if trade.PositionSide == futures.PositionSideTypeShort {
records[i].Side = "short"
}
// Determine close type from order type (approximate)
if trade.Buyer && records[i].Side == "short" ||
!trade.Buyer && records[i].Side == "long" {
// This is a close trade
records[i].CloseType = "unknown" // Can't determine SL/TP from trade data
}
records[i].OrderID = strconv.FormatInt(trade.OrderID, 10)
break
}
}
}
}
}