Files
nofx/trader/auto_trader_grid.go
T
Lance 7ae5bf8247 release: merge dev into main (2026-04-17) (#1484)
* feat(store): prevent deletion of active strategies and update translations (#1461)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* fix: allow model switching without re-entering wallet key

Users with existing wallets could not switch AI models because the
"Start Trading" button required a valid private key even when one was
already configured. Now the button is enabled when hasExistingWallet
is true, and handleSubmit passes an empty key so the backend preserves
the existing key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace window.location with useNavigate for routing in auth components (#1470)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* feat(trader): implement margin mode handling for order and leverage settings

* refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging

* feat(api): enhance strategy handling by integrating claw402 wallet key validation

Added validation for the claw402 model's wallet key during strategy test runs. If the selected AI model is claw402, the server now checks for a valid wallet key and returns appropriate error messages if it's missing or if the model fails to load. This ensures better error handling and user feedback when working with AI models.

* refactor(api): streamline claw402 wallet key retrieval and error handling

Refactored the strategy handling logic to encapsulate claw402 wallet key retrieval in a new method, `resolveStrategyDataWalletKey`. This improves code readability and maintains consistent error handling for missing or invalid wallet keys during strategy test runs. The changes enhance the overall robustness of the AI model integration.

* feat(trader): add claw402 wallet key resolution for trader configuration

Implemented a new method, `resolveTraderDataWalletKey`, to retrieve the claw402 wallet key based on the selected AI model and user ID. This enhancement allows for better integration of the claw402 model within the trader configuration, ensuring that the correct wallet key is used for trading operations. The `AutoTraderConfig` struct has been updated to include the new `Claw402WalletKey` field, improving the overall handling of wallet keys in the trading process.

* feat(claw402): preflight USDC balance before AI calls (#1479)

* chore: ignore nofx-server build artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(claw402): preflight USDC balance before AI calls

Short-circuit claw402 Call/CallWithRequestFull when the wallet balance
can't cover the estimated cost of the call, surfacing ErrInsufficientFunds
instead of letting x402 fail mid-flight after the sign step.

- wallet: cached balance lookup (30s TTL, per-address mutex) to avoid
  hammering the Base RPC; separate error-returning and display-only APIs
  so callers can distinguish zero balance from an unreachable RPC.
- claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0×
  for reasoner models whose chain-of-thought cost can blow past the
  flat rate. Fail-open on RPC errors — x402 still gates actually-empty
  wallets, and we prefer availability over extra strictness.
- shortAddr redacts the wallet in error strings to avoid leaking the
  full address into telemetry bundles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telemetry): report token usage for SSE streaming paths (#1475)

* fix(telemetry): report token usage for SSE streaming paths

ParseSSEStream already parsed the usage block from SSE chunks but only
printed it, so claw402 streaming calls (and native streaming) never
fired TokenUsageCallback. GA4 therefore undercounted AI usage on the
streaming path.

Return the parsed usage from ParseSSEStream and have both callers fire
the callback with their own Provider/Model.

* chore: drop leftover debug Printf in ParseSSEStream

Telemetry is now wired via TokenUsageCallback, so the Printf is
redundant noise in the stream path.

* fix(gemini): update default model to gemini-3.1-pro

Google discontinued gemini-3-pro-preview on 2026-03-26 and directs all
callers to gemini-3.1-pro / gemini-3.1-pro-preview. Users on their own
API key were getting errors from the native Gemini endpoint because the
provider default pointed at the retired ID. Claw402 was unaffected
because its route map already used gemini-3.1-pro.

Align both the native provider default and the handler's preset list
with gemini-3.1-pro so every code path sends a live model ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract ResolveClaw402WalletKey to store layer and expand OKX margin mode tests

- Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey
- api/strategy.go and manager/trader_manager.go now delegate to the shared method
- Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach
  and why the legacy /api/v5/account/set-isolated-mode endpoint is not called
- Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode

* fix(auth): prevent SetupPage remount from wiping freshly-set auth token (#1481)

After #1470 moved routing into react-router, SetupPage is rendered at two
different tree positions (top-level guard + /setup Route). When register
success flushSync-sets `user`, the top-level guard stops matching and the
Route-level SetupPage mounts as a new instance, re-running its cleanup
useEffect and removing the auth_token that handlePostAuthSuccess just wrote.
Subsequent requests 401 and bounce the user back to /login.

Redirect /setup to /welcome when user is already set so SetupPage is never
re-mounted during the auth transition.

* fix(wallet): handle JSON-RPC null error field in balance query

Some RPC implementations return explicit "error": null on success.
json.RawMessage deserializes this as the 4-byte literal "null", so
len() > 0 was true, causing every balance query to fail with
"rpc error: null". Skip the null literal to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: deanokk <wuhao@vergex.trade>
Co-authored-by: Dean <afei.wuhao@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: root <root@localhost.localdomain>
2026-04-17 19:13:35 +08:00

651 lines
19 KiB
Go

package trader
import (
"encoding/json"
"fmt"
"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
// Grid direction adjustment
CurrentDirection market.GridDirection
DirectionChangedAt time.Time
DirectionChangeCount int
}
// 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),
CurrentDirection: market.GridDirectionNeutral,
}
}
// ============================================================================
// Breakout Detection (price vs grid boundary)
// ============================================================================
// 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
}
// ============================================================================
// AutoTrader Grid Lifecycle
// ============================================================================
// 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
// Keep grid orders aligned with the trader's configured cross/isolated mode.
if err := at.trader.SetMarginMode(gridConfig.Symbol, at.config.IsCrossMargin); err != nil {
logger.Warnf("[Grid] Failed to set margin mode for %s: %v", gridConfig.Symbol, err)
} else {
marginMode := "cross"
if !at.config.IsCrossMargin {
marginMode = "isolated"
}
logger.Infof("[Grid] Margin mode set to %s for %s", marginMode, gridConfig.Symbol)
}
// 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
}
// 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
}
}
// 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
}
// 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)
}
}
// 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"`
// Grid direction
CurrentGridDirection string `json:"current_grid_direction"`
DirectionChangeCount int `json:"direction_change_count"`
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
}