mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
7e96c5d0f2
* 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
1580 lines
47 KiB
Go
1580 lines
47 KiB
Go
package trader
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"nofx/kernel"
|
||
"nofx/logger"
|
||
"nofx/market"
|
||
"nofx/store"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ============================================================================
|
||
// Grid Trading State Management
|
||
// ============================================================================
|
||
|
||
// GridState holds the runtime state for grid trading
|
||
type GridState struct {
|
||
mu sync.RWMutex
|
||
|
||
// Configuration
|
||
Config *store.GridStrategyConfig
|
||
|
||
// Grid levels
|
||
Levels []kernel.GridLevelInfo
|
||
|
||
// Calculated bounds
|
||
UpperPrice float64
|
||
LowerPrice float64
|
||
GridSpacing float64
|
||
|
||
// State flags
|
||
IsPaused bool
|
||
IsInitialized bool
|
||
|
||
// Performance tracking
|
||
TotalProfit float64
|
||
TotalTrades int
|
||
WinningTrades int
|
||
MaxDrawdown float64
|
||
PeakEquity float64
|
||
DailyPnL float64
|
||
LastDailyReset time.Time
|
||
|
||
// Order tracking
|
||
OrderBook map[string]int // OrderID -> LevelIndex
|
||
|
||
// Box state
|
||
ShortBoxUpper float64
|
||
ShortBoxLower float64
|
||
MidBoxUpper float64
|
||
MidBoxLower float64
|
||
LongBoxUpper float64
|
||
LongBoxLower float64
|
||
|
||
// Breakout state
|
||
BreakoutLevel string
|
||
BreakoutDirection string
|
||
BreakoutConfirmCount int
|
||
|
||
// Position reduction (0 = normal, 50 = reduced after false breakout)
|
||
PositionReductionPct float64
|
||
|
||
// Current regime level
|
||
CurrentRegimeLevel string
|
||
}
|
||
|
||
// NewGridState creates a new grid state
|
||
func NewGridState(config *store.GridStrategyConfig) *GridState {
|
||
return &GridState{
|
||
Config: config,
|
||
Levels: make([]kernel.GridLevelInfo, 0),
|
||
OrderBook: make(map[string]int),
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Breakout Detection
|
||
// ============================================================================
|
||
|
||
// BreakoutType represents the type of price breakout
|
||
type BreakoutType string
|
||
|
||
const (
|
||
BreakoutNone BreakoutType = "none"
|
||
BreakoutUpper BreakoutType = "upper"
|
||
BreakoutLower BreakoutType = "lower"
|
||
)
|
||
|
||
// checkBreakout detects if price has broken out of grid range
|
||
// Returns breakout type and percentage beyond boundary
|
||
func (at *AutoTrader) checkBreakout() (BreakoutType, float64) {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
if err != nil {
|
||
return BreakoutNone, 0
|
||
}
|
||
|
||
at.gridState.mu.RLock()
|
||
upper := at.gridState.UpperPrice
|
||
lower := at.gridState.LowerPrice
|
||
at.gridState.mu.RUnlock()
|
||
|
||
if upper <= 0 || lower <= 0 {
|
||
return BreakoutNone, 0
|
||
}
|
||
|
||
// Check upper breakout
|
||
if currentPrice > upper {
|
||
breakoutPct := (currentPrice - upper) / upper * 100
|
||
return BreakoutUpper, breakoutPct
|
||
}
|
||
|
||
// Check lower breakout
|
||
if currentPrice < lower {
|
||
breakoutPct := (lower - currentPrice) / lower * 100
|
||
return BreakoutLower, breakoutPct
|
||
}
|
||
|
||
return BreakoutNone, 0
|
||
}
|
||
|
||
// checkMaxDrawdown checks if current drawdown exceeds maximum allowed
|
||
// Returns: (exceeded bool, currentDrawdown float64)
|
||
func (at *AutoTrader) checkMaxDrawdown() (bool, float64) {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig.MaxDrawdownPct <= 0 {
|
||
return false, 0
|
||
}
|
||
|
||
// Get current equity
|
||
balance, err := at.trader.GetBalance()
|
||
if err != nil {
|
||
return false, 0
|
||
}
|
||
|
||
currentEquity := 0.0
|
||
if equity, ok := balance["total_equity"].(float64); ok {
|
||
currentEquity = equity
|
||
} else if total, ok := balance["totalWalletBalance"].(float64); ok {
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
currentEquity = total + unrealized
|
||
}
|
||
}
|
||
|
||
if currentEquity <= 0 {
|
||
return false, 0
|
||
}
|
||
|
||
// Update peak equity
|
||
at.gridState.mu.Lock()
|
||
if currentEquity > at.gridState.PeakEquity {
|
||
at.gridState.PeakEquity = currentEquity
|
||
}
|
||
peakEquity := at.gridState.PeakEquity
|
||
at.gridState.mu.Unlock()
|
||
|
||
if peakEquity <= 0 {
|
||
return false, 0
|
||
}
|
||
|
||
// Calculate current drawdown
|
||
drawdown := (peakEquity - currentEquity) / peakEquity * 100
|
||
|
||
// Update max drawdown tracking
|
||
at.gridState.mu.Lock()
|
||
if drawdown > at.gridState.MaxDrawdown {
|
||
at.gridState.MaxDrawdown = drawdown
|
||
}
|
||
at.gridState.mu.Unlock()
|
||
|
||
return drawdown >= gridConfig.MaxDrawdownPct, drawdown
|
||
}
|
||
|
||
// checkDailyLossLimit checks if daily loss exceeds limit
|
||
// Returns: (exceeded bool, dailyLossPct float64)
|
||
func (at *AutoTrader) checkDailyLossLimit() (bool, float64) {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig.DailyLossLimitPct <= 0 {
|
||
return false, 0
|
||
}
|
||
|
||
at.gridState.mu.Lock()
|
||
// Reset daily PnL if new day
|
||
now := time.Now()
|
||
if now.YearDay() != at.gridState.LastDailyReset.YearDay() ||
|
||
now.Year() != at.gridState.LastDailyReset.Year() {
|
||
at.gridState.DailyPnL = 0
|
||
at.gridState.LastDailyReset = now
|
||
}
|
||
dailyPnL := at.gridState.DailyPnL
|
||
at.gridState.mu.Unlock()
|
||
|
||
// Calculate daily loss as percentage of total investment
|
||
dailyLossPct := 0.0
|
||
if gridConfig.TotalInvestment > 0 && dailyPnL < 0 {
|
||
dailyLossPct = (-dailyPnL) / gridConfig.TotalInvestment * 100
|
||
}
|
||
|
||
return dailyLossPct >= gridConfig.DailyLossLimitPct, dailyLossPct
|
||
}
|
||
|
||
// updateDailyPnL updates the daily PnL tracking
|
||
func (at *AutoTrader) updateDailyPnL(realizedPnL float64) {
|
||
at.gridState.mu.Lock()
|
||
at.gridState.DailyPnL += realizedPnL
|
||
at.gridState.TotalProfit += realizedPnL
|
||
at.gridState.mu.Unlock()
|
||
}
|
||
|
||
// emergencyExit closes all positions and cancels all orders
|
||
func (at *AutoTrader) emergencyExit(reason string) error {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
logger.Errorf("[Grid] EMERGENCY EXIT: %s", reason)
|
||
|
||
// Cancel all orders
|
||
if err := at.cancelAllGridOrders(); err != nil {
|
||
logger.Errorf("[Grid] Failed to cancel orders in emergency: %v", err)
|
||
}
|
||
|
||
// Close all positions
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
|
||
if size, ok := pos["positionAmt"].(float64); ok && size != 0 {
|
||
if size > 0 {
|
||
at.trader.CloseLong(gridConfig.Symbol, size)
|
||
} else {
|
||
at.trader.CloseShort(gridConfig.Symbol, -size)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pause grid
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
|
||
return nil
|
||
}
|
||
|
||
// handleBreakout handles price breakout from grid range
|
||
func (at *AutoTrader) handleBreakout(breakoutType BreakoutType, breakoutPct float64) error {
|
||
logger.Warnf("[Grid] BREAKOUT DETECTED: %s, %.2f%% beyond boundary", breakoutType, breakoutPct)
|
||
|
||
// If breakout exceeds 2%, pause grid and cancel orders
|
||
if breakoutPct >= 2.0 {
|
||
logger.Warnf("[Grid] Significant breakout (%.2f%%), pausing grid and canceling orders", breakoutPct)
|
||
|
||
// Cancel all pending orders to prevent further losses
|
||
if err := at.cancelAllGridOrders(); err != nil {
|
||
logger.Errorf("[Grid] Failed to cancel orders on breakout: %v", err)
|
||
}
|
||
|
||
// Pause grid trading
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
|
||
return fmt.Errorf("grid paused due to %s breakout (%.2f%%)", breakoutType, breakoutPct)
|
||
}
|
||
|
||
// If breakout is minor (< 2%), consider adjusting grid
|
||
if breakoutPct >= 1.0 {
|
||
logger.Infof("[Grid] Minor breakout (%.2f%%), considering grid adjustment", breakoutPct)
|
||
// Let AI decide whether to adjust
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// checkBoxBreakout checks for multi-period box breakouts and takes appropriate action
|
||
func (at *AutoTrader) checkBoxBreakout() error {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
// Get box data
|
||
box, err := market.GetBoxData(gridConfig.Symbol)
|
||
if err != nil {
|
||
logger.Infof("Failed to get box data: %v", err)
|
||
return nil // Non-fatal, continue with other checks
|
||
}
|
||
|
||
// Update grid state with box values
|
||
at.gridState.mu.Lock()
|
||
at.gridState.ShortBoxUpper = box.ShortUpper
|
||
at.gridState.ShortBoxLower = box.ShortLower
|
||
at.gridState.MidBoxUpper = box.MidUpper
|
||
at.gridState.MidBoxLower = box.MidLower
|
||
at.gridState.LongBoxUpper = box.LongUpper
|
||
at.gridState.LongBoxLower = box.LongLower
|
||
at.gridState.mu.Unlock()
|
||
|
||
// Detect breakout
|
||
breakoutLevel, direction := detectBoxBreakout(box)
|
||
|
||
// Get current breakout state
|
||
state := &BreakoutState{
|
||
Level: market.BreakoutLevel(at.gridState.BreakoutLevel),
|
||
Direction: at.gridState.BreakoutDirection,
|
||
ConfirmCount: at.gridState.BreakoutConfirmCount,
|
||
}
|
||
|
||
// Check if breakout is confirmed (3 candles)
|
||
confirmed := confirmBreakout(state, breakoutLevel, direction)
|
||
|
||
// Update grid state
|
||
at.gridState.mu.Lock()
|
||
at.gridState.BreakoutLevel = string(state.Level)
|
||
at.gridState.BreakoutDirection = state.Direction
|
||
at.gridState.BreakoutConfirmCount = state.ConfirmCount
|
||
at.gridState.mu.Unlock()
|
||
|
||
if !confirmed {
|
||
return nil
|
||
}
|
||
|
||
// Take action based on breakout level
|
||
action := getBreakoutAction(breakoutLevel)
|
||
return at.executeBreakoutAction(action)
|
||
}
|
||
|
||
// executeBreakoutAction executes the appropriate action for a breakout
|
||
func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
|
||
switch action {
|
||
case BreakoutActionReducePosition:
|
||
// Short box breakout: reduce position to 50%
|
||
logger.Infof("Short box breakout confirmed, reducing position to 50%%")
|
||
at.gridState.mu.Lock()
|
||
at.gridState.PositionReductionPct = 50
|
||
at.gridState.mu.Unlock()
|
||
return nil
|
||
|
||
case BreakoutActionPauseGrid:
|
||
// Mid box breakout: pause grid + cancel orders
|
||
logger.Infof("Mid box breakout confirmed, pausing grid and canceling orders")
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
return at.cancelAllGridOrders()
|
||
|
||
case BreakoutActionCloseAll:
|
||
// Long box breakout: pause + cancel + close all
|
||
logger.Infof("Long box breakout confirmed, closing all positions")
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
if err := at.cancelAllGridOrders(); err != nil {
|
||
logger.Infof("Failed to cancel orders: %v", err)
|
||
}
|
||
return at.closeAllPositions()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// closeAllPositions closes all open positions for the grid symbol
|
||
func (at *AutoTrader) closeAllPositions() error {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get positions: %w", err)
|
||
}
|
||
|
||
for _, pos := range positions {
|
||
symbol, _ := pos["symbol"].(string)
|
||
if symbol != gridConfig.Symbol {
|
||
continue
|
||
}
|
||
|
||
size, _ := pos["positionAmt"].(float64)
|
||
if size == 0 {
|
||
continue
|
||
}
|
||
|
||
if size > 0 {
|
||
_, err = at.trader.CloseLong(symbol, size)
|
||
} else {
|
||
_, err = at.trader.CloseShort(symbol, -size)
|
||
}
|
||
if err != nil {
|
||
logger.Infof("Failed to close position: %v", err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// checkFalseBreakoutRecovery checks if price has returned to box after breakout
|
||
func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
at.gridState.mu.RLock()
|
||
breakoutLevel := at.gridState.BreakoutLevel
|
||
isPaused := at.gridState.IsPaused
|
||
positionReduction := at.gridState.PositionReductionPct
|
||
at.gridState.mu.RUnlock()
|
||
|
||
// Only check if we had a breakout
|
||
if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
|
||
return nil
|
||
}
|
||
|
||
// Get current box data
|
||
box, err := market.GetBoxData(gridConfig.Symbol)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
// Check if price is back inside the long box
|
||
if box.CurrentPrice >= box.LongLower && box.CurrentPrice <= box.LongUpper {
|
||
logger.Infof("Price returned to box, recovering with 50%% position")
|
||
|
||
at.gridState.mu.Lock()
|
||
at.gridState.BreakoutLevel = string(market.BreakoutNone)
|
||
at.gridState.BreakoutDirection = ""
|
||
at.gridState.BreakoutConfirmCount = 0
|
||
at.gridState.PositionReductionPct = 50 // Recover at 50%
|
||
at.gridState.IsPaused = false
|
||
at.gridState.mu.Unlock()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// AutoTrader Grid Methods
|
||
// ============================================================================
|
||
|
||
// InitializeGrid initializes the grid state and calculates levels
|
||
func (at *AutoTrader) InitializeGrid() error {
|
||
if at.config.StrategyConfig == nil || at.config.StrategyConfig.GridConfig == nil {
|
||
return fmt.Errorf("grid configuration not found")
|
||
}
|
||
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
at.gridState = NewGridState(gridConfig)
|
||
|
||
// Get current market price
|
||
price, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get market price: %w", err)
|
||
}
|
||
|
||
// Calculate grid bounds
|
||
if gridConfig.UseATRBounds {
|
||
// Get ATR for bound calculation
|
||
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"4h"}, "4h", 20)
|
||
if err != nil {
|
||
logger.Warnf("Failed to get market data for ATR: %v, using default bounds", err)
|
||
at.calculateDefaultBounds(price, gridConfig)
|
||
} else {
|
||
at.calculateATRBounds(price, mktData, gridConfig)
|
||
}
|
||
} else {
|
||
// Use manual bounds
|
||
at.gridState.UpperPrice = gridConfig.UpperPrice
|
||
at.gridState.LowerPrice = gridConfig.LowerPrice
|
||
}
|
||
|
||
// Calculate grid spacing
|
||
at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1)
|
||
|
||
// Initialize grid levels
|
||
at.initializeGridLevels(price, gridConfig)
|
||
|
||
at.gridState.IsInitialized = true
|
||
|
||
// CRITICAL: Set leverage on exchange before trading
|
||
if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil {
|
||
logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err)
|
||
// Not fatal - continue with default leverage
|
||
} else {
|
||
logger.Infof("[Grid] Leverage set to %dx for %s", gridConfig.Leverage, gridConfig.Symbol)
|
||
}
|
||
|
||
logger.Infof("📊 [Grid] Initialized: %d levels, $%.2f - $%.2f, spacing $%.2f",
|
||
gridConfig.GridCount, at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing)
|
||
|
||
return nil
|
||
}
|
||
|
||
// calculateDefaultBounds calculates default bounds based on price
|
||
func (at *AutoTrader) calculateDefaultBounds(price float64, config *store.GridStrategyConfig) {
|
||
// Default: ±3% from current price
|
||
multiplier := 0.03 * float64(config.GridCount) / 10
|
||
at.gridState.UpperPrice = price * (1 + multiplier)
|
||
at.gridState.LowerPrice = price * (1 - multiplier)
|
||
}
|
||
|
||
// calculateATRBounds calculates bounds using ATR
|
||
func (at *AutoTrader) calculateATRBounds(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
|
||
atr := 0.0
|
||
if mktData.LongerTermContext != nil {
|
||
atr = mktData.LongerTermContext.ATR14
|
||
}
|
||
|
||
if atr <= 0 {
|
||
at.calculateDefaultBounds(price, config)
|
||
return
|
||
}
|
||
|
||
multiplier := config.ATRMultiplier
|
||
if multiplier <= 0 {
|
||
multiplier = 2.0
|
||
}
|
||
|
||
halfRange := atr * multiplier
|
||
at.gridState.UpperPrice = price + halfRange
|
||
at.gridState.LowerPrice = price - halfRange
|
||
}
|
||
|
||
// initializeGridLevels creates the grid level structure
|
||
func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.GridStrategyConfig) {
|
||
levels := make([]kernel.GridLevelInfo, config.GridCount)
|
||
totalWeight := 0.0
|
||
weights := make([]float64, config.GridCount)
|
||
|
||
// Calculate weights based on distribution
|
||
for i := 0; i < config.GridCount; i++ {
|
||
switch config.Distribution {
|
||
case "gaussian":
|
||
// Gaussian distribution - more weight in the middle
|
||
center := float64(config.GridCount-1) / 2
|
||
sigma := float64(config.GridCount) / 4
|
||
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
|
||
case "pyramid":
|
||
// Pyramid - more weight at bottom
|
||
weights[i] = float64(config.GridCount - i)
|
||
default: // uniform
|
||
weights[i] = 1.0
|
||
}
|
||
totalWeight += weights[i]
|
||
}
|
||
|
||
// Create levels
|
||
for i := 0; i < config.GridCount; i++ {
|
||
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
|
||
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
|
||
|
||
// Determine initial side (below current price = buy, above = sell)
|
||
side := "buy"
|
||
if price > currentPrice {
|
||
side = "sell"
|
||
}
|
||
|
||
levels[i] = kernel.GridLevelInfo{
|
||
Index: i,
|
||
Price: price,
|
||
State: "empty",
|
||
Side: side,
|
||
AllocatedUSD: allocatedUSD,
|
||
}
|
||
}
|
||
|
||
at.gridState.Levels = levels
|
||
}
|
||
|
||
// RunGridCycle executes one grid trading cycle
|
||
func (at *AutoTrader) RunGridCycle() error {
|
||
// 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("[Grid] Trader is stopped, aborting grid cycle")
|
||
return nil
|
||
}
|
||
|
||
if at.gridState == nil || !at.gridState.IsInitialized {
|
||
if err := at.InitializeGrid(); err != nil {
|
||
return fmt.Errorf("failed to initialize grid: %w", err)
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Check for breakout before executing any trades
|
||
breakoutType, breakoutPct := at.checkBreakout()
|
||
if breakoutType != BreakoutNone {
|
||
if err := at.handleBreakout(breakoutType, breakoutPct); err != nil {
|
||
return err // Grid paused due to breakout
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Check max drawdown
|
||
exceeded, drawdown := at.checkMaxDrawdown()
|
||
if exceeded {
|
||
return at.emergencyExit(fmt.Sprintf("max drawdown exceeded: %.2f%%", drawdown))
|
||
}
|
||
|
||
// CRITICAL: Check daily loss limit
|
||
dailyExceeded, dailyLossPct := at.checkDailyLossLimit()
|
||
if dailyExceeded {
|
||
logger.Errorf("[Grid] Daily loss limit exceeded: %.2f%%", dailyLossPct)
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
return fmt.Errorf("daily loss limit exceeded: %.2f%%", dailyLossPct)
|
||
}
|
||
|
||
// Check multi-period box breakout
|
||
if err := at.checkBoxBreakout(); err != nil {
|
||
logger.Infof("Box breakout check error: %v", err)
|
||
}
|
||
|
||
// Check for false breakout recovery
|
||
if err := at.checkFalseBreakoutRecovery(); err != nil {
|
||
logger.Infof("False breakout recovery check error: %v", err)
|
||
}
|
||
|
||
// Check if grid is paused
|
||
at.gridState.mu.RLock()
|
||
isPaused := at.gridState.IsPaused
|
||
at.gridState.mu.RUnlock()
|
||
if isPaused {
|
||
logger.Infof("[Grid] Grid is paused, skipping cycle")
|
||
return nil
|
||
}
|
||
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
lang := at.config.StrategyConfig.Language
|
||
if lang == "" {
|
||
lang = "en"
|
||
}
|
||
|
||
// Build grid context
|
||
gridCtx, err := at.buildGridContext()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to build grid context: %w", err)
|
||
}
|
||
|
||
// Get AI decisions
|
||
decision, err := kernel.GetGridDecisions(gridCtx, at.mcpClient, gridConfig, lang)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get grid decisions: %w", err)
|
||
}
|
||
|
||
// 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("[Grid] Trader stopped before decision execution, aborting grid cycle")
|
||
return nil
|
||
}
|
||
|
||
// Execute decisions
|
||
for _, d := range decision.Decisions {
|
||
// Check if trader is still running before each decision
|
||
at.isRunningMutex.RLock()
|
||
running := at.isRunning
|
||
at.isRunningMutex.RUnlock()
|
||
if !running {
|
||
logger.Infof("[Grid] Trader stopped, skipping remaining %d decisions", len(decision.Decisions))
|
||
break
|
||
}
|
||
|
||
if err := at.executeGridDecision(&d); err != nil {
|
||
logger.Warnf("[Grid] Failed to execute decision %s: %v", d.Action, err)
|
||
}
|
||
}
|
||
|
||
// Sync state with exchange
|
||
at.syncGridState()
|
||
|
||
// Save decision record
|
||
at.saveGridDecisionRecord(decision)
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildGridContext builds the context for AI grid decisions
|
||
func (at *AutoTrader) buildGridContext() (*kernel.GridContext, error) {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// Get market data
|
||
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"5m", "4h"}, "5m", 50)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get market data: %w", err)
|
||
}
|
||
|
||
// Build base context from market data
|
||
ctx := kernel.BuildGridContextFromMarketData(mktData, gridConfig)
|
||
|
||
// Add grid state
|
||
at.gridState.mu.RLock()
|
||
ctx.Levels = at.gridState.Levels
|
||
ctx.UpperPrice = at.gridState.UpperPrice
|
||
ctx.LowerPrice = at.gridState.LowerPrice
|
||
ctx.GridSpacing = at.gridState.GridSpacing
|
||
ctx.IsPaused = at.gridState.IsPaused
|
||
ctx.TotalProfit = at.gridState.TotalProfit
|
||
ctx.TotalTrades = at.gridState.TotalTrades
|
||
ctx.WinningTrades = at.gridState.WinningTrades
|
||
ctx.MaxDrawdown = at.gridState.MaxDrawdown
|
||
ctx.DailyPnL = at.gridState.DailyPnL
|
||
|
||
// Count active orders and filled levels
|
||
for _, level := range at.gridState.Levels {
|
||
if level.State == "pending" {
|
||
ctx.ActiveOrderCount++
|
||
} else if level.State == "filled" {
|
||
ctx.FilledLevelCount++
|
||
}
|
||
}
|
||
at.gridState.mu.RUnlock()
|
||
|
||
// Get account info
|
||
balance, err := at.trader.GetBalance()
|
||
if err == nil {
|
||
if equity, ok := balance["total_equity"].(float64); ok {
|
||
ctx.TotalEquity = equity
|
||
}
|
||
if available, ok := balance["availableBalance"].(float64); ok {
|
||
ctx.AvailableBalance = available
|
||
}
|
||
if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok {
|
||
ctx.UnrealizedPnL = unrealized
|
||
}
|
||
}
|
||
|
||
// Get current position
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
|
||
if size, ok := pos["positionAmt"].(float64); ok {
|
||
ctx.CurrentPosition = size
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ctx, nil
|
||
}
|
||
|
||
// executeGridDecision executes a single grid decision
|
||
func (at *AutoTrader) executeGridDecision(d *kernel.Decision) error {
|
||
switch d.Action {
|
||
case "place_buy_limit":
|
||
return at.placeGridLimitOrder(d, "BUY")
|
||
case "place_sell_limit":
|
||
return at.placeGridLimitOrder(d, "SELL")
|
||
case "cancel_order":
|
||
return at.cancelGridOrder(d)
|
||
case "cancel_all_orders":
|
||
return at.cancelAllGridOrders()
|
||
case "pause_grid":
|
||
return at.pauseGrid(d.Reasoning)
|
||
case "resume_grid":
|
||
return at.resumeGrid()
|
||
case "adjust_grid":
|
||
return at.adjustGrid(d)
|
||
case "hold":
|
||
logger.Infof("[Grid] Holding current state: %s", d.Reasoning)
|
||
return nil
|
||
// Support standard actions for closing positions
|
||
case "close_long":
|
||
_, err := at.trader.CloseLong(d.Symbol, d.Quantity)
|
||
return err
|
||
case "close_short":
|
||
_, err := at.trader.CloseShort(d.Symbol, d.Quantity)
|
||
return err
|
||
default:
|
||
logger.Warnf("[Grid] Unknown action: %s", d.Action)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// checkTotalPositionLimit checks if adding a new position would exceed total limits
|
||
// Returns: (allowed bool, currentPositionValue float64, maxAllowed float64)
|
||
func (at *AutoTrader) checkTotalPositionLimit(symbol string, additionalValue float64) (bool, float64, float64) {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// Calculate max allowed total position value
|
||
// Total position should not exceed: TotalInvestment × Leverage
|
||
maxTotalPositionValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage)
|
||
|
||
// Get current position value from exchange
|
||
currentPositionValue := 0.0
|
||
positions, err := at.trader.GetPositions()
|
||
if err == nil {
|
||
for _, pos := range positions {
|
||
if sym, ok := pos["symbol"].(string); ok && sym == symbol {
|
||
if size, ok := pos["positionAmt"].(float64); ok {
|
||
if price, ok := pos["markPrice"].(float64); ok {
|
||
currentPositionValue = math.Abs(size) * price
|
||
} else if entryPrice, ok := pos["entryPrice"].(float64); ok {
|
||
currentPositionValue = math.Abs(size) * entryPrice
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Also count pending orders as potential position
|
||
at.gridState.mu.RLock()
|
||
pendingValue := 0.0
|
||
for _, level := range at.gridState.Levels {
|
||
if level.State == "pending" {
|
||
pendingValue += level.OrderQuantity * level.Price
|
||
}
|
||
}
|
||
at.gridState.mu.RUnlock()
|
||
|
||
totalAfterOrder := currentPositionValue + pendingValue + additionalValue
|
||
allowed := totalAfterOrder <= maxTotalPositionValue
|
||
|
||
return allowed, currentPositionValue + pendingValue, maxTotalPositionValue
|
||
}
|
||
|
||
// placeGridLimitOrder places a limit order for grid trading
|
||
func (at *AutoTrader) placeGridLimitOrder(d *kernel.Decision, side string) error {
|
||
// Check if trader supports GridTrader interface
|
||
gridTrader, ok := at.trader.(GridTrader)
|
||
if !ok {
|
||
// Fallback to adapter
|
||
gridTrader = NewGridTraderAdapter(at.trader)
|
||
}
|
||
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// CRITICAL: Validate and cap quantity to prevent excessive position sizes
|
||
// This protects against AI miscalculations or leverage misconfigurations
|
||
quantity := d.Quantity
|
||
if d.Price > 0 && gridConfig.TotalInvestment > 0 {
|
||
// Calculate max allowed position value per grid level
|
||
// Each level gets proportional share of total investment
|
||
maxMarginPerLevel := gridConfig.TotalInvestment / float64(gridConfig.GridCount)
|
||
maxPositionValuePerLevel := maxMarginPerLevel * float64(gridConfig.Leverage)
|
||
maxQuantityPerLevel := maxPositionValuePerLevel / d.Price
|
||
|
||
// Also get the level's allocated USD for additional validation
|
||
at.gridState.mu.RLock()
|
||
var levelAllocatedUSD float64
|
||
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
|
||
levelAllocatedUSD = at.gridState.Levels[d.LevelIndex].AllocatedUSD
|
||
}
|
||
at.gridState.mu.RUnlock()
|
||
|
||
// Use level-specific allocation if available
|
||
if levelAllocatedUSD > 0 {
|
||
levelMaxPositionValue := levelAllocatedUSD * float64(gridConfig.Leverage)
|
||
levelMaxQuantity := levelMaxPositionValue / d.Price
|
||
if levelMaxQuantity < maxQuantityPerLevel {
|
||
maxQuantityPerLevel = levelMaxQuantity
|
||
}
|
||
}
|
||
|
||
// Cap quantity if it exceeds the maximum allowed
|
||
if quantity > maxQuantityPerLevel {
|
||
logger.Warnf("[Grid] ⚠️ Quantity %.4f exceeds max allowed %.4f (position_value $%.2f > max $%.2f), capping",
|
||
quantity, maxQuantityPerLevel, quantity*d.Price, maxPositionValuePerLevel)
|
||
quantity = maxQuantityPerLevel
|
||
}
|
||
|
||
// Safety check: ensure position value is reasonable (within 2x of intended max as absolute limit)
|
||
positionValue := quantity * d.Price
|
||
absoluteMaxValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage) * 2 // 2x safety margin
|
||
if positionValue > absoluteMaxValue {
|
||
logger.Errorf("[Grid] CRITICAL: Position value $%.2f exceeds absolute max $%.2f! Rejecting order.",
|
||
positionValue, absoluteMaxValue)
|
||
return fmt.Errorf("position value $%.2f exceeds safety limit $%.2f", positionValue, absoluteMaxValue)
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Check total position limit before placing order
|
||
orderValue := quantity * d.Price
|
||
allowed, currentValue, maxValue := at.checkTotalPositionLimit(d.Symbol, orderValue)
|
||
if !allowed {
|
||
logger.Errorf("[Grid] TOTAL POSITION LIMIT EXCEEDED: current=$%.2f + order=$%.2f > max=$%.2f. Rejecting order.",
|
||
currentValue, orderValue, maxValue)
|
||
return fmt.Errorf("total position value $%.2f would exceed limit $%.2f", currentValue+orderValue, maxValue)
|
||
}
|
||
|
||
req := &LimitOrderRequest{
|
||
Symbol: d.Symbol,
|
||
Side: side,
|
||
Price: d.Price,
|
||
Quantity: quantity, // Use validated/capped quantity
|
||
Leverage: gridConfig.Leverage,
|
||
PostOnly: gridConfig.UseMakerOnly,
|
||
ReduceOnly: false,
|
||
ClientID: fmt.Sprintf("grid-%d-%d", d.LevelIndex, time.Now().UnixNano()%1000000),
|
||
}
|
||
|
||
result, err := gridTrader.PlaceLimitOrder(req)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to place limit order: %w", err)
|
||
}
|
||
|
||
// Update grid level state
|
||
at.gridState.mu.Lock()
|
||
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
|
||
at.gridState.Levels[d.LevelIndex].State = "pending"
|
||
at.gridState.Levels[d.LevelIndex].OrderID = result.OrderID
|
||
at.gridState.Levels[d.LevelIndex].OrderQuantity = d.Quantity
|
||
at.gridState.OrderBook[result.OrderID] = d.LevelIndex
|
||
}
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Infof("[Grid] Placed %s limit order at $%.2f, qty=%.4f, level=%d, orderID=%s",
|
||
side, d.Price, d.Quantity, d.LevelIndex, result.OrderID)
|
||
|
||
return nil
|
||
}
|
||
|
||
// cancelGridOrder cancels a specific grid order
|
||
func (at *AutoTrader) cancelGridOrder(d *kernel.Decision) error {
|
||
gridTrader, ok := at.trader.(GridTrader)
|
||
if !ok {
|
||
gridTrader = NewGridTraderAdapter(at.trader)
|
||
}
|
||
|
||
if err := gridTrader.CancelOrder(d.Symbol, d.OrderID); err != nil {
|
||
return fmt.Errorf("failed to cancel order: %w", err)
|
||
}
|
||
|
||
// Update state
|
||
at.gridState.mu.Lock()
|
||
if levelIdx, ok := at.gridState.OrderBook[d.OrderID]; ok {
|
||
if levelIdx >= 0 && levelIdx < len(at.gridState.Levels) {
|
||
at.gridState.Levels[levelIdx].State = "empty"
|
||
at.gridState.Levels[levelIdx].OrderID = ""
|
||
at.gridState.Levels[levelIdx].OrderQuantity = 0
|
||
}
|
||
delete(at.gridState.OrderBook, d.OrderID)
|
||
}
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Infof("[Grid] Cancelled order: %s", d.OrderID)
|
||
return nil
|
||
}
|
||
|
||
// cancelAllGridOrders cancels all grid orders
|
||
func (at *AutoTrader) cancelAllGridOrders() error {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
if err := at.trader.CancelAllOrders(gridConfig.Symbol); err != nil {
|
||
return fmt.Errorf("failed to cancel all orders: %w", err)
|
||
}
|
||
|
||
// Reset all pending levels
|
||
at.gridState.mu.Lock()
|
||
for i := range at.gridState.Levels {
|
||
if at.gridState.Levels[i].State == "pending" {
|
||
at.gridState.Levels[i].State = "empty"
|
||
at.gridState.Levels[i].OrderID = ""
|
||
at.gridState.Levels[i].OrderQuantity = 0
|
||
}
|
||
}
|
||
at.gridState.OrderBook = make(map[string]int)
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Infof("[Grid] Cancelled all orders")
|
||
return nil
|
||
}
|
||
|
||
// pauseGrid pauses grid trading
|
||
func (at *AutoTrader) pauseGrid(reason string) error {
|
||
at.cancelAllGridOrders()
|
||
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = true
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Infof("[Grid] Paused: %s", reason)
|
||
return nil
|
||
}
|
||
|
||
// resumeGrid resumes grid trading
|
||
func (at *AutoTrader) resumeGrid() error {
|
||
at.gridState.mu.Lock()
|
||
at.gridState.IsPaused = false
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Infof("[Grid] Resumed")
|
||
return nil
|
||
}
|
||
|
||
// adjustGrid adjusts grid parameters
|
||
func (at *AutoTrader) adjustGrid(d *kernel.Decision) error {
|
||
// Cancel existing orders first
|
||
at.cancelAllGridOrders()
|
||
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// Get current price
|
||
price, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get market price: %w", err)
|
||
}
|
||
|
||
// Reinitialize grid levels
|
||
at.initializeGridLevels(price, gridConfig)
|
||
|
||
logger.Infof("[Grid] Adjusted grid bounds around price $%.2f", price)
|
||
return nil
|
||
}
|
||
|
||
// syncGridState syncs grid state with exchange
|
||
func (at *AutoTrader) syncGridState() {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// Get open orders from exchange
|
||
openOrders, err := at.trader.GetOpenOrders(gridConfig.Symbol)
|
||
if err != nil {
|
||
logger.Warnf("[Grid] Failed to get open orders: %v", err)
|
||
return
|
||
}
|
||
|
||
// Build set of active order IDs
|
||
activeOrderIDs := make(map[string]bool)
|
||
for _, order := range openOrders {
|
||
activeOrderIDs[order.OrderID] = true
|
||
}
|
||
|
||
// Get current positions to verify fills
|
||
positions, err := at.trader.GetPositions()
|
||
currentPositionSize := 0.0
|
||
if err != nil {
|
||
logger.Warnf("[Grid] Failed to get positions for state sync: %v", err)
|
||
} else {
|
||
for _, pos := range positions {
|
||
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
|
||
if size, ok := pos["positionAmt"].(float64); ok {
|
||
currentPositionSize = size
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update levels based on order status
|
||
at.gridState.mu.Lock()
|
||
expectedPositionSize := 0.0
|
||
for _, level := range at.gridState.Levels {
|
||
if level.State == "filled" {
|
||
expectedPositionSize += level.PositionSize
|
||
}
|
||
}
|
||
|
||
for i := range at.gridState.Levels {
|
||
level := &at.gridState.Levels[i]
|
||
if level.State == "pending" && level.OrderID != "" {
|
||
if !activeOrderIDs[level.OrderID] {
|
||
// Order no longer exists - check if position changed to determine fill vs cancel
|
||
// This is a heuristic - ideally we'd query order history
|
||
// If current position is larger than expected filled positions, this order was likely filled
|
||
if math.Abs(currentPositionSize) > math.Abs(expectedPositionSize) {
|
||
// Position increased, likely filled
|
||
level.State = "filled"
|
||
level.PositionEntry = level.Price
|
||
level.PositionSize = level.OrderQuantity
|
||
at.gridState.TotalTrades++
|
||
logger.Infof("[Grid] Level %d order filled at $%.2f", i, level.Price)
|
||
} else {
|
||
// Position didn't increase as expected, likely cancelled
|
||
level.State = "empty"
|
||
level.OrderID = ""
|
||
level.OrderQuantity = 0
|
||
logger.Infof("[Grid] Level %d order cancelled/expired", i)
|
||
}
|
||
delete(at.gridState.OrderBook, level.OrderID)
|
||
}
|
||
}
|
||
}
|
||
at.gridState.mu.Unlock()
|
||
|
||
logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", currentPositionSize, len(openOrders))
|
||
|
||
// Check stop loss
|
||
at.checkAndExecuteStopLoss()
|
||
|
||
// Check grid skew
|
||
at.autoAdjustGrid()
|
||
}
|
||
|
||
// saveGridDecisionRecord saves the grid decision to database
|
||
func (at *AutoTrader) saveGridDecisionRecord(decision *kernel.FullDecision) {
|
||
if at.store == nil {
|
||
return
|
||
}
|
||
|
||
at.cycleNumber++
|
||
|
||
record := &store.DecisionRecord{
|
||
TraderID: at.id,
|
||
CycleNumber: at.cycleNumber,
|
||
Timestamp: time.Now().UTC(),
|
||
SystemPrompt: decision.SystemPrompt,
|
||
InputPrompt: decision.UserPrompt,
|
||
CoTTrace: decision.CoTTrace,
|
||
RawResponse: decision.RawResponse,
|
||
AIRequestDurationMs: decision.AIRequestDurationMs,
|
||
Success: true,
|
||
}
|
||
|
||
if len(decision.Decisions) > 0 {
|
||
decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ")
|
||
record.DecisionJSON = string(decisionJSON)
|
||
|
||
// Convert kernel.Decision to store.DecisionAction for frontend display
|
||
for _, d := range decision.Decisions {
|
||
actionRecord := store.DecisionAction{
|
||
Action: d.Action,
|
||
Symbol: d.Symbol,
|
||
Quantity: d.Quantity,
|
||
Leverage: d.Leverage,
|
||
Price: d.Price,
|
||
StopLoss: d.StopLoss,
|
||
TakeProfit: d.TakeProfit,
|
||
Confidence: d.Confidence,
|
||
Reasoning: d.Reasoning,
|
||
Timestamp: time.Now().UTC(),
|
||
Success: true, // Grid decisions are executed inline
|
||
}
|
||
record.Decisions = append(record.Decisions, actionRecord)
|
||
}
|
||
}
|
||
|
||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("Grid cycle completed with %d decisions", len(decision.Decisions)))
|
||
|
||
if err := at.store.Decision().LogDecision(record); err != nil {
|
||
logger.Warnf("[Grid] Failed to save decision record: %v", err)
|
||
}
|
||
}
|
||
|
||
// IsGridStrategy returns true if current strategy is grid trading
|
||
func (at *AutoTrader) IsGridStrategy() bool {
|
||
if at.config.StrategyConfig == nil {
|
||
return false
|
||
}
|
||
return at.config.StrategyConfig.StrategyType == "grid_trading" && at.config.StrategyConfig.GridConfig != nil
|
||
}
|
||
|
||
// checkGridSkew checks if grid is heavily skewed (too many fills on one side)
|
||
// Returns: (skewed bool, buyFilledCount int, sellFilledCount int)
|
||
func (at *AutoTrader) checkGridSkew() (bool, int, int) {
|
||
at.gridState.mu.RLock()
|
||
defer at.gridState.mu.RUnlock()
|
||
|
||
buyFilled := 0
|
||
sellFilled := 0
|
||
buyEmpty := 0
|
||
sellEmpty := 0
|
||
|
||
for _, level := range at.gridState.Levels {
|
||
if level.Side == "buy" {
|
||
if level.State == "filled" {
|
||
buyFilled++
|
||
} else if level.State == "empty" {
|
||
buyEmpty++
|
||
}
|
||
} else {
|
||
if level.State == "filled" {
|
||
sellFilled++
|
||
} else if level.State == "empty" {
|
||
sellEmpty++
|
||
}
|
||
}
|
||
}
|
||
|
||
// Grid is skewed if one side has 3x more fills than the other
|
||
// or if one side is completely empty
|
||
skewed := false
|
||
if buyFilled > 0 && sellFilled == 0 && sellEmpty > 5 {
|
||
skewed = true // All buys filled, no sells
|
||
} else if sellFilled > 0 && buyFilled == 0 && buyEmpty > 5 {
|
||
skewed = true // All sells filled, no buys
|
||
} else if buyFilled >= 3*sellFilled && buyFilled > 5 {
|
||
skewed = true
|
||
} else if sellFilled >= 3*buyFilled && sellFilled > 5 {
|
||
skewed = true
|
||
}
|
||
|
||
return skewed, buyFilled, sellFilled
|
||
}
|
||
|
||
// autoAdjustGrid automatically adjusts grid when heavily skewed
|
||
func (at *AutoTrader) autoAdjustGrid() {
|
||
skewed, buyFilled, sellFilled := at.checkGridSkew()
|
||
if !skewed {
|
||
return
|
||
}
|
||
|
||
logger.Warnf("[Grid] Grid heavily skewed: buy_filled=%d, sell_filled=%d. Auto-adjusting...",
|
||
buyFilled, sellFilled)
|
||
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
|
||
// Get current price
|
||
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
if err != nil {
|
||
logger.Errorf("[Grid] Failed to get price for auto-adjust: %v", err)
|
||
return
|
||
}
|
||
|
||
// Check if price is near grid boundary
|
||
at.gridState.mu.RLock()
|
||
upper := at.gridState.UpperPrice
|
||
lower := at.gridState.LowerPrice
|
||
at.gridState.mu.RUnlock()
|
||
|
||
// Only adjust if price has moved significantly (>30% of grid range)
|
||
gridRange := upper - lower
|
||
midPrice := (upper + lower) / 2
|
||
priceDeviation := math.Abs(currentPrice - midPrice)
|
||
|
||
if priceDeviation < gridRange*0.3 {
|
||
return // Price still near center, don't adjust
|
||
}
|
||
|
||
logger.Infof("[Grid] Adjusting grid around new price $%.2f", currentPrice)
|
||
|
||
// Cancel existing orders first (before taking the lock for state modification)
|
||
if err := at.cancelAllGridOrders(); err != nil {
|
||
logger.Errorf("[Grid] Failed to cancel orders during auto-adjust: %v", err)
|
||
// Continue with adjustment anyway
|
||
}
|
||
|
||
// CRITICAL FIX: Hold lock for the entire adjustment operation to ensure atomicity
|
||
at.gridState.mu.Lock()
|
||
defer at.gridState.mu.Unlock()
|
||
|
||
// Preserve filled positions before reinitializing
|
||
filledPositions := make(map[int]kernel.GridLevelInfo)
|
||
for i, level := range at.gridState.Levels {
|
||
if level.State == "filled" {
|
||
filledPositions[i] = level
|
||
}
|
||
}
|
||
|
||
// CRITICAL FIX: Recalculate grid bounds centered on current price
|
||
// Use the same logic as InitializeGrid() - either ATR-based or default percentage
|
||
if gridConfig.UseATRBounds {
|
||
// Try to get ATR for bound calculation
|
||
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"4h"}, "4h", 20)
|
||
if err != nil {
|
||
logger.Warnf("[Grid] Failed to get market data for ATR during adjust: %v, using default bounds", err)
|
||
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
|
||
} else {
|
||
at.calculateATRBoundsLocked(currentPrice, mktData, gridConfig)
|
||
}
|
||
} else {
|
||
// Use default bounds calculation (scaled by grid count)
|
||
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
|
||
}
|
||
|
||
// Recalculate grid spacing based on new bounds
|
||
at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1)
|
||
|
||
logger.Infof("[Grid] New bounds: $%.2f - $%.2f, spacing: $%.2f",
|
||
at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing)
|
||
|
||
// Initialize new grid levels (without lock since we already hold it)
|
||
at.initializeGridLevelsLocked(currentPrice, gridConfig)
|
||
|
||
// CRITICAL FIX: Restore filled positions - find closest new level for each filled position
|
||
for _, filledLevel := range filledPositions {
|
||
closestIdx := -1
|
||
closestDist := math.MaxFloat64
|
||
|
||
for i, newLevel := range at.gridState.Levels {
|
||
dist := math.Abs(newLevel.Price - filledLevel.PositionEntry)
|
||
if dist < closestDist {
|
||
closestDist = dist
|
||
closestIdx = i
|
||
}
|
||
}
|
||
|
||
if closestIdx >= 0 {
|
||
// Restore the filled state to the closest level
|
||
at.gridState.Levels[closestIdx].State = "filled"
|
||
at.gridState.Levels[closestIdx].PositionEntry = filledLevel.PositionEntry
|
||
at.gridState.Levels[closestIdx].PositionSize = filledLevel.PositionSize
|
||
at.gridState.Levels[closestIdx].UnrealizedPnL = filledLevel.UnrealizedPnL
|
||
at.gridState.Levels[closestIdx].OrderID = filledLevel.OrderID
|
||
at.gridState.Levels[closestIdx].OrderQuantity = filledLevel.OrderQuantity
|
||
logger.Infof("[Grid] Restored filled position at level %d (entry $%.2f)", closestIdx, filledLevel.PositionEntry)
|
||
}
|
||
}
|
||
}
|
||
|
||
// calculateDefaultBoundsLocked calculates default bounds (caller must hold lock)
|
||
func (at *AutoTrader) calculateDefaultBoundsLocked(price float64, config *store.GridStrategyConfig) {
|
||
// Default: ±3% from current price, scaled by grid count
|
||
multiplier := 0.03 * float64(config.GridCount) / 10
|
||
at.gridState.UpperPrice = price * (1 + multiplier)
|
||
at.gridState.LowerPrice = price * (1 - multiplier)
|
||
}
|
||
|
||
// calculateATRBoundsLocked calculates bounds using ATR (caller must hold lock)
|
||
func (at *AutoTrader) calculateATRBoundsLocked(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
|
||
atr := 0.0
|
||
if mktData.LongerTermContext != nil {
|
||
atr = mktData.LongerTermContext.ATR14
|
||
}
|
||
|
||
if atr <= 0 {
|
||
at.calculateDefaultBoundsLocked(price, config)
|
||
return
|
||
}
|
||
|
||
multiplier := config.ATRMultiplier
|
||
if multiplier <= 0 {
|
||
multiplier = 2.0
|
||
}
|
||
|
||
halfRange := atr * multiplier
|
||
at.gridState.UpperPrice = price + halfRange
|
||
at.gridState.LowerPrice = price - halfRange
|
||
}
|
||
|
||
// initializeGridLevelsLocked creates the grid level structure (caller must hold lock)
|
||
func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *store.GridStrategyConfig) {
|
||
levels := make([]kernel.GridLevelInfo, config.GridCount)
|
||
totalWeight := 0.0
|
||
weights := make([]float64, config.GridCount)
|
||
|
||
// Calculate weights based on distribution
|
||
for i := 0; i < config.GridCount; i++ {
|
||
switch config.Distribution {
|
||
case "gaussian":
|
||
// Gaussian distribution - more weight in the middle
|
||
center := float64(config.GridCount-1) / 2
|
||
sigma := float64(config.GridCount) / 4
|
||
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
|
||
case "pyramid":
|
||
// Pyramid - more weight at bottom
|
||
weights[i] = float64(config.GridCount - i)
|
||
default: // uniform
|
||
weights[i] = 1.0
|
||
}
|
||
totalWeight += weights[i]
|
||
}
|
||
|
||
// Create levels
|
||
for i := 0; i < config.GridCount; i++ {
|
||
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
|
||
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
|
||
|
||
// Determine initial side (below current price = buy, above = sell)
|
||
side := "buy"
|
||
if price > currentPrice {
|
||
side = "sell"
|
||
}
|
||
|
||
levels[i] = kernel.GridLevelInfo{
|
||
Index: i,
|
||
Price: price,
|
||
State: "empty",
|
||
Side: side,
|
||
AllocatedUSD: allocatedUSD,
|
||
}
|
||
}
|
||
|
||
at.gridState.Levels = levels
|
||
}
|
||
|
||
// GridRiskInfo contains risk information for frontend display
|
||
type GridRiskInfo struct {
|
||
CurrentLeverage int `json:"current_leverage"`
|
||
EffectiveLeverage float64 `json:"effective_leverage"`
|
||
RecommendedLeverage int `json:"recommended_leverage"`
|
||
|
||
CurrentPosition float64 `json:"current_position"`
|
||
MaxPosition float64 `json:"max_position"`
|
||
PositionPercent float64 `json:"position_percent"`
|
||
|
||
LiquidationPrice float64 `json:"liquidation_price"`
|
||
LiquidationDistance float64 `json:"liquidation_distance"`
|
||
|
||
RegimeLevel string `json:"regime_level"`
|
||
|
||
ShortBoxUpper float64 `json:"short_box_upper"`
|
||
ShortBoxLower float64 `json:"short_box_lower"`
|
||
MidBoxUpper float64 `json:"mid_box_upper"`
|
||
MidBoxLower float64 `json:"mid_box_lower"`
|
||
LongBoxUpper float64 `json:"long_box_upper"`
|
||
LongBoxLower float64 `json:"long_box_lower"`
|
||
CurrentPrice float64 `json:"current_price"`
|
||
|
||
BreakoutLevel string `json:"breakout_level"`
|
||
BreakoutDirection string `json:"breakout_direction"`
|
||
}
|
||
|
||
// GetGridRiskInfo returns current risk information for frontend display
|
||
func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig == nil {
|
||
return &GridRiskInfo{}
|
||
}
|
||
|
||
at.gridState.mu.RLock()
|
||
defer at.gridState.mu.RUnlock()
|
||
|
||
// Get current price
|
||
currentPrice, _ := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
|
||
// Calculate effective leverage
|
||
totalInvestment := gridConfig.TotalInvestment
|
||
leverage := gridConfig.Leverage
|
||
|
||
// Get current position value
|
||
positions, _ := at.trader.GetPositions()
|
||
var currentPositionValue float64
|
||
var currentPositionSize float64
|
||
for _, pos := range positions {
|
||
if sym, _ := pos["symbol"].(string); sym == gridConfig.Symbol {
|
||
size, _ := pos["positionAmt"].(float64)
|
||
entry, _ := pos["entryPrice"].(float64)
|
||
currentPositionValue = math.Abs(size * entry)
|
||
currentPositionSize = size
|
||
break
|
||
}
|
||
}
|
||
|
||
effectiveLeverage := 0.0
|
||
if totalInvestment > 0 {
|
||
effectiveLeverage = currentPositionValue / totalInvestment
|
||
}
|
||
|
||
// Calculate max position based on regime
|
||
regimeLevel := market.RegimeLevel(at.gridState.CurrentRegimeLevel)
|
||
if regimeLevel == "" {
|
||
regimeLevel = market.RegimeLevelStandard
|
||
}
|
||
|
||
// Use default position limit since GridStrategyConfig doesn't have regime-specific limits
|
||
// Default is 70% for standard regime
|
||
maxPositionPct := 70.0
|
||
switch regimeLevel {
|
||
case market.RegimeLevelNarrow:
|
||
maxPositionPct = 40.0
|
||
case market.RegimeLevelStandard:
|
||
maxPositionPct = 70.0
|
||
case market.RegimeLevelWide:
|
||
maxPositionPct = 60.0
|
||
case market.RegimeLevelVolatile:
|
||
maxPositionPct = 40.0
|
||
}
|
||
|
||
maxPosition := totalInvestment * maxPositionPct / 100 * float64(leverage)
|
||
|
||
// Use default leverage limits since GridStrategyConfig doesn't have regime-specific limits
|
||
recommendedLeverage := leverage
|
||
switch regimeLevel {
|
||
case market.RegimeLevelNarrow:
|
||
recommendedLeverage = min(leverage, 2)
|
||
case market.RegimeLevelStandard:
|
||
recommendedLeverage = min(leverage, 4)
|
||
case market.RegimeLevelWide:
|
||
recommendedLeverage = min(leverage, 3)
|
||
case market.RegimeLevelVolatile:
|
||
recommendedLeverage = min(leverage, 2)
|
||
}
|
||
|
||
// Calculate liquidation distance and price only when there's a position
|
||
var liquidationDistance float64
|
||
var liquidationPrice float64
|
||
if currentPositionSize != 0 && currentPrice > 0 {
|
||
liquidationDistance = 100.0 / float64(leverage) * 0.9 // ~90% of theoretical max
|
||
if currentPositionSize > 0 {
|
||
// Long position: liquidation below entry
|
||
liquidationPrice = currentPrice * (1 - liquidationDistance/100)
|
||
} else {
|
||
// Short position: liquidation above entry
|
||
liquidationPrice = currentPrice * (1 + liquidationDistance/100)
|
||
}
|
||
}
|
||
|
||
positionPercent := 0.0
|
||
if maxPosition > 0 {
|
||
positionPercent = currentPositionValue / maxPosition * 100
|
||
}
|
||
|
||
return &GridRiskInfo{
|
||
CurrentLeverage: leverage,
|
||
EffectiveLeverage: effectiveLeverage,
|
||
RecommendedLeverage: recommendedLeverage,
|
||
|
||
CurrentPosition: currentPositionValue,
|
||
MaxPosition: maxPosition,
|
||
PositionPercent: positionPercent,
|
||
|
||
LiquidationPrice: liquidationPrice,
|
||
LiquidationDistance: liquidationDistance,
|
||
|
||
RegimeLevel: string(regimeLevel),
|
||
|
||
ShortBoxUpper: at.gridState.ShortBoxUpper,
|
||
ShortBoxLower: at.gridState.ShortBoxLower,
|
||
MidBoxUpper: at.gridState.MidBoxUpper,
|
||
MidBoxLower: at.gridState.MidBoxLower,
|
||
LongBoxUpper: at.gridState.LongBoxUpper,
|
||
LongBoxLower: at.gridState.LongBoxLower,
|
||
CurrentPrice: currentPrice,
|
||
|
||
BreakoutLevel: at.gridState.BreakoutLevel,
|
||
BreakoutDirection: at.gridState.BreakoutDirection,
|
||
}
|
||
}
|
||
|
||
// checkAndExecuteStopLoss checks if any filled level has exceeded stop loss and closes it
|
||
func (at *AutoTrader) checkAndExecuteStopLoss() {
|
||
gridConfig := at.config.StrategyConfig.GridConfig
|
||
if gridConfig.StopLossPct <= 0 {
|
||
return // Stop loss not configured
|
||
}
|
||
|
||
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
|
||
if err != nil {
|
||
logger.Warnf("[Grid] Failed to get market price for stop loss check: %v", err)
|
||
return
|
||
}
|
||
|
||
at.gridState.mu.Lock()
|
||
defer at.gridState.mu.Unlock()
|
||
|
||
for i := range at.gridState.Levels {
|
||
level := &at.gridState.Levels[i]
|
||
if level.State != "filled" || level.PositionEntry <= 0 {
|
||
continue
|
||
}
|
||
|
||
// Calculate loss percentage
|
||
var lossPct float64
|
||
if level.Side == "buy" {
|
||
// Long position: loss when price drops
|
||
lossPct = (level.PositionEntry - currentPrice) / level.PositionEntry * 100
|
||
} else {
|
||
// Short position: loss when price rises
|
||
lossPct = (currentPrice - level.PositionEntry) / level.PositionEntry * 100
|
||
}
|
||
|
||
// Check if stop loss triggered
|
||
if lossPct >= gridConfig.StopLossPct {
|
||
logger.Warnf("[Grid] STOP LOSS TRIGGERED: Level %d, entry=$%.2f, current=$%.2f, loss=%.2f%%",
|
||
i, level.PositionEntry, currentPrice, lossPct)
|
||
|
||
// Close the position
|
||
var closeErr error
|
||
if level.Side == "buy" {
|
||
_, closeErr = at.trader.CloseLong(gridConfig.Symbol, level.PositionSize)
|
||
} else {
|
||
_, closeErr = at.trader.CloseShort(gridConfig.Symbol, level.PositionSize)
|
||
}
|
||
|
||
if closeErr != nil {
|
||
logger.Errorf("[Grid] Failed to execute stop loss for level %d: %v", i, closeErr)
|
||
} else {
|
||
level.State = "stopped"
|
||
realizedLoss := -lossPct * level.AllocatedUSD / 100
|
||
level.UnrealizedPnL = realizedLoss
|
||
at.gridState.TotalTrades++
|
||
// Update daily PnL tracking (lock already held, update directly)
|
||
at.gridState.DailyPnL += realizedLoss
|
||
at.gridState.TotalProfit += realizedLoss
|
||
logger.Infof("[Grid] Stop loss executed: Level %d closed at $%.2f (loss %.2f%%)",
|
||
i, currentPrice, lossPct)
|
||
}
|
||
}
|
||
}
|
||
}
|