mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
- Bybit: replace http.Get/http.DefaultClient with shared bybitHTTPClient (30s timeout, connection pooling)
- Frontend: fix resetPassword missing auth token (endpoint now requires auth)
- Frontend: fix SettingsPage using wrong localStorage key ('token' → 'auth_token')
- Agent: migrate remaining io.ReadAll(io.LimitReader()) to safe.ReadAllLimited for consistency
- Remove unused 'io' imports from brain.go and sentinel.go
207 lines
4.9 KiB
Go
207 lines
4.9 KiB
Go
package bybit
|
|
|
|
import (
|
|
"nofx/safe"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"nofx/logger"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
bybit "github.com/bybit-exchange/bybit.go.api"
|
|
)
|
|
|
|
// bybitHTTPClient is a shared HTTP client for direct Bybit API calls (public + signed).
|
|
// Reused across requests to benefit from connection pooling; 30s timeout prevents hangs.
|
|
var bybitHTTPClient = &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 20,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
}
|
|
|
|
// BybitTrader Bybit USDT Perpetual Futures Trader
|
|
type BybitTrader struct {
|
|
client *bybit.Client
|
|
apiKey string
|
|
secretKey string
|
|
|
|
// Balance cache
|
|
cachedBalance map[string]interface{}
|
|
balanceCacheTime time.Time
|
|
balanceCacheMutex sync.RWMutex
|
|
|
|
// Position cache
|
|
cachedPositions []map[string]interface{}
|
|
positionsCacheTime time.Time
|
|
positionsCacheMutex sync.RWMutex
|
|
|
|
// Trading pair precision cache (symbol -> qtyStep)
|
|
qtyStepCache map[string]float64
|
|
qtyStepCacheMutex sync.RWMutex
|
|
|
|
// Cache duration (15 seconds)
|
|
cacheDuration time.Duration
|
|
}
|
|
|
|
// NewBybitTrader creates a Bybit trader
|
|
func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
|
|
const src = "Up000938"
|
|
|
|
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
|
|
}
|
|
|
|
client.HTTPClient.Transport = &headerRoundTripper{
|
|
base: defaultTransport,
|
|
refererID: src,
|
|
}
|
|
}
|
|
|
|
trader := &BybitTrader{
|
|
client: client,
|
|
apiKey: apiKey,
|
|
secretKey: secretKey,
|
|
cacheDuration: 15 * time.Second,
|
|
qtyStepCache: make(map[string]float64),
|
|
}
|
|
|
|
logger.Infof("🔵 [Bybit] Trader initialized")
|
|
|
|
return trader
|
|
}
|
|
|
|
// headerRoundTripper HTTP RoundTripper for adding custom headers
|
|
type headerRoundTripper struct {
|
|
base http.RoundTripper
|
|
refererID string
|
|
}
|
|
|
|
func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
req.Header.Set("Referer", h.refererID)
|
|
return h.base.RoundTrip(req)
|
|
}
|
|
|
|
// getQtyStep retrieves the quantity step for a trading pair
|
|
func (t *BybitTrader) getQtyStep(symbol string) float64 {
|
|
// Check cache first
|
|
t.qtyStepCacheMutex.RLock()
|
|
if step, ok := t.qtyStepCache[symbol]; ok {
|
|
t.qtyStepCacheMutex.RUnlock()
|
|
return step
|
|
}
|
|
t.qtyStepCacheMutex.RUnlock()
|
|
|
|
// Call public API directly to get contract information
|
|
url := fmt.Sprintf("https://api.bybit.com/v5/market/instruments-info?category=linear&symbol=%s", symbol)
|
|
resp, err := bybitHTTPClient.Get(url)
|
|
if err != nil {
|
|
logger.Infof("⚠️ [Bybit] Failed to get precision info for %s: %v", symbol, err)
|
|
return 1 // Default to integer
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := safe.ReadAllLimited(resp.Body)
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
|
|
var result struct {
|
|
RetCode int `json:"retCode"`
|
|
Result struct {
|
|
List []struct {
|
|
LotSizeFilter struct {
|
|
QtyStep string `json:"qtyStep"`
|
|
} `json:"lotSizeFilter"`
|
|
} `json:"list"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return 1
|
|
}
|
|
|
|
if result.RetCode != 0 || len(result.Result.List) == 0 {
|
|
return 1
|
|
}
|
|
|
|
qtyStep, _ := strconv.ParseFloat(result.Result.List[0].LotSizeFilter.QtyStep, 64)
|
|
if qtyStep <= 0 {
|
|
qtyStep = 1
|
|
}
|
|
|
|
// Cache result
|
|
t.qtyStepCacheMutex.Lock()
|
|
t.qtyStepCache[symbol] = qtyStep
|
|
t.qtyStepCacheMutex.Unlock()
|
|
|
|
logger.Infof("🔵 [Bybit] %s qtyStep: %v", symbol, qtyStep)
|
|
|
|
return qtyStep
|
|
}
|
|
|
|
// FormatQuantity formats quantity
|
|
func (t *BybitTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
|
// Get qtyStep for this symbol
|
|
qtyStep := t.getQtyStep(symbol)
|
|
|
|
// Align quantity according to qtyStep (round down to nearest step)
|
|
alignedQty := math.Floor(quantity/qtyStep) * qtyStep
|
|
|
|
// Calculate required decimal places
|
|
decimals := 0
|
|
if qtyStep < 1 {
|
|
stepStr := strconv.FormatFloat(qtyStep, 'f', -1, 64)
|
|
if idx := strings.Index(stepStr, "."); idx >= 0 {
|
|
decimals = len(stepStr) - idx - 1
|
|
}
|
|
}
|
|
|
|
// Format
|
|
format := fmt.Sprintf("%%.%df", decimals)
|
|
formatted := fmt.Sprintf(format, alignedQty)
|
|
|
|
return formatted, nil
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
func (t *BybitTrader) clearCache() {
|
|
t.balanceCacheMutex.Lock()
|
|
t.cachedBalance = nil
|
|
t.balanceCacheMutex.Unlock()
|
|
|
|
t.positionsCacheMutex.Lock()
|
|
t.cachedPositions = nil
|
|
t.positionsCacheMutex.Unlock()
|
|
}
|
|
|
|
func (t *BybitTrader) parseOrderResult(result *bybit.ServerResponse) (map[string]interface{}, error) {
|
|
if result.RetCode != 0 {
|
|
return nil, fmt.Errorf("order placement failed: %s", result.RetMsg)
|
|
}
|
|
|
|
resultData, ok := result.Result.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("return format error")
|
|
}
|
|
|
|
orderId, _ := resultData["orderId"].(string)
|
|
|
|
return map[string]interface{}{
|
|
"orderId": orderId,
|
|
"status": "NEW",
|
|
}, nil
|
|
}
|