mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
7e96c5d0f2
* feat: add AI grid trading and market regime classification - Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook - Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter) - Add grid engine with ATR-based boundary calculation and fund distribution - Add market regime classification documents (Chinese/English) - Add GridConfigEditor component for frontend configuration * fix: implement GetOpenOrders for Lighter exchange * debug: add logging for Lighter GetActiveOrders API call * fix: correct Lighter API response parsing for GetOpenOrders - Changed response field from 'data' to 'orders' to match Lighter API - Updated OrderResponse struct to match Lighter's actual field names - Fixed field types: price/quantity as strings, is_ask for side * feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges - Aster: uses /fapi/v3/openOrders endpoint - OKX: uses /api/v5/trade/orders-pending and orders-algo-pending - Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending * fix: address code review issues for GetOpenOrders - Add error logging for OKX/Bitget API failures (was silently swallowed) - Fix Lighter position side logic to handle reduce-only orders - Change verbose debug logs from Infof to Debugf level * fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck * fix: use auth query parameter instead of Authorization header for Lighter API * test: add Lighter API authentication tests and diagnostic tools * fix(grid): add leverage setting before order placement CRITICAL BUG FIX: - Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder() - Set leverage during grid initialization - Log leverage setting results * fix(grid): prevent CancelOrder from canceling all orders CRITICAL BUG FIX: - CancelOrder no longer calls CancelAllOrders - Try exchange-specific CancelOrder if available - Return error if individual cancellation not supported * fix(grid): add total position value limit check CRITICAL: Prevent excessive position accumulation - New checkTotalPositionLimit() function - Checks current + pending + new order value - Rejects orders that would exceed TotalInvestment x Leverage - Logs clear error messages when limit exceeded * feat(grid): implement stop loss execution CRITICAL: Add code-level stop loss protection - New checkAndExecuteStopLoss() function - Checks each filled level against StopLossPct - Automatically closes positions exceeding stop loss - Called during every grid state sync * feat(grid): add breakout detection and auto-pause CRITICAL: Detect price breakout from grid range - New checkBreakout() function to detect upper/lower breakouts - Auto-pause grid on significant breakout (>2%) - Cancel all orders when breakout detected - Prevent continued losses in trending market - Minor breakouts (1-2%) logged for AI consideration * feat(grid): enforce max drawdown limit with emergency exit CRITICAL: Add drawdown protection - New checkMaxDrawdown() function tracks peak equity - emergencyExit() closes all positions and cancels orders - Auto-pause grid when MaxDrawdownPct exceeded - Protect capital from excessive losses * feat(grid): enforce daily loss limit - Add checkDailyLossLimit() function to check if daily loss exceeds limit - Track daily PnL with auto-reset at midnight - Pause grid when DailyLossLimitPct exceeded - Add updateDailyPnL() helper for realized PnL tracking - Prevent excessive single-day losses * fix(grid): update daily PnL when stop loss is executed The updateDailyPnL() function was added but never called, leaving DailyPnL always at 0 and preventing daily loss limit checks from triggering. This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss() when a stop loss is executed. We update directly rather than calling updateDailyPnL() because the mutex is already held in that function. * feat(grid): add automatic grid adjustment - New checkGridSkew() detects imbalanced grid - autoAdjustGrid() reinitializes around current price - Prevents grid from becoming ineffective after drift - Triggers when one side is 3x more filled than other * fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels Critical fix for grid auto-adjustment: - Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered on current price before reinitializing grid levels - Preserve filled positions during adjustment by saving and restoring them to the closest new level after reinitialization - Hold mutex lock for the entire adjustment operation to ensure atomicity - Add locked variants of calculateDefaultBounds, calculateATRBounds, and initializeGridLevels to use during adjustment Without this fix, autoAdjustGrid was using old boundaries when creating new grid levels, defeating the purpose of auto-adjustment when price moved significantly. * fix(grid): improve order state sync logic - Don't assume missing orders are filled - Compare position size to determine fill vs cancel - Properly reset cancelled orders to empty state - More accurate grid state tracking * fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity` which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution (gaussian, pyramid, uniform) where orders have different quantities, this could lead to incorrect fill detection. Now sums the actual PositionSize from filled levels for accurate comparison. Also adds warning log when GetPositions() fails. * docs: add grid market regime detection design Design for enhanced market state recognition with: - Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI) - Multi-period box indicators (72/240/500 1h candles) - 4-level ranging classification - Breakout detection and handling - Frontend risk control panel * docs: add grid market regime implementation plan 20 tasks covering: - Donchian channel calculation - Box data types and API - Regime classification (4 levels) - Breakout detection and handling - False breakout recovery - Frontend risk panel - AI prompt updates * feat(market): add Donchian channel calculation Add calculateDonchian function to compute highest high and lowest low over a specified period. This is the foundation for box (range) detection in the multi-period box indicator system for grid trading. * fix(market): handle invalid period in calculateDonchian * feat(market): add BoxData and RegimeLevel types * feat(market): add GetBoxData for multi-period box calculation Adds calculateBoxData internal function and GetBoxData public API that fetches 1h klines and computes three Donchian box levels (short/mid/long). This will be used by the grid trading system to detect market regime. * feat(store): add box and regime fields to grid models * feat(trader): add regime classification and breakout detection Implements Tasks 6-9 for grid market regime awareness: - Task 6: classifyRegimeLevel with Bollinger/ATR thresholds - Task 7: detectBoxBreakout for multi-period box breakouts - Task 8: confirmBreakout with 3-candle confirmation logic - Task 9: getBreakoutAction mapping breakout levels to actions * feat(trader): integrate box breakout detection into grid cycle - Task 10: Add checkBoxBreakout with 3-candle confirmation - Task 11: Add checkFalseBreakoutRecovery for 50% position recovery - Task 12: Add box/breakout/regime fields to GridState * feat: add grid risk panel with API endpoint - Task 13: Add GridRiskInfo type to frontend - Task 14: Add /traders/:id/grid-risk API endpoint - Task 15: Add GetGridRiskInfo method to AutoTrader - Task 16: Create GridRiskPanel component with i18n * feat(kernel): add box indicators to AI prompt - Add BoxData field to GridContext - Add box indicator table to both zh/en prompts - Show breakout/warning alerts based on price position * feat(web): integrate GridRiskPanel into TraderDashboardPage * feat(lighter): improve API key validation and market caching - Add API key validation status tracking - Add market list caching to reduce API calls - Improve logging (debug vs info levels) - Add comprehensive integration tests - Update trader manager and store for lighter support * fix: remove hardcoded test wallet address * fix(grid): improve GridRiskPanel layout and fix liquidation data - Make panel collapsible with summary badges when collapsed - Use compact 2-column grid layout for detailed info - Fix auth token key (token -> auth_token) - Only calculate liquidation distance when position exists * fix(grid): add isRunning checks to prevent trades after Stop() is called
1487 lines
46 KiB
Go
1487 lines
46 KiB
Go
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)
|
||
// Now uses both legacy API and new Algo Order API
|
||
func (t *FuturesTrader) CancelStopLossOrders(symbol string) error {
|
||
canceledCount := 0
|
||
var cancelErrors []error
|
||
|
||
// 1. Cancel legacy stop-loss orders
|
||
orders, err := t.client.NewListOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, order := range orders {
|
||
orderType := string(order.Type)
|
||
|
||
// Only cancel stop-loss orders (don't cancel take-profit orders)
|
||
// Use string comparison since OrderType constants were removed in v2.8.9
|
||
if orderType == "STOP_MARKET" || orderType == "STOP" {
|
||
_, 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 legacy stop-loss order: %s", errMsg)
|
||
continue
|
||
}
|
||
|
||
canceledCount++
|
||
logger.Infof(" ✓ Canceled legacy stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Cancel Algo stop-loss orders
|
||
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, algoOrder := range algoOrders {
|
||
// Only cancel stop-loss orders
|
||
if algoOrder.OrderType == futures.AlgoOrderTypeStopMarket || algoOrder.OrderType == futures.AlgoOrderTypeStop {
|
||
_, err := t.client.NewCancelAlgoOrderService().
|
||
AlgoID(algoOrder.AlgoId).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err)
|
||
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
|
||
logger.Infof(" ⚠ Failed to cancel Algo stop-loss order: %s", errMsg)
|
||
continue
|
||
}
|
||
|
||
canceledCount++
|
||
logger.Infof(" ✓ Canceled Algo stop-loss order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
// Now uses both legacy API and new Algo Order API
|
||
func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error {
|
||
canceledCount := 0
|
||
var cancelErrors []error
|
||
|
||
// 1. Cancel legacy take-profit orders
|
||
orders, err := t.client.NewListOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, order := range orders {
|
||
orderType := string(order.Type)
|
||
|
||
// Only cancel take-profit orders (don't cancel stop-loss orders)
|
||
// Use string comparison since OrderType constants were removed in v2.8.9
|
||
if orderType == "TAKE_PROFIT_MARKET" || orderType == "TAKE_PROFIT" {
|
||
_, 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 legacy take-profit order: %s", errMsg)
|
||
continue
|
||
}
|
||
|
||
canceledCount++
|
||
logger.Infof(" ✓ Canceled legacy take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Cancel Algo take-profit orders
|
||
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, algoOrder := range algoOrders {
|
||
// Only cancel take-profit orders
|
||
if algoOrder.OrderType == futures.AlgoOrderTypeTakeProfitMarket || algoOrder.OrderType == futures.AlgoOrderTypeTakeProfit {
|
||
_, err := t.client.NewCancelAlgoOrderService().
|
||
AlgoID(algoOrder.AlgoId).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err)
|
||
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
|
||
logger.Infof(" ⚠ Failed to cancel Algo take-profit order: %s", errMsg)
|
||
continue
|
||
}
|
||
|
||
canceledCount++
|
||
logger.Infof(" ✓ Canceled Algo take-profit order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType)
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
// Now uses both legacy API and new Algo Order API
|
||
func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||
// 1. Cancel all legacy orders
|
||
err := t.client.NewCancelAllOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
logger.Infof(" ⚠ Failed to cancel legacy orders: %v", err)
|
||
} else {
|
||
logger.Infof(" ✓ Canceled all legacy pending orders for %s", symbol)
|
||
}
|
||
|
||
// 2. Cancel all Algo orders
|
||
err = t.client.NewCancelAllAlgoOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
// Ignore "no algo orders" error
|
||
if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") {
|
||
logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err)
|
||
}
|
||
} else {
|
||
logger.Infof(" ✓ Canceled all Algo orders for %s", symbol)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// PlaceLimitOrder places a limit order for grid trading
|
||
// This implements the GridTrader interface for FuturesTrader
|
||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||
// Format quantity to correct precision
|
||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||
}
|
||
|
||
// Format price to correct precision
|
||
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||
}
|
||
|
||
// Set leverage if specified
|
||
if req.Leverage > 0 {
|
||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||
logger.Warnf("Failed to set leverage: %v", err)
|
||
}
|
||
}
|
||
|
||
// Determine side and position side
|
||
var side futures.SideType
|
||
var positionSide futures.PositionSideType
|
||
|
||
if req.Side == "BUY" {
|
||
side = futures.SideTypeBuy
|
||
positionSide = futures.PositionSideTypeLong
|
||
} else {
|
||
side = futures.SideTypeSell
|
||
positionSide = futures.PositionSideTypeShort
|
||
}
|
||
|
||
// Build order service with broker ID
|
||
orderService := t.client.NewCreateOrderService().
|
||
Symbol(req.Symbol).
|
||
Side(side).
|
||
PositionSide(positionSide).
|
||
Type(futures.OrderTypeLimit).
|
||
TimeInForce(futures.TimeInForceTypeGTC).
|
||
Quantity(quantityStr).
|
||
Price(priceStr).
|
||
NewClientOrderID(getBrOrderID())
|
||
|
||
// Execute order
|
||
order, err := orderService.Do(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||
}
|
||
|
||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||
|
||
return &LimitOrderResult{
|
||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||
ClientID: order.ClientOrderID,
|
||
Symbol: order.Symbol,
|
||
Side: string(order.Side),
|
||
PositionSide: string(order.PositionSide),
|
||
Price: req.Price,
|
||
Quantity: req.Quantity,
|
||
Status: string(order.Status),
|
||
}, nil
|
||
}
|
||
|
||
// CancelOrder cancels a specific order by ID
|
||
// This implements the GridTrader interface for FuturesTrader
|
||
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
|
||
// Parse order ID to int64
|
||
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid order ID: %w", err)
|
||
}
|
||
|
||
_, err = t.client.NewCancelOrderService().
|
||
Symbol(symbol).
|
||
OrderID(orderIDInt).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("failed to cancel order: %w", err)
|
||
}
|
||
|
||
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
|
||
return nil
|
||
}
|
||
|
||
// GetOrderBook gets the order book for a symbol
|
||
// This implements the GridTrader interface for FuturesTrader
|
||
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||
book, err := t.client.NewDepthService().
|
||
Symbol(symbol).
|
||
Limit(depth).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||
}
|
||
|
||
// Convert bids
|
||
bids = make([][]float64, len(book.Bids))
|
||
for i, bid := range book.Bids {
|
||
price, _ := strconv.ParseFloat(bid.Price, 64)
|
||
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
|
||
bids[i] = []float64{price, qty}
|
||
}
|
||
|
||
// Convert asks
|
||
asks = make([][]float64, len(book.Asks))
|
||
for i, ask := range book.Asks {
|
||
price, _ := strconv.ParseFloat(ask.Price, 64)
|
||
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
|
||
asks[i] = []float64{price, qty}
|
||
}
|
||
|
||
return bids, asks, nil
|
||
}
|
||
|
||
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
|
||
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||
canceledCount := 0
|
||
|
||
// 1. Cancel legacy stop orders (for backward compatibility)
|
||
orders, err := t.client.NewListOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, order := range orders {
|
||
orderType := string(order.Type)
|
||
|
||
// Only cancel stop-loss and take-profit orders
|
||
// Use string comparison since OrderType constants were removed in v2.8.9
|
||
if orderType == "STOP_MARKET" ||
|
||
orderType == "TAKE_PROFIT_MARKET" ||
|
||
orderType == "STOP" ||
|
||
orderType == "TAKE_PROFIT" {
|
||
|
||
_, err := t.client.NewCancelOrderService().
|
||
Symbol(symbol).
|
||
OrderID(order.OrderID).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
logger.Infof(" ⚠ Failed to cancel legacy order %d: %v", order.OrderID, err)
|
||
continue
|
||
}
|
||
|
||
canceledCount++
|
||
logger.Infof(" ✓ Canceled legacy stop order for %s (Order ID: %d, Type: %s)",
|
||
symbol, order.OrderID, orderType)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Cancel Algo orders (new API)
|
||
err = t.client.NewCancelAllAlgoOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
// Ignore "no algo orders" error
|
||
if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") {
|
||
logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err)
|
||
}
|
||
} else {
|
||
logger.Infof(" ✓ Canceled all Algo orders for %s", symbol)
|
||
canceledCount++
|
||
}
|
||
|
||
if canceledCount == 0 {
|
||
logger.Infof(" ℹ %s has no take-profit/stop-loss orders to cancel", symbol)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetOpenOrders gets all open/pending orders for a symbol
|
||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||
var result []OpenOrder
|
||
|
||
// 1. Get legacy open orders
|
||
orders, err := t.client.NewListOpenOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||
}
|
||
|
||
for _, order := range orders {
|
||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||
|
||
result = append(result, OpenOrder{
|
||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||
Symbol: order.Symbol,
|
||
Side: string(order.Side),
|
||
PositionSide: string(order.PositionSide),
|
||
Type: string(order.Type),
|
||
Price: price,
|
||
StopPrice: stopPrice,
|
||
Quantity: quantity,
|
||
Status: string(order.Status),
|
||
})
|
||
}
|
||
|
||
// 2. Get Algo orders (new API for stop-loss/take-profit)
|
||
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
|
||
Symbol(symbol).
|
||
Do(context.Background())
|
||
|
||
if err == nil {
|
||
for _, algoOrder := range algoOrders {
|
||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||
|
||
result = append(result, OpenOrder{
|
||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||
Symbol: algoOrder.Symbol,
|
||
Side: string(algoOrder.Side),
|
||
PositionSide: string(algoOrder.PositionSide),
|
||
Type: string(algoOrder.OrderType),
|
||
Price: 0, // Algo orders use stop price
|
||
StopPrice: triggerPrice,
|
||
Quantity: quantity,
|
||
Status: "NEW",
|
||
})
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// GetMarketPrice gets market price
|
||
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
||
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
|
||
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 using new Algo Order API
|
||
// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO)
|
||
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
|
||
}
|
||
|
||
// Use new Algo Order API
|
||
_, err := t.client.NewCreateAlgoOrderService().
|
||
Symbol(symbol).
|
||
Side(side).
|
||
PositionSide(posSide).
|
||
Type(futures.AlgoOrderTypeStopMarket).
|
||
TriggerPrice(fmt.Sprintf("%.8f", stopPrice)).
|
||
WorkingType(futures.WorkingTypeContractPrice).
|
||
ClosePosition(true).
|
||
ClientAlgoId(getBrOrderID()).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("failed to set stop-loss: %w", err)
|
||
}
|
||
|
||
logger.Infof(" Stop-loss price set (Algo Order): %.4f", stopPrice)
|
||
return nil
|
||
}
|
||
|
||
// SetTakeProfit sets take-profit order using new Algo Order API
|
||
// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO)
|
||
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
|
||
}
|
||
|
||
// Use new Algo Order API
|
||
_, err := t.client.NewCreateAlgoOrderService().
|
||
Symbol(symbol).
|
||
Side(side).
|
||
PositionSide(posSide).
|
||
Type(futures.AlgoOrderTypeTakeProfitMarket).
|
||
TriggerPrice(fmt.Sprintf("%.8f", takeProfitPrice)).
|
||
WorkingType(futures.WorkingTypeContractPrice).
|
||
ClosePosition(true).
|
||
ClientAlgoId(getBrOrderID()).
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
return fmt.Errorf("failed to set take-profit: %w", err)
|
||
}
|
||
|
||
logger.Infof(" Take-profit price set (Algo Order): %.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
|
||
}
|
||
|
||
// GetSymbolPricePrecision gets the price precision for a trading pair
|
||
func (t *FuturesTrader) GetSymbolPricePrecision(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 PRICE_FILTER filter
|
||
for _, filter := range s.Filters {
|
||
if filter["filterType"] == "PRICE_FILTER" {
|
||
tickSize := filter["tickSize"].(string)
|
||
precision := calculatePrecision(tickSize)
|
||
return precision, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default to 2 decimal places for price
|
||
return 2, nil
|
||
}
|
||
|
||
// FormatPrice formats price to correct precision
|
||
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
|
||
precision, err := t.GetSymbolPricePrecision(symbol)
|
||
if err != nil {
|
||
// If retrieval fails, use default format
|
||
return fmt.Sprintf("%.2f", price), nil
|
||
}
|
||
|
||
format := fmt.Sprintf("%%.%df", precision)
|
||
return fmt.Sprintf(format, price), 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 recent closing trades from Binance Futures
|
||
// Note: Binance does NOT have a position history API, only trade history.
|
||
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
|
||
// NOT suitable for historical position reconstruction - use only for matching recent closures.
|
||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||
trades, err := t.GetTrades(startTime, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
|
||
var records []ClosedPnLRecord
|
||
for _, trade := range trades {
|
||
if trade.RealizedPnL == 0 {
|
||
continue // Skip opening trades
|
||
}
|
||
|
||
// Determine side from trade
|
||
side := "long"
|
||
if trade.PositionSide == "SHORT" || trade.PositionSide == "short" {
|
||
side = "short"
|
||
} else if trade.PositionSide == "BOTH" || trade.PositionSide == "" {
|
||
// One-way mode: selling closes long, buying closes short
|
||
if trade.Side == "SELL" || trade.Side == "Sell" {
|
||
side = "long"
|
||
} else {
|
||
side = "short"
|
||
}
|
||
}
|
||
|
||
// Calculate entry price from PnL (mathematically accurate for this trade)
|
||
var entryPrice float64
|
||
if trade.Quantity > 0 {
|
||
if side == "long" {
|
||
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
|
||
} else {
|
||
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
|
||
}
|
||
}
|
||
|
||
records = append(records, ClosedPnLRecord{
|
||
Symbol: trade.Symbol,
|
||
Side: side,
|
||
EntryPrice: entryPrice,
|
||
ExitPrice: trade.Price,
|
||
Quantity: trade.Quantity,
|
||
RealizedPnL: trade.RealizedPnL,
|
||
Fee: trade.Fee,
|
||
ExitTime: trade.Time,
|
||
EntryTime: trade.Time, // Approximate
|
||
OrderID: trade.TradeID,
|
||
ExchangeID: trade.TradeID,
|
||
CloseType: "unknown",
|
||
})
|
||
}
|
||
|
||
return records, nil
|
||
}
|
||
|
||
// GetTrades retrieves trade history from Binance Futures using Income API
|
||
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
|
||
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
if limit > 1000 {
|
||
limit = 1000
|
||
}
|
||
|
||
// Use Income API to get REALIZED_PNL records (all symbols)
|
||
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)
|
||
}
|
||
|
||
var trades []TradeRecord
|
||
for _, income := range incomes {
|
||
pnl, _ := strconv.ParseFloat(income.Income, 64)
|
||
if pnl == 0 {
|
||
continue // Skip zero PnL records
|
||
}
|
||
|
||
// Income API doesn't provide full trade details, create a minimal record
|
||
// This is mainly used for detecting recent closures, not historical reconstruction
|
||
trade := TradeRecord{
|
||
TradeID: strconv.FormatInt(income.TranID, 10),
|
||
Symbol: income.Symbol,
|
||
RealizedPnL: pnl,
|
||
Time: time.UnixMilli(income.Time).UTC(),
|
||
// Note: Income API doesn't provide price, quantity, side, fee
|
||
// For accurate data, use GetTradesForSymbol with specific symbol
|
||
}
|
||
trades = append(trades, trade)
|
||
}
|
||
|
||
return trades, nil
|
||
}
|
||
|
||
// GetTradesForSymbol retrieves trade history for a specific symbol
|
||
// This is more reliable than using Income API which may have delays
|
||
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
if limit > 1000 {
|
||
limit = 1000
|
||
}
|
||
|
||
accountTrades, err := t.client.NewListAccountTradeService().
|
||
Symbol(symbol).
|
||
StartTime(startTime.UnixMilli()).
|
||
Limit(limit).
|
||
Do(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
|
||
}
|
||
|
||
var trades []TradeRecord
|
||
for _, at := range accountTrades {
|
||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||
|
||
trade := TradeRecord{
|
||
TradeID: strconv.FormatInt(at.ID, 10),
|
||
Symbol: at.Symbol,
|
||
Side: string(at.Side),
|
||
PositionSide: string(at.PositionSide),
|
||
Price: price,
|
||
Quantity: qty,
|
||
RealizedPnL: pnl,
|
||
Fee: fee,
|
||
Time: time.UnixMilli(at.Time).UTC(),
|
||
}
|
||
trades = append(trades, trade)
|
||
}
|
||
|
||
return trades, nil
|
||
}
|
||
|
||
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
|
||
// This is used for incremental sync - only fetch new trades since last sync
|
||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
|
||
if limit <= 0 {
|
||
limit = 100
|
||
}
|
||
if limit > 1000 {
|
||
limit = 1000
|
||
}
|
||
|
||
accountTrades, err := t.client.NewListAccountTradeService().
|
||
Symbol(symbol).
|
||
FromID(fromID).
|
||
Limit(limit).
|
||
Do(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
|
||
}
|
||
|
||
var trades []TradeRecord
|
||
for _, at := range accountTrades {
|
||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||
|
||
trade := TradeRecord{
|
||
TradeID: strconv.FormatInt(at.ID, 10),
|
||
Symbol: at.Symbol,
|
||
Side: string(at.Side),
|
||
PositionSide: string(at.PositionSide),
|
||
Price: price,
|
||
Quantity: qty,
|
||
RealizedPnL: pnl,
|
||
Fee: fee,
|
||
Time: time.UnixMilli(at.Time).UTC(),
|
||
}
|
||
trades = append(trades, trade)
|
||
}
|
||
|
||
return trades, nil
|
||
}
|
||
|
||
// GetCommissionSymbols returns symbols that have new commission records since lastSyncTime
|
||
// COMMISSION income is generated for every trade, so this is more reliable than REALIZED_PNL
|
||
func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string, error) {
|
||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||
IncomeType("COMMISSION").
|
||
StartTime(lastSyncTime.UnixMilli()).
|
||
Limit(1000).
|
||
Do(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get commission history: %w", err)
|
||
}
|
||
|
||
symbolMap := make(map[string]bool)
|
||
for _, income := range incomes {
|
||
if income.Symbol != "" {
|
||
symbolMap[income.Symbol] = true
|
||
}
|
||
}
|
||
|
||
var symbols []string
|
||
for symbol := range symbolMap {
|
||
symbols = append(symbols, symbol)
|
||
}
|
||
|
||
return symbols, nil
|
||
}
|
||
|
||
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
|
||
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
|
||
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
|
||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||
IncomeType("REALIZED_PNL").
|
||
StartTime(lastSyncTime.UnixMilli()).
|
||
Limit(1000).
|
||
Do(context.Background())
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get PnL history: %w", err)
|
||
}
|
||
|
||
symbolMap := make(map[string]bool)
|
||
for _, income := range incomes {
|
||
if income.Symbol != "" {
|
||
symbolMap[income.Symbol] = true
|
||
}
|
||
}
|
||
|
||
var symbols []string
|
||
for symbol := range symbolMap {
|
||
symbols = append(symbols, symbol)
|
||
}
|
||
|
||
return symbols, nil
|
||
}
|