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

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

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,

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
}

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
}

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 {

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
}

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
}

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
trader/position_rebuild.go Normal file
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",
}
}

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