diff --git a/store/strategy.go b/store/strategy.go index a406399e5c..a0183df4e8 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -230,6 +230,11 @@ type RiskControlConfig struct { MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"` // Min AI confidence to open position (AI guided) MinConfidence int `json:"min_confidence"` + + // Profit drawdown close: close position when current profit > MinProfitPct and pullback from peak >= DrawdownClosePct (CODE ENFORCED) + EnableDrawdownClose bool `json:"enable_drawdown_close"` // default true; set false to disable + DrawdownCloseMinProfitPct float64 `json:"drawdown_close_min_profit_pct"` // min current profit % to consider (default 5) + DrawdownClosePct float64 `json:"drawdown_close_pct"` // close when pullback from peak >= this % (default 40) } // NewStrategyStore creates a new StrategyStore @@ -315,8 +320,11 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED) MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED) MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED) - MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) - MinConfidence: 75, // Min 75% confidence (AI guided) + MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided) + MinConfidence: 75, // Min 75% confidence (AI guided) + EnableDrawdownClose: true, // Close when profit pulls back from peak + DrawdownCloseMinProfitPct: 5.0, // Only close if current profit > this % + DrawdownClosePct: 40.0, // Close when pullback from peak >= this % }, } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 9145e47681..90b38817d3 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -1812,6 +1812,19 @@ func (at *AutoTrader) startDrawdownMonitor() { }() } +// shouldTriggerDrawdownClose returns true when drawdown close is enabled, current profit is above min, +// and pullback from peak meets the threshold. Used by checkPositionDrawdown; exposed for tests. +func shouldTriggerDrawdownClose(enable bool, currentPnLPct, peakPnLPct, minProfitPct, pullbackPct float64) bool { + if !enable || currentPnLPct <= minProfitPct { + return false + } + if peakPnLPct <= 0 || currentPnLPct >= peakPnLPct { + return false + } + drawdownPct := ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100 + return drawdownPct >= pullbackPct +} + // checkPositionDrawdown checks position drawdown situation func (at *AutoTrader) checkPositionDrawdown() { // Get current positions @@ -1867,9 +1880,24 @@ func (at *AutoTrader) checkPositionDrawdown() { drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100 } - // Check close position condition: profit > 5% and drawdown >= 40% - if currentPnLPct > 5.0 && drawdownPct >= 40.0 { - logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%", + // Thresholds from strategy risk control (defaults: min profit 5%, pullback 40%) + minProfitPct := 5.0 + pullbackPct := 40.0 + enableDrawdownClose := true + if at.config.StrategyConfig != nil { + rc := at.config.StrategyConfig.RiskControl + enableDrawdownClose = rc.EnableDrawdownClose + if rc.DrawdownCloseMinProfitPct > 0 { + minProfitPct = rc.DrawdownCloseMinProfitPct + } + if rc.DrawdownClosePct > 0 { + pullbackPct = rc.DrawdownClosePct + } + } + + // Close when current profit > minProfitPct and pullback from peak >= pullbackPct + if shouldTriggerDrawdownClose(enableDrawdownClose, currentPnLPct, peakPnLPct, minProfitPct, pullbackPct) { + logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Pullback: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) // Execute close position @@ -1880,9 +1908,8 @@ func (at *AutoTrader) checkPositionDrawdown() { // Clear cache for this position after closing at.ClearPeakPnLCache(symbol, side) } - } else if currentPnLPct > 5.0 { - // Record situations close to close position condition (for debugging) - logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%", + } else if enableDrawdownClose && currentPnLPct > minProfitPct { + logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Pullback: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) } } diff --git a/trader/drawdown_test.go b/trader/drawdown_test.go new file mode 100644 index 0000000000..9a4053bfe8 --- /dev/null +++ b/trader/drawdown_test.go @@ -0,0 +1,118 @@ +package trader + +import ( + "testing" +) + +func TestShouldTriggerDrawdownClose(t *testing.T) { + tests := []struct { + name string + enable bool + currentPnLPct float64 + peakPnLPct float64 + minProfitPct float64 + pullbackPct float64 + expected bool + }{ + { + name: "disabled returns false", + enable: false, + currentPnLPct: 10, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "current below min profit returns false", + enable: true, + currentPnLPct: 4, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "current equals min profit returns false", + enable: true, + currentPnLPct: 5, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "peak <= 0 returns false", + enable: true, + currentPnLPct: 10, + peakPnLPct: 0, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "current >= peak returns false", + enable: true, + currentPnLPct: 20, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "pullback below threshold returns false", + enable: true, + currentPnLPct: 15, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: false, + }, + { + name: "pullback at threshold returns true", + enable: true, + currentPnLPct: 12, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: true, + }, + { + name: "pullback above threshold returns true", + enable: true, + currentPnLPct: 6, + peakPnLPct: 20, + minProfitPct: 5, + pullbackPct: 40, + expected: true, + }, + { + name: "default thresholds 5% profit and 40% pullback", + enable: true, + currentPnLPct: 6, + peakPnLPct: 10, + minProfitPct: 5, + pullbackPct: 40, + expected: true, + }, + { + name: "custom thresholds 10% profit 30% pullback", + enable: true, + currentPnLPct: 14, + peakPnLPct: 20, + minProfitPct: 10, + pullbackPct: 30, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldTriggerDrawdownClose(tt.enable, tt.currentPnLPct, tt.peakPnLPct, tt.minProfitPct, tt.pullbackPct) + if got != tt.expected { + t.Errorf("shouldTriggerDrawdownClose(%v, %.2f, %.2f, %.2f, %.2f) = %v, want %v", + tt.enable, tt.currentPnLPct, tt.peakPnLPct, tt.minProfitPct, tt.pullbackPct, got, tt.expected) + } + }) + } +} diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 64be039fd8..8f95ec21f5 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -42,6 +42,13 @@ export function RiskControlEditor({ minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' }, minConfidence: { zh: '最小信心度', en: 'Min Confidence' }, minConfidenceDesc: { zh: 'AI 开仓信心度阈值', en: 'AI confidence threshold for entry' }, + profitDrawdownClose: { zh: '盈利回撤平仓', en: 'Profit drawdown close' }, + profitDrawdownCloseDesc: { zh: '当持仓盈利高于「最低盈利%」且从峰值回撤达到「回撤%」时自动平仓,锁定利润', en: 'Close position when profit is above min % and pullback from peak reaches threshold (lock in gains)' }, + enableDrawdownClose: { zh: '启用盈利回撤平仓', en: 'Enable profit drawdown close' }, + minProfitPct: { zh: '最低盈利 %', en: 'Min profit %' }, + minProfitPctDesc: { zh: '仅当当前盈利高于此百分比时才考虑平仓', en: 'Only consider closing when current profit is above this %' }, + pullbackFromPeakPct: { zh: '回撤 %(相对峰值)', en: 'Pullback from peak %' }, + pullbackFromPeakPctDesc: { zh: '当盈利从峰值回撤达到此百分比时平仓', en: 'Close when profit has pulled back this much from peak' }, } return translations[key]?.[language] || key } @@ -386,6 +393,97 @@ export function RiskControlEditor({ + + {/* Profit drawdown close */} +
+
+ +

+ {t('profitDrawdownClose')} +

+
+

+ {t('profitDrawdownCloseDesc')} +

+
+ +
+
+
+ +

+ {t('minProfitPctDesc')} +

+
+ + updateField('drawdown_close_min_profit_pct', parseFloat(e.target.value) || 0) + } + disabled={disabled} + min={0} + max={100} + step={0.5} + className="w-20 px-3 py-2 rounded" + style={{ + background: '#1E2329', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + % +
+
+
+ +

+ {t('pullbackFromPeakPctDesc')} +

+
+ + updateField('drawdown_close_pct', parseFloat(e.target.value) || 0) + } + disabled={disabled} + min={0} + max={100} + step={5} + className="w-20 px-3 py-2 rounded" + style={{ + background: '#1E2329', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + % +
+
+
+
) } diff --git a/web/src/types.ts b/web/src/types.ts index 703dc61137..2bef10ffee 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -607,6 +607,11 @@ 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) + + // Profit drawdown close: close when current profit > min and pullback from peak >= % (CODE ENFORCED) + enable_drawdown_close?: boolean; // default true; false = disabled + drawdown_close_min_profit_pct?: number; // min current profit % to consider (default 5) + drawdown_close_pct?: number; // close when pullback from peak >= this % (default 40) } // Debate Arena Types