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.
This commit is contained in:
tinkle-community
2026-07-24 13:39:40 +09:00
parent ed3bebf287
commit 5cd62e3c3a
5 changed files with 124 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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