diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go index 5f40c229..c8b1e36f 100644 --- a/kernel/engine_prompt.go +++ b/kernel/engine_prompt.go @@ -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 == "" { diff --git a/store/strategy.go b/store/strategy.go index 0ba1dd43..b313d786 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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, // }, } diff --git a/trader/auto_trader_throttle.go b/trader/auto_trader_throttle.go index 9cf83e58..1c3d3ede 100644 --- a/trader/auto_trader_throttle.go +++ b/trader/auto_trader_throttle.go @@ -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)) } diff --git a/trader/auto_trader_throttle_test.go b/trader/auto_trader_throttle_test.go index 3cbd8179..c154766f 100644 --- a/trader/auto_trader_throttle_test.go +++ b/trader/auto_trader_throttle_test.go @@ -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{} diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 12790aeb..da0a66bc 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -2321,6 +2321,68 @@ export function StrategyStudioPage() { +
+ + + +
diff --git a/web/src/types/strategy.ts b/web/src/types/strategy.ts index 3cafbc1b..1822e319 100644 --- a/web/src/types/strategy.ts +++ b/web/src/types/strategy.ts @@ -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) }