refactor: restructure project directories for better modularity

- Delete llm/ dead code (3 files, zero references)
- Split mcp/ into sub-packages: mcp/provider/ (8 providers) and
  mcp/payment/ (4 payment clients) with registry pattern
- Export Client internal fields and ClientHooks interface for
  sub-package access
- Split api/server.go (3892 lines) into 8 domain-specific handler files
- Split trader/auto_trader.go (2296 lines) into 5 focused files
- Reorganize web/src/components/ flat files into auth/, charts/,
  trader/, common/, modals/, backtest/ subdirectories
- Update all consumer imports to use registry-based provider creation
This commit is contained in:
tinkle-community
2026-03-11 23:58:13 +08:00
parent 6a30e11ee5
commit 8e294a5eed
103 changed files with 6391 additions and 8984 deletions

263
trader/auto_trader_risk.go Normal file
View File

@@ -0,0 +1,263 @@
package trader
import (
"fmt"
"nofx/logger"
"strings"
"time"
)
// startDrawdownMonitor starts drawdown monitoring
func (at *AutoTrader) startDrawdownMonitor() {
at.monitorWg.Add(1)
go func() {
defer at.monitorWg.Done()
ticker := time.NewTicker(1 * time.Minute) // Check every minute
defer ticker.Stop()
logger.Info("📊 Started position drawdown monitoring (check every minute)")
for {
select {
case <-ticker.C:
at.checkPositionDrawdown()
case <-at.stopMonitorCh:
logger.Info("⏹ Stopped position drawdown monitoring")
return
}
}
}()
}
// checkPositionDrawdown checks position drawdown situation
func (at *AutoTrader) checkPositionDrawdown() {
// Get current positions
positions, err := at.trader.GetPositions()
if err != nil {
logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err)
return
}
for _, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity // Short position quantity is negative, convert to positive
}
// Calculate current P&L percentage
leverage := 10 // Default value
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
var currentPnLPct float64
if side == "long" {
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
} else {
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
}
// Construct unique position identifier (distinguish long/short)
posKey := symbol + "_" + side
// Get historical peak profit for this position
at.peakPnLCacheMutex.RLock()
peakPnLPct, exists := at.peakPnLCache[posKey]
at.peakPnLCacheMutex.RUnlock()
if !exists {
// If no historical peak record, use current P&L as initial value
peakPnLPct = currentPnLPct
at.UpdatePeakPnL(symbol, side, currentPnLPct)
} else {
// Update peak cache
at.UpdatePeakPnL(symbol, side, currentPnLPct)
}
// Calculate drawdown (magnitude of decline from peak)
var drawdownPct float64
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
}
// Check close position condition: profit > 5% and drawdown >= 40%
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
// Execute close position
if err := at.emergencyClosePosition(symbol, side); err != nil {
logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err)
} else {
logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side)
// Clear cache for this position after closing
at.ClearPeakPnLCache(symbol, side)
}
} else if currentPnLPct > 5.0 {
// Record situations close to close position condition (for debugging)
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
}
}
}
// emergencyClosePosition emergency close position function
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
switch side {
case "long":
order, err := at.trader.CloseLong(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"])
case "short":
order, err := at.trader.CloseShort(symbol, 0) // 0 = close all
if err != nil {
return err
}
logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"])
default:
return fmt.Errorf("unknown position direction: %s", side)
}
return nil
}
// GetPeakPnLCache gets peak profit cache
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
at.peakPnLCacheMutex.RLock()
defer at.peakPnLCacheMutex.RUnlock()
// Return a copy of the cache
cache := make(map[string]float64)
for k, v := range at.peakPnLCache {
cache[k] = v
}
return cache
}
// UpdatePeakPnL updates peak profit cache
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
if peak, exists := at.peakPnLCache[posKey]; exists {
// Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare)
if currentPnLPct > peak {
at.peakPnLCache[posKey] = currentPnLPct
}
} else {
// First time recording
at.peakPnLCache[posKey] = currentPnLPct
}
}
// ClearPeakPnLCache clears peak cache for specified position
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
at.peakPnLCacheMutex.Lock()
defer at.peakPnLCacheMutex.Unlock()
posKey := symbol + "_" + side
delete(at.peakPnLCache, posKey)
}
// ============================================================================
// Risk Control Helpers
// ============================================================================
// isBTCETH checks if a symbol is BTC or ETH
func isBTCETH(symbol string) bool {
symbol = strings.ToUpper(symbol)
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
}
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
// Returns the adjusted position size (capped if necessary) and whether the position was capped
// positionSizeUSD: the original position size in USD
// equity: the account equity
// symbol: the trading symbol
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
if at.config.StrategyConfig == nil {
return positionSizeUSD, false
}
riskControl := at.config.StrategyConfig.RiskControl
// Get the appropriate position value ratio limit
var maxPositionValueRatio float64
if isBTCETH(symbol) {
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
if maxPositionValueRatio <= 0 {
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH
}
} else {
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
if maxPositionValueRatio <= 0 {
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
}
}
// Calculate max allowed position value = equity × ratio
maxPositionValue := equity * maxPositionValueRatio
// Check if position size exceeds limit
if positionSizeUSD > maxPositionValue {
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
return maxPositionValue, true
}
return positionSizeUSD, false
}
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
if at.config.StrategyConfig == nil {
return nil
}
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
if minSize <= 0 {
minSize = 12 // Default: 12 USDT
}
if positionSizeUSD < minSize {
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
}
return nil
}
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
if at.config.StrategyConfig == nil {
return nil
}
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
if maxPositions <= 0 {
maxPositions = 3 // Default: 3 positions
}
if currentPositionCount >= maxPositions {
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
}
return nil
}
// getSideFromAction converts order action to side (BUY/SELL)
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}