diff --git a/api/handler_user.go b/api/handler_user.go index 330fbfd3..aa91c232 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -285,23 +285,23 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { name, description string } type strategyLocale struct { - balanced, conservative, aggressive strategyI18n + trend, megaCap, breakout strategyI18n } locales := map[string]strategyLocale{ "zh": { - balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"}, - conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"}, - aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"}, + trend: strategyI18n{"美股趋势策略", "开箱即用的 Hyperliquid 美股 USDC 策略。只扫描流动性更好的美股合约,低杠杆、低频率,适合直接创建 Agent 后运行。"}, + megaCap: strategyI18n{"美股大盘稳健策略", "开箱即用的 Hyperliquid 美股 USDC 策略。固定关注 AAPL、MSFT、GOOGL、AMZN、META 等大盘股,强调趋势确认和回撤控制。"}, + breakout: strategyI18n{"美股突破策略", "开箱即用的 Hyperliquid 美股 USDC 策略。扫描 24h 强势美股,等待突破确认后再开仓,避免频繁追涨。"}, }, "en": { - balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."}, - conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."}, - aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."}, + trend: strategyI18n{"US Stock Trend Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans liquid US stock perps with low leverage and low trade frequency, suitable for one-click Agent deployment."}, + megaCap: strategyI18n{"US Mega-Cap Steady Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Fixed universe: AAPL, MSFT, GOOGL, AMZN and META, with trend confirmation and drawdown control."}, + breakout: strategyI18n{"US Stock Breakout Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans 24h strong US stocks and waits for breakout confirmation before entering, avoiding impulsive chasing."}, }, "id": { - balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."}, - conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."}, - aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."}, + trend: strategyI18n{"Strategi Tren Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai perp saham AS likuid dengan leverage rendah dan frekuensi rendah."}, + megaCap: strategyI18n{"Strategi Stabil Mega-Cap AS", "Strategi saham AS USDC Hyperliquid siap jalan. Universe tetap: AAPL, MSFT, GOOGL, AMZN, META, dengan konfirmasi tren."}, + breakout: strategyI18n{"Strategi Breakout Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai saham AS kuat 24 jam dan menunggu konfirmasi breakout."}, }, } locale, ok := locales[lang] @@ -316,45 +316,76 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { applyConfig func(*store.StrategyConfig) } + setStockRank := func(c *store.StrategyConfig, direction string, limit int) { + c.CoinSource.SourceType = "hyper_rank" + c.CoinSource.StaticCoins = nil + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + c.CoinSource.HyperRankCategory = "stock" + c.CoinSource.HyperRankDirection = direction + c.CoinSource.HyperRankLimit = limit + } + setStaticStocks := func(c *store.StrategyConfig, symbols []string) { + c.CoinSource.SourceType = "static" + c.CoinSource.StaticCoins = symbols + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + } + setStableRisk := func(c *store.StrategyConfig) { + c.RiskControl.MaxPositions = 2 + c.RiskControl.BTCETHMaxLeverage = 3 + c.RiskControl.AltcoinMaxLeverage = 3 + c.RiskControl.BTCETHMaxPositionValueRatio = 2.0 + c.RiskControl.AltcoinMaxPositionValueRatio = 0.6 + c.RiskControl.MaxMarginUsage = 0.45 + c.RiskControl.MinConfidence = 78 + c.RiskControl.MinRiskRewardRatio = 3.0 + c.Indicators.Klines.PrimaryTimeframe = "15m" + c.Indicators.Klines.LongerTimeframe = "4h" + c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"} + c.Indicators.EnableEMA = true + c.Indicators.EnableMACD = true + c.Indicators.EnableRSI = true + c.Indicators.EnableATR = true + c.Indicators.EnableVolume = true + } + definitions := []strategyDef{ { - name: locale.balanced.name, - description: locale.balanced.description, + name: locale.trend.name, + description: locale.trend.description, isActive: true, applyConfig: func(c *store.StrategyConfig) { - // Uses default config as-is + setStockRank(c, "volume", 5) + setStableRisk(c) }, }, { - name: locale.conservative.name, - description: locale.conservative.description, + name: locale.megaCap.name, + description: locale.megaCap.description, isActive: false, applyConfig: func(c *store.StrategyConfig) { - c.RiskControl.BTCETHMaxLeverage = 3 - c.RiskControl.AltcoinMaxLeverage = 3 - c.RiskControl.BTCETHMaxPositionValueRatio = 3.0 - c.RiskControl.AltcoinMaxPositionValueRatio = 0.5 + setStaticStocks(c, []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"}) + setStableRisk(c) + c.RiskControl.MaxPositions = 2 c.RiskControl.MinConfidence = 80 - c.RiskControl.MinRiskRewardRatio = 4.0 - c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"} - c.Indicators.Klines.PrimaryTimeframe = "15m" }, }, { - name: locale.aggressive.name, - description: locale.aggressive.description, + name: locale.breakout.name, + description: locale.breakout.description, isActive: false, applyConfig: func(c *store.StrategyConfig) { - c.RiskControl.BTCETHMaxLeverage = 10 - c.RiskControl.AltcoinMaxLeverage = 7 - c.RiskControl.MaxPositions = 5 - c.RiskControl.AltcoinMaxPositionValueRatio = 2.0 - c.RiskControl.MinConfidence = 70 - c.CoinSource.AI500Limit = 5 - c.CoinSource.UseOITop = true - c.CoinSource.OITopLimit = 5 - c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"} - c.Indicators.Klines.PrimaryTimeframe = "3m" + setStockRank(c, "gainers", 5) + setStableRisk(c) + c.RiskControl.MinConfidence = 82 + c.RiskControl.MinRiskRewardRatio = 3.5 }, }, } @@ -370,6 +401,7 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { for _, def := range definitions { config := store.GetDefaultStrategyConfig(configLang) def.applyConfig(&config) + config.ClampLimits() strategy := &store.Strategy{ ID: uuid.New().String(), @@ -385,11 +417,46 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error { strategies = append(strategies, strategy) } + legacyDefaultNames := []string{ + "均衡策略", "稳健策略", "积极策略", + "Balanced Strategy", "Conservative Strategy", "Aggressive Strategy", + "Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif", + } + return s.store.Transaction(func(tx *gorm.DB) error { + // Remove obsolete built-in risk-profile presets for this user. If a trader still + // references one of them, keep it to avoid breaking an existing running setup. + deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID). + Delete(&store.Strategy{}) + if deleteResult.Error != nil { + return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error) + } + if deleteResult.RowsAffected > 0 { + logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected) + } + + var activeCount int64 + if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil { + return fmt.Errorf("failed to count active strategies: %w", err) + } + for _, strategy := range strategies { + var existing int64 + if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil { + return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err) + } + if existing > 0 { + continue + } + if activeCount > 0 { + strategy.IsActive = false + } if err := tx.Create(strategy).Error; err != nil { return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err) } + if strategy.IsActive { + activeCount++ + } logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive) } return nil diff --git a/api/handler_user_default_strategy_test.go b/api/handler_user_default_strategy_test.go new file mode 100644 index 00000000..8d2eecc0 --- /dev/null +++ b/api/handler_user_default_strategy_test.go @@ -0,0 +1,151 @@ +package api + +import ( + "testing" + + "github.com/google/uuid" + "nofx/store" +) + +func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) { + st, err := store.New(t.TempDir() + "/nofx.db") + if err != nil { + t.Fatalf("store.New failed: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + s := &Server{store: st} + userID := "user-us-stock-presets" + if err := s.createDefaultStrategies(userID, "zh"); err != nil { + t.Fatalf("createDefaultStrategies failed: %v", err) + } + + strategies, err := st.Strategy().List(userID) + if err != nil { + t.Fatalf("List strategies failed: %v", err) + } + if len(strategies) != 3 { + t.Fatalf("expected 3 default strategies, got %d", len(strategies)) + } + + byName := map[string]*store.Strategy{} + activeCount := 0 + for _, strategy := range strategies { + byName[strategy.Name] = strategy + if strategy.IsActive { + activeCount++ + } + if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" { + t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name) + } + } + if activeCount != 1 { + t.Fatalf("expected exactly one active strategy, got %d", activeCount) + } + + trend := byName["美股趋势策略"] + if trend == nil || !trend.IsActive { + t.Fatalf("美股趋势策略 should exist and be active") + } + trendCfg, err := trend.ParseConfig() + if err != nil { + t.Fatalf("trend ParseConfig failed: %v", err) + } + if trendCfg.CoinSource.SourceType != "hyper_rank" || trendCfg.CoinSource.HyperRankCategory != "stock" || trendCfg.CoinSource.HyperRankDirection != "volume" { + t.Fatalf("trend strategy should use Hyperliquid stock volume ranking, got %+v", trendCfg.CoinSource) + } + if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 || trendCfg.RiskControl.MaxMarginUsage > 0.45 { + t.Fatalf("trend strategy should be low-risk Hyperliquid native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl) + } + + megaCap := byName["美股大盘稳健策略"] + if megaCap == nil { + t.Fatalf("美股大盘稳健策略 should exist") + } + megaCfg, err := megaCap.ParseConfig() + if err != nil { + t.Fatalf("megaCap ParseConfig failed: %v", err) + } + if megaCfg.CoinSource.SourceType != "static" { + t.Fatalf("mega-cap strategy should use static stock symbols, got %+v", megaCfg.CoinSource) + } + wantSymbols := []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"} + if len(megaCfg.CoinSource.StaticCoins) != len(wantSymbols) { + t.Fatalf("unexpected static stock list: %+v", megaCfg.CoinSource.StaticCoins) + } + for i, want := range wantSymbols { + if megaCfg.CoinSource.StaticCoins[i] != want { + t.Fatalf("static stock %d: want %s got %s", i, want, megaCfg.CoinSource.StaticCoins[i]) + } + } +} + +func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) { + st, err := store.New(t.TempDir() + "/nofx.db") + if err != nil { + t.Fatalf("store.New failed: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + userID := "user-existing-custom" + legacyCfg := store.GetDefaultStrategyConfig("zh") + legacy := &store.Strategy{ + ID: uuid.New().String(), + UserID: userID, + Name: "均衡策略", + Description: "legacy", + IsActive: false, + } + if err := legacy.SetConfig(&legacyCfg); err != nil { + t.Fatalf("legacy SetConfig failed: %v", err) + } + if err := st.Strategy().Create(legacy); err != nil { + t.Fatalf("create legacy failed: %v", err) + } + + custom := &store.Strategy{ + ID: uuid.New().String(), + UserID: userID, + Name: "aa", + Description: "user custom active strategy", + IsActive: true, + } + if err := custom.SetConfig(&legacyCfg); err != nil { + t.Fatalf("custom SetConfig failed: %v", err) + } + if err := st.Strategy().Create(custom); err != nil { + t.Fatalf("create custom failed: %v", err) + } + + s := &Server{store: st} + if err := s.createDefaultStrategies(userID, "zh"); err != nil { + t.Fatalf("createDefaultStrategies failed: %v", err) + } + if err := s.createDefaultStrategies(userID, "zh"); err != nil { + t.Fatalf("second createDefaultStrategies should be idempotent: %v", err) + } + + strategies, err := st.Strategy().List(userID) + if err != nil { + t.Fatalf("List strategies failed: %v", err) + } + byName := map[string]int{} + activeNames := []string{} + for _, strategy := range strategies { + byName[strategy.Name]++ + if strategy.IsActive { + activeNames = append(activeNames, strategy.Name) + } + } + if byName["均衡策略"] != 0 { + t.Fatalf("legacy preset should be removed, got names=%+v", byName) + } + for _, name := range []string{"美股趋势策略", "美股大盘稳健策略", "美股突破策略"} { + if byName[name] != 1 { + t.Fatalf("expected exactly one %s, got names=%+v", name, byName) + } + } + if len(activeNames) != 1 || activeNames[0] != "aa" { + t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames) + } +} diff --git a/api/strategy.go b/api/strategy.go index 023cd022..ad784b18 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -105,6 +105,14 @@ func (s *Server) handleGetStrategies(c *gin.Context) { return } + lang := c.Query("lang") + if lang == "" { + lang = "zh" + } + if err := s.createDefaultStrategies(userID, lang); err != nil { + logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err) + } + strategies, err := s.store.Strategy().List(userID) if err != nil { SafeInternalError(c, "Failed to get strategy list", err) diff --git a/web/src/components/trader/TraderConfigModal.tsx b/web/src/components/trader/TraderConfigModal.tsx index 60097250..a294114e 100644 --- a/web/src/components/trader/TraderConfigModal.tsx +++ b/web/src/components/trader/TraderConfigModal.tsx @@ -336,7 +336,10 @@ export function TraderConfigModal({ return (
- {t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? '固定币种' : + {t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? (language === 'zh' ? '固定美股' : 'Fixed US stocks') : + aiConfig.coin_source.source_type === 'hyper_rank' ? (language === 'zh' ? 'Hyperliquid 美股榜单' : 'Hyperliquid US stock ranking') : + aiConfig.coin_source.source_type === 'hyper_all' ? (language === 'zh' ? 'Hyperliquid 全市场' : 'Hyperliquid all markets') : + aiConfig.coin_source.source_type === 'hyper_main' ? (language === 'zh' ? 'Hyperliquid 主流市场' : 'Hyperliquid main markets') : aiConfig.coin_source.source_type === 'ai500' ? 'AI500' : aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' : aiConfig.coin_source.source_type === 'oi_low' ? 'OI Low' : '-'}