fix(trader): harden API calls with timeouts, strict balance parsing, error context

- binance/bybit/gate: SDK default http.DefaultClient has no timeout; use a
  dedicated 30s-timeout client so a hung connection cannot stall the loop
- bybit: stop mutating http.DefaultClient.Transport, which leaked the
  referer header into every other HTTP request in the process
- add types.ParseFloatField: empty exchange fields stay zero, but malformed
  numeric values now surface as errors instead of silently becoming zero
  balances (applied to GetBalance across 8 exchanges)
- wrap order/market-data errors in auto_trader_orders and okx cancel paths
  with symbol context; log per-order cancel failures in okx CancelAllOrders
This commit is contained in:
tinkle-community
2026-06-11 00:30:34 +08:00
parent 094ab45476
commit 9ea9bd705f
15 changed files with 206 additions and 50 deletions

View File

@@ -36,14 +36,21 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
foundUSDT = true foundUSDT = true
// Parse Aster fields (reference: https://github.com/asterdex/api-docs) // Parse Aster fields (reference: https://github.com/asterdex/api-docs)
var parseErr error
if avail, ok := bal["availableBalance"].(string); ok { if avail, ok := bal["availableBalance"].(string); ok {
availableBalance, _ = strconv.ParseFloat(avail, 64) if availableBalance, parseErr = types.ParseFloatField("availableBalance", avail); parseErr != nil {
return nil, parseErr
}
} }
if unpnl, ok := bal["crossUnPnl"].(string); ok { if unpnl, ok := bal["crossUnPnl"].(string); ok {
crossUnPnl, _ = strconv.ParseFloat(unpnl, 64) if crossUnPnl, parseErr = types.ParseFloatField("crossUnPnl", unpnl); parseErr != nil {
return nil, parseErr
}
} }
if cwb, ok := bal["crossWalletBalance"].(string); ok { if cwb, ok := bal["crossWalletBalance"].(string); ok {
crossWalletBalance, _ = strconv.ParseFloat(cwb, 64) if crossWalletBalance, parseErr = types.ParseFloatField("crossWalletBalance", cwb); parseErr != nil {
return nil, parseErr
}
} }
break break
} }

View File

@@ -53,7 +53,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err)
} }
// Get balance (needed for multiple checks) // Get balance (needed for multiple checks)
@@ -117,7 +117,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
// Open position // Open position
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage) order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open long position for %s: %w", decision.Symbol, err)
} }
// Record order ID // Record order ID
@@ -170,7 +170,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err)
} }
// Get balance (needed for multiple checks) // Get balance (needed for multiple checks)
@@ -234,7 +234,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
// Open position // Open position
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage) order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open short position for %s: %w", decision.Symbol, err)
} }
// Record order ID // Record order ID
@@ -269,7 +269,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err)
} }
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
@@ -311,7 +311,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
// Close position // Close position
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
if err != nil { if err != nil {
return err return fmt.Errorf("failed to close long position for %s: %w", decision.Symbol, err)
} }
// Record order ID // Record order ID
@@ -333,7 +333,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
// Get current price // Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err)
} }
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
@@ -375,7 +375,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
// Close position // Close position
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all
if err != nil { if err != nil {
return err return fmt.Errorf("failed to close short position for %s: %w", decision.Symbol, err)
} }
// Record order ID // Record order ID

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http"
"nofx/hook" "nofx/hook"
"nofx/logger" "nofx/logger"
"strings" "strings"
@@ -63,6 +64,9 @@ type FuturesTrader struct {
// NewFuturesTrader creates futures trader // NewFuturesTrader creates futures trader
func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader { func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader {
client := futures.NewClient(apiKey, secretKey) client := futures.NewClient(apiKey, secretKey)
// The SDK defaults to http.DefaultClient, which has no timeout — a hung
// connection would stall the trading loop indefinitely.
client.HTTPClient = &http.Client{Timeout: 30 * time.Second}
hookRes := hook.HookExec[hook.NewBinanceTraderResult](hook.NEW_BINANCE_TRADER, userId, client) hookRes := hook.HookExec[hook.NewBinanceTraderResult](hook.NEW_BINANCE_TRADER, userId, client)
if hookRes != nil && hookRes.GetResult() != nil { if hookRes != nil && hookRes.GetResult() != nil {

View File

@@ -30,9 +30,17 @@ func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) {
} }
result := make(map[string]interface{}) result := make(map[string]interface{})
result["totalWalletBalance"], _ = strconv.ParseFloat(account.TotalWalletBalance, 64) for field, value := range map[string]string{
result["availableBalance"], _ = strconv.ParseFloat(account.AvailableBalance, 64) "totalWalletBalance": account.TotalWalletBalance,
result["totalUnrealizedProfit"], _ = strconv.ParseFloat(account.TotalUnrealizedProfit, 64) "availableBalance": account.AvailableBalance,
"totalUnrealizedProfit": account.TotalUnrealizedProfit,
} {
parsed, parseErr := types.ParseFloatField(field, value)
if parseErr != nil {
return nil, parseErr
}
result[field] = parsed
}
logger.Infof("✓ Binance API returned: total balance=%s, available=%s, unrealized PnL=%s", logger.Infof("✓ Binance API returned: total balance=%s, available=%s, unrealized PnL=%s",
account.TotalWalletBalance, account.TotalWalletBalance,

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -43,9 +44,15 @@ func (t *BitgetTrader) GetBalance() (map[string]interface{}, error) {
var totalEquity, availableBalance, unrealizedPnL float64 var totalEquity, availableBalance, unrealizedPnL float64
for _, acc := range accounts { for _, acc := range accounts {
if acc.MarginCoin == "USDT" { if acc.MarginCoin == "USDT" {
totalEquity, _ = strconv.ParseFloat(acc.AccountEquity, 64) if totalEquity, err = types.ParseFloatField("accountEquity", acc.AccountEquity); err != nil {
availableBalance, _ = strconv.ParseFloat(acc.Available, 64) return nil, err
unrealizedPnL, _ = strconv.ParseFloat(acc.UnrealizedPL, 64) }
if availableBalance, err = types.ParseFloatField("available", acc.Available); err != nil {
return nil, err
}
if unrealizedPnL, err = types.ParseFloatField("unrealizedPL", acc.UnrealizedPL); err != nil {
return nil, err
}
logger.Infof("✓ [Bitget] Balance: equity=%.2f, available=%.2f", totalEquity, availableBalance) logger.Infof("✓ [Bitget] Balance: equity=%.2f, available=%.2f", totalEquity, availableBalance)
break break
} }

View File

@@ -45,16 +45,22 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
client := bybit.NewBybitHttpClient(apiKey, secretKey, bybit.WithBaseURL(bybit.MAINNET)) client := bybit.NewBybitHttpClient(apiKey, secretKey, bybit.WithBaseURL(bybit.MAINNET))
// Set HTTP transport // Set HTTP transport. Use a dedicated client instead of mutating the
if client != nil && client.HTTPClient != nil { // SDK default (http.DefaultClient): mutating it would leak the referer
defaultTransport := client.HTTPClient.Transport // header to every other request in the process, and the default client
if defaultTransport == nil { // has no timeout, so a hung connection would stall the trading loop.
defaultTransport = http.DefaultTransport if client != nil {
defaultTransport := http.DefaultTransport
if client.HTTPClient != nil && client.HTTPClient != http.DefaultClient && client.HTTPClient.Transport != nil {
defaultTransport = client.HTTPClient.Transport
} }
client.HTTPClient.Transport = &headerRoundTripper{ client.HTTPClient = &http.Client{
base: defaultTransport, Timeout: 30 * time.Second,
refererID: src, Transport: &headerRoundTripper{
base: defaultTransport,
refererID: src,
},
} }
} }

View File

@@ -51,19 +51,28 @@ func (t *BybitTrader) GetBalance() (map[string]interface{}, error) {
if len(list) > 0 { if len(list) > 0 {
account, _ := list[0].(map[string]interface{}) account, _ := list[0].(map[string]interface{})
var parseErr error
if equityStr, ok := account["totalEquity"].(string); ok { if equityStr, ok := account["totalEquity"].(string); ok {
totalEquity, _ = strconv.ParseFloat(equityStr, 64) if totalEquity, parseErr = types.ParseFloatField("totalEquity", equityStr); parseErr != nil {
return nil, parseErr
}
} }
if availStr, ok := account["totalAvailableBalance"].(string); ok { if availStr, ok := account["totalAvailableBalance"].(string); ok {
availableBalance, _ = strconv.ParseFloat(availStr, 64) if availableBalance, parseErr = types.ParseFloatField("totalAvailableBalance", availStr); parseErr != nil {
return nil, parseErr
}
} }
// Bybit UNIFIED account wallet balance field // Bybit UNIFIED account wallet balance field
if walletStr, ok := account["totalWalletBalance"].(string); ok { if walletStr, ok := account["totalWalletBalance"].(string); ok {
totalWalletBalance, _ = strconv.ParseFloat(walletStr, 64) if totalWalletBalance, parseErr = types.ParseFloatField("totalWalletBalance", walletStr); parseErr != nil {
return nil, parseErr
}
} }
// Bybit perpetual contract unrealized PnL // Bybit perpetual contract unrealized PnL
if uplStr, ok := account["totalPerpUPL"].(string); ok { if uplStr, ok := account["totalPerpUPL"].(string); ok {
totalPerpUPL, _ = strconv.ParseFloat(uplStr, 64) if totalPerpUPL, parseErr = types.ParseFloatField("totalPerpUPL", uplStr); parseErr != nil {
return nil, parseErr
}
} }
} }

View File

@@ -3,6 +3,7 @@ package gate
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"nofx/trader/types" "nofx/trader/types"
"strings" "strings"
"sync" "sync"
@@ -34,6 +35,9 @@ type GateTrader struct {
func NewGateTrader(apiKey, secretKey string) *GateTrader { func NewGateTrader(apiKey, secretKey string) *GateTrader {
config := gateapi.NewConfiguration() config := gateapi.NewConfiguration()
config.AddDefaultHeader("X-Gate-Channel-Id", "nofx") config.AddDefaultHeader("X-Gate-Channel-Id", "nofx")
// The SDK default HTTP client has no timeout — a hung connection would
// stall the trading loop indefinitely.
config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
client := gateapi.NewAPIClient(config) client := gateapi.NewAPIClient(config)
ctx := context.WithValue(context.Background(), ctx := context.WithValue(context.Background(),

View File

@@ -27,9 +27,18 @@ func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
return nil, fmt.Errorf("failed to get balance: %w", err) return nil, fmt.Errorf("failed to get balance: %w", err)
} }
total, _ := strconv.ParseFloat(accounts.Total, 64) total, err := types.ParseFloatField("total", accounts.Total)
available, _ := strconv.ParseFloat(accounts.Available, 64) if err != nil {
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64) return nil, err
}
available, err := types.ParseFloatField("available", accounts.Available)
if err != nil {
return nil, err
}
unrealizedPnl, err := types.ParseFloatField("unrealisedPnl", accounts.UnrealisedPnl)
if err != nil {
return nil, err
}
result := map[string]interface{}{ result := map[string]interface{}{
"totalWalletBalance": total, "totalWalletBalance": total,

View File

@@ -47,16 +47,25 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
var summaryType string var summaryType string
var summary interface{} var summary interface{}
var parseErr error
if t.isCrossMargin { if t.isCrossMargin {
// Cross margin mode: use CrossMarginSummary // Cross margin mode: use CrossMarginSummary
accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64) if accountValue, parseErr = types.ParseFloatField("accountValue", accountState.CrossMarginSummary.AccountValue); parseErr != nil {
totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64) return nil, parseErr
}
if totalMarginUsed, parseErr = types.ParseFloatField("totalMarginUsed", accountState.CrossMarginSummary.TotalMarginUsed); parseErr != nil {
return nil, parseErr
}
summaryType = "CrossMarginSummary (cross margin)" summaryType = "CrossMarginSummary (cross margin)"
summary = accountState.CrossMarginSummary summary = accountState.CrossMarginSummary
} else { } else {
// Isolated margin mode: use MarginSummary // Isolated margin mode: use MarginSummary
accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64) if accountValue, parseErr = types.ParseFloatField("accountValue", accountState.MarginSummary.AccountValue); parseErr != nil {
totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64) return nil, parseErr
}
if totalMarginUsed, parseErr = types.ParseFloatField("totalMarginUsed", accountState.MarginSummary.TotalMarginUsed); parseErr != nil {
return nil, parseErr
}
summaryType = "MarginSummary (isolated margin)" summaryType = "MarginSummary (isolated margin)"
summary = accountState.MarginSummary summary = accountState.MarginSummary
} }

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
) )
@@ -113,11 +114,26 @@ func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
} }
// Parse string values to float64 // Parse string values to float64
availableBalance, _ := strconv.ParseFloat(accountInfo.AvailableBalance, 64) availableBalance, err := types.ParseFloatField("available_balance", accountInfo.AvailableBalance)
collateral, _ := strconv.ParseFloat(accountInfo.Collateral, 64) if err != nil {
crossAssetValue, _ := strconv.ParseFloat(accountInfo.CrossAssetValue, 64) return nil, err
totalEquity, _ := strconv.ParseFloat(accountInfo.TotalEquity, 64) }
unrealizedPnl, _ := strconv.ParseFloat(accountInfo.UnrealizedPnl, 64) collateral, err := types.ParseFloatField("collateral", accountInfo.Collateral)
if err != nil {
return nil, err
}
crossAssetValue, err := types.ParseFloatField("cross_asset_value", accountInfo.CrossAssetValue)
if err != nil {
return nil, err
}
totalEquity, err := types.ParseFloatField("total_equity", accountInfo.TotalEquity)
if err != nil {
return nil, err
}
unrealizedPnl, err := types.ParseFloatField("unrealized_pnl", accountInfo.UnrealizedPnl)
if err != nil {
return nil, err
}
// Use collateral as total equity if total_equity is 0 // Use collateral as total equity if total_equity is 0
if totalEquity == 0 { if totalEquity == 0 {

View File

@@ -55,13 +55,20 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
var usdtAvail, usdtUPL float64 var usdtAvail, usdtUPL float64
for _, detail := range balance.Details { for _, detail := range balance.Details {
if detail.Ccy == "USDT" { if detail.Ccy == "USDT" {
usdtAvail, _ = strconv.ParseFloat(detail.AvailBal, 64) if usdtAvail, err = types.ParseFloatField("availBal", detail.AvailBal); err != nil {
usdtUPL, _ = strconv.ParseFloat(detail.UPL, 64) return nil, err
}
if usdtUPL, err = types.ParseFloatField("upl", detail.UPL); err != nil {
return nil, err
}
break break
} }
} }
totalEq, _ := strconv.ParseFloat(balance.TotalEq, 64) totalEq, err := types.ParseFloatField("totalEq", balance.TotalEq)
if err != nil {
return nil, err
}
result := map[string]interface{}{ result := map[string]interface{}{
"totalWalletBalance": totalEq, "totalWalletBalance": totalEq,

View File

@@ -508,7 +508,7 @@ func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error {
path := fmt.Sprintf("%s?instType=SWAP&instId=%s&ordType=conditional", okxAlgoPendingPath, instId) path := fmt.Sprintf("%s?instType=SWAP&instId=%s&ordType=conditional", okxAlgoPendingPath, instId)
data, err := t.doRequest("GET", path, nil) data, err := t.doRequest("GET", path, nil)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get pending algo orders for %s: %w", symbol, err)
} }
var orders []struct { var orders []struct {
@@ -517,7 +517,7 @@ func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error {
} }
if err := json.Unmarshal(data, &orders); err != nil { if err := json.Unmarshal(data, &orders); err != nil {
return err return fmt.Errorf("failed to parse pending algo orders for %s: %w", symbol, err)
} }
canceledCount := 0 canceledCount := 0
@@ -552,7 +552,7 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error {
path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId) path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId)
data, err := t.doRequest("GET", path, nil) data, err := t.doRequest("GET", path, nil)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to get pending orders for %s: %w", symbol, err)
} }
var orders []struct { var orders []struct {
@@ -561,7 +561,7 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error {
} }
if err := json.Unmarshal(data, &orders); err != nil { if err := json.Unmarshal(data, &orders); err != nil {
return err return fmt.Errorf("failed to parse pending orders for %s: %w", symbol, err)
} }
// Batch cancel // Batch cancel
@@ -570,11 +570,15 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error {
"instId": order.InstId, "instId": order.InstId,
"ordId": order.OrdId, "ordId": order.OrdId,
} }
t.doRequest("POST", okxCancelOrderPath, body) if _, err := t.doRequest("POST", okxCancelOrderPath, body); err != nil {
logger.Infof(" ⚠ Failed to cancel order %s for %s: %v", order.OrdId, symbol, err)
}
} }
// Also cancel algo orders // Also cancel algo orders
t.cancelAlgoOrders(symbol, "") if err := t.cancelAlgoOrders(symbol, ""); err != nil {
logger.Infof(" ⚠ Failed to cancel algo orders for %s: %v", symbol, err)
}
if len(orders) > 0 { if len(orders) > 0 {
logger.Infof(" ✓ Canceled all pending orders for %s", symbol) logger.Infof(" ✓ Canceled all pending orders for %s", symbol)

22
trader/types/parse.go Normal file
View File

@@ -0,0 +1,22 @@
package types
import (
"fmt"
"strconv"
)
// ParseFloatField parses a numeric string field from an exchange API response.
// An empty string is treated as zero, since exchanges commonly omit fields
// that have no value (e.g. unrealized PnL with no open position). A non-empty
// value that fails to parse returns an error naming the field, so a malformed
// API response surfaces instead of silently becoming a zero balance.
func ParseFloatField(field, value string) (float64, error) {
if value == "" {
return 0, nil
}
v, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse %s value %q: %w", field, value, err)
}
return v, nil
}

View File

@@ -0,0 +1,44 @@
package types
import (
"strings"
"testing"
)
func TestParseFloatField(t *testing.T) {
tests := []struct {
name string
field string
input string
want float64
wantErr bool
}{
{name: "normal value", field: "totalEq", input: "1234.56", want: 1234.56},
{name: "zero", field: "totalEq", input: "0", want: 0},
{name: "negative", field: "upl", input: "-12.5", want: -12.5},
{name: "empty string treated as zero", field: "upl", input: "", want: 0},
{name: "garbage returns error", field: "totalEq", input: "abc", wantErr: true},
{name: "null literal returns error", field: "totalEq", input: "null", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseFloatField(tt.field, tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseFloatField(%q, %q) expected error, got nil", tt.field, tt.input)
}
if !strings.Contains(err.Error(), tt.field) {
t.Errorf("error %q should mention field name %q", err.Error(), tt.field)
}
return
}
if err != nil {
t.Fatalf("ParseFloatField(%q, %q) unexpected error: %v", tt.field, tt.input, err)
}
if got != tt.want {
t.Errorf("ParseFloatField(%q, %q) = %v, want %v", tt.field, tt.input, got, tt.want)
}
})
}
}