mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
* feat: add AI grid trading and market regime classification - Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook - Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter) - Add grid engine with ATR-based boundary calculation and fund distribution - Add market regime classification documents (Chinese/English) - Add GridConfigEditor component for frontend configuration * fix: implement GetOpenOrders for Lighter exchange * debug: add logging for Lighter GetActiveOrders API call * fix: correct Lighter API response parsing for GetOpenOrders - Changed response field from 'data' to 'orders' to match Lighter API - Updated OrderResponse struct to match Lighter's actual field names - Fixed field types: price/quantity as strings, is_ask for side * feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges - Aster: uses /fapi/v3/openOrders endpoint - OKX: uses /api/v5/trade/orders-pending and orders-algo-pending - Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending * fix: address code review issues for GetOpenOrders - Add error logging for OKX/Bitget API failures (was silently swallowed) - Fix Lighter position side logic to handle reduce-only orders - Change verbose debug logs from Infof to Debugf level * fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck * fix: use auth query parameter instead of Authorization header for Lighter API * test: add Lighter API authentication tests and diagnostic tools * fix(grid): add leverage setting before order placement CRITICAL BUG FIX: - Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder() - Set leverage during grid initialization - Log leverage setting results * fix(grid): prevent CancelOrder from canceling all orders CRITICAL BUG FIX: - CancelOrder no longer calls CancelAllOrders - Try exchange-specific CancelOrder if available - Return error if individual cancellation not supported * fix(grid): add total position value limit check CRITICAL: Prevent excessive position accumulation - New checkTotalPositionLimit() function - Checks current + pending + new order value - Rejects orders that would exceed TotalInvestment x Leverage - Logs clear error messages when limit exceeded * feat(grid): implement stop loss execution CRITICAL: Add code-level stop loss protection - New checkAndExecuteStopLoss() function - Checks each filled level against StopLossPct - Automatically closes positions exceeding stop loss - Called during every grid state sync * feat(grid): add breakout detection and auto-pause CRITICAL: Detect price breakout from grid range - New checkBreakout() function to detect upper/lower breakouts - Auto-pause grid on significant breakout (>2%) - Cancel all orders when breakout detected - Prevent continued losses in trending market - Minor breakouts (1-2%) logged for AI consideration * feat(grid): enforce max drawdown limit with emergency exit CRITICAL: Add drawdown protection - New checkMaxDrawdown() function tracks peak equity - emergencyExit() closes all positions and cancels orders - Auto-pause grid when MaxDrawdownPct exceeded - Protect capital from excessive losses * feat(grid): enforce daily loss limit - Add checkDailyLossLimit() function to check if daily loss exceeds limit - Track daily PnL with auto-reset at midnight - Pause grid when DailyLossLimitPct exceeded - Add updateDailyPnL() helper for realized PnL tracking - Prevent excessive single-day losses * fix(grid): update daily PnL when stop loss is executed The updateDailyPnL() function was added but never called, leaving DailyPnL always at 0 and preventing daily loss limit checks from triggering. This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss() when a stop loss is executed. We update directly rather than calling updateDailyPnL() because the mutex is already held in that function. * feat(grid): add automatic grid adjustment - New checkGridSkew() detects imbalanced grid - autoAdjustGrid() reinitializes around current price - Prevents grid from becoming ineffective after drift - Triggers when one side is 3x more filled than other * fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels Critical fix for grid auto-adjustment: - Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered on current price before reinitializing grid levels - Preserve filled positions during adjustment by saving and restoring them to the closest new level after reinitialization - Hold mutex lock for the entire adjustment operation to ensure atomicity - Add locked variants of calculateDefaultBounds, calculateATRBounds, and initializeGridLevels to use during adjustment Without this fix, autoAdjustGrid was using old boundaries when creating new grid levels, defeating the purpose of auto-adjustment when price moved significantly. * fix(grid): improve order state sync logic - Don't assume missing orders are filled - Compare position size to determine fill vs cancel - Properly reset cancelled orders to empty state - More accurate grid state tracking * fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity` which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution (gaussian, pyramid, uniform) where orders have different quantities, this could lead to incorrect fill detection. Now sums the actual PositionSize from filled levels for accurate comparison. Also adds warning log when GetPositions() fails. * docs: add grid market regime detection design Design for enhanced market state recognition with: - Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI) - Multi-period box indicators (72/240/500 1h candles) - 4-level ranging classification - Breakout detection and handling - Frontend risk control panel * docs: add grid market regime implementation plan 20 tasks covering: - Donchian channel calculation - Box data types and API - Regime classification (4 levels) - Breakout detection and handling - False breakout recovery - Frontend risk panel - AI prompt updates * feat(market): add Donchian channel calculation Add calculateDonchian function to compute highest high and lowest low over a specified period. This is the foundation for box (range) detection in the multi-period box indicator system for grid trading. * fix(market): handle invalid period in calculateDonchian * feat(market): add BoxData and RegimeLevel types * feat(market): add GetBoxData for multi-period box calculation Adds calculateBoxData internal function and GetBoxData public API that fetches 1h klines and computes three Donchian box levels (short/mid/long). This will be used by the grid trading system to detect market regime. * feat(store): add box and regime fields to grid models * feat(trader): add regime classification and breakout detection Implements Tasks 6-9 for grid market regime awareness: - Task 6: classifyRegimeLevel with Bollinger/ATR thresholds - Task 7: detectBoxBreakout for multi-period box breakouts - Task 8: confirmBreakout with 3-candle confirmation logic - Task 9: getBreakoutAction mapping breakout levels to actions * feat(trader): integrate box breakout detection into grid cycle - Task 10: Add checkBoxBreakout with 3-candle confirmation - Task 11: Add checkFalseBreakoutRecovery for 50% position recovery - Task 12: Add box/breakout/regime fields to GridState * feat: add grid risk panel with API endpoint - Task 13: Add GridRiskInfo type to frontend - Task 14: Add /traders/:id/grid-risk API endpoint - Task 15: Add GetGridRiskInfo method to AutoTrader - Task 16: Create GridRiskPanel component with i18n * feat(kernel): add box indicators to AI prompt - Add BoxData field to GridContext - Add box indicator table to both zh/en prompts - Show breakout/warning alerts based on price position * feat(web): integrate GridRiskPanel into TraderDashboardPage * feat(lighter): improve API key validation and market caching - Add API key validation status tracking - Add market list caching to reduce API calls - Improve logging (debug vs info levels) - Add comprehensive integration tests - Update trader manager and store for lighter support * fix: remove hardcoded test wallet address * fix(grid): improve GridRiskPanel layout and fix liquidation data - Make panel collapsible with summary badges when collapsed - Use compact 2-column grid layout for detailed info - Fix auth token key (token -> auth_token) - Only calculate liquidation distance when position exists * fix(grid): add isRunning checks to prevent trades after Stop() is called
2265 lines
76 KiB
Go
2265 lines
76 KiB
Go
package trader
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"nofx/kernel"
|
||
"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 *kernel.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
|
||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||
}
|
||
|
||
// 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 := kernel.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.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Hyperliquid order sync if using Hyperliquid exchange
|
||
if at.exchange == "hyperliquid" {
|
||
if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil {
|
||
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Bybit order sync if using Bybit exchange
|
||
if at.exchange == "bybit" {
|
||
if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil {
|
||
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start OKX order sync if using OKX exchange
|
||
if at.exchange == "okx" {
|
||
if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil {
|
||
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Bitget order sync if using Bitget exchange
|
||
if at.exchange == "bitget" {
|
||
if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil {
|
||
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Aster order sync if using Aster exchange
|
||
if at.exchange == "aster" {
|
||
if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil {
|
||
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
// Start Binance order sync if using Binance exchange
|
||
if at.exchange == "binance" {
|
||
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
|
||
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
|
||
}
|
||
}
|
||
|
||
ticker := time.NewTicker(at.config.ScanInterval)
|
||
defer ticker.Stop()
|
||
|
||
// Check if this is a grid trading strategy
|
||
isGridStrategy := at.IsGridStrategy()
|
||
if isGridStrategy {
|
||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||
if err := at.InitializeGrid(); err != nil {
|
||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||
return fmt.Errorf("grid initialization failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Execute immediately on first run
|
||
if isGridStrategy {
|
||
if err := at.RunGridCycle(); err != nil {
|
||
logger.Infof("❌ Grid execution failed: %v", err)
|
||
}
|
||
} else {
|
||
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 isGridStrategy {
|
||
if err := at.RunGridCycle(); err != nil {
|
||
logger.Infof("❌ Grid execution failed: %v", err)
|
||
}
|
||
} else {
|
||
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 := kernel.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(kernel.Decisions))
|
||
// for i, d := range kernel.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().UTC(),
|
||
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() (*kernel.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
|
||
totalEquity := 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
|
||
}
|
||
|
||
// Use totalEquity directly if provided by trader (more accurate)
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
totalEquity = eq
|
||
} else {
|
||
// Fallback: 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 []kernel.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 > 0 {
|
||
updateTime = dbPos.EntryTime
|
||
}
|
||
}
|
||
}
|
||
// 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, kernel.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 := &kernel.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: kernel.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, kernel.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,
|
||
})
|
||
}
|
||
}
|
||
// Get trading statistics for AI context
|
||
stats, err := at.store.Position().GetFullStats(at.id)
|
||
if err != nil {
|
||
logger.Infof("⚠️ [%s] Failed to get trading stats: %v", at.name, err)
|
||
} else if stats == nil {
|
||
logger.Infof("⚠️ [%s] GetFullStats returned nil", at.name)
|
||
} else if stats.TotalTrades == 0 {
|
||
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
||
} else {
|
||
ctx.TradingStats = &kernel.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,
|
||
}
|
||
logger.Infof("📈 [%s] Trading stats: %d trades, %.1f%% win rate, PF=%.2f, Sharpe=%.2f, DD=%.1f%%",
|
||
at.name, stats.TotalTrades, stats.WinRate, stats.ProfitFactor, stats.SharpeRatio, stats.MaxDrawdownPct)
|
||
}
|
||
} 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 {
|
||
// 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))
|
||
}
|
||
}
|
||
|
||
// 10. Get NetFlow ranking data (market-wide fund flow)
|
||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||
logger.Infof("💰 [%s] Fetching NetFlow ranking data...", at.name)
|
||
ctx.NetFlowRankingData = at.strategyEngine.FetchNetFlowRankingData()
|
||
if ctx.NetFlowRankingData != nil {
|
||
logger.Infof("💰 [%s] NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||
at.name, len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||
}
|
||
}
|
||
|
||
// 11. Get Price ranking data (market-wide gainers/losers)
|
||
if strategyConfig.Indicators.EnablePriceRanking {
|
||
logger.Infof("📈 [%s] Fetching Price ranking data...", at.name)
|
||
ctx.PriceRankingData = at.strategyEngine.FetchPriceRankingData()
|
||
if ctx.PriceRankingData != nil {
|
||
logger.Infof("📈 [%s] Price ranking data ready for %d durations",
|
||
at.name, len(ctx.PriceRankingData.Durations))
|
||
}
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|
||
|
||
// executeDecisionWithRecord executes AI decision and records detailed information
|
||
func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.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 *kernel.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 *kernel.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 *kernel.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 *kernel.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
|
||
|
||
// Normalize symbol for database lookup
|
||
normalizedSymbol := market.Normalize(decision.Symbol)
|
||
|
||
// Get entry price and quantity - prioritize local database for accurate quantity
|
||
var entryPrice float64
|
||
var quantity float64
|
||
|
||
// First try to get from local database (more accurate for quantity)
|
||
if at.store != nil {
|
||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil {
|
||
quantity = openPos.Quantity
|
||
entryPrice = openPos.EntryPrice
|
||
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
}
|
||
|
||
// Fallback to exchange API if local data not found
|
||
if quantity == 0 {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
|
||
// 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 *kernel.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
|
||
|
||
// Normalize symbol for database lookup
|
||
normalizedSymbol := market.Normalize(decision.Symbol)
|
||
|
||
// Get entry price and quantity - prioritize local database for accurate quantity
|
||
var entryPrice float64
|
||
var quantity float64
|
||
|
||
// First try to get from local database (more accurate for quantity)
|
||
if at.store != nil {
|
||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil {
|
||
quantity = openPos.Quantity
|
||
entryPrice = openPos.EntryPrice
|
||
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
}
|
||
|
||
// Fallback to exchange API if local data not found
|
||
if quantity == 0 {
|
||
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
|
||
}
|
||
}
|
||
}
|
||
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||
// This is used by grid trading and other components that need direct exchange access
|
||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||
return at.trader
|
||
}
|
||
|
||
// 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 *kernel.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()
|
||
|
||
result := 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,
|
||
}
|
||
|
||
// Add strategy info
|
||
if at.config.StrategyConfig != nil {
|
||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||
if at.config.StrategyConfig.GridConfig != nil {
|
||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// 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
|
||
totalEquity := 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
|
||
}
|
||
|
||
// Use totalEquity directly if provided by trader (more accurate)
|
||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||
totalEquity = eq
|
||
} else {
|
||
// Fallback: 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 []kernel.Decision) []kernel.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([]kernel.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"
|
||
}
|
||
|
||
var actualPrice = price
|
||
var actualQty = quantity
|
||
var fee float64
|
||
|
||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||
switch at.exchange {
|
||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||
return
|
||
}
|
||
|
||
// For exchanges without OrderSync (e.g., Binance): record immediately and poll for fill data
|
||
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)
|
||
}
|
||
|
||
// Normalize symbol for position record consistency
|
||
normalizedSymbolForPosition := market.Normalize(symbol)
|
||
|
||
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 (use normalized symbol)
|
||
at.recordPositionChange(orderID, normalizedSymbolForPosition, 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
|
||
nowMs := time.Now().UTC().UnixMilli()
|
||
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: nowMs,
|
||
Leverage: leverage,
|
||
Status: "OPEN",
|
||
CreatedAt: nowMs,
|
||
UpdatedAt: nowMs,
|
||
}
|
||
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 using PositionBuilder for consistent handling
|
||
// PositionBuilder will handle both cases:
|
||
// 1. If open position exists: close it properly
|
||
// 2. If no open position (e.g., table cleared): create a closed position record
|
||
posBuilder := store.NewPositionBuilder(at.store.Position())
|
||
if err := posBuilder.ProcessTrade(
|
||
at.id, at.exchangeID, at.exchange,
|
||
symbol, side, action,
|
||
quantity, price, fee, 0, // realizedPnL will be calculated
|
||
time.Now().UTC().UnixMilli(), orderID,
|
||
); err != nil {
|
||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||
} else {
|
||
logger.Infof(" ✅ Position closed [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.exchangeID,
|
||
ExchangeType: 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().UTC().UnixMilli(),
|
||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
}
|
||
|
||
// 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.exchangeID,
|
||
ExchangeType: 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().UTC().UnixMilli(),
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
}
|
||
|
||
// GetOpenOrders returns open orders (pending SL/TP) from exchange
|
||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||
return at.trader.GetOpenOrders(symbol)
|
||
}
|
||
|