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:
tinkle-community
2025-12-10 22:01:57 +08:00
parent 870faa0843
commit ecbedc6525
29 changed files with 2141 additions and 1647 deletions
+21 -7
View File
@@ -45,17 +45,31 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
## Screenshots
### Competition Mode - Real-time AI Battle
![Competition Page](screenshots/competition-page.png)
### 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
![Dashboard Market Chart](screenshots/dashboard-market-chart.png)
*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 Studio](screenshots/strategy-studio.png)
*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
View File
@@ -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
View File
@@ -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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+92 -6
View File
@@ -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
}
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+195
View File
@@ -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
View File
@@ -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
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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>
+52
View File
@@ -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
View File
@@ -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