Files
nofx/trader/auto_trader_risk.go
tinkle-community 5cd62e3c3a fix: exit-guard thresholds were margin-basis, tightening with leverage
Live decomposition of the 2026-07-23 drawdown (equity 62.6 -> 31.1 in one
day, 43 fills, avg hold <1h despite a 4h min hold): both exit guards
compared thresholds meant as price moves against leverage-multiplied
margin PnL%, so raising leverage 5x -> 10x silently halved every trigger
distance:

- throttle bypass: -5% 'stop' unlocked AI closes at a -0.5% price wiggle
  (ETH -0.5%/44min, AAVE -0.9%/45min, MU -2.4%/14min all sailed through)
- drawdown monitor: 'profit > 5%' armed at +0.5% price, then market-closed
  winners on a 40% giveback (SNDK +0.6%/35min, NVDA +0.3%/37min) — every
  winner strangled at breakeven while losers realized fast

Fix: evaluate both guards on price-basis PnL (margin PnL% / leverage).
Throttle thresholds keep their documented price meaning at any leverage;
the drawdown monitor now arms only after a real +5% price move. Peak-PnL
cache stays margin-basis for prompt display consistency.

Replay simulator updated to the same price-basis semantics.
2026-07-24 13:39:40 +09:00

348 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package trader
import (
"fmt"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
"strings"
"time"
)
const (
// The monitor arms only once the underlying PRICE has moved +5% in the
// position's favor (leverage-independent — at 10x the old margin-basis
// check armed at a +0.5% price wiggle and strangled every winner), then
// closes if the position gives back 40% of its peak profit.
drawdownClosePriceGainPct = 5.0
drawdownCloseGivebackPct = 40.0
)
// shouldDrawdownClose reports whether the profit-protection close should fire.
// pricePnLPct is the price-basis move in the position's favor; drawdownPct is
// the relative giveback from the position's peak profit.
func shouldDrawdownClose(pricePnLPct, drawdownPct float64) bool {
return pricePnLPct > drawdownClosePriceGainPct && drawdownPct >= drawdownCloseGivebackPct
}
// 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
}
// Guard: skip if entry price is zero (prevents division by zero panic)
if entryPrice <= 0 {
logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side)
continue
}
// Calculate current P&L percentage
leverage := 10 // Default value
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
// Price-basis move drives the close decision so the trigger point does
// not tighten as leverage grows; the margin-basis (leveraged) value is
// only kept for the peak cache shown alongside margin-based PnL% in
// prompts.
var pricePnLPct float64
if side == "long" {
pricePnLPct = ((markPrice - entryPrice) / entryPrice) * 100
} else {
pricePnLPct = ((entryPrice - markPrice) / entryPrice) * 100
}
currentPnLPct := pricePnLPct * float64(leverage)
// 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: price move > +5% and drawdown >= 40%
if shouldDrawdownClose(pricePnLPct, drawdownPct) {
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Price move: %.2f%% | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
symbol, side, pricePnLPct, 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 pricePnLPct > drawdownClosePriceGainPct {
// Record situations close to close position condition (for debugging)
logger.Infof("📊 Drawdown monitoring: %s %s | Price move: %.2f%% | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
symbol, side, pricePnLPct, 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")
}
// isMajorAsset returns true for assets that should use the BTC/ETH higher
// position-value tier rather than the altcoin (1x equity) tier. This covers
// BTC/ETH crypto perps AND Hyperliquid XYZ assets (US equities, commodities,
// forex) — none of which are "altcoins" and all of which deserve the higher
// per-position cap so the AI can actually take meaningful positions.
func isMajorAsset(symbol string) bool {
if isBTCETH(symbol) {
return true
}
return market.IsXyzDexAsset(symbol)
}
// 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. BTC/ETH AND Hyperliquid
// XYZ assets (US stocks etc.) use the higher tier; pure altcoins use the
// lower tier.
var maxPositionValueRatio float64
if isMajorAsset(symbol) {
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
if maxPositionValueRatio <= 0 {
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH and XYZ assets
}
} 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
}
func (at *AutoTrader) applyAutopilotFullSizeOpen(decision *kernel.Decision, equity float64) {
if at == nil || decision == nil || at.config.StrategyConfig == nil || equity <= 0 {
return
}
cfg := at.config.StrategyConfig
if cfg.CoinSource.SourceType != "vergex_signal" {
return
}
riskControl := cfg.RiskControl
leverage := riskControl.AltcoinMaxLeverage
positionValueRatio := riskControl.AltcoinMaxPositionValueRatio
if isMajorAsset(decision.Symbol) {
leverage = riskControl.BTCETHMaxLeverage
positionValueRatio = riskControl.BTCETHMaxPositionValueRatio
}
if leverage < store.MinLeverage {
leverage = store.MinLeverage
}
if leverage > store.MaxAltLeverage {
leverage = store.MaxAltLeverage
}
if positionValueRatio <= 0 {
positionValueRatio = 1.0
}
fullPositionSize := equity * positionValueRatio
if fullPositionSize <= 0 {
return
}
if decision.Leverage != leverage || decision.PositionSizeUSD != fullPositionSize {
logger.Infof(" 📏 [AUTOPILOT] Full-size open enforced for %s: leverage %dx → %dx, notional %.2f → %.2f USDT",
decision.Symbol, decision.Leverage, leverage, decision.PositionSizeUSD, fullPositionSize)
}
decision.Leverage = leverage
decision.PositionSizeUSD = fullPositionSize
}
// 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"
}
}