mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +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)
292 lines
8.7 KiB
Go
292 lines
8.7 KiB
Go
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
|
|
}
|