diff --git a/backtest/config.go b/backtest/config.go index 5cb5d09b7e..d6cef5e706 100644 --- a/backtest/config.go +++ b/backtest/config.go @@ -226,6 +226,9 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { result.CustomPrompt = cfg.CustomPrompt } + // Normalize trailing stop defaults for legacy strategies + result.RiskControl.TrailingStop = result.RiskControl.TrailingStop.WithDefaults() + return &result } @@ -241,12 +244,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { return &store.StrategyConfig{ CoinSource: store.CoinSourceConfig{ - SourceType: "static", + SourceType: "static", StaticCoins: cfg.Symbols, - UseAI500: false, - AI500Limit: len(cfg.Symbols), - UseOITop: false, - OITopLimit: 0, + UseAI500: false, + AI500Limit: len(cfg.Symbols), + UseOITop: false, + OITopLimit: 0, }, Indicators: store.IndicatorConfig{ Klines: store.KlineConfig{ @@ -272,6 +275,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { CustomPrompt: cfg.CustomPrompt, RiskControl: store.RiskControlConfig{ MaxPositions: 3, + TrailingStop: store.DefaultTrailingStopConfig(), BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage, AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage, BTCETHMaxPositionValueRatio: 5.0, diff --git a/backtest/runner.go b/backtest/runner.go index 70d2c5eb2e..b4b07fc1ce 100644 --- a/backtest/runner.go +++ b/backtest/runner.go @@ -60,11 +60,20 @@ type Runner struct { aiCache *AICache cachePath string + trailingCfg store.TrailingStopConfig + trailingState map[string]trailingState + trailingMu sync.Mutex + lockInfo *RunLockInfo lockStop chan struct{} lockStopOnce sync.Once // Ensures lockStop is closed only once } +type trailingState struct { + PeakPnLPct float64 + PeakPrice float64 +} + // NewRunner constructs a backtest runner. func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) { if err := ensureRunDir(cfg.RunID); err != nil { @@ -126,6 +135,8 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) { feed: feed, account: account, strategyEngine: strategyEngine, + trailingCfg: strategyConfig.RiskControl.TrailingStop.WithDefaults(), + trailingState: make(map[string]trailingState), decisionLogDir: dLogDir, mcpClient: client, status: RunStateCreated, @@ -295,6 +306,18 @@ func (r *Runner) stepOnce() error { hadError bool ) + // Apply trailing stops before asking AI to make new decisions + trailingEvents, trailingLogs, trailingErr := r.applyTrailingStops(ts, priceMap, state.DecisionCycle) + if len(trailingEvents) > 0 { + tradeEvents = append(tradeEvents, trailingEvents...) + } + if len(trailingLogs) > 0 { + execLog = append(execLog, trailingLogs...) + } + if trailingErr { + hadError = true + } + decisionAttempted := shouldDecide if shouldDecide { @@ -771,6 +794,173 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6 } } +// applyTrailingStops enforces trailing stop rules on open positions before AI decisions +func (r *Runner) applyTrailingStops(ts int64, priceMap map[string]float64, cycle int) ([]TradeEvent, []string, bool) { + cfg := r.trailingCfg + if !cfg.Enabled { + return nil, nil, false + } + if cfg.Mode == "" { + cfg.Mode = "pnl_pct" + } + + events := make([]TradeEvent, 0) + logs := make([]string, 0) + activeKeys := make(map[string]struct{}) + hasError := false + + for _, pos := range r.account.Positions() { + price, ok := priceMap[pos.Symbol] + if !ok || price <= 0 { + logs = append(logs, fmt.Sprintf("⚠️ Trailing stop skipped %s %s: price unavailable", pos.Symbol, pos.Side)) + continue + } + + leverage := pos.Leverage + if leverage <= 0 { + leverage = 1 + } + + var currentPnLPct float64 + if pos.Side == "long" { + currentPnLPct = ((price - pos.EntryPrice) / pos.EntryPrice) * float64(leverage) * 100 + } else { + currentPnLPct = ((pos.EntryPrice - price) / pos.EntryPrice) * float64(leverage) * 100 + } + + key := fmt.Sprintf("%s:%s", pos.Symbol, pos.Side) + activeKeys[key] = struct{}{} + + r.trailingMu.Lock() + state := r.trailingState[key] + if state.PeakPnLPct == 0 && state.PeakPrice == 0 { + state.PeakPnLPct = currentPnLPct + state.PeakPrice = price + } + if cfg.Mode == "price_pct" { + if pos.Side == "long" { + if price > state.PeakPrice { + state.PeakPrice = price + } + } else { + if state.PeakPrice == 0 || price < state.PeakPrice { + state.PeakPrice = price + } + } + } else { + if currentPnLPct > state.PeakPnLPct { + state.PeakPnLPct = currentPnLPct + } + } + r.trailingState[key] = state + r.trailingMu.Unlock() + + // Determine active trail pct with tightening + activeTrail := cfg.TrailPct + for _, band := range cfg.TightenBands { + if currentPnLPct >= band.ProfitPct && band.TrailPct > 0 { + activeTrail = band.TrailPct + } + } + + // Activation gate based on PnL% + if cfg.ActivationPct > 0 && currentPnLPct < cfg.ActivationPct && state.PeakPnLPct < cfg.ActivationPct { + continue + } + + trigger := false + switch cfg.Mode { + case "price_pct": + peakPrice := state.PeakPrice + if peakPrice == 0 { + peakPrice = price + } + if pos.Side == "long" { + stopPrice := peakPrice * (1 - activeTrail/100) + if price <= stopPrice { + trigger = true + } + } else { + stopPrice := peakPrice * (1 + activeTrail/100) + if price >= stopPrice { + trigger = true + } + } + default: + stopPnL := state.PeakPnLPct - activeTrail + if currentPnLPct <= stopPnL { + trigger = true + } + } + + if trigger { + closeQty := pos.Quantity + if cfg.ClosePct > 0 && cfg.ClosePct < 1 { + closeQty = pos.Quantity * cfg.ClosePct + } + fillPrice := r.executionPrice(pos.Symbol, price, ts) + realized, fee, execPrice, err := r.account.Close(pos.Symbol, pos.Side, closeQty, fillPrice) + if err != nil { + logs = append(logs, fmt.Sprintf("❌ Trailing stop close failed (%s %s): %v", pos.Symbol, pos.Side, err)) + hasError = true + continue + } + + r.trailingMu.Lock() + if closeQty >= pos.Quantity || closeQty == 0 { + delete(r.trailingState, key) + } else { + r.trailingState[key] = trailingState{ + PeakPnLPct: currentPnLPct, + PeakPrice: price, + } + } + r.trailingMu.Unlock() + + action := "close_long" + slippage := price - execPrice + if pos.Side == "short" { + action = "close_short" + slippage = execPrice - price + } + + events = append(events, TradeEvent{ + Timestamp: ts, + Symbol: pos.Symbol, + Action: action, + Side: pos.Side, + Quantity: closeQty, + Price: execPrice, + Fee: fee, + Slippage: slippage, + OrderValue: execPrice * closeQty, + RealizedPnL: realized - fee, + Leverage: leverage, + Cycle: cycle, + PositionAfter: r.remainingPosition(pos.Symbol, pos.Side), + Note: "trailing_stop", + }) + + logs = append(logs, fmt.Sprintf("🚨 Trailing stop triggered: %s %s | Profit: %.2f%% | Peak: %.2f%% | Trail: %.2f%% (mode=%s, closePct=%.2f)", + pos.Symbol, pos.Side, currentPnLPct, state.PeakPnLPct, activeTrail, cfg.Mode, cfg.ClosePct)) + } else { + logs = append(logs, fmt.Sprintf("📊 Trailing stop armed: %s %s | Profit: %.2f%% | Peak: %.2f%% | Trail: %.2f%% (mode=%s)", + pos.Symbol, pos.Side, currentPnLPct, state.PeakPnLPct, activeTrail, cfg.Mode)) + } + } + + // Prune cache entries for closed positions + r.trailingMu.Lock() + for key := range r.trailingState { + if _, ok := activeKeys[key]; !ok { + delete(r.trailingState, key) + } + } + r.trailingMu.Unlock() + + return events, logs, hasError +} + // MinPositionSizeUSD is the minimum position size in USD to avoid dust positions const MinPositionSizeUSD = 10.0 diff --git a/store/strategy.go b/store/strategy.go index 1b3f5b1106..945daa04bc 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -3,6 +3,7 @@ package store import ( "encoding/json" "fmt" + "math" "time" "gorm.io/gorm" @@ -21,8 +22,8 @@ type Strategy struct { Description string `gorm:"default:''" json:"description"` IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"` IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"` - IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market - ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible + IsPublic bool `gorm:"column:is_public;default:false;index" json:"is_public"` // whether visible in strategy market + ConfigVisible bool `gorm:"column:config_visible;default:true" json:"config_visible"` // whether config details are visible Config string `gorm:"not null;default:'{}'" json:"config"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -47,6 +48,26 @@ type StrategyConfig struct { PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"` } +// TrailingStopConfig configures the trailing stop +type TrailingStopConfig struct { + Enabled bool `json:"enabled"` // Whether trailing stop is enabled + Mode string `json:"mode,omitempty"` // "pnl_pct" (default) or "price_pct" + ActivationPct float64 `json:"activation_pct,omitempty"` // Start trailing after this profit % (0 = trail immediately) + TrailPct float64 `json:"trail_pct,omitempty"` // Trailing distance in percentage points + CheckIntervalSec int `json:"check_interval_sec,omitempty"` // Monitor interval in seconds (legacy) + CheckIntervalMs int `json:"check_interval_ms,omitempty"` // Monitor interval in milliseconds (preferred for WS-driven flow) + TightenBands []TrailingTightenBand `json:"tighten_bands,omitempty"` // Optional tightening bands + ClosePct float64 `json:"close_pct,omitempty"` // Portion to close when triggered (1.0 = full close) + Provided bool `json:"-"` // Whether trailing_stop was present in the payload + EnabledSet bool `json:"-"` // Whether enabled flag was explicitly provided +} + +// TrailingTightenBand defines a profit band where the trail is tightened +type TrailingTightenBand struct { + ProfitPct float64 `json:"profit_pct"` // Profit % threshold to apply this band + TrailPct float64 `json:"trail_pct"` // Trail % to apply once threshold is reached +} + // PromptSectionsConfig editable sections of System Prompt type PromptSectionsConfig struct { // role definition (title + description) @@ -89,7 +110,7 @@ type IndicatorConfig struct { EnableMACD bool `json:"enable_macd"` EnableRSI bool `json:"enable_rsi"` EnableATR bool `json:"enable_atr"` - EnableBOLL bool `json:"enable_boll"` // Bollinger Bands + EnableBOLL bool `json:"enable_boll"` // Bollinger Bands EnableVolume bool `json:"enable_volume"` EnableOI bool `json:"enable_oi"` // open interest EnableFundingRate bool `json:"enable_funding_rate"` // funding rate @@ -147,10 +168,10 @@ type KlineConfig struct { // ExternalDataSource external data source configuration type ExternalDataSource struct { - Name string `json:"name"` // data source name - Type string `json:"type"` // type: "api" | "webhook" - URL string `json:"url"` // API URL - Method string `json:"method"` // HTTP method + Name string `json:"name"` // data source name + Type string `json:"type"` // type: "api" | "webhook" + URL string `json:"url"` // API URL + Method string `json:"method"` // HTTP method Headers map[string]string `json:"headers,omitempty"` DataPath string `json:"data_path,omitempty"` // JSON data path RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds) @@ -161,6 +182,9 @@ type RiskControlConfig struct { // Max number of coins held simultaneously (CODE ENFORCED) MaxPositions int `json:"max_positions"` + // Trailing stop / drawdown monitor (CODE ENFORCED) + TrailingStop TrailingStopConfig `json:"trailing_stop,omitempty"` + // BTC/ETH exchange leverage for opening positions (AI guided) BTCETHMaxLeverage int `json:"btc_eth_max_leverage"` // Altcoin exchange leverage for opening positions (AI guided) @@ -187,6 +211,117 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore { return &StrategyStore{db: db} } +// DefaultTrailingStopConfig returns the default trailing stop config +func DefaultTrailingStopConfig() TrailingStopConfig { + return TrailingStopConfig{ + Enabled: true, + Mode: "pnl_pct", + ActivationPct: 0.0, // Trail immediately by default + TrailPct: 3.0, // Trail by 3 percentage points + CheckIntervalSec: 30, // Check every 30 seconds + CheckIntervalMs: 0, // Prefer ms when provided (derived from sec when 0) + TightenBands: []TrailingTightenBand{}, + ClosePct: 1.0, // Close full position by default + } +} + +// UnmarshalJSON tracks presence of trailing_stop and enabled flag +func (c *TrailingStopConfig) UnmarshalJSON(data []byte) error { + type Alias TrailingStopConfig + aux := &struct { + Enabled *bool `json:"enabled"` + *Alias + }{ + Alias: (*Alias)(c), + } + + // Reset before unmarshalling to avoid stale markers + *c = TrailingStopConfig{} + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + c.Provided = true + if aux.Enabled != nil { + c.Enabled = *aux.Enabled + c.EnabledSet = true + } + + return nil +} + +// WithDefaults fills missing fields with defaults while respecting explicit values +func (c TrailingStopConfig) WithDefaults() TrailingStopConfig { + def := DefaultTrailingStopConfig() + + isZeroConfig := c.Mode == "" && c.TrailPct == 0 && c.CheckIntervalSec == 0 && c.CheckIntervalMs == 0 && c.ClosePct == 0 && len(c.TightenBands) == 0 && c.ActivationPct == 0 + + // Legacy strategies that omitted trailing_stop entirely: enable with defaults + if !c.Provided && isZeroConfig { + return def + } + + normalize := func(cfg TrailingStopConfig) TrailingStopConfig { + if cfg.Mode == "" { + cfg.Mode = def.Mode + } + if cfg.TrailPct <= 0 { + cfg.TrailPct = def.TrailPct + } + cfg = backfillIntervals(cfg, def) + if cfg.ClosePct <= 0 || cfg.ClosePct > 1 { + cfg.ClosePct = def.ClosePct + } + if cfg.TightenBands == nil { + cfg.TightenBands = []TrailingTightenBand{} + } + return cfg + } + + // Explicit disable: respect Enabled=false if the flag was provided + if c.EnabledSet && !c.Enabled { + return normalize(c) + } + + // Empty but provided config falls back to defaults (enabled) + if isZeroConfig { + c = def + } else { + c = normalize(c) + } + + // Default to enabled when not explicitly disabled + c.Enabled = true + return c +} + +// backfillIntervals harmonizes second/ms interval fields with sensible defaults +func backfillIntervals(c TrailingStopConfig, def TrailingStopConfig) TrailingStopConfig { + // Prefer ms if provided + if c.CheckIntervalMs <= 0 { + if c.CheckIntervalSec > 0 { + c.CheckIntervalMs = c.CheckIntervalSec * 1000 + } else if def.CheckIntervalMs > 0 { + c.CheckIntervalMs = def.CheckIntervalMs + } else { + c.CheckIntervalMs = def.CheckIntervalSec * 1000 + } + } + + // Keep sec in sync (legacy callers/UI may rely on it) + if c.CheckIntervalSec <= 0 { + derived := int(math.Round(float64(c.CheckIntervalMs) / 1000)) + if derived > 0 { + c.CheckIntervalSec = derived + } else if c.CheckIntervalMs == 0 { + c.CheckIntervalSec = def.CheckIntervalSec + } + } + + return c +} + func (s *StrategyStore) initTables() error { // AutoMigrate will add missing columns without dropping existing data return s.db.AutoMigrate(&Strategy{}) @@ -256,15 +391,16 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { PriceRankingLimit: 10, }, RiskControl: RiskControlConfig{ - MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) - BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided) - AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided) - BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED) - 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) + MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) + TrailingStop: DefaultTrailingStopConfig(), + BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided) + AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided) + BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED) + 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) }, } diff --git a/store/trailing_stop_config_test.go b/store/trailing_stop_config_test.go new file mode 100644 index 0000000000..31ddf70264 --- /dev/null +++ b/store/trailing_stop_config_test.go @@ -0,0 +1,106 @@ +package store + +import ( + "encoding/json" + "testing" +) + +func TestTrailingStopWithDefaultsLegacyOmitted(t *testing.T) { + cfg := TrailingStopConfig{} + + res := cfg.WithDefaults() + + if !res.Enabled { + t.Fatalf("expected legacy omitted config to be enabled by default") + } + if res.Mode != "pnl_pct" { + t.Fatalf("expected default mode, got %s", res.Mode) + } + if res.CheckIntervalSec != 30 { + t.Fatalf("expected default interval 30s, got %d", res.CheckIntervalSec) + } + if res.ClosePct != 1.0 { + t.Fatalf("expected default close pct 1.0, got %f", res.ClosePct) + } +} + +func TestTrailingStopWithDefaultsExplicitDisable(t *testing.T) { + cfg := TrailingStopConfig{ + Enabled: false, + Provided: true, + EnabledSet: true, + } + + res := cfg.WithDefaults() + + if res.Enabled { + t.Fatalf("expected explicit disable to stay disabled") + } + if res.Mode == "" { + t.Fatalf("expected mode to be backfilled even when disabled") + } + if res.CheckIntervalMs == 0 { + t.Fatalf("expected interval to be backfilled when disabled") + } +} + +func TestTrailingStopWithDefaultsEnabledNormalization(t *testing.T) { + cfg := TrailingStopConfig{ + Enabled: true, + EnabledSet: true, + Provided: true, + Mode: "price_pct", + TrailPct: 1.5, + CheckIntervalMs: 1200, + ActivationPct: 0.2, + ClosePct: 0, // should backfill to default + TightenBands: nil, + CheckIntervalSec: 0, + } + + res := cfg.WithDefaults() + + if !res.Enabled { + t.Fatalf("expected enabled config to stay enabled") + } + if res.Mode != "price_pct" || res.TrailPct != 1.5 { + t.Fatalf("expected custom mode and trail pct to be preserved") + } + if res.CheckIntervalSec == 0 || res.CheckIntervalMs != 1200 { + t.Fatalf("expected interval backfilled in both units, got sec=%d ms=%d", res.CheckIntervalSec, res.CheckIntervalMs) + } + if res.ClosePct != 1.0 { + t.Fatalf("expected close pct to backfill to default 1.0, got %f", res.ClosePct) + } + if res.TightenBands == nil { + t.Fatalf("expected tighten bands slice initialized") + } +} + +func TestTrailingStopUnmarshalPresence(t *testing.T) { + jsonData := []byte(`{"risk_control":{}}`) + var cfg StrategyConfig + if err := json.Unmarshal(jsonData, &cfg); err != nil { + t.Fatalf("failed to unmarshal strategy: %v", err) + } + if cfg.RiskControl.TrailingStop.Provided { + t.Fatalf("expected trailing stop to be marked not provided when field is absent") + } +} + +func TestTrailingStopUnmarshalEnabledMarker(t *testing.T) { + jsonData := []byte(`{"risk_control":{"trailing_stop":{"enabled":false}}}`) + var cfg StrategyConfig + if err := json.Unmarshal(jsonData, &cfg); err != nil { + t.Fatalf("failed to unmarshal strategy: %v", err) + } + if !cfg.RiskControl.TrailingStop.Provided { + t.Fatalf("expected trailing stop to be marked provided when field exists") + } + if !cfg.RiskControl.TrailingStop.EnabledSet { + t.Fatalf("expected enabled flag presence to be tracked") + } + if cfg.RiskControl.TrailingStop.Enabled { + t.Fatalf("expected enabled to be false from payload") + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 2f0daee06c..e2d5b8c659 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" "math" - "nofx/kernel" "nofx/experience" + "nofx/kernel" "nofx/logger" "nofx/market" "nofx/mcp" @@ -35,13 +35,13 @@ type AutoTraderConfig struct { BybitSecretKey string // OKX API configuration - OKXAPIKey string - OKXSecretKey string + OKXAPIKey string + OKXSecretKey string OKXPassphrase string // Bitget API configuration - BitgetAPIKey string - BitgetSecretKey string + BitgetAPIKey string + BitgetSecretKey string BitgetPassphrase string // Hyperliquid configuration @@ -94,35 +94,68 @@ type AutoTraderConfig struct { // AutoTrader automatic trader type AutoTrader struct { - id string // Trader unique identifier - name string // Trader display name - aiModel string // AI model name - exchange string // Trading platform type (binance/bybit/etc) - exchangeID string // Exchange account UUID - showInCompetition bool // Whether to show in competition page - config AutoTraderConfig - trader Trader // Use Trader interface (supports multiple platforms) - mcpClient mcp.AIClient - store *store.Store // Data storage (decision records, etc.) - strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration) - cycleNumber int // Current cycle number - initialBalance float64 - dailyPnL float64 - customPrompt string // Custom trading strategy prompt - overrideBasePrompt bool // Whether to override base prompt - lastResetTime time.Time - stopUntil time.Time - isRunning bool - isRunningMutex sync.RWMutex // Mutex to protect isRunning flag - startTime time.Time // System start time - callCount int // AI call count - positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds) - stopMonitorCh chan struct{} // Used to stop monitoring goroutine - monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish - peakPnLCache map[string]float64 // Peak profit cache (symbol -> peak P&L percentage) - peakPnLCacheMutex sync.RWMutex // Cache read-write lock - lastBalanceSyncTime time.Time // Last balance sync time - userID string // User ID + id string // Trader unique identifier + name string // Trader display name + aiModel string // AI model name + exchange string // Trading platform type (binance/bybit/etc) + exchangeID string // Exchange account UUID + showInCompetition bool // Whether to show in competition page + config AutoTraderConfig + trader Trader // Use Trader interface (supports multiple platforms) + mcpClient mcp.AIClient + store *store.Store // Data storage (decision records, etc.) + strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration) + cycleNumber int // Current cycle number + initialBalance float64 + dailyPnL float64 + customPrompt string // Custom trading strategy prompt + overrideBasePrompt bool // Whether to override base prompt + lastResetTime time.Time + stopUntil time.Time + isRunning bool + isRunningMutex sync.RWMutex // Mutex to protect isRunning flag + startTime time.Time // System start time + callCount int // AI call count + positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds) + stopMonitorCh chan struct{} // Used to stop monitoring goroutine + monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish + trailingState map[string]TrailingState // Per-position trailing state + trailingStateMutex sync.RWMutex // Cache read-write lock + trailingPositions map[string]trailingPosition + trailingPositionsMu sync.RWMutex + trailingPrices map[string]float64 + trailingPricesMu sync.RWMutex + trailingEvalCh chan trailingEvalTask + trailingHealthMu sync.RWMutex + lastTrailingPriceEvent time.Time + lastTrailingRefresh time.Time + trailingCloseMu sync.Mutex + trailingClosing map[string]struct{} + lastBalanceSyncTime time.Time // Last balance sync time + userID string // User ID +} + +// TrailingState stores peak information per position +type TrailingState struct { + PeakPnLPct float64 + PeakPrice float64 +} + +// trailingPosition caches the minimum data needed for trailing stop evaluation +type trailingPosition struct { + Symbol string + Side string + EntryPrice float64 + MarkPrice float64 + Quantity float64 + Leverage int + LiquidationPrice float64 +} + +type trailingEvalTask struct { + cfg store.TrailingStopConfig + pos trailingPosition + price float64 } // NewAutoTrader creates an automatic trader @@ -334,8 +367,14 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au positionFirstSeenTime: make(map[string]int64), stopMonitorCh: make(chan struct{}), monitorWg: sync.WaitGroup{}, - peakPnLCache: make(map[string]float64), - peakPnLCacheMutex: sync.RWMutex{}, + trailingState: make(map[string]TrailingState), + trailingStateMutex: sync.RWMutex{}, + trailingPositions: make(map[string]trailingPosition), + trailingPositionsMu: sync.RWMutex{}, + trailingPrices: make(map[string]float64), + trailingPricesMu: sync.RWMutex{}, + trailingEvalCh: nil, + trailingClosing: make(map[string]struct{}), lastBalanceSyncTime: time.Now(), userID: userID, }, nil @@ -357,8 +396,13 @@ func (at *AutoTrader) Run() error { at.monitorWg.Add(1) defer at.monitorWg.Done() - // Start drawdown monitoring - at.startDrawdownMonitor() + // Start trailing stop monitoring (drawdown-based) + tsConfig := at.trailingStopConfig() + if tsConfig.Enabled { + at.startTrailingStopMonitor(tsConfig) + } else { + logger.Info("⏸ Trailing stop monitor disabled by strategy config") + } // Start Lighter order sync if using Lighter exchange if at.exchange == "lighter" { @@ -763,10 +807,10 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { updateTime = at.positionFirstSeenTime[posKey] } - // Get peak profit rate for this position - at.peakPnLCacheMutex.RLock() - peakPnlPct := at.peakPnLCache[posKey] - at.peakPnLCacheMutex.RUnlock() + // Get peak profit rate for this position (from trailing state) + at.trailingStateMutex.RLock() + peakPnlPct := at.trailingState[posKey].PeakPnLPct + at.trailingStateMutex.RUnlock() positionInfos = append(positionInfos, kernel.PositionInfo{ Symbol: symbol, @@ -1592,6 +1636,8 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { return nil, fmt.Errorf("failed to get positions: %w", err) } + trailingCfg := at.trailingStopConfig() + var result []map[string]interface{} for _, pos := range positions { symbol := pos["symbol"].(string) @@ -1616,6 +1662,112 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { // Calculate P&L percentage (based on margin) pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed) + // Calculate current P&L percentage aligned with trailing stop logic (price-based) + var currentPnLPct float64 + if side == "long" { + currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100 + } else { + currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100 + } + + // Trailing stop status (for UI visibility) + trailingInfo := map[string]interface{}{ + "enabled": trailingCfg.Enabled, + "status": "disabled", + } + if trailingCfg.Enabled { + posKey := symbol + "_" + side + at.trailingStateMutex.RLock() + state := at.trailingState[posKey] + at.trailingStateMutex.RUnlock() + + // Helper to derive PnL% from price (handles long/short) + calcPnLFromPrice := func(price float64) float64 { + if price == 0 { + return 0 + } + if side == "long" { + return ((price - entryPrice) / entryPrice) * float64(leverage) * 100 + } + return ((entryPrice - price) / entryPrice) * float64(leverage) * 100 + } + + peakPrice := state.PeakPrice + if peakPrice == 0 { + peakPrice = markPrice + } + peakPnLPct := state.PeakPnLPct + if peakPnLPct == 0 { + // Derive from peak price when no PnL cache yet + peakPnLPct = calcPnLFromPrice(peakPrice) + } + + // Activation gate: only armed after profit meets threshold + activationReached := !(trailingCfg.ActivationPct > 0 && currentPnLPct < trailingCfg.ActivationPct && peakPnLPct < trailingCfg.ActivationPct) + status := "armed" + if !activationReached { + status = "waiting_activation" + } + + // Apply tighten bands + activeTrailPct := trailingCfg.TrailPct + for _, band := range trailingCfg.TightenBands { + if currentPnLPct >= band.ProfitPct && band.TrailPct > 0 { + activeTrailPct = band.TrailPct + } + } + + var stopPrice float64 + var stopPnLPct float64 + if activationReached { + switch trailingCfg.Mode { + case "price_pct": + if side == "long" { + stopPrice = peakPrice * (1 - activeTrailPct/100) + } else { + stopPrice = peakPrice * (1 + activeTrailPct/100) + } + stopPnLPct = calcPnLFromPrice(stopPrice) + default: // pnl_pct + stopPnLPct = peakPnLPct - activeTrailPct + if side == "long" { + stopPrice = entryPrice * (1 + stopPnLPct/(100*float64(leverage))) + } else { + stopPrice = entryPrice * (1 - stopPnLPct/(100*float64(leverage))) + } + } + } + + // Activation price (if applicable) + var activationPrice float64 + if trailingCfg.ActivationPct > 0 { + if side == "long" { + activationPrice = entryPrice * (1 + trailingCfg.ActivationPct/(100*float64(leverage))) + } else { + activationPrice = entryPrice * (1 - trailingCfg.ActivationPct/(100*float64(leverage))) + } + } + + trailingInfo = map[string]interface{}{ + "enabled": true, + "status": status, + "mode": trailingCfg.Mode, + "activation_pct": trailingCfg.ActivationPct, + "activation_price": activationPrice, + "trail_pct": trailingCfg.TrailPct, + "active_trail_pct": activeTrailPct, + "close_pct": trailingCfg.ClosePct, + "peak_pnl_pct": peakPnLPct, + "peak_price": peakPrice, + "stop_pnl_pct": stopPnLPct, + "stop_price": stopPrice, + "current_pnl_pct": currentPnLPct, + "last_mark_price": markPrice, + "last_entry_price": entryPrice, + "activation_reached": activationReached, + } + } + result = append(result, map[string]interface{}{ "symbol": symbol, "side": side, @@ -1627,6 +1779,7 @@ func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { "unrealized_pnl_pct": pnlPct, "liquidation_price": liquidationPrice, "margin_used": marginUsed, + "trailing": trailingInfo, }) } @@ -1679,38 +1832,262 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision { return sorted } -// startDrawdownMonitor starts drawdown monitoring -func (at *AutoTrader) startDrawdownMonitor() { - at.monitorWg.Add(1) - go func() { - defer at.monitorWg.Done() +// trailingStopConfig returns trailing stop configuration with defaults applied +func (at *AutoTrader) trailingStopConfig() store.TrailingStopConfig { + if at.config.StrategyConfig == nil { + return store.DefaultTrailingStopConfig() + } + return at.config.StrategyConfig.RiskControl.TrailingStop.WithDefaults() +} - ticker := time.NewTicker(1 * time.Minute) // Check every minute - defer ticker.Stop() +// trailingInterval returns the configured monitoring cadence with ms support and sane fallback +func (at *AutoTrader) trailingInterval(cfg store.TrailingStopConfig) time.Duration { + if cfg.CheckIntervalMs > 0 { + return time.Duration(cfg.CheckIntervalMs) * time.Millisecond + } + if cfg.CheckIntervalSec > 0 { + return time.Duration(cfg.CheckIntervalSec) * time.Second + } + return 30 * time.Second +} - logger.Info("📊 Started position drawdown monitoring (check every minute)") +// startTrailingEvaluator spins up a worker that processes trailing evaluations off the price/feed path +func (at *AutoTrader) startTrailingEvaluator() { + if at.trailingEvalCh != nil { + return + } + at.trailingEvalCh = make(chan trailingEvalTask, 1024) + at.monitorWg.Add(1) + go func() { + defer at.monitorWg.Done() for { select { - case <-ticker.C: - at.checkPositionDrawdown() + case task := <-at.trailingEvalCh: + at.evaluateTrailingStop(task.cfg, task.pos, task.price) case <-at.stopMonitorCh: - logger.Info("⏹ Stopped position drawdown monitoring") return } } }() } -// checkPositionDrawdown checks position drawdown situation -func (at *AutoTrader) checkPositionDrawdown() { +// enqueueTrailingEval routes an evaluation to the worker, falling back to inline if worker missing +func (at *AutoTrader) enqueueTrailingEval(cfg store.TrailingStopConfig, pos trailingPosition, markPrice float64) { + if at.trailingEvalCh == nil { + at.evaluateTrailingStop(cfg, pos, markPrice) + return + } + + select { + case at.trailingEvalCh <- trailingEvalTask{cfg: cfg, pos: pos, price: markPrice}: + default: + logger.Infof("⚠️ Trailing stop worker queue is full, dropping evaluation for %s %s", pos.Symbol, pos.Side) + } +} + +func (at *AutoTrader) tryBeginTrailingClose(posKey string) bool { + at.trailingCloseMu.Lock() + defer at.trailingCloseMu.Unlock() + if _, ok := at.trailingClosing[posKey]; ok { + return false + } + at.trailingClosing[posKey] = struct{}{} + return true +} + +func (at *AutoTrader) finishTrailingClose(posKey string) { + at.trailingCloseMu.Lock() + defer at.trailingCloseMu.Unlock() + delete(at.trailingClosing, posKey) +} + +// startTrailingStopMonitor starts drawdown-based trailing stop monitoring +func (at *AutoTrader) startTrailingStopMonitor(cfg store.TrailingStopConfig) { + // Ensure evaluation worker is available to keep price feed responsive + at.startTrailingEvaluator() + + at.monitorWg.Add(1) + go func() { + defer at.monitorWg.Done() + + interval := at.trailingInterval(cfg) + + // Prefer websocket-driven monitoring for Hyperliquid to achieve ms-level responsiveness + if at.exchange == "hyperliquid" && cfg.Enabled { + if err := at.runHyperliquidTrailingMonitor(cfg, interval); err == nil { + return + } else { + logger.Infof("⚠️ Hyperliquid websocket monitor unavailable, falling back to polling: %v", err) + } + } + + at.runPollingTrailingMonitor(cfg, interval) + }() +} + +// runPollingTrailingMonitor runs the legacy ticker-based trailing stop monitor +func (at *AutoTrader) runPollingTrailingMonitor(cfg store.TrailingStopConfig, interval time.Duration) { + if interval <= 0 { + interval = time.Minute + } + + minInterval := 50 * time.Millisecond + if interval < minInterval { + interval = minInterval + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + logger.Infof("📊 Started trailing stop monitoring (mode=%s, every %v, activate at %.2f%%, trail %.2f%%)", + cfg.Mode, interval, cfg.ActivationPct, cfg.TrailPct) + + for { + select { + case <-ticker.C: + at.checkTrailingStops(cfg) + case <-at.stopMonitorCh: + logger.Info("⏹ Stopped trailing stop monitoring") + return + } + } +} + +// runHyperliquidTrailingMonitor uses websocket price updates for fast trailing stops +func (at *AutoTrader) runHyperliquidTrailingMonitor(cfg store.TrailingStopConfig, interval time.Duration) error { + stream := NewHyperliquidPriceStream(at.config.HyperliquidTestnet) + if err := stream.Start(); err != nil { + return fmt.Errorf("failed to start Hyperliquid price stream: %w", err) + } + defer stream.Close() + + priceCh := make(chan hyperliquidPriceEvent, 512) + activeSubs := make(map[string]struct{}) + + subscribe := func(coins map[string]struct{}) { + for coin := range coins { + if _, ok := activeSubs[coin]; ok { + continue + } + normalizedSymbol := hyperliquidCoinToSymbol(coin) + symbol := normalizedSymbol + if err := stream.SubscribeBbo(coin, func(price float64, ts time.Time) { + ev := hyperliquidPriceEvent{ + symbol: symbol, + price: price, + ts: ts, + } + select { + case priceCh <- ev: + default: + logger.Infof("⚠️ Hyperliquid price channel full, dropping event for %s", symbol) + } + }); err != nil { + logger.Infof("⚠️ Failed to subscribe Hyperliquid price for %s: %v", coin, err) + continue + } + activeSubs[coin] = struct{}{} + logger.Infof("🔌 Subscribed Hyperliquid price stream for %s (%s)", coin, normalizedSymbol) + } + } + + // Initial refresh to populate caches and subscriptions + at.checkTrailingStops(cfg) + subscribe(at.hyperliquidCoinsFromCache()) + + refreshInterval := interval + if refreshInterval < time.Second { + refreshInterval = time.Second + } + + refreshTicker := time.NewTicker(refreshInterval) + defer refreshTicker.Stop() + + for { + select { + case ev := <-priceCh: + at.trailingHealthMu.Lock() + at.lastTrailingPriceEvent = ev.ts + at.trailingHealthMu.Unlock() + for _, pos := range at.trailingPositionsForSymbol(ev.symbol) { + at.enqueueTrailingEval(cfg, pos, ev.price) + } + case <-refreshTicker.C: + // Periodically refresh positions to keep entry/qty/leverage accurate + at.checkTrailingStops(cfg) + at.trailingHealthMu.Lock() + at.lastTrailingRefresh = time.Now() + at.trailingHealthMu.Unlock() + + // Resubscribe for new/removed symbols + currentCoins := at.hyperliquidCoinsFromCache() + subscribe(currentCoins) + for coin := range activeSubs { + if _, ok := currentCoins[coin]; !ok { + stream.Unsubscribe(coin) + delete(activeSubs, coin) + logger.Infof("⏸ Unsubscribed Hyperliquid price stream for %s (no open position)", coin) + } + } + case <-at.stopMonitorCh: + logger.Info("⏹ Stopped Hyperliquid trailing stop monitoring") + return nil + } + } +} + +// hyperliquidCoinsFromCache converts cached position symbols to Hyperliquid coin names +func (at *AutoTrader) hyperliquidCoinsFromCache() map[string]struct{} { + at.trailingPositionsMu.RLock() + defer at.trailingPositionsMu.RUnlock() + + coins := make(map[string]struct{}) + for _, pos := range at.trailingPositions { + coin := convertSymbolToHyperliquid(pos.Symbol) + if coin != "" { + coins[coin] = struct{}{} + } + } + return coins +} + +// trailingPositionsForSymbol returns cached positions for a symbol (both long/short) +func (at *AutoTrader) trailingPositionsForSymbol(symbol string) []trailingPosition { + at.trailingPositionsMu.RLock() + defer at.trailingPositionsMu.RUnlock() + + var result []trailingPosition + for key, pos := range at.trailingPositions { + if strings.HasPrefix(key, symbol+"_") { + result = append(result, pos) + } + } + return result +} + +// hyperliquidCoinToSymbol converts Hyperliquid coin codes back to platform symbols +func hyperliquidCoinToSymbol(coin string) string { + base := strings.ToUpper(coin) + if strings.HasPrefix(strings.ToLower(base), "xyz:") { + clean := strings.TrimPrefix(base, "XYZ:") + return "xyz:" + clean + } + return base + "USDT" +} + +// checkTrailingStops checks trailing stop conditions for all open positions +func (at *AutoTrader) checkTrailingStops(cfg store.TrailingStopConfig) { // Get current positions positions, err := at.trader.GetPositions() if err != nil { - logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err) + logger.Infof("❌ Trailing stop: failed to get positions: %v", err) return } + activeKeys := make(map[string]struct{}) + snapshot := make(map[string]trailingPosition) + for _, pos := range positions { symbol := pos["symbol"].(string) side := pos["side"].(string) @@ -1721,74 +2098,232 @@ func (at *AutoTrader) checkPositionDrawdown() { quantity = -quantity // Short position quantity is negative, convert to positive } - // Calculate current P&L percentage leverage := 10 // Default value if lev, ok := pos["leverage"].(float64); ok { leverage = int(lev) } - var currentPnLPct float64 - if side == "long" { - currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100 - } else { - currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100 + posKey := symbol + "_" + side + activeKeys[posKey] = struct{}{} + + tp := trailingPosition{ + Symbol: symbol, + Side: side, + EntryPrice: entryPrice, + MarkPrice: markPrice, + Quantity: quantity, + Leverage: leverage, + LiquidationPrice: pos["liquidationPrice"].(float64), } - // Construct unique position identifier (distinguish long/short) - posKey := symbol + "_" + side + snapshot[posKey] = tp + at.enqueueTrailingEval(cfg, tp, markPrice) + } + + at.trailingPositionsMu.Lock() + at.trailingPositions = snapshot + at.trailingPositionsMu.Unlock() + + // Prune peak cache entries for positions that are no longer open + at.trailingStateMutex.Lock() + for k := range at.trailingState { + if _, ok := activeKeys[k]; !ok { + delete(at.trailingState, k) + } + } + at.trailingStateMutex.Unlock() + + at.trailingPricesMu.Lock() + for k := range at.trailingPrices { + if _, ok := activeKeys[k]; !ok { + delete(at.trailingPrices, k) + } + } + at.trailingPricesMu.Unlock() + + at.trailingHealthMu.Lock() + at.lastTrailingRefresh = time.Now() + at.trailingHealthMu.Unlock() +} - // Get historical peak profit for this position - at.peakPnLCacheMutex.RLock() - peakPnLPct, exists := at.peakPnLCache[posKey] - at.peakPnLCacheMutex.RUnlock() +// evaluateTrailingStop applies trailing stop rules to a single position snapshot and executes closes when needed +func (at *AutoTrader) evaluateTrailingStop(cfg store.TrailingStopConfig, pos trailingPosition, markPrice float64) { + if !cfg.Enabled { + return + } + + if cfg.Mode == "" { + cfg.Mode = "pnl_pct" + } + + if pos.EntryPrice <= 0 || markPrice <= 0 || pos.Quantity <= 0 { + return + } + + leverage := pos.Leverage + if leverage <= 0 { + leverage = 1 + } + + posKey := pos.Symbol + "_" + pos.Side + + // Cache last seen price for UI + at.trailingPricesMu.Lock() + at.trailingPrices[posKey] = markPrice + at.trailingPricesMu.Unlock() + + // Calculate current P&L percentage + currentPnLPct := 0.0 + if pos.Side == "long" { + currentPnLPct = ((markPrice - pos.EntryPrice) / pos.EntryPrice) * float64(leverage) * 100 + } else { + currentPnLPct = ((pos.EntryPrice - markPrice) / pos.EntryPrice) * float64(leverage) * 100 + } - if !exists { - // If no historical peak record, use current P&L as initial value - peakPnLPct = currentPnLPct - at.UpdatePeakPnL(symbol, side, currentPnLPct) + // Helper to derive PnL% from price for activation/stop calculations + calcPnLFromPrice := func(price float64) float64 { + if price <= 0 { + return 0 + } + if pos.Side == "long" { + return ((price - pos.EntryPrice) / pos.EntryPrice) * float64(leverage) * 100 + } + return ((pos.EntryPrice - price) / pos.EntryPrice) * float64(leverage) * 100 + } + + // Load and update trailing state + at.trailingStateMutex.Lock() + state := at.trailingState[posKey] + if state.PeakPrice == 0 { + state.PeakPrice = markPrice + } + + // Track peak PnL regardless of mode for activation gate + if currentPnLPct > state.PeakPnLPct { + state.PeakPnLPct = currentPnLPct + } + + switch cfg.Mode { + case "price_pct": + if pos.Side == "long" { + if markPrice > state.PeakPrice { + state.PeakPrice = markPrice + } } else { - // Update peak cache - at.UpdatePeakPnL(symbol, side, currentPnLPct) + if state.PeakPrice == 0 || markPrice < state.PeakPrice { + state.PeakPrice = markPrice + } } + default: // pnl_pct + if pos.Side == "long" { + if markPrice > state.PeakPrice { + state.PeakPrice = markPrice + } + } else { + if state.PeakPrice == 0 || markPrice < state.PeakPrice { + state.PeakPrice = markPrice + } + } + } + at.trailingState[posKey] = state + at.trailingStateMutex.Unlock() - // Calculate drawdown (magnitude of decline from peak) - var drawdownPct float64 - if peakPnLPct > 0 && currentPnLPct < peakPnLPct { - drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100 + // Determine active trail percentage (tighten if bands met) + activeTrailPct := cfg.TrailPct + for _, band := range cfg.TightenBands { + if currentPnLPct >= band.ProfitPct && band.TrailPct > 0 { + activeTrailPct = band.TrailPct } + } + if activeTrailPct <= 0 { + logger.Infof("⚠️ Trailing stop active trail pct is non-positive, skipping (symbol=%s)", pos.Symbol) + return + } - // 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%%", - symbol, side, currentPnLPct, peakPnLPct, drawdownPct) + // Activation gate (use PnL% for gating) + peakPnL := state.PeakPnLPct + if peakPnL == 0 { + peakPnL = calcPnLFromPrice(state.PeakPrice) + } + if cfg.ActivationPct > 0 && currentPnLPct < cfg.ActivationPct && peakPnL < cfg.ActivationPct { + logger.Infof("📊 Trailing stop waiting activation: %s %s | Profit: %.2f%% < %.2f%%", pos.Symbol, pos.Side, currentPnLPct, cfg.ActivationPct) + return + } - // Execute close position - if err := at.emergencyClosePosition(symbol, side); err != nil { - logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err) - } else { - logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side) - // Clear cache for this position after closing - at.ClearPeakPnLCache(symbol, side) + trigger := false + switch cfg.Mode { + case "price_pct": + peakPrice := state.PeakPrice + if peakPrice == 0 { + peakPrice = markPrice + } + if pos.Side == "long" { + stopPrice := peakPrice * (1 - activeTrailPct/100) + if markPrice <= stopPrice { + trigger = true + } + } else { + stopPrice := peakPrice * (1 + activeTrailPct/100) + if markPrice >= stopPrice { + trigger = true } - } 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%%", - symbol, side, currentPnLPct, peakPnLPct, drawdownPct) } + default: // pnl_pct + stopPnL := peakPnL - activeTrailPct + if currentPnLPct <= stopPnL { + trigger = true + } + } + + if !trigger { + logger.Infof("📊 Trailing stop armed: %s %s | Profit: %.2f%% | Peak: %.2f%% | Trail: %.2f%% (mode=%s)", pos.Symbol, pos.Side, currentPnLPct, peakPnL, activeTrailPct, cfg.Mode) + return + } + + // Determine close quantity (0 = close all) + closeQty := 0.0 + if cfg.ClosePct > 0 && cfg.ClosePct < 1 { + closeQty = pos.Quantity * cfg.ClosePct + } + + logger.Infof("🚨 Trailing stop triggered: %s %s | Profit: %.2f%% | Peak: %.2f%% | Trail: %.2f%% (mode=%s, closePct=%.2f)", + pos.Symbol, pos.Side, currentPnLPct, peakPnL, activeTrailPct, cfg.Mode, cfg.ClosePct) + + if !at.tryBeginTrailingClose(posKey) { + logger.Infof("⚠️ Trailing stop close already in progress, skipping duplicate for %s %s", pos.Symbol, pos.Side) + return + } + defer at.finishTrailingClose(posKey) + + if err := at.emergencyClosePosition(pos.Symbol, pos.Side, closeQty); err != nil { + logger.Infof("❌ Trailing stop close failed (%s %s): %v", pos.Symbol, pos.Side, err) + return + } + + logger.Infof("✅ Trailing stop close succeeded: %s %s", pos.Symbol, pos.Side) + + if closeQty == 0 || closeQty >= pos.Quantity { + at.ClearTrailingState(pos.Symbol, pos.Side) + } else { + // Reset peaks to current to avoid immediate retrigger + at.SetTrailingState(pos.Symbol, pos.Side, TrailingState{ + PeakPnLPct: currentPnLPct, + PeakPrice: markPrice, + }) } } -// emergencyClosePosition emergency close position function -func (at *AutoTrader) emergencyClosePosition(symbol, side string) error { +// emergencyClosePosition emergency close position function (qty=0 closes all) +func (at *AutoTrader) emergencyClosePosition(symbol, side string, qty float64) error { switch side { case "long": - order, err := at.trader.CloseLong(symbol, 0) // 0 = close all + order, err := at.trader.CloseLong(symbol, qty) // 0 = close all if err != nil { return err } logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"]) case "short": - order, err := at.trader.CloseShort(symbol, 0) // 0 = close all + order, err := at.trader.CloseShort(symbol, qty) // 0 = close all if err != nil { return err } @@ -1800,43 +2335,32 @@ func (at *AutoTrader) emergencyClosePosition(symbol, side string) error { return nil } -// GetPeakPnLCache gets peak profit cache +// GetPeakPnLCache gets peak profit cache (legacy API, PnL only) func (at *AutoTrader) GetPeakPnLCache() map[string]float64 { - at.peakPnLCacheMutex.RLock() - defer at.peakPnLCacheMutex.RUnlock() + at.trailingStateMutex.RLock() + defer at.trailingStateMutex.RUnlock() - // Return a copy of the cache cache := make(map[string]float64) - for k, v := range at.peakPnLCache { - cache[k] = v + for k, v := range at.trailingState { + cache[k] = v.PeakPnLPct } return cache } -// UpdatePeakPnL updates peak profit cache -func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) { - at.peakPnLCacheMutex.Lock() - defer at.peakPnLCacheMutex.Unlock() - - posKey := symbol + "_" + side - if peak, exists := at.peakPnLCache[posKey]; exists { - // Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare) - if currentPnLPct > peak { - at.peakPnLCache[posKey] = currentPnLPct - } - } else { - // First time recording - at.peakPnLCache[posKey] = currentPnLPct - } +// SetTrailingState sets trailing state for a position +func (at *AutoTrader) SetTrailingState(symbol, side string, state TrailingState) { + at.trailingStateMutex.Lock() + defer at.trailingStateMutex.Unlock() + at.trailingState[symbol+"_"+side] = state } -// ClearPeakPnLCache clears peak cache for specified position -func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) { - at.peakPnLCacheMutex.Lock() - defer at.peakPnLCacheMutex.Unlock() +// ClearTrailingState clears trailing state for specified position +func (at *AutoTrader) ClearTrailingState(symbol, side string) { + at.trailingStateMutex.Lock() + defer at.trailingStateMutex.Unlock() posKey := symbol + "_" + side - delete(at.peakPnLCache, posKey) + delete(at.trailingState, posKey) } // recordAndConfirmOrder polls order status for actual fill data and records position @@ -2079,22 +2603,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb normalizedSymbol := market.Normalize(symbol) fill := &store.TraderFill{ - TraderID: at.id, - ExchangeID: at.exchangeID, - ExchangeType: at.exchange, - OrderID: orderRecordID, - ExchangeOrderID: exchangeOrderID, - ExchangeTradeID: tradeID, - Symbol: normalizedSymbol, - Side: side, - Price: price, - Quantity: quantity, - QuoteQuantity: price * quantity, - Commission: fee, - CommissionAsset: "USDT", - RealizedPnL: 0, // Will be calculated for close orders - IsMaker: false, // Market orders are usually taker - CreatedAt: time.Now().UTC().UnixMilli(), + TraderID: at.id, + ExchangeID: at.exchangeID, + ExchangeType: at.exchange, + OrderID: orderRecordID, + ExchangeOrderID: exchangeOrderID, + ExchangeTradeID: tradeID, + Symbol: normalizedSymbol, + Side: side, + Price: price, + Quantity: quantity, + QuoteQuantity: price * quantity, + Commission: fee, + CommissionAsset: "USDT", + RealizedPnL: 0, // Will be calculated for close orders + IsMaker: false, // Market orders are usually taker + CreatedAt: time.Now().UTC().UnixMilli(), } // Calculate realized PnL for close orders @@ -2222,4 +2746,3 @@ func getSideFromAction(action string) string { func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) { return at.trader.GetOpenOrders(symbol) } - diff --git a/trader/hyperliquid_price_stream.go b/trader/hyperliquid_price_stream.go new file mode 100644 index 0000000000..0a2e80a9dc --- /dev/null +++ b/trader/hyperliquid_price_stream.go @@ -0,0 +1,127 @@ +package trader + +import ( + "context" + "fmt" + "nofx/logger" + "sync" + "time" + + "github.com/sonirico/go-hyperliquid" +) + +// hyperliquidPriceEvent carries a normalized symbol with the latest mid price +type hyperliquidPriceEvent struct { + symbol string + price float64 + ts time.Time +} + +// HyperliquidPriceStream manages websocket subscriptions for price updates +type HyperliquidPriceStream struct { + client *hyperliquid.WebsocketClient + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + subs map[string]*hyperliquid.Subscription +} + +// NewHyperliquidPriceStream creates a websocket client for mainnet/testnet +func NewHyperliquidPriceStream(testnet bool) *HyperliquidPriceStream { + baseURL := hyperliquid.MainnetAPIURL + if testnet { + baseURL = hyperliquid.TestnetAPIURL + } + ctx, cancel := context.WithCancel(context.Background()) + return &HyperliquidPriceStream{ + client: hyperliquid.NewWebsocketClient(baseURL), + ctx: ctx, + cancel: cancel, + subs: make(map[string]*hyperliquid.Subscription), + } +} + +// Start connects the websocket client +func (s *HyperliquidPriceStream) Start() error { + return s.ensureConnected() +} + +// SubscribeBbo subscribes to best bid/offer for a coin and returns mid prices +func (s *HyperliquidPriceStream) SubscribeBbo(coin string, handler func(price float64, ts time.Time)) error { + if handler == nil { + return fmt.Errorf("handler cannot be nil") + } + if err := s.ensureConnected(); err != nil { + return err + } + + s.mu.Lock() + if _, ok := s.subs[coin]; ok { + s.mu.Unlock() + return nil + } + s.mu.Unlock() + + sub, err := s.client.Bbo(hyperliquid.BboSubscriptionParams{Coin: coin}, func(bbo hyperliquid.Bbo, err error) { + if err != nil { + logger.Infof("⚠️ Hyperliquid BBO error (%s): %v", coin, err) + return + } + if len(bbo.Bbo) == 0 { + return + } + + var mid float64 + if len(bbo.Bbo) >= 2 { + mid = (bbo.Bbo[0].Px + bbo.Bbo[1].Px) / 2 + } else { + mid = bbo.Bbo[0].Px + } + + handler(mid, time.UnixMilli(bbo.Time)) + }) + if err != nil { + return err + } + + s.mu.Lock() + s.subs[coin] = sub + s.mu.Unlock() + + return nil +} + +// Unsubscribe removes a subscription for the given coin +func (s *HyperliquidPriceStream) Unsubscribe(coin string) { + s.mu.Lock() + defer s.mu.Unlock() + + if sub, ok := s.subs[coin]; ok && sub != nil && sub.Close != nil { + sub.Close() + } + delete(s.subs, coin) +} + +// Close shuts down all subscriptions and the underlying websocket +func (s *HyperliquidPriceStream) Close() { + s.cancel() + + s.mu.Lock() + defer s.mu.Unlock() + + for coin, sub := range s.subs { + if sub != nil && sub.Close != nil { + sub.Close() + } + delete(s.subs, coin) + } +} + +// ensureConnected establishes websocket connection if not already connected +func (s *HyperliquidPriceStream) ensureConnected() error { + if s.client == nil { + return fmt.Errorf("nil websocket client") + } + return s.client.Connect(s.ctx) +} diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 64be039fd8..be97a8207b 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -1,5 +1,5 @@ import { Shield, AlertTriangle } from 'lucide-react' -import type { RiskControlConfig } from '../../types' +import type { RiskControlConfig, TrailingStopConfig } from '../../types' interface RiskControlEditorProps { config: RiskControlConfig @@ -19,6 +19,23 @@ export function RiskControlEditor({ positionLimits: { zh: '仓位限制', en: 'Position Limits' }, maxPositions: { zh: '最大持仓数量', en: 'Max Positions' }, maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' }, + trailingStop: { zh: '移动止盈', en: 'Trailing Stop' }, + trailingStopDesc: { zh: '常规移动止盈:跟随持仓盈亏或价格,触发即平仓(可选部分平仓)', en: 'Classic trailing stop on PnL% or price; closes when stop is hit (optional partial close)' }, + enableTrailing: { zh: '启用移动止盈', en: 'Enable trailing stop' }, + mode: { zh: '模式', en: 'Mode' }, + modeDesc: { zh: '按盈亏%或价格跟踪', en: 'Trail by PnL% or price' }, + activationPct: { zh: '启动阈值(%)', en: 'Activation Threshold (%)' }, + activationPctDesc: { zh: '盈亏达到该值后开始跟踪(0=立即)', en: 'Start trailing after this PnL% (0 = immediate)' }, + trailPct: { zh: '跟踪距离(%)', en: 'Trail Distance (%)' }, + trailPctDesc: { zh: '止损线=峰值-该百分比(百分比点)', en: 'Stop = peak – this percentage distance (percent points)' }, + checkInterval: { zh: '检查频率(毫秒)', en: 'Check Interval (ms)' }, + checkIntervalDesc: { zh: '监控间隔(支持毫秒,越短越及时,需 websocket)', en: 'Monitoring interval (ms, websocket friendly for fast response)' }, + closePct: { zh: '平仓比例', en: 'Close Portion' }, + closePctDesc: { zh: '触发后平掉多少仓位(1=全平)', en: 'Portion of position to close when triggered (1=full)' }, + tightenBands: { zh: '收紧梯度', en: 'Tighten Bands' }, + addBand: { zh: '添加梯度', en: 'Add band' }, + profitPct: { zh: '收益达到(%)', en: 'Profit ≥ (%)' }, + bandTrailPct: { zh: '跟踪距离(%)', en: 'Trail (%)' }, // Trading leverage (exchange leverage) tradingLeverage: { zh: '交易杠杆(交易所杠杆)', en: 'Trading Leverage (Exchange)' }, btcEthLeverage: { zh: 'BTC/ETH 交易杠杆', en: 'BTC/ETH Trading Leverage' }, @@ -55,8 +72,314 @@ export function RiskControlEditor({ } } + const trailingDefaults: TrailingStopConfig = { + enabled: true, + mode: 'pnl_pct', + activation_pct: 0, + trail_pct: 3, + check_interval_sec: 5, + check_interval_ms: undefined, + tighten_bands: [], + close_pct: 1, + } + + const trailing = { + ...trailingDefaults, + ...(config.trailing_stop || {}), + } + + const updateTrailing = (patch: Partial) => { + if (disabled) return + onChange({ + ...config, + trailing_stop: { + ...trailing, + ...patch, + }, + }) + } + return (
+ {/* Trailing Stop */} +
+
+ +

+ {t('trailingStop')} +

+
+

+ {t('trailingStopDesc')} +

+
+
+ + +
+ +
+ +

+ {t('modeDesc')} +

+ +
+ +
+ +

+ {t('activationPctDesc')} +

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

+ {t('trailPctDesc')} +

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

+ {t('checkIntervalDesc')} +

+
+ {(() => { + const intervalMs = + trailing.check_interval_ms ?? + (trailing.check_interval_sec ? trailing.check_interval_sec * 1000 : 30000) + return ( + <> + { + const val = parseInt(e.target.value) || 0 + updateTrailing({ + check_interval_ms: val, + check_interval_sec: Math.round(val / 1000), + }) + }} + disabled={disabled} + min={10} + max={600000} + step={10} + className="w-28 px-3 py-2 rounded" + style={{ + background: '#1E2329', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + ms + + (~{(intervalMs / 1000).toFixed(2)}s) + + + ) + })()} +
+
+ +
+ +

+ {t('closePctDesc')} +

+
+ + updateTrailing({ close_pct: parseFloat(e.target.value) || 0 }) + } + disabled={disabled} + min={0.01} + max={1} + step={0.01} + className="w-24 px-3 py-2 rounded" + style={{ + background: '#1E2329', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + % +
+
+
+ + {/* Tighten Bands */} +
+
+
+

+ {t('tightenBands')} +

+

+ {language === 'zh' + ? '达到收益阈值后自动缩紧跟踪距离' + : 'Tighten trail after reaching profit bands'} +

+
+ +
+
+ {(trailing.tighten_bands || []).map((band, idx) => ( +
+
+ {t('profitPct')} + { + const updated = [...(trailing.tighten_bands || [])] + updated[idx] = { ...band, profit_pct: parseFloat(e.target.value) || 0 } + updateTrailing({ tighten_bands: updated }) + }} + disabled={disabled} + min={0} + step={0.01} + className="w-24 px-2 py-1 rounded text-sm" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + /> + % +
+
+ {t('bandTrailPct')} + { + const updated = [...(trailing.tighten_bands || [])] + updated[idx] = { ...band, trail_pct: parseFloat(e.target.value) || 0 } + updateTrailing({ tighten_bands: updated }) + }} + disabled={disabled} + min={0.01} + step={0.01} + className="w-24 px-2 py-1 rounded text-sm" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + /> + % +
+
+ +
+
+ ))} + {(trailing.tighten_bands || []).length === 0 && ( +

+ {language === 'zh' ? '未设置收紧梯度' : 'No tighten bands configured'} +

+ )} +
+
+
+ {/* Position Limits */}
diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index fcb3fc172c..d651013867 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -30,7 +30,7 @@ import { Upload, Globe, } from 'lucide-react' -import type { Strategy, StrategyConfig, AIModel } from '../types' +import type { Strategy, StrategyConfig, AIModel, TrailingStopConfig } from '../types' import { confirmToast, notify } from '../lib/notify' import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor' import { IndicatorEditor } from '../components/strategy/IndicatorEditor' @@ -78,6 +78,31 @@ export function StrategyStudioPage() { const [isLoadingPrompt, setIsLoadingPrompt] = useState(false) const [selectedVariant, setSelectedVariant] = useState('balanced') + const withTrailingDefaults = useCallback( + (config: StrategyConfig): StrategyConfig => { + const trailing: TrailingStopConfig | undefined = config?.risk_control?.trailing_stop + const normalizedTrailing: TrailingStopConfig = { + enabled: trailing?.enabled ?? true, + mode: trailing?.mode || 'pnl_pct', + activation_pct: trailing?.activation_pct ?? 0, + trail_pct: trailing?.trail_pct ?? 3, + check_interval_sec: trailing?.check_interval_sec ?? 30, + check_interval_ms: trailing?.check_interval_ms, + tighten_bands: trailing?.tighten_bands || [], + close_pct: trailing?.close_pct ?? 1, + } + const riskControl = config.risk_control || ({} as any) + return { + ...config, + risk_control: { + ...riskControl, + trailing_stop: normalizedTrailing, + }, + } + }, + [] + ) + // AI Test Run states const [aiTestResult, setAiTestResult] = useState<{ system_prompt?: string @@ -128,23 +153,27 @@ export function StrategyStudioPage() { }) if (!response.ok) throw new Error('Failed to fetch strategies') const data = await response.json() - setStrategies(data.strategies || []) + const normalizedStrategies: Strategy[] = (data.strategies || []).map((s: Strategy) => ({ + ...s, + config: withTrailingDefaults(s.config), + })) + setStrategies(normalizedStrategies) // Select active or first strategy - const active = data.strategies?.find((s: Strategy) => s.is_active) + const active = normalizedStrategies.find((s: Strategy) => s.is_active) if (active) { setSelectedStrategy(active) setEditingConfig(active.config) - } else if (data.strategies?.length > 0) { - setSelectedStrategy(data.strategies[0]) - setEditingConfig(data.strategies[0].config) + } else if (normalizedStrategies.length > 0) { + setSelectedStrategy(normalizedStrategies[0]) + setEditingConfig(normalizedStrategies[0].config) } } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error') } finally { setIsLoading(false) } - }, [token]) + }, [token, withTrailingDefaults]) useEffect(() => { fetchStrategies() @@ -198,7 +227,7 @@ export function StrategyStudioPage() { `${API_BASE}/api/strategies/default-config?lang=${language}`, { headers: { Authorization: `Bearer ${token}` } } ) - const defaultConfig = await configResponse.json() + const defaultConfig = withTrailingDefaults(await configResponse.json()) const response = await fetch(`${API_BASE}/api/strategies`, { method: 'POST', diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index 6f60b76f58..01487f50ad 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -89,6 +89,55 @@ function truncateAddress(address: string, startLen = 6, endLen = 4): string { return `${address.slice(0, startLen)}...${address.slice(-endLen)}` } +function getTrailingDisplay(trailing: Position['trailing'], language: Language) { + if (!trailing || !trailing.enabled) { + return { + status: language === 'zh' ? '未开启' : 'Off', + color: 'text-nofx-text-muted', + parts: [] as string[], + } + } + + const status = + trailing.status === 'waiting_activation' + ? language === 'zh' + ? '待激活' + : 'Waiting' + : language === 'zh' + ? '已就绪' + : 'Armed' + const color = + trailing.status === 'waiting_activation' + ? 'text-nofx-gold' + : 'text-nofx-green' + + const trail = trailing.active_trail_pct ?? trailing.trail_pct + const stopPrice = trailing.stop_price + const stopLabel = `${language === 'zh' ? '止损价' : 'Stop'} ${stopPrice ? stopPrice.toFixed(4) : '—'}` + const peakLabel = `${language === 'zh' ? '峰值' : 'Peak'} ${ + trailing.peak_pnl_pct !== undefined ? trailing.peak_pnl_pct.toFixed(2) : '—' + }%` + const trailLabel = `${language === 'zh' ? '跟踪' : 'Trail'} ${ + trail !== undefined ? trail.toFixed(2) : '—' + }%` + let activationLabel = + trailing.activation_pct && trailing.activation_pct > 0 + ? `${language === 'zh' ? '激活' : 'Act'} ${trailing.activation_pct.toFixed(2)}%` + : language === 'zh' + ? '立即' + : 'Immediate' + if (trailing.activation_price && trailing.activation_pct && trailing.activation_pct > 0) { + activationLabel += ` / ${trailing.activation_price.toFixed(4)}` + } + const modeLabel = trailing.mode === 'price_pct' ? (language === 'zh' ? '价格跟踪' : 'Price trail') : 'PnL trail' + + return { + status, + color, + parts: [modeLabel, stopLabel, peakLabel, trailLabel, activationLabel], + } +} + // --- Components --- interface TraderDashboardPageProps { @@ -607,6 +656,24 @@ export function TraderDashboardPage({ > {pos.symbol} +
+ {(() => { + const trailingDisplay = getTrailingDisplay(pos.trailing, language) + return ( + <> + + {trailingDisplay.status} + + {trailingDisplay.parts.map((p, idx) => ( + + + {p} + + ))} + + ) + })()} +