fix: prevent panics from unsafe type assertions in trading code + add request body limit

Security & Reliability:
- Add requestBodyLimitMiddleware (1MB) to prevent OOM from oversized API payloads
- Fix defer resp.Body.Close() inside loop in getPublicIPFromAPI (connection leak)
- Add posFloat64/posString safe helpers for position map access

Panic Prevention (critical for trading):
- Convert 30+ unsafe type assertions (pos["key"].(type)) to safe comma-ok
  pattern across all exchange traders: OKX, Hyperliquid, Aster, Bybit,
  KuCoin, Gate, Bitget, Binance
- Fix auto_trader_risk.go: drawdown monitor could panic and silently stop
  monitoring, leaving positions unprotected
- Fix auto_trader_decision.go & auto_trader_loop.go: core trading loop
  position parsing now crash-proof
- All trader/ code now has zero unsafe type assertions

Frontend:
- Fix config.ts: rejected promise cached forever on network error (never retries)
This commit is contained in:
shinchan-zhai
2026-03-23 12:01:44 +08:00
parent 574e5602c1
commit 6911bc3e2b
16 changed files with 101 additions and 56 deletions

View File

@@ -36,6 +36,9 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
router := gin.Default()
// Limit request body size to prevent OOM from oversized payloads (1MB default)
router.Use(requestBodyLimitMiddleware(1 << 20))
// Enable CORS
router.Use(corsMiddleware())
@@ -57,6 +60,17 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
return s
}
// requestBodyLimitMiddleware limits the size of incoming request bodies.
// Returns 413 Payload Too Large if the body exceeds maxBytes.
func requestBodyLimitMiddleware(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
}
c.Next()
}
}
// corsMiddleware CORS middleware with configurable allowed origins.
// Set CORS_ALLOWED_ORIGINS env var to a comma-separated list of origins
// (e.g. "http://localhost:5173,https://nofx.example.com").
@@ -445,25 +459,33 @@ func getPublicIPFromAPI() string {
}
for _, service := range services {
resp, err := client.Get(service)
if err != nil {
continue
}
defer resp.Body.Close()
ip := func() string {
resp, err := client.Get(service)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
if resp.StatusCode == http.StatusOK {
body := make([]byte, 128)
n, err := resp.Body.Read(body)
if err != nil && err.Error() != "EOF" {
continue
return ""
}
ip := strings.TrimSpace(string(body[:n]))
parsedIP := net.ParseIP(ip)
candidate := strings.TrimSpace(string(body[:n]))
parsedIP := net.ParseIP(candidate)
// Verify if it's a valid IPv4 address (not containing ":")
if parsedIP != nil && parsedIP.To4() != nil {
return ip
return candidate
}
return ""
}()
if ip != "" {
return ip
}
}

View File

@@ -70,12 +70,12 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
totalMarginUsed := 0.0
realUnrealizedPnl := 0.0
for _, pos := range positions {
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
markPrice, _ := pos["markPrice"].(float64)
quantity, _ := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
unrealizedPnl, _ := pos["unRealizedProfit"].(float64)
realUnrealizedPnl += unrealizedPnl
leverage := 10

View File

@@ -166,7 +166,7 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}
@@ -249,7 +249,7 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
// Aster's GetPositions has already converted short position quantity to positive, use directly
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -133,12 +133,12 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
totalMarginUsed := 0.0
totalUnrealizedPnLCalculated := 0.0
for _, pos := range positions {
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
markPrice := posFloat64(pos, "markPrice")
quantity := posFloat64(pos, "positionAmt")
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
unrealizedPnl := posFloat64(pos, "unRealizedProfit")
totalUnrealizedPnLCalculated += unrealizedPnl
leverage := 10
@@ -199,16 +199,16 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
var result []map[string]interface{}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
symbol := posString(pos, "symbol")
side := posString(pos, "side")
entryPrice := posFloat64(pos, "entryPrice")
markPrice := posFloat64(pos, "markPrice")
quantity := posFloat64(pos, "positionAmt")
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
unrealizedPnl := posFloat64(pos, "unRealizedProfit")
liquidationPrice := posFloat64(pos, "liquidationPrice")
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {

View File

@@ -331,11 +331,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
currentPositionKeys := make(map[string]bool)
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
symbol := posString(pos, "symbol")
side := posString(pos, "side")
entryPrice := posFloat64(pos, "entryPrice")
markPrice := posFloat64(pos, "markPrice")
quantity := posFloat64(pos, "positionAmt")
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
@@ -345,8 +345,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
continue
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
unrealizedPnl := posFloat64(pos, "unRealizedProfit")
liquidationPrice := posFloat64(pos, "liquidationPrice")
// Calculate margin used (estimated)
leverage := 10 // Default value, should actually be fetched from position info

View File

@@ -41,11 +41,14 @@ func (at *AutoTrader) checkPositionDrawdown() {
}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
symbol, _ := pos["symbol"].(string)
side, _ := pos["side"].(string)
entryPrice, _ := pos["entryPrice"].(float64)
markPrice, _ := pos["markPrice"].(float64)
quantity, _ := pos["positionAmt"].(float64)
if symbol == "" || side == "" || entryPrice == 0 {
continue // skip malformed position data
}
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}

View File

@@ -131,7 +131,7 @@ func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]i
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -227,7 +227,7 @@ func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) {
// Get precision from LOT_SIZE filter
for _, filter := range s.Filters {
if filter["filterType"] == "LOT_SIZE" {
stepSize := filter["stepSize"].(string)
stepSize, _ := filter["stepSize"].(string)
precision := calculatePrecision(stepSize)
logger.Infof(" %s quantity precision: %d (stepSize: %s)", symbol, precision, stepSize)
return precision, nil
@@ -264,7 +264,7 @@ func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
// Get precision from PRICE_FILTER filter
for _, filter := range s.Filters {
if filter["filterType"] == "PRICE_FILTER" {
tickSize := filter["tickSize"].(string)
tickSize, _ := filter["tickSize"].(string)
precision := calculatePrecision(tickSize)
return precision, nil
}

View File

@@ -129,7 +129,7 @@ func (t *BitgetTrader) CloseLong(symbol string, quantity float64) (map[string]in
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}
@@ -192,7 +192,7 @@ func (t *BitgetTrader) CloseShort(symbol string, quantity float64) (map[string]i
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -109,7 +109,7 @@ func (t *BybitTrader) CloseLong(symbol string, quantity float64) (map[string]int
for _, pos := range positions {
side, _ := pos["side"].(string)
if pos["symbol"] == symbol && strings.ToLower(side) == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -165,9 +165,10 @@ func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]inte
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
rawSymbol, _ := pos["symbol"].(string)
posSymbol := t.convertSymbol(rawSymbol)
if posSymbol == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}
@@ -233,9 +234,10 @@ func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]int
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
rawSymbol, _ := pos["symbol"].(string)
posSymbol := t.convertSymbol(rawSymbol)
if posSymbol == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -5,6 +5,19 @@ import (
"strconv"
)
// posFloat64 extracts a float64 from a position map, returning 0 on failure.
// Use in loops where a malformed position should be skipped, not crash.
func posFloat64(pos map[string]interface{}, key string) float64 {
v, _ := SafeFloat64(pos, key)
return v
}
// posString extracts a string from a position map, returning "" on failure.
func posString(pos map[string]interface{}, key string) string {
v, _ := SafeString(pos, key)
return v
}
// SafeFloat64 Safely extract float64 value from map
func SafeFloat64(data map[string]interface{}, key string) (float64, error) {
value, ok := data[key]

View File

@@ -179,9 +179,9 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
posSymbol, _ := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}
@@ -266,9 +266,9 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str
}
for _, pos := range positions {
posSymbol := pos["symbol"].(string)
posSymbol, _ := pos["symbol"].(string)
if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
quantity, _ = pos["positionAmt"].(float64)
break
}
}

View File

@@ -161,7 +161,7 @@ func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]in
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
actualQty = pos["positionAmt"].(float64)
actualQty, _ = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
@@ -242,7 +242,7 @@ func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]i
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
actualQty, _ = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {

View File

@@ -188,11 +188,11 @@ func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]inter
for _, pos := range positions {
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v", pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol {
side := pos["side"].(string)
side, _ := pos["side"].(string)
// In net_mode, "long" means positive position
// In dual mode, check explicit "long" side
if side == "long" || (t.positionMode == "net_mode" && side == "long") {
actualQty = pos["positionAmt"].(float64)
actualQty, _ = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode
@@ -300,7 +300,7 @@ func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]inte
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v",
pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
actualQty, _ = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode

View File

@@ -19,6 +19,11 @@ export function getSystemConfig(): Promise<SystemConfig> {
cachedConfig = data
return data
})
.catch((err) => {
// Reset so next call retries instead of returning the rejected promise forever
configPromise = null
throw err
})
return configPromise
}