mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 06:22:49 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
trader/auto_trader_risk_test.go
Normal file
27
trader/auto_trader_risk_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user