mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 15:26:55 +08:00
fix: OKX trading issues and improve position tracking
- Add maxMktSz check for OKX market orders to prevent exceeding limits - Increase margin safety buffer (0.1% fee + 1% buffer) for all exchanges - Fix Binance position closure detection with direct trade queries - Move Recent Completed Trades before Current Positions in AI prompt - Update README screenshots with table layout for better alignment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user