refactor: split large files and clean up project structure

- 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)
This commit is contained in:
tinkle-community
2026-03-12 12:53:57 +08:00
parent 8e294a5eed
commit cb31782be4
113 changed files with 20423 additions and 25733 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
package binance
import (
"context"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"time"
)
// 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
}
// 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) ([]types.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 []types.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, types.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) ([]types.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 []types.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 := types.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) ([]types.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 []types.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 := types.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) ([]types.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 []types.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 := types.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
}

View File

@@ -0,0 +1,758 @@
package binance
import (
"context"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"github.com/adshao/go-binance/v2/futures"
)
// 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 *types.LimitOrderRequest) (*types.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 &types.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) ([]types.OpenOrder, error) {
var result []types.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, types.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, types.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
}
// 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
}
// 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
}

View File

@@ -0,0 +1,290 @@
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
}