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

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

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)
}

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,