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
This commit is contained in:
tinkle-community
2026-01-17 22:02:45 +08:00
parent 826276f58c
commit 2b1012b85b
4 changed files with 630 additions and 3 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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<GridRiskInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
// 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 (
<div className="p-4 text-center" style={{ color: '#848E9C' }}>
{t('loading')}
</div>
)
}
if (error) {
return (
<div className="p-4 text-center" style={{ color: '#F6465D' }}>
{t('error')}: {error}
</div>
)
}
if (!riskInfo) {
return (
<div className="p-4 text-center" style={{ color: '#848E9C' }}>
{t('noData')}
</div>
)
}
return (
<div className="space-y-4">
{/* Leverage Info */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
{t('leverageInfo')}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-xs" style={labelStyle}>
{t('currentLeverage')}
</div>
<div className="text-lg font-mono" style={valueStyle}>
{riskInfo.current_leverage}x
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('effectiveLeverage')}
</div>
<div className="text-lg font-mono" style={{ color: '#F0B90B' }}>
{riskInfo.effective_leverage.toFixed(2)}x
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('recommendedLeverage')}
</div>
<div
className="text-lg font-mono"
style={{
color:
riskInfo.current_leverage > riskInfo.recommended_leverage
? '#F6465D'
: '#0ECB81',
}}
>
{riskInfo.recommended_leverage}x
</div>
</div>
</div>
</div>
{/* Position Info */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center gap-2 mb-3">
<Activity className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
{t('positionInfo')}
</span>
</div>
<div className="grid grid-cols-3 gap-4 mb-3">
<div>
<div className="text-xs" style={labelStyle}>
{t('currentPosition')}
</div>
<div className="text-lg font-mono" style={valueStyle}>
{formatUSD(riskInfo.current_position)}
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('maxPosition')}
</div>
<div className="text-lg font-mono" style={valueStyle}>
{formatUSD(riskInfo.max_position)}
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('positionPercent')}
</div>
<div
className="text-lg font-mono"
style={{ color: getPositionColor(riskInfo.position_percent) }}
>
{riskInfo.position_percent.toFixed(1)}%
</div>
</div>
</div>
{/* Position Progress Bar */}
<div className="h-2 rounded-full overflow-hidden" style={{ background: '#1E2329' }}>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.min(riskInfo.position_percent, 100)}%`,
background: getPositionColor(riskInfo.position_percent),
}}
/>
</div>
</div>
{/* Liquidation Info */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="w-4 h-4" style={{ color: '#F6465D' }} />
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
{t('liquidationInfo')}
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs" style={labelStyle}>
{t('liquidationPrice')}
</div>
<div className="text-lg font-mono" style={{ color: '#F6465D' }}>
{riskInfo.liquidation_price > 0 ? formatPrice(riskInfo.liquidation_price) : '-'}
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('liquidationDistance')}
</div>
<div className="text-lg font-mono" style={{ color: '#F6465D' }}>
{riskInfo.liquidation_distance.toFixed(1)}%
</div>
</div>
</div>
</div>
{/* Market State */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center gap-2 mb-3">
<Shield className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
{t('marketState')}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<div className="text-xs" style={labelStyle}>
{t('regimeLevel')}
</div>
<div
className="text-lg font-medium"
style={{ color: getRegimeColor(riskInfo.regime_level) }}
>
{t(riskInfo.regime_level || 'standard')}
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('currentPrice')}
</div>
<div className="text-lg font-mono" style={valueStyle}>
{formatPrice(riskInfo.current_price)}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs" style={labelStyle}>
{t('breakoutLevel')}
</div>
<div
className="text-lg font-medium"
style={{ color: getBreakoutColor(riskInfo.breakout_level) }}
>
{t(riskInfo.breakout_level || 'none')}
</div>
</div>
<div>
<div className="text-xs" style={labelStyle}>
{t('breakoutDirection')}
</div>
<div
className="text-lg font-medium"
style={{
color: riskInfo.breakout_direction === 'up' ? '#0ECB81' : riskInfo.breakout_direction === 'down' ? '#F6465D' : '#848E9C',
}}
>
{riskInfo.breakout_direction ? t(riskInfo.breakout_direction) : '-'}
</div>
</div>
</div>
</div>
{/* Box State */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center gap-2 mb-3">
<Box className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
{t('boxState')}
</span>
</div>
<div className="space-y-3">
{/* Short Box */}
<div className="flex items-center justify-between">
<span className="text-xs" style={labelStyle}>
{t('shortBox')}
</span>
<span className="text-sm font-mono" style={valueStyle}>
{formatPrice(riskInfo.short_box_lower)} - {formatPrice(riskInfo.short_box_upper)}
</span>
</div>
{/* Mid Box */}
<div className="flex items-center justify-between">
<span className="text-xs" style={labelStyle}>
{t('midBox')}
</span>
<span className="text-sm font-mono" style={valueStyle}>
{formatPrice(riskInfo.mid_box_lower)} - {formatPrice(riskInfo.mid_box_upper)}
</span>
</div>
{/* Long Box */}
<div className="flex items-center justify-between">
<span className="text-xs" style={labelStyle}>
{t('longBox')}
</span>
<span className="text-sm font-mono" style={valueStyle}>
{formatPrice(riskInfo.long_box_lower)} - {formatPrice(riskInfo.long_box_upper)}
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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
}