Files
nofx/trader/auto_trader.go
T
tinkle-community 1744e7f38e feat: migrate to CoinAnk API and improve chart UI
- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend
- Data source migration: use CoinAnk API exclusively for all kline data
- Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go)
- Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold
- Lighter integration: add order sync functionality, fix market order precision
- Remove ticker merge logic for simplicity
2025-12-26 00:58:12 +08:00

2125 lines
70 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package trader
import (
"encoding/json"
"fmt"
"math"
"nofx/decision"
"nofx/experience"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
"strings"
"sync"
"time"
)
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
type AutoTraderConfig struct {
// Trader identification
ID string // Trader unique identifier (for log directory, etc.)
Name string // Trader display name
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
BinanceAPIKey string
BinanceSecretKey string
// Bybit API configuration
BybitAPIKey string
BybitSecretKey string
// OKX API configuration
OKXAPIKey string
OKXSecretKey string
OKXPassphrase string
// Bitget API configuration
BitgetAPIKey string
BitgetSecretKey string
BitgetPassphrase string
// Hyperliquid configuration
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
// Aster configuration
AsterUser string // Aster main wallet address
AsterSigner string // Aster API wallet address
AsterPrivateKey string // Aster API wallet private key
// LIGHTER configuration
LighterWalletAddr string // LIGHTER wallet address (L1 wallet)
LighterPrivateKey string // LIGHTER L1 private key (for account identification)
LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing)
LighterAPIKeyIndex int // LIGHTER API Key index (0-255)
LighterTestnet bool // Whether to use testnet
// AI configuration
UseQwen bool
DeepSeekKey string
QwenKey string
// Custom AI API configuration
CustomAPIURL string
CustomAPIKey string
CustomModelName string
// Scan configuration
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
// Account configuration
InitialBalance float64 // Initial balance (for P&L calculation, must be set manually)
// Risk control (only as hints, AI can make autonomous decisions)
MaxDailyLoss float64 // Maximum daily loss percentage (hint)
MaxDrawdown float64 // Maximum drawdown percentage (hint)
StopTradingTime time.Duration // Pause duration after risk control triggers
// Position mode
IsCrossMargin bool // true=cross margin mode, false=isolated margin mode
// Competition visibility
ShowInCompetition bool // Whether to show in competition page
// Strategy configuration (use complete strategy config)
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
}
// AutoTrader automatic trader
type AutoTrader struct {
id string // Trader unique identifier
name string // Trader display name
aiModel string // AI model name
exchange string // Trading platform type (binance/bybit/etc)
exchangeID string // Exchange account UUID
showInCompetition bool // Whether to show in competition page
config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient
store *store.Store // Data storage (decision records, etc.)
strategyEngine *decision.StrategyEngine // Strategy engine (uses strategy configuration)
cycleNumber int // Current cycle number
initialBalance float64
dailyPnL float64
customPrompt string // Custom trading strategy prompt
overrideBasePrompt bool // Whether to override base prompt
lastResetTime time.Time
stopUntil time.Time
isRunning bool
isRunningMutex sync.RWMutex // Mutex to protect isRunning flag
startTime time.Time // System start time
callCount int // AI call count
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
stopMonitorCh chan struct{} // Used to stop monitoring goroutine
monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish
peakPnLCache map[string]float64 // Peak profit cache (symbol -> peak P&L percentage)
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
lastBalanceSyncTime time.Time // Last balance sync time
userID string // User ID
}
// NewAutoTrader creates an automatic trader
// st parameter is used to store decision records to database
func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) {
// Set default values
if config.ID == "" {
config.ID = "default_trader"
}
if config.Name == "" {
config.Name = "Default Trader"
}
if config.AIModel == "" {
if config.UseQwen {
config.AIModel = "qwen"
} else {
config.AIModel = "deepseek"
}
}
// Initialize AI client based on provider
var mcpClient mcp.AIClient
aiModel := config.AIModel
if config.UseQwen && aiModel == "" {
aiModel = "qwen"
}
switch aiModel {
case "claude":
mcpClient = mcp.NewClaudeClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using Claude AI", config.Name)
case "kimi":
mcpClient = mcp.NewKimiClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using Kimi (Moonshot) AI", config.Name)
case "gemini":
mcpClient = mcp.NewGeminiClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using Google Gemini AI", config.Name)
case "grok":
mcpClient = mcp.NewGrokClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using xAI Grok AI", config.Name)
case "openai":
mcpClient = mcp.NewOpenAIClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
case "qwen":
mcpClient = mcp.NewQwenClient()
apiKey := config.QwenKey
if apiKey == "" {
apiKey = config.CustomAPIKey
}
mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI", config.Name)
case "custom":
mcpClient = mcp.New()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using custom AI API: %s (model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
default: // deepseek or empty
mcpClient = mcp.NewDeepSeekClient()
apiKey := config.DeepSeekKey
if apiKey == "" {
apiKey = config.CustomAPIKey
}
mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name)
}
if config.CustomAPIURL != "" || config.CustomModelName != "" {
logger.Infof("🔧 [%s] Custom config - URL: %s, Model: %s", config.Name, config.CustomAPIURL, config.CustomModelName)
}
// Set default trading platform
if config.Exchange == "" {
config.Exchange = "binance"
}
// Create corresponding trader based on configuration
var trader Trader
var err error
// Record position mode (general)
marginModeStr := "Cross Margin"
if !config.IsCrossMargin {
marginModeStr = "Isolated Margin"
}
logger.Infof("📊 [%s] Position mode: %s", config.Name, marginModeStr)
switch config.Exchange {
case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
}
case "lighter":
logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name)
if config.LighterWalletAddr == "" || config.LighterAPIKeyPrivateKey == "" {
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
}
// Lighter only supports mainnet (testnet disabled)
trader, err = NewLighterTraderV2(
config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
if err != nil {
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
}
logger.Infof("✓ LIGHTER trader initialized successfully")
default:
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
}
// Validate initial balance configuration, auto-fetch from exchange if 0
if config.InitialBalance <= 0 {
logger.Infof("📊 [%s] Initial balance not set, attempting to fetch current balance from exchange...", config.Name)
account, err := trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("initial balance not set and unable to fetch balance from exchange: %w", err)
}
// Try multiple balance field names (different exchanges return different formats)
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
var foundBalance float64
for _, key := range balanceKeys {
if balance, ok := account[key].(float64); ok && balance > 0 {
foundBalance = balance
break
}
}
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")
}
}
// Get last cycle number (for recovery)
var cycleNumber int
if st != nil {
cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID)
logger.Infof("📊 [%s] Decision records will be stored to database", config.Name)
}
// Create strategy engine (must have strategy config)
if config.StrategyConfig == nil {
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
}
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig)
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
return &AutoTrader{
id: config.ID,
name: config.Name,
aiModel: config.AIModel,
exchange: config.Exchange,
exchangeID: config.ExchangeID,
showInCompetition: config.ShowInCompetition,
config: config,
trader: trader,
mcpClient: mcpClient,
store: st,
strategyEngine: strategyEngine,
cycleNumber: cycleNumber,
initialBalance: config.InitialBalance,
lastResetTime: time.Now(),
startTime: time.Now(),
callCount: 0,
isRunning: false,
positionFirstSeenTime: make(map[string]int64),
stopMonitorCh: make(chan struct{}),
monitorWg: sync.WaitGroup{},
peakPnLCache: make(map[string]float64),
peakPnLCacheMutex: sync.RWMutex{},
lastBalanceSyncTime: time.Now(),
userID: userID,
}, nil
}
// Run runs the automatic trading main loop
func (at *AutoTrader) Run() error {
at.isRunningMutex.Lock()
at.isRunning = true
at.isRunningMutex.Unlock()
at.stopMonitorCh = make(chan struct{})
at.startTime = time.Now()
logger.Info("🚀 AI-driven automatic trading system started")
logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance)
logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval)
logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.")
at.monitorWg.Add(1)
defer at.monitorWg.Done()
// Start drawdown monitoring
at.startDrawdownMonitor()
// Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.store.Order(), 30*time.Second)
logger.Infof("🔄 [%s] Lighter order sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop()
// Execute immediately on first run
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
for {
at.isRunningMutex.RLock()
running := at.isRunning
at.isRunningMutex.RUnlock()
if !running {
break
}
select {
case <-ticker.C:
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
case <-at.stopMonitorCh:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
return nil
}
}
return nil
}
// Stop stops the automatic trading
func (at *AutoTrader) Stop() {
at.isRunningMutex.Lock()
if !at.isRunning {
at.isRunningMutex.Unlock()
return
}
at.isRunning = false
at.isRunningMutex.Unlock()
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
logger.Info("⏹ Automatic trading system stopped")
}
// runCycle runs one trading cycle (using AI full decision-making)
func (at *AutoTrader) runCycle() error {
at.callCount++
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
logger.Info(strings.Repeat("=", 70))
// 0. Check if trader is stopped (early exit to prevent trades after Stop() is called)
at.isRunningMutex.RLock()
running := at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
return nil
}
// Create decision record
record := &store.DecisionRecord{
ExecutionLog: []string{},
Success: true,
}
// 1. Check if trading needs to be stopped
if time.Now().Before(at.stopUntil) {
remaining := at.stopUntil.Sub(time.Now())
logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes())
record.Success = false
record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes())
at.saveDecision(record)
return nil
}
// 2. Reset daily P&L (reset every day)
if time.Since(at.lastResetTime) > 24*time.Hour {
at.dailyPnL = 0
at.lastResetTime = time.Now()
logger.Info("📅 Daily P&L reset")
}
// 4. Collect trading context
ctx, err := at.buildTradingContext()
if err != nil {
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err)
at.saveDecision(record)
return fmt.Errorf("failed to build trading context: %w", err)
}
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
at.saveEquitySnapshot(ctx)
logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
}
logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d",
ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount)
// 5. Use strategy engine to call AI for decision
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000)
record.ExecutionLog = append(record.ExecutionLog,
fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs))
}
// Save chain of thought, decisions, and input prompt even if there's an error (for debugging)
if aiDecision != nil {
record.SystemPrompt = aiDecision.SystemPrompt // Save system prompt
record.InputPrompt = aiDecision.UserPrompt
record.CoTTrace = aiDecision.CoTTrace
record.RawResponse = aiDecision.RawResponse // Save raw AI response for debugging
if len(aiDecision.Decisions) > 0 {
decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ")
record.DecisionJSON = string(decisionJSON)
}
}
if err != nil {
record.Success = false
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
// Print system prompt and AI chain of thought (output even with errors for debugging)
if aiDecision != nil {
logger.Info("\n" + strings.Repeat("=", 70) + "\n")
logger.Infof("📋 System prompt (error case)")
logger.Info(strings.Repeat("=", 70))
logger.Info(aiDecision.SystemPrompt)
logger.Info(strings.Repeat("=", 70))
if aiDecision.CoTTrace != "" {
logger.Info("\n" + strings.Repeat("-", 70) + "\n")
logger.Info("💭 AI chain of thought analysis (error case):")
logger.Info(strings.Repeat("-", 70))
logger.Info(aiDecision.CoTTrace)
logger.Info(strings.Repeat("-", 70))
}
}
at.saveDecision(record)
return fmt.Errorf("failed to get AI decision: %w", err)
}
// // 5. Print system prompt
// logger.Infof("\n" + strings.Repeat("=", 70))
// logger.Infof("📋 System prompt [template: %s]", at.systemPromptTemplate)
// logger.Info(strings.Repeat("=", 70))
// logger.Info(decision.SystemPrompt)
// logger.Infof(strings.Repeat("=", 70) + "\n")
// 6. Print AI chain of thought
// logger.Infof("\n" + strings.Repeat("-", 70))
// logger.Info("💭 AI chain of thought analysis:")
// logger.Info(strings.Repeat("-", 70))
// logger.Info(decision.CoTTrace)
// logger.Infof(strings.Repeat("-", 70) + "\n")
// 7. Print AI decisions
// logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions))
// for i, d := range decision.Decisions {
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
// if d.Action == "open_long" || d.Action == "open_short" {
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
// d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit)
// }
// }
logger.Info()
logger.Info(strings.Repeat("-", 70))
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
logger.Info(strings.Repeat("-", 70))
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
for i, d := range sortedDecisions {
logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action)
}
logger.Info()
// Check if trader is stopped before executing any decisions (prevent trades after Stop())
at.isRunningMutex.RLock()
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
return nil
}
// Execute decisions and record results
for _, d := range sortedDecisions {
// Check if trader is stopped before each decision (allow immediate stop during execution)
at.isRunningMutex.RLock()
running = at.isRunning
at.isRunningMutex.RUnlock()
if !running {
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
break
}
actionRecord := store.DecisionAction{
Action: d.Action,
Symbol: d.Symbol,
Quantity: 0,
Leverage: d.Leverage,
Price: 0,
StopLoss: d.StopLoss,
TakeProfit: d.TakeProfit,
Confidence: d.Confidence,
Reasoning: d.Reasoning,
Timestamp: time.Now(),
Success: false,
}
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
actionRecord.Error = err.Error()
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err))
} else {
actionRecord.Success = true
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s succeeded", d.Symbol, d.Action))
// Brief delay after successful execution
time.Sleep(1 * time.Second)
}
record.Decisions = append(record.Decisions, actionRecord)
}
// 9. Save decision record
if err := at.saveDecision(record); err != nil {
logger.Infof("⚠ Failed to save decision record: %v", err)
}
return nil
}
// buildTradingContext builds trading context
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
// 1. Get account information
balance, err := at.trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
// Get account fields
totalWalletBalance := 0.0
totalUnrealizedProfit := 0.0
availableBalance := 0.0
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
totalWalletBalance = wallet
}
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
totalUnrealizedProfit = unrealized
}
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Total Equity = Wallet balance + Unrealized profit
totalEquity := totalWalletBalance + totalUnrealizedProfit
// 2. Get position information
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positionInfos []decision.PositionInfo
totalMarginUsed := 0.0
// Current position key set (for cleaning up closed position records)
currentPositionKeys := make(map[string]bool)
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Skip closed positions (quantity = 0), prevent "ghost positions" from being passed to AI
if quantity == 0 {
continue
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
// Calculate margin used (estimated)
leverage := 10 // Default value, should actually be fetched from position info
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
marginUsed := (quantity * markPrice) / float64(leverage)
totalMarginUsed += marginUsed
// Calculate P&L percentage (based on margin, considering leverage)
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
// Get position open time from exchange (preferred) or fallback to local tracking
posKey := symbol + "_" + side
currentPositionKeys[posKey] = true
var updateTime int64
// Priority 1: Get from database (trader_positions table) - most accurate
if at.store != nil {
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
if !dbPos.EntryTime.IsZero() {
updateTime = dbPos.EntryTime.UnixMilli()
}
}
}
// Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime)
if updateTime == 0 {
if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 {
updateTime = createdTime
}
}
// Priority 3: Fallback to local tracking
if updateTime == 0 {
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
}
updateTime = at.positionFirstSeenTime[posKey]
}
// Get peak profit rate for this position
at.peakPnLCacheMutex.RLock()
peakPnlPct := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock()
positionInfos = append(positionInfos, decision.PositionInfo{
Symbol: symbol,
Side: side,
EntryPrice: entryPrice,
MarkPrice: markPrice,
Quantity: quantity,
Leverage: leverage,
UnrealizedPnL: unrealizedPnl,
UnrealizedPnLPct: pnlPct,
PeakPnLPct: peakPnlPct,
LiquidationPrice: liquidationPrice,
MarginUsed: marginUsed,
UpdateTime: updateTime,
})
}
// Clean up closed position records
for key := range at.positionFirstSeenTime {
if !currentPositionKeys[key] {
delete(at.positionFirstSeenTime, key)
}
}
// 3. Use strategy engine to get candidate coins (must have strategy engine)
if at.strategyEngine == nil {
return nil, fmt.Errorf("trader has no strategy engine configured")
}
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
}
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
// 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance
totalPnLPct := 0.0
if at.initialBalance > 0 {
totalPnLPct = (totalPnL / at.initialBalance) * 100
}
marginUsedPct := 0.0
if totalEquity > 0 {
marginUsedPct = (totalMarginUsed / totalEquity) * 100
}
// 5. Get leverage from strategy config
strategyConfig := at.strategyEngine.GetConfig()
btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage
altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
// 6. Build context
ctx := &decision.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
Account: decision.AccountInfo{
TotalEquity: totalEquity,
AvailableBalance: availableBalance,
UnrealizedPnL: totalUnrealizedProfit,
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
MarginUsed: totalMarginUsed,
MarginUsedPct: marginUsedPct,
PositionCount: len(positionInfos),
},
Positions: positionInfos,
CandidateCoins: candidateCoins,
}
// 7. Add recent closed trades (if store is available)
if at.store != nil {
// Get recent 10 closed trades for AI context
recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10)
if err != nil {
logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err)
} else {
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
for _, trade := range recentTrades {
// Convert Unix timestamps to formatted strings for AI readability
entryTimeStr := ""
if trade.EntryTime > 0 {
entryTimeStr = time.Unix(trade.EntryTime, 0).UTC().Format("01-02 15:04 UTC")
}
exitTimeStr := ""
if trade.ExitTime > 0 {
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
}
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,
EntryTime: entryTimeStr,
ExitTime: exitTimeStr,
HoldDuration: trade.HoldDuration,
})
}
}
} else {
logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name)
}
// 8. Get quantitative data (if enabled in strategy config)
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
// Collect symbols to query (candidate coins + position coins)
symbolsToQuery := make(map[string]bool)
for _, coin := range candidateCoins {
symbolsToQuery[coin.Symbol] = true
}
for _, pos := range positionInfos {
symbolsToQuery[pos.Symbol] = true
}
symbols := make([]string, 0, len(symbolsToQuery))
for sym := range symbolsToQuery {
symbols = append(symbols, sym)
}
logger.Infof("📊 [%s] Fetching quantitative data for %d symbols...", at.name, len(symbols))
ctx.QuantDataMap = at.strategyEngine.FetchQuantDataBatch(symbols)
logger.Infof("📊 [%s] Successfully fetched quantitative data for %d symbols", at.name, len(ctx.QuantDataMap))
}
// 9. Get OI ranking data (market-wide position changes)
if strategyConfig.Indicators.EnableOIRanking {
logger.Infof("📊 [%s] Fetching OI ranking data...", at.name)
ctx.OIRankingData = at.strategyEngine.FetchOIRankingData()
if ctx.OIRankingData != nil {
logger.Infof("📊 [%s] OI ranking data ready: %d top, %d low positions",
at.name, len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
}
}
return ctx, nil
}
// executeDecisionWithRecord executes AI decision and records detailed information
func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
switch decision.Action {
case "open_long":
return at.executeOpenLongWithRecord(decision, actionRecord)
case "open_short":
return at.executeOpenShortWithRecord(decision, actionRecord)
case "close_long":
return at.executeCloseLongWithRecord(decision, actionRecord)
case "close_short":
return at.executeCloseShortWithRecord(decision, actionRecord)
case "hold", "wait":
// No execution needed, just record
return nil
default:
return fmt.Errorf("unknown action: %s", decision.Action)
}
}
// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
// This is a public method that can be called by other modules
func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error {
logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
// Create a minimal action record for tracking
actionRecord := &store.DecisionAction{
Symbol: d.Symbol,
Action: d.Action,
Leverage: d.Leverage,
StopLoss: d.StopLoss,
TakeProfit: d.TakeProfit,
Confidence: d.Confidence,
Reasoning: d.Reasoning,
}
// Execute the decision
err := at.executeDecisionWithRecord(d, actionRecord)
if err != nil {
logger.Errorf("[%s] External decision execution failed: %v", at.name, err)
return err
}
logger.Infof("[%s] External decision executed successfully: %s %s", at.name, d.Action, d.Symbol)
return nil
}
// executeOpenLongWithRecord executes open long position and records detailed information
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 📈 Open long: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks
positions, err := at.trader.GetPositions()
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)
}
}
// Get current price
marketData, err := market.Get(decision.Symbol)
if err != nil {
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
}
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err
}
// Calculate quantity with adjusted position size
quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice
// Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Continue execution, doesn't affect trading
}
// Open position
order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage)
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0)
// Record position opening time
posKey := decision.Symbol + "_long"
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
// Set stop loss and take profit
if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil {
logger.Infof(" ⚠ Failed to set take profit: %v", err)
}
return nil
}
// executeOpenShortWithRecord executes open short position and records detailed information
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 📉 Open short: %s", decision.Symbol)
// ⚠️ Get current positions for multiple checks
positions, err := at.trader.GetPositions()
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)
}
}
// Get current price
marketData, err := market.Get(decision.Symbol)
if err != nil {
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
}
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err
}
// Calculate quantity with adjusted position size
quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice
// Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Continue execution, doesn't affect trading
}
// Open position
order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage)
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity)
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0)
// Record position opening time
posKey := decision.Symbol + "_short"
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
// Set stop loss and take profit
if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil {
logger.Infof(" ⚠ Failed to set stop loss: %v", err)
}
if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil {
logger.Infof(" ⚠ Failed to set take profit: %v", err)
}
return nil
}
// executeCloseLongWithRecord executes close long position and records detailed information
func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// Get entry price and quantity from exchange API (most accurate)
var entryPrice float64
var quantity float64
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
quantity = amt
}
break
}
}
}
// Close position
order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice)
logger.Infof(" ✓ Position closed successfully")
return nil
}
// executeCloseShortWithRecord executes close short position and records detailed information
func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// Get entry price and quantity from exchange API (most accurate)
var entryPrice float64
var quantity float64
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok {
quantity = -amt // positionAmt is negative for short
}
break
}
}
}
// Close position
order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all
if err != nil {
return err
}
// Record order ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
// Record order to database and poll for confirmation
at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice)
logger.Infof(" ✓ Position closed successfully")
return nil
}
// GetID gets trader ID
func (at *AutoTrader) GetID() string {
return at.id
}
// GetName gets trader name
func (at *AutoTrader) GetName() string {
return at.name
}
// GetAIModel gets AI model
func (at *AutoTrader) GetAIModel() string {
return at.aiModel
}
// GetExchange gets exchange
func (at *AutoTrader) GetExchange() string {
return at.exchange
}
// GetShowInCompetition returns whether trader should be shown in competition
func (at *AutoTrader) GetShowInCompetition() bool {
return at.showInCompetition
}
// SetShowInCompetition sets whether trader should be shown in competition
func (at *AutoTrader) SetShowInCompetition(show bool) {
at.showInCompetition = show
}
// SetCustomPrompt sets custom trading strategy prompt
func (at *AutoTrader) SetCustomPrompt(prompt string) {
at.customPrompt = prompt
}
// SetOverrideBasePrompt sets whether to override base prompt
func (at *AutoTrader) SetOverrideBasePrompt(override bool) {
at.overrideBasePrompt = override
}
// GetSystemPromptTemplate gets current system prompt template name (from strategy config)
func (at *AutoTrader) GetSystemPromptTemplate() string {
if at.strategyEngine != nil {
config := at.strategyEngine.GetConfig()
if config.CustomPrompt != "" {
return "custom"
}
}
return "strategy"
}
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
func (at *AutoTrader) saveEquitySnapshot(ctx *decision.Context) {
if at.store == nil || ctx == nil {
return
}
snapshot := &store.EquitySnapshot{
TraderID: at.id,
Timestamp: time.Now().UTC(),
TotalEquity: ctx.Account.TotalEquity,
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
UnrealizedPnL: ctx.Account.UnrealizedPnL,
PositionCount: ctx.Account.PositionCount,
MarginUsedPct: ctx.Account.MarginUsedPct,
}
if err := at.store.Equity().Save(snapshot); err != nil {
logger.Infof("⚠️ Failed to save equity snapshot: %v", err)
}
}
// saveDecision saves AI decision log to database (only records AI input/output, for debugging)
func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error {
if at.store == nil {
return nil
}
at.cycleNumber++
record.CycleNumber = at.cycleNumber
record.TraderID = at.id
if record.Timestamp.IsZero() {
record.Timestamp = time.Now().UTC()
}
if err := at.store.Decision().LogDecision(record); err != nil {
logger.Infof("⚠️ Failed to save decision record: %v", err)
return err
}
logger.Infof("📝 Decision record saved: trader=%s, cycle=%d", at.id, at.cycleNumber)
return nil
}
// GetStore gets data store (for external access to decision records, etc.)
func (at *AutoTrader) GetStore() *store.Store {
return at.store
}
// GetStatus gets system status (for API)
func (at *AutoTrader) GetStatus() map[string]interface{} {
aiProvider := "DeepSeek"
if at.config.UseQwen {
aiProvider = "Qwen"
}
at.isRunningMutex.RLock()
isRunning := at.isRunning
at.isRunningMutex.RUnlock()
return map[string]interface{}{
"trader_id": at.id,
"trader_name": at.name,
"ai_model": at.aiModel,
"exchange": at.exchange,
"is_running": isRunning,
"start_time": at.startTime.Format(time.RFC3339),
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
"call_count": at.callCount,
"initial_balance": at.initialBalance,
"scan_interval": at.config.ScanInterval.String(),
"stop_until": at.stopUntil.Format(time.RFC3339),
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
"ai_provider": aiProvider,
}
}
// GetAccountInfo gets account information (for API)
func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
balance, err := at.trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
// Get account fields
totalWalletBalance := 0.0
totalUnrealizedProfit := 0.0
availableBalance := 0.0
if wallet, ok := balance["totalWalletBalance"].(float64); ok {
totalWalletBalance = wallet
}
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
totalUnrealizedProfit = unrealized
}
if avail, ok := balance["availableBalance"].(float64); ok {
availableBalance = avail
}
// Total Equity = Wallet balance + Unrealized profit
totalEquity := totalWalletBalance + totalUnrealizedProfit
// Get positions to calculate total margin
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
totalMarginUsed := 0.0
totalUnrealizedPnLCalculated := 0.0
for _, pos := range positions {
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
totalUnrealizedPnLCalculated += unrealizedPnl
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
marginUsed := (quantity * markPrice) / float64(leverage)
totalMarginUsed += marginUsed
}
// Verify unrealized P&L consistency (API value vs calculated from positions)
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
}
totalPnL := totalEquity - at.initialBalance
totalPnLPct := 0.0
if at.initialBalance > 0 {
totalPnLPct = (totalPnL / at.initialBalance) * 100
} else {
logger.Infof("⚠️ Initial Balance abnormal: %.2f, cannot calculate P&L percentage", at.initialBalance)
}
marginUsedPct := 0.0
if totalEquity > 0 {
marginUsedPct = (totalMarginUsed / totalEquity) * 100
}
return map[string]interface{}{
// Core fields
"total_equity": totalEquity, // Account equity = wallet + unrealized
"wallet_balance": totalWalletBalance, // Wallet balance (excluding unrealized P&L)
"unrealized_profit": totalUnrealizedProfit, // Unrealized P&L (official value from exchange API)
"available_balance": availableBalance, // Available balance
// P&L statistics
"total_pnl": totalPnL, // Total P&L = equity - initial
"total_pnl_pct": totalPnLPct, // Total P&L percentage
"initial_balance": at.initialBalance, // Initial balance
"daily_pnl": at.dailyPnL, // Daily P&L
// Position information
"position_count": len(positions), // Position count
"margin_used": totalMarginUsed, // Margin used
"margin_used_pct": marginUsedPct, // Margin usage rate
}, nil
}
// GetPositions gets position list (for API)
func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) {
positions, err := at.trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
// Calculate margin used
marginUsed := (quantity * markPrice) / float64(leverage)
// Calculate P&L percentage (based on margin)
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
result = append(result, map[string]interface{}{
"symbol": symbol,
"side": side,
"entry_price": entryPrice,
"mark_price": markPrice,
"quantity": quantity,
"leverage": leverage,
"unrealized_pnl": unrealizedPnl,
"unrealized_pnl_pct": pnlPct,
"liquidation_price": liquidationPrice,
"margin_used": marginUsed,
})
}
return result, nil
}
// calculatePnLPercentage calculates P&L percentage (based on margin, automatically considers leverage)
// Return rate = Unrealized P&L / Margin × 100%
func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
if marginUsed > 0 {
return (unrealizedPnl / marginUsed) * 100
}
return 0.0
}
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
// This avoids position stacking overflow when changing positions
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
if len(decisions) <= 1 {
return decisions
}
// Define priority
getActionPriority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1 // Highest priority: close positions first
case "open_long", "open_short":
return 2 // Second priority: open positions later
case "hold", "wait":
return 3 // Lowest priority: wait
default:
return 999 // Unknown actions at the end
}
}
// Copy decision list
sorted := make([]decision.Decision, len(decisions))
copy(sorted, decisions)
// Sort by priority
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
}
// startDrawdownMonitor starts drawdown monitoring
func (at *AutoTrader) startDrawdownMonitor() {
at.monitorWg.Add(1)
go func() {
defer at.monitorWg.Done()
ticker := time.NewTicker(1 * time.Minute) // Check every minute
defer ticker.Stop()
logger.Info("📊 Started position drawdown monitoring (check every minute)")
for {
select {
case <-ticker.C:
at.checkPositionDrawdown()
case <-at.stopMonitorCh:
logger.Info("⏹ Stopped position drawdown monitoring")
return
}
}
}()
}
// checkPositionDrawdown checks position drawdown situation
func (at *AutoTrader) checkPositionDrawdown() {
// Get current positions
positions, err := at.trader.GetPositions()
if err != nil {
logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err)
return
}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Calculate current P&L percentage
leverage := 10 // Default value
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
var currentPnLPct float64
if side == "long" {
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
} else {
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
}
// Construct unique position identifier (distinguish long/short)
posKey := symbol + "_" + side
// Get historical peak profit for this position
at.peakPnLCacheMutex.RLock()
peakPnLPct, exists := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock()
if !exists {
// If no historical peak record, use current P&L as initial value
peakPnLPct = currentPnLPct
at.UpdatePeakPnL(symbol, side, currentPnLPct)
} else {
// Update peak cache
at.UpdatePeakPnL(symbol, side, currentPnLPct)
}
// Calculate drawdown (magnitude of decline from peak)
var drawdownPct float64
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
}
// Check close position condition: profit > 5% and drawdown >= 40%
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
// Execute close position
if err := at.emergencyClosePosition(symbol, side); err != nil {
logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err)
} else {
logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side)
// Clear cache for this position after closing
at.ClearPeakPnLCache(symbol, side)
}
} else if currentPnLPct > 5.0 {
// Record situations close to close position condition (for debugging)
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
}
}
}
// emergencyClosePosition emergency close position function
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
switch side {
case "long":
order, err := at.trader.CloseLong(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"])
case "short":
order, err := at.trader.CloseShort(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"])
default:
return fmt.Errorf("unknown position direction: %s", side)
}
return nil
}
// GetPeakPnLCache gets peak profit cache
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
at.peakPnLCacheMutex.RLock()
defer at.peakPnLCacheMutex.RUnlock()
// Return a copy of the cache
cache := make(map[string]float64)
for k, v := range at.peakPnLCache {
cache[k] = v
}
return cache
}
// UpdatePeakPnL updates peak profit cache
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
if peak, exists := at.peakPnLCache[posKey]; exists {
// Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare)
if currentPnLPct > peak {
at.peakPnLCache[posKey] = currentPnLPct
}
} else {
// First time recording
at.peakPnLCache[posKey] = currentPnLPct
}
}
// ClearPeakPnLCache clears peak cache for specified position
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
delete(at.peakPnLCache, posKey)
}
// recordAndConfirmOrder polls order status for actual fill data and records position
// action: open_long, open_short, close_long, close_short
// entryPrice: entry price when closing (0 when opening)
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
if at.store == nil {
return
}
// Get order ID (supports multiple types)
var orderID string
switch v := orderResult["orderId"].(type) {
case int64:
orderID = fmt.Sprintf("%d", v)
case float64:
orderID = fmt.Sprintf("%.0f", v)
case string:
orderID = v
default:
orderID = fmt.Sprintf("%v", v)
}
if orderID == "" || orderID == "0" {
logger.Infof(" ⚠️ Order ID is empty, skipping record")
return
}
// Determine positionSide
var positionSide string
switch action {
case "open_long", "close_long":
positionSide = "LONG"
case "open_short", "close_short":
positionSide = "SHORT"
}
// For Lighter exchange, market orders fill immediately - record as FILLED directly
var actualPrice = price // fallback to market price
var actualQty = quantity // fallback to requested quantity
var fee float64
if at.exchange == "lighter" {
// Estimate fee (0.04% for Lighter taker)
fee = price * quantity * 0.0004
// Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT)
normalizedSymbol := market.Normalize(symbol)
// Create order record directly as FILLED
orderRecord := &store.TraderOrder{
TraderID: at.id,
ExchangeID: at.exchange,
ExchangeOrderID: orderID,
Symbol: normalizedSymbol,
Side: getSideFromAction(action),
PositionSide: positionSide,
Type: "MARKET",
OrderAction: action,
Quantity: quantity,
Price: 0, // Market order
Status: "FILLED",
FilledQuantity: quantity,
AvgFillPrice: price,
Commission: fee,
FilledAt: time.Now(),
Leverage: leverage,
ReduceOnly: (action == "close_long" || action == "close_short"),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
} else {
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, action, symbol, quantity, price)
// Record fill details
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, price, quantity, fee)
}
} else {
// For other exchanges, record as NEW and poll for status
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
} else {
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
}
// Wait for order to be filled and get actual fill data
time.Sleep(500 * time.Millisecond)
for i := 0; i < 5; i++ {
status, err := at.trader.GetOrderStatus(symbol, orderID)
if err == nil {
statusStr, _ := status["status"].(string)
if statusStr == "FILLED" {
// Get actual fill price
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
actualPrice = avgPrice
}
// Get actual executed quantity
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
actualQty = execQty
}
// Get commission/fee
if commission, ok := status["commission"].(float64); ok {
fee = commission
}
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
// Update order status to FILLED
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
}
// Record fill details
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
break
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
// Update order status
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
}
return
}
}
time.Sleep(500 * time.Millisecond)
}
}
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
orderID, action, actualPrice, actualQty, fee)
// Record position change with actual fill data
at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
// Send anonymous trade statistics for experience improvement (async, non-blocking)
// This helps us understand overall product usage across all deployments
experience.TrackTrade(experience.TradeEvent{
Exchange: at.exchange,
TradeType: action,
Symbol: symbol,
AmountUSD: actualPrice * actualQty,
Leverage: leverage,
UserID: at.userID,
TraderID: at.id,
})
}
// recordPositionChange records position change (create record on open, update record on close)
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) {
if at.store == nil {
return
}
switch action {
case "open_long", "open_short":
// Open position: create new position record
pos := &store.TraderPosition{
TraderID: at.id,
ExchangeID: at.exchangeID, // Exchange account UUID
ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc
Symbol: symbol,
Side: side, // LONG or SHORT
Quantity: quantity,
EntryPrice: price,
EntryOrderID: orderID,
EntryTime: time.Now(),
Leverage: leverage,
Status: "OPEN",
}
if err := at.store.Position().Create(pos); err != nil {
logger.Infof(" ⚠️ Failed to record position: %v", err)
} else {
logger.Infof(" 📊 Position recorded [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
}
case "close_long", "close_short":
// Close position: find corresponding open position record and update
openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side)
if err != nil || openPos == nil {
logger.Infof(" ⚠️ Cannot find corresponding open position record (%s %s)", symbol, side)
return
}
// Calculate P&L
var realizedPnL float64
if side == "LONG" {
realizedPnL = (price - openPos.EntryPrice) * openPos.Quantity
} else {
realizedPnL = (openPos.EntryPrice - price) * openPos.Quantity
}
// Update position record
err = at.store.Position().ClosePosition(
openPos.ID,
price, // exitPrice
orderID, // exitOrderID
realizedPnL,
fee, // fee from exchange API
"ai_decision",
)
if err != nil {
logger.Infof(" ⚠️ Failed to update position: %v", err)
} else {
logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f, Fee: %.4f",
at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL, fee)
}
}
}
// createOrderRecord creates an order record struct from order details
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
// Determine order type (market for auto trader)
orderType := "MARKET"
// Determine side (BUY/SELL)
var side string
switch action {
case "open_long", "close_short":
side = "BUY"
case "open_short", "close_long":
side = "SELL"
}
// Use action as orderAction directly (keep lowercase format)
orderAction := action
// Determine if it's a reduce only order
reduceOnly := (action == "close_long" || action == "close_short")
// Normalize symbol for consistency
normalizedSymbol := market.Normalize(symbol)
return &store.TraderOrder{
TraderID: at.id,
ExchangeID: at.exchange,
ExchangeOrderID: orderID,
Symbol: normalizedSymbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
TimeInForce: "GTC",
Quantity: quantity,
Price: price,
Status: "NEW",
FilledQuantity: 0,
AvgFillPrice: 0,
Commission: 0,
CommissionAsset: "USDT",
Leverage: leverage,
ReduceOnly: reduceOnly,
ClosePosition: reduceOnly,
OrderAction: orderAction,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
// recordOrderFill records order fill/trade details
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
if at.store == nil {
return
}
// Determine side (BUY/SELL)
var side string
switch action {
case "open_long", "close_short":
side = "BUY"
case "open_short", "close_long":
side = "SELL"
}
// Generate a simple trade ID (exchange doesn't always provide one)
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
// Normalize symbol for consistency
normalizedSymbol := market.Normalize(symbol)
fill := &store.TraderFill{
TraderID: at.id,
ExchangeID: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now(),
}
// Calculate realized PnL for close orders
if action == "close_long" || action == "close_short" {
// Try to get the entry price from the open position
var positionSide string
if action == "close_long" {
positionSide = "LONG"
} else {
positionSide = "SHORT"
}
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
if positionSide == "LONG" {
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
} else {
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
}
}
}
if err := at.store.Order().CreateFill(fill); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
}
}
// ============================================================================
// 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
}
// getSideFromAction converts order action to side (BUY/SELL)
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}