mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
fix: initial balance calculation and UI improvements
- Fix initial balance using available_balance instead of total_equity - Fix WSMonitor nil pointer by starting market monitor before loading traders - Add strategy name display on traders list and dashboard pages - Various position sync and trading improvements
This commit is contained in:
@@ -580,9 +580,15 @@ func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (m
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err)
|
||||
}
|
||||
|
||||
// Set leverage first
|
||||
// Set leverage first (non-fatal if position already exists)
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
// Error -2030: Cannot adjust leverage when position exists
|
||||
// This is expected when adding to an existing position, continue with current leverage
|
||||
if strings.Contains(err.Error(), "-2030") {
|
||||
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current price
|
||||
@@ -647,9 +653,15 @@ func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) (
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err)
|
||||
}
|
||||
|
||||
// Set leverage first
|
||||
// Set leverage first (non-fatal if position already exists)
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
// Error -2030: Cannot adjust leverage when position exists
|
||||
// This is expected when adding to an existing position, continue with current leverage
|
||||
if strings.Contains(err.Error(), "-2030") {
|
||||
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current price
|
||||
@@ -1279,3 +1291,12 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -240,6 +240,14 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
if foundBalance > 0 {
|
||||
config.InitialBalance = foundBalance
|
||||
logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance)
|
||||
// Save to database so it persists across restarts
|
||||
if st != nil {
|
||||
if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil {
|
||||
logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err)
|
||||
} else {
|
||||
logger.Infof("✓ [%s] Initial balance saved to database", config.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance")
|
||||
}
|
||||
@@ -657,7 +665,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
|
||||
// 6. Build context
|
||||
ctx := &decision.Context{
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||
CallCount: at.callCount,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
@@ -676,33 +684,21 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
CandidateCoins: candidateCoins,
|
||||
}
|
||||
|
||||
// 7. Add trading statistics and historical orders (if store is available)
|
||||
// 7. Add recent closed trades (if store is available)
|
||||
if at.store != nil {
|
||||
// Get trading statistics (using new positions table)
|
||||
if stats, err := at.store.Position().GetFullStats(at.id); err == nil {
|
||||
ctx.TradingStats = &decision.TradingStats{
|
||||
TotalTrades: stats.TotalTrades,
|
||||
WinRate: stats.WinRate,
|
||||
ProfitFactor: stats.ProfitFactor,
|
||||
SharpeRatio: stats.SharpeRatio,
|
||||
TotalPnL: stats.TotalPnL,
|
||||
AvgWin: stats.AvgWin,
|
||||
AvgLoss: stats.AvgLoss,
|
||||
MaxDrawdownPct: stats.MaxDrawdownPct,
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent 10 closed trades (using new positions table)
|
||||
// Get recent 10 closed trades for AI context
|
||||
if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
|
||||
for _, trade := range recentTrades {
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
ExitPrice: trade.ExitPrice,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
PnLPct: trade.PnLPct,
|
||||
FilledAt: trade.ExitTime,
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
ExitPrice: trade.ExitPrice,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
PnLPct: trade.PnLPct,
|
||||
EntryTime: trade.EntryTime,
|
||||
ExitTime: trade.ExitTime,
|
||||
HoldDuration: trade.HoldDuration,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -755,13 +751,21 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow)
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err == nil {
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||||
return fmt.Errorf("❌ %s already has long position, rejecting to prevent position stacking overflow. If changing position, please give close_long decision first", decision.Symbol)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Check max positions limit
|
||||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if there's already a position in the same symbol and direction
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||||
return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +775,37 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
return err
|
||||
}
|
||||
|
||||
// Get balance (needed for multiple checks)
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Get equity for position value ratio check
|
||||
equity := 0.0
|
||||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else {
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
decision.PositionSizeUSD = adjustedPositionSize
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Minimum position size check
|
||||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate quantity
|
||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||
actionRecord.Quantity = quantity
|
||||
@@ -779,15 +814,6 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Fee estimation (Taker fee rate 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
@@ -838,13 +864,21 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow)
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err == nil {
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||||
return fmt.Errorf("❌ %s already has short position, rejecting to prevent position stacking overflow. If changing position, please give close_short decision first", decision.Symbol)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Check max positions limit
|
||||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if there's already a position in the same symbol and direction
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||||
return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +888,37 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
return err
|
||||
}
|
||||
|
||||
// Get balance (needed for multiple checks)
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Get equity for position value ratio check
|
||||
equity := 0.0
|
||||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else {
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
decision.PositionSizeUSD = adjustedPositionSize
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Minimum position size check
|
||||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate quantity
|
||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||
actionRecord.Quantity = quantity
|
||||
@@ -862,15 +927,6 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Fee estimation (Taker fee rate 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
@@ -1606,3 +1662,86 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Risk Control Helpers
|
||||
// ============================================================================
|
||||
|
||||
// isBTCETH checks if a symbol is BTC or ETH
|
||||
func isBTCETH(symbol string) bool {
|
||||
symbol = strings.ToUpper(symbol)
|
||||
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
|
||||
}
|
||||
|
||||
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
|
||||
// Returns the adjusted position size (capped if necessary) and whether the position was capped
|
||||
// positionSizeUSD: the original position size in USD
|
||||
// equity: the account equity
|
||||
// symbol: the trading symbol
|
||||
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return positionSizeUSD, false
|
||||
}
|
||||
|
||||
riskControl := at.config.StrategyConfig.RiskControl
|
||||
|
||||
// Get the appropriate position value ratio limit
|
||||
var maxPositionValueRatio float64
|
||||
if isBTCETH(symbol) {
|
||||
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
|
||||
if maxPositionValueRatio <= 0 {
|
||||
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH
|
||||
}
|
||||
} else {
|
||||
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
|
||||
if maxPositionValueRatio <= 0 {
|
||||
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate max allowed position value = equity × ratio
|
||||
maxPositionValue := equity * maxPositionValueRatio
|
||||
|
||||
// Check if position size exceeds limit
|
||||
if positionSizeUSD > maxPositionValue {
|
||||
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
|
||||
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
|
||||
return maxPositionValue, true
|
||||
}
|
||||
|
||||
return positionSizeUSD, false
|
||||
}
|
||||
|
||||
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
|
||||
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
|
||||
if minSize <= 0 {
|
||||
minSize = 12 // Default: 12 USDT
|
||||
}
|
||||
|
||||
if positionSizeUSD < minSize {
|
||||
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
|
||||
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
|
||||
if maxPositions <= 0 {
|
||||
maxPositions = 3 // Default: 3 positions
|
||||
}
|
||||
|
||||
if currentPositionCount >= maxPositions {
|
||||
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -957,3 +957,116 @@ 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
|
||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
// Use income history API to get realized PnL
|
||||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||
IncomeType("REALIZED_PNL").
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(int64(limit)).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get income history: %w", err)
|
||||
}
|
||||
|
||||
records := make([]ClosedPnLRecord, 0, len(incomes))
|
||||
|
||||
for _, income := range incomes {
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: income.Symbol,
|
||||
ExchangeID: fmt.Sprintf("%d", income.TranID),
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,7 +21,9 @@ import (
|
||||
|
||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||
type BybitTrader struct {
|
||||
client *bybit.Client
|
||||
client *bybit.Client
|
||||
apiKey string
|
||||
secretKey string
|
||||
|
||||
// Balance cache
|
||||
cachedBalance map[string]interface{}
|
||||
@@ -59,6 +64,8 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
|
||||
|
||||
trader := &BybitTrader{
|
||||
client: client,
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
cacheDuration: 15 * time.Second,
|
||||
qtyStepCache: make(map[string]float64),
|
||||
}
|
||||
@@ -856,3 +863,149 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
|
||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
|
||||
return t.getClosedPnLViaHTTP(startTime, limit)
|
||||
}
|
||||
|
||||
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
|
||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// Build query string
|
||||
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
||||
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
|
||||
|
||||
// Generate timestamp
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
recvWindow := "5000"
|
||||
|
||||
// Build signature payload: timestamp + api_key + recv_window + queryString
|
||||
signPayload := timestamp + t.apiKey + recvWindow + queryParams
|
||||
|
||||
// Generate HMAC-SHA256 signature
|
||||
h := hmac.New(sha256.New, []byte(t.secretKey))
|
||||
h.Write([]byte(signPayload))
|
||||
signature := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add Bybit V5 API headers
|
||||
req.Header.Set("X-BAPI-API-KEY", t.apiKey)
|
||||
req.Header.Set("X-BAPI-SIGN", signature)
|
||||
req.Header.Set("X-BAPI-SIGN-TYPE", "2")
|
||||
req.Header.Set("X-BAPI-TIMESTAMP", timestamp)
|
||||
req.Header.Set("X-BAPI-RECV-WINDOW", recvWindow)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Use http.DefaultClient for the request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call Bybit API: %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)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
return t.parseClosedPnLResult(result.Result)
|
||||
}
|
||||
|
||||
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
|
||||
data, ok := resultData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result format")
|
||||
}
|
||||
|
||||
list, _ := data["list"].([]interface{})
|
||||
var records []ClosedPnLRecord
|
||||
|
||||
for _, item := range list {
|
||||
pnl, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse fields
|
||||
symbol, _ := pnl["symbol"].(string)
|
||||
side, _ := pnl["side"].(string)
|
||||
orderId, _ := pnl["orderId"].(string)
|
||||
|
||||
avgEntryPriceStr, _ := pnl["avgEntryPrice"].(string)
|
||||
avgExitPriceStr, _ := pnl["avgExitPrice"].(string)
|
||||
qtyStr, _ := pnl["qty"].(string)
|
||||
closedPnLStr, _ := pnl["closedPnl"].(string)
|
||||
cumEntryValueStr, _ := pnl["cumEntryValue"].(string)
|
||||
cumExitValueStr, _ := pnl["cumExitValue"].(string)
|
||||
leverageStr, _ := pnl["leverage"].(string)
|
||||
createdTimeStr, _ := pnl["createdTime"].(string)
|
||||
updatedTimeStr, _ := pnl["updatedTime"].(string)
|
||||
|
||||
avgEntryPrice, _ := strconv.ParseFloat(avgEntryPriceStr, 64)
|
||||
avgExitPrice, _ := strconv.ParseFloat(avgExitPriceStr, 64)
|
||||
qty, _ := strconv.ParseFloat(qtyStr, 64)
|
||||
closedPnL, _ := strconv.ParseFloat(closedPnLStr, 64)
|
||||
leverage, _ := strconv.ParseInt(leverageStr, 10, 64)
|
||||
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
|
||||
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
|
||||
|
||||
// Calculate approximate fee from value difference
|
||||
cumEntryValue, _ := strconv.ParseFloat(cumEntryValueStr, 64)
|
||||
cumExitValue, _ := strconv.ParseFloat(cumExitValueStr, 64)
|
||||
expectedPnL := cumExitValue - cumEntryValue
|
||||
if side == "Sell" {
|
||||
expectedPnL = cumEntryValue - cumExitValue
|
||||
}
|
||||
fee := expectedPnL - closedPnL
|
||||
if fee < 0 {
|
||||
fee = 0
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
normalizedSide := "long"
|
||||
if side == "Sell" {
|
||||
normalizedSide = "short"
|
||||
}
|
||||
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
EntryPrice: avgEntryPrice,
|
||||
ExitPrice: avgExitPrice,
|
||||
Quantity: qty,
|
||||
RealizedPnL: closedPnL,
|
||||
Fee: fee,
|
||||
Leverage: int(leverage),
|
||||
EntryTime: time.UnixMilli(createdTime),
|
||||
ExitTime: time.UnixMilli(updatedTime),
|
||||
OrderID: orderId,
|
||||
CloseType: "unknown", // Bybit doesn't provide close type directly
|
||||
ExchangeID: orderId, // Use orderId as exchange ID
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
@@ -949,3 +950,12 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
package trader
|
||||
|
||||
import "time"
|
||||
|
||||
// ClosedPnLRecord represents a single closed position record from exchange
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string // Trading pair (e.g., "BTCUSDT")
|
||||
Side string // "long" or "short"
|
||||
EntryPrice float64 // Entry price
|
||||
ExitPrice float64 // Exit/close price
|
||||
Quantity float64 // Position size
|
||||
RealizedPnL float64 // Realized profit/loss
|
||||
Fee float64 // Trading fee/commission
|
||||
Leverage int // Leverage used
|
||||
EntryTime time.Time // Position open time
|
||||
ExitTime time.Time // Position close time
|
||||
OrderID string // Close order ID
|
||||
CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown"
|
||||
ExchangeID string // Exchange-specific position ID
|
||||
}
|
||||
|
||||
// Trader Unified trader interface
|
||||
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
|
||||
type Trader interface {
|
||||
@@ -54,4 +73,10 @@ type Trader interface {
|
||||
// GetOrderStatus Get order status
|
||||
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
|
||||
GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error)
|
||||
|
||||
// GetClosedPnL Get closed position PnL records from exchange
|
||||
// startTime: start time for query (usually last sync time)
|
||||
// limit: max number of records to return
|
||||
// Returns accurate exit price, fees, and close reason for positions closed externally
|
||||
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
|
||||
}
|
||||
|
||||
@@ -213,3 +213,12 @@ func (t *LighterTrader) Run() error {
|
||||
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -277,3 +277,12 @@ func (t *LighterTraderV2) Cleanup() error {
|
||||
logger.Info("⏹ LIGHTER trader cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1138,3 +1138,112 @@ var okxTag = func() string {
|
||||
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
|
||||
return string(b)
|
||||
}()
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from OKX
|
||||
// OKX API: /api/v5/account/positions-history
|
||||
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Build query path with parameters
|
||||
path := fmt.Sprintf("/api/v5/account/positions-history?instType=SWAP&limit=%d", limit)
|
||||
if !startTime.IsZero() {
|
||||
path += fmt.Sprintf("&after=%d", startTime.UnixMilli())
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions history: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data []struct {
|
||||
InstID string `json:"instId"` // Instrument ID (e.g., "BTC-USDT-SWAP")
|
||||
Direction string `json:"direction"` // Position direction: "long" or "short"
|
||||
OpenAvgPx string `json:"openAvgPx"` // Average open price
|
||||
CloseAvgPx string `json:"closeAvgPx"` // Average close price
|
||||
CloseTotalPos string `json:"closeTotalPos"` // Closed position quantity
|
||||
RealizedPnl string `json:"realizedPnl"` // Realized PnL
|
||||
Fee string `json:"fee"` // Total fee
|
||||
FundingFee string `json:"fundingFee"` // Funding fee
|
||||
Lever string `json:"lever"` // Leverage
|
||||
CTime string `json:"cTime"` // Position open time
|
||||
UTime string `json:"uTime"` // Position close time
|
||||
Type string `json:"type"` // Close type: 1=close position, 2=partial close, 3=liquidation, 4=partial liquidation
|
||||
PosId string `json:"posId"` // Position ID
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Code != "0" {
|
||||
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
records := make([]ClosedPnLRecord, 0, len(resp.Data))
|
||||
|
||||
for _, pos := range resp.Data {
|
||||
record := ClosedPnLRecord{}
|
||||
|
||||
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
|
||||
parts := strings.Split(pos.InstID, "-")
|
||||
if len(parts) >= 2 {
|
||||
record.Symbol = parts[0] + parts[1]
|
||||
} else {
|
||||
record.Symbol = pos.InstID
|
||||
}
|
||||
|
||||
// Side
|
||||
record.Side = pos.Direction // OKX already returns "long" or "short"
|
||||
|
||||
// Prices
|
||||
record.EntryPrice, _ = strconv.ParseFloat(pos.OpenAvgPx, 64)
|
||||
record.ExitPrice, _ = strconv.ParseFloat(pos.CloseAvgPx, 64)
|
||||
|
||||
// Quantity
|
||||
record.Quantity, _ = strconv.ParseFloat(pos.CloseTotalPos, 64)
|
||||
|
||||
// PnL
|
||||
record.RealizedPnL, _ = strconv.ParseFloat(pos.RealizedPnl, 64)
|
||||
|
||||
// Fee
|
||||
fee, _ := strconv.ParseFloat(pos.Fee, 64)
|
||||
fundingFee, _ := strconv.ParseFloat(pos.FundingFee, 64)
|
||||
record.Fee = -fee + fundingFee // Fee is negative in OKX
|
||||
|
||||
// Leverage
|
||||
lev, _ := strconv.ParseFloat(pos.Lever, 64)
|
||||
record.Leverage = int(lev)
|
||||
|
||||
// Times
|
||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||
record.EntryTime = time.UnixMilli(cTime)
|
||||
record.ExitTime = time.UnixMilli(uTime)
|
||||
|
||||
// Close type
|
||||
switch pos.Type {
|
||||
case "1", "2":
|
||||
record.CloseType = "unknown" // Could be manual or AI, need to cross-reference
|
||||
case "3", "4":
|
||||
record.CloseType = "liquidation"
|
||||
default:
|
||||
record.CloseType = "unknown"
|
||||
}
|
||||
|
||||
// Exchange ID
|
||||
record.ExchangeID = pos.PosId
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ import (
|
||||
// PositionSyncManager Position status synchronization manager
|
||||
// Responsible for periodically synchronizing exchange positions, detecting manual closures and other changes
|
||||
type PositionSyncManager struct {
|
||||
store *store.Store
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
||||
cacheMutex sync.RWMutex
|
||||
store *store.Store
|
||||
interval time.Duration
|
||||
historySyncInterval time.Duration // Interval for full history sync
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
||||
cacheMutex sync.RWMutex
|
||||
lastHistorySync map[string]time.Time // trader_id -> last history sync time
|
||||
lastHistorySyncMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPositionSyncManager Create position synchronization manager
|
||||
@@ -26,11 +29,13 @@ func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSy
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
return &PositionSyncManager{
|
||||
store: st,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
traderCache: make(map[string]Trader),
|
||||
configCache: make(map[string]*store.TraderFullConfig),
|
||||
store: st,
|
||||
interval: interval,
|
||||
historySyncInterval: 5 * time.Minute, // Sync closed positions every 5 minutes
|
||||
stopCh: make(chan struct{}),
|
||||
traderCache: make(map[string]Trader),
|
||||
configCache: make(map[string]*store.TraderFullConfig),
|
||||
lastHistorySync: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +44,9 @@ func (m *PositionSyncManager) Start() {
|
||||
m.wg.Add(1)
|
||||
go m.run()
|
||||
logger.Info("📊 Position sync manager started")
|
||||
|
||||
// Run startup sync in background
|
||||
go m.startupSync()
|
||||
}
|
||||
|
||||
// Stop Stop position synchronization service
|
||||
@@ -109,6 +117,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
||||
return
|
||||
}
|
||||
|
||||
// Get exchange ID for history sync
|
||||
config, _ := m.getTraderConfig(traderID)
|
||||
exchangeID := ""
|
||||
if config != nil {
|
||||
exchangeID = config.Exchange.ID
|
||||
}
|
||||
|
||||
// Maybe run periodic history sync
|
||||
if exchangeID != "" {
|
||||
m.maybeRunHistorySync(traderID, exchangeID, trader)
|
||||
}
|
||||
|
||||
// Get current exchange positions
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
@@ -154,40 +174,133 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
||||
|
||||
// closeLocalPosition Mark local position as closed
|
||||
func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) {
|
||||
// Try to get last trade price as exit price
|
||||
exitPrice := pos.EntryPrice // Default to entry price
|
||||
// Try to get accurate closure data from exchange first
|
||||
closedPnLRecord := m.findClosedPnLRecord(trader, pos)
|
||||
|
||||
// Try to get latest price from exchange
|
||||
if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 {
|
||||
exitPrice = price
|
||||
}
|
||||
var exitPrice, realizedPnL, fee float64
|
||||
var closeReason, exitOrderID string
|
||||
|
||||
// Calculate PnL
|
||||
var realizedPnL float64
|
||||
if pos.Side == "LONG" {
|
||||
realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity
|
||||
if closedPnLRecord != nil {
|
||||
// Use accurate data from exchange
|
||||
exitPrice = closedPnLRecord.ExitPrice
|
||||
realizedPnL = closedPnLRecord.RealizedPnL
|
||||
fee = closedPnLRecord.Fee
|
||||
closeReason = closedPnLRecord.CloseType
|
||||
exitOrderID = closedPnLRecord.OrderID
|
||||
logger.Infof("📊 Found accurate closure data from exchange for %s %s", pos.Symbol, pos.Side)
|
||||
} else {
|
||||
realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity
|
||||
// Fallback: use market price and calculate PnL
|
||||
exitPrice = pos.EntryPrice // Default to entry price
|
||||
if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 {
|
||||
exitPrice = price
|
||||
}
|
||||
|
||||
// Calculate PnL
|
||||
if pos.Side == "LONG" {
|
||||
realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity
|
||||
} else {
|
||||
realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity
|
||||
}
|
||||
closeReason = reason
|
||||
fee = 0
|
||||
exitOrderID = ""
|
||||
logger.Infof("⚠️ Using market price for closure (no exchange data): %s %s", pos.Symbol, pos.Side)
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := m.store.Position().ClosePosition(
|
||||
pos.ID,
|
||||
exitPrice,
|
||||
"", // Manual close has no order ID
|
||||
exitOrderID,
|
||||
realizedPnL,
|
||||
0, // Manual close cannot get fee
|
||||
reason,
|
||||
fee,
|
||||
closeReason,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to update position status: %v", err)
|
||||
} else {
|
||||
logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f (%s)",
|
||||
pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, reason)
|
||||
logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f, Fee: %.4f (%s)",
|
||||
pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, fee, closeReason)
|
||||
}
|
||||
}
|
||||
|
||||
// findClosedPnLRecord Try to find matching ClosedPnL record from exchange
|
||||
func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.TraderPosition) *ClosedPnLRecord {
|
||||
// Get closed PnL records from the last 24 hours (to cover recent closures)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
records, err := trader.GetClosedPnL(startTime, 50)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get closed PnL records: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
for i := range records {
|
||||
record := &records[i]
|
||||
if record.Symbol != pos.Symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match side (case-insensitive)
|
||||
recordSide := record.Side
|
||||
if recordSide == "LONG" {
|
||||
recordSide = "long"
|
||||
} else if recordSide == "SHORT" {
|
||||
recordSide = "short"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
// abs returns absolute value of float64
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// getOrCreateTrader Get or create trader instance
|
||||
func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) {
|
||||
m.cacheMutex.RLock()
|
||||
@@ -320,3 +433,215 @@ func getFloatFromMap(m map[string]interface{}, key string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup and History Sync Methods
|
||||
// =============================================================================
|
||||
|
||||
// startupSync performs initial sync on startup
|
||||
// 1. Sync existing positions from exchange (to detect external positions)
|
||||
// 2. Sync closed positions history from exchange
|
||||
func (m *PositionSyncManager) startupSync() {
|
||||
logger.Info("📊 Starting startup sync...")
|
||||
|
||||
// Get all traders
|
||||
traders, err := m.store.Trader().ListAll()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get traders for startup sync: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, traderInfo := range traders {
|
||||
traderID := traderInfo.ID
|
||||
|
||||
// Get trader instance
|
||||
trader, err := m.getOrCreateTrader(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get trader instance for startup sync (ID: %s): %v", traderID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get exchange ID
|
||||
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
|
||||
|
||||
// 1. Sync current open positions from exchange
|
||||
m.syncExternalPositions(traderID, exchangeID, trader)
|
||||
|
||||
// 2. Sync closed positions history from exchange
|
||||
m.syncClosedPositionsHistory(traderID, exchangeID, trader)
|
||||
}
|
||||
|
||||
logger.Info("📊 Startup sync completed")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Get current positions from exchange
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get exchange positions for external sync (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get local open positions
|
||||
localPositions, err := m.store.Position().GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get local positions for external sync (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build local position map: symbol_side -> position
|
||||
localMap := make(map[string]*store.TraderPosition)
|
||||
for _, pos := range localPositions {
|
||||
key := fmt.Sprintf("%s_%s", pos.Symbol, pos.Side)
|
||||
localMap[key] = pos
|
||||
}
|
||||
|
||||
// Find positions that exist on exchange but not locally
|
||||
for _, pos := range exchangePositions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
if symbol == "" || side == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
normalizedSide := side
|
||||
if side == "Buy" || side == "LONG" || side == "long" {
|
||||
normalizedSide = "LONG"
|
||||
} else if side == "Sell" || side == "SHORT" || side == "short" {
|
||||
normalizedSide = "SHORT"
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s_%s", symbol, normalizedSide)
|
||||
|
||||
// Check if we already have this position locally
|
||||
if _, exists := localMap[key]; exists {
|
||||
continue // Already tracking this position
|
||||
}
|
||||
|
||||
// This is an external position - create local record
|
||||
qty := getFloatFromMap(pos, "positionAmt")
|
||||
if qty < 0 {
|
||||
qty = -qty
|
||||
}
|
||||
if qty < 0.0000001 {
|
||||
continue // No actual position
|
||||
}
|
||||
|
||||
entryPrice := getFloatFromMap(pos, "entryPrice")
|
||||
leverage := int(getFloatFromMap(pos, "leverage"))
|
||||
if leverage == 0 {
|
||||
leverage = 1
|
||||
}
|
||||
|
||||
// Get entry time if available
|
||||
createdTime := getFloatFromMap(pos, "createdTime")
|
||||
var entryTime time.Time
|
||||
if createdTime > 0 {
|
||||
entryTime = time.UnixMilli(int64(createdTime))
|
||||
} else {
|
||||
entryTime = time.Now() // Use current time as fallback
|
||||
}
|
||||
|
||||
// Generate unique exchange position ID
|
||||
exchangePositionID := fmt.Sprintf("%s_%s_%d", symbol, normalizedSide, entryTime.UnixMilli())
|
||||
|
||||
newPos := &store.TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangePositionID: exchangePositionID,
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
Quantity: qty,
|
||||
EntryPrice: entryPrice,
|
||||
EntryTime: entryTime,
|
||||
Leverage: leverage,
|
||||
Source: "sync", // Mark as synced from exchange
|
||||
}
|
||||
|
||||
if err := m.store.Position().CreateOpenPosition(newPos); err != nil {
|
||||
logger.Infof("⚠️ Failed to create external position record: %v", err)
|
||||
} else {
|
||||
logger.Infof("📊 Synced external position: [%s] %s %s @ %.4f (qty: %.4f)",
|
||||
traderID[:8], symbol, normalizedSide, entryPrice, qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncClosedPositionsHistory syncs closed positions from exchange history
|
||||
func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID string, trader Trader) {
|
||||
// Get last sync time
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.Infof("📊 Synced %d new closed positions for trader %s (skipped %d duplicates)",
|
||||
created, traderID[:8], skipped)
|
||||
}
|
||||
|
||||
// Update last history sync time
|
||||
m.lastHistorySyncMutex.Lock()
|
||||
m.lastHistorySync[traderID] = time.Now()
|
||||
m.lastHistorySyncMutex.Unlock()
|
||||
}
|
||||
|
||||
// maybeRunHistorySync checks if it's time to run history sync for a trader
|
||||
func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user