feat: make exit throttle gates strategy-configurable, soften defaults

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
This commit is contained in:
tinkle-community
2026-07-25 19:26:04 +09:00
parent 05899d5afe
commit 434301cb09
6 changed files with 257 additions and 50 deletions

View File

@@ -10,30 +10,22 @@ import (
)
const (
// "Hold for big moves, don't churn" regime. Live history showed the
// account bleeding to death by fees: 0.3-0.5% in/out moves where a ~0.14%
// round-trip fee ate 30-50% of every small winner. These values force
// positions to be held for hours and to develop meaningful moves before
// closing, and cut the trade frequency hard.
autopilotMinHoldDuration = 4 * time.Hour
autopilotNoiseCloseHoldDuration = 8 * time.Hour
autopilotReentryCooldown = 3 * time.Hour
// Drastically cut churn: at most a couple of new positions per hour/cycle.
autopilotMaxOpensPerHour = 3
autopilotMaxOpensPerCycle = 2
// 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
// Anti-churn open caps: at most a couple of new positions per hour/cycle.
autopilotMaxOpensPerHour = 3
autopilotMaxOpensPerCycle = 2
)
// exitGates returns the strategy-configurable exit throttle: minimum hold,
// noise-close window, re-entry cooldown and the PRICE-basis PnL thresholds.
// Values come from the strategy's RiskControl (hot-reloaded from the DB) with
// built-in defaults for unset fields — see store.RiskControlConfig.
func (at *AutoTrader) exitGates() store.RiskControlConfig {
if at != nil && at.config.StrategyConfig != nil {
return at.config.StrategyConfig.RiskControl
}
return store.RiskControlConfig{}
}
// positionPricePnLPct converts the margin-based UnrealizedPnLPct reported for
// a position into the underlying price-move percentage.
func positionPricePnLPct(pos *kernel.PositionInfo) float64 {
@@ -126,9 +118,10 @@ func (at *AutoTrader) openThrottleReason(decision kernel.Decision, ctx *kernel.C
return fmt.Sprintf("trade throttle: %d open order already executed in the last hour; max is %d", openCount, autopilotMaxOpensPerHour)
}
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-autopilotReentryCooldown)); order != nil {
reentryCooldown := at.exitGates().ReentryCooldown()
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-reentryCooldown)); order != nil {
age := time.Since(time.UnixMilli(order.CreatedAt))
remaining := autopilotReentryCooldown - age
remaining := reentryCooldown - age
if remaining < 0 {
remaining = 0
}
@@ -145,6 +138,10 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
return ""
}
gates := at.exitGates()
minHold := gates.MinHold()
noiseHold := gates.NoiseHold()
pos := findContextPosition(ctx, symbol, side)
pnlPct := 0.0
entryTime := int64(0)
@@ -153,7 +150,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
entryTime = pos.UpdateTime
}
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-autopilotNoiseCloseHoldDuration)); order != nil && order.CreatedAt > entryTime {
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-noiseHold)); order != nil && order.CreatedAt > entryTime {
entryTime = order.CreatedAt
}
if entryTime <= 0 {
@@ -164,41 +161,41 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
if heldFor < 0 {
heldFor = 0
}
if heldFor >= autopilotMinHoldDuration {
if heldFor >= autopilotNoiseCloseHoldDuration ||
pnlPct <= noiseCloseLossFloorPct ||
pnlPct >= noiseCloseProfitCeilingPct {
if heldFor >= minHold {
if heldFor >= noiseHold ||
pnlPct <= gates.NoiseFloorPct() ||
pnlPct >= gates.NoiseCeilingPct() {
return ""
}
remaining := autopilotNoiseCloseHoldDuration - heldFor
remaining := noiseHold - heldFor
return fmt.Sprintf(
"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),
pnlPct,
noiseCloseLossFloorPct,
noiseCloseProfitCeilingPct,
gates.NoiseFloorPct(),
gates.NoiseCeilingPct(),
roundDuration(remaining),
)
}
// Do not block true risk exits or unusually strong take-profit exits.
if pnlPct <= earlyCloseStopLossBypassPct || pnlPct >= earlyCloseTakeProfitBypassPct {
if pnlPct <= gates.StopBypassPct() || pnlPct >= gates.TPBypassPct() {
return ""
}
remaining := autopilotMinHoldDuration - heldFor
remaining := minHold - heldFor
return fmt.Sprintf(
"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),
pnlPct,
roundDuration(autopilotMinHoldDuration),
earlyCloseStopLossBypassPct,
earlyCloseTakeProfitBypassPct,
roundDuration(minHold),
gates.StopBypassPct(),
gates.TPBypassPct(),
) + fmt.Sprintf("; wait about %s", roundDuration(remaining))
}

View File

@@ -2,6 +2,7 @@ package trader
import (
"nofx/kernel"
"nofx/store"
"strings"
"testing"
"time"
@@ -37,7 +38,7 @@ func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
at := &AutoTrader{}
// Only a real -5% stop bypasses the min hold now.
// 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)
@@ -68,8 +69,8 @@ func TestTradeThrottleBypassIsPriceBasisNotMarginBasis(t *testing.T) {
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)
// 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") {
@@ -79,8 +80,9 @@ func TestTradeThrottleNoiseBandIsPriceBasisNotMarginBasis(t *testing.T) {
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)
// 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") {
@@ -90,8 +92,8 @@ func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
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)
// 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 != "" {
@@ -99,6 +101,27 @@ func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
}
}
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{}