mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 06:22:49 +08:00
The anti-churn exit gates (min hold, noise-close window, re-entry cooldown, bypass and noise-band thresholds) were hardcoded constants in auto_trader_throttle.go — every flat-ish position was forced to hold 8h+ and changing the pacing meant a code change and redeploy. - RiskControlConfig gains 7 exit-gate fields (minutes / signed price-%), zero = built-in default; accessor methods centralize fallbacks and are hot-reloaded from the DB like the rest of the strategy config - Throttle reads the gates from the strategy; prompt hold/exit guidance is now rendered from the same values so the AI is told exactly what the code will enforce - Defaults softened: min hold 4h -> 1.5h, noise window 8h -> 3h, re-entry 3h -> 1.5h, bypasses -5/+12 -> -3/+8, noise band -4..+6 -> -2..+3 (price-basis). A +-2-3% move is 15-20x round-trip fees — no longer 'noise' worth locking - Strategy Studio gets an 'exit pacing' row (min hold / flat-close window / re-entry cooldown); thresholds editable via strategy JSON - Live strategy updated in data/data.db with the softened values
160 lines
6.3 KiB
Go
160 lines
6.3 KiB
Go
package trader
|
|
|
|
import (
|
|
"nofx/kernel"
|
|
"nofx/store"
|
|
"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{}
|
|
// A price loss beyond the default -3% bypass unlocks the min hold.
|
|
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 default -2%..+3% noise band — flat close must stay blocked.
|
|
ctx := leveragedThrottleContext("xyz:INTC", "long", 2*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 default 90m min hold but still inside the noise band and
|
|
// under the 3h noise window.
|
|
ctx := throttleContext("xyz:INTC", "long", 2*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 min hold, loss beyond the -2% noise floor → close allowed.
|
|
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, -2.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 TestTradeThrottleRespectsConfiguredGates(t *testing.T) {
|
|
// Strategy config overrides the built-in defaults (hot-reloaded from DB).
|
|
strict := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &store.StrategyConfig{
|
|
RiskControl: store.RiskControlConfig{MinHoldMinutes: 600, EarlyStopBypassPct: -10},
|
|
}}}
|
|
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, -4.0)
|
|
reason := strict.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if !strings.Contains(reason, "min AI-managed hold") {
|
|
t.Fatalf("expected configured 10h min hold to block a 5h close, got %q", reason)
|
|
}
|
|
|
|
loose := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &store.StrategyConfig{
|
|
RiskControl: store.RiskControlConfig{MinHoldMinutes: 30, NoiseHoldMinutes: 40},
|
|
}}}
|
|
ctx = throttleContext("xyz:INTC", "long", 45*time.Minute, 0.1)
|
|
reason = loose.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
|
if reason != "" {
|
|
t.Fatalf("expected 45m close to pass with a 40m noise window, 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)
|
|
}
|
|
}
|