mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
8e294a5eed
- 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
264 lines
8.0 KiB
Go
264 lines
8.0 KiB
Go
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"
|
||
}
|
||
}
|