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:
tinkle-community
2025-12-10 14:40:08 +08:00
parent c19ee51dee
commit 319ccb8ca3
45 changed files with 2951 additions and 3392 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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