diff --git a/README.md b/README.md
index 75a318fc..298a0448 100644
--- a/README.md
+++ b/README.md
@@ -45,17 +45,31 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
## Screenshots
-### Competition Mode - Real-time AI Battle
-
+### Config Page
+| AI Models & Exchanges | Traders List |
+|:---:|:---:|
+|
|
|
+
+### Competition Mode
+
+
+
|
|
+
+| Positions | Trader Details |
+|:---:|:---:|
+|
|
|
### Strategy Studio
-
-*Strategy configuration with multiple data sources and AI test*
+| Strategy Editor | Indicators Config |
+|:---:|:---:|
+|
|
|
---
diff --git a/api/server.go b/api/server.go
index 773d43a8..f39b5222 100644
--- a/api/server.go
+++ b/api/server.go
@@ -139,7 +139,9 @@ func (s *Server) setupRoutes() {
// Exchange configuration
protected.GET("/exchanges", s.handleGetExchangeConfigs)
+ protected.POST("/exchanges", s.handleCreateExchange)
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
+ protected.DELETE("/exchanges/:id", s.handleDeleteExchange)
// Strategy management
protected.GET("/strategies", s.handleGetStrategies)
@@ -392,14 +394,17 @@ type ExchangeConfig struct {
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
type SafeExchangeConfig struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Type string `json:"type"` // "cex" or "dex"
+ ID string `json:"id"` // UUID
+ ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
+ AccountName string `json:"account_name"` // User-defined account name
+ Name string `json:"name"` // Display name
+ Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
Testnet bool `json:"testnet,omitempty"`
- HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
- AsterUser string `json:"asterUser"` // Aster username (not sensitive)
- AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
+ HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
+ AsterUser string `json:"asterUser"` // Aster username (not sensitive)
+ AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
+ LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
}
type UpdateModelConfigRequest struct {
@@ -459,8 +464,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
}
}
- // Generate trader ID
- traderID := fmt.Sprintf("%s_%s_%d", req.ExchangeID, req.AIModelID, time.Now().Unix())
+ // Generate trader ID (use short UUID prefix for readability)
+ exchangeIDShort := req.ExchangeID
+ if len(exchangeIDShort) > 8 {
+ exchangeIDShort = exchangeIDShort[:8]
+ }
+ traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
// Set default values
isCrossMargin := true // Default to cross margin mode
@@ -515,7 +524,8 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
var tempTrader trader.Trader
var createErr error
- switch req.ExchangeID {
+ // Use ExchangeType (e.g., "binance") instead of ID (UUID)
+ switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
case "hyperliquid":
@@ -535,8 +545,29 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
exchangeCfg.APIKey,
exchangeCfg.SecretKey,
)
+ case "okx":
+ tempTrader = trader.NewOKXTrader(
+ exchangeCfg.APIKey,
+ exchangeCfg.SecretKey,
+ exchangeCfg.Passphrase,
+ )
+ case "lighter":
+ if exchangeCfg.LighterAPIKeyPrivateKey != "" {
+ tempTrader, createErr = trader.NewLighterTraderV2(
+ exchangeCfg.LighterPrivateKey,
+ exchangeCfg.LighterWalletAddr,
+ exchangeCfg.LighterAPIKeyPrivateKey,
+ exchangeCfg.Testnet,
+ )
+ } else {
+ tempTrader, createErr = trader.NewLighterTrader(
+ exchangeCfg.LighterPrivateKey,
+ exchangeCfg.LighterWalletAddr,
+ exchangeCfg.Testnet,
+ )
+ }
default:
- logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", req.ExchangeID)
+ logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
}
if createErr != nil {
@@ -951,7 +982,8 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
var tempTrader trader.Trader
var createErr error
- switch traderConfig.ExchangeID {
+ // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
+ switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
case "hyperliquid":
@@ -1066,7 +1098,6 @@ func (s *Server) handleClosePosition(c *gin.Context) {
return
}
- traderConfig := fullConfig.Trader
exchangeCfg := fullConfig.Exchange
if exchangeCfg == nil || !exchangeCfg.Enabled {
@@ -1078,7 +1109,8 @@ func (s *Server) handleClosePosition(c *gin.Context) {
var tempTrader trader.Trader
var createErr error
- switch traderConfig.ExchangeID {
+ // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
+ switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
case "hyperliquid":
@@ -1293,18 +1325,10 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
return
}
- // If no exchanges in database, return default exchanges
+ // If no exchanges in database, return empty array (user needs to create accounts)
if len(exchanges) == 0 {
- logger.Infof("⚠️ No exchanges in database, returning defaults")
- defaultExchanges := []SafeExchangeConfig{
- {ID: "binance", Name: "Binance", Type: "cex", Enabled: false},
- {ID: "bybit", Name: "Bybit", Type: "cex", Enabled: false},
- {ID: "okx", Name: "OKX", Type: "cex", Enabled: false},
- {ID: "hyperliquid", Name: "Hyperliquid", Type: "dex", Enabled: false},
- {ID: "aster", Name: "Aster", Type: "dex", Enabled: false},
- {ID: "lighter", Name: "LIGHTER", Type: "dex", Enabled: false},
- }
- c.JSON(http.StatusOK, defaultExchanges)
+ logger.Infof("⚠️ No exchanges in database for user %s", userID)
+ c.JSON(http.StatusOK, []SafeExchangeConfig{})
return
}
@@ -1315,6 +1339,8 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
for i, exchange := range exchanges {
safeExchanges[i] = SafeExchangeConfig{
ID: exchange.ID,
+ ExchangeType: exchange.ExchangeType,
+ AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
@@ -1322,6 +1348,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
+ LighterWalletAddr: exchange.LighterWalletAddr,
}
}
@@ -1408,6 +1435,145 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
}
+// CreateExchangeRequest request structure for creating a new exchange account
+type CreateExchangeRequest struct {
+ ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
+ AccountName string `json:"account_name"` // User-defined account name
+ Enabled bool `json:"enabled"`
+ APIKey string `json:"api_key"`
+ SecretKey string `json:"secret_key"`
+ Passphrase string `json:"passphrase"`
+ Testnet bool `json:"testnet"`
+ HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
+ AsterUser string `json:"aster_user"`
+ AsterSigner string `json:"aster_signer"`
+ AsterPrivateKey string `json:"aster_private_key"`
+ LighterWalletAddr string `json:"lighter_wallet_addr"`
+ LighterPrivateKey string `json:"lighter_private_key"`
+ LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
+}
+
+// handleCreateExchange Create a new exchange account
+func (s *Server) handleCreateExchange(c *gin.Context) {
+ userID := c.GetString("user_id")
+ cfg := config.Get()
+
+ // Read raw request body
+ bodyBytes, err := c.GetRawData()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
+ return
+ }
+
+ var req CreateExchangeRequest
+
+ // Check if transport encryption is enabled
+ if !cfg.TransportEncryption {
+ // Transport encryption disabled, accept plain JSON
+ if err := json.Unmarshal(bodyBytes, &req); err != nil {
+ logger.Infof("❌ Failed to parse plain JSON request: %v", err)
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
+ return
+ }
+ } else {
+ // Transport encryption enabled, require encrypted payload
+ var encryptedPayload crypto.EncryptedPayload
+ if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
+ return
+ }
+
+ if encryptedPayload.WrappedKey == "" {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "This endpoint only supports encrypted transmission",
+ "code": "ENCRYPTION_REQUIRED",
+ "message": "Encrypted transmission is required for security reasons",
+ })
+ return
+ }
+
+ decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
+ return
+ }
+
+ if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
+ return
+ }
+ }
+
+ // Validate exchange type
+ validTypes := map[string]bool{
+ "binance": true, "bybit": true, "okx": true,
+ "hyperliquid": true, "aster": true, "lighter": true,
+ }
+ if !validTypes[req.ExchangeType] {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
+ return
+ }
+
+ // Create new exchange account
+ id, err := s.store.Exchange().Create(
+ userID, req.ExchangeType, req.AccountName, req.Enabled,
+ req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
+ req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
+ req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey,
+ )
+ if err != nil {
+ logger.Infof("❌ Failed to create exchange account: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create exchange account: %v", err)})
+ return
+ }
+
+ logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Exchange account created",
+ "id": id,
+ })
+}
+
+// handleDeleteExchange Delete an exchange account
+func (s *Server) handleDeleteExchange(c *gin.Context) {
+ userID := c.GetString("user_id")
+ exchangeID := c.Param("id")
+
+ if exchangeID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"})
+ return
+ }
+
+ // Check if any traders are using this exchange
+ traders, err := s.store.Trader().List(userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"})
+ return
+ }
+
+ for _, trader := range traders {
+ if trader.ExchangeID == exchangeID {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Cannot delete exchange account that is in use by traders",
+ "trader_id": trader.ID,
+ "trader_name": trader.Name,
+ })
+ return
+ }
+ }
+
+ // Delete exchange account
+ err = s.store.Exchange().Delete(userID, exchangeID)
+ if err != nil {
+ logger.Infof("❌ Failed to delete exchange account: %v", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete exchange account: %v", err)})
+ return
+ }
+
+ logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
+ c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
+}
+
// handleTraderList Trader list
func (s *Server) handleTraderList(c *gin.Context) {
userID := c.GetString("user_id")
@@ -2083,14 +2249,15 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
// handleGetSupportedExchanges Get list of exchanges supported by the system
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
- // Return static list of supported exchanges
+ // Return static list of supported exchange types
+ // Note: ID is empty for supported exchanges (they are templates, not actual accounts)
supportedExchanges := []SafeExchangeConfig{
- {ID: "binance", Name: "Binance Futures", Type: "binance"},
- {ID: "bybit", Name: "Bybit Futures", Type: "bybit"},
- {ID: "okx", Name: "OKX Futures", Type: "okx"},
- {ID: "hyperliquid", Name: "Hyperliquid", Type: "hyperliquid"},
- {ID: "aster", Name: "Aster DEX", Type: "aster"},
- {ID: "lighter", Name: "LIGHTER DEX", Type: "lighter"},
+ {ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
+ {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
+ {ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
+ {ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
+ {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
+ {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
}
c.JSON(http.StatusOK, supportedExchanges)
diff --git a/decision/engine.go b/decision/engine.go
index 1875ba3c..fc50632e 100644
--- a/decision/engine.go
+++ b/decision/engine.go
@@ -854,17 +854,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
ctx.Account.MarginUsedPct,
ctx.Account.PositionCount))
- // Position information
- if len(ctx.Positions) > 0 {
- sb.WriteString("## Current Positions\n")
- for i, pos := range ctx.Positions {
- sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
- }
- } else {
- sb.WriteString("Current Positions: None\n\n")
- }
-
- // Recently completed orders
+ // Recently completed orders (placed before positions to ensure visibility)
if len(ctx.RecentOrders) > 0 {
sb.WriteString("## Recent Completed Trades\n")
for i, order := range ctx.RecentOrders {
@@ -881,6 +871,16 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
sb.WriteString("\n")
}
+ // Position information
+ if len(ctx.Positions) > 0 {
+ sb.WriteString("## Current Positions\n")
+ for i, pos := range ctx.Positions {
+ sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
+ }
+ } else {
+ sb.WriteString("Current Positions: None\n\n")
+ }
+
// Candidate coins
sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap)))
displayedCount := 0
diff --git a/img.png b/img.png
deleted file mode 100644
index bb004f17..00000000
Binary files a/img.png and /dev/null differ
diff --git a/img_1.png b/img_1.png
deleted file mode 100644
index a99a90d4..00000000
Binary files a/img_1.png and /dev/null differ
diff --git a/manager/trader_manager.go b/manager/trader_manager.go
index ef69f6c9..d655c166 100644
--- a/manager/trader_manager.go
+++ b/manager/trader_manager.go
@@ -465,7 +465,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
}
// Use existing method to load trader
- logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID, traderCfg.StrategyID)
+ logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID)
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
@@ -605,7 +605,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
ID: traderCfg.ID,
Name: traderCfg.Name,
AIModel: aiModelCfg.Provider,
- Exchange: exchangeCfg.ID,
+ Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc
+ ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account)
BinanceAPIKey: "",
BinanceSecretKey: "",
HyperliquidPrivateKey: "",
@@ -622,7 +623,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
}
// Set API keys based on exchange type
- switch exchangeCfg.ID {
+ switch exchangeCfg.ExchangeType {
case "binance":
traderConfig.BinanceAPIKey = exchangeCfg.APIKey
traderConfig.BinanceSecretKey = exchangeCfg.SecretKey
@@ -671,7 +672,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
}
tm.traders[traderCfg.ID] = at
- logger.Infof("✓ Trader '%s' (%s + %s) loaded to memory", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID)
+ logger.Infof("✓ Trader '%s' (%s + %s/%s) loaded to memory", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName)
// Auto-start if trader was running before shutdown
if traderCfg.IsRunning {
diff --git a/screenshots/config-ai-exchanges.png b/screenshots/config-ai-exchanges.png
new file mode 100644
index 00000000..e251c116
Binary files /dev/null and b/screenshots/config-ai-exchanges.png differ
diff --git a/screenshots/config-traders-list.png b/screenshots/config-traders-list.png
new file mode 100644
index 00000000..573a8d97
Binary files /dev/null and b/screenshots/config-traders-list.png differ
diff --git a/store/exchange.go b/store/exchange.go
index 6ea21013..afc4a328 100644
--- a/store/exchange.go
+++ b/store/exchange.go
@@ -6,6 +6,8 @@ import (
"nofx/logger"
"strings"
"time"
+
+ "github.com/google/uuid"
)
// ExchangeStore exchange storage
@@ -17,10 +19,12 @@ type ExchangeStore struct {
// Exchange exchange configuration
type Exchange struct {
- ID string `json:"id"`
+ ID string `json:"id"` // UUID
+ ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
+ AccountName string `json:"account_name"` // User-defined account name
UserID string `json:"user_id"`
- Name string `json:"name"`
- Type string `json:"type"`
+ Name string `json:"name"` // Display name (auto-generated or user-defined)
+ Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
@@ -38,9 +42,12 @@ type Exchange struct {
}
func (s *ExchangeStore) initTables() error {
+ // Create new table structure with UUID as primary key
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS exchanges (
- id TEXT NOT NULL,
+ id TEXT PRIMARY KEY,
+ exchange_type TEXT NOT NULL DEFAULT '',
+ account_name TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
type TEXT NOT NULL,
@@ -57,28 +64,140 @@ func (s *ExchangeStore) initTables() error {
lighter_private_key TEXT DEFAULT '',
lighter_api_key_private_key TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (id, user_id)
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
- // Migration: add passphrase column (if not exists)
+ // Migration: add new columns if not exists
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN passphrase TEXT DEFAULT ''`)
+ s.db.Exec(`ALTER TABLE exchanges ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`)
+ s.db.Exec(`ALTER TABLE exchanges ADD COLUMN account_name TEXT NOT NULL DEFAULT ''`)
- // Trigger
+ // Run migration to multi-account if needed
+ if err := s.migrateToMultiAccount(); err != nil {
+ logger.Warnf("Multi-account migration warning: %v", err)
+ }
+
+ // Fix empty account_name for existing records
+ s.db.Exec(`UPDATE exchanges SET account_name = 'Default' WHERE account_name = '' OR account_name IS NULL`)
+
+ // Update trigger for new schema
+ s.db.Exec(`DROP TRIGGER IF EXISTS update_exchanges_updated_at`)
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at
AFTER UPDATE ON exchanges
BEGIN
- UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id AND user_id = NEW.user_id;
+ UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`)
return err
}
+// migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID)
+func (s *ExchangeStore) migrateToMultiAccount() error {
+ // Check if migration is needed by looking for old-style IDs (non-UUID)
+ var count int
+ err := s.db.QueryRow(`
+ SELECT COUNT(*) FROM exchanges
+ WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter')
+ `).Scan(&count)
+ if err != nil {
+ return err
+ }
+
+ if count == 0 {
+ // No migration needed
+ return nil
+ }
+
+ logger.Infof("🔄 Migrating %d exchange records to multi-account schema...", count)
+
+ // Get all old records
+ rows, err := s.db.Query(`
+ SELECT id, user_id, name, type, enabled, api_key, secret_key,
+ COALESCE(passphrase, '') as passphrase, testnet,
+ COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
+ COALESCE(aster_user, '') as aster_user,
+ COALESCE(aster_signer, '') as aster_signer,
+ COALESCE(aster_private_key, '') as aster_private_key,
+ COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
+ COALESCE(lighter_private_key, '') as lighter_private_key,
+ COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key
+ FROM exchanges
+ WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter')
+ `)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ type oldRecord struct {
+ id, userID, name, typ string
+ enabled, testnet bool
+ apiKey, secretKey, passphrase string
+ hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string
+ lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string
+ }
+
+ var records []oldRecord
+ for rows.Next() {
+ var r oldRecord
+ if err := rows.Scan(&r.id, &r.userID, &r.name, &r.typ, &r.enabled,
+ &r.apiKey, &r.secretKey, &r.passphrase, &r.testnet,
+ &r.hyperliquidWalletAddr, &r.asterUser, &r.asterSigner, &r.asterPrivateKey,
+ &r.lighterWalletAddr, &r.lighterPrivateKey, &r.lighterApiKeyPrivateKey); err != nil {
+ return err
+ }
+ records = append(records, r)
+ }
+
+ // Begin transaction
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Migrate each record
+ for _, r := range records {
+ newID := uuid.New().String()
+ oldID := r.id // This is the exchange type (e.g., "binance")
+
+ // Update traders table to use new UUID
+ _, err = tx.Exec(`UPDATE traders SET exchange_id = ? WHERE exchange_id = ? AND user_id = ?`,
+ newID, oldID, r.userID)
+ if err != nil {
+ logger.Errorf("Failed to update traders for exchange %s: %v", oldID, err)
+ return err
+ }
+
+ // Update the exchange record
+ _, err = tx.Exec(`
+ UPDATE exchanges SET
+ id = ?,
+ exchange_type = ?,
+ account_name = ?
+ WHERE id = ? AND user_id = ?
+ `, newID, oldID, "Default", oldID, r.userID)
+ if err != nil {
+ logger.Errorf("Failed to migrate exchange %s: %v", oldID, err)
+ return err
+ }
+
+ logger.Infof("✅ Migrated exchange %s -> UUID %s for user %s", oldID, newID, r.userID)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+
+ logger.Infof("✅ Multi-account migration completed successfully")
+ return nil
+}
+
func (s *ExchangeStore) initDefaultData() error {
// No longer pre-populate exchanges - create on demand when user configures
return nil
@@ -101,7 +220,8 @@ func (s *ExchangeStore) decrypt(encrypted string) string {
// List gets user's exchange list
func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
rows, err := s.db.Query(`
- SELECT id, user_id, name, type, enabled, api_key, secret_key,
+ SELECT id, COALESCE(exchange_type, '') as exchange_type, COALESCE(account_name, '') as account_name,
+ user_id, name, type, enabled, api_key, secret_key,
COALESCE(passphrase, '') as passphrase, testnet,
COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
COALESCE(aster_user, '') as aster_user,
@@ -111,7 +231,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
COALESCE(lighter_private_key, '') as lighter_private_key,
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
created_at, updated_at
- FROM exchanges WHERE user_id = ? ORDER BY id
+ FROM exchanges WHERE user_id = ? ORDER BY exchange_type, account_name
`, userID)
if err != nil {
return nil, err
@@ -123,7 +243,8 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
var e Exchange
var createdAt, updatedAt string
err := rows.Scan(
- &e.ID, &e.UserID, &e.Name, &e.Type,
+ &e.ID, &e.ExchangeType, &e.AccountName,
+ &e.UserID, &e.Name, &e.Type,
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
@@ -145,7 +266,101 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
return exchanges, nil
}
-// Update updates exchange configuration
+// GetByID gets a specific exchange by UUID
+func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
+ var e Exchange
+ var createdAt, updatedAt string
+ err := s.db.QueryRow(`
+ SELECT id, COALESCE(exchange_type, '') as exchange_type, COALESCE(account_name, '') as account_name,
+ user_id, name, type, enabled, api_key, secret_key,
+ COALESCE(passphrase, '') as passphrase, testnet,
+ COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
+ COALESCE(aster_user, '') as aster_user,
+ COALESCE(aster_signer, '') as aster_signer,
+ COALESCE(aster_private_key, '') as aster_private_key,
+ COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
+ COALESCE(lighter_private_key, '') as lighter_private_key,
+ COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
+ created_at, updated_at
+ FROM exchanges WHERE id = ? AND user_id = ?
+ `, id, userID).Scan(
+ &e.ID, &e.ExchangeType, &e.AccountName,
+ &e.UserID, &e.Name, &e.Type,
+ &e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
+ &e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
+ &e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
+ &createdAt, &updatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ e.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
+ e.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
+ e.APIKey = s.decrypt(e.APIKey)
+ e.SecretKey = s.decrypt(e.SecretKey)
+ e.Passphrase = s.decrypt(e.Passphrase)
+ e.AsterPrivateKey = s.decrypt(e.AsterPrivateKey)
+ e.LighterPrivateKey = s.decrypt(e.LighterPrivateKey)
+ e.LighterAPIKeyPrivateKey = s.decrypt(e.LighterAPIKeyPrivateKey)
+ return &e, nil
+}
+
+// getExchangeNameAndType returns the display name and type for an exchange type
+func getExchangeNameAndType(exchangeType string) (name string, typ string) {
+ switch exchangeType {
+ case "binance":
+ return "Binance Futures", "cex"
+ case "bybit":
+ return "Bybit Futures", "cex"
+ case "okx":
+ return "OKX Futures", "cex"
+ case "hyperliquid":
+ return "Hyperliquid", "dex"
+ case "aster":
+ return "Aster DEX", "dex"
+ case "lighter":
+ return "LIGHTER DEX", "dex"
+ default:
+ return exchangeType + " Exchange", "cex"
+ }
+}
+
+// Create creates a new exchange account with UUID
+func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
+ apiKey, secretKey, passphrase string, testnet bool,
+ hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
+ lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) (string, error) {
+
+ id := uuid.New().String()
+ name, typ := getExchangeNameAndType(exchangeType)
+
+ // If account name is empty, use "Default"
+ if accountName == "" {
+ accountName = "Default"
+ }
+
+ logger.Debugf("🔧 ExchangeStore.Create: userID=%s, exchangeType=%s, accountName=%s, id=%s",
+ userID, exchangeType, accountName, id)
+
+ _, err := s.db.Exec(`
+ INSERT INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled,
+ api_key, secret_key, passphrase, testnet,
+ hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
+ lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key,
+ created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
+ `, id, exchangeType, accountName, userID, name, typ, enabled,
+ s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet,
+ hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey),
+ lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey))
+
+ if err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) error {
@@ -197,46 +412,59 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
- // Create new record, use exchange ID as type for correct identification
- var name, typ string
- switch id {
- case "binance":
- name, typ = "Binance Futures", "binance"
- case "bybit":
- name, typ = "Bybit Futures", "bybit"
- case "okx":
- name, typ = "OKX Futures", "okx"
- case "hyperliquid":
- name, typ = "Hyperliquid", "hyperliquid"
- case "aster":
- name, typ = "Aster DEX", "aster"
- case "lighter":
- name, typ = "LIGHTER DEX", "lighter"
- default:
- name, typ = id+" Exchange", id
- }
-
- _, err = s.db.Exec(`
- INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, passphrase, testnet,
- hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
- lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
- `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet,
- hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey),
- lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey))
- return err
+ return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
}
return nil
}
-// Create creates exchange configuration
-func (s *ExchangeStore) Create(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool,
+// UpdateAccountName updates the account name for an exchange
+func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error {
+ result, err := s.db.Exec(`UPDATE exchanges SET account_name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`,
+ accountName, id, userID)
+ if err != nil {
+ return err
+ }
+ rowsAffected, _ := result.RowsAffected()
+ if rowsAffected == 0 {
+ return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
+ }
+ return nil
+}
+
+// Delete deletes an exchange account
+func (s *ExchangeStore) Delete(userID, id string) error {
+ result, err := s.db.Exec(`DELETE FROM exchanges WHERE id = ? AND user_id = ?`, id, userID)
+ if err != nil {
+ return err
+ }
+ rowsAffected, _ := result.RowsAffected()
+ if rowsAffected == 0 {
+ return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID)
+ }
+ logger.Infof("🗑️ Deleted exchange: id=%s, userID=%s", id, userID)
+ return nil
+}
+
+// CreateLegacy creates exchange configuration (legacy API for backward compatibility)
+// This method is deprecated, use Create instead
+func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
+
+ // Check if this is an old-style ID (exchange type as ID)
+ if id == "binance" || id == "bybit" || id == "okx" || id == "hyperliquid" || id == "aster" || id == "lighter" {
+ // Use new Create method with exchange type
+ _, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
+ hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "")
+ return err
+ }
+
+ // Otherwise assume it's already a UUID
_, err := s.db.Exec(`
- INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet,
+ INSERT OR IGNORE INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled,
+ api_key, secret_key, testnet,
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
lighter_wallet_addr, lighter_private_key)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')
+ VALUES (?, '', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')
`, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet,
hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey))
return err
diff --git a/store/position.go b/store/position.go
index fb5f0c08..59d26f03 100644
--- a/store/position.go
+++ b/store/position.go
@@ -27,7 +27,8 @@ type TraderStats struct {
type TraderPosition struct {
ID int64 `json:"id"`
TraderID string `json:"trader_id"`
- ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
+ ExchangeID string `json:"exchange_id"` // Exchange account UUID (for multi-account support)
+ ExchangeType string `json:"exchange_type"` // Exchange type: binance/bybit/okx/hyperliquid/aster/lighter
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
Symbol string `json:"symbol"`
Side string `json:"side"` // LONG/SHORT
@@ -92,6 +93,8 @@ func (s *PositionStore) InitTables() error {
// Migration: add exchange_id column to existing table (if not exists)
// Must be executed before creating indexes!
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
+ // Migration: add exchange_type column (binance/bybit/okx/etc)
+ s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`)
// Migration: add exchange_position_id for deduplication
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`)
// Migration: add source field (system/manual/sync)
@@ -105,7 +108,9 @@ func (s *PositionStore) InitTables() error {
`CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`,
`CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`,
`CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`,
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_unique ON trader_positions(trader_id, exchange_position_id) WHERE exchange_position_id != ''`,
+ // Unique index based on exchange_id (account UUID), not trader_id
+ // This ensures the same position from an exchange account is not duplicated across different traders
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`,
}
for _, idx := range indices {
if _, err := s.db.Exec(idx); err != nil {
@@ -128,11 +133,11 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
result, err := s.db.Exec(`
INSERT INTO trader_positions (
- trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id,
+ trader_id, exchange_id, exchange_type, symbol, side, quantity, entry_price, entry_order_id,
entry_time, leverage, status, created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
- pos.TraderID, pos.ExchangeID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice,
+ pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice,
pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339),
)
@@ -167,7 +172,7 @@ func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID s
// GetOpenPositions gets all open positions
func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) {
rows, err := s.db.Query(`
- SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id,
+ SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
leverage, status, close_reason, created_at, updated_at
FROM trader_positions
@@ -188,14 +193,14 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
var entryTime, exitTime, createdAt, updatedAt sql.NullString
err := s.db.QueryRow(`
- SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id,
+ SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
leverage, status, close_reason, created_at, updated_at
FROM trader_positions
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
ORDER BY entry_time DESC LIMIT 1
`, traderID, symbol, side).Scan(
- &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.Symbol, &pos.Side, &pos.Quantity,
+ &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity,
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
@@ -214,7 +219,7 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
// GetClosedPositions gets closed positions (historical records)
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
rows, err := s.db.Query(`
- SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id,
+ SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
leverage, status, close_reason, created_at, updated_at
FROM trader_positions
@@ -233,7 +238,7 @@ func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*Trade
// GetAllOpenPositions gets all traders' open positions (for global sync)
func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
rows, err := s.db.Query(`
- SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id,
+ SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
leverage, status, close_reason, created_at, updated_at
FROM trader_positions
@@ -515,7 +520,7 @@ func (s *PositionStore) scanPositions(rows *sql.Rows) ([]*TraderPosition, error)
var entryTime, exitTime, createdAt, updatedAt sql.NullString
err := rows.Scan(
- &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.Symbol, &pos.Side, &pos.Quantity,
+ &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity,
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
@@ -883,7 +888,9 @@ func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummar
// =============================================================================
// ExistsWithExchangePositionID checks if a position with the given exchange position ID already exists
-func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionID string) (bool, error) {
+// Note: Uses exchange_id (account UUID) for deduplication, not trader_id
+// This ensures that the same position from an exchange account is not duplicated across different traders
+func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) {
if exchangePositionID == "" {
return false, nil
}
@@ -891,8 +898,8 @@ func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionI
var count int
err := s.db.QueryRow(`
SELECT COUNT(*) FROM trader_positions
- WHERE trader_id = ? AND exchange_position_id = ?
- `, traderID, exchangePositionID).Scan(&count)
+ WHERE exchange_id = ? AND exchange_position_id = ?
+ `, exchangeID, exchangePositionID).Scan(&count)
if err != nil {
return false, fmt.Errorf("failed to check position existence: %w", err)
}
@@ -901,17 +908,52 @@ func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionI
// CreateFromClosedPnL creates a closed position record from exchange closed PnL data
// This is used for syncing historical positions from exchange
-// Returns true if created, false if already exists (deduped)
-func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record *ClosedPnLRecord) (bool, error) {
- // Generate unique exchange position ID from record data
- exchangePositionID := record.ExchangeID
- if exchangePositionID == "" {
- // Fallback: generate from order ID + exit time
- exchangePositionID = fmt.Sprintf("%s_%d", record.OrderID, record.ExitTime.UnixMilli())
+// Returns true if created, false if already exists (deduped) or invalid data
+func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType string, record *ClosedPnLRecord) (bool, error) {
+ // ==========================================================================
+ // Step 1: Validate required fields
+ // ==========================================================================
+ if record.Symbol == "" {
+ return false, nil // Skip: no symbol
}
- // Check if already exists
- exists, err := s.ExistsWithExchangePositionID(traderID, exchangePositionID)
+ // Normalize and validate side
+ side := strings.ToUpper(record.Side)
+ if side == "LONG" || side == "BUY" {
+ side = "LONG"
+ } else if side == "SHORT" || side == "SELL" {
+ side = "SHORT"
+ } else {
+ return false, nil // Skip: invalid side
+ }
+
+ // Validate quantity
+ if record.Quantity <= 0 {
+ return false, nil // Skip: invalid quantity
+ }
+
+ // Validate prices (entry price can be calculated, but should be positive)
+ if record.ExitPrice <= 0 {
+ return false, nil // Skip: invalid exit price
+ }
+ if record.EntryPrice <= 0 {
+ return false, nil // Skip: invalid entry price
+ }
+
+ // ==========================================================================
+ // Step 2: Generate unique exchange position ID for deduplication
+ // ==========================================================================
+ exchangePositionID := record.ExchangeID
+ if exchangePositionID == "" {
+ // Fallback: generate from symbol + side + exit time + pnl (to ensure uniqueness)
+ exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f",
+ record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL)
+ }
+
+ // ==========================================================================
+ // Step 3: Check for duplicates based on (exchange_id, exchange_position_id)
+ // ==========================================================================
+ exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
if err != nil {
return false, err
}
@@ -919,49 +961,48 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record
return false, nil // Already exists, skip
}
- // Normalize side
- side := strings.ToUpper(record.Side)
- if side == "LONG" || side == "BUY" {
- side = "LONG"
- } else {
- side = "SHORT"
- }
-
+ // ==========================================================================
+ // Step 4: Handle timestamps
+ // ==========================================================================
now := time.Now()
exitTime := record.ExitTime
entryTime := record.EntryTime
- // Handle zero entry time - use exit time or current time as fallback
- if entryTime.IsZero() || entryTime.Year() < 2000 {
- if !exitTime.IsZero() && exitTime.Year() >= 2000 {
- entryTime = exitTime // Use exit time as approximation
- } else {
- entryTime = now // Last resort: use current time
- }
- }
-
- // Handle zero exit time
+ // Validate exit time
if exitTime.IsZero() || exitTime.Year() < 2000 {
- exitTime = now
+ return false, nil // Skip: invalid exit time
}
+ // Handle zero entry time - use exit time as approximation
+ if entryTime.IsZero() || entryTime.Year() < 2000 {
+ entryTime = exitTime
+ }
+
+ // Entry time should not be after exit time
+ if entryTime.After(exitTime) {
+ entryTime = exitTime
+ }
+
+ // ==========================================================================
+ // Step 5: Insert into database
+ // ==========================================================================
_, err = s.db.Exec(`
INSERT INTO trader_positions (
- trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
+ trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity,
entry_price, entry_order_id, entry_time,
exit_price, exit_order_id, exit_time,
realized_pnl, fee, leverage, status, close_reason, source,
created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?)
`,
- traderID, exchangeID, exchangePositionID, record.Symbol, side, record.Quantity,
+ traderID, exchangeID, exchangeType, exchangePositionID, record.Symbol, side, record.Quantity,
record.EntryPrice, "", entryTime.Format(time.RFC3339),
record.ExitPrice, record.OrderID, exitTime.Format(time.RFC3339),
record.RealizedPnL, record.Fee, record.Leverage, record.CloseType,
now.Format(time.RFC3339), now.Format(time.RFC3339),
)
if err != nil {
- // Could be duplicate key error, treat as already exists
+ // Duplicate key error, treat as already exists
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return false, nil
}
@@ -1012,9 +1053,9 @@ func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, e
// CreateOpenPosition creates an open position record with exchange position ID
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
- // Check if already exists by exchange position ID
- if pos.ExchangePositionID != "" {
- exists, err := s.ExistsWithExchangePositionID(pos.TraderID, pos.ExchangePositionID)
+ // Check if already exists by exchange position ID (based on exchange_id, not trader_id)
+ if pos.ExchangePositionID != "" && pos.ExchangeID != "" {
+ exists, err := s.ExistsWithExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
if err != nil {
return err
}
@@ -1033,12 +1074,12 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
result, err := s.db.Exec(`
INSERT INTO trader_positions (
- trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
+ trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity,
entry_price, entry_order_id, entry_time, leverage, status, source,
created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
- pos.TraderID, pos.ExchangeID, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity,
+ pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity,
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339),
)
@@ -1075,11 +1116,11 @@ func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float6
// SyncClosedPositions syncs closed positions from exchange to local database
// Returns (created count, skipped count, error)
-func (s *PositionStore) SyncClosedPositions(traderID, exchangeID string, records []ClosedPnLRecord) (int, int, error) {
+func (s *PositionStore) SyncClosedPositions(traderID, exchangeID, exchangeType string, records []ClosedPnLRecord) (int, int, error) {
created, skipped := 0, 0
for _, record := range records {
rec := record // Create local copy to avoid closure issues
- wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, &rec)
+ wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, exchangeType, &rec)
if err != nil {
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
}
diff --git a/store/trader.go b/store/trader.go
index c9890273..df1c9a0a 100644
--- a/store/trader.go
+++ b/store/trader.go
@@ -305,7 +305,8 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
t.created_at, t.updated_at,
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key,
COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at,
- e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet,
+ e.id, COALESCE(e.exchange_type, '') as exchange_type, COALESCE(e.account_name, '') as account_name,
+ e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet,
COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''),
COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''),
COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at
@@ -321,7 +322,8 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
&trader.SystemPromptTemplate, &traderCreatedAt, &traderUpdatedAt,
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
&aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt,
- &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
+ &exchange.ID, &exchange.ExchangeType, &exchange.AccountName,
+ &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
&exchange.APIKey, &exchange.SecretKey, &exchange.Passphrase, &exchange.Testnet, &exchange.HyperliquidWalletAddr,
&exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
diff --git a/trader/aster_trader.go b/trader/aster_trader.go
index c543ee1a..a07ffbe9 100644
--- a/trader/aster_trader.go
+++ b/trader/aster_trader.go
@@ -1292,11 +1292,125 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
return response, nil
}
-// GetClosedPnL gets closed position PnL records from exchange
-// Aster does not have a direct closed PnL API, returns empty slice
+// GetClosedPnL gets recent closing trades from Aster
+// Note: Aster does NOT have a position history API, only trade history.
+// This returns individual closing trades for real-time position closure detection.
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
- // Aster does not provide a closed PnL history API
- // Position closure data needs to be tracked locally via position sync
- logger.Infof("⚠️ Aster GetClosedPnL not supported, returning empty")
- return []ClosedPnLRecord{}, nil
+ trades, err := t.GetTrades(startTime, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter only closing trades (realizedPnl != 0)
+ var records []ClosedPnLRecord
+ for _, trade := range trades {
+ if trade.RealizedPnL == 0 {
+ continue
+ }
+
+ // Determine side from PositionSide or trade direction
+ side := "long"
+ if trade.PositionSide == "SHORT" || trade.PositionSide == "short" {
+ side = "short"
+ } else if trade.PositionSide == "BOTH" || trade.PositionSide == "" {
+ if trade.Side == "SELL" || trade.Side == "Sell" {
+ side = "long"
+ } else {
+ side = "short"
+ }
+ }
+
+ // Calculate entry price from PnL
+ var entryPrice float64
+ if trade.Quantity > 0 {
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ }
+
+ records = append(records, ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee,
+ ExitTime: trade.Time,
+ EntryTime: trade.Time,
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ })
+ }
+
+ return records, nil
+}
+
+// AsterTradeRecord represents a trade from Aster API
+type AsterTradeRecord struct {
+ ID int64 `json:"id"`
+ Symbol string `json:"symbol"`
+ OrderID int64 `json:"orderId"`
+ Side string `json:"side"` // BUY or SELL
+ PositionSide string `json:"positionSide"` // LONG or SHORT
+ Price string `json:"price"`
+ Qty string `json:"qty"`
+ RealizedPnl string `json:"realizedPnl"`
+ Commission string `json:"commission"`
+ Time int64 `json:"time"`
+ Buyer bool `json:"buyer"`
+ Maker bool `json:"maker"`
+}
+
+// GetTrades retrieves trade history from Aster
+func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
+ if limit <= 0 {
+ limit = 500
+ }
+
+ // Build request params
+ params := map[string]interface{}{
+ "startTime": startTime.UnixMilli(),
+ "limit": limit,
+ }
+
+ // Use existing request method with signing
+ body, err := t.request("GET", "/fapi/v3/userTrades", params)
+ if err != nil {
+ logger.Infof("⚠️ Aster userTrades API error: %v", err)
+ return []TradeRecord{}, nil
+ }
+
+ var asterTrades []AsterTradeRecord
+ if err := json.Unmarshal(body, &asterTrades); err != nil {
+ logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
+ return []TradeRecord{}, nil
+ }
+
+ // Convert to unified TradeRecord format
+ var result []TradeRecord
+ for _, at := range asterTrades {
+ price, _ := strconv.ParseFloat(at.Price, 64)
+ qty, _ := strconv.ParseFloat(at.Qty, 64)
+ fee, _ := strconv.ParseFloat(at.Commission, 64)
+ pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
+
+ trade := TradeRecord{
+ TradeID: strconv.FormatInt(at.ID, 10),
+ Symbol: at.Symbol,
+ Side: at.Side,
+ PositionSide: at.PositionSide,
+ Price: price,
+ Quantity: qty,
+ RealizedPnL: pnl,
+ Fee: fee,
+ Time: time.UnixMilli(at.Time),
+ }
+ result = append(result, trade)
+ }
+
+ return result, nil
}
diff --git a/trader/auto_trader.go b/trader/auto_trader.go
index 85fca44e..3838a364 100644
--- a/trader/auto_trader.go
+++ b/trader/auto_trader.go
@@ -22,7 +22,8 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
- Exchange string // "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter"
+ Exchange string // Exchange type: "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter"
+ ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
BinanceAPIKey string
@@ -86,7 +87,8 @@ type AutoTrader struct {
id string // Trader unique identifier
name string // Trader display name
aiModel string // AI model name
- exchange string // Trading platform name
+ exchange string // Trading platform type (binance/bybit/etc)
+ exchangeID string // Exchange account UUID
config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient
@@ -272,6 +274,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
name: config.Name,
aiModel: config.AIModel,
exchange: config.Exchange,
+ exchangeID: config.ExchangeID,
config: config,
trader: trader,
mcpClient: mcpClient,
@@ -687,7 +690,11 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
// 7. Add recent closed trades (if store is available)
if at.store != nil {
// Get recent 10 closed trades for AI context
- if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
+ recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
+ if err != nil {
+ logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err)
+ } else {
+ logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
for _, trade := range recentTrades {
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
Symbol: trade.Symbol,
@@ -702,6 +709,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
})
}
}
+ } else {
+ logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name)
}
// 8. Get quantitative data (if enabled in strategy config)
@@ -814,13 +823,16 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
- // Fee estimation (Taker fee rate 0.04%)
- estimatedFee := decision.PositionSizeUSD * 0.0004
- totalRequired := requiredMargin + estimatedFee
+ // Fee estimation: use 0.1% (safety buffer over typical 0.04% taker fee)
+ // This accounts for: taker fee, slippage, funding rate, and exchange-specific variations (OKX needs more buffer)
+ estimatedFee := decision.PositionSizeUSD * 0.001
+ // Add 1% safety buffer for price fluctuation and rounding
+ safetyBuffer := requiredMargin * 0.01
+ totalRequired := requiredMargin + estimatedFee + safetyBuffer
if totalRequired > availableBalance {
- return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f), available %.2f USDT",
- totalRequired, requiredMargin, estimatedFee, availableBalance)
+ return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f + buffer %.2f), available %.2f USDT",
+ totalRequired, requiredMargin, estimatedFee, safetyBuffer, availableBalance)
}
// Set margin mode
@@ -927,13 +939,16 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
- // Fee estimation (Taker fee rate 0.04%)
- estimatedFee := decision.PositionSizeUSD * 0.0004
- totalRequired := requiredMargin + estimatedFee
+ // Fee estimation: use 0.1% (safety buffer over typical 0.04% taker fee)
+ // This accounts for: taker fee, slippage, funding rate, and exchange-specific variations (OKX needs more buffer)
+ estimatedFee := decision.PositionSizeUSD * 0.001
+ // Add 1% safety buffer for price fluctuation and rounding
+ safetyBuffer := requiredMargin * 0.01
+ totalRequired := requiredMargin + estimatedFee + safetyBuffer
if totalRequired > availableBalance {
- return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f), available %.2f USDT",
- totalRequired, requiredMargin, estimatedFee, availableBalance)
+ return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f + buffer %.2f), available %.2f USDT",
+ totalRequired, requiredMargin, estimatedFee, safetyBuffer, availableBalance)
}
// Set margin mode
@@ -1612,7 +1627,8 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
// Open position: create new position record
pos := &store.TraderPosition{
TraderID: at.id,
- ExchangeID: at.exchange, // Record specific exchange ID
+ ExchangeID: at.exchangeID, // Exchange account UUID
+ ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc
Symbol: symbol,
Side: side, // LONG or SHORT
Quantity: quantity,
diff --git a/trader/binance_futures.go b/trader/binance_futures.go
index bb3f4ec0..68561315 100644
--- a/trader/binance_futures.go
+++ b/trader/binance_futures.go
@@ -958,9 +958,68 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
return result, nil
}
-// GetClosedPnL retrieves closed position PnL records from Binance Futures
-// Binance API: /fapi/v1/income with incomeType=REALIZED_PNL
+// GetClosedPnL retrieves recent closing trades from Binance Futures
+// Note: Binance does NOT have a position history API, only trade history.
+// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
+// NOT suitable for historical position reconstruction - use only for matching recent closures.
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
+ trades, err := t.GetTrades(startTime, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
+ var records []ClosedPnLRecord
+ for _, trade := range trades {
+ if trade.RealizedPnL == 0 {
+ continue // Skip opening trades
+ }
+
+ // Determine side from trade
+ side := "long"
+ if trade.PositionSide == "SHORT" || trade.PositionSide == "short" {
+ side = "short"
+ } else if trade.PositionSide == "BOTH" || trade.PositionSide == "" {
+ // One-way mode: selling closes long, buying closes short
+ if trade.Side == "SELL" || trade.Side == "Sell" {
+ side = "long"
+ } else {
+ side = "short"
+ }
+ }
+
+ // Calculate entry price from PnL (mathematically accurate for this trade)
+ var entryPrice float64
+ if trade.Quantity > 0 {
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ }
+
+ records = append(records, ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee,
+ ExitTime: trade.Time,
+ EntryTime: trade.Time, // Approximate
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ })
+ }
+
+ return records, nil
+}
+
+// GetTrades retrieves trade history from Binance Futures using Income API
+// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
+func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -968,7 +1027,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
limit = 1000
}
- // Use income history API to get realized PnL
+ // Use Income API to get REALIZED_PNL records (all symbols)
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(startTime.UnixMilli()).
@@ -978,95 +1037,68 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
return nil, fmt.Errorf("failed to get income history: %w", err)
}
- records := make([]ClosedPnLRecord, 0, len(incomes))
-
+ var trades []TradeRecord
for _, income := range incomes {
- record := ClosedPnLRecord{
- Symbol: income.Symbol,
- ExchangeID: fmt.Sprintf("%d", income.TranID),
+ pnl, _ := strconv.ParseFloat(income.Income, 64)
+ if pnl == 0 {
+ continue // Skip zero PnL records
}
- // Parse realized PnL
- record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64)
-
- // Parse time
- record.ExitTime = time.UnixMilli(income.Time)
-
- // Income API doesn't provide entry/exit price directly
- // We need to get these from trade history if needed
- // For now, leave them as 0 (will be matched with local DB records)
-
- // Determine side from PnL sign (approximate)
- // Note: This is not 100% accurate; actual side comes from position tracking
- record.Side = "unknown"
- record.CloseType = "unknown"
-
- records = append(records, record)
+ // Income API doesn't provide full trade details, create a minimal record
+ // This is mainly used for detecting recent closures, not historical reconstruction
+ trade := TradeRecord{
+ TradeID: strconv.FormatInt(income.TranID, 10),
+ Symbol: income.Symbol,
+ RealizedPnL: pnl,
+ Time: time.UnixMilli(income.Time),
+ // Note: Income API doesn't provide price, quantity, side, fee
+ // For accurate data, use GetTradesForSymbol with specific symbol
+ }
+ trades = append(trades, trade)
}
- // Enrich with trade history for more details (if needed)
- // This requires additional API calls per symbol, so we do it only for important records
- if len(records) > 0 {
- t.enrichClosedPnLWithTrades(records, startTime)
- }
-
- return records, nil
+ return trades, nil
}
-// enrichClosedPnLWithTrades adds entry/exit price details from trade history
-func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) {
- // Group by symbol
- symbolSet := make(map[string]bool)
- for _, r := range records {
- symbolSet[r.Symbol] = true
+// GetTradesForSymbol retrieves trade history for a specific symbol
+// This is more reliable than using Income API which may have delays
+func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ if limit > 1000 {
+ limit = 1000
}
- // Get trade history for each symbol
- for symbol := range symbolSet {
- trades, err := t.client.NewListAccountTradeService().
- Symbol(symbol).
- StartTime(startTime.UnixMilli()).
- Limit(100).
- Do(context.Background())
- if err != nil {
- continue
- }
-
- // Build a map of trades by time for quick lookup
- for i := range records {
- if records[i].Symbol != symbol {
- continue
- }
-
- // Find matching trade(s) near the income time
- for _, trade := range trades {
- tradeTime := time.UnixMilli(trade.Time)
- // Match if within 1 second of the PnL record
- if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second {
- // Found matching trade
- records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64)
- records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64)
- commission, _ := strconv.ParseFloat(trade.Commission, 64)
- records[i].Fee += commission
-
- // Determine side
- if trade.PositionSide == futures.PositionSideTypeLong {
- records[i].Side = "long"
- } else if trade.PositionSide == futures.PositionSideTypeShort {
- records[i].Side = "short"
- }
-
- // Determine close type from order type (approximate)
- if trade.Buyer && records[i].Side == "short" ||
- !trade.Buyer && records[i].Side == "long" {
- // This is a close trade
- records[i].CloseType = "unknown" // Can't determine SL/TP from trade data
- }
-
- records[i].OrderID = strconv.FormatInt(trade.OrderID, 10)
- break
- }
- }
- }
+ accountTrades, err := t.client.NewListAccountTradeService().
+ Symbol(symbol).
+ StartTime(startTime.UnixMilli()).
+ Limit(limit).
+ Do(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
}
+
+ var trades []TradeRecord
+ for _, at := range accountTrades {
+ price, _ := strconv.ParseFloat(at.Price, 64)
+ qty, _ := strconv.ParseFloat(at.Quantity, 64)
+ fee, _ := strconv.ParseFloat(at.Commission, 64)
+ pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
+
+ trade := TradeRecord{
+ TradeID: strconv.FormatInt(at.ID, 10),
+ Symbol: at.Symbol,
+ Side: string(at.Side),
+ PositionSide: string(at.PositionSide),
+ Price: price,
+ Quantity: qty,
+ RealizedPnL: pnl,
+ Fee: fee,
+ Time: time.UnixMilli(at.Time),
+ }
+ trades = append(trades, trade)
+ }
+
+ return trades, nil
}
diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go
index c566e0b1..0aea5000 100644
--- a/trader/hyperliquid_trader.go
+++ b/trader/hyperliquid_trader.go
@@ -951,11 +951,97 @@ func absFloat(x float64) float64 {
return x
}
-// GetClosedPnL gets closed position PnL records from exchange
-// Hyperliquid does not have a direct closed PnL API, returns empty slice
+// GetClosedPnL gets recent closing trades from Hyperliquid
+// Note: Hyperliquid does NOT have a position history API, only fill history.
+// This returns individual closing trades for real-time position closure detection.
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
- // Hyperliquid does not provide a closed PnL history API
- // Position closure data needs to be tracked locally via position sync
- logger.Infof("⚠️ Hyperliquid GetClosedPnL not supported, returning empty")
- return []ClosedPnLRecord{}, nil
+ trades, err := t.GetTrades(startTime, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter only closing trades (realizedPnl != 0)
+ var records []ClosedPnLRecord
+ for _, trade := range trades {
+ if trade.RealizedPnL == 0 {
+ continue
+ }
+
+ // Determine side (Hyperliquid uses one-way mode)
+ side := "long"
+ if trade.Side == "SELL" || trade.Side == "Sell" {
+ side = "long" // Selling closes long
+ } else {
+ side = "short" // Buying closes short
+ }
+
+ // Calculate entry price from PnL
+ var entryPrice float64
+ if trade.Quantity > 0 {
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ }
+
+ records = append(records, ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee,
+ ExitTime: trade.Time,
+ EntryTime: trade.Time,
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ })
+ }
+
+ return records, nil
+}
+
+// GetTrades retrieves trade history from Hyperliquid
+func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
+ // Use UserFillsByTime API
+ startTimeMs := startTime.UnixMilli()
+ fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get user fills: %w", err)
+ }
+
+ var trades []TradeRecord
+ for _, fill := range fills {
+ price, _ := strconv.ParseFloat(fill.Price, 64)
+ qty, _ := strconv.ParseFloat(fill.Size, 64)
+ fee, _ := strconv.ParseFloat(fill.Fee, 64)
+ pnl, _ := strconv.ParseFloat(fill.ClosedPnl, 64)
+
+ // Determine side: "B" = Buy, "S" = Sell (or "A" = Ask, "B" = Bid)
+ var side string
+ if fill.Side == "B" || fill.Side == "Buy" || fill.Side == "bid" {
+ side = "BUY"
+ } else {
+ side = "SELL"
+ }
+
+ // Hyperliquid uses one-way mode, so PositionSide is "BOTH"
+ trade := TradeRecord{
+ TradeID: strconv.FormatInt(fill.Tid, 10),
+ Symbol: fill.Coin,
+ Side: side,
+ PositionSide: "BOTH", // Hyperliquid doesn't have hedge mode
+ Price: price,
+ Quantity: qty,
+ RealizedPnL: pnl,
+ Fee: fee,
+ Time: time.UnixMilli(fill.Time),
+ }
+ trades = append(trades, trade)
+ }
+
+ return trades, nil
}
diff --git a/trader/interface.go b/trader/interface.go
index fcc9f36a..d9074184 100644
--- a/trader/interface.go
+++ b/trader/interface.go
@@ -19,6 +19,20 @@ type ClosedPnLRecord struct {
ExchangeID string // Exchange-specific position ID
}
+// TradeRecord represents a single trade/fill from exchange
+// Used for reconstructing position history with unified algorithm
+type TradeRecord struct {
+ TradeID string // Unique trade ID from exchange
+ Symbol string // Trading pair (e.g., "BTCUSDT")
+ Side string // "BUY" or "SELL"
+ PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode)
+ Price float64 // Execution price
+ Quantity float64 // Executed quantity
+ RealizedPnL float64 // Realized PnL (non-zero for closing trades)
+ Fee float64 // Trading fee/commission
+ Time time.Time // Trade execution time
+}
+
// Trader Unified trader interface
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
type Trader interface {
diff --git a/trader/lighter_trader.go b/trader/lighter_trader.go
index 4d4773c5..f8dedd0b 100644
--- a/trader/lighter_trader.go
+++ b/trader/lighter_trader.go
@@ -214,11 +214,173 @@ func (t *LighterTrader) Run() error {
return fmt.Errorf("please use AutoTrader to manage trader lifecycle")
}
-// GetClosedPnL gets closed position PnL records from exchange
-// LIGHTER does not have a direct closed PnL API, returns empty slice
+// GetClosedPnL gets recent closing trades from Lighter
+// Note: Lighter does NOT have a position history API, only trade history.
+// This returns individual closing trades for real-time position closure detection.
func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
- // LIGHTER does not provide a closed PnL history API
- // Position closure data needs to be tracked locally via position sync
- logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty")
- return []ClosedPnLRecord{}, nil
+ trades, err := t.GetTrades(startTime, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter only closing trades (realizedPnl != 0)
+ var records []ClosedPnLRecord
+ for _, trade := range trades {
+ if trade.RealizedPnL == 0 {
+ continue
+ }
+
+ // Determine side (Lighter uses one-way mode)
+ side := "long"
+ if trade.Side == "SELL" || trade.Side == "Sell" {
+ side = "long"
+ } else {
+ side = "short"
+ }
+
+ // Calculate entry price from PnL
+ var entryPrice float64
+ if trade.Quantity > 0 {
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ }
+
+ records = append(records, ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee,
+ ExitTime: trade.Time,
+ EntryTime: trade.Time,
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ })
+ }
+
+ return records, nil
+}
+
+// LighterTradeResponse represents the response from Lighter trades API
+type LighterTradeResponse struct {
+ Trades []LighterTrade `json:"trades"`
+}
+
+// LighterTrade represents a single trade from Lighter
+type LighterTrade struct {
+ TradeID string `json:"trade_id"`
+ AccountIndex int64 `json:"account_index"`
+ MarketIndex int `json:"market_index"`
+ Symbol string `json:"symbol"`
+ Side string `json:"side"` // "buy" or "sell"
+ Price string `json:"price"`
+ Size string `json:"size"`
+ RealizedPnl string `json:"realized_pnl"`
+ Fee string `json:"fee"`
+ Timestamp int64 `json:"timestamp"`
+ IsMaker bool `json:"is_maker"`
+}
+
+// GetTrades retrieves trade history from Lighter
+func (t *LighterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
+ // Ensure we have account index
+ if t.accountIndex == 0 {
+ accountInfo, err := t.getAccountByL1Address()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get account index: %w", err)
+ }
+ if idx, ok := accountInfo["index"].(int); ok {
+ t.accountIndex = idx
+ } else if idx, ok := accountInfo["index"].(float64); ok {
+ t.accountIndex = int(idx)
+ }
+ }
+
+ // Build request URL
+ // API: GET /api/v1/trades?account_index=X&start_time=Y&limit=Z
+ startTimeMs := startTime.UnixMilli()
+ endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
+ t.baseURL, t.accountIndex, startTimeMs)
+ if limit > 0 {
+ endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
+ }
+
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := t.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get trades: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
+ return []TradeRecord{}, nil // Return empty on error
+ }
+
+ var response LighterTradeResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ // Try parsing as array directly
+ var trades []LighterTrade
+ if err := json.Unmarshal(body, &trades); err != nil {
+ logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err)
+ return []TradeRecord{}, nil
+ }
+ response.Trades = trades
+ }
+
+ // Convert to unified TradeRecord format
+ var result []TradeRecord
+ for _, lt := range response.Trades {
+ price, _ := parseFloat(lt.Price)
+ qty, _ := parseFloat(lt.Size)
+ fee, _ := parseFloat(lt.Fee)
+ pnl, _ := parseFloat(lt.RealizedPnl)
+
+ var side string
+ if strings.ToLower(lt.Side) == "buy" {
+ side = "BUY"
+ } else {
+ side = "SELL"
+ }
+
+ trade := TradeRecord{
+ TradeID: lt.TradeID,
+ Symbol: lt.Symbol,
+ Side: side,
+ PositionSide: "BOTH", // Lighter uses one-way mode
+ Price: price,
+ Quantity: qty,
+ RealizedPnL: pnl,
+ Fee: fee,
+ Time: time.UnixMilli(lt.Timestamp),
+ }
+ result = append(result, trade)
+ }
+
+ return result, nil
+}
+
+// parseFloat safely parses a float string
+func parseFloat(s string) (float64, error) {
+ if s == "" {
+ return 0, nil
+ }
+ var f float64
+ _, err := fmt.Sscanf(s, "%f", &f)
+ return f, err
}
diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go
index 712a81b6..a2c0ea73 100644
--- a/trader/lighter_trader_v2.go
+++ b/trader/lighter_trader_v2.go
@@ -281,8 +281,129 @@ func (t *LighterTraderV2) Cleanup() error {
// GetClosedPnL gets closed position PnL records from exchange
// LIGHTER does not have a direct closed PnL API, returns empty slice
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
- // LIGHTER does not provide a closed PnL history API
- // Position closure data needs to be tracked locally via position sync
- logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty")
- return []ClosedPnLRecord{}, nil
+ trades, err := t.GetTrades(startTime, limit)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter only closing trades (realizedPnl != 0)
+ var records []ClosedPnLRecord
+ for _, trade := range trades {
+ if trade.RealizedPnL == 0 {
+ continue
+ }
+
+ side := "long"
+ if trade.Side == "SELL" || trade.Side == "Sell" {
+ side = "long"
+ } else {
+ side = "short"
+ }
+
+ var entryPrice float64
+ if trade.Quantity > 0 {
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ }
+
+ records = append(records, ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee,
+ ExitTime: trade.Time,
+ EntryTime: trade.Time,
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ })
+ }
+
+ return records, nil
+}
+
+// GetTrades retrieves trade history from Lighter
+func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
+ // Ensure we have account index
+ if t.accountIndex == 0 {
+ if err := t.initializeAccount(); err != nil {
+ return nil, fmt.Errorf("failed to get account index: %w", err)
+ }
+ }
+
+ // Build request URL
+ startTimeMs := startTime.UnixMilli()
+ endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
+ t.baseURL, t.accountIndex, startTimeMs)
+ if limit > 0 {
+ endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
+ }
+
+ req, err := http.NewRequest("GET", endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := t.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get trades: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
+ return []TradeRecord{}, nil
+ }
+
+ var response LighterTradeResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ var trades []LighterTrade
+ if err := json.Unmarshal(body, &trades); err != nil {
+ logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err)
+ return []TradeRecord{}, nil
+ }
+ response.Trades = trades
+ }
+
+ // Convert to unified TradeRecord format
+ var result []TradeRecord
+ for _, lt := range response.Trades {
+ price, _ := parseFloat(lt.Price)
+ qty, _ := parseFloat(lt.Size)
+ fee, _ := parseFloat(lt.Fee)
+ pnl, _ := parseFloat(lt.RealizedPnl)
+
+ var side string
+ if strings.ToLower(lt.Side) == "buy" {
+ side = "BUY"
+ } else {
+ side = "SELL"
+ }
+
+ trade := TradeRecord{
+ TradeID: lt.TradeID,
+ Symbol: lt.Symbol,
+ Side: side,
+ PositionSide: "BOTH",
+ Price: price,
+ Quantity: qty,
+ RealizedPnL: pnl,
+ Fee: fee,
+ Time: time.UnixMilli(lt.Timestamp),
+ }
+ result = append(result, trade)
+ }
+
+ return result, nil
}
diff --git a/trader/okx_trader.go b/trader/okx_trader.go
index d03c8e1d..d705fabe 100644
--- a/trader/okx_trader.go
+++ b/trader/okx_trader.go
@@ -65,13 +65,14 @@ type OKXTrader struct {
// OKXInstrument OKX instrument info
type OKXInstrument struct {
- InstID string // Instrument ID
- CtVal float64 // Contract value
- CtMult float64 // Contract multiplier
- LotSz float64 // Minimum order size
- MinSz float64 // Minimum order size
- TickSz float64 // Minimum price increment
- CtType string // Contract type
+ InstID string // Instrument ID
+ CtVal float64 // Contract value
+ CtMult float64 // Contract multiplier
+ LotSz float64 // Minimum order size
+ MinSz float64 // Minimum order size
+ MaxMktSz float64 // Maximum market order size
+ TickSz float64 // Minimum price increment
+ CtType string // Contract type
}
// OKXResponse OKX API response
@@ -97,13 +98,18 @@ func genOkxClOrdID() string {
// NewOKXTrader creates OKX trader
func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader {
- // Use http.DefaultClient to stay consistent with Binance/Bybit SDK
- // DefaultClient uses DefaultTransport, which reads proxy settings from environment variables
+ // Use default transport which respects system proxy settings
+ // OKX requires proxy in China due to DNS pollution
+ httpClient := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: http.DefaultTransport,
+ }
+
trader := &OKXTrader{
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
- httpClient: http.DefaultClient,
+ httpClient: httpClient,
cacheDuration: 15 * time.Second,
instrumentsCache: make(map[string]*OKXInstrument),
}
@@ -394,13 +400,14 @@ func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
}
var instruments []struct {
- InstId string `json:"instId"`
- CtVal string `json:"ctVal"`
- CtMult string `json:"ctMult"`
- LotSz string `json:"lotSz"`
- MinSz string `json:"minSz"`
- TickSz string `json:"tickSz"`
- CtType string `json:"ctType"`
+ InstId string `json:"instId"`
+ CtVal string `json:"ctVal"`
+ CtMult string `json:"ctMult"`
+ LotSz string `json:"lotSz"`
+ MinSz string `json:"minSz"`
+ MaxMktSz string `json:"maxMktSz"` // Maximum market order size
+ TickSz string `json:"tickSz"`
+ CtType string `json:"ctType"`
}
if err := json.Unmarshal(data, &instruments); err != nil {
@@ -416,16 +423,18 @@ func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
ctMult, _ := strconv.ParseFloat(inst.CtMult, 64)
lotSz, _ := strconv.ParseFloat(inst.LotSz, 64)
minSz, _ := strconv.ParseFloat(inst.MinSz, 64)
+ maxMktSz, _ := strconv.ParseFloat(inst.MaxMktSz, 64)
tickSz, _ := strconv.ParseFloat(inst.TickSz, 64)
instrument := &OKXInstrument{
- InstID: inst.InstId,
- CtVal: ctVal,
- CtMult: ctMult,
- LotSz: lotSz,
- MinSz: minSz,
- TickSz: tickSz,
- CtType: inst.CtType,
+ InstID: inst.InstId,
+ CtVal: ctVal,
+ CtMult: ctMult,
+ LotSz: lotSz,
+ MinSz: minSz,
+ MaxMktSz: maxMktSz,
+ TickSz: tickSz,
+ CtType: inst.CtType,
}
// Update cache
@@ -525,6 +534,13 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map
sz := quantity * price / inst.CtVal
szStr := t.formatSize(sz, inst)
+ // Check max market order size limit
+ if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
+ logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
+ sz = inst.MaxMktSz
+ szStr = t.formatSize(sz, inst)
+ }
+
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
@@ -596,6 +612,13 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma
sz := quantity * price / inst.CtVal
szStr := t.formatSize(sz, inst)
+ // Check max market order size limit
+ if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
+ logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
+ sz = inst.MaxMktSz
+ szStr = t.formatSize(sz, inst)
+ }
+
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
diff --git a/trader/position_rebuild.go b/trader/position_rebuild.go
new file mode 100644
index 00000000..79c8cf2e
--- /dev/null
+++ b/trader/position_rebuild.go
@@ -0,0 +1,195 @@
+package trader
+
+import (
+ "fmt"
+ "sort"
+ "time"
+)
+
+// =============================================================================
+// Unified Position Rebuild Algorithm
+// All exchanges use this same algorithm to reconstruct position history from trades
+// =============================================================================
+
+// openTradeEntry represents an opening trade for position tracking
+type openTradeEntry struct {
+ Price float64
+ Quantity float64
+ Fee float64
+ Time time.Time
+ TradeID string
+}
+
+// positionState tracks open trades for a symbol+side combination
+type positionState struct {
+ OpenTrades []openTradeEntry
+ TotalQty float64
+}
+
+// RebuildPositionsFromTrades reconstructs complete position records from trade history
+// This is the unified algorithm used by all exchanges
+//
+// Algorithm:
+// 1. Sort trades by time
+// 2. For each trade, determine if it's opening or closing based on RealizedPnL
+// 3. Opening trade (RealizedPnL == 0): Add to open trades list
+// 4. Closing trade (RealizedPnL != 0): Match with open trades using FIFO, generate position record
+//
+// The algorithm handles:
+// - Partial opens (multiple trades to build a position)
+// - Partial closes (multiple trades to close a position)
+// - Both hedge mode (LONG/SHORT) and one-way mode (BOTH)
+func RebuildPositionsFromTrades(trades []TradeRecord) []ClosedPnLRecord {
+ if len(trades) == 0 {
+ return nil
+ }
+
+ // Sort trades by time
+ sort.Slice(trades, func(i, j int) bool {
+ return trades[i].Time.Before(trades[j].Time)
+ })
+
+ // Track positions by symbol_side
+ positions := make(map[string]*positionState)
+ var records []ClosedPnLRecord
+
+ for _, trade := range trades {
+ // Determine position side
+ side := determinePositionSide(trade)
+ if side == "" {
+ continue // Skip invalid trades
+ }
+
+ key := fmt.Sprintf("%s_%s", trade.Symbol, side)
+ if positions[key] == nil {
+ positions[key] = &positionState{}
+ }
+ state := positions[key]
+
+ if trade.RealizedPnL == 0 {
+ // Opening trade: add to open trades list
+ state.OpenTrades = append(state.OpenTrades, openTradeEntry{
+ Price: trade.Price,
+ Quantity: trade.Quantity,
+ Fee: trade.Fee,
+ Time: trade.Time,
+ TradeID: trade.TradeID,
+ })
+ state.TotalQty += trade.Quantity
+ } else {
+ // Closing trade: generate position record
+ record := buildClosedPosition(trade, side, state)
+ if record != nil {
+ records = append(records, *record)
+ }
+ }
+ }
+
+ return records
+}
+
+// determinePositionSide determines the position side from a trade
+func determinePositionSide(trade TradeRecord) string {
+ // Hedge mode: use PositionSide directly
+ switch trade.PositionSide {
+ case "LONG", "long":
+ return "long"
+ case "SHORT", "short":
+ return "short"
+ }
+
+ // One-way mode (BOTH or empty): determine from trade direction and RealizedPnL
+ if trade.RealizedPnL == 0 {
+ // Opening trade
+ if trade.Side == "BUY" || trade.Side == "Buy" {
+ return "long"
+ } else if trade.Side == "SELL" || trade.Side == "Sell" {
+ return "short"
+ }
+ } else {
+ // Closing trade
+ if trade.Side == "BUY" || trade.Side == "Buy" {
+ return "short" // Buy to close short
+ } else if trade.Side == "SELL" || trade.Side == "Sell" {
+ return "long" // Sell to close long
+ }
+ }
+
+ return ""
+}
+
+// buildClosedPosition builds a closed position record from a closing trade
+func buildClosedPosition(trade TradeRecord, side string, state *positionState) *ClosedPnLRecord {
+ var entryPrice float64
+ var entryTime time.Time
+ var totalEntryFee float64
+
+ if len(state.OpenTrades) > 0 {
+ // Use FIFO to match open trades
+ remainingQty := trade.Quantity
+ var weightedSum float64
+ var matchedQty float64
+
+ for i := 0; i < len(state.OpenTrades) && remainingQty > 0.00000001; i++ {
+ ot := &state.OpenTrades[i]
+ matchQty := ot.Quantity
+ if matchQty > remainingQty {
+ matchQty = remainingQty
+ }
+
+ weightedSum += ot.Price * matchQty
+ matchedQty += matchQty
+ totalEntryFee += ot.Fee * (matchQty / ot.Quantity)
+
+ if entryTime.IsZero() {
+ entryTime = ot.Time
+ }
+
+ remainingQty -= matchQty
+ ot.Quantity -= matchQty
+
+ // Remove fully consumed open trade
+ if ot.Quantity <= 0.00000001 {
+ state.OpenTrades = append(state.OpenTrades[:i], state.OpenTrades[i+1:]...)
+ i--
+ }
+ }
+
+ if matchedQty > 0.00000001 {
+ entryPrice = weightedSum / matchedQty
+ }
+ state.TotalQty -= trade.Quantity
+ }
+
+ // If no open trades found (history incomplete), calculate entry price from PnL
+ if entryPrice == 0 && trade.Quantity > 0 {
+ // PnL = (exitPrice - entryPrice) * qty for LONG
+ // PnL = (entryPrice - exitPrice) * qty for SHORT
+ if side == "long" {
+ entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
+ } else {
+ entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
+ }
+ entryTime = trade.Time // Use exit time as fallback
+ }
+
+ // Validate data
+ if entryPrice <= 0 || trade.Price <= 0 || trade.Quantity <= 0 {
+ return nil
+ }
+
+ return &ClosedPnLRecord{
+ Symbol: trade.Symbol,
+ Side: side,
+ EntryPrice: entryPrice,
+ ExitPrice: trade.Price,
+ Quantity: trade.Quantity,
+ RealizedPnL: trade.RealizedPnL,
+ Fee: trade.Fee + totalEntryFee,
+ EntryTime: entryTime,
+ ExitTime: trade.Time,
+ OrderID: trade.TradeID,
+ ExchangeID: trade.TradeID,
+ CloseType: "unknown",
+ }
+}
diff --git a/trader/position_sync.go b/trader/position_sync.go
index e565eb23..cee40c04 100644
--- a/trader/position_sync.go
+++ b/trader/position_sync.go
@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/logger"
"nofx/store"
+ "strings"
"sync"
"time"
)
@@ -117,16 +118,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
return
}
- // Get exchange ID for history sync
+ // Get exchange info for history sync
config, _ := m.getTraderConfig(traderID)
exchangeID := ""
+ exchangeType := ""
if config != nil {
- exchangeID = config.Exchange.ID
+ exchangeID = config.Exchange.ID // UUID for database association
+ exchangeType = config.Exchange.ExchangeType // "binance", "bybit" etc for trader creation
}
// Maybe run periodic history sync
- if exchangeID != "" {
- m.maybeRunHistorySync(traderID, exchangeID, trader)
+ if exchangeID != "" && exchangeType != "" {
+ m.maybeRunHistorySync(traderID, exchangeID, exchangeType, trader)
}
// Get current exchange positions
@@ -137,14 +140,17 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
}
// Build exchange position map: symbol_side -> position
+ // Note: Exchange returns side as "long"/"short" (lowercase), database stores "LONG"/"SHORT" (uppercase)
exchangeMap := make(map[string]map[string]interface{})
for _, pos := range exchangePositions {
symbol, _ := pos["symbol"].(string)
- side, _ := pos["positionSide"].(string)
+ side, _ := pos["side"].(string) // Note: use "side" not "positionSide"
if symbol == "" || side == "" {
continue
}
- key := fmt.Sprintf("%s_%s", symbol, side)
+ // Normalize side to uppercase for matching with database
+ normalizedSide := strings.ToUpper(side)
+ key := fmt.Sprintf("%s_%s", symbol, normalizedSide)
exchangeMap[key] = pos
}
@@ -226,31 +232,125 @@ func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trad
}
// findClosedPnLRecord Try to find matching ClosedPnL record from exchange
+// For Binance, directly query trades for the specific symbol (more reliable than Income API)
func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.TraderPosition) *ClosedPnLRecord {
- // Get closed PnL records from the last 24 hours (to cover recent closures)
+ // Try to get trades directly for this symbol (Binance-specific, more reliable)
+ if binanceTrader, ok := trader.(*FuturesTrader); ok {
+ return m.findClosedPnLFromBinanceTrades(binanceTrader, pos)
+ }
+
+ // Fallback: use GetClosedPnL for other exchanges
startTime := time.Now().Add(-24 * time.Hour)
- records, err := trader.GetClosedPnL(startTime, 50)
+ records, err := trader.GetClosedPnL(startTime, 100)
if err != nil {
logger.Infof("⚠️ Failed to get closed PnL records: %v", err)
return nil
}
+ return m.aggregateClosedRecords(records, pos)
+}
+
+// findClosedPnLFromBinanceTrades queries Binance directly for trades of a specific symbol
+func (m *PositionSyncManager) findClosedPnLFromBinanceTrades(trader *FuturesTrader, pos *store.TraderPosition) *ClosedPnLRecord {
+ // Query trades for this specific symbol from the last hour
+ startTime := time.Now().Add(-1 * time.Hour)
+ trades, err := trader.GetTradesForSymbol(pos.Symbol, startTime, 100)
+ if err != nil {
+ logger.Infof("⚠️ Failed to get trades for %s: %v", pos.Symbol, err)
+ return nil
+ }
+
+ if len(trades) == 0 {
+ logger.Infof("⚠️ No trades found for %s in the last hour", pos.Symbol)
+ return nil
+ }
+
+ // Find all closing trades (realizedPnl != 0) that match this position
+ var totalQty, totalPnL, totalFee float64
+ var weightedExitPrice float64
+ var latestExitTime time.Time
+ var latestTradeID string
+ matchCount := 0
+
+ posSide := strings.ToLower(pos.Side)
+
+ for _, trade := range trades {
+ // Skip opening trades
+ if trade.RealizedPnL == 0 {
+ continue
+ }
+
+ // Determine if this trade closes our position
+ // For LONG position: SELL closes it
+ // For SHORT position: BUY closes it
+ isClosingTrade := false
+ tradeSide := strings.ToUpper(trade.Side)
+ positionSide := strings.ToUpper(trade.PositionSide)
+
+ if positionSide == "LONG" && posSide == "long" {
+ isClosingTrade = true
+ } else if positionSide == "SHORT" && posSide == "short" {
+ isClosingTrade = true
+ } else if positionSide == "BOTH" || positionSide == "" {
+ // One-way mode
+ if tradeSide == "SELL" && posSide == "long" {
+ isClosingTrade = true
+ } else if tradeSide == "BUY" && posSide == "short" {
+ isClosingTrade = true
+ }
+ }
+
+ if !isClosingTrade {
+ continue
+ }
+
+ // Aggregate this trade
+ totalQty += trade.Quantity
+ totalPnL += trade.RealizedPnL
+ totalFee += trade.Fee
+ weightedExitPrice += trade.Price * trade.Quantity
+ matchCount++
+
+ if trade.Time.After(latestExitTime) {
+ latestExitTime = trade.Time
+ latestTradeID = trade.TradeID
+ }
+ }
+
+ if matchCount == 0 {
+ logger.Infof("⚠️ No closing trades found for %s %s", pos.Symbol, pos.Side)
+ return nil
+ }
+
+ avgExitPrice := weightedExitPrice / totalQty
+
+ logger.Infof("📊 Found %d closing trades for %s %s: qty=%.4f, exitPrice=%.6f, pnl=%.4f, fee=%.4f",
+ matchCount, pos.Symbol, pos.Side, totalQty, avgExitPrice, totalPnL, totalFee)
+
+ return &ClosedPnLRecord{
+ Symbol: pos.Symbol,
+ Side: posSide,
+ EntryPrice: pos.EntryPrice,
+ ExitPrice: avgExitPrice,
+ Quantity: totalQty,
+ RealizedPnL: totalPnL,
+ Fee: totalFee,
+ ExitTime: latestExitTime,
+ EntryTime: pos.EntryTime,
+ OrderID: latestTradeID,
+ ExchangeID: latestTradeID,
+ CloseType: "unknown",
+ }
+}
+
+// aggregateClosedRecords aggregates closed PnL records for a position
+func (m *PositionSyncManager) aggregateClosedRecords(records []ClosedPnLRecord, pos *store.TraderPosition) *ClosedPnLRecord {
if len(records) == 0 {
return nil
}
- // Normalize position side for comparison
- posSide := pos.Side
- if posSide == "LONG" {
- posSide = "long"
- } else if posSide == "SHORT" {
- posSide = "short"
- }
-
- // Find matching record by symbol and side
- // Priority: exact match on symbol and side, closest entry price
- var bestMatch *ClosedPnLRecord
- var bestPriceDiff float64 = -1
+ posSide := strings.ToLower(pos.Side)
+ var matchingRecords []ClosedPnLRecord
for i := range records {
record := &records[i]
@@ -258,39 +358,55 @@ func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.Trad
continue
}
- // Match side (case-insensitive)
- recordSide := record.Side
- if recordSide == "LONG" {
- recordSide = "long"
- } else if recordSide == "SHORT" {
- recordSide = "short"
- }
-
+ recordSide := strings.ToLower(record.Side)
if recordSide != posSide {
continue
}
- // Check if entry price is close (within 2% to account for slippage)
- if record.EntryPrice > 0 {
- priceDiff := abs((record.EntryPrice - pos.EntryPrice) / pos.EntryPrice)
- if priceDiff > 0.02 {
- continue // Entry price too different, probably not the same position
- }
+ matchingRecords = append(matchingRecords, *record)
+ }
- // Prefer closest entry price match
- if bestMatch == nil || priceDiff < bestPriceDiff {
- bestMatch = record
- bestPriceDiff = priceDiff
- }
- } else {
- // No entry price in record, accept if symbol and side match
- if bestMatch == nil {
- bestMatch = record
- }
+ if len(matchingRecords) == 0 {
+ return nil
+ }
+
+ var totalQty, totalPnL, totalFee float64
+ var weightedExitPrice float64
+ var latestExitTime time.Time
+ var latestOrderID, latestExchangeID string
+
+ for _, rec := range matchingRecords {
+ totalQty += rec.Quantity
+ totalPnL += rec.RealizedPnL
+ totalFee += rec.Fee
+ weightedExitPrice += rec.ExitPrice * rec.Quantity
+
+ if rec.ExitTime.After(latestExitTime) {
+ latestExitTime = rec.ExitTime
+ latestOrderID = rec.OrderID
+ latestExchangeID = rec.ExchangeID
}
}
- return bestMatch
+ avgExitPrice := weightedExitPrice / totalQty
+
+ logger.Infof("📊 Aggregated %d closing trades for %s %s: qty=%.4f, pnl=%.4f, fee=%.4f",
+ len(matchingRecords), pos.Symbol, pos.Side, totalQty, totalPnL, totalFee)
+
+ return &ClosedPnLRecord{
+ Symbol: pos.Symbol,
+ Side: posSide,
+ EntryPrice: pos.EntryPrice,
+ ExitPrice: avgExitPrice,
+ Quantity: totalQty,
+ RealizedPnL: totalPnL,
+ Fee: totalFee,
+ ExitTime: latestExitTime,
+ EntryTime: pos.EntryTime,
+ OrderID: latestOrderID,
+ ExchangeID: latestExchangeID,
+ CloseType: "unknown",
+ }
}
// abs returns absolute value of float64
@@ -373,8 +489,8 @@ func (m *PositionSyncManager) getTraderConfig(traderID string) (*store.TraderFul
func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) {
exchange := config.Exchange
- // Use exchange.ID to determine specific exchange, not exchange.Type (cex/dex)
- switch exchange.ID {
+ // Use exchange.ExchangeType to determine specific exchange, not exchange.ID (UUID) or exchange.Type (cex/dex)
+ switch exchange.ExchangeType {
case "binance":
return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil
@@ -402,7 +518,7 @@ func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trad
return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet)
default:
- return nil, fmt.Errorf("unsupported exchange: %s", exchange.ID)
+ return nil, fmt.Errorf("unsupported exchange type: %s", exchange.ExchangeType)
}
}
@@ -461,19 +577,20 @@ func (m *PositionSyncManager) startupSync() {
continue
}
- // Get exchange ID
+ // Get exchange info
config, err := m.getTraderConfig(traderID)
if err != nil {
logger.Infof("⚠️ Failed to get trader config for startup sync (ID: %s): %v", traderID, err)
continue
}
- exchangeID := config.Exchange.ID
+ exchangeID := config.Exchange.ID // UUID
+ exchangeType := config.Exchange.ExchangeType // "binance", "bybit" etc
// 1. Sync current open positions from exchange
- m.syncExternalPositions(traderID, exchangeID, trader)
+ m.syncExternalPositions(traderID, exchangeID, exchangeType, trader)
// 2. Sync closed positions history from exchange
- m.syncClosedPositionsHistory(traderID, exchangeID, trader)
+ m.syncClosedPositionsHistory(traderID, exchangeID, exchangeType, trader)
}
logger.Info("📊 Startup sync completed")
@@ -481,7 +598,7 @@ func (m *PositionSyncManager) startupSync() {
// syncExternalPositions syncs positions that exist on exchange but not locally
// These could be positions opened manually or from other systems
-func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, trader Trader) {
+func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID, exchangeType string, trader Trader) {
// Get current positions from exchange
exchangePositions, err := trader.GetPositions()
if err != nil {
@@ -556,6 +673,7 @@ func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string,
newPos := &store.TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
+ ExchangeType: exchangeType,
ExchangePositionID: exchangePositionID,
Symbol: symbol,
Side: normalizedSide,
@@ -576,57 +694,97 @@ func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string,
}
// syncClosedPositionsHistory syncs closed positions from exchange history
-func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID string, trader Trader) {
- // Get last sync time
+// IMPORTANT: Only exchanges with position-level history API should sync history:
+// - Bybit: /v5/position/closed-pnl (accurate position records)
+// - OKX: /api/v5/account/positions-history (accurate position records)
+// Other exchanges (Binance, Hyperliquid, Lighter, Aster) only have trade-level data,
+// which cannot accurately reconstruct positions. They should NOT sync historical positions.
+func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID, exchangeType string, trader Trader) {
+ // Only sync history for exchanges with position-level API
+ // Binance/Hyperliquid/Lighter/Aster only have trade-level data, skip history sync
+ switch exchangeType {
+ case "bybit", "okx":
+ // These exchanges have position-level history API, proceed with sync
+ default:
+ // Other exchanges don't have accurate position history API
+ // Their GetClosedPnL only returns recent trades for closure detection, not for history sync
+ return
+ }
+
+ // Get last sync time from database
lastSyncTime, err := m.store.Position().GetLastClosedPositionTime(traderID)
if err != nil {
logger.Infof("⚠️ Failed to get last closed position time (ID: %s): %v", traderID, err)
- lastSyncTime = time.Now().Add(-30 * 24 * time.Hour) // Default to 30 days ago
+ // First sync: go back 90 days to get more history
+ lastSyncTime = time.Now().Add(-90 * 24 * time.Hour)
}
// Subtract a small buffer to avoid missing positions at the boundary
startTime := lastSyncTime.Add(-1 * time.Minute)
- // Get closed positions from exchange
- closedRecords, err := trader.GetClosedPnL(startTime, 200) // Get up to 200 records
- if err != nil {
- logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err)
- return
- }
+ // Pagination loop to get all records
+ const batchSize = 500
+ totalCreated := 0
+ totalSkipped := 0
- if len(closedRecords) == 0 {
- return
- }
-
- // Convert to store.ClosedPnLRecord and sync
- storeRecords := make([]store.ClosedPnLRecord, len(closedRecords))
- for i, rec := range closedRecords {
- storeRecords[i] = store.ClosedPnLRecord{
- Symbol: rec.Symbol,
- Side: rec.Side,
- EntryPrice: rec.EntryPrice,
- ExitPrice: rec.ExitPrice,
- Quantity: rec.Quantity,
- RealizedPnL: rec.RealizedPnL,
- Fee: rec.Fee,
- Leverage: rec.Leverage,
- EntryTime: rec.EntryTime,
- ExitTime: rec.ExitTime,
- OrderID: rec.OrderID,
- CloseType: rec.CloseType,
- ExchangeID: rec.ExchangeID,
+ for {
+ // Get closed positions from exchange
+ closedRecords, err := trader.GetClosedPnL(startTime, batchSize)
+ if err != nil {
+ logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err)
+ break
}
+
+ if len(closedRecords) == 0 {
+ break
+ }
+
+ // Convert to store.ClosedPnLRecord and sync
+ storeRecords := make([]store.ClosedPnLRecord, len(closedRecords))
+ var latestExitTime time.Time
+ for i, rec := range closedRecords {
+ storeRecords[i] = store.ClosedPnLRecord{
+ Symbol: rec.Symbol,
+ Side: rec.Side,
+ EntryPrice: rec.EntryPrice,
+ ExitPrice: rec.ExitPrice,
+ Quantity: rec.Quantity,
+ RealizedPnL: rec.RealizedPnL,
+ Fee: rec.Fee,
+ Leverage: rec.Leverage,
+ EntryTime: rec.EntryTime,
+ ExitTime: rec.ExitTime,
+ OrderID: rec.OrderID,
+ CloseType: rec.CloseType,
+ ExchangeID: rec.ExchangeID,
+ }
+ // Track latest exit time for pagination
+ if rec.ExitTime.After(latestExitTime) {
+ latestExitTime = rec.ExitTime
+ }
+ }
+
+ created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, exchangeType, storeRecords)
+ if err != nil {
+ logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err)
+ break
+ }
+
+ totalCreated += created
+ totalSkipped += skipped
+
+ // If we got fewer records than batch size, we've reached the end
+ if len(closedRecords) < batchSize {
+ break
+ }
+
+ // Move start time forward for next batch (add 1ms to avoid duplicate)
+ startTime = latestExitTime.Add(time.Millisecond)
}
- created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, storeRecords)
- if err != nil {
- logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err)
- return
- }
-
- if created > 0 {
+ if totalCreated > 0 {
logger.Infof("📊 Synced %d new closed positions for trader %s (skipped %d duplicates)",
- created, traderID[:8], skipped)
+ totalCreated, traderID[:8], totalSkipped)
}
// Update last history sync time
@@ -636,12 +794,12 @@ func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID st
}
// maybeRunHistorySync checks if it's time to run history sync for a trader
-func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID string, trader Trader) {
+func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID, exchangeType string, trader Trader) {
m.lastHistorySyncMutex.RLock()
lastSync, exists := m.lastHistorySync[traderID]
m.lastHistorySyncMutex.RUnlock()
if !exists || time.Since(lastSync) >= m.historySyncInterval {
- m.syncClosedPositionsHistory(traderID, exchangeID, trader)
+ m.syncClosedPositionsHistory(traderID, exchangeID, exchangeType, trader)
}
}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 326d6d23..68b10d55 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -29,6 +29,7 @@ import type {
DecisionRecord,
Statistics,
TraderInfo,
+ Exchange,
} from './types'
type Page =
@@ -55,6 +56,23 @@ function getModelDisplayName(modelId: string): string {
}
}
+// Helper function to get exchange display name from exchange ID (UUID)
+function getExchangeDisplayNameFromList(exchangeId: string | undefined, exchanges: Exchange[] | undefined): string {
+ if (!exchangeId) return 'Unknown'
+ const exchange = exchanges?.find(e => e.id === exchangeId)
+ if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...'
+ const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
+ return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName
+}
+
+// Helper function to get exchange type from exchange ID (UUID) - for TradingView charts
+function getExchangeTypeFromList(exchangeId: string | undefined, exchanges: Exchange[] | undefined): string {
+ if (!exchangeId) return 'BINANCE'
+ const exchange = exchanges?.find(e => e.id === exchangeId)
+ if (!exchange) return 'BINANCE' // Default to BINANCE for charts
+ return exchange.exchange_type?.toUpperCase() || 'BINANCE'
+}
+
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
@@ -130,6 +148,16 @@ function App() {
}
)
+ // 获取exchanges列表(用于显示交易所名称)
+ const { data: exchanges } = useSWR
-