From 2b1012b85b94ab75224a4d260832ba4332311762 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 17 Jan 2026 22:02:45 +0800 Subject: [PATCH] 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 --- api/server.go | 47 +- trader/auto_trader_grid.go | 144 ++++++ web/src/components/strategy/GridRiskPanel.tsx | 409 ++++++++++++++++++ web/src/types.ts | 33 ++ 4 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 web/src/components/strategy/GridRiskPanel.tsx diff --git a/api/server.go b/api/server.go index 99eeb8c0..dfe4d7b3 100644 --- a/api/server.go +++ b/api/server.go @@ -157,6 +157,7 @@ func (s *Server) setupRoutes() { protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) protected.POST("/traders/:id/close-position", s.handleClosePosition) protected.PUT("/traders/:id/competition", s.handleToggleCompetition) + protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo) // AI model configuration protected.GET("/models", s.handleGetModelConfigs) @@ -1096,6 +1097,20 @@ func (s *Server) handleToggleCompetition(c *gin.Context) { }) } +// handleGetGridRiskInfo returns current risk information for a grid trader +func (s *Server) handleGetGridRiskInfo(c *gin.Context) { + traderID := c.Param("id") + + autoTrader, err := s.traderManager.GetTrader(traderID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"}) + return + } + + riskInfo := autoTrader.GetGridRiskInfo() + c.JSON(http.StatusOK, riskInfo) +} + // handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection) func (s *Server) handleSyncBalance(c *gin.Context) { userID := c.GetString("user_id") @@ -1369,7 +1384,7 @@ func (s *Server) handleClosePosition(c *gin.Context) { if closeErr != nil { logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr) - SafeInternalError(c, "Failed to close position", closeErr) + SafeInternalError(c, "Close position", closeErr) return } @@ -1705,8 +1720,15 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID) } - // Update each model's configuration + // Update each model's configuration and track traders that need reload + tradersToReload := make(map[string]bool) for modelID, modelData := range req.Models { + // Find traders using this AI model BEFORE updating + traders, _ := s.store.Trader().ListByAIModelID(userID, modelID) + for _, t := range traders { + tradersToReload[t.ID] = true + } + err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) if err != nil { SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err) @@ -1714,6 +1736,12 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { } } + // Remove affected traders from memory BEFORE reloading to pick up new config + for traderID := range tradersToReload { + logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID) + s.traderManager.RemoveTrader(traderID) + } + // Reload all traders for this user to make new config take effect immediately err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { @@ -1825,8 +1853,15 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID) } - // Update each exchange's configuration + // Update each exchange's configuration and track traders that need reload + tradersToReload := make(map[string]bool) for exchangeID, exchangeData := range req.Exchanges { + // Find traders using this exchange BEFORE updating + traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID) + for _, t := range traders { + tradersToReload[t.ID] = true + } + err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) if err != nil { SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err) @@ -1834,6 +1869,12 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { } } + // Remove affected traders from memory BEFORE reloading to pick up new config + for traderID := range tradersToReload { + logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID) + s.traderManager.RemoveTrader(traderID) + } + // Reload all traders for this user to make new config take effect immediately err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { diff --git a/trader/auto_trader_grid.go b/trader/auto_trader_grid.go index 95b79de0..c4b80fe0 100644 --- a/trader/auto_trader_grid.go +++ b/trader/auto_trader_grid.go @@ -1345,6 +1345,150 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s 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 + liquidationDistance := 100.0 / float64(leverage) * 0.9 // ~90% of theoretical max + + var liquidationPrice float64 + if currentPositionSize != 0 && currentPrice > 0 { + 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 diff --git a/web/src/components/strategy/GridRiskPanel.tsx b/web/src/components/strategy/GridRiskPanel.tsx new file mode 100644 index 00000000..6a07f664 --- /dev/null +++ b/web/src/components/strategy/GridRiskPanel.tsx @@ -0,0 +1,409 @@ +import { useState, useEffect, useCallback } from 'react' +import { Shield, TrendingUp, AlertTriangle, Activity, Box } from 'lucide-react' +import type { GridRiskInfo } from '../../types' + +interface GridRiskPanelProps { + traderId: string + language?: string + refreshInterval?: number // ms, default 5000 +} + +export function GridRiskPanel({ + traderId, + language = 'en', + refreshInterval = 5000, +}: GridRiskPanelProps) { + const [riskInfo, setRiskInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const t = (key: string) => { + const translations: Record> = { + // Section titles + leverageInfo: { zh: '杠杆信息', en: 'Leverage Info' }, + positionInfo: { zh: '仓位信息', en: 'Position Info' }, + liquidationInfo: { zh: '清算信息', en: 'Liquidation Info' }, + marketState: { zh: '市场状态', en: 'Market State' }, + boxState: { zh: '盒子状态', en: 'Box State' }, + + // Leverage + currentLeverage: { zh: '当前杠杆', en: 'Current Leverage' }, + effectiveLeverage: { zh: '有效杠杆', en: 'Effective Leverage' }, + recommendedLeverage: { zh: '建议杠杆', en: 'Recommended Leverage' }, + + // Position + currentPosition: { zh: '当前仓位', en: 'Current Position' }, + maxPosition: { zh: '最大仓位', en: 'Max Position' }, + positionPercent: { zh: '仓位占比', en: 'Position %' }, + + // Liquidation + liquidationPrice: { zh: '清算价格', en: 'Liquidation Price' }, + liquidationDistance: { zh: '清算距离', en: 'Liquidation Distance' }, + + // Market + regimeLevel: { zh: '波动级别', en: 'Regime Level' }, + currentPrice: { zh: '当前价格', en: 'Current Price' }, + breakoutLevel: { zh: '突破级别', en: 'Breakout Level' }, + breakoutDirection: { zh: '突破方向', en: 'Breakout Direction' }, + + // Box + shortBox: { zh: '短期盒子', en: 'Short Box' }, + midBox: { zh: '中期盒子', en: 'Mid Box' }, + longBox: { zh: '长期盒子', en: 'Long Box' }, + + // Regime levels + narrow: { zh: '窄幅震荡', en: 'Narrow' }, + standard: { zh: '标准震荡', en: 'Standard' }, + wide: { zh: '宽幅震荡', en: 'Wide' }, + volatile: { zh: '剧烈震荡', en: 'Volatile' }, + trending: { zh: '趋势', en: 'Trending' }, + + // Breakout levels + none: { zh: '无', en: 'None' }, + short: { zh: '短期', en: 'Short' }, + mid: { zh: '中期', en: 'Mid' }, + long: { zh: '长期', en: 'Long' }, + + // Directions + up: { zh: '向上', en: 'Up' }, + down: { zh: '向下', en: 'Down' }, + + // Status + loading: { zh: '加载中...', en: 'Loading...' }, + error: { zh: '加载失败', en: 'Load Failed' }, + noData: { zh: '暂无数据', en: 'No Data' }, + } + return translations[key]?.[language] || key + } + + const fetchRiskInfo = useCallback(async () => { + try { + const token = localStorage.getItem('token') + const response = await fetch(`/api/traders/${traderId}/grid-risk`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const data = await response.json() + setRiskInfo(data) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + }, [traderId]) + + useEffect(() => { + fetchRiskInfo() + const interval = setInterval(fetchRiskInfo, refreshInterval) + return () => clearInterval(interval) + }, [fetchRiskInfo, refreshInterval]) + + const getRegimeColor = (regime: string) => { + switch (regime) { + case 'narrow': + return '#0ECB81' // Green - safe + case 'standard': + return '#F0B90B' // Yellow - normal + case 'wide': + return '#F7931A' // Orange - caution + case 'volatile': + return '#F6465D' // Red - danger + case 'trending': + return '#8B5CF6' // Purple - trending + default: + return '#848E9C' // Gray + } + } + + const getBreakoutColor = (level: string) => { + switch (level) { + case 'none': + return '#0ECB81' // Green - safe + case 'short': + return '#F0B90B' // Yellow - minor + case 'mid': + return '#F7931A' // Orange - warning + case 'long': + return '#F6465D' // Red - critical + default: + return '#848E9C' + } + } + + const getPositionColor = (percent: number) => { + if (percent < 50) return '#0ECB81' // Green + if (percent < 80) return '#F0B90B' // Yellow + return '#F6465D' // Red + } + + const formatPrice = (price: number) => { + if (price === 0) return '-' + if (price >= 1000) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + if (price >= 1) return price.toFixed(4) + return price.toFixed(6) + } + + const formatUSD = (value: number) => { + return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + } + + const sectionStyle = { + background: '#0B0E11', + border: '1px solid #2B3139', + } + + const labelStyle = { color: '#848E9C' } + const valueStyle = { color: '#EAECEF' } + + if (loading) { + return ( +
+ {t('loading')} +
+ ) + } + + if (error) { + return ( +
+ {t('error')}: {error} +
+ ) + } + + if (!riskInfo) { + return ( +
+ {t('noData')} +
+ ) + } + + return ( +
+ {/* Leverage Info */} +
+
+ + + {t('leverageInfo')} + +
+
+
+
+ {t('currentLeverage')} +
+
+ {riskInfo.current_leverage}x +
+
+
+
+ {t('effectiveLeverage')} +
+
+ {riskInfo.effective_leverage.toFixed(2)}x +
+
+
+
+ {t('recommendedLeverage')} +
+
riskInfo.recommended_leverage + ? '#F6465D' + : '#0ECB81', + }} + > + {riskInfo.recommended_leverage}x +
+
+
+
+ + {/* Position Info */} +
+
+ + + {t('positionInfo')} + +
+
+
+
+ {t('currentPosition')} +
+
+ {formatUSD(riskInfo.current_position)} +
+
+
+
+ {t('maxPosition')} +
+
+ {formatUSD(riskInfo.max_position)} +
+
+
+
+ {t('positionPercent')} +
+
+ {riskInfo.position_percent.toFixed(1)}% +
+
+
+ {/* Position Progress Bar */} +
+
+
+
+ + {/* Liquidation Info */} +
+
+ + + {t('liquidationInfo')} + +
+
+
+
+ {t('liquidationPrice')} +
+
+ {riskInfo.liquidation_price > 0 ? formatPrice(riskInfo.liquidation_price) : '-'} +
+
+
+
+ {t('liquidationDistance')} +
+
+ {riskInfo.liquidation_distance.toFixed(1)}% +
+
+
+
+ + {/* Market State */} +
+
+ + + {t('marketState')} + +
+
+
+
+ {t('regimeLevel')} +
+
+ {t(riskInfo.regime_level || 'standard')} +
+
+
+
+ {t('currentPrice')} +
+
+ {formatPrice(riskInfo.current_price)} +
+
+
+
+
+
+ {t('breakoutLevel')} +
+
+ {t(riskInfo.breakout_level || 'none')} +
+
+
+
+ {t('breakoutDirection')} +
+
+ {riskInfo.breakout_direction ? t(riskInfo.breakout_direction) : '-'} +
+
+
+
+ + {/* Box State */} +
+
+ + + {t('boxState')} + +
+
+ {/* Short Box */} +
+ + {t('shortBox')} + + + {formatPrice(riskInfo.short_box_lower)} - {formatPrice(riskInfo.short_box_upper)} + +
+ {/* Mid Box */} +
+ + {t('midBox')} + + + {formatPrice(riskInfo.mid_box_lower)} - {formatPrice(riskInfo.mid_box_upper)} + +
+ {/* Long Box */} +
+ + {t('longBox')} + + + {formatPrice(riskInfo.long_box_lower)} - {formatPrice(riskInfo.long_box_upper)} + +
+
+
+
+ ) +} diff --git a/web/src/types.ts b/web/src/types.ts index f4e6dee0..d2e1d1b0 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -786,3 +786,36 @@ export interface PositionHistoryResponse { symbol_stats: SymbolStats[]; direction_stats: DirectionStats[]; } + +// Grid Risk Information for frontend display +export interface GridRiskInfo { + // Leverage info + current_leverage: number + effective_leverage: number + recommended_leverage: number + + // Position info + current_position: number + max_position: number + position_percent: number + + // Liquidation info + liquidation_price: number + liquidation_distance: number + + // Market state + regime_level: string + + // Box state + short_box_upper: number + short_box_lower: number + mid_box_upper: number + mid_box_lower: number + long_box_upper: number + long_box_lower: number + current_price: number + + // Breakout state + breakout_level: string + breakout_direction: string +}