mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
cb31782be4
- 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)
181 lines
5.0 KiB
Go
181 lines
5.0 KiB
Go
package binance
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"nofx/hook"
|
||
"nofx/logger"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/adshao/go-binance/v2/futures"
|
||
)
|
||
|
||
// getBrOrderID generates unique order ID (for futures contracts)
|
||
// Format: x-{BR_ID}{TIMESTAMP}{RANDOM}
|
||
// Futures limit is 32 characters, use this limit consistently
|
||
// Uses nanosecond timestamp + random number to ensure global uniqueness (collision probability < 10^-20)
|
||
func getBrOrderID() string {
|
||
brID := "KzrpZaP9" // Futures br ID
|
||
|
||
// Calculate available space: 32 - len("x-KzrpZaP9") = 32 - 11 = 21 characters
|
||
// Allocation: 13-digit timestamp + 8-digit random = 21 characters (perfect utilization)
|
||
timestamp := time.Now().UnixNano() % 10000000000000 // 13-digit nanosecond timestamp
|
||
|
||
// Generate 4-byte random number (8 hex digits)
|
||
randomBytes := make([]byte, 4)
|
||
rand.Read(randomBytes)
|
||
randomHex := hex.EncodeToString(randomBytes)
|
||
|
||
// Format: x-KzrpZaP9{13-digit timestamp}{8-digit random}
|
||
// Example: x-KzrpZaP91234567890123abcdef12 (exactly 31 characters)
|
||
orderID := fmt.Sprintf("x-%s%d%s", brID, timestamp, randomHex)
|
||
|
||
// Ensure not exceeding 32-character limit (theoretically exactly 31 characters)
|
||
if len(orderID) > 32 {
|
||
orderID = orderID[:32]
|
||
}
|
||
|
||
return orderID
|
||
}
|
||
|
||
// FuturesTrader Binance futures trader
|
||
type FuturesTrader struct {
|
||
client *futures.Client
|
||
|
||
// Balance cache
|
||
cachedBalance map[string]interface{}
|
||
balanceCacheTime time.Time
|
||
balanceCacheMutex sync.RWMutex
|
||
|
||
// Position cache
|
||
cachedPositions []map[string]interface{}
|
||
positionsCacheTime time.Time
|
||
positionsCacheMutex sync.RWMutex
|
||
|
||
// Cache validity period (15 seconds)
|
||
cacheDuration time.Duration
|
||
}
|
||
|
||
// NewFuturesTrader creates futures trader
|
||
func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader {
|
||
client := futures.NewClient(apiKey, secretKey)
|
||
|
||
hookRes := hook.HookExec[hook.NewBinanceTraderResult](hook.NEW_BINANCE_TRADER, userId, client)
|
||
if hookRes != nil && hookRes.GetResult() != nil {
|
||
client = hookRes.GetResult()
|
||
}
|
||
|
||
// Sync time to avoid "Timestamp ahead" error
|
||
syncBinanceServerTime(client)
|
||
trader := &FuturesTrader{
|
||
client: client,
|
||
cacheDuration: 15 * time.Second, // 15-second cache
|
||
}
|
||
|
||
// Set dual-side position mode (Hedge Mode)
|
||
// This is required because the code uses PositionSide (LONG/SHORT)
|
||
if err := trader.setDualSidePosition(); err != nil {
|
||
logger.Infof("⚠️ Failed to set dual-side position mode: %v (ignore this warning if already in dual-side mode)", err)
|
||
}
|
||
|
||
return trader
|
||
}
|
||
|
||
// setDualSidePosition sets dual-side position mode (called during initialization)
|
||
func (t *FuturesTrader) setDualSidePosition() error {
|
||
// Try to set dual-side position mode
|
||
err := t.client.NewChangePositionModeService().
|
||
DualSide(true). // true = dual-side position (Hedge Mode)
|
||
Do(context.Background())
|
||
|
||
if err != nil {
|
||
// If error message contains "No need to change", it means already in dual-side position mode
|
||
if strings.Contains(err.Error(), "No need to change position side") {
|
||
logger.Infof(" ✓ Account is already in dual-side position mode (Hedge Mode)")
|
||
return nil
|
||
}
|
||
// Other errors are returned (but won't interrupt initialization in the caller)
|
||
return err
|
||
}
|
||
|
||
logger.Infof(" ✓ Account has been switched to dual-side position mode (Hedge Mode)")
|
||
logger.Infof(" ℹ️ Dual-side position mode allows holding both long and short positions simultaneously")
|
||
return nil
|
||
}
|
||
|
||
// syncBinanceServerTime syncs Binance server time to ensure request timestamps are valid
|
||
func syncBinanceServerTime(client *futures.Client) {
|
||
serverTime, err := client.NewServerTimeService().Do(context.Background())
|
||
if err != nil {
|
||
logger.Infof("⚠️ Failed to sync Binance server time: %v", err)
|
||
return
|
||
}
|
||
|
||
now := time.Now().UnixMilli()
|
||
offset := now - serverTime
|
||
client.TimeOffset = offset
|
||
logger.Infof("⏱ Binance server time synced, offset %dms", offset)
|
||
}
|
||
|
||
// Helper functions
|
||
|
||
func contains(s, substr string) bool {
|
||
return len(s) >= len(substr) && stringContains(s, substr)
|
||
}
|
||
|
||
func stringContains(s, substr string) bool {
|
||
for i := 0; i <= len(s)-len(substr); i++ {
|
||
if s[i:i+len(substr)] == substr {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// calculatePrecision calculates precision from stepSize
|
||
func calculatePrecision(stepSize string) int {
|
||
// Remove trailing zeros
|
||
stepSize = trimTrailingZeros(stepSize)
|
||
|
||
// Find decimal point
|
||
dotIndex := -1
|
||
for i := 0; i < len(stepSize); i++ {
|
||
if stepSize[i] == '.' {
|
||
dotIndex = i
|
||
break
|
||
}
|
||
}
|
||
|
||
// If no decimal point or decimal point is at the end, precision is 0
|
||
if dotIndex == -1 || dotIndex == len(stepSize)-1 {
|
||
return 0
|
||
}
|
||
|
||
// Return number of digits after decimal point
|
||
return len(stepSize) - dotIndex - 1
|
||
}
|
||
|
||
// trimTrailingZeros removes trailing zeros
|
||
func trimTrailingZeros(s string) string {
|
||
// If no decimal point, return directly
|
||
if !stringContains(s, ".") {
|
||
return s
|
||
}
|
||
|
||
// Iterate backwards to remove trailing zeros
|
||
for len(s) > 0 && s[len(s)-1] == '0' {
|
||
s = s[:len(s)-1]
|
||
}
|
||
|
||
// If last character is decimal point, remove it too
|
||
if len(s) > 0 && s[len(s)-1] == '.' {
|
||
s = s[:len(s)-1]
|
||
}
|
||
|
||
return s
|
||
}
|