diff --git a/.gitignore b/.gitignore index db7745d5e0..cae87c9e05 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ Thumbs.db *.tmp *.bak *.backup +.cache/ +.gh-config/ # 环境变量 .env diff --git a/README.md b/README.md index 534593b1a5..313dd21f79 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ No accounts. No API keys. No prepaid credits. One wallet, every model. | Feature | Description | |:--------|:------------| -| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — switch anytime | +| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime | | **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Strategy Studio** | Visual builder — coin sources, indicators, risk controls | | **AI Competition** | AIs compete in real-time, leaderboard ranks performance | @@ -110,6 +110,7 @@ Crypto · US Stocks · Forex · Metals | **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) | | **Grok** | ✅ | [Get API Key](https://console.x.ai) | | **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) | ### AI Models (x402 Mode — No API Key) @@ -211,6 +212,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas ## Setup +**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step. + +**Advanced mode**: + 1. **AI** — Add API keys or configure x402 wallet 2. **Exchange** — Connect exchange API credentials 3. **Strategy** — Build in Strategy Studio diff --git a/api/handler_ai_model.go b/api/handler_ai_model.go index 32badbd00e..4ffce4f2a0 100644 --- a/api/handler_ai_model.go +++ b/api/handler_ai_model.go @@ -10,6 +10,7 @@ import ( "nofx/crypto" "nofx/logger" "nofx/security" + "nofx/wallet" "github.com/gin-gonic/gin" ) @@ -31,6 +32,8 @@ type SafeModelConfig struct { Enabled bool `json:"enabled"` CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive) CustomModelName string `json:"customModelName"` // Custom model name (not sensitive) + WalletAddress string `json:"walletAddress,omitempty"` + BalanceUSDC string `json:"balanceUsdc,omitempty"` } type UpdateModelConfigRequest struct { @@ -75,7 +78,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { // Convert to safe response structure, remove sensitive information safeModels := make([]SafeModelConfig, len(models)) for i, model := range models { - safeModels[i] = SafeModelConfig{ + safeModel := SafeModelConfig{ ID: model.ID, Name: model.Name, Provider: model.Provider, @@ -83,6 +86,19 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) { CustomAPIURL: model.CustomAPIURL, CustomModelName: model.CustomModelName, } + + if model.Provider == "claw402" { + if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" { + if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil { + safeModel.WalletAddress = walletAddress + safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress) + } else { + logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr) + } + } + } + + safeModels[i] = safeModel } c.JSON(http.StatusOK, safeModels) @@ -201,7 +217,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) { {"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"}, {"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"}, {"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"}, - {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.5"}, + {"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"}, {"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek"}, } diff --git a/api/handler_onboarding.go b/api/handler_onboarding.go new file mode 100644 index 0000000000..263d1909c4 --- /dev/null +++ b/api/handler_onboarding.go @@ -0,0 +1,323 @@ +package api + +import ( + "bufio" + "encoding/hex" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "nofx/logger" + "nofx/wallet" + + gethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/gin-gonic/gin" +) + +type beginnerOnboardingResponse struct { + Address string `json:"address"` + PrivateKey string `json:"private_key"` + Chain string `json:"chain"` + Asset string `json:"asset"` + Provider string `json:"provider"` + DefaultModel string `json:"default_model"` + ConfiguredModelID string `json:"configured_model_id"` + BalanceUSDC string `json:"balance_usdc"` + EnvSaved bool `json:"env_saved"` + EnvPath string `json:"env_path,omitempty"` + ReusedExisting bool `json:"reused_existing"` + EnvWarning string `json:"env_warning,omitempty"` +} + +type currentBeginnerWalletResponse struct { + Found bool `json:"found"` + Address string `json:"address,omitempty"` + BalanceUSDC string `json:"balance_usdc,omitempty"` + Source string `json:"source,omitempty"` + Claw402Status string `json:"claw402_status"` +} + +func (s *Server) handleBeginnerOnboarding(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"}) + return + } + + privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID) + if err != nil { + logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"}) + return + } + + if !reusedExisting { + if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "deepseek"); err != nil { + logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"}) + return + } + + configuredModelID, err = s.findConfiguredClaw402ModelID(userID) + if err != nil { + logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err) + } + } + + os.Setenv("CLAW402_WALLET_KEY", privateKey) + os.Setenv("CLAW402_WALLET_ADDRESS", address) + os.Setenv("CLAW402_DEFAULT_MODEL", "deepseek") + + envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address) + resp := beginnerOnboardingResponse{ + Address: address, + PrivateKey: privateKey, + Chain: "base", + Asset: "USDC", + Provider: "claw402", + DefaultModel: "deepseek", + ConfiguredModelID: configuredModelID, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + EnvSaved: envSaved, + EnvPath: envPath, + ReusedExisting: reusedExisting, + } + if envErr != nil { + resp.EnvWarning = envErr.Error() + logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr) + } + + c.JSON(http.StatusOK, resp) +} + +func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"}) + return + } + claw402Status := checkClaw402Health() + + models, err := s.store.AIModel().List(userID) + if err != nil { + logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"}) + return + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + + privateKey := strings.TrimSpace(model.APIKey.String()) + if privateKey == "" { + continue + } + + address, addrErr := walletAddressFromPrivateKey(privateKey) + if addrErr != nil { + logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr) + continue + } + + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: true, + Address: address, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + Source: "model", + Claw402Status: claw402Status, + }) + return + } + + address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS")) + if address != "" { + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: true, + Address: address, + BalanceUSDC: wallet.QueryUSDCBalanceStr(address), + Source: "env", + Claw402Status: claw402Status, + }) + return + } + + c.JSON(http.StatusOK, currentBeginnerWalletResponse{ + Found: false, + Claw402Status: claw402Status, + }) +} + +func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) { + models, err := s.store.AIModel().List(userID) + if err != nil { + return "", "", "", false, err + } + + for _, model := range models { + if model == nil || model.Provider != "claw402" { + continue + } + existingKey := strings.TrimSpace(model.APIKey.String()) + if existingKey == "" { + continue + } + + addr, addrErr := walletAddressFromPrivateKey(existingKey) + if addrErr != nil { + logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr) + break + } + + return existingKey, addr, model.ID, true, nil + } + + privateKeyObj, genErr := gethcrypto.GenerateKey() + if genErr != nil { + return "", "", "", false, genErr + } + + addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey) + keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj)) + return keyHex, addr.Hex(), "", false, nil +} + +func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) { + models, err := s.store.AIModel().List(userID) + if err != nil { + return "", err + } + + for _, model := range models { + if model != nil && model.Provider == "claw402" { + return model.ID, nil + } + } + + return "", fmt.Errorf("claw402 model not found") +} + +func walletAddressFromPrivateKey(privateKey string) (string, error) { + key := strings.TrimSpace(privateKey) + if !strings.HasPrefix(key, "0x") { + return "", fmt.Errorf("private key must start with 0x") + } + if len(key) != 66 { + return "", fmt.Errorf("private key must be 66 characters") + } + + privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x")) + if err != nil { + return "", err + } + + return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil +} + +func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) { + paths := uniqueEnvPaths([]string{ + ".env", + filepath.Join(".", ".env"), + "/app/.env", + }) + + var lastErr error + for _, path := range paths { + if path == "" { + continue + } + + if err := upsertEnvFile(path, map[string]string{ + "CLAW402_WALLET_KEY": privateKey, + "CLAW402_WALLET_ADDRESS": address, + "CLAW402_DEFAULT_MODEL": "deepseek", + }); err != nil { + lastErr = err + continue + } + + return true, path, nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("no writable .env path found") + } + return false, "", lastErr +} + +func uniqueEnvPaths(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + result := make([]string, 0, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + result = append(result, clean) + } + return result +} + +func upsertEnvFile(path string, values map[string]string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + existingLines := make([]string, 0) + if file, err := os.Open(path); err == nil { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + existingLines = append(existingLines, scanner.Text()) + } + file.Close() + if err := scanner.Err(); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + + remaining := make(map[string]string, len(values)) + for key, value := range values { + remaining[key] = value + } + + updatedLines := make([]string, 0, len(existingLines)+len(values)) + for _, line := range existingLines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") { + updatedLines = append(updatedLines, line) + continue + } + + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + value, ok := remaining[key] + if !ok { + updatedLines = append(updatedLines, line) + continue + } + + updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value)) + delete(remaining, key) + } + + for key, value := range remaining { + updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value)) + } + + content := strings.Join(updatedLines, "\n") + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return err + } + + return nil +} diff --git a/api/handler_user.go b/api/handler_user.go index 1b22010ca3..2a3d502f91 100644 --- a/api/handler_user.go +++ b/api/handler_user.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "strings" "time" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "gorm.io/gorm" ) // handleLogout Add current token to blacklist @@ -59,6 +61,7 @@ func (s *Server) handleRegister(c *gin.Context) { var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` + Lang string `json:"lang"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -66,6 +69,11 @@ func (s *Server) handleRegister(c *gin.Context) { return } + lang := req.Lang + if lang != "zh" && lang != "id" { + lang = "en" + } + // Check if email already exists _, err = s.store.User().GetByEmail(req.Email) if err == nil { @@ -102,7 +110,7 @@ func (s *Server) handleRegister(c *gin.Context) { } // Initialize default model and exchange configs for user - err = s.initUserDefaultConfigs(user.ID) + err = s.initUserDefaultConfigs(user.ID, lang) if err != nil { logger.Infof("Failed to initialize user default configs: %v", err) } @@ -214,10 +222,128 @@ func (s *Server) handleResetPassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"}) } -// initUserDefaultConfigs Initialize default model and exchange configs for new user -func (s *Server) initUserDefaultConfigs(userID string) error { - // Commented out auto-creation of default configs, let users add manually - // This way new users won't have config items automatically after registration - logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID) +// initUserDefaultConfigs Initialize default configs for new user +func (s *Server) initUserDefaultConfigs(userID string, lang string) error { + if err := s.createDefaultStrategies(userID, lang); err != nil { + logger.Warnf("Failed to create default strategies for user %s: %v", userID, err) + // Non-fatal: user can create strategies manually + } + logger.Infof("✓ User %s registration completed with default strategies", userID) return nil } + +func (s *Server) createDefaultStrategies(userID string, lang string) error { + type strategyI18n struct { + name, description string + } + type strategyLocale struct { + balanced, conservative, aggressive strategyI18n + } + locales := map[string]strategyLocale{ + "zh": { + balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"}, + conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"}, + aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"}, + }, + "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."}, + }, + "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."}, + }, + } + locale, ok := locales[lang] + if !ok { + locale = locales["en"] + } + + type strategyDef struct { + name string + description string + isActive bool + applyConfig func(*store.StrategyConfig) + } + + definitions := []strategyDef{ + { + name: locale.balanced.name, + description: locale.balanced.description, + isActive: true, + applyConfig: func(c *store.StrategyConfig) { + // Uses default config as-is + }, + }, + { + name: locale.conservative.name, + description: locale.conservative.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 + 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, + 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" + }, + }, + } + + // GetDefaultStrategyConfig only supports zh/en; map id -> en + configLang := lang + if lang == "id" { + configLang = "en" + } + + // Pre-build all strategy objects before opening the transaction + var strategies []*store.Strategy + for _, def := range definitions { + config := store.GetDefaultStrategyConfig(configLang) + def.applyConfig(&config) + + strategy := &store.Strategy{ + ID: uuid.New().String(), + UserID: userID, + Name: def.name, + Description: def.description, + IsActive: def.isActive, + IsDefault: false, + } + if err := strategy.SetConfig(&config); err != nil { + return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err) + } + strategies = append(strategies, strategy) + } + + return s.store.Transaction(func(tx *gorm.DB) error { + for _, strategy := range strategies { + if err := tx.Create(strategy).Error; err != nil { + return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err) + } + logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive) + } + return nil + }) +} diff --git a/api/server.go b/api/server.go index a6037e774c..4f5ea098e4 100644 --- a/api/server.go +++ b/api/server.go @@ -110,6 +110,7 @@ func (s *Server) setupRoutes() { // Public strategy market (no authentication required) s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies) + s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens) // Authentication related routes (no authentication required) s.route(api, "POST", "/register", "Register new user", s.handleRegister) @@ -121,6 +122,8 @@ func (s *Server) setupRoutes() { { // Logout (add to blacklist) s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout) + s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding) + s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet) // User account management s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password", diff --git a/api/strategy.go b/api/strategy.go index c58f327885..1985c3844e 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -31,6 +31,20 @@ func validateStrategyConfig(config *store.StrategyConfig) []string { return warnings } +// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation) +func (s *Server) handleEstimateTokens(c *gin.Context) { + var req struct { + Config store.StrategyConfig `json:"config" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + SafeBadRequest(c, "Invalid request parameters") + return + } + + estimate := req.Config.EstimateTokens() + c.JSON(http.StatusOK, estimate) +} + // handlePublicStrategies Get public strategies for strategy market (no auth required) func (s *Server) handlePublicStrategies(c *gin.Context) { strategies, err := s.store.Strategy().ListPublic() @@ -150,8 +164,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Lang string `json:"lang"` // "zh" or "en", used when config is omitted - Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted + Lang string `json:"lang"` // "zh" or "en", used when config is omitted + Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted } if err := c.ShouldBindJSON(&req); err != nil { @@ -289,6 +303,25 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) { return } + // Token overflow check — block save if all models exceed context limits + if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" { + estimate := mergedConfig.EstimateTokens() + allExceed := true + for _, ml := range estimate.ModelLimits { + if ml.UsagePct <= 100 { + allExceed = false + break + } + } + if allExceed && len(estimate.ModelLimits) > 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total), + "token_estimate": estimate, + }) + return + } + } + // Validate merged configuration and collect warnings warnings := validateStrategyConfig(&mergedConfig) @@ -311,7 +344,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) { } if err := s.store.Strategy().Delete(userID, strategyID); err != nil { - SafeInternalError(c, "Failed to delete strategy", err) + c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")}) return } @@ -419,9 +452,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) { } var req struct { - Config store.StrategyConfig `json:"config" binding:"required"` - AccountEquity float64 `json:"account_equity"` - PromptVariant string `json:"prompt_variant"` + Config store.StrategyConfig `json:"config" binding:"required"` + AccountEquity float64 `json:"account_equity"` + PromptVariant string `json:"prompt_variant"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -664,4 +697,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) return response, nil } - diff --git a/docker-compose.yml b/docker-compose.yml index 82723977cf..7d36c2c1a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "${NOFX_BACKEND_PORT:-8080}:8080" - "6060:6060" # pprof profiling volumes: + - ./.env:/app/.env - ./data:/app/data - /etc/localtime:/etc/localtime:ro env_file: @@ -49,4 +50,4 @@ services: networks: nofx-network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docs/i18n/ja/README.md b/docs/i18n/ja/README.md index ab02527a65..763fa7b21f 100644 --- a/docs/i18n/ja/README.md +++ b/docs/i18n/ja/README.md @@ -69,7 +69,7 @@ x402 フロー: | 機能 | 説明 | |:--------|:------------| -| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — いつでも切替 | +| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 | | **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 | | **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 | @@ -112,6 +112,7 @@ x402 フロー: | **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) | | **Grok** | ✅ | [API キー取得](https://console.x.ai) | | **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) | ### AI モデル (x402 モード — API キー不要) diff --git a/docs/i18n/ko/README.md b/docs/i18n/ko/README.md index fd66d8e3cc..41b3069777 100644 --- a/docs/i18n/ko/README.md +++ b/docs/i18n/ko/README.md @@ -69,7 +69,7 @@ x402 플로우: | 기능 | 설명 | |:--------|:------------| -| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — 언제든 전환 | +| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 | | **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 | | **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 | @@ -112,6 +112,7 @@ x402 플로우: | **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) | | **Grok** | ✅ | [API 키 받기](https://console.x.ai) | | **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) | ### AI 모델 (x402 모드 — API 키 불필요) diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index 2dc6363ebe..bded307f8e 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -69,7 +69,7 @@ x402 процесс: | Функция | Описание | |:--------|:------------| -| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключение в любой момент | +| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент | | **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков | | **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют | @@ -112,6 +112,7 @@ x402 процесс: | **Gemini** | ✅ | [Получить](https://aistudio.google.com) | | **Grok** | ✅ | [Получить](https://console.x.ai) | | **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) | ### AI Модели (Режим x402 — без API ключей) diff --git a/docs/i18n/uk/README.md b/docs/i18n/uk/README.md index 1bf1ef7aa6..30a8b2ce9d 100644 --- a/docs/i18n/uk/README.md +++ b/docs/i18n/uk/README.md @@ -69,7 +69,7 @@ x402 процес: | Функція | Опис | |:--------|:------------| -| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикання будь-коли | +| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли | | **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків | | **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують | @@ -112,6 +112,7 @@ x402 процес: | **Gemini** | ✅ | [Отримати](https://aistudio.google.com) | | **Grok** | ✅ | [Отримати](https://console.x.ai) | | **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) | ### AI Моделі (Режим x402 — без API ключів) diff --git a/docs/i18n/vi/README.md b/docs/i18n/vi/README.md index cd507a0877..e91e467986 100644 --- a/docs/i18n/vi/README.md +++ b/docs/i18n/vi/README.md @@ -69,7 +69,7 @@ Không tài khoản. Không API key. Không trả trước. Một ví, tất c | Tính năng | Mô tả | |:--------|:------------| -| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — chuyển đổi bất cứ lúc nào | +| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào | | **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter | | **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro | | **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất | @@ -110,6 +110,7 @@ Crypto · Cổ phiếu Mỹ · Forex · Kim loại | **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) | | **Grok** | ✅ | [Lấy API Key](https://console.x.ai) | | **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) | ### Mô hình AI (Chế độ x402 — Không cần API Key) diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index e24b99ea33..eb224bebeb 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -71,7 +71,7 @@ x402 流程: | 功能 | 描述 | |:--------|:------------| -| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi — 随时切换 | +| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 | | **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter | | **策略工作室** | 可视化构建器 — 币种来源、指标、风控 | | **AI 竞赛** | AI 实时竞争,排行榜排名 | @@ -113,6 +113,7 @@ x402 流程: | **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) | | **Grok** | ✅ | [获取 API Key](https://console.x.ai) | | **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) | +| **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) | ### AI 模型 (x402 模式 — 无需 API Key) @@ -170,6 +171,10 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas ## 配置 +**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。 + +**进阶模式**: + 1. **AI** — 添加 API Key 或配置 x402 钱包 2. **交易所** — 连接交易所 API 凭证 3. **策略** — 在策略工作室构建 diff --git a/docs/token-estimation.zh-CN.md b/docs/token-estimation.zh-CN.md new file mode 100644 index 0000000000..eb8aa5e732 --- /dev/null +++ b/docs/token-estimation.zh-CN.md @@ -0,0 +1,137 @@ +# 📊 Token 估算分析与候选币种上限指南 + +> 版本:v1.0 | 更新:2026-03-27 +> 适用:策略配置 · 模型选择 · 候选币种数量决策 + +--- + +## 目录 + +- [Token 估算公式](#-token-估算公式) +- [系统提示词的准确性分析](#-系统提示词的准确性分析) +- [典型配置下的安全币种数量](#-典型配置下的安全币种数量) +- [模型上限参考](#-模型上限参考) +- [MaxCandidateCoins 常量说明](#-maxcandidatecoins-常量说明) + +--- + +## 📐 Token 估算公式 + +代码入口:`store/strategy.go` → `EstimateTokens()` + +整体结构: + +``` +total = (staticTokens + N × perCoinTokens) × 1.15 +``` + +其中 `1.15` 为 15% 安全边际。 + +### 静态部分(与候选币数量无关) + +``` +SystemPrompt = baseChars / 2(zh)或 / 4(en) + baseChars ≈ 3000(zh)/ 4000(en)+ 自定义提示段落长度 + +FixedOverhead = 200 tokens(时间戳、账户信息、章节标题) + +RankingData = (OILimit × 60 + NetFlowLimit × 80 + PriceLimit × durations × 40) / 4 + +staticTokens = SystemPrompt + FixedOverhead + RankingData + ≈ 1500 + 200 + 650 = 2350 tokens(默认中文配置) +``` + +### 每枚币的 Token 开销 + +``` +# 每行指标额外字符数(I) +I = EnableEMA×20 + EnableMACD×30 + EnableRSI×15 + + EnableATR×15 + EnableBOLL×25 + EnableVolume×10 + +# 每枚币的市场数据 token +marketPerCoin = (T × K × (80 + I) + 100) / 4 + ↑ T=时间框架数 K=每TF K线数 + ↑ 100 = OI + 资金费率固定开销 + +# 每枚币的量化数据 token +quantPerCoin = (EnableQuantOI×300 + EnableQuantNetflow×300) / 4 + +perCoinTokens = marketPerCoin + quantPerCoin +``` + +### 反向公式:最大安全币数 + +``` +budget = modelContextLimit × 0.80 / 1.15 +maxSafeCoins = floor((budget - staticTokens) / perCoinTokens) +``` + +--- + +## 📊 典型配置下的安全币种数量 + +**基准:131K 模型(DeepSeek / Grok / Qwen)**,80% 警戒线 + +### 三种配置的 perCoinTokens + +| 配置 | T | K | I | quantPerCoin | perCoinTokens | +| ------------------------------------------ | --- | --- | --- | ------------ | ------------- | +| **最小**(单TF,无指标,无量化) | 1 | 10 | 0 | 0 | **225** | +| **默认**(3TF,仅Volume,QuantOI+Netflow) | 3 | 20 | 10 | 600 | **1525** | +| **最大**(4TF,全部指标,全量化) | 4 | 30 | 115 | 600 | **6025** | + +### 各模型下的最大安全币数 + +| 模型上限 | 最小配置 | 默认配置 | 最大配置 | +| ------------------------------ | ------------ | ------------ | ----------- | +| 131K(DeepSeek / Grok / Qwen) | ≥10(封顶) | ≥10(封顶) | **14** | +| 128K(OpenAI GPT-4) | ≥10(封顶) | ≥10(封顶) | **14** | +| 200K(Claude) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) | +| 1M(Gemini / Minimax) | ≥10(封顶) | ≥10(封顶) | ≥10(封顶) | + +--- + +## 🤖 模型上限参考 + +来源:`store/strategy.go` → `ModelContextLimits` + +| 模型 | Context 上限 | 80% 警戒线 | +| -------- | ------------ | ---------- | +| deepseek | 131,072 | 104,858 | +| openai | 128,000 | 102,400 | +| claude | 200,000 | 160,000 | +| qwen | 131,072 | 104,858 | +| gemini | 1,000,000 | 800,000 | +| grok | 131,072 | 104,858 | +| kimi | 131,072 | 104,858 | +| minimax | 1,000,000 | 800,000 | + +--- + +## 🔒 MaxCandidateCoins 常量说明 + +来源:`store/strategy.go` 第 14-20 行 + +```go +const ( + MaxCandidateCoins = 10 // UI 硬限制:用户最多设定的候选币数量 + MaxPositions = 3 // 最大同时持仓数 + MaxTimeframes = 4 // 最大时间框架数 + MinKlineCount = 10 // 最少 K 线数 + MaxKlineCount = 30 // 最多 K 线数 +) +``` + +### 为什么 MaxCandidateCoins = 10? + +- **默认配置**下 10 枚币约用 **~20,000 tokens**(~15% of 131K),完全安全 +- **极端配置**(4TF + 全指标)10 枚币约用 **~72,000 tokens**(~55% of 131K),仍有充足余量 +- 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制 + +### 建议使用范围 + +| 用户类型 | 建议配置 | 最大建议币数 | +| ------------------- | ----------------------- | ------------ | +| 新手 / 使用默认配置 | 3TF, K=20, 仅 Volume | 10-20 枚 | +| 进阶 / 启用部分指标 | 3TF, K=20, EMA+MACD+RSI | 10-15 枚 | +| 高级 / 全部指标 | 3-4TF, K=20-30, 全指标 | 5-10 枚 | diff --git a/kernel/engine.go b/kernel/engine.go index 1fe9c95823..a5e1f0ceec 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "nofx/logger" "nofx/market" "nofx/provider/hyperliquid" @@ -185,8 +186,9 @@ type StrategyEngine struct { nofxosClient *nofxos.Client } -// NewStrategyEngine creates strategy execution engine -func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { +// NewStrategyEngine creates strategy execution engine. +// claw402WalletKey is optional — if provided, nofxos data requests are routed through claw402. +func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) *StrategyEngine { // Create NofxOS client with API key from config apiKey := config.Indicators.NofxOSAPIKey if apiKey == "" { @@ -194,6 +196,28 @@ func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { } client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey) + // If claw402 wallet key is provided (from trader's AI config), route through claw402 + walletKey := "" + if len(claw402WalletKey) > 0 { + walletKey = claw402WalletKey[0] + } + if walletKey == "" { + walletKey = os.Getenv("CLAW402_WALLET_KEY") + } + if walletKey != "" { + claw402URL := os.Getenv("CLAW402_URL") + if claw402URL == "" { + claw402URL = "https://claw402.ai" + } + claw402Client, err := nofxos.NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{}) + if err == nil { + client.SetClaw402(claw402Client) + logger.Infof("🔗 NofxOS data routed through claw402 (%s)", claw402URL) + } else { + logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err) + } + } + return &StrategyEngine{ config: config, nofxosClient: client, diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go index 4a1071bd7c..8c08ba0b93 100644 --- a/kernel/engine_analysis.go +++ b/kernel/engine_analysis.go @@ -51,6 +51,33 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S engine = NewStrategyEngine(&defaultConfig) } + // Clamp strategy limits to prevent token overflow + engineConfig := engine.GetConfig() + engineConfig.ClampLimits() + + // Token estimation check — block if exceeding the specific model's context limit + estimate := engineConfig.EstimateTokens() + + // Determine context limit for the specific model being used + contextLimit := 131072 // safe default (strictest common limit) + var providerName string + if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok { + base := embedder.BaseClient() + providerName = base.Provider + contextLimit = store.GetContextLimitForClient(base.Provider, base.Model) + } + + if estimate.Total > contextLimit { + logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis", + estimate.Total, providerName, contextLimit) + return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count", + estimate.Total, contextLimit) + } + if estimate.Total*100/contextLimit >= 80 { + logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d", + estimate.Total, providerName, contextLimit) + } + // 1. Fetch market data using strategy config if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { diff --git a/main.go b/main.go index 7fa7295cbc..a2c4f44140 100644 --- a/main.go +++ b/main.go @@ -118,8 +118,12 @@ func main() { if t.IsRunning { status = "✅ Running" } - logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", - t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID) + idShort := t.ID + if len(idShort) > 8 { + idShort = idShort[:8] + } + logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s", + t.Name, idShort, status, t.AIModelID, t.ExchangeID) } } diff --git a/market/data_klines.go b/market/data_klines.go index e4cb869fc9..94c320f0ad 100644 --- a/market/data_klines.go +++ b/market/data_klines.go @@ -78,15 +78,19 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) - if err != nil { - // If exchange-specific data fails, fallback to Binance + if err != nil || len(coinankKlines) == 0 { + // If exchange-specific data fails or returns empty, fallback to Binance if coinankExchange != coinank_enum.Binance { - logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err) + if err != nil { + logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err) + } else { + logger.Warnf("⚠️ CoinAnk %s %s data empty for %s, falling back to Binance", exchange, interval, symbol) + } coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) if err != nil { return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err) } - } else { + } else if err != nil { return nil, fmt.Errorf("CoinAnk API error: %w", err) } } diff --git a/mcp/client.go b/mcp/client.go index 99b0fec276..b70ac1cfc2 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -392,6 +392,9 @@ func (client *Client) String() string { client.Provider, client.Model) } +// BaseClient returns the underlying *Client (satisfies ClientEmbedder interface). +func (c *Client) BaseClient() *Client { return c } + // IsRetryableError determines if error is retryable (network errors, timeouts, etc.) func (client *Client) IsRetryableError(err error) bool { errStr := err.Error() @@ -760,10 +763,21 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string } `json:"delta"` FinishReason *string `json:"finish_reason"` } `json:"choices"` + Usage *struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage,omitempty"` } if err := json.Unmarshal([]byte(data), &chunk); err != nil { continue // skip malformed chunks } + + if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 { + fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n", + chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens) + } + if len(chunk.Choices) == 0 { continue } diff --git a/mcp/payment/x402.go b/mcp/payment/x402.go index 064cc086d5..7ce70fdc3f 100644 --- a/mcp/payment/x402.go +++ b/mcp/payment/x402.go @@ -81,6 +81,13 @@ func X402DecodeHeader(b64 string) ([]byte, error) { return decoded, nil } +// MakeClaw402SignFunc creates an X402SignFunc from a private key for claw402 payments. +func MakeClaw402SignFunc(privateKey *ecdsa.PrivateKey) X402SignFunc { + return func(paymentHeaderB64 string) (string, error) { + return SignBasePaymentHeader(privateKey, paymentHeaderB64, "Claw402") + } +} + // SignBasePaymentHeader decodes a base64 x402 header, parses it, and signs with // EIP-712 (USDC TransferWithAuthorization). func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) { diff --git a/mcp/provider/minimax.go b/mcp/provider/minimax.go index 23e66686b7..e0d86580a7 100644 --- a/mcp/provider/minimax.go +++ b/mcp/provider/minimax.go @@ -8,7 +8,7 @@ import ( const ( DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" - DefaultMiniMaxModel = "MiniMax-M2.5" + DefaultMiniMaxModel = "MiniMax-M2.7" ) func init() { diff --git a/mcp/providers.go b/mcp/providers.go index 75f3d9c7f9..29018b3c6a 100644 --- a/mcp/providers.go +++ b/mcp/providers.go @@ -25,5 +25,5 @@ const ( // Default MiniMax configuration (used by WithMiniMaxConfig convenience option) DefaultMiniMaxBaseURL = "https://api.minimax.io/v1" - DefaultMiniMaxModel = "MiniMax-M2.5" + DefaultMiniMaxModel = "MiniMax-M2.7" ) diff --git a/provider/nofxos/claw402.go b/provider/nofxos/claw402.go new file mode 100644 index 0000000000..0e4d610705 --- /dev/null +++ b/provider/nofxos/claw402.go @@ -0,0 +1,112 @@ +package nofxos + +import ( + "context" + "crypto/ecdsa" + "fmt" + "net/http" + "nofx/mcp" + "nofx/mcp/payment" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +// Claw402DataClient wraps nofxos API calls through claw402's x402 payment gateway. +// Instead of calling nofxos.ai directly, it calls claw402.ai/api/v1/nofx/... +// and pays with USDC for each request. +type Claw402DataClient struct { + claw402URL string + privateKey *ecdsa.PrivateKey + httpClient *http.Client + logger mcp.Logger +} + +// NewClaw402DataClient creates a client that routes nofxos requests through claw402. +// privateKeyHex is the wallet private key (0x-prefixed hex string). +func NewClaw402DataClient(claw402URL, privateKeyHex string, logger mcp.Logger) (*Claw402DataClient, error) { + if claw402URL == "" { + claw402URL = "https://claw402.ai" + } + claw402URL = strings.TrimRight(claw402URL, "/") + + if privateKeyHex == "" { + privateKeyHex = os.Getenv("CLAW402_WALLET_KEY") + } + if privateKeyHex == "" { + return nil, fmt.Errorf("claw402 wallet private key not set") + } + + hexKey := strings.TrimPrefix(privateKeyHex, "0x") + pk, err := crypto.HexToECDSA(hexKey) + if err != nil { + return nil, fmt.Errorf("invalid claw402 private key: %w", err) + } + + return &Claw402DataClient{ + claw402URL: claw402URL, + privateKey: pk, + httpClient: &http.Client{Timeout: 30 * time.Second}, + logger: logger, + }, nil +} + +// endpoint mapping: nofxos path → claw402 path +var endpointMap = map[string]string{ + "/api/ai500/list": "/api/v1/nofx/ai500/list", + "/api/ai500/stats": "/api/v1/nofx/ai500/stats", +} + +// mapEndpoint converts a nofxos endpoint to a claw402 endpoint. +// For endpoints not in the static map, applies the general pattern: +// /api/xxx → /api/v1/nofx/xxx +func mapEndpoint(nofxosPath string) string { + if mapped, ok := endpointMap[nofxosPath]; ok { + return mapped + } + // General pattern: /api/xxx → /api/v1/nofx/xxx + if strings.HasPrefix(nofxosPath, "/api/") { + return "/api/v1/nofx/" + strings.TrimPrefix(nofxosPath, "/api/") + } + return nofxosPath +} + +// DoRequest makes a GET request through claw402 with x402 payment. +func (c *Claw402DataClient) DoRequest(endpoint string) ([]byte, error) { + claw402Path := mapEndpoint(endpoint) + // Strip auth= query params (claw402 uses x402 payment, not auth keys) + if idx := strings.Index(claw402Path, "?auth="); idx != -1 { + claw402Path = claw402Path[:idx] + } + if idx := strings.Index(claw402Path, "&auth="); idx != -1 { + claw402Path = claw402Path[:idx] + } + + fullURL := c.claw402URL + claw402Path + + buildReq := func() (*http.Request, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Client-ID", "nofx") + return req, nil + } + + signFn := payment.MakeClaw402SignFunc(c.privateKey) + + body, err := payment.DoX402Request( + c.httpClient, + buildReq, + signFn, + "claw402-data", + c.logger, + ) + if err != nil { + return nil, fmt.Errorf("claw402 data request failed (%s): %w", claw402Path, err) + } + + return body, nil +} diff --git a/provider/nofxos/client.go b/provider/nofxos/client.go index a3a99a009b..bee2dad5d1 100644 --- a/provider/nofxos/client.go +++ b/provider/nofxos/client.go @@ -25,6 +25,7 @@ type Client struct { AuthKey string Timeout time.Duration mu sync.RWMutex + claw402 *Claw402DataClient // If set, routes requests through claw402 } var ( @@ -59,6 +60,13 @@ func NewClient(baseURL, authKey string) *Client { } } +// SetClaw402 enables routing requests through claw402 payment gateway. +func (c *Client) SetClaw402(claw402Client *Claw402DataClient) { + c.mu.Lock() + defer c.mu.Unlock() + c.claw402 = claw402Client +} + // SetConfig updates client configuration func (c *Client) SetConfig(baseURL, authKey string) { c.mu.Lock() @@ -85,14 +93,21 @@ func (c *Client) GetAuthKey() string { return c.AuthKey } -// doRequest performs an HTTP GET request with authentication +// doRequest performs an HTTP GET request with authentication. +// If claw402 client is configured, routes through claw402 payment gateway instead. func (c *Client) doRequest(endpoint string) ([]byte, error) { c.mu.RLock() + claw402Client := c.claw402 baseURL := c.BaseURL authKey := c.AuthKey timeout := c.Timeout c.mu.RUnlock() + // Route through claw402 if configured + if claw402Client != nil { + return claw402Client.DoRequest(endpoint) + } + url := baseURL + endpoint if !strings.Contains(url, "auth=") { if strings.Contains(url, "?") { diff --git a/store/strategy.go b/store/strategy.go index 80eaa1716d..19bc95260f 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -3,11 +3,63 @@ package store import ( "encoding/json" "fmt" + "sort" + "strings" "time" "gorm.io/gorm" ) +// Hard limits to prevent token explosion in AI requests +const ( + MaxCandidateCoins = 10 + MaxPositions = 3 + MaxTimeframes = 4 + MinKlineCount = 10 + MaxKlineCount = 30 +) + +// ClampLimits enforces product-level limits on strategy config to prevent token overflow. +func (c *StrategyConfig) ClampLimits() { + // Clamp coin source limits + if c.CoinSource.AI500Limit > MaxCandidateCoins { + c.CoinSource.AI500Limit = MaxCandidateCoins + } + if c.CoinSource.OITopLimit > MaxCandidateCoins { + c.CoinSource.OITopLimit = MaxCandidateCoins + } + if c.CoinSource.OILowLimit > MaxCandidateCoins { + c.CoinSource.OILowLimit = MaxCandidateCoins + } + + // Clamp static coins + if len(c.CoinSource.StaticCoins) > MaxCandidateCoins { + c.CoinSource.StaticCoins = c.CoinSource.StaticCoins[:MaxCandidateCoins] + } + + // Clamp kline count + if c.Indicators.Klines.PrimaryCount < MinKlineCount { + c.Indicators.Klines.PrimaryCount = MinKlineCount + } + if c.Indicators.Klines.PrimaryCount > MaxKlineCount { + c.Indicators.Klines.PrimaryCount = MaxKlineCount + } + if c.Indicators.Klines.LongerCount > MaxKlineCount { + c.Indicators.Klines.LongerCount = MaxKlineCount + } + + // Clamp timeframes + if len(c.Indicators.Klines.SelectedTimeframes) > MaxTimeframes { + c.Indicators.Klines.SelectedTimeframes = c.Indicators.Klines.SelectedTimeframes[:MaxTimeframes] + } + + // Clamp max positions + if c.RiskControl.MaxPositions > MaxPositions { + c.RiskControl.MaxPositions = MaxPositions + } + +} + // StrategyStore strategy storage type StrategyStore struct { db *gorm.DB @@ -21,8 +73,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"` @@ -139,7 +191,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 @@ -197,10 +249,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) @@ -260,20 +312,20 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { CoinSource: CoinSourceConfig{ SourceType: "ai500", UseAI500: true, - AI500Limit: 10, + AI500Limit: 3, UseOITop: false, - OITopLimit: 10, + OITopLimit: 3, UseOILow: false, - OILowLimit: 10, + OILowLimit: 3, }, Indicators: IndicatorConfig{ Klines: KlineConfig{ PrimaryTimeframe: "5m", - PrimaryCount: 30, + PrimaryCount: 20, LongerTimeframe: "4h", LongerCount: 10, EnableMultiTimeframe: true, - SelectedTimeframes: []string{"5m", "15m", "1h", "4h"}, + SelectedTimeframes: []string{"5m", "15m", "1h"}, }, EnableRawKlines: true, // Required - raw OHLCV data for AI analysis EnableEMA: false, @@ -308,15 +360,15 @@ 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) + 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) }, } @@ -388,8 +440,18 @@ func (s *StrategyStore) Update(strategy *Strategy) error { func (s *StrategyStore) Delete(userID, id string) error { // do not allow deleting system default strategy var st Strategy - if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault { - return fmt.Errorf("cannot delete system default strategy") + if err := s.db.Where("id = ?", id).First(&st).Error; err == nil { + if st.IsDefault { + return fmt.Errorf("cannot delete system default strategy") + } + } + + // Check if any trader references this strategy + var count int64 + if err := s.db.Model(&Trader{}). + Where("user_id = ? AND strategy_id = ?", userID, id). + Count(&count).Error; err == nil && count > 0 { + return fmt.Errorf("cannot delete strategy in use by %d trader(s) - reassign those traders first", count) } return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error @@ -510,3 +572,308 @@ func (s *Strategy) SetConfig(config *StrategyConfig) error { s.Config = string(data) return nil } + +// ============================================================================ +// Token Estimation +// ============================================================================ + +// TokenEstimate holds the result of token estimation +type TokenEstimate struct { + Total int `json:"total"` + Breakdown TokenBreakdown `json:"breakdown"` + ModelLimits []ModelLimit `json:"model_limits"` + Suggestions []string `json:"suggestions"` +} + +// TokenBreakdown shows estimated tokens per component +type TokenBreakdown struct { + SystemPrompt int `json:"system_prompt"` + MarketData int `json:"market_data"` + RankingData int `json:"ranking_data"` + QuantData int `json:"quant_data"` + FixedOverhead int `json:"fixed_overhead"` +} + +// ModelLimit shows token usage against a specific model's context limit +type ModelLimit struct { + Name string `json:"name"` + ContextLimit int `json:"context_limit"` + UsagePct int `json:"usage_pct"` + Level string `json:"level"` // "ok" | "warning" | "danger" +} + +// Context window sizes (tokens) for each model family +const ( + contextLimitDeepSeek = 131_072 // 128K + contextLimitOpenAI = 128_000 // 128K + contextLimitClaude = 200_000 // 200K + contextLimitQwen = 131_072 // 128K + contextLimitGemini = 1_000_000 // 1M + contextLimitGrok = 131_072 // 128K + contextLimitKimi = 131_072 // 128K + contextLimitMinimax = 1_000_000 // 1M +) + +// ModelContextLimits maps provider names to their context window sizes (in tokens) +var ModelContextLimits = map[string]int{ + "deepseek": contextLimitDeepSeek, + "openai": contextLimitOpenAI, + "claude": contextLimitClaude, + "qwen": contextLimitQwen, + "gemini": contextLimitGemini, + "grok": contextLimitGrok, + "kimi": contextLimitKimi, + "minimax": contextLimitMinimax, +} + +// GetContextLimit returns the context limit for a given provider +func GetContextLimit(provider string) int { + if limit, ok := ModelContextLimits[provider]; ok { + return limit + } + return contextLimitDeepSeek // safe default +} + +// GetContextLimitForClient returns context limit for a provider+model pair. +// For claw402, the underlying model is inferred from the model name prefix. +func GetContextLimitForClient(provider, model string) int { + if provider == "claw402" { + switch { + case strings.HasPrefix(model, "claude"): + return ModelContextLimits["claude"] + case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"): + return ModelContextLimits["openai"] + case strings.HasPrefix(model, "gemini"): + return ModelContextLimits["gemini"] + case strings.HasPrefix(model, "grok"): + return ModelContextLimits["grok"] + case strings.HasPrefix(model, "kimi"): + return ModelContextLimits["kimi"] + case strings.HasPrefix(model, "qwen"): + return ModelContextLimits["qwen"] + case strings.HasPrefix(model, "minimax"): + return ModelContextLimits["minimax"] + case strings.HasPrefix(model, "deepseek"): + return ModelContextLimits["deepseek"] + default: + return ModelContextLimits["deepseek"] + } + } + return GetContextLimit(provider) +} + +// EstimateTokens estimates the total token count for a strategy configuration. +// This is a pure computation based on config fields — no network calls. +func (c *StrategyConfig) EstimateTokens() TokenEstimate { + breakdown := TokenBreakdown{} + + // --- System Prompt --- + // Base system prompt: schema + role + rules + output format + baseChars := 4000 // English default + if c.Language == "zh" { + baseChars = 3000 + } + // Add prompt sections + baseChars += len(c.PromptSections.RoleDefinition) + baseChars += len(c.PromptSections.TradingFrequency) + baseChars += len(c.PromptSections.EntryStandards) + baseChars += len(c.PromptSections.DecisionProcess) + baseChars += len(c.CustomPrompt) + + if c.Language == "zh" { + breakdown.SystemPrompt = baseChars / 2 // CJK: ~2 chars per token + } else { + breakdown.SystemPrompt = baseChars / 4 // English: ~4 chars per token + } + + // --- Fixed Overhead --- + // Time, BTC price, account info, section headers + breakdown.FixedOverhead = 800 / 4 // ~200 tokens + + // --- Market Data --- + numCoins := c.getEffectiveCoinCount() + numTimeframes := c.getEffectiveTimeframeCount() + klineCount := c.Indicators.Klines.PrimaryCount + if klineCount <= 0 { + klineCount = 20 + } + + // Per coin per timeframe: kline OHLCV rows + charsPerCoinTF := klineCount * 80 // each OHLCV line ~80 chars + + // Add enabled indicator overhead per timeframe + indicatorCharsPerLine := 0 + if c.Indicators.EnableEMA { + indicatorCharsPerLine += 20 // EMA values appended + } + if c.Indicators.EnableMACD { + indicatorCharsPerLine += 30 + } + if c.Indicators.EnableRSI { + indicatorCharsPerLine += 15 + } + if c.Indicators.EnableATR { + indicatorCharsPerLine += 15 + } + if c.Indicators.EnableBOLL { + indicatorCharsPerLine += 25 + } + if c.Indicators.EnableVolume { + indicatorCharsPerLine += 10 + } + charsPerCoinTF += klineCount * indicatorCharsPerLine + + totalMarketChars := numCoins * numTimeframes * charsPerCoinTF + + // OI + Funding per coin + if c.Indicators.EnableOI || c.Indicators.EnableFundingRate { + totalMarketChars += numCoins * 100 + } + + breakdown.MarketData = totalMarketChars / 4 // numeric data: ~4 chars per token + + // --- Quant Data --- + if c.Indicators.EnableQuantData { + quantCharsPerCoin := 0 + if c.Indicators.EnableQuantOI { + quantCharsPerCoin += 300 + } + if c.Indicators.EnableQuantNetflow { + quantCharsPerCoin += 300 + } + breakdown.QuantData = (numCoins * quantCharsPerCoin) / 4 + } + + // --- Ranking Data --- + rankingChars := 0 + if c.Indicators.EnableOIRanking { + limit := c.Indicators.OIRankingLimit + if limit <= 0 { + limit = 10 + } + rankingChars += limit * 60 + } + if c.Indicators.EnableNetFlowRanking { + limit := c.Indicators.NetFlowRankingLimit + if limit <= 0 { + limit = 10 + } + rankingChars += limit * 80 + } + if c.Indicators.EnablePriceRanking { + limit := c.Indicators.PriceRankingLimit + if limit <= 0 { + limit = 10 + } + // Count durations (comma-separated) + numDurations := 1 + if c.Indicators.PriceRankingDuration != "" { + numDurations = len(strings.Split(c.Indicators.PriceRankingDuration, ",")) + } + rankingChars += limit * numDurations * 40 + } + breakdown.RankingData = rankingChars / 4 + + // --- Total with 15% safety margin --- + subtotal := breakdown.SystemPrompt + breakdown.MarketData + breakdown.RankingData + breakdown.QuantData + breakdown.FixedOverhead + total := subtotal * 115 / 100 + + // --- Model limits --- + modelLimits := make([]ModelLimit, 0, len(ModelContextLimits)) + for name, limit := range ModelContextLimits { + pct := total * 100 / limit + level := "ok" + if pct >= 100 { + level = "danger" + } else if pct >= 80 { + level = "warning" + } + modelLimits = append(modelLimits, ModelLimit{ + Name: name, + ContextLimit: limit, + UsagePct: pct, + Level: level, + }) + } + + // Sort by usage_pct desc, then name asc for deterministic order + sort.Slice(modelLimits, func(i, j int) bool { + if modelLimits[i].UsagePct != modelLimits[j].UsagePct { + return modelLimits[i].UsagePct > modelLimits[j].UsagePct + } + return modelLimits[i].Name < modelLimits[j].Name + }) + + // --- Suggestions --- + var suggestions []string + // Find the strictest model (smallest context) + minLimit := 0 + for _, limit := range ModelContextLimits { + if minLimit == 0 || limit < minLimit { + minLimit = limit + } + } + if minLimit > 0 && total > minLimit { + if numTimeframes > 1 { + savedPerTF := (numCoins * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100 + suggestions = append(suggestions, fmt.Sprintf("Reduce 1 timeframe to save ~%d tokens", savedPerTF)) + } + if numCoins > 1 { + savedPerCoin := (numTimeframes * klineCount * (80 + indicatorCharsPerLine)) / 4 * 115 / 100 + suggestions = append(suggestions, fmt.Sprintf("Reduce 1 coin to save ~%d tokens", savedPerCoin)) + } + if klineCount > 15 { + suggestions = append(suggestions, "Reduce K-line count to 15 to save tokens") + } + } + + return TokenEstimate{ + Total: total, + Breakdown: breakdown, + ModelLimits: modelLimits, + Suggestions: suggestions, + } +} + +// getEffectiveCoinCount returns the estimated number of coins that will be analyzed +func (c *StrategyConfig) getEffectiveCoinCount() int { + count := 0 + switch c.CoinSource.SourceType { + case "static": + count = len(c.CoinSource.StaticCoins) + case "ai500": + count = c.CoinSource.AI500Limit + case "oi_top": + count = c.CoinSource.OITopLimit + case "oi_low": + count = c.CoinSource.OILowLimit + case "mixed": + if c.CoinSource.UseAI500 { + count += c.CoinSource.AI500Limit + } + if c.CoinSource.UseOITop { + count += c.CoinSource.OITopLimit + } + if c.CoinSource.UseOILow { + count += c.CoinSource.OILowLimit + } + default: + count = c.CoinSource.AI500Limit + } + if count <= 0 { + count = 3 + } + return count +} + +// getEffectiveTimeframeCount returns the number of timeframes that will be used +func (c *StrategyConfig) getEffectiveTimeframeCount() int { + if len(c.Indicators.Klines.SelectedTimeframes) > 0 { + return len(c.Indicators.Klines.SelectedTimeframes) + } + count := 1 + if c.Indicators.Klines.LongerTimeframe != "" { + count++ + } + return count +} diff --git a/store/strategy_token_test.go b/store/strategy_token_test.go new file mode 100644 index 0000000000..d9d0997a1e --- /dev/null +++ b/store/strategy_token_test.go @@ -0,0 +1,112 @@ +package store + +import "testing" + +func TestEstimateTokens_DefaultConfig(t *testing.T) { + config := GetDefaultStrategyConfig("en") + est := config.EstimateTokens() + + if est.Total <= 0 { + t.Errorf("expected positive token estimate, got %d", est.Total) + } + if est.Total > 200000 { + t.Errorf("token estimate %d seems unreasonably high for default config", est.Total) + } + + // Breakdown should sum approximately to total (before 15% margin) + subtotal := est.Breakdown.SystemPrompt + est.Breakdown.MarketData + + est.Breakdown.RankingData + est.Breakdown.QuantData + est.Breakdown.FixedOverhead + expectedTotal := subtotal * 115 / 100 + if est.Total != expectedTotal { + t.Errorf("total %d != breakdown subtotal %d * 1.15 = %d", est.Total, subtotal, expectedTotal) + } + + // Should have model limits + if len(est.ModelLimits) == 0 { + t.Error("expected model limits to be populated") + } + + // Default config should be ok for all models + for _, ml := range est.ModelLimits { + if ml.Level == "danger" { + t.Errorf("default config should not exceed %s limit, got %d%%", ml.Name, ml.UsagePct) + } + } +} + +func TestEstimateTokens_ZhVsEn(t *testing.T) { + enConfig := GetDefaultStrategyConfig("en") + zhConfig := GetDefaultStrategyConfig("zh") + + enEst := enConfig.EstimateTokens() + zhEst := zhConfig.EstimateTokens() + + // Chinese config should have more tokens for system prompt due to CJK encoding + // but total can vary — just ensure both are reasonable + if enEst.Total <= 0 || zhEst.Total <= 0 { + t.Errorf("both estimates should be positive: en=%d, zh=%d", enEst.Total, zhEst.Total) + } +} + +func TestEstimateTokens_HighConfig(t *testing.T) { + config := GetDefaultStrategyConfig("en") + // Push config to extremes (beyond clamped limits) + config.CoinSource.SourceType = "static" + config.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "DOGEUSDT", "XRPUSDT"} + config.Indicators.Klines.SelectedTimeframes = []string{"1m", "3m", "5m", "15m", "1h", "4h"} + config.Indicators.Klines.PrimaryCount = 100 + config.Indicators.EnableEMA = true + config.Indicators.EnableMACD = true + config.Indicators.EnableRSI = true + config.Indicators.EnableATR = true + config.Indicators.EnableBOLL = true + + est := config.EstimateTokens() + + // Should produce a higher estimate than default + defaultCfg := GetDefaultStrategyConfig("en") + defaultEst := defaultCfg.EstimateTokens() + if est.Total <= defaultEst.Total { + t.Errorf("high config estimate %d should be greater than default %d", est.Total, defaultEst.Total) + } + + // Should have some models in warning/danger + hasDanger := false + for _, ml := range est.ModelLimits { + if ml.Level == "danger" || ml.Level == "warning" { + hasDanger = true + break + } + } + // With 5 coins * 6 timeframes * 100 klines, this should exceed small models + if !hasDanger { + t.Logf("high config estimate: %d tokens", est.Total) + } +} + +func TestGetContextLimit(t *testing.T) { + if got := GetContextLimit("deepseek"); got != 131072 { + t.Errorf("deepseek limit = %d, want 131072", got) + } + if got := GetContextLimit("unknown_provider"); got != 131072 { + t.Errorf("unknown provider should return default 131072, got %d", got) + } +} + +func TestGetEffectiveCoinCount(t *testing.T) { + config := StrategyConfig{ + CoinSource: CoinSourceConfig{ + SourceType: "static", + StaticCoins: []string{"BTCUSDT", "ETHUSDT"}, + }, + } + if got := config.getEffectiveCoinCount(); got != 2 { + t.Errorf("static coin count = %d, want 2", got) + } + + config.CoinSource.SourceType = "ai500" + config.CoinSource.AI500Limit = 5 + if got := config.getEffectiveCoinCount(); got != 5 { + t.Errorf("ai500 coin count = %d, want 5", got) + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 5874af028d..42d6a13ff2 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -333,7 +333,13 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au if config.StrategyConfig == nil { return nil, fmt.Errorf("[%s] strategy not configured", config.Name) } - strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig) + // Pass claw402 wallet key to strategy engine so nofxos data requests + // are routed through claw402 (reuses the same wallet as AI calls) + var claw402Key string + if config.AIModel == "claw402" && config.CustomAPIKey != "" { + claw402Key = config.CustomAPIKey + } + strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig, claw402Key) logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name) return &AutoTrader{ diff --git a/trader/auto_trader_risk.go b/trader/auto_trader_risk.go index b8c94fb36d..937b1839d1 100644 --- a/trader/auto_trader_risk.go +++ b/trader/auto_trader_risk.go @@ -49,6 +49,12 @@ func (at *AutoTrader) checkPositionDrawdown() { quantity = -quantity // Short position quantity is negative, convert to positive } + // Guard: skip if entry price is zero (prevents division by zero panic) + if entryPrice <= 0 { + logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side) + continue + } + // Calculate current P&L percentage leverage := 10 // Default value if lev, ok := pos["leverage"].(float64); ok { diff --git a/web/package-lock.json b/web/package-lock.json index bd3d7f5eca..881bf8cf7e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1010,9 +1010,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1021,9 +1021,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1084,9 +1084,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1105,9 +1105,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1728,9 +1728,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -1742,9 +1742,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -1756,9 +1756,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -1770,9 +1770,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -1784,9 +1784,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -1798,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -1812,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], @@ -1826,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], @@ -1840,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], @@ -1854,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], @@ -1868,9 +1868,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], @@ -1882,9 +1896,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], @@ -1896,9 +1924,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], @@ -1910,9 +1938,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], @@ -1924,9 +1952,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], @@ -1938,9 +1966,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], @@ -1952,9 +1980,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], @@ -1965,10 +1993,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1980,9 +2022,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1994,9 +2036,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -2008,9 +2050,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -2022,9 +2064,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -2694,9 +2736,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -3029,13 +3071,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3070,9 +3112,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4285,9 +4327,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4296,9 +4338,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4349,9 +4391,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4383,9 +4425,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4640,9 +4682,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5933,9 +5975,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -6095,13 +6137,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6497,9 +6539,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6968,9 +7010,9 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6990,12 +7032,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", - "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", "license": "MIT", "dependencies": { - "react-router": "7.10.1" + "react-router": "7.13.2" }, "engines": { "node": ">=20.0.0" @@ -7247,9 +7289,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7263,28 +7305,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -8094,9 +8139,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8541,9 +8586,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8632,9 +8677,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -8932,9 +8977,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 17a9c2b1d6..b53bcc0c08 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -15,6 +15,7 @@ import { FAQPage } from './pages/FAQPage' import { StrategyStudioPage } from './pages/StrategyStudioPage' import { StrategyMarketPage } from './pages/StrategyMarketPage' import { DataPage } from './pages/DataPage' +import { BeginnerOnboardingPage } from './pages/BeginnerOnboardingPage' import { LoginRequiredOverlay } from './components/auth/LoginRequiredOverlay' import HeaderBar from './components/common/HeaderBar' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' @@ -22,6 +23,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/common/ConfirmDialog' import { t } from './i18n/translations' import { useSystemConfig } from './hooks/useSystemConfig' +import { getUserMode, hasCompletedBeginnerOnboarding } from './lib/onboarding' import { OFFICIAL_LINKS } from './constants/branding' import type { @@ -53,16 +55,12 @@ function App() { const { config: systemConfig, loading: configLoading } = useSystemConfig() const [route, setRoute] = useState(window.location.pathname) - // Debug log - useEffect(() => { - console.log('[App] Mounted. Route:', window.location.pathname); - }, []); - // 从URL路径读取初始页面状态(支持刷新保持页面) const getInitialPage = (): Page => { const path = window.location.pathname const hash = window.location.hash.slice(1) // 去掉 # + if (path === '/welcome') return 'traders' if (path === '/traders' || hash === 'traders') return 'traders' if (path === '/strategy' || hash === 'strategy') return 'strategy' if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market' @@ -132,6 +130,20 @@ function App() { } const [lastUpdate, setLastUpdate] = useState('--:--:--') const [decisionsLimit, setDecisionsLimit] = useState(5) + const hasPersistedAuth = + !!localStorage.getItem('auth_token') && !!localStorage.getItem('auth_user') + + // Poll-off states: stop polling after 3 consecutive failures + const [accountPollOff, setAccountPollOff] = useState(false) + const [positionsPollOff, setPositionsPollOff] = useState(false) + const [decisionsPollOff, setDecisionsPollOff] = useState(false) + + // Reset poll-off states when trader changes + useEffect(() => { + setAccountPollOff(false) + setPositionsPollOff(false) + setDecisionsPollOff(false) + }, [selectedTraderId]) // 监听URL变化,同步页面状态 useEffect(() => { @@ -141,7 +153,9 @@ function App() { const params = new URLSearchParams(window.location.search) const traderParam = params.get('trader') - if (path === '/traders' || hash === 'traders') { + if (path === '/welcome') { + setCurrentPage('traders') + } else if (path === '/traders' || hash === 'traders') { setCurrentPage('traders') } else if (path === '/strategy' || hash === 'strategy') { setCurrentPage('strategy') @@ -238,11 +252,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `account-${selectedTraderId}` : null, - () => api.getAccount(selectedTraderId), + () => api.getAccount(selectedTraderId, true), { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 + refreshInterval: accountPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setAccountPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (accountPollOff) setAccountPollOff(false) }, } ) @@ -250,11 +269,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `positions-${selectedTraderId}` : null, - () => api.getPositions(selectedTraderId), + () => api.getPositions(selectedTraderId, true), { - refreshInterval: 15000, // 15秒刷新(配合后端15秒缓存) - revalidateOnFocus: false, // 禁用聚焦时重新验证,减少请求 - dedupingInterval: 10000, // 10秒去重,防止短时间内重复请求 + refreshInterval: positionsPollOff ? 0 : 15000, + revalidateOnFocus: false, + dedupingInterval: 10000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setPositionsPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (positionsPollOff) setPositionsPollOff(false) }, } ) @@ -262,11 +286,16 @@ function App() { currentPage === 'trader' && selectedTraderId ? `decisions/latest-${selectedTraderId}-${decisionsLimit}` : null, - () => api.getLatestDecisions(selectedTraderId, decisionsLimit), + () => api.getLatestDecisions(selectedTraderId, decisionsLimit, true), { - refreshInterval: 30000, // 30秒刷新(决策更新频率较低) + refreshInterval: decisionsPollOff ? 0 : 30000, revalidateOnFocus: false, dedupingInterval: 20000, + onErrorRetry: (_err, _key, _config, revalidate, { retryCount }) => { + if (retryCount >= 2) { setDecisionsPollOff(true); return } + setTimeout(() => revalidate({ retryCount }), 500) + }, + onSuccess: () => { if (decisionsPollOff) setDecisionsPollOff(false) }, } ) @@ -291,6 +320,10 @@ function App() { const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId) + const effectiveAccount = account + const effectivePositions = positions + const effectiveDecisions = decisions + // Handle routing useEffect(() => { const handlePopState = () => { @@ -302,7 +335,9 @@ function App() { // Set current page based on route for consistent navigation state useEffect(() => { - if (route === '/competition') { + if (route === '/welcome') { + setCurrentPage('traders') + } else if (route === '/competition') { setCurrentPage('competition') } else if (route === '/traders') { setCurrentPage('traders') @@ -311,6 +346,9 @@ function App() { } }, [route]) + const showBeginnerOnboarding = + route === '/welcome' && (!!user || hasPersistedAuth) && getUserMode() === 'beginner' && !hasCompletedBeginnerOnboarding() + // Show loading spinner while checking auth or config if (isLoading || configLoading) { return ( @@ -347,6 +385,16 @@ function App() { } return } + if (route === '/welcome') { + if ((!user || !token) && !hasPersistedAuth) { + window.location.href = '/login' + return null + } + if (getUserMode() !== 'beginner') { + window.location.href = '/traders' + return null + } + } if (route === '/faq') { return (
} if (route === '/settings') { - if (!user || !token) { + if ((!user || !token) && !hasPersistedAuth) { window.location.href = '/login' return null } @@ -398,19 +446,7 @@ function App() { // Data page - publicly accessible with embedded dashboard if (route === '/data') { const dataPageNavigate = (page: Page) => { - const pathMap: Record = { - 'data': '/data', - 'competition': '/competition', - 'strategy-market': '/strategy-market', - 'traders': '/traders', - 'trader': '/dashboard', - 'strategy': '/strategy', - 'faq': '/faq', - } - const path = pathMap[page] - if (path) { - window.location.href = path - } + navigateToPage(page) } return (
setLoginOverlayOpen(false)} featureName={loginOverlayFeature} /> + + {showBeginnerOnboarding && }
) } diff --git a/web/src/components/auth/LoginPage.tsx b/web/src/components/auth/LoginPage.tsx index d7fe38453b..68d5c24051 100644 --- a/web/src/components/auth/LoginPage.tsx +++ b/web/src/components/auth/LoginPage.tsx @@ -5,6 +5,9 @@ import { useAuth } from '../../contexts/AuthContext' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' import { DeepVoidBackground } from '../common/DeepVoidBackground' +import { LanguageSwitcher } from '../common/LanguageSwitcher' +import { OnboardingModeSelector } from './OnboardingModeSelector' +import type { UserMode } from '../../lib/onboarding' export function LoginPage() { const { language } = useLanguage() @@ -15,7 +18,16 @@ export function LoginPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [expiredToastId, setExpiredToastId] = useState(null) + const [mode, setMode] = useState('beginner') + // Clean up stale auth state once on mount + useEffect(() => { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + localStorage.removeItem('user_id') + }, []) + + // Show session-expired toast (re-runs on language change to update text) useEffect(() => { if (sessionStorage.getItem('from401') === 'true') { const id = toast.warning(t('sessionExpired', language), { duration: Infinity }) @@ -28,7 +40,7 @@ export function LoginPage() { e.preventDefault() setError('') setLoading(true) - const result = await login(email, password) + const result = await login(email, password, mode) setLoading(false) if (result.success) { if (expiredToastId) toast.dismiss(expiredToastId) @@ -41,6 +53,8 @@ export function LoginPage() { return ( + +
@@ -109,6 +123,12 @@ export function LoginPage() {
+ + {/* Error */} {error && (

diff --git a/web/src/components/auth/OnboardingModeSelector.tsx b/web/src/components/auth/OnboardingModeSelector.tsx new file mode 100644 index 0000000000..5d403a3ae2 --- /dev/null +++ b/web/src/components/auth/OnboardingModeSelector.tsx @@ -0,0 +1,75 @@ +import type { UserMode } from '../../lib/onboarding' + +interface OnboardingModeSelectorProps { + language: string + mode: UserMode + onChange: (mode: UserMode) => void +} + +export function OnboardingModeSelector({ + language, + mode, + onChange, +}: OnboardingModeSelectorProps) { + const isZh = language === 'zh' + + const options: Array<{ + id: UserMode + title: string + badge?: string + description: string + }> = [ + { + id: 'beginner', + title: isZh ? '新手模式' : 'Beginner Mode', + badge: isZh ? '推荐' : 'Recommended', + description: isZh + ? '自动生成 Base 钱包,默认接入 Claw402 + DeepSeek,最快完成首次启动。' + : 'Generate a Base wallet automatically and start with Claw402 + DeepSeek by default.', + }, + { + id: 'advanced', + title: isZh ? '老手模式' : 'Advanced Mode', + description: isZh + ? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。' + : 'Keep the full manual flow and configure models, wallets, and exchanges yourself.', + }, + ] + + return ( +

+
+ {isZh ? '使用模式' : 'Experience'} +
+
+ {options.map((option) => { + const selected = option.id === mode + return ( + + ) + })} +
+
+ ) +} diff --git a/web/src/components/charts/ChartTabs.tsx b/web/src/components/charts/ChartTabs.tsx index 6692398564..d77e8c13a0 100644 --- a/web/src/components/charts/ChartTabs.tsx +++ b/web/src/components/charts/ChartTabs.tsx @@ -123,7 +123,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C // Auto-switch to kline chart when symbol selected externally useEffect(() => { if (selectedSymbol) { - console.log('[ChartTabs] Symbol selected:', selectedSymbol, 'updateKey:', updateKey) setChartSymbol(selectedSymbol) setActiveTab('kline') } @@ -143,8 +142,6 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C } } - console.log('[ChartTabs] rendering, activeTab:', activeTab) - return (
diff --git a/web/src/components/common/DeepVoidBackground.tsx b/web/src/components/common/DeepVoidBackground.tsx index 6ab2357a8f..bb29f06e67 100644 --- a/web/src/components/common/DeepVoidBackground.tsx +++ b/web/src/components/common/DeepVoidBackground.tsx @@ -9,27 +9,35 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes { export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) { return (
- {/* BACKGROUND LAYERS */} + {/* Background layers: use a much lighter static stack when animations are disabled */} + {disableAnimation ? ( + <> +
+
+ + ) : ( + <> + {/* 1. Grain/Noise Texture */} +
- {/* 1. Grain/Noise Texture */} -
+ {/* 2. Grid System */} +
+
+
+
- {/* 2. Grid System */} -
-
-
-
- - {/* 3. Ambient Glow Spots */} -
-
-
-
+ {/* 3. Ambient Glow Spots */} +
+
+
+
- {/* 4. CRT/Scanline Overlay */} -
-
-
+ {/* 4. CRT/Scanline Overlay */} +
+
+
+ + )} {/* Content Layer */}
diff --git a/web/src/components/common/HeaderBar.tsx b/web/src/components/common/HeaderBar.tsx index 1b580b5303..3922392be5 100644 --- a/web/src/components/common/HeaderBar.tsx +++ b/web/src/components/common/HeaderBar.tsx @@ -4,6 +4,12 @@ import { motion, AnimatePresence } from 'framer-motion' import { Menu, X, ChevronDown, Settings } from 'lucide-react' import { t, type Language } from '../../i18n/translations' import { OFFICIAL_LINKS } from '../../constants/branding' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../../lib/onboarding' type Page = | 'competition' @@ -44,8 +50,21 @@ export default function HeaderBar({ const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false) const [userDropdownOpen, setUserDropdownOpen] = useState(false) + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') const dropdownRef = useRef(null) const userDropdownRef = useRef(null) + + const navigateInApp = (path: string) => { + navigate(path) + window.dispatchEvent(new PopStateEvent('popstate')) + } + + const handleSwitchMode = (nextMode: UserMode) => { + setUserMode(nextMode) + setUserModeState(nextMode) + setUserDropdownOpen(false) + navigateInApp(getPostAuthPath(nextMode)) + } // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -216,6 +235,15 @@ export default function HeaderBar({ Settings + {onLogout && ( + ))} +
+ ) +} diff --git a/web/src/components/modals/SetupPage.tsx b/web/src/components/modals/SetupPage.tsx index 6243e4a409..724974a287 100644 --- a/web/src/components/modals/SetupPage.tsx +++ b/web/src/components/modals/SetupPage.tsx @@ -1,65 +1,163 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { Eye, EyeOff } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' -import { DeepVoidBackground } from '../common/DeepVoidBackground' import { invalidateSystemConfig } from '../../lib/config' +import { OnboardingModeSelector } from '../auth/OnboardingModeSelector' +import type { UserMode } from '../../lib/onboarding' +import { useLanguage } from '../../contexts/LanguageContext' +import { LanguageSwitcher } from '../common/LanguageSwitcher' + +const labels = { + zh: { + welcome: '欢迎使用 NOFX', + subtitle: '创建账号开始使用', + email: '邮箱', + emailPlaceholder: 'you@example.com', + password: '密码', + passwordPlaceholder: '至少 8 个字符', + passwordError: '密码至少需要 8 个字符', + submit: '开始使用', + submitting: '创建中...', + setupFailed: '创建失败,请重试', + singleUser: '单用户系统 — 这是唯一的账号', + }, + en: { + welcome: 'Welcome to NOFX', + subtitle: 'Create your account to get started', + email: 'Email', + emailPlaceholder: 'you@example.com', + password: 'Password', + passwordPlaceholder: 'At least 8 characters', + passwordError: 'Password must be at least 8 characters', + submit: 'Get Started', + submitting: 'Creating account...', + setupFailed: 'Setup failed, please try again', + singleUser: 'Single-user system — this is the only account', + }, + id: { + welcome: 'Selamat Datang di NOFX', + subtitle: 'Buat akun untuk memulai', + email: 'Email', + emailPlaceholder: 'you@example.com', + password: 'Kata Sandi', + passwordPlaceholder: 'Minimal 8 karakter', + passwordError: 'Kata sandi minimal 8 karakter', + submit: 'Mulai', + submitting: 'Membuat akun...', + setupFailed: 'Gagal membuat akun, coba lagi', + singleUser: 'Sistem pengguna tunggal — ini satu-satunya akun', + }, +} as const export function SetupPage() { + const { language } = useLanguage() const { register } = useAuth() const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [mode, setMode] = useState('beginner') + + // Clean up any stale auth/onboarding state on setup page load + useEffect(() => { + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + localStorage.removeItem('user_id') + localStorage.removeItem('nofx_beginner_onboarding_completed') + localStorage.removeItem('nofx_beginner_wallet_address') + }, []) + + const l = labels[language as keyof typeof labels] || labels.en const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') if (password.length < 8) { - setError('Password must be at least 8 characters') + setError(l.passwordError) return } setLoading(true) - const result = await register(email, password) + const result = await register(email, password, undefined, mode) setLoading(false) if (result.success) { invalidateSystemConfig() - window.location.href = '/traders' } else { - setError(result.message || 'Setup failed, please try again') + setError(result.message || l.setupFailed) } } return ( - -
-
+
+ {/* Decorative background - simulates the main app behind a modal */} + + {/* Grid */} +
+
+
+ + {/* Glow spots */} +
+
+
+
+
+ + {/* Faux UI elements in background to simulate the app */} +
+ {/* Fake header bar */} +
+
+
+
+
+
+
+ {/* Fake content cards */} +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+
+
+
+ + {/* Blur overlay */} +
+ + + + {/* Modal card */} +
+
{/* Logo + Title */} -
-
+
+
-
- NOFX +
+ NOFX
-

Welcome to NOFX

-

Create your account to get started

+

{l.welcome}

+

{l.subtitle}

{/* Card */} -
+
{/* Email */}
- + setEmail(e.target.value)} - className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" - placeholder="you@example.com" + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder={l.emailPlaceholder} required autoFocus /> @@ -67,14 +165,14 @@ export function SetupPage() { {/* Password */}
- +
setPassword(e.target.value)} - className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" - placeholder="At least 8 characters" + className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all" + placeholder={l.passwordPlaceholder} required />
+ + {/* Error */} {error && (

@@ -98,18 +202,25 @@ export function SetupPage() {

- Single-user system — this is the only account + {l.singleUser}

- + + +
) } diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 86751c899c..20ba34653c 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react' import type { CoinSourceConfig } from '../../types' import { coinSource, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface CoinSourceEditorProps { config: CoinSourceConfig @@ -24,7 +25,6 @@ export function CoinSourceEditor({ { value: 'ai500', icon: Database, color: '#F0B90B' }, { value: 'oi_top', icon: TrendingUp, color: '#0ECB81' }, { value: 'oi_low', icon: TrendingDown, color: '#F6465D' }, - { value: 'mixed', icon: Shuffle, color: '#60a5fa' }, ] as const // Calculate mixed mode summary @@ -33,16 +33,16 @@ export function CoinSourceEditor({ let totalLimit = 0 if (config.use_ai500) { - sources.push(`AI500(${config.ai500_limit || 10})`) - totalLimit += config.ai500_limit || 10 + sources.push(`AI500(${config.ai500_limit || 3})`) + totalLimit += config.ai500_limit || 3 } if (config.use_oi_top) { - sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 10})`) - totalLimit += config.oi_top_limit || 10 + sources.push(`${ts(coinSource.oiIncreaseShort, language)}(${config.oi_top_limit || 3})`) + totalLimit += config.oi_top_limit || 3 } if (config.use_oi_low) { - sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 10})`) - totalLimit += config.oi_low_limit || 10 + sources.push(`${ts(coinSource.oiDecreaseShort, language)}(${config.oi_low_limit || 3})`) + totalLimit += config.oi_low_limit || 3 } if ((config.static_coins || []).length > 0) { sources.push(`${ts(coinSource.custom, language)}(${config.static_coins?.length || 0})`) @@ -71,8 +71,26 @@ export function CoinSourceEditor({ return xyzDexAssets.has(base) } + const MAX_STATIC_COINS = 10 + + const showToast = (msg: string) => { + const toast = document.createElement('div') + toast.textContent = msg + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + } + const handleAddCoin = () => { if (!newCoin.trim()) return + + const currentCoins = config.static_coins || [] + if (currentCoins.length >= MAX_STATIC_COINS) { + showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`) + return + } + const symbol = newCoin.toUpperCase().trim() // For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT @@ -85,7 +103,6 @@ export function CoinSourceEditor({ formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` } - const currentCoins = config.static_coins || [] if (!currentCoins.includes(formattedSymbol)) { onChange({ ...config, @@ -148,7 +165,7 @@ export function CoinSourceEditor({ -
+
{sourceTypes.map(({ value, icon: Icon, color }) => (
)} @@ -366,19 +380,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiTopLimit, language)}: - + />
)} @@ -423,19 +434,16 @@ export function CoinSourceEditor({ {ts(coinSource.oiLowLimit, language)}: - + />
)} @@ -483,20 +491,13 @@ export function CoinSourceEditor({ {config.use_ai500 && (
Limit: - + />
)}
@@ -530,20 +531,13 @@ export function CoinSourceEditor({ {config.use_oi_top && (
Limit: - + />
)}
@@ -577,20 +571,13 @@ export function CoinSourceEditor({ {config.use_oi_low && (
Limit: - + />
)}
diff --git a/web/src/components/strategy/GridConfigEditor.tsx b/web/src/components/strategy/GridConfigEditor.tsx index 4566501ea3..0219a0316e 100644 --- a/web/src/components/strategy/GridConfigEditor.tsx +++ b/web/src/components/strategy/GridConfigEditor.tsx @@ -1,6 +1,7 @@ import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react' import type { GridStrategyConfig } from '../../types' import { gridConfig, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' interface GridConfigEditorProps { config: GridStrategyConfig @@ -74,20 +75,21 @@ export function GridConfigEditor({

{ts(gridConfig.symbolDesc, language)}

- + options={[ + { value: 'BTCUSDT', label: 'BTC/USDT' }, + { value: 'ETHUSDT', label: 'ETH/USDT' }, + { value: 'SOLUSDT', label: 'SOL/USDT' }, + { value: 'BNBUSDT', label: 'BNB/USDT' }, + { value: 'XRPUSDT', label: 'XRP/USDT' }, + { value: 'DOGEUSDT', label: 'DOGE/USDT' }, + ]} + />
{/* Investment */} @@ -170,17 +172,18 @@ export function GridConfigEditor({

{ts(gridConfig.distributionDesc, language)}

- + options={[ + { value: 'uniform', label: ts(gridConfig.uniform, language) }, + { value: 'gaussian', label: ts(gridConfig.gaussian, language) }, + { value: 'pyramid', label: ts(gridConfig.pyramid, language) }, + ]} + />
diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index 2a0667c5d1..e54c1b5662 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,6 +1,7 @@ import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react' import type { IndicatorConfig } from '../../types' import { indicator, ts } from '../../i18n/strategy-translations' +import { NofxSelect } from '../ui/select' // Default NofxOS API Key const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c' @@ -60,6 +61,16 @@ export function IndicatorEditor({ }) } } else { + if (current.length >= 4) { + // Show toast notification + const toast = document.createElement('div') + toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed' + toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' + toast.style.cssText = 'background:#F6465D;color:#fff;' + document.body.appendChild(toast) + setTimeout(() => toast.remove(), 2000) + return + } current.push(tf) onChange({ ...config, @@ -299,26 +310,22 @@ export function IndicatorEditor({

{ts(indicator.oiRankingDesc, language)}

{config.enable_oi_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)}
@@ -359,26 +366,22 @@ export function IndicatorEditor({

{ts(indicator.netflowRankingDesc, language)}

{config.enable_netflow_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)}
@@ -419,27 +422,27 @@ export function IndicatorEditor({

{ts(indicator.priceRankingDesc, language)}

{config.enable_price_ranking && (
e.stopPropagation()}> - - + options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} + />
)}
@@ -515,7 +518,7 @@ export function IndicatorEditor({ } disabled={disabled} min={10} - max={200} + max={30} className="w-16 px-2 py-1 rounded text-xs text-center" style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} /> diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 8df913a14a..152c977388 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -54,7 +54,7 @@ export function RiskControlEditor({ } disabled={disabled} min={1} - max={10} + max={3} className="w-32 px-3 py-2 rounded" style={{ background: '#1E2329', diff --git a/web/src/components/strategy/TokenEstimateBar.tsx b/web/src/components/strategy/TokenEstimateBar.tsx new file mode 100644 index 0000000000..ec4a5707db --- /dev/null +++ b/web/src/components/strategy/TokenEstimateBar.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect, useRef } from 'react' +import { Loader2, Info } from 'lucide-react' +import type { StrategyConfig } from '../../types' +import { t, type Language } from '../../i18n/translations' + +const API_BASE = import.meta.env.VITE_API_BASE || '' + +interface ModelLimit { + name: string + context_limit: number + usage_pct: number + level: string +} + +interface TokenEstimateResult { + total: number + model_limits: ModelLimit[] + suggestions: string[] +} + +interface TokenEstimateBarProps { + config: StrategyConfig | null + language: Language + onTokenCountChange?: (total: number) => void +} + +export function TokenEstimateBar({ config, language, onTokenCountChange }: TokenEstimateBarProps) { + const [estimate, setEstimate] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const debounceRef = useRef | null>(null) + + const tr = (key: string) => t(`strategyStudio.${key}`, language) + + useEffect(() => { + if (!config) { + setEstimate(null) + return + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + debounceRef.current = setTimeout(async () => { + setIsLoading(true) + try { + const response = await fetch(`${API_BASE}/api/strategies/estimate-tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }) + if (response.ok) { + const data = await response.json() + setEstimate(data) + onTokenCountChange?.(data.total) + } + } catch { + // silently ignore — non-critical UI element + } finally { + setIsLoading(false) + } + }, 800) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [config]) + + if (!config) return null + + if (isLoading && !estimate) { + return ( +
+ + {tr('tokenEstimating')} +
+ ) + } + + if (!estimate) return null + + // Display based on 200K reference + const pct = Math.round(estimate.total * 100 / 200000) + const barWidth = Math.min(pct, 100) + + let barColor = '#0ECB81' // green + let textColor = '#848E9C' + if (pct >= 100) { + barColor = '#F6465D' // red + textColor = '#F6465D' + } else if (pct >= 80) { + barColor = '#F0B90B' // yellow + textColor = '#F0B90B' + } + + return ( +
+
+
+
+
+ + {isLoading ? : `${pct}%`} + +
+ +
+ {tr('tokenTooltip')} (~{estimate.total.toLocaleString()} / 200K) +
+
+
+
+ ) +} diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 37d630f37b..de26058cfd 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -18,6 +18,7 @@ import { TelegramConfigModal } from './TelegramConfigModal' import { ModelConfigModal } from './ModelConfigModal' import { ConfigStatusGrid } from './ConfigStatusGrid' import { TradersList } from './TradersList' +import { BeginnerGuideCards } from './BeginnerGuideCards' import { Bot, Plus, @@ -25,6 +26,12 @@ import { } from 'lucide-react' import { confirmToast } from '../../lib/notify' import { toast } from 'sonner' +import { + getBeginnerWalletAddress, + getUserMode, + setBeginnerWalletAddress as persistBeginnerWalletAddress, +} from '../../lib/onboarding' +import type { Strategy } from '../../types' interface AITradersPageProps { onTraderSelect?: (traderId: string) => void @@ -48,6 +55,14 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [visibleTraderAddresses, setVisibleTraderAddresses] = useState>(new Set()) const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState>(new Set()) const [copiedId, setCopiedId] = useState(null) + const [quickSetupLoading, setQuickSetupLoading] = useState(false) + const [beginnerWalletAddress, setBeginnerWalletAddress] = useState(() => getBeginnerWalletAddress()) + const isBeginnerMode = getUserMode() === 'beginner' + + const navigateInApp = (path: string) => { + navigate(path) + window.dispatchEvent(new PopStateEvent('popstate')) + } // Toggle wallet address visibility for a trader const toggleTraderAddressVisibility = (traderId: string) => { @@ -91,6 +106,11 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { api.getTraders, { refreshInterval: 5000 } ) + const { data: strategies } = useSWR( + user && token ? 'strategies' : null, + api.getStrategies, + { refreshInterval: 30000 } + ) useEffect(() => { const loadConfigs = async () => { @@ -115,6 +135,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { api.getSupportedModels(), ]) setAllModels(modelConfigs) + const clawWalletAddress = + modelConfigs.find((model) => model.provider === 'claw402')?.walletAddress || null + if (clawWalletAddress) { + setBeginnerWalletAddress(clawWalletAddress) + persistBeginnerWalletAddress(clawWalletAddress) + } setAllExchanges(exchangeConfigs) setSupportedModels(models) } catch (error) { @@ -233,7 +259,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const handleSaveEditTrader = async (data: CreateTraderRequest) => { - console.log('🔥🔥🔥 handleSaveEditTrader CALLED with data:', data) if (!editingTrader) return try { @@ -261,10 +286,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { show_in_competition: data.show_in_competition, } - console.log('🔥 handleSaveEditTrader - data:', data) - console.log('🔥 handleSaveEditTrader - data.strategy_id:', data.strategy_id) - console.log('🔥 handleSaveEditTrader - request:', request) - await api.updateTrader(editingTrader.trader_id, request) toast.success(t('aiTradersToast.saved', language)) setShowEditModal(false) @@ -616,6 +637,36 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { setShowExchangeModal(true) } + const handleQuickSetupClaw402 = async () => { + if (quickSetupLoading) return + + try { + setQuickSetupLoading(true) + const result = await api.prepareBeginnerOnboarding() + setBeginnerWalletAddress(result.address) + const refreshedModels = await api.getModelConfigs() + setAllModels(refreshedModels) + toast.success( + language === 'zh' + ? 'Claw402 已默认配置为 DeepSeek' + : 'Claw402 is configured with DeepSeek by default' + ) + } catch (error) { + console.error('Failed to quick setup claw402:', error) + toast.error( + language === 'zh' + ? '一键配置 Claw402 失败' + : 'Failed to quick setup Claw402' + ) + } finally { + setQuickSetupLoading(false) + } + } + + const claw402Configured = configuredModels.some((model) => model.provider === 'claw402') + const hasStrategies = (strategies?.length || 0) > 0 + const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0 + return (
@@ -687,6 +738,21 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
+ {isBeginnerMode ? ( + 0} + strategyReady={hasStrategies} + canCreateTrader={canCreateTrader} + walletAddress={beginnerWalletAddress} + onQuickSetupClaw402={handleQuickSetupClaw402} + onOpenExchange={handleAddExchange} + onOpenStrategy={() => navigateInApp('/strategy')} + onCreateTrader={() => setShowCreateModal(true)} + /> + ) : null} + {/* Configuration Status Grid */} navigate(path)} + onNavigate={navigateInApp} onEditTrader={handleEditTrader} onToggleTrader={handleToggleTrader} onToggleCompetition={handleToggleCompetition} diff --git a/web/src/components/trader/BeginnerGuideCards.tsx b/web/src/components/trader/BeginnerGuideCards.tsx new file mode 100644 index 0000000000..26f00f6df1 --- /dev/null +++ b/web/src/components/trader/BeginnerGuideCards.tsx @@ -0,0 +1,199 @@ +import { Brain, Landmark, Rocket, Sparkles } from 'lucide-react' + +interface BeginnerGuideCardsProps { + language: string + claw402Ready: boolean + exchangeReady: boolean + strategyReady: boolean + canCreateTrader: boolean + walletAddress?: string | null + onQuickSetupClaw402: () => void + onOpenExchange: () => void + onOpenStrategy: () => void + onCreateTrader: () => void +} + +function truncateAddress(address: string) { + if (address.length <= 12) return address + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +export function BeginnerGuideCards({ + language, + claw402Ready, + exchangeReady, + strategyReady, + canCreateTrader, + walletAddress, + onQuickSetupClaw402, + onOpenExchange, + onOpenStrategy, + onCreateTrader, +}: BeginnerGuideCardsProps) { + const isZh = language === 'zh' + + const cards = [ + { + key: 'model', + icon: Brain, + title: isZh ? '1. 极速模型' : '1. Fast AI', + desc: isZh + ? '默认就是 Claw402 + DeepSeek。第一次不用挑模型,先跑起来。' + : 'Start with Claw402 + DeepSeek. No model picking needed for the first run.', + meta: walletAddress + ? isZh + ? `钱包 ${truncateAddress(walletAddress)}` + : `Wallet ${truncateAddress(walletAddress)}` + : isZh + ? 'Base 链 USDC 按次付费' + : 'Pay per call with Base USDC', + ready: claw402Ready, + actionLabel: claw402Ready + ? isZh + ? '已配置' + : 'Configured' + : isZh + ? '一键配置' + : 'One-click setup', + onAction: onQuickSetupClaw402, + disabled: claw402Ready, + }, + { + key: 'exchange', + icon: Landmark, + title: isZh ? '2. 连接交易所' : '2. Add Exchange', + desc: isZh + ? '交易所接好以后,AI 才能真正下单。' + : 'Connect an exchange so the AI can actually place trades.', + meta: exchangeReady + ? isZh + ? '已准备好' + : 'Ready' + : isZh + ? 'Binance / OKX / Bybit / Hyperliquid' + : 'Binance / OKX / Bybit / Hyperliquid', + ready: exchangeReady, + actionLabel: exchangeReady + ? isZh + ? '继续管理' + : 'Manage' + : isZh + ? '去配置' + : 'Configure', + onAction: onOpenExchange, + disabled: false, + }, + { + key: 'strategy', + icon: Sparkles, + title: isZh ? '3. 选择策略' : '3. Pick Strategy', + desc: isZh + ? '先用默认策略也可以,后面再慢慢细调。' + : 'You can start with a default strategy and fine-tune later.', + meta: strategyReady + ? isZh + ? '已有策略可用' + : 'Strategy ready' + : isZh + ? '可选,但建议提前看一眼' + : 'Optional, but worth a quick look', + ready: strategyReady, + actionLabel: isZh ? '打开策略页' : 'Open strategy', + onAction: onOpenStrategy, + disabled: false, + }, + { + key: 'trader', + icon: Rocket, + title: isZh ? '4. 创建 Trader' : '4. Create Trader', + desc: isZh + ? '最后一步,把模型和交易所绑在一起,就能开始运行。' + : 'Last step: bind your model and exchange, then start running.', + meta: canCreateTrader + ? isZh + ? '已经可以创建' + : 'Ready to create' + : isZh + ? '先完成前两步' + : 'Finish the first two steps first', + ready: canCreateTrader, + actionLabel: isZh ? '立即创建' : 'Create now', + onAction: onCreateTrader, + disabled: !canCreateTrader, + }, + ] + + return ( +
+
+
+
+ {isZh ? '新手引导' : 'Quickstart'} +
+

+ {isZh + ? '先按这 4 步走,最快上手' + : 'Follow these 4 steps to get started fast'} +

+
+ {/*
+ {isZh ? '老手模式不会看到这块' : 'Hidden in advanced mode'} +
*/} +
+ +
+ {cards.map((card) => { + const Icon = card.icon + return ( +
+
+
+ +
+ + {card.ready + ? isZh + ? '已就绪' + : 'Ready' + : isZh + ? '待完成' + : 'Pending'} + +
+ +

+ {card.title} +

+

+ {card.desc} +

+
{card.meta}
+ + +
+ ) + })} +
+
+ ) +} diff --git a/web/src/components/trader/CompetitionPage.tsx b/web/src/components/trader/CompetitionPage.tsx index 71395eb01a..83b6d6c6a5 100644 --- a/web/src/components/trader/CompetitionPage.tsx +++ b/web/src/components/trader/CompetitionPage.tsx @@ -281,14 +281,14 @@ export function CompetitionPage() {
{/* Stats */} -
+
{/* Total Equity */} -
-
+
+
{t('equity', language)}
{trader.total_equity?.toFixed(2) || '0.00'} @@ -297,11 +297,11 @@ export function CompetitionPage() { {/* P&L */}
-
+
{t('pnl', language)}
= 0 @@ -313,7 +313,7 @@ export function CompetitionPage() { {trader.total_pnl_pct?.toFixed(2) || '0.00'}%
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''} @@ -322,17 +322,17 @@ export function CompetitionPage() {
{/* Positions */} -
-
+
+
{t('pos', language)}
{trader.position_count}
-
+
{trader.margin_used_pct.toFixed(1)}%
diff --git a/web/src/components/trader/ConfigStatusGrid.tsx b/web/src/components/trader/ConfigStatusGrid.tsx index fb6c31977e..75dc3d31b9 100644 --- a/web/src/components/trader/ConfigStatusGrid.tsx +++ b/web/src/components/trader/ConfigStatusGrid.tsx @@ -92,6 +92,20 @@ export function ConfigStatusGrid({
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
+ {model.provider === 'claw402' && (model.balanceUsdc || model.walletAddress) ? ( +
+ {model.balanceUsdc ? ( + + {model.balanceUsdc} USDC + + ) : null} + {model.walletAddress ? ( + + {truncateAddress(model.walletAddress)} + + ) : null} +
+ ) : null}
diff --git a/web/src/components/trader/ModelConfigModal.tsx b/web/src/components/trader/ModelConfigModal.tsx index 7b8780a937..51fa370a35 100644 --- a/web/src/components/trader/ModelConfigModal.tsx +++ b/web/src/components/trader/ModelConfigModal.tsx @@ -4,6 +4,7 @@ import { Trash2, Brain, ExternalLink } from 'lucide-react' import type { AIModel } from '../../types' import type { Language } from '../../i18n/translations' import { t } from '../../i18n/translations' +import { api } from '../../lib/api' import { getModelIcon } from '../common/ModelIcons' import { ModelStepIndicator } from './ModelStepIndicator' import { ModelCard } from './ModelCard' @@ -12,6 +13,7 @@ import { AI_PROVIDER_CONFIG, getShortName, } from './model-constants' +import { getBeginnerWalletAddress, getUserMode } from '../../lib/onboarding' interface ModelConfigModalProps { allModels: AIModel[] @@ -42,20 +44,22 @@ export function ModelConfigModal({ const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [modelName, setModelName] = useState('') + const configuredModel = + configuredModels?.find((model) => model.id === selectedModelId) || null // Always prefer allModels (supportedModels) for provider/id lookup; // fall back to configuredModels for edit mode details (apiKey etc.) const selectedModel = - allModels?.find((m) => m.id === selectedModelId) || - configuredModels?.find((m) => m.id === selectedModelId) + allModels?.find((m) => m.id === selectedModelId) || configuredModel useEffect(() => { - if (editingModelId && selectedModel) { - setApiKey(selectedModel.apiKey || '') - setBaseUrl(selectedModel.customApiUrl || '') - setModelName(selectedModel.customModelName || '') + const modelDetails = configuredModel || selectedModel + if (editingModelId && modelDetails) { + setApiKey(modelDetails.apiKey || '') + setBaseUrl(modelDetails.customApiUrl || '') + setModelName(modelDetails.customModelName || '') } - }, [editingModelId, selectedModel]) + }, [editingModelId, configuredModel, selectedModel]) const handleSelectModel = (modelId: string) => { setSelectedModelId(modelId) @@ -79,7 +83,19 @@ export function ModelConfigModal({ const availableModels = allModels || [] const configuredIds = new Set(configuredModels?.map(m => m.id) || []) - const stepLabels = [t('modelConfig.selectModel', language), t('modelConfig.configureApi', language)] + const isClaw402Selected = selectedModel?.provider === 'claw402' || selectedModel?.id === 'claw402' + const isBeginnerDefaultModel = isClaw402Selected && getUserMode() === 'beginner' + const stepLabels = [ + t('modelConfig.selectModel', language), + t( + !selectedModel + ? 'modelConfig.configure' + : isClaw402Selected + ? 'modelConfig.configureWallet' + : 'modelConfig.configure', + language + ), + ] return (
@@ -102,7 +118,7 @@ export function ModelConfigModal({
- {editingModelId && ( + {editingModelId && !isBeginnerDefaultModel && (
- {configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && ( + {configuredIds.has(claw402Model.id) && (
)}
@@ -235,23 +255,57 @@ function ModelSelectionStep({ GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
+
+ {t('modelConfig.claw402EntryDesc', language)} +
)} -
- {availableModels.filter(m => m.provider !== 'claw402').map((model) => ( - onSelectModel(model.id)} - configured={configuredIds.has(model.id)} - /> - ))} -
-
- {t('modelConfig.modelsConfigured', language)} -
+ {otherProviders.length > 0 && ( +
+ + + {showOtherProviders && ( +
+
+ {otherProviders.map((model) => ( + onSelectModel(model.id)} + configured={configuredIds.has(model.id)} + /> + ))} +
+
+ {t('modelConfig.modelsConfigured', language)} +
+
+ )} +
+ )}
) } @@ -259,6 +313,7 @@ function ModelSelectionStep({ function Claw402ConfigForm({ apiKey, modelName, + configuredModel, editingModelId, onApiKeyChange, onModelNameChange, @@ -268,6 +323,7 @@ function Claw402ConfigForm({ }: { apiKey: string modelName: string + configuredModel: AIModel | null editingModelId: string | null onApiKeyChange: (value: string) => void onModelNameChange: (value: string) => void @@ -278,14 +334,21 @@ function Claw402ConfigForm({ const [walletAddress, setWalletAddress] = useState('') const [copiedAddr, setCopiedAddr] = useState(false) const [showDeposit, setShowDeposit] = useState(false) - const [showNewWalletBackup, setShowNewWalletBackup] = useState(false) - const [newWalletKey, setNewWalletKey] = useState('') const [usdcBalance, setUsdcBalance] = useState(null) const [keyError, setKeyError] = useState('') const [validating, setValidating] = useState(false) const [claw402Status, setClaw402Status] = useState(null) const [testResult, setTestResult] = useState<{ status: string; message: string } | null>(null) const [testing, setTesting] = useState(false) + const [serverWalletAddress, setServerWalletAddress] = useState('') + const [serverWalletBalance, setServerWalletBalance] = useState(null) + const localWalletAddress = getBeginnerWalletAddress()?.trim() || '' + const configuredWalletAddress = + configuredModel?.walletAddress?.trim() || localWalletAddress || serverWalletAddress + const resolvedWalletAddress = walletAddress || configuredWalletAddress + const resolvedUsdcBalance = + usdcBalance ?? configuredModel?.balanceUsdc ?? serverWalletBalance ?? null + const hasExistingWallet = Boolean(configuredWalletAddress) // Client-side validation helper const getClientError = (key: string): string => { @@ -298,8 +361,36 @@ function Claw402ConfigForm({ const isKeyValid = apiKey.length === 66 && apiKey.startsWith('0x') && /^0x[0-9a-fA-F]{64}$/.test(apiKey) - // Truncate address for display + useEffect(() => { + if (hasExistingWallet) { + setShowDeposit(true) + } + }, [hasExistingWallet]) + useEffect(() => { + if (configuredModel?.walletAddress || localWalletAddress || serverWalletAddress) { + return + } + + let cancelled = false + void api + .getCurrentBeginnerWallet() + .then((result) => { + setClaw402Status(result.claw402_status || 'unknown') + if (cancelled || !result.found || !result.address) { + return + } + setServerWalletAddress(result.address) + setServerWalletBalance(result.balance_usdc || null) + }) + .catch(() => { + // Ignore silently: this is a best-effort fallback for showing the current wallet. + }) + + return () => { + cancelled = true + } + }, [configuredModel?.walletAddress, localWalletAddress, serverWalletAddress]) // Debounced validation when apiKey changes useEffect(() => { @@ -347,6 +438,23 @@ function Claw402ConfigForm({ setTesting(true) setTestResult(null) try { + if (!apiKey && hasExistingWallet) { + const result = await api.getCurrentBeginnerWallet() + setClaw402Status(result.claw402_status || 'unknown') + if (result.found && result.address) { + setWalletAddress(result.address) + setUsdcBalance(result.balance_usdc || '0.00') + setShowDeposit(true) + } + setTestResult({ + status: result.claw402_status === 'ok' ? 'ok' : 'error', + message: result.claw402_status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language), + }) + return + } + const res = await fetch('/api/wallet/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -374,7 +482,7 @@ function Claw402ConfigForm({ } } - const balanceNum = usdcBalance ? parseFloat(usdcBalance) : 0 + const balanceNum = resolvedUsdcBalance ? parseFloat(resolvedUsdcBalance) : 0 return (
@@ -396,6 +504,25 @@ function Claw402ConfigForm({ ))}
+
+ + {claw402Status ? ( +
+ {claw402Status === 'ok' + ? t('modelConfig.claw402Connected', language) + : t('modelConfig.claw402Unreachable', language)} +
+ ) : null} +
{/* Step 1: Select AI Model */} @@ -467,6 +594,33 @@ function Claw402ConfigForm({
+ {hasExistingWallet && ( +
+
+ {language === 'zh' ? '已自动提取当前钱包' : 'Current wallet loaded automatically'} +
+
+ {language === 'zh' + ? '你现在可以直接查看当前钱包地址、余额和充值二维码。只有在想更换钱包时,才需要重新输入新的私钥。' + : 'You can view the current wallet address, balance, and deposit QR code right away. Only enter a new private key if you want to replace this wallet.'} +
+ {!configuredModel?.walletAddress && localWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自本地已保存的新手钱包。' + : 'This address comes from the locally saved beginner wallet.'} +
+ ) : null} + {!configuredModel?.walletAddress && !localWalletAddress && serverWalletAddress ? ( +
+ {language === 'zh' + ? '当前地址来自后端保存的钱包配置。' + : 'This address comes from the wallet saved on the server.'} +
+ ) : null} +
+ )} +
{t('modelConfig.walletPrivateKey', language)} @@ -476,72 +630,30 @@ function Claw402ConfigForm({ type="password" value={apiKey} onChange={(e) => onApiKeyChange(e.target.value)} - placeholder="0x..." + placeholder={ + hasExistingWallet + ? language === 'zh' + ? '如需切换钱包,请手动输入新的私钥' + : 'Enter a new private key only if you want to switch wallets' + : '0x...' + } className="flex-1 px-4 py-3 rounded-xl font-mono text-sm" style={{ background: '#0B0E11', border: keyError ? '1px solid #EF4444' : walletAddress ? '1px solid #00E096' : '1px solid #2B3139', color: '#EAECEF', }} - required + required={!hasExistingWallet} /> - {!apiKey && ( - - )}
- {/* New wallet backup warning */} - {showNewWalletBackup && newWalletKey && ( -
-
- 🚨 {language === 'zh' ? '重要:请立即备份私钥!' : 'Important: Backup your private key NOW!'} -
-
- {language === 'zh' - ? '这是你的钱包私钥,丢失后无法恢复,钱包里的资产将永久丢失。请复制并安全保存。' - : 'This is your wallet private key. If lost, it cannot be recovered and all assets will be permanently lost. Copy and save it securely.'} -
-
- - {newWalletKey} - - -
-
-
✅ {language === 'zh' ? '建议保存到密码管理器(1Password / Bitwarden)' : 'Save to a password manager (1Password / Bitwarden)'}
-
✅ {language === 'zh' ? '或抄在纸上放安全的地方' : 'Or write it down and store it safely'}
-
❌ {language === 'zh' ? '不要截图发给别人' : 'Do NOT screenshot or share with anyone'}
-
+ {hasExistingWallet && !apiKey ? ( +
+ {language === 'zh' + ? '后续这里只使用你第一次创建并保存的钱包;如果你要换钱包,请手动填写新的私钥。' + : 'This screen keeps using the wallet created and saved the first time. Enter a new private key manually only if you want to switch wallets.'}
- )} + ) : null}
🔒 @@ -552,7 +664,7 @@ function Claw402ConfigForm({
{/* Wallet Validation Results */} - {apiKey && ( + {(apiKey || hasExistingWallet) && (
{/* Validating spinner */} {validating && ( @@ -571,7 +683,7 @@ function Claw402ConfigForm({ )} {/* Success: address + balance + status */} - {walletAddress && !validating && !keyError && ( + {resolvedWalletAddress && !validating && !keyError && ( <>
@@ -581,7 +693,7 @@ function Claw402ConfigForm({
- {walletAddress} + {resolvedWalletAddress}
⚠️ {language === 'zh' ? '请确认这是你的钱包地址(可在 MetaMask 中核对)' : 'Please confirm this is your wallet address (verify in MetaMask)'}
- {usdcBalance !== null && ( + {resolvedUsdcBalance !== null && (
💰 0 ? '#00E096' : '#F59E0B' }}> - {t('modelConfig.usdcBalance', language)}: ${usdcBalance} + {t('modelConfig.usdcBalance', language)}: ${resolvedUsdcBalance}
)} + {!apiKey && hasExistingWallet && ( +
+ {language === 'zh' + ? '当前正在使用这个钱包充值。若要切换钱包,再输入新的私钥并保存即可。' + : 'This wallet is currently used for funding. Enter a new private key only if you want to switch wallets.'} +
+ )} {claw402Status && (
{claw402Status === 'ok' ? '🟢' : '🔴'} @@ -662,11 +781,11 @@ function Claw402ConfigForm({ )} {/* Test Connection button */} - {isKeyValid && !validating && ( + {(isKeyValid || hasExistingWallet) && !validating && (
@@ -708,28 +706,26 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {t('positionHistory.sort', language)}: - + />
@@ -841,20 +837,21 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { {language === 'zh' ? '每页' : 'Per page'}: - + />
{/* Page navigation */} diff --git a/web/src/components/trader/TelegramConfigModal.tsx b/web/src/components/trader/TelegramConfigModal.tsx index e06184ea6b..97a3fa056a 100644 --- a/web/src/components/trader/TelegramConfigModal.tsx +++ b/web/src/components/trader/TelegramConfigModal.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { api } from '../../lib/api' import type { TelegramConfig, AIModel } from '../../types' import { t, type Language } from '../../i18n/translations' +import { NofxSelect } from '../ui/select' // Step indicator (reused pattern from ExchangeConfigModal) function StepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) { @@ -133,23 +134,20 @@ export function TelegramConfigModal({ onClose, language }: TelegramConfigModalPr {t('telegram.noEnabledModels', language)}
) : ( - + /> )}
{t('telegram.autoUseEnabled', language)} @@ -489,23 +487,20 @@ function BoundModelSelector({ {t('telegram.aiModelLabel', language)}
- + />
- + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={availableExchanges.map((exchange) => ({ + value: exchange.id, + label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase() + + (exchange.account_name ? ` - ${exchange.account_name}` : ''), + }))} + /> {/* Exchange Registration Link */} {formData.exchange_id && (() => { // Find the selected exchange to get its type @@ -323,22 +320,20 @@ export function TraderConfigModal({ - + className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" + options={[ + { value: '', label: t('noStrategyManual', language) }, + ...strategies.map((strategy) => ({ + value: strategy.id, + label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''), + })), + ]} + /> {strategies.length === 0 && (

{t('noStrategyHint', language)} diff --git a/web/src/components/trader/model-constants.ts b/web/src/components/trader/model-constants.ts index cb741e2742..2b53be37f4 100644 --- a/web/src/components/trader/model-constants.ts +++ b/web/src/components/trader/model-constants.ts @@ -91,7 +91,7 @@ export const AI_PROVIDER_CONFIG: Record = { apiName: 'Moonshot', }, minimax: { - defaultModel: 'MiniMax-M2.5', + defaultModel: 'MiniMax-M2.7', apiUrl: 'https://platform.minimax.io', apiName: 'MiniMax', }, diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 0000000000..a7e4699cf8 --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,102 @@ +import { useRef, useState, useLayoutEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown } from 'lucide-react' +import { cn } from '../../lib/cn' + +export interface SelectOption { + value: string | number + label: string +} + +interface NofxSelectProps { + value: string | number + onChange: (value: string) => void + options: SelectOption[] + disabled?: boolean + className?: string + style?: React.CSSProperties +} + +export function NofxSelect({ value, onChange, options, disabled, className, style }: NofxSelectProps) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const dropdownRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + const selected = options.find(o => String(o.value) === String(value)) + + const updatePos = useCallback(() => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) + }, []) + + useLayoutEffect(() => { + if (!open) return + updatePos() + const handleClose = (e: MouseEvent) => { + const target = e.target as Node + if (triggerRef.current?.contains(target)) return + if (dropdownRef.current?.contains(target)) return + setOpen(false) + } + const handleScroll = (e: Event) => { + if (dropdownRef.current?.contains(e.target as Node)) return + setOpen(false) + } + document.addEventListener('mousedown', handleClose) + window.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('mousedown', handleClose) + window.removeEventListener('scroll', handleScroll, true) + } + }, [open, updatePos]) + + return ( +

+
{ + e.stopPropagation() + if (!disabled) setOpen(!open) + }} + > + {selected?.label ?? String(value)} + +
+ {open && createPortal( +
+ {options.map((opt) => ( +
{ + e.stopPropagation() + onChange(String(opt.value)) + setOpen(false) + }} + > + {opt.label} +
+ ))} +
, + document.body, + )} +
+ ) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index f7c609852d..af2bc2f306 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,6 +1,9 @@ import React, { createContext, useContext, useState, useEffect } from 'react' -import { getSystemConfig } from '../lib/config' +import { flushSync } from 'react-dom' +import { getSystemConfig, invalidateSystemConfig } from '../lib/config' import { reset401Flag, httpClient } from '../lib/httpClient' +import { getPostAuthPath, setUserMode, type UserMode } from '../lib/onboarding' +import { useLanguage } from './LanguageContext' interface User { id: string @@ -12,7 +15,8 @@ interface AuthContextType { token: string | null login: ( email: string, - password: string + password: string, + mode?: UserMode ) => Promise<{ success: boolean message?: string @@ -24,7 +28,8 @@ interface AuthContextType { register: ( email: string, password: string, - betaCode?: string + betaCode?: string, + mode?: UserMode ) => Promise<{ success: boolean; message?: string }> resetPassword: ( email: string, @@ -37,6 +42,7 @@ interface AuthContextType { const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: React.ReactNode }) { + const { language } = useLanguage() const [user, setUser] = useState(null) const [token, setToken] = useState(null) const [isLoading, setIsLoading] = useState(true) @@ -89,7 +95,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } }, []) - const login = async (email: string, password: string) => { + const handlePostAuthSuccess = ( + authToken: string, + userInfo: User, + mode?: UserMode + ) => { + reset401Flag() + + if (mode) { + setUserMode(mode) + } + + localStorage.setItem('auth_token', authToken) + localStorage.setItem('auth_user', JSON.stringify(userInfo)) + localStorage.setItem('user_id', userInfo.id) + flushSync(() => { + setToken(authToken) + setUser(userInfo) + }) + + const returnUrl = sessionStorage.getItem('returnUrl') + const nextPath = returnUrl || getPostAuthPath(mode) + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + } + + window.history.pushState({}, '', nextPath) + window.dispatchEvent(new PopStateEvent('popstate')) + } + + const login = async (email: string, password: string, mode?: UserMode) => { try { const response = await fetch('/api/login', { method: 'POST', @@ -103,26 +138,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (response.ok) { if (data.token) { - // Reset 401 flag on successful login - reset401Flag() - const userInfo = { id: data.user_id, email: data.email } - setToken(data.token) - setUser(userInfo) - localStorage.setItem('auth_token', data.token) - localStorage.setItem('auth_user', JSON.stringify(userInfo)) - - // Check and redirect to returnUrl if exists - const returnUrl = sessionStorage.getItem('returnUrl') - if (returnUrl) { - sessionStorage.removeItem('returnUrl') - window.history.pushState({}, '', returnUrl) - window.dispatchEvent(new PopStateEvent('popstate')) - } else { - // Redirect to config page - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) - } + handlePostAuthSuccess(data.token, userInfo, mode) return { success: true, message: data.message } } @@ -156,10 +173,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { id: data.user_id || 'admin', email: data.email || 'admin@localhost', } - setToken(data.token) - setUser(userInfo) localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) + flushSync(() => { + setToken(data.token) + setUser(userInfo) + }) // Check and redirect to returnUrl if exists const returnUrl = sessionStorage.getItem('returnUrl') @@ -184,13 +203,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const register = async ( email: string, password: string, - betaCode?: string + betaCode?: string, + mode?: UserMode ) => { const requestBody: { email: string password: string beta_code?: string - } = { email, password } + lang?: string + } = { email, password, lang: language } if (betaCode) { requestBody.beta_code = betaCode } @@ -204,26 +225,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }>('/api/register', requestBody) if (result.success && result.data) { - // Reset 401 flag on successful login - reset401Flag() + // Clear stale onboarding state so new users always see the welcome flow + localStorage.removeItem('nofx_beginner_onboarding_completed') + localStorage.removeItem('nofx_beginner_wallet_address') const userInfo = { id: result.data.user_id, email: result.data.email } - setToken(result.data.token) - setUser(userInfo) - localStorage.setItem('auth_token', result.data.token) - localStorage.setItem('auth_user', JSON.stringify(userInfo)) - - // Check and redirect to returnUrl if exists - const returnUrl = sessionStorage.getItem('returnUrl') - if (returnUrl) { - sessionStorage.removeItem('returnUrl') - window.history.pushState({}, '', returnUrl) - window.dispatchEvent(new PopStateEvent('popstate')) - } else { - // Redirect to config page - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) - } + handlePostAuthSuccess(result.data.token, userInfo, mode) return { success: true, @@ -287,6 +294,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setToken(null) localStorage.removeItem('auth_token') localStorage.removeItem('auth_user') + invalidateSystemConfig() + window.history.pushState({}, '', '/') + window.dispatchEvent(new PopStateEvent('popstate')) } return ( diff --git a/web/src/hooks/useSystemConfig.ts b/web/src/hooks/useSystemConfig.ts index 4c33620bce..237089b2e3 100644 --- a/web/src/hooks/useSystemConfig.ts +++ b/web/src/hooks/useSystemConfig.ts @@ -1,13 +1,15 @@ -import { useEffect, useState } from 'react' -import { getSystemConfig, type SystemConfig } from '../lib/config' +import { useEffect, useState, useCallback } from 'react' +import { getSystemConfig, invalidateSystemConfig, type SystemConfig } from '../lib/config' export function useSystemConfig() { const [config, setConfig] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [fetchKey, setFetchKey] = useState(0) useEffect(() => { let mounted = true + setLoading(true) getSystemConfig() .then((data) => { if (!mounted) return @@ -23,7 +25,18 @@ export function useSystemConfig() { return () => { mounted = false } + }, [fetchKey]) + + // Listen for invalidation events and re-fetch automatically + useEffect(() => { + const handler = () => setFetchKey((k) => k + 1) + window.addEventListener('system-config-invalidated', handler) + return () => window.removeEventListener('system-config-invalidated', handler) + }, []) + + const refresh = useCallback(() => { + invalidateSystemConfig() }, []) - return { config, loading, error } + return { config, loading, error, refresh } } diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index d524fad645..12d8541259 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -1080,11 +1080,16 @@ export const translations = { public: 'Public', addDescription: 'Add strategy description...', unsaved: 'Unsaved', + discardChanges: 'Discard', selectOrCreate: 'Select or create a strategy', customPromptDesc: 'Extra prompt appended to System Prompt for personalized trading style', customPromptPlaceholder: 'Enter custom prompt...', generatePromptPreview: 'Click to generate prompt preview', runAiTestHint: 'Click to run AI test', + tokenEstimate: 'Token Estimate', + tokenExceedWarning: 'Token estimate exceeds 128K. AI requests may fail for some models.', + tokenEstimating: 'Estimating...', + tokenTooltip: 'Based on 200K context', }, // Metric Tooltip @@ -1162,6 +1167,9 @@ export const translations = { close: 'Close', showingPositions: 'Showing {shown} of {total} positions', perPage: 'Per page', + accountFetchFailed: 'DATA_FETCH::FAILED — Account data unavailable, check connection', + positionsFetchFailed: 'Position data unavailable', + decisionsFetchFailed: 'Decision data unavailable', }, // AITradersPage toast messages @@ -1205,8 +1213,13 @@ export const translations = { // ModelConfigModal modelConfig: { selectModel: 'Select Model', + configure: 'Configure', configureApi: 'Configure API', + configureWallet: 'Configure Wallet', chooseProvider: 'Choose Your AI Provider', + claw402EntryDesc: 'Recommended default path. Use Base USDC pay-per-call instead of managing API keys.', + otherApiEntry: 'Other API Providers', + otherApiEntryDesc: 'Use your own API key for OpenAI, Claude, Gemini, DeepSeek, and more.', payPerCall: 'Pay-per-call USDC · All AI Models · No API Key', recommended: 'Best', allModelsClaw: 'Pay-per-call with USDC — supports all major AI models', @@ -2371,11 +2384,16 @@ export const translations = { public: '公开', addDescription: '添加策略简介...', unsaved: '未保存', + discardChanges: '撤销', selectOrCreate: '选择或创建策略', customPromptDesc: '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格', customPromptPlaceholder: '输入自定义提示词...', generatePromptPreview: '点击生成 Prompt 预览', runAiTestHint: '点击运行 AI 测试', + tokenEstimate: 'Token 预估', + tokenExceedWarning: 'Token 估算超过 128K,部分模型请求可能失败', + tokenEstimating: '预估中...', + tokenTooltip: '基于 200K 上下文计算', }, // Metric Tooltip @@ -2452,6 +2470,9 @@ export const translations = { close: '平仓', showingPositions: '显示 {shown} / {total} 个持仓', perPage: '每页', + accountFetchFailed: 'DATA_FETCH::FAILED — 账户数据请求失败,请检查连接', + positionsFetchFailed: '持仓数据请求失败', + decisionsFetchFailed: '决策记录请求失败', }, aiTradersToast: { @@ -2493,8 +2514,13 @@ export const translations = { modelConfig: { selectModel: '选择模型', + configure: '配置', configureApi: '配置 API', + configureWallet: '配置钱包', chooseProvider: '选择 AI 模型提供商', + claw402EntryDesc: '默认推荐走这条路。直接用 Base USDC 按次付费,不需要自己管理 API Key。', + otherApiEntry: '其他 API 模型', + otherApiEntryDesc: '如果你已经有自己的 OpenAI、Claude、Gemini、DeepSeek 等 API Key,再从这里进入。', payPerCall: 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key', recommended: '推荐', allModelsClaw: '用 USDC 按次付费,支持所有主流 AI 模型', @@ -3464,11 +3490,16 @@ export const translations = { public: 'Publik', addDescription: 'Tambah deskripsi strategi...', unsaved: 'Belum Disimpan', + discardChanges: 'Buang', selectOrCreate: 'Pilih atau buat strategi', customPromptDesc: 'Prompt tambahan di akhir System Prompt untuk gaya trading personal', customPromptPlaceholder: 'Masukkan prompt kustom...', generatePromptPreview: 'Klik untuk generate pratinjau prompt', runAiTestHint: 'Klik untuk menjalankan uji AI', + tokenEstimate: 'Estimasi Token', + tokenExceedWarning: 'Estimasi token melebihi 128K. Permintaan AI mungkin gagal untuk beberapa model.', + tokenEstimating: 'Mengestimasi...', + tokenTooltip: 'Berdasarkan konteks 200K', }, // Metric Tooltip @@ -3545,6 +3576,9 @@ export const translations = { close: 'Tutup', showingPositions: 'Menampilkan {shown} dari {total} posisi', perPage: 'Per halaman', + accountFetchFailed: 'DATA_FETCH::FAILED — Data akun tidak tersedia, periksa koneksi', + positionsFetchFailed: 'Data posisi tidak tersedia', + decisionsFetchFailed: 'Data keputusan tidak tersedia', }, aiTradersToast: { @@ -3586,8 +3620,13 @@ export const translations = { modelConfig: { selectModel: 'Pilih Model', + configure: 'Konfigurasi', configureApi: 'Konfigurasi API', + configureWallet: 'Konfigurasi Wallet', chooseProvider: 'Pilih Penyedia AI Anda', + claw402EntryDesc: 'Jalur default yang direkomendasikan. Gunakan Base USDC bayar per panggilan tanpa mengelola API key.', + otherApiEntry: 'Penyedia API Lain', + otherApiEntryDesc: 'Gunakan API key Anda sendiri untuk OpenAI, Claude, Gemini, DeepSeek, dan lainnya.', payPerCall: 'Bayar per panggilan USDC · Semua Model AI · Tanpa API Key', recommended: 'Terbaik', allModelsClaw: 'Bayar per panggilan dengan USDC — mendukung semua model AI utama', diff --git a/web/src/lib/api/config.ts b/web/src/lib/api/config.ts index 0e05082658..a34bc70f87 100644 --- a/web/src/lib/api/config.ts +++ b/web/src/lib/api/config.ts @@ -4,6 +4,8 @@ import type { UpdateModelConfigRequest, UpdateExchangeConfigRequest, CreateExchangeRequest, + BeginnerOnboardingResponse, + CurrentBeginnerWalletResponse, } from '../../types' import { API_BASE, httpClient, CryptoService } from './helpers' @@ -183,4 +185,24 @@ export const configApi = { if (!result.success) throw new Error('Failed to fetch server IP') return result.data! }, + + async prepareBeginnerOnboarding(): Promise { + const result = await httpClient.post( + `${API_BASE}/onboarding/beginner` + ) + if (!result.success || !result.data) { + throw new Error(result.message || 'Failed to prepare beginner onboarding') + } + return result.data + }, + + async getCurrentBeginnerWallet(): Promise { + const result = await httpClient.get( + `${API_BASE}/onboarding/beginner/current` + ) + if (!result.success || !result.data) { + throw new Error(result.message || 'Failed to fetch current beginner wallet') + } + return result.data + }, } diff --git a/web/src/lib/api/data.ts b/web/src/lib/api/data.ts index 6cf371f13e..58345370cc 100644 --- a/web/src/lib/api/data.ts +++ b/web/src/lib/api/data.ts @@ -19,20 +19,20 @@ export const dataApi = { return result.data! }, - async getAccount(traderId?: string): Promise { + async getAccount(traderId?: string, silent?: boolean): Promise { const url = traderId ? `${API_BASE}/account?trader_id=${traderId}` : `${API_BASE}/account` - const result = await httpClient.get(url) + const result = await httpClient.request(url, { silent }) if (!result.success) throw new Error('Failed to fetch account info') return result.data! }, - async getPositions(traderId?: string): Promise { + async getPositions(traderId?: string, silent?: boolean): Promise { const url = traderId ? `${API_BASE}/positions?trader_id=${traderId}` : `${API_BASE}/positions` - const result = await httpClient.get(url) + const result = await httpClient.request(url, { silent }) if (!result.success) throw new Error('Failed to fetch positions') return result.data! }, @@ -48,7 +48,8 @@ export const dataApi = { async getLatestDecisions( traderId?: string, - limit: number = 5 + limit: number = 5, + silent?: boolean ): Promise { const params = new URLSearchParams() if (traderId) { @@ -56,8 +57,9 @@ export const dataApi = { } params.append('limit', limit.toString()) - const result = await httpClient.get( - `${API_BASE}/decisions/latest?${params}` + const result = await httpClient.request( + `${API_BASE}/decisions/latest?${params}`, + { silent } ) if (!result.success) throw new Error('Failed to fetch latest decisions') return result.data! diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 54d138e08b..ed4b2c67ac 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -26,4 +26,5 @@ export function getSystemConfig(): Promise { export function invalidateSystemConfig() { cachedConfig = null configPromise = null + window.dispatchEvent(new Event('system-config-invalidated')) } diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts index 3c97cac706..783d64797b 100644 --- a/web/src/lib/httpClient.ts +++ b/web/src/lib/httpClient.ts @@ -85,11 +85,16 @@ export class HttpClient { * Only business errors are returned to caller */ private async handleError(error: AxiosError): Promise { + const isSilent = (error.config as any)?.silentError === true + // Network error (no response from server) if (!error.response) { - toast.error('Network error - Please check your connection', { - description: 'Unable to reach the server', - }) + if (!isSilent) { + toast.error('Network error - Please check your connection', { + id: 'network-error', + description: 'Unable to reach the server', + }) + } throw new Error('Network error') } @@ -132,25 +137,34 @@ export class HttpClient { // Handle 403 Forbidden - system error if (status === 403) { - toast.error('Permission Denied', { - description: 'You do not have permission to access this resource', - }) + if (!isSilent) { + toast.error('Permission Denied', { + id: 'permission-denied', + description: 'You do not have permission to access this resource', + }) + } throw new Error('Permission denied') } // Handle 404 Not Found - system error if (status === 404) { - toast.error('API Not Found', { - description: 'The requested endpoint does not exist (404)', - }) + if (!isSilent) { + toast.error('API Not Found', { + id: `404-${(error.config as any)?.url || 'unknown'}`, + description: 'The requested endpoint does not exist (404)', + }) + } throw new Error('API not found') } // Handle 500+ Server Error - system error if (status >= 500) { - toast.error('Server Error', { - description: 'Please try again later or contact support', - }) + if (!isSilent) { + toast.error('Server Error', { + id: 'server-error', + description: 'Please try again later or contact support', + }) + } throw new Error('Server error') } @@ -171,6 +185,7 @@ export class HttpClient { data?: any params?: any headers?: Record + silent?: boolean } = {} ): Promise> { try { @@ -180,6 +195,7 @@ export class HttpClient { data: options.data, params: options.params, headers: options.headers, + ...(options.silent && { silentError: true }), }) // Success diff --git a/web/src/lib/onboarding.ts b/web/src/lib/onboarding.ts new file mode 100644 index 0000000000..81ab9dc3b9 --- /dev/null +++ b/web/src/lib/onboarding.ts @@ -0,0 +1,37 @@ +export type UserMode = 'beginner' | 'advanced' + +const USER_MODE_KEY = 'nofx_user_mode' +const BEGINNER_WALLET_ADDRESS_KEY = 'nofx_beginner_wallet_address' +const BEGINNER_ONBOARDING_COMPLETED_KEY = 'nofx_beginner_onboarding_completed' + +export function getUserMode(): UserMode | null { + const value = localStorage.getItem(USER_MODE_KEY) + if (value === 'beginner' || value === 'advanced') { + return value + } + return null +} + +export function setUserMode(mode: UserMode) { + localStorage.setItem(USER_MODE_KEY, mode) +} + +export function getPostAuthPath(mode: UserMode | null | undefined): string { + return mode === 'beginner' ? '/welcome' : '/traders' +} + +export function setBeginnerWalletAddress(address: string) { + localStorage.setItem(BEGINNER_WALLET_ADDRESS_KEY, address) +} + +export function getBeginnerWalletAddress(): string | null { + return localStorage.getItem(BEGINNER_WALLET_ADDRESS_KEY) +} + +export function hasCompletedBeginnerOnboarding(): boolean { + return localStorage.getItem(BEGINNER_ONBOARDING_COMPLETED_KEY) === 'true' +} + +export function markBeginnerOnboardingCompleted() { + localStorage.setItem(BEGINNER_ONBOARDING_COMPLETED_KEY, 'true') +} diff --git a/web/src/pages/BeginnerOnboardingPage.tsx b/web/src/pages/BeginnerOnboardingPage.tsx new file mode 100644 index 0000000000..001fb9ff57 --- /dev/null +++ b/web/src/pages/BeginnerOnboardingPage.tsx @@ -0,0 +1,269 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ArrowRight, + Copy, + RefreshCw, + Shield, + Wallet, + X, +} from 'lucide-react' +import { QRCodeSVG } from 'qrcode.react' +import { toast } from 'sonner' +import { useLanguage } from '../contexts/LanguageContext' +import { api } from '../lib/api' +import type { BeginnerOnboardingResponse } from '../types' +import { setBeginnerWalletAddress, markBeginnerOnboardingCompleted } from '../lib/onboarding' + +export function BeginnerOnboardingPage() { + const { language } = useLanguage() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [refreshingBalance, setRefreshingBalance] = useState(false) + const hasRequestedRef = useRef(false) + const isZh = language === 'zh' + + const loadOnboarding = async (showLoading: boolean) => { + if (showLoading) { + setLoading(true) + } else { + setRefreshingBalance(true) + } + + setError('') + try { + const result = await api.prepareBeginnerOnboarding() + setData(result) + setBeginnerWalletAddress(result.address) + } catch (err) { + setError( + err instanceof Error + ? err.message + : isZh + ? '新手钱包准备失败' + : 'Failed to prepare beginner wallet' + ) + } finally { + if (showLoading) { + setLoading(false) + } else { + setRefreshingBalance(false) + } + } + } + + useEffect(() => { + if (hasRequestedRef.current) { + return + } + hasRequestedRef.current = true + void loadOnboarding(true) + }, []) + + const noticeText = useMemo( + () => + isZh + ? '此钱包仅用于大模型调用费用,不会自动充到交易所。私钥丢失后无法恢复,只充 Base 链 USDC。' + : 'This wallet only pays for model calls. It does not fund your exchange automatically. The private key cannot be recovered, and you should only deposit Base USDC.', + [isZh] + ) + + const copyText = async (value: string, label: string) => { + try { + await navigator.clipboard.writeText(value) + toast.success(isZh ? `${label}已复制` : `${label} copied`) + } catch { + toast.error(isZh ? '复制失败' : 'Copy failed') + } + } + + const handleContinue = () => { + markBeginnerOnboardingCompleted() + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } + + return ( +
+
+
+ +
+
+
+
+ +
+
+
+ {isZh ? '新手保护' : 'Beginner Guard'} +
+

+ {isZh ? '钱包已经帮你准备好了' : 'Your wallet is ready'} +

+
+
+ +
+ Claw402 + DeepSeek · + {isZh ? '按次付费' : 'Pay per call'} +
+
+ +
+ {loading ? ( +
+ {isZh ? '正在准备你的 Base 钱包...' : 'Preparing your Base wallet...'} +
+ ) : data ? ( +
+
+
+
+ +
+ +
+ {isZh ? '充值地址(Base USDC)' : 'Deposit address (Base USDC)'} +
+ +
+
+
+ {data.balance_usdc} + USDC +
+
+ +
+ +
+ {isZh ? '$5-$10 可以用很久' : '$5-$10 usually lasts a long time'} +
+
+
+ +
+
+
+
+ + {isZh ? '钱包地址' : 'Wallet address'} +
+
+
+
{data.address}
+
+ +
+
+ +
+
+ + {isZh ? '私钥,请立即备份' : 'Private key, back it up now'} +
+
+
+
{data.private_key}
+
+
+ +
+
+
+ +
+ + {noticeText} +
+ + {data.env_warning ? ( +
+ {data.env_warning} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + + + {data.env_saved ? ( +
+ {isZh + ? `钱包信息已同步保存到 ${data.env_path || '.env'}` + : `Wallet details were also saved to ${data.env_path || '.env'}`} +
+ ) : null} +
+
+
+ ) : null} +
+
+
+
+ ) +} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index d278e1cafd..78c5080862 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -4,6 +4,12 @@ import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, P import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { api } from '../lib/api' +import { + getPostAuthPath, + getUserMode, + setUserMode, + type UserMode, +} from '../lib/onboarding' import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal' import { TelegramConfigModal } from '../components/trader/TelegramConfigModal' import { ModelConfigModal } from '../components/trader/ModelConfigModal' @@ -15,6 +21,7 @@ export function SettingsPage() { const { user } = useAuth() const { language } = useLanguage() const [activeTab, setActiveTab] = useState('account') + const [userMode, setUserModeState] = useState(() => getUserMode() ?? 'advanced') // Account state const [newPassword, setNewPassword] = useState('') @@ -81,6 +88,26 @@ export function SettingsPage() { } } + const handleSwitchMode = (nextMode: UserMode) => { + if (nextMode === userMode) { + return + } + + setUserMode(nextMode) + setUserModeState(nextMode) + toast.success( + language === 'zh' + ? `已切换到${nextMode === 'beginner' ? '新手模式' : '老手模式'}` + : nextMode === 'beginner' + ? 'Switched to beginner mode' + : 'Switched to advanced mode' + ) + + const nextPath = getPostAuthPath(nextMode) + window.history.pushState({}, '', nextPath) + window.dispatchEvent(new PopStateEvent('popstate')) + } + const handleSaveModel = async ( modelId: string, apiKey: string, @@ -281,6 +308,66 @@ export function SettingsPage() {

{user?.email}

+
+
+
+

+ {language === 'zh' ? '使用模式' : 'Usage Mode'} +

+

+ {language === 'zh' + ? '新手模式会显示钱包引导和 4 步卡片;老手模式保持原来的专业界面。' + : 'Beginner mode shows wallet onboarding and quickstart cards. Advanced mode keeps the original pro workflow.'} +

+
+ + {userMode === 'beginner' + ? language === 'zh' ? '当前:新手模式' : 'Current: Beginner' + : language === 'zh' ? '当前:老手模式' : 'Current: Advanced'} + +
+ +
+ + + +
+
+

Change Password

diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index fbc90f36c5..5e4ee2558f 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -38,6 +38,7 @@ import { RiskControlEditor } from '../components/strategy/RiskControlEditor' import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor' +import { TokenEstimateBar } from '../components/strategy/TokenEstimateBar' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' import { t } from '../i18n/translations' @@ -52,6 +53,7 @@ export function StrategyStudioPage() { const [editingConfig, setEditingConfig] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [estimatedTokens, setEstimatedTokens] = useState(0) const [error, setError] = useState(null) const [hasChanges, setHasChanges] = useState(false) @@ -247,6 +249,24 @@ export function StrategyStudioPage() { const handleDeleteStrategy = async (id: string) => { if (!token) return + // Check if strategy is in use by any trader before showing dialog + try { + const tradersResp = await fetch(`${API_BASE}/api/my-traders`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (tradersResp.ok) { + const traderList = await tradersResp.json() + const using = traderList.filter((t: any) => t.strategy_id === id) + if (using.length > 0) { + const names = using.map((t: any) => t.trader_name).join(', ') + notify.error(`Strategy is in use by: ${names}`) + return + } + } + } catch { + // fetch failed — proceed, backend will guard + } + const confirmed = await confirmToast( tr('confirmDeleteStrategy'), { @@ -262,9 +282,12 @@ export function StrategyStudioPage() { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) - if (!response.ok) throw new Error('Failed to delete strategy') + if (!response.ok) { + const data = await response.json().catch(() => ({})) + notify.error(data.error || 'Failed to delete strategy') + return + } notify.success(tr('strategyDeleted')) - // Clear selection if deleted strategy was selected if (selectedStrategy?.id === id) { setSelectedStrategy(null) setEditingConfig(null) @@ -272,9 +295,7 @@ export function StrategyStudioPage() { } await fetchStrategies() } catch (err) { - const errorMsg = err instanceof Error ? err.message : 'Unknown error' - setError(errorMsg) - notify.error(errorMsg) + notify.error(err instanceof Error ? err.message : 'Unknown error') } } @@ -378,6 +399,10 @@ export function StrategyStudioPage() { // Save strategy const handleSaveStrategy = async () => { if (!token || !selectedStrategy || !editingConfig) return + if (estimatedTokens >= 128000 && currentStrategyType === 'ai_trading') { + notify.warning(tr('tokenExceedWarning')) + // continue with save + } setIsSaving(true) try { // Always sync the config language with the current interface language @@ -681,7 +706,7 @@ export function StrategyStudioPage() {
-
+
{strategies.map((strategy) => (
-
- {strategy.name} +
+ {strategy.name}
+ {/* Token Estimate Bar */} + {currentStrategyType === 'ai_trading' && ( +
+ +
+ )} + {/* Strategy Type Selector */} {editingConfig && (
diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index 703574263d..53f545d877 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -10,6 +10,7 @@ import { formatPrice, formatQuantity } from '../utils/format' import { t, type Language } from '../i18n/translations' import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react' import { DeepVoidBackground } from '../components/common/DeepVoidBackground' +import { NofxSelect } from '../components/ui/select' import { GridRiskPanel } from '../components/strategy/GridRiskPanel' import type { SystemStatus, @@ -102,8 +103,11 @@ interface TraderDashboardPageProps { onNavigateToTraders: () => void status?: SystemStatus account?: AccountInfo + accountFailed?: boolean positions?: Position[] + positionsFailed?: boolean decisions?: DecisionRecord[] + decisionsFailed?: boolean decisionsLimit: number onDecisionsLimitChange: (limit: number) => void stats?: Statistics @@ -116,8 +120,11 @@ export function TraderDashboardPage({ selectedTrader, status, account, + accountFailed, positions, + positionsFailed, decisions, + decisionsFailed, decisionsLimit, onDecisionsLimitChange, lastUpdate, @@ -376,17 +383,12 @@ export function TraderDashboardPage({ {/* Trader Selector */} {traders && traders.length > 0 && (
- + onTraderSelect(val)} + options={traders.map(t => ({ value: t.trader_id, label: t.trader_name }))} + className="bg-transparent text-sm font-medium cursor-pointer transition-colors text-nofx-text-main px-2 py-1" + />
)} @@ -484,48 +486,60 @@ export function TraderDashboardPage({
{/* Debug Info */} - {account && ( -
- SYSTEM_STATUS::ONLINE +
+ SYSTEM_STATUS::ONLINE + {account ? (
LAST_UPDATE::{lastUpdate} - EQ::{account?.total_equity?.toFixed(2)} - PNL::{account?.total_pnl?.toFixed(2)} + EQ::{account.total_equity?.toFixed(2)} + PNL::{account.total_pnl?.toFixed(2)}
-
- )} + ) : accountFailed ? ( + {t('traderDashboard.accountFetchFailed', language)} + ) : ( +
+ + + +
+ )} +
{/* Account Overview */}
0} icon="💰" + loading={!account && !accountFailed} /> = 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) || '0.00'}`} + value={accountFailed && !account ? '--' : `${account?.total_pnl !== undefined && account.total_pnl >= 0 ? '+' : ''}${account?.total_pnl?.toFixed(2) ?? '--'}`} unit="USDT" - change={account?.total_pnl_pct || 0} + change={account ? (account.total_pnl_pct || 0) : undefined} positive={(account?.total_pnl ?? 0) >= 0} icon="📈" + loading={!account && !accountFailed} />
@@ -671,15 +685,12 @@ export function TraderDashboardPage({
{t('traderDashboard.perPage', language)}: - + onChange={(val) => setPositionsPageSize(Number(val))} + options={[{ value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="bg-black/40 border border-white/10 rounded px-2 py-1 text-xs text-nofx-text-main transition-colors" + />
{totalPositionPages > 1 && (
@@ -716,6 +727,11 @@ export function TraderDashboardPage({
)}
+ ) : positionsFailed ? ( +
+
⚠️
+
{t('traderDashboard.positionsFetchFailed', language)}
+
) : (
📊
@@ -752,17 +768,12 @@ export function TraderDashboardPage({ )}
{/* Limit Selector */} - + onChange={(val) => onDecisionsLimitChange(Number(val))} + options={[{ value: 5, label: '5' }, { value: 10, label: '10' }, { value: 20, label: '20' }, { value: 50, label: '50' }, { value: 100, label: '100' }]} + className="px-3 py-1.5 rounded-lg text-sm font-medium cursor-pointer transition-all bg-black/40 text-nofx-text-main border border-white/10 hover:border-nofx-accent" + />
{/* Decisions List - Scrollable */} @@ -774,6 +785,11 @@ export function TraderDashboardPage({ decisions.map((decision, i) => ( )) + ) : decisionsFailed ? ( +
+
⚠️
+
{t('traderDashboard.decisionsFetchFailed', language)}
+
) : (
🧠
@@ -818,6 +834,7 @@ function StatCard({ positive, subtitle, icon, + loading, }: { title: string value: string @@ -826,6 +843,7 @@ function StatCard({ positive?: boolean subtitle?: string icon?: string + loading?: boolean }) { return (
@@ -835,27 +853,35 @@ function StatCard({
{title}
-
-
- {value} + {loading ? ( +
+
+
- {unit && {unit}} -
- - {change !== undefined && ( -
-
- {positive ? '▲' : '▼'} - {positive ? '+' : ''}{change.toFixed(2)}% + ) : ( + <> +
+
+ {value} +
+ {unit && {unit}}
-
- )} - {subtitle && ( -
- {subtitle} -
+ {change !== undefined && ( +
+
+ {positive ? '▲' : '▼'} + {positive ? '+' : ''}{change.toFixed(2)}% +
+
+ )} + {subtitle && ( +
+ {subtitle} +
+ )} + )}
) diff --git a/web/src/types/config.ts b/web/src/types/config.ts index fe2c2be386..25211b4eb0 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -6,6 +6,8 @@ export interface AIModel { apiKey?: string customApiUrl?: string customModelName?: string + walletAddress?: string + balanceUsdc?: string } export interface TelegramConfig { @@ -110,3 +112,26 @@ export interface UpdateExchangeConfigRequest { } } } + +export interface BeginnerOnboardingResponse { + address: string + private_key: string + chain: string + asset: string + provider: string + default_model: string + configured_model_id: string + balance_usdc: string + env_saved: boolean + env_path?: string + reused_existing: boolean + env_warning?: string +} + +export interface CurrentBeginnerWalletResponse { + found: boolean + address?: string + balance_usdc?: string + source?: string + claw402_status?: string +}