From 574ddfb1aeebbcee124c56b24699e4d9d7637afe Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 26 Jul 2026 13:27:59 +0900 Subject: [PATCH] revert: drop exit-gate configurability, hardcode the replay-validated values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- kernel/engine_prompt.go | 39 +++--------- store/strategy.go | 89 ---------------------------- trader/auto_trader_throttle.go | 60 +++++++++---------- trader/auto_trader_throttle_test.go | 28 +++------ web/src/pages/StrategyStudioPage.tsx | 62 ------------------- web/src/types/strategy.ts | 10 ---- 6 files changed, 47 insertions(+), 241 deletions(-) diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go index c8b1e36f..56a98d0c 100644 --- a/kernel/engine_prompt.go +++ b/kernel/engine_prompt.go @@ -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 { diff --git a/store/strategy.go b/store/strategy.go index b313d786..0ba1dd43 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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, // }, } diff --git a/trader/auto_trader_throttle.go b/trader/auto_trader_throttle.go index 1c3d3ede..f176cdf7 100644 --- a/trader/auto_trader_throttle.go +++ b/trader/auto_trader_throttle.go @@ -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)) } diff --git a/trader/auto_trader_throttle_test.go b/trader/auto_trader_throttle_test.go index c154766f..2b0fde02 100644 --- a/trader/auto_trader_throttle_test.go +++ b/trader/auto_trader_throttle_test.go @@ -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) } } diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index da0a66bc..12790aeb 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -2321,68 +2321,6 @@ export function StrategyStudioPage() { -
- - - -
diff --git a/web/src/types/strategy.ts b/web/src/types/strategy.ts index 1822e319..3cafbc1b 100644 --- a/web/src/types/strategy.ts +++ b/web/src/types/strategy.ts @@ -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) }