mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 06:22:49 +08:00
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.
137 lines
5.2 KiB
Go
137 lines
5.2 KiB
Go
package trader
|
|
|
|
import (
|
|
"nofx/kernel"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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(),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -0.3)
|
|
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if !strings.Contains(reason, "min AI-managed hold") {
|
|
t.Fatalf("expected early close to be blocked by min hold, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
// Only a real -5% stop bypasses the min hold now.
|
|
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -6.0)
|
|
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if reason != "" {
|
|
t.Fatalf("expected hard stop close to pass, got %q", reason)
|
|
}
|
|
}
|
|
|
|
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.
|
|
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, 0.4)
|
|
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if !strings.Contains(reason, "noise band") {
|
|
t.Fatalf("expected flat close to be blocked inside noise window, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
// Past the 4h min hold, loss beyond the -4% noise floor → close allowed.
|
|
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, -4.5)
|
|
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if reason != "" {
|
|
t.Fatalf("expected confirmed loss after min hold to pass, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
ctx := &kernel.Context{}
|
|
|
|
// One open already queued this cycle (e.g. the long) — the second open
|
|
// (the short) must still be allowed so a directional pair can open.
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 1)
|
|
if reason != "" {
|
|
t.Fatalf("expected the second (short) open in cycle to be allowed, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleBlocksOpensOverCycleCap(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
ctx := &kernel.Context{}
|
|
|
|
// under the 2-per-cycle cap, a further open is allowed
|
|
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1); reason != "" {
|
|
t.Fatalf("expected open within the 2-per-cycle cap to be allowed, got %q", reason)
|
|
}
|
|
// at the cap, the next open is blocked
|
|
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 2); !strings.Contains(reason, "2 new position") {
|
|
t.Fatalf("expected open beyond the 2-per-cycle cap to be blocked, got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestTradeThrottleBlocksOpeningAgainstExistingPosition(t *testing.T) {
|
|
at := &AutoTrader{}
|
|
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 1.0)
|
|
|
|
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 0)
|
|
if !strings.Contains(reason, "already has an open") {
|
|
t.Fatalf("expected opposite open to be blocked when position exists, got %q", reason)
|
|
}
|
|
}
|