mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
feat(strategy): replace default presets with Hyperliquid US stock strategies
Remove the old generic risk-profile defaults from the user strategy bootstrap path and replace them with concrete Hyperliquid USDC equity presets that can be selected directly when creating an AI trader. Add three ready-to-run strategy presets: a volume-ranked US stock trend preset, a fixed mega-cap preset covering AAPL-USDC/MSFT-USDC/GOOGL-USDC/AMZN-USDC/META-USDC, and a gainers-ranked US stock breakout preset. Normalize the presets to use Hyperliquid-native stock discovery instead of AI500/OI crypto-style sources, with conservative defaults for max positions, leverage, margin usage, confidence, risk-reward, and multi-timeframe indicators. Make default strategy synchronization idempotent for existing users: remove obsolete unused legacy preset rows, backfill the new US stock presets, and avoid overriding an already active custom strategy. Update the trader creation modal preview labels so Hyperliquid stock ranking and fixed US stock sources are described clearly when users select a strategy. Add API tests covering the new preset set, legacy preset cleanup, idempotent sync behavior, and preservation of an existing active custom strategy. Verified with: go test ./api ./store; npm run build; docker compose up -d --build nofx nofx-frontend; backend /api/health; frontend HTTP 200; compose health checks.
This commit is contained in:
@@ -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
|
||||
|
||||
151
api/handler_user_default_strategy_test.go
Normal file
151
api/handler_user_default_strategy_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -336,7 +336,10 @@ export function TraderConfigModal({
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
||||
<div>
|
||||
{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' : '-'}
|
||||
|
||||
Reference in New Issue
Block a user