mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 04:20:59 +08:00
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
291 lines
9.2 KiB
Go
291 lines
9.2 KiB
Go
package binance
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"nofx/logger"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/adshao/go-binance/v2/futures"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|