mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
fix: OKX trading issues and improve position tracking
- Add maxMktSz check for OKX market orders to prevent exceeding limits - Increase margin safety buffer (0.1% fee + 1% buffer) for all exchanges - Fix Binance position closure detection with direct trade queries - Move Recent Completed Trades before Current Positions in AI prompt - Update README screenshots with table layout for better alignment
This commit is contained in:
@@ -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 |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
|
||||
|
||||
### Competition Mode
|
||||
<p align="center">
|
||||
<img src="screenshots/competition-page.png" width="700" alt="Competition Page"/>
|
||||
</p>
|
||||
|
||||
*Multi-AI leaderboard with real-time performance comparison*
|
||||
|
||||
### Dashboard - Market Chart View
|
||||

|
||||
*Professional trading dashboard with TradingView-style charts*
|
||||
### Dashboard
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
|
||||
|
||||
### Strategy Studio
|
||||

|
||||
*Strategy configuration with multiple data sources and AI test*
|
||||
| Strategy Editor | Indicators Config |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+198
-31
@@ -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)
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
+273
-45
@@ -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
|
||||
|
||||
+95
-54
@@ -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)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+120
-6
@@ -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
|
||||
}
|
||||
|
||||
+30
-14
@@ -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,
|
||||
|
||||
+116
-84
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+168
-6
@@ -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
|
||||
}
|
||||
|
||||
+125
-4
@@ -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
|
||||
}
|
||||
|
||||
+47
-24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
+251
-93
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -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<Exchange[]>(
|
||||
user && token ? 'exchanges' : null,
|
||||
api.getExchangeConfigs,
|
||||
{
|
||||
refreshInterval: 60000, // 1分钟刷新一次
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
// 当获取到traders后,设置默认选中第一个
|
||||
useEffect(() => {
|
||||
if (traders && traders.length > 0 && !selectedTraderId) {
|
||||
@@ -445,6 +473,7 @@ function App() {
|
||||
setRoute('/traders')
|
||||
setCurrentPage('traders')
|
||||
}}
|
||||
exchanges={exchanges}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -563,6 +592,7 @@ function TraderDetailsPage({
|
||||
selectedTraderId,
|
||||
onTraderSelect,
|
||||
onNavigateToTraders,
|
||||
exchanges,
|
||||
}: {
|
||||
selectedTrader?: TraderInfo
|
||||
traders?: TraderInfo[]
|
||||
@@ -577,6 +607,7 @@ function TraderDetailsPage({
|
||||
stats?: Statistics
|
||||
lastUpdate: string
|
||||
language: Language
|
||||
exchanges?: Exchange[]
|
||||
}) {
|
||||
const [closingPosition, setClosingPosition] = useState<string | null>(null)
|
||||
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>(undefined)
|
||||
@@ -830,7 +861,7 @@ function TraderDetailsPage({
|
||||
<span>
|
||||
Exchange:{' '}
|
||||
<span className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{selectedTrader.exchange_id?.toUpperCase() || 'N/A'}
|
||||
{getExchangeDisplayNameFromList(selectedTrader.exchange_id, exchanges)}
|
||||
</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
@@ -907,7 +938,7 @@ function TraderDetailsPage({
|
||||
traderId={selectedTrader.trader_id}
|
||||
selectedSymbol={selectedChartSymbol}
|
||||
updateKey={chartUpdateKey}
|
||||
exchangeId={selectedTrader.exchange_id}
|
||||
exchangeId={getExchangeTypeFromList(selectedTrader.exchange_id, exchanges)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+103
-1175
File diff suppressed because it is too large
Load Diff
@@ -276,16 +276,16 @@ export function TraderConfigModal({
|
||||
>
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(
|
||||
exchange.name || exchange.id
|
||||
).toUpperCase()}
|
||||
{getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()}
|
||||
{exchange.account_name ? ` - ${exchange.account_name}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Exchange Registration Link */}
|
||||
{formData.exchange_id && (() => {
|
||||
// Exchange ID is the exchange type (e.g., "binance", "okx", "aster")
|
||||
const exchangeType = formData.exchange_id.toLowerCase()
|
||||
// Find the selected exchange to get its type
|
||||
const selectedExchange = availableExchanges.find(e => e.id === formData.exchange_id)
|
||||
const exchangeType = selectedExchange?.exchange_type?.toLowerCase() || ''
|
||||
const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType]
|
||||
if (!regLink) return null
|
||||
return (
|
||||
|
||||
@@ -16,11 +16,23 @@ import { toast } from 'sonner'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { getShortName } from './utils'
|
||||
|
||||
// Supported exchange templates for creating new accounts
|
||||
const SUPPORTED_EXCHANGE_TEMPLATES = [
|
||||
{ exchange_type: 'binance', name: 'Binance Futures', type: 'cex' as const },
|
||||
{ exchange_type: 'bybit', name: 'Bybit Futures', type: 'cex' as const },
|
||||
{ exchange_type: 'okx', name: 'OKX Futures', type: 'cex' as const },
|
||||
{ exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const },
|
||||
{ exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const },
|
||||
{ exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const },
|
||||
]
|
||||
|
||||
interface ExchangeConfigModalProps {
|
||||
allExchanges: Exchange[]
|
||||
editingExchangeId: string | null
|
||||
onSave: (
|
||||
exchangeId: string,
|
||||
exchangeId: string | null, // null for creating new account
|
||||
exchangeType: string,
|
||||
accountName: string,
|
||||
apiKey: string,
|
||||
secretKey?: string,
|
||||
passphrase?: string, // OKX专用
|
||||
@@ -46,9 +58,8 @@ export function ExchangeConfigModal({
|
||||
onClose,
|
||||
language,
|
||||
}: ExchangeConfigModalProps) {
|
||||
const [selectedExchangeId, setSelectedExchangeId] = useState(
|
||||
editingExchangeId || ''
|
||||
)
|
||||
// Selected exchange type for creating new accounts
|
||||
const [selectedExchangeType, setSelectedExchangeType] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [secretKey, setSecretKey] = useState('')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
@@ -87,10 +98,25 @@ export function ExchangeConfigModal({
|
||||
// 保存中状态
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// 获取当前编辑的交易所信息
|
||||
const selectedExchange = allExchanges?.find(
|
||||
(e) => e.id === selectedExchangeId
|
||||
)
|
||||
// 账户名称
|
||||
const [accountName, setAccountName] = useState('')
|
||||
|
||||
// 获取当前编辑的交易所信息或模板
|
||||
// For editing: find the existing account by id (UUID)
|
||||
// For creating: use the selected exchange template
|
||||
const selectedExchange = editingExchangeId
|
||||
? allExchanges?.find((e) => e.id === editingExchangeId)
|
||||
: null
|
||||
|
||||
// Get the exchange template for displaying UI fields
|
||||
const selectedTemplate = editingExchangeId
|
||||
? SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchange?.exchange_type)
|
||||
: SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchangeType)
|
||||
|
||||
// Get the current exchange type (from existing account or selected template)
|
||||
const currentExchangeType = editingExchangeId
|
||||
? selectedExchange?.exchange_type
|
||||
: selectedExchangeType
|
||||
|
||||
// 交易所注册链接配置
|
||||
const exchangeRegistrationLinks: Record<string, { url: string; hasReferral?: boolean }> = {
|
||||
@@ -105,6 +131,7 @@ export function ExchangeConfigModal({
|
||||
// 如果是编辑现有交易所,初始化表单数据
|
||||
useEffect(() => {
|
||||
if (editingExchangeId && selectedExchange) {
|
||||
setAccountName(selectedExchange.account_name || '')
|
||||
setApiKey(selectedExchange.apiKey || '')
|
||||
setSecretKey(selectedExchange.secretKey || '')
|
||||
setPassphrase('') // Don't load existing passphrase for security
|
||||
@@ -127,7 +154,7 @@ export function ExchangeConfigModal({
|
||||
|
||||
// 加载服务器IP(当选择binance时)
|
||||
useEffect(() => {
|
||||
if (selectedExchangeId === 'binance' && !serverIP) {
|
||||
if (currentExchangeType === 'binance' && !serverIP) {
|
||||
setLoadingIP(true)
|
||||
api
|
||||
.getServerIP()
|
||||
@@ -141,7 +168,7 @@ export function ExchangeConfigModal({
|
||||
setLoadingIP(false)
|
||||
})
|
||||
}
|
||||
}, [selectedExchangeId])
|
||||
}, [currentExchangeType])
|
||||
|
||||
const handleCopyIP = async (ip: string) => {
|
||||
try {
|
||||
@@ -231,32 +258,49 @@ export function ExchangeConfigModal({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedExchangeId || isSaving) return
|
||||
if (isSaving) return
|
||||
|
||||
// For creating, we need the exchange type
|
||||
if (!editingExchangeId && !selectedExchangeType) return
|
||||
|
||||
// Validate account name
|
||||
const trimmedAccountName = accountName.trim()
|
||||
if (!trimmedAccountName) {
|
||||
toast.error(language === 'zh' ? '请输入账户名称' : 'Please enter account name')
|
||||
return
|
||||
}
|
||||
|
||||
const exchangeId = editingExchangeId || null
|
||||
const exchangeType = currentExchangeType || ''
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// 根据交易所类型验证不同字段
|
||||
if (selectedExchange?.id === 'binance') {
|
||||
if (currentExchangeType === 'binance') {
|
||||
if (!apiKey.trim() || !secretKey.trim()) return
|
||||
await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
|
||||
} else if (selectedExchange?.id === 'okx') {
|
||||
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet)
|
||||
} else if (currentExchangeType === 'okx') {
|
||||
if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
|
||||
await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
|
||||
} else if (selectedExchange?.id === 'hyperliquid') {
|
||||
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
|
||||
} else if (currentExchangeType === 'hyperliquid') {
|
||||
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址
|
||||
await onSave(
|
||||
selectedExchangeId,
|
||||
exchangeId,
|
||||
exchangeType,
|
||||
trimmedAccountName,
|
||||
apiKey.trim(),
|
||||
'',
|
||||
'',
|
||||
testnet,
|
||||
hyperliquidWalletAddr.trim()
|
||||
)
|
||||
} else if (selectedExchange?.id === 'aster') {
|
||||
} else if (currentExchangeType === 'aster') {
|
||||
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim())
|
||||
return
|
||||
await onSave(
|
||||
selectedExchangeId,
|
||||
exchangeId,
|
||||
exchangeType,
|
||||
trimmedAccountName,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
@@ -266,10 +310,12 @@ export function ExchangeConfigModal({
|
||||
asterSigner.trim(),
|
||||
asterPrivateKey.trim()
|
||||
)
|
||||
} else if (selectedExchange?.id === 'lighter') {
|
||||
} else if (currentExchangeType === 'lighter') {
|
||||
if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return
|
||||
await onSave(
|
||||
selectedExchangeId,
|
||||
exchangeId,
|
||||
exchangeType,
|
||||
trimmedAccountName,
|
||||
lighterPrivateKey.trim(),
|
||||
'',
|
||||
'',
|
||||
@@ -285,16 +331,13 @@ export function ExchangeConfigModal({
|
||||
} else {
|
||||
// 默认情况(其他CEX交易所)
|
||||
if (!apiKey.trim() || !secretKey.trim()) return
|
||||
await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
|
||||
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet)
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 可选择的交易所列表(所有支持的交易所)
|
||||
const availableExchanges = allExchanges || []
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
@@ -314,7 +357,7 @@ export function ExchangeConfigModal({
|
||||
: t('addExchange', language)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedExchange?.id === 'binance' && (
|
||||
{currentExchangeType === 'binance' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(true)}
|
||||
@@ -373,8 +416,8 @@ export function ExchangeConfigModal({
|
||||
{t('environmentSteps.selectTitle', language)}
|
||||
</div>
|
||||
<select
|
||||
value={selectedExchangeId}
|
||||
onChange={(e) => setSelectedExchangeId(e.target.value)}
|
||||
value={selectedExchangeType}
|
||||
onChange={(e) => setSelectedExchangeType(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
@@ -391,10 +434,10 @@ export function ExchangeConfigModal({
|
||||
<option value="">
|
||||
{t('pleaseSelectExchange', language)}
|
||||
</option>
|
||||
{availableExchanges.map((exchange) => (
|
||||
<option key={exchange.id} value={exchange.id}>
|
||||
{getShortName(exchange.name)} (
|
||||
{exchange.type.toUpperCase()})
|
||||
{SUPPORTED_EXCHANGE_TEMPLATES.map((template) => (
|
||||
<option key={template.exchange_type} value={template.exchange_type}>
|
||||
{getShortName(template.name)} (
|
||||
{template.type.toUpperCase()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -402,32 +445,65 @@ export function ExchangeConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExchange && (
|
||||
{selectedTemplate && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-8 h-8 flex items-center justify-center">
|
||||
{getExchangeIcon(selectedExchange.id, {
|
||||
{getExchangeIcon(selectedTemplate.exchange_type, {
|
||||
width: 32,
|
||||
height: 32,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{getShortName(selectedExchange.name)}
|
||||
{getShortName(selectedTemplate.name)}
|
||||
{editingExchangeId && selectedExchange?.account_name && (
|
||||
<span className="text-sm font-normal ml-2" style={{ color: '#848E9C' }}>
|
||||
- {selectedExchange.account_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{selectedExchange.type.toUpperCase()} •{' '}
|
||||
{selectedExchange.id}
|
||||
{selectedTemplate.type.toUpperCase()} •{' '}
|
||||
{selectedTemplate.exchange_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户名称输入 */}
|
||||
<div className="mt-3">
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{language === 'zh' ? '账户名称' : 'Account Name'} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder={language === 'zh' ? '例如:主账户、套利账户' : 'e.g., Main Account, Arbitrage Account'}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '为此账户设置一个易于识别的名称,以便区分同一交易所的多个账户'
|
||||
: 'Set an easily recognizable name for this account to distinguish multiple accounts on the same exchange'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 注册链接 */}
|
||||
<a
|
||||
href={exchangeRegistrationLinks[selectedExchange.id]?.url || '#'}
|
||||
href={exchangeRegistrationLinks[currentExchangeType || '']?.url || '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg mt-3 transition-all hover:scale-[1.02]"
|
||||
@@ -441,7 +517,7 @@ export function ExchangeConfigModal({
|
||||
<span className="text-sm" style={{ color: '#EAECEF' }}>
|
||||
{language === 'zh' ? '还没有交易所账号?点击注册' : "No exchange account? Register here"}
|
||||
</span>
|
||||
{exchangeRegistrationLinks[selectedExchange.id]?.hasReferral && (
|
||||
{exchangeRegistrationLinks[currentExchangeType || '']?.hasReferral && (
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.2)', color: '#0ECB81' }}
|
||||
@@ -455,15 +531,15 @@ export function ExchangeConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedExchange && (
|
||||
{selectedTemplate && (
|
||||
<>
|
||||
{/* Binance/Bybit/OKX 的输入字段 */}
|
||||
{(selectedExchange.id === 'binance' ||
|
||||
selectedExchange.id === 'bybit' ||
|
||||
selectedExchange.id === 'okx') && (
|
||||
{(currentExchangeType === 'binance' ||
|
||||
currentExchangeType === 'bybit' ||
|
||||
currentExchangeType === 'okx') && (
|
||||
<>
|
||||
{/* 币安用户配置提示 (D1 方案) */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
{currentExchangeType === 'binance' && (
|
||||
<div
|
||||
className="mb-4 p-3 rounded cursor-pointer transition-colors"
|
||||
style={{
|
||||
@@ -605,7 +681,7 @@ export function ExchangeConfigModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedExchange.id === 'okx' && (
|
||||
{currentExchangeType === 'okx' && (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
@@ -630,7 +706,7 @@ export function ExchangeConfigModal({
|
||||
)}
|
||||
|
||||
{/* Binance 白名单IP提示 */}
|
||||
{selectedExchange.id === 'binance' && (
|
||||
{currentExchangeType === 'binance' && (
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
@@ -690,7 +766,7 @@ export function ExchangeConfigModal({
|
||||
)}
|
||||
|
||||
{/* Aster 交易所的字段 */}
|
||||
{selectedExchange.id === 'aster' && (
|
||||
{currentExchangeType === 'aster' && (
|
||||
<>
|
||||
{/* API Pro 代理钱包说明 banner */}
|
||||
<div
|
||||
@@ -829,7 +905,7 @@ export function ExchangeConfigModal({
|
||||
)}
|
||||
|
||||
{/* Hyperliquid 交易所的字段 */}
|
||||
{selectedExchange.id === 'hyperliquid' && (
|
||||
{currentExchangeType === 'hyperliquid' && (
|
||||
<>
|
||||
{/* 安全提示 banner */}
|
||||
<div
|
||||
@@ -965,7 +1041,7 @@ export function ExchangeConfigModal({
|
||||
)}
|
||||
|
||||
{/* LIGHTER 特定配置 */}
|
||||
{selectedExchange?.id === 'lighter' && (
|
||||
{currentExchangeType === 'lighter' && (
|
||||
<>
|
||||
{/* L1 Wallet Address */}
|
||||
<div className="mb-4">
|
||||
@@ -1100,30 +1176,31 @@ export function ExchangeConfigModal({
|
||||
type="submit"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!selectedExchange ||
|
||||
(selectedExchange.id === 'binance' &&
|
||||
!selectedTemplate ||
|
||||
!accountName.trim() ||
|
||||
(currentExchangeType === 'binance' &&
|
||||
(!apiKey.trim() || !secretKey.trim())) ||
|
||||
(selectedExchange.id === 'okx' &&
|
||||
(currentExchangeType === 'okx' &&
|
||||
(!apiKey.trim() ||
|
||||
!secretKey.trim() ||
|
||||
!passphrase.trim())) ||
|
||||
(selectedExchange.id === 'hyperliquid' &&
|
||||
(currentExchangeType === 'hyperliquid' &&
|
||||
(!apiKey.trim() || !hyperliquidWalletAddr.trim())) || // 验证私钥和钱包地址
|
||||
(selectedExchange.id === 'aster' &&
|
||||
(currentExchangeType === 'aster' &&
|
||||
(!asterUser.trim() ||
|
||||
!asterSigner.trim() ||
|
||||
!asterPrivateKey.trim())) ||
|
||||
(selectedExchange.id === 'lighter' &&
|
||||
(currentExchangeType === 'lighter' &&
|
||||
(!lighterWalletAddr.trim() || !lighterPrivateKey.trim())) ||
|
||||
(selectedExchange.id === 'bybit' &&
|
||||
(currentExchangeType === 'bybit' &&
|
||||
(!apiKey.trim() || !secretKey.trim())) ||
|
||||
(selectedExchange.type === 'cex' &&
|
||||
selectedExchange.id !== 'hyperliquid' &&
|
||||
selectedExchange.id !== 'aster' &&
|
||||
selectedExchange.id !== 'lighter' &&
|
||||
selectedExchange.id !== 'binance' &&
|
||||
selectedExchange.id !== 'bybit' &&
|
||||
selectedExchange.id !== 'okx' &&
|
||||
(selectedTemplate?.type === 'cex' &&
|
||||
currentExchangeType !== 'hyperliquid' &&
|
||||
currentExchangeType !== 'aster' &&
|
||||
currentExchangeType !== 'lighter' &&
|
||||
currentExchangeType !== 'binance' &&
|
||||
currentExchangeType !== 'bybit' &&
|
||||
currentExchangeType !== 'okx' &&
|
||||
(!apiKey.trim() || !secretKey.trim()))
|
||||
}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
|
||||
|
||||
@@ -45,17 +45,20 @@ export function ExchangesSection({
|
||||
>
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<div className="w-7 h-7 md:w-8 md:h-8 flex items-center justify-center flex-shrink-0">
|
||||
{getExchangeIcon(exchange.id, { width: 28, height: 28 })}
|
||||
{getExchangeIcon(exchange.exchange_type, { width: 28, height: 28 })}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="font-semibold text-sm md:text-base truncate"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{getShortName(exchange.name)}
|
||||
{exchange.exchange_type?.toUpperCase() || getShortName(exchange.name)}
|
||||
<span className="text-xs font-normal ml-1.5" style={{ color: '#F0B90B' }}>
|
||||
- {exchange.account_name || 'Default'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{exchange.type.toUpperCase()} •{' '}
|
||||
{exchange.type?.toUpperCase() || 'CEX'} •{' '}
|
||||
{inUse
|
||||
? t('inUse', language)
|
||||
: exchange.enabled
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react'
|
||||
import { t, type Language } from '../../../i18n/translations'
|
||||
import { getModelDisplayName } from '../index'
|
||||
import type { TraderInfo } from '../../../types'
|
||||
import type { TraderInfo, Exchange } from '../../../types'
|
||||
import { PunkAvatar, getTraderAvatar } from '../../PunkAvatar'
|
||||
|
||||
interface TradersGridProps {
|
||||
language: Language
|
||||
traders: TraderInfo[] | undefined
|
||||
exchanges?: Exchange[]
|
||||
onTraderSelect: (traderId: string) => void
|
||||
onEditTrader: (traderId: string) => void
|
||||
onDeleteTrader: (traderId: string) => void
|
||||
@@ -16,11 +17,20 @@ interface TradersGridProps {
|
||||
export function TradersGrid({
|
||||
language,
|
||||
traders,
|
||||
exchanges = [],
|
||||
onTraderSelect,
|
||||
onEditTrader,
|
||||
onDeleteTrader,
|
||||
onToggleTrader,
|
||||
}: TradersGridProps) {
|
||||
// Helper function to get exchange display name
|
||||
const getExchangeDisplayName = (exchangeId: string | undefined) => {
|
||||
if (!exchangeId) return 'Unknown'
|
||||
const exchange = exchanges.find(e => e.id === exchangeId)
|
||||
if (!exchange) return exchangeId.toUpperCase()
|
||||
const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
|
||||
return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName
|
||||
}
|
||||
if (!traders || traders.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 md:py-16" style={{ color: '#848E9C' }}>
|
||||
@@ -74,7 +84,7 @@ export function TradersGrid({
|
||||
{getModelDisplayName(
|
||||
trader.ai_model.split('_').pop() || trader.ai_model
|
||||
)}{' '}
|
||||
Model • {trader.exchange_id?.toUpperCase()}
|
||||
Model • {getExchangeDisplayName(trader.exchange_id)}
|
||||
<span style={{ color: '#F0B90B' }}> • {trader.strategy_name || 'No Strategy'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AIModel,
|
||||
Exchange,
|
||||
CreateTraderRequest,
|
||||
CreateExchangeRequest,
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
@@ -224,6 +225,57 @@ export const api = {
|
||||
if (!result.success) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 创建新的交易所账户
|
||||
async createExchange(request: CreateExchangeRequest): Promise<{ id: string }> {
|
||||
const result = await httpClient.post<{ id: string }>(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 创建新的交易所账户(加密传输)
|
||||
async createExchangeEncrypted(request: CreateExchangeRequest): Promise<{ id: string }> {
|
||||
// 检查是否启用了传输加密
|
||||
const config = await CryptoService.fetchCryptoConfig()
|
||||
|
||||
if (!config.transport_encryption) {
|
||||
// 传输加密禁用时,直接发送明文
|
||||
const result = await httpClient.post<{ id: string }>(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
}
|
||||
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const result = await httpClient.post<{ id: string }>(
|
||||
`${API_BASE}/exchanges`,
|
||||
encryptedPayload
|
||||
)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 删除交易所账户
|
||||
async deleteExchange(exchangeId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/exchanges/${exchangeId}`)
|
||||
if (!result.success) throw new Error('删除交易所账户失败')
|
||||
},
|
||||
|
||||
// 使用加密传输更新交易所配置(自动检测是否启用加密)
|
||||
async updateExchangeConfigsEncrypted(
|
||||
request: UpdateExchangeConfigRequest
|
||||
|
||||
+25
-6
@@ -110,26 +110,45 @@ export interface AIModel {
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string
|
||||
name: string
|
||||
id: string // UUID (empty for supported exchange templates)
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
name: string // Display name
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
passphrase?: string // OKX 特定字段
|
||||
passphrase?: string // OKX specific
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
// Hyperliquid specific
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster 特定字段
|
||||
// Aster specific
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
// LIGHTER 特定字段
|
||||
// LIGHTER specific
|
||||
lighterWalletAddr?: string
|
||||
lighterPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string
|
||||
}
|
||||
|
||||
export interface CreateExchangeRequest {
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
enabled: boolean
|
||||
api_key?: string
|
||||
secret_key?: string
|
||||
passphrase?: string
|
||||
testnet?: boolean
|
||||
hyperliquid_wallet_addr?: string
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
name: string
|
||||
ai_model_id: string
|
||||
|
||||
Reference in New Issue
Block a user