diff --git a/trader/aster/trader_account.go b/trader/aster/trader_account.go index bfc41304..a573c564 100644 --- a/trader/aster/trader_account.go +++ b/trader/aster/trader_account.go @@ -36,14 +36,21 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) { foundUSDT = true // Parse Aster fields (reference: https://github.com/asterdex/api-docs) + var parseErr error 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 { - crossUnPnl, _ = strconv.ParseFloat(unpnl, 64) + if crossUnPnl, parseErr = types.ParseFloatField("crossUnPnl", unpnl); parseErr != nil { + return nil, parseErr + } } 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 } diff --git a/trader/auto_trader_orders.go b/trader/auto_trader_orders.go index edfc1995..e18b2b8f 100644 --- a/trader/auto_trader_orders.go +++ b/trader/auto_trader_orders.go @@ -53,7 +53,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) 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) @@ -117,7 +117,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio // Open position order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage) if err != nil { - return err + return fmt.Errorf("failed to open long position for %s: %w", decision.Symbol, err) } // Record order ID @@ -170,7 +170,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) 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) @@ -234,7 +234,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti // Open position order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage) if err != nil { - return err + return fmt.Errorf("failed to open short position for %s: %w", decision.Symbol, err) } // Record order ID @@ -269,7 +269,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { - return err + return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err) } actionRecord.Price = marketData.CurrentPrice @@ -311,7 +311,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti // Close position order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all if err != nil { - return err + return fmt.Errorf("failed to close long position for %s: %w", decision.Symbol, err) } // Record order ID @@ -333,7 +333,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act // Get current price marketData, err := market.GetWithExchange(decision.Symbol, at.exchange) if err != nil { - return err + return fmt.Errorf("failed to get market data for %s: %w", decision.Symbol, err) } actionRecord.Price = marketData.CurrentPrice @@ -375,7 +375,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act // Close position order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all if err != nil { - return err + return fmt.Errorf("failed to close short position for %s: %w", decision.Symbol, err) } // Record order ID diff --git a/trader/binance/futures.go b/trader/binance/futures.go index efc3bb32..57d3c1e9 100644 --- a/trader/binance/futures.go +++ b/trader/binance/futures.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "net/http" "nofx/hook" "nofx/logger" "strings" @@ -63,6 +64,9 @@ type FuturesTrader struct { // NewFuturesTrader creates futures trader func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader { 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) if hookRes != nil && hookRes.GetResult() != nil { diff --git a/trader/binance/futures_account.go b/trader/binance/futures_account.go index 549e3fd7..812a182d 100644 --- a/trader/binance/futures_account.go +++ b/trader/binance/futures_account.go @@ -30,9 +30,17 @@ func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) { } 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) + for field, value := range map[string]string{ + "totalWalletBalance": account.TotalWalletBalance, + "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", account.TotalWalletBalance, diff --git a/trader/bitget/trader_account.go b/trader/bitget/trader_account.go index 5449a33c..eb6e8276 100644 --- a/trader/bitget/trader_account.go +++ b/trader/bitget/trader_account.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "nofx/logger" + "nofx/trader/types" "strconv" "strings" "time" @@ -43,9 +44,15 @@ func (t *BitgetTrader) GetBalance() (map[string]interface{}, error) { var totalEquity, availableBalance, unrealizedPnL float64 for _, acc := range accounts { if acc.MarginCoin == "USDT" { - totalEquity, _ = strconv.ParseFloat(acc.AccountEquity, 64) - availableBalance, _ = strconv.ParseFloat(acc.Available, 64) - unrealizedPnL, _ = strconv.ParseFloat(acc.UnrealizedPL, 64) + if totalEquity, err = types.ParseFloatField("accountEquity", acc.AccountEquity); err != nil { + return nil, err + } + 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) break } diff --git a/trader/bybit/trader.go b/trader/bybit/trader.go index 5f8e9bfb..fd7995f5 100644 --- a/trader/bybit/trader.go +++ b/trader/bybit/trader.go @@ -45,16 +45,22 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader { client := bybit.NewBybitHttpClient(apiKey, secretKey, bybit.WithBaseURL(bybit.MAINNET)) - // Set HTTP transport - if client != nil && client.HTTPClient != nil { - defaultTransport := client.HTTPClient.Transport - if defaultTransport == nil { - defaultTransport = http.DefaultTransport + // Set HTTP transport. Use a dedicated client instead of mutating the + // SDK default (http.DefaultClient): mutating it would leak the referer + // header to every other request in the process, and the default client + // has no timeout, so a hung connection would stall the trading loop. + 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{ - base: defaultTransport, - refererID: src, + client.HTTPClient = &http.Client{ + Timeout: 30 * time.Second, + Transport: &headerRoundTripper{ + base: defaultTransport, + refererID: src, + }, } } diff --git a/trader/bybit/trader_account.go b/trader/bybit/trader_account.go index 88616a04..ae5943c0 100644 --- a/trader/bybit/trader_account.go +++ b/trader/bybit/trader_account.go @@ -51,19 +51,28 @@ func (t *BybitTrader) GetBalance() (map[string]interface{}, error) { if len(list) > 0 { account, _ := list[0].(map[string]interface{}) + var parseErr error 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 { - availableBalance, _ = strconv.ParseFloat(availStr, 64) + if availableBalance, parseErr = types.ParseFloatField("totalAvailableBalance", availStr); parseErr != nil { + return nil, parseErr + } } // Bybit UNIFIED account wallet balance field 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 if uplStr, ok := account["totalPerpUPL"].(string); ok { - totalPerpUPL, _ = strconv.ParseFloat(uplStr, 64) + if totalPerpUPL, parseErr = types.ParseFloatField("totalPerpUPL", uplStr); parseErr != nil { + return nil, parseErr + } } } diff --git a/trader/gate/trader.go b/trader/gate/trader.go index 27a1d1e1..d4bde56c 100644 --- a/trader/gate/trader.go +++ b/trader/gate/trader.go @@ -3,6 +3,7 @@ package gate import ( "context" "fmt" + "net/http" "nofx/trader/types" "strings" "sync" @@ -34,6 +35,9 @@ type GateTrader struct { func NewGateTrader(apiKey, secretKey string) *GateTrader { config := gateapi.NewConfiguration() 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) ctx := context.WithValue(context.Background(), diff --git a/trader/gate/trader_account.go b/trader/gate/trader_account.go index 1c912315..00a92ec0 100644 --- a/trader/gate/trader_account.go +++ b/trader/gate/trader_account.go @@ -27,9 +27,18 @@ func (t *GateTrader) GetBalance() (map[string]interface{}, error) { return nil, fmt.Errorf("failed to get balance: %w", err) } - total, _ := strconv.ParseFloat(accounts.Total, 64) - available, _ := strconv.ParseFloat(accounts.Available, 64) - unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64) + total, err := types.ParseFloatField("total", accounts.Total) + if err != nil { + 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{}{ "totalWalletBalance": total, diff --git a/trader/hyperliquid/trader_account.go b/trader/hyperliquid/trader_account.go index f556455a..5104c2ef 100644 --- a/trader/hyperliquid/trader_account.go +++ b/trader/hyperliquid/trader_account.go @@ -47,16 +47,25 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { var summaryType string var summary interface{} + var parseErr error if t.isCrossMargin { // Cross margin mode: use CrossMarginSummary - accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64) - totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64) + if accountValue, parseErr = types.ParseFloatField("accountValue", accountState.CrossMarginSummary.AccountValue); parseErr != nil { + return nil, parseErr + } + if totalMarginUsed, parseErr = types.ParseFloatField("totalMarginUsed", accountState.CrossMarginSummary.TotalMarginUsed); parseErr != nil { + return nil, parseErr + } summaryType = "CrossMarginSummary (cross margin)" summary = accountState.CrossMarginSummary } else { // Isolated margin mode: use MarginSummary - accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64) - totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64) + if accountValue, parseErr = types.ParseFloatField("accountValue", accountState.MarginSummary.AccountValue); parseErr != nil { + return nil, parseErr + } + if totalMarginUsed, parseErr = types.ParseFloatField("totalMarginUsed", accountState.MarginSummary.TotalMarginUsed); parseErr != nil { + return nil, parseErr + } summaryType = "MarginSummary (isolated margin)" summary = accountState.MarginSummary } diff --git a/trader/lighter/account.go b/trader/lighter/account.go index 39c14130..a37597c1 100644 --- a/trader/lighter/account.go +++ b/trader/lighter/account.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "nofx/logger" + "nofx/trader/types" "strconv" "strings" ) @@ -113,11 +114,26 @@ func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) { } // Parse string values to float64 - availableBalance, _ := strconv.ParseFloat(accountInfo.AvailableBalance, 64) - collateral, _ := strconv.ParseFloat(accountInfo.Collateral, 64) - crossAssetValue, _ := strconv.ParseFloat(accountInfo.CrossAssetValue, 64) - totalEquity, _ := strconv.ParseFloat(accountInfo.TotalEquity, 64) - unrealizedPnl, _ := strconv.ParseFloat(accountInfo.UnrealizedPnl, 64) + availableBalance, err := types.ParseFloatField("available_balance", accountInfo.AvailableBalance) + if err != nil { + return nil, err + } + 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 if totalEquity == 0 { diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go index 9220b3d8..3c20ecbb 100644 --- a/trader/okx/trader_account.go +++ b/trader/okx/trader_account.go @@ -55,13 +55,20 @@ func (t *OKXTrader) GetBalance() (map[string]interface{}, error) { var usdtAvail, usdtUPL float64 for _, detail := range balance.Details { if detail.Ccy == "USDT" { - usdtAvail, _ = strconv.ParseFloat(detail.AvailBal, 64) - usdtUPL, _ = strconv.ParseFloat(detail.UPL, 64) + if usdtAvail, err = types.ParseFloatField("availBal", detail.AvailBal); err != nil { + return nil, err + } + if usdtUPL, err = types.ParseFloatField("upl", detail.UPL); err != nil { + return nil, err + } break } } - totalEq, _ := strconv.ParseFloat(balance.TotalEq, 64) + totalEq, err := types.ParseFloatField("totalEq", balance.TotalEq) + if err != nil { + return nil, err + } result := map[string]interface{}{ "totalWalletBalance": totalEq, diff --git a/trader/okx/trader_orders.go b/trader/okx/trader_orders.go index ebe23115..5b5decf8 100644 --- a/trader/okx/trader_orders.go +++ b/trader/okx/trader_orders.go @@ -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) data, err := t.doRequest("GET", path, nil) if err != nil { - return err + return fmt.Errorf("failed to get pending algo orders for %s: %w", symbol, err) } var orders []struct { @@ -517,7 +517,7 @@ func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error { } 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 @@ -552,7 +552,7 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error { path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId) data, err := t.doRequest("GET", path, nil) if err != nil { - return err + return fmt.Errorf("failed to get pending orders for %s: %w", symbol, err) } var orders []struct { @@ -561,7 +561,7 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error { } 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 @@ -570,11 +570,15 @@ func (t *OKXTrader) CancelAllOrders(symbol string) error { "instId": order.InstId, "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 - 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 { logger.Infof(" ✓ Canceled all pending orders for %s", symbol) diff --git a/trader/types/parse.go b/trader/types/parse.go new file mode 100644 index 00000000..7cad2bde --- /dev/null +++ b/trader/types/parse.go @@ -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 +} diff --git a/trader/types/parse_test.go b/trader/types/parse_test.go new file mode 100644 index 00000000..7a2fc217 --- /dev/null +++ b/trader/types/parse_test.go @@ -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) + } + }) + } +}