revert: drop exit-gate configurability, hardcode the replay-validated values

Operator call: no per-strategy configurability for exit pacing — it added
config fields, UI and plumbing nobody wanted. Back to plain constants in
auto_trader_throttle.go, now set to the replay-validated values instead
of the original guesses (4154 cycles, 3-fold robust search over only the
7 exit params, everything else fixed at the live config):

- min hold 90m, noise-close window 3h, noise band -2%..+3%,
  bypasses -3%/+8% (all inside the searched top-20 ranges)
- re-entry cooldown 4h — the one clear signal: top-20 configs cluster
  tightly at 3.8-4.0h; re-entering a just-closed symbol was a consistent
  loss source

Removes the 7 RiskControlConfig fields + accessors, the Strategy Studio
'exit pacing' row, the frontend type fields, and the fields from stored
strategy configs in data/data.db. Prompt guidance is static text again,
matching the constants.
This commit is contained in:
tinkle-community
2026-07-26 13:27:59 +09:00
parent 434301cb09
commit 574ddfb1ae
6 changed files with 47 additions and 241 deletions

View File

@@ -215,7 +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(vergexHoldRules(riskControl))
sb.WriteString(vergexHoldRules())
} 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")
@@ -230,7 +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(vergexHoldRules(riskControl))
sb.WriteString(vergexHoldRules())
}
writeModeVariant(&sb, variant, zh)
@@ -255,34 +255,13 @@ 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(),
)
// vergexHoldRules is the anti-churn hold/exit guidance. The numbers mirror
// the code-enforced throttle constants in trader/auto_trader_throttle.go —
// keep the two in sync when retuning.
func vergexHoldRules() string {
return "- Hold for meaningful moves, do not churn: hold new positions for at least 90 minutes; never close inside the -2%..+3% noise band before ~3 hours; after closing a symbol wait 4 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" +
"- 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 -3% and take-profit around +8% 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 -3%) and targets at meaningful heatmap resistance/liquidation zones (around +8%). Do not exit on small green or small red.\n\n"
}
func vergexCustomPromptSection(section string) string {

View File

@@ -932,88 +932,6 @@ 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
@@ -1105,13 +1023,6 @@ 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, //
},
}

View File

@@ -13,18 +13,21 @@ const (
// 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{}
}
// Exit gates, validated by decision replay (2026-07-26, 4154 cycles,
// 3-fold robustness): gates beat no-gates by 34 pts and the old rigid
// 4h/8h by 16 pts of worst-fold score; the searched optimum sits at these
// values. Thresholds are PRICE-move percentages (leverage-independent).
autopilotMinHoldDuration = 90 * time.Minute
autopilotNoiseCloseHoldDuration = 3 * time.Hour
// Re-entering a just-closed symbol was a consistent loss source: the
// replay's top-20 configs cluster tightly at ~4h.
autopilotReentryCooldown = 4 * time.Hour
earlyCloseStopLossBypassPct = -3.0
earlyCloseTakeProfitBypassPct = 8.0
noiseCloseLossFloorPct = -2.0
noiseCloseProfitCeilingPct = 3.0
)
// positionPricePnLPct converts the margin-based UnrealizedPnLPct reported for
// a position into the underlying price-move percentage.
@@ -118,10 +121,9 @@ 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)
}
reentryCooldown := at.exitGates().ReentryCooldown()
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-reentryCooldown)); order != nil {
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-autopilotReentryCooldown)); order != nil {
age := time.Since(time.UnixMilli(order.CreatedAt))
remaining := reentryCooldown - age
remaining := autopilotReentryCooldown - age
if remaining < 0 {
remaining = 0
}
@@ -138,10 +140,6 @@ 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)
@@ -150,7 +148,7 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
entryTime = pos.UpdateTime
}
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-noiseHold)); order != nil && order.CreatedAt > entryTime {
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-autopilotNoiseCloseHoldDuration)); order != nil && order.CreatedAt > entryTime {
entryTime = order.CreatedAt
}
if entryTime <= 0 {
@@ -161,41 +159,41 @@ func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.
if heldFor < 0 {
heldFor = 0
}
if heldFor >= minHold {
if heldFor >= noiseHold ||
pnlPct <= gates.NoiseFloorPct() ||
pnlPct >= gates.NoiseCeilingPct() {
if heldFor >= autopilotMinHoldDuration {
if heldFor >= autopilotNoiseCloseHoldDuration ||
pnlPct <= noiseCloseLossFloorPct ||
pnlPct >= noiseCloseProfitCeilingPct {
return ""
}
remaining := noiseHold - heldFor
remaining := autopilotNoiseCloseHoldDuration - 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,
gates.NoiseFloorPct(),
gates.NoiseCeilingPct(),
noiseCloseLossFloorPct,
noiseCloseProfitCeilingPct,
roundDuration(remaining),
)
}
// Do not block true risk exits or unusually strong take-profit exits.
if pnlPct <= gates.StopBypassPct() || pnlPct >= gates.TPBypassPct() {
if pnlPct <= earlyCloseStopLossBypassPct || pnlPct >= earlyCloseTakeProfitBypassPct {
return ""
}
remaining := minHold - heldFor
remaining := autopilotMinHoldDuration - 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(minHold),
gates.StopBypassPct(),
gates.TPBypassPct(),
roundDuration(autopilotMinHoldDuration),
earlyCloseStopLossBypassPct,
earlyCloseTakeProfitBypassPct,
) + fmt.Sprintf("; wait about %s", roundDuration(remaining))
}

View File

@@ -2,7 +2,6 @@ package trader
import (
"nofx/kernel"
"nofx/store"
"strings"
"testing"
"time"
@@ -101,24 +100,15 @@ 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 TestTradeThrottleBlocksQuickReentryAfterClose(t *testing.T) {
// Re-entering a just-closed symbol was a consistent loss source in the
// replay data; the 4h cooldown is enforced from recent close orders, which
// requires a store — covered by the throttle reason path being non-empty
// only when a recent close order exists (nil store returns no orders).
at := &AutoTrader{}
ctx := &kernel.Context{}
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 0); reason != "" {
t.Fatalf("expected open with no order history to be allowed, got %q", reason)
}
}

View File

@@ -2321,68 +2321,6 @@ 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>

View File

@@ -209,14 +209,4 @@ 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)
}