mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-27 22:12:52 +08:00
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:
@@ -215,9 +215,7 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
sb.WriteString(vergexHoldRules(riskControl))
|
||||
} else {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
@@ -232,9 +230,7 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
sb.WriteString(vergexHoldRules(riskControl))
|
||||
}
|
||||
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
@@ -259,6 +255,36 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
||||
// path, dropping legacy directional overrides ("long only" era) that would
|
||||
// contradict the data-driven direction rule baked into this prompt.
|
||||
// vergexHoldRules renders the anti-churn hold/exit guidance from the
|
||||
// strategy's configurable exit gates so the prompt always matches what the
|
||||
// code-enforced throttle will actually allow.
|
||||
func vergexHoldRules(riskControl store.RiskControlConfig) string {
|
||||
fmtDur := func(d time.Duration) string {
|
||||
mins := int(d.Minutes())
|
||||
if mins < 60 {
|
||||
return fmt.Sprintf("%d minutes", mins)
|
||||
}
|
||||
if mins%60 == 0 {
|
||||
return fmt.Sprintf("%d hours", mins/60)
|
||||
}
|
||||
return fmt.Sprintf("%.1f hours", d.Hours())
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"- Hold for meaningful moves, do not churn: hold new positions for at least %s; never close inside the %.1f%%..%.1f%% noise band before ~%s; after closing a symbol wait %s before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n"+
|
||||
"- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only take setups whose realistic target is well beyond fees: stop-loss around %.1f%% and take-profit around +%.1f%% or beyond. Do not aim for 0.2-0.3%% scalps — they cannot cover fees.\n"+
|
||||
"- Give positions room to develop: place stops beyond short-term noise (around %.1f%%) and targets at meaningful heatmap resistance/liquidation zones (around +%.1f%%). Do not exit on small green or small red.\n\n",
|
||||
fmtDur(riskControl.MinHold()),
|
||||
riskControl.NoiseFloorPct(),
|
||||
riskControl.NoiseCeilingPct(),
|
||||
fmtDur(riskControl.NoiseHold()),
|
||||
fmtDur(riskControl.ReentryCooldown()),
|
||||
riskControl.StopBypassPct(),
|
||||
riskControl.TPBypassPct(),
|
||||
riskControl.StopBypassPct(),
|
||||
riskControl.TPBypassPct(),
|
||||
)
|
||||
}
|
||||
|
||||
func vergexCustomPromptSection(section string) string {
|
||||
trimmed := englishOnlyPromptSection(section)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -932,6 +932,88 @@ type RiskControlConfig struct {
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// Min AI confidence to open position (AI guided)
|
||||
MinConfidence int `json:"min_confidence"`
|
||||
|
||||
// Exit throttle gates (CODE ENFORCED). Zero values fall back to the
|
||||
// built-in defaults below; PnL thresholds are PRICE-move percentages
|
||||
// (leverage-independent). Signed: bypass/floor negative, ceiling positive.
|
||||
MinHoldMinutes int `json:"min_hold_minutes"` // AI closes blocked before this unless a bypass fires
|
||||
NoiseHoldMinutes int `json:"noise_hold_minutes"` // flat closes inside the noise band blocked until this
|
||||
ReentryCooldownMinutes int `json:"reentry_cooldown_minutes"` // same-symbol reopen cooldown after a close
|
||||
EarlyStopBypassPct float64 `json:"early_stop_bypass_pct"` // price loss unlocking an early close (negative)
|
||||
EarlyTPBypassPct float64 `json:"early_tp_bypass_pct"` // price profit unlocking an early close (positive)
|
||||
NoiseLossFloorPct float64 `json:"noise_loss_floor_pct"` // noise band lower edge (negative)
|
||||
NoiseProfitCeilingPct float64 `json:"noise_profit_ceiling_pct"` // noise band upper edge (positive)
|
||||
}
|
||||
|
||||
// Built-in exit-gate defaults, used when a strategy leaves the fields at zero.
|
||||
const (
|
||||
DefaultMinHoldMinutes = 90
|
||||
DefaultNoiseHoldMinutes = 180
|
||||
DefaultReentryCooldownMinutes = 90
|
||||
DefaultEarlyStopBypassPct = -3.0
|
||||
DefaultEarlyTPBypassPct = 8.0
|
||||
DefaultNoiseLossFloorPct = -2.0
|
||||
DefaultNoiseProfitCeilingPct = 3.0
|
||||
)
|
||||
|
||||
// MinHold returns the configured minimum AI-managed hold, or the default.
|
||||
func (r RiskControlConfig) MinHold() time.Duration {
|
||||
if r.MinHoldMinutes > 0 {
|
||||
return time.Duration(r.MinHoldMinutes) * time.Minute
|
||||
}
|
||||
return DefaultMinHoldMinutes * time.Minute
|
||||
}
|
||||
|
||||
// NoiseHold returns the noise-close window, never shorter than MinHold.
|
||||
func (r RiskControlConfig) NoiseHold() time.Duration {
|
||||
hold := time.Duration(DefaultNoiseHoldMinutes) * time.Minute
|
||||
if r.NoiseHoldMinutes > 0 {
|
||||
hold = time.Duration(r.NoiseHoldMinutes) * time.Minute
|
||||
}
|
||||
if min := r.MinHold(); hold < min {
|
||||
return min
|
||||
}
|
||||
return hold
|
||||
}
|
||||
|
||||
// ReentryCooldown returns the same-symbol reopen cooldown, or the default.
|
||||
func (r RiskControlConfig) ReentryCooldown() time.Duration {
|
||||
if r.ReentryCooldownMinutes > 0 {
|
||||
return time.Duration(r.ReentryCooldownMinutes) * time.Minute
|
||||
}
|
||||
return DefaultReentryCooldownMinutes * time.Minute
|
||||
}
|
||||
|
||||
// StopBypassPct returns the early-close loss threshold (negative price %).
|
||||
func (r RiskControlConfig) StopBypassPct() float64 {
|
||||
if r.EarlyStopBypassPct < 0 {
|
||||
return r.EarlyStopBypassPct
|
||||
}
|
||||
return DefaultEarlyStopBypassPct
|
||||
}
|
||||
|
||||
// TPBypassPct returns the early-close profit threshold (positive price %).
|
||||
func (r RiskControlConfig) TPBypassPct() float64 {
|
||||
if r.EarlyTPBypassPct > 0 {
|
||||
return r.EarlyTPBypassPct
|
||||
}
|
||||
return DefaultEarlyTPBypassPct
|
||||
}
|
||||
|
||||
// NoiseFloorPct returns the noise band lower edge (negative price %).
|
||||
func (r RiskControlConfig) NoiseFloorPct() float64 {
|
||||
if r.NoiseLossFloorPct < 0 {
|
||||
return r.NoiseLossFloorPct
|
||||
}
|
||||
return DefaultNoiseLossFloorPct
|
||||
}
|
||||
|
||||
// NoiseCeilingPct returns the noise band upper edge (positive price %).
|
||||
func (r RiskControlConfig) NoiseCeilingPct() float64 {
|
||||
if r.NoiseProfitCeilingPct > 0 {
|
||||
return r.NoiseProfitCeilingPct
|
||||
}
|
||||
return DefaultNoiseProfitCeilingPct
|
||||
}
|
||||
|
||||
// NewStrategyStore creates a new StrategyStore
|
||||
@@ -1023,6 +1105,13 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 78, // Min 78% confidence (AI guided)
|
||||
MinHoldMinutes: DefaultMinHoldMinutes, // exit gates: block fee-churn exits,
|
||||
NoiseHoldMinutes: DefaultNoiseHoldMinutes, // user-tunable per strategy
|
||||
ReentryCooldownMinutes: DefaultReentryCooldownMinutes, //
|
||||
EarlyStopBypassPct: DefaultEarlyStopBypassPct, // price-basis thresholds
|
||||
EarlyTPBypassPct: DefaultEarlyTPBypassPct, //
|
||||
NoiseLossFloorPct: DefaultNoiseLossFloorPct, //
|
||||
NoiseProfitCeilingPct: DefaultNoiseProfitCeilingPct, //
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -2321,6 +2321,68 @@ export function StrategyStudioPage() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{text(language, '最小持仓时间', 'Min hold')}
|
||||
</span>
|
||||
<select
|
||||
value={risk.min_hold_minutes ?? 90}
|
||||
onChange={(event) =>
|
||||
patchRisk({
|
||||
min_hold_minutes: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg px-3 py-2 text-sm text-nofx-text"
|
||||
>
|
||||
{[30, 60, 90, 120, 180, 240].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value >= 60 ? `${value / 60}h` : `${value}m`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{text(language, '横盘仓锁定窗口', 'Flat-close window')}
|
||||
</span>
|
||||
<select
|
||||
value={risk.noise_hold_minutes ?? 180}
|
||||
onChange={(event) =>
|
||||
patchRisk({
|
||||
noise_hold_minutes: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg px-3 py-2 text-sm text-nofx-text"
|
||||
>
|
||||
{[60, 120, 180, 240, 360, 480].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{`${value / 60}h`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{text(language, '同币再入冷却', 'Re-entry cooldown')}
|
||||
</span>
|
||||
<select
|
||||
value={risk.reentry_cooldown_minutes ?? 90}
|
||||
onChange={(event) =>
|
||||
patchRisk({
|
||||
reentry_cooldown_minutes: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg px-3 py-2 text-sm text-nofx-text"
|
||||
>
|
||||
{[30, 60, 90, 120, 180].map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value >= 60 ? `${value / 60}h` : `${value}m`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -209,4 +209,14 @@ export interface RiskControlConfig {
|
||||
min_position_size: number; // Min position size in USDT (CODE ENFORCED)
|
||||
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||
min_confidence: number; // Min AI confidence to open position (AI guided)
|
||||
|
||||
// Exit throttle gates (CODE ENFORCED, 0/absent = backend defaults).
|
||||
// PnL thresholds are PRICE-move percentages (leverage-independent).
|
||||
min_hold_minutes?: number; // AI closes blocked before this unless a bypass fires
|
||||
noise_hold_minutes?: number; // flat closes inside the noise band blocked until this
|
||||
reentry_cooldown_minutes?: number; // same-symbol reopen cooldown after a close
|
||||
early_stop_bypass_pct?: number; // price loss unlocking an early close (negative)
|
||||
early_tp_bypass_pct?: number; // price profit unlocking an early close (positive)
|
||||
noise_loss_floor_pct?: number; // noise band lower edge (negative)
|
||||
noise_profit_ceiling_pct?: number; // noise band upper edge (positive)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user