diff --git a/scripts/optimize/simulate.py b/scripts/optimize/simulate.py index 347c1f12..f547ae24 100644 --- a/scripts/optimize/simulate.py +++ b/scripts/optimize/simulate.py @@ -1,8 +1,9 @@ """Replay recorded AI decisions under alternative risk/throttle parameters. Mirrors the live semantics of trader/auto_trader_throttle.go: - - throttle thresholds compare MARGIN-based PnL% (leverage-multiplied price move), - because auto_trader_loop.go computes UnrealizedPnLPct = pnl / margin + - throttle thresholds compare PRICE-basis PnL% (leverage-independent), + matching the 2026-07-24 fix that converted the live thresholds from + margin basis to price basis - stop-loss / take-profit are exchange trigger orders -> intra-candle fills - opens are gated by confidence, per-cycle/per-hour caps, re-entry cooldown, max positions and available margin @@ -38,9 +39,9 @@ class Params: max_positions: int = 2 leverage: float = 10.0 ratio: float = 5.0 # per-position notional = ratio x equity - sl_bypass: float = -5.0 # margin-PnL% allowing early AI close + sl_bypass: float = -5.0 # price-PnL% allowing early AI close tp_bypass: float = 12.0 - noise_floor: float = -4.0 # margin-PnL% band blocking flat closes + noise_floor: float = -4.0 # price-PnL% band blocking flat closes noise_ceiling: float = 6.0 sl_mult: float = 1.0 # scale AI stop distance from entry tp_mult: float = 1.0 @@ -138,11 +139,11 @@ class Simulator: i = bisect.bisect_right(times, ts) - 1 return self.candles[symbol][i][4] if i >= 0 else None - def margin_pnl_pct(self, pos, price, leverage): + def price_pnl_pct(self, pos, price): move = (price - pos.entry) / pos.entry if pos.side == "short": move = -move - return move * leverage * 100.0 + return move * 100.0 def run(self, p: Params, start=None, end=None): equity = self.start_equity @@ -223,7 +224,7 @@ class Simulator: price = self.price_at(sym, cycle_id, ts) or pos.last_price if not price: continue - pnl_pct = self.margin_pnl_pct(pos, price, p.leverage) + pnl_pct = self.price_pnl_pct(pos, price) held = ts - pos.entry_ts if held >= min_hold: allowed = (held >= noise_hold diff --git a/trader/auto_trader_risk.go b/trader/auto_trader_risk.go index 17a3f6f7..2ee63684 100644 --- a/trader/auto_trader_risk.go +++ b/trader/auto_trader_risk.go @@ -10,6 +10,22 @@ import ( "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) @@ -64,12 +80,17 @@ func (at *AutoTrader) checkPositionDrawdown() { leverage = int(lev) } - var currentPnLPct float64 + // 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" { - currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100 + pricePnLPct = ((markPrice - entryPrice) / entryPrice) * 100 } else { - currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100 + pricePnLPct = ((entryPrice - markPrice) / entryPrice) * 100 } + currentPnLPct := pricePnLPct * float64(leverage) // Construct unique position identifier (distinguish long/short) posKey := symbol + "_" + side @@ -94,10 +115,10 @@ func (at *AutoTrader) checkPositionDrawdown() { 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) + // 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 { @@ -107,10 +128,10 @@ func (at *AutoTrader) checkPositionDrawdown() { // Clear cache for this position after closing at.ClearPeakPnLCache(symbol, side) } - } else if currentPnLPct > 5.0 { + } else if pricePnLPct > drawdownClosePriceGainPct { // 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) + logger.Infof("📊 Drawdown monitoring: %s %s | Price move: %.2f%% | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%", + symbol, side, pricePnLPct, currentPnLPct, peakPnLPct, drawdownPct) } } } diff --git a/trader/auto_trader_risk_test.go b/trader/auto_trader_risk_test.go new file mode 100644 index 00000000..634c1e23 --- /dev/null +++ b/trader/auto_trader_risk_test.go @@ -0,0 +1,27 @@ +package trader + +import "testing" + +func TestDrawdownCloseArmsOnPriceBasisOnly(t *testing.T) { + cases := []struct { + name string + pricePnLPct float64 + drawdownPct float64 + shouldClose bool + }{ + // +0.5% price move (what +5% margin at 10x used to arm on) must NOT + // arm the monitor, no matter how large the relative drawdown is. + {"tiny price gain big drawdown", 0.5, 60.0, false}, + // Armed only from a real +5% price move, and still needs the 40% giveback. + {"real gain small drawdown", 6.0, 20.0, false}, + {"real gain big drawdown", 6.0, 45.0, true}, + {"at threshold not armed", 5.0, 45.0, false}, + {"loss never triggers", -3.0, 80.0, false}, + } + for _, c := range cases { + if got := shouldDrawdownClose(c.pricePnLPct, c.drawdownPct); got != c.shouldClose { + t.Fatalf("%s: shouldDrawdownClose(%.1f, %.1f) = %v, want %v", + c.name, c.pricePnLPct, c.drawdownPct, got, c.shouldClose) + } + } +} diff --git a/trader/auto_trader_throttle.go b/trader/auto_trader_throttle.go index 437cd702..9cf83e58 100644 --- a/trader/auto_trader_throttle.go +++ b/trader/auto_trader_throttle.go @@ -21,16 +21,31 @@ const ( // Drastically cut churn: at most a couple of new positions per hour/cycle. autopilotMaxOpensPerHour = 3 autopilotMaxOpensPerCycle = 2 - // Wide, asymmetric exits. Cut a loser only at a real -5% (at 5x leverage - // that is -25% of margin — survivable), let a winner run to +12% before - // any early take-profit. The noise band (-4%..+6%) blocks closing on the - // small moves that were grinding the account to nothing. + // Wide, asymmetric exits, expressed as PRICE-move percentages so their + // meaning does not drift with leverage (the exchange reports margin-based + // PnL%, which is the price move multiplied by leverage — comparing these + // thresholds against that let a -0.5% wiggle bypass the min hold at 10x). + // Cut a loser only at a real -5% price move, let a winner run to +12% + // before any early take-profit. The noise band (-4%..+6%) blocks closing + // on the small moves that were grinding the account to nothing. earlyCloseStopLossBypassPct = -5.0 earlyCloseTakeProfitBypassPct = 12.0 noiseCloseLossFloorPct = -4.0 noiseCloseProfitCeilingPct = 6.0 ) +// positionPricePnLPct converts the margin-based UnrealizedPnLPct reported for +// a position into the underlying price-move percentage. +func positionPricePnLPct(pos *kernel.PositionInfo) float64 { + if pos == nil { + return 0 + } + if pos.Leverage > 1 { + return pos.UnrealizedPnLPct / float64(pos.Leverage) + } + return pos.UnrealizedPnLPct +} + func isOpenAction(action string) bool { switch strings.ToLower(strings.TrimSpace(action)) { case "open_long", "open_short": @@ -134,7 +149,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel. pnlPct := 0.0 entryTime := int64(0) if pos != nil { - pnlPct = pos.UnrealizedPnLPct + pnlPct = positionPricePnLPct(pos) entryTime = pos.UpdateTime } @@ -158,7 +173,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel. remaining := autopilotNoiseCloseHoldDuration - heldFor return fmt.Sprintf( - "trade throttle: %s %s has been held for %s with PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close", + "trade throttle: %s %s has been held for %s with price PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close", symbol, side, roundDuration(heldFor), @@ -176,7 +191,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel. remaining := autopilotMinHoldDuration - heldFor return fmt.Sprintf( - "trade throttle: %s %s has only been held for %s with PnL %.2f%%; min AI-managed hold is %s unless loss <= %.1f%% or profit >= %.1f%%", + "trade throttle: %s %s has only been held for %s with price PnL %.2f%%; min AI-managed hold is %s unless price loss <= %.1f%% or price profit >= %.1f%%", symbol, side, roundDuration(heldFor), diff --git a/trader/auto_trader_throttle_test.go b/trader/auto_trader_throttle_test.go index d9ae1143..3cbd8179 100644 --- a/trader/auto_trader_throttle_test.go +++ b/trader/auto_trader_throttle_test.go @@ -8,12 +8,17 @@ import ( ) func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context { + return leveragedThrottleContext(symbol, side, heldFor, pnlPct, 1) +} + +func leveragedThrottleContext(symbol, side string, heldFor time.Duration, pnlPct float64, leverage int) *kernel.Context { return &kernel.Context{ Positions: []kernel.PositionInfo{ { Symbol: symbol, Side: side, UnrealizedPnLPct: pnlPct, + Leverage: leverage, UpdateTime: time.Now().Add(-heldFor).UnixMilli(), }, }, @@ -41,6 +46,37 @@ func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) { } } +func TestTradeThrottleBypassIsPriceBasisNotMarginBasis(t *testing.T) { + at := &AutoTrader{} + // At 10x leverage the exchange reports margin-based PnL: -6% margin is + // only a -0.6% price move — noise, must NOT bypass the min hold. + ctx := leveragedThrottleContext("xyz:INTC", "long", 20*time.Minute, -6.0, 10) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if !strings.Contains(reason, "min AI-managed hold") { + t.Fatalf("expected -0.6%% price move to stay blocked at 10x, got %q", reason) + } + + // -60% margin at 10x is a real -6% price move — bypass allowed. + ctx = leveragedThrottleContext("xyz:INTC", "long", 20*time.Minute, -60.0, 10) + reason = at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if reason != "" { + t.Fatalf("expected -6%% price move to bypass min hold at 10x, got %q", reason) + } +} + +func TestTradeThrottleNoiseBandIsPriceBasisNotMarginBasis(t *testing.T) { + at := &AutoTrader{} + // Past min hold at 10x: +20% margin is only a +2% price move, still + // inside the -4%..+6% noise band — flat close must stay blocked. + ctx := leveragedThrottleContext("xyz:INTC", "long", 5*time.Hour, 20.0, 10) + + reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0) + if !strings.Contains(reason, "noise band") { + t.Fatalf("expected +2%% price move to be blocked inside noise band at 10x, got %q", reason) + } +} + func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) { at := &AutoTrader{} // Held past the 4h min hold but still inside the wide -4%..+6% noise band.