repo_description: Your personal AI trading assistant. Any market. Any model. Pay with USDC, not API keys.
repo_topics: agentictrading ai ai-trading arbitrage binance bitcoin bybit exchange forex hyperliquid lighter metals okx perp-dex stocks trading x402 x402-agent
code: package api
  
  import (
  	"fmt"
  	"net/http"
  	"strings"
  	"time"
  
  	"nofx/auth"
  	"nofx/logger"
  	"nofx/store"
  
  	"github.com/gin-gonic/gin"
  	"github.com/google/uuid"
  	"gorm.io/gorm"
  )
  
  // handleLogout Add current token to blacklist
  func (s *Server) handleLogout(c *gin.Context) {
  	authHeader := c.GetHeader("Authorization")
  	if authHeader == "" {
  		c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
  		return
  	}
  	parts := strings.Split(authHeader, " ")
  	if len(parts) != 2 || parts[0] != "Bearer" {
  		c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
  		return
  	}
  	tokenString := parts[1]
  	claims, err := auth.ValidateJWT(tokenString)
  	if err != nil {
  		c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
  		return
  	}
  	var exp time.Time
  	if claims.ExpiresAt != nil {
  		exp = claims.ExpiresAt.Time
  	} else {
  		exp = time.Now().Add(24 * time.Hour)
  	}
  	auth.BlacklistToken(tokenString, exp)
  	c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
  }
  
  // handleRegister Handle user registration request.
  // handleRegister allows registration only when no users exist yet (first-time setup).
  // This is a single-user system; subsequent registrations are permanently closed.
  func (s *Server) handleRegister(c *gin.Context) {
  	userCount, err := s.store.User().Count()
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
  		return
  	}
  
  	if userCount > 0 {
  		c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
  		return
  	}
  
  	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 {
  		SafeBadRequest(c, "Invalid request parameters")
  		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 {
  		c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
  		return
  	}
  
  	// Generate password hash
  	passwordHash, err := auth.HashPassword(req.Password)
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
  		return
  	}
  
  	// Create user
  	userID := uuid.New().String()
  	user := &store.User{
  		ID:           userID,
  		Email:        req.Email,
  		PasswordHash: passwordHash,
  	}
  
  	err = s.store.User().Create(user)
  	if err != nil {
  		SafeInternalError(c, "Failed to create user", err)
  		return
  	}
  
  	// Adopt orphan records from previous account (e.g. after account reset)
  	// This preserves wallet keys and exchange configs so funds are not lost.
  	s.adoptOrphanRecords(userID)
  
  	// Generate JWT token
  	token, err := auth.GenerateJWT(user.ID, user.Email)
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
  		return
  	}
  
  	// Initialize default model and exchange configs for user
  	err = s.initUserDefaultConfigs(user.ID, lang)
  	if err != nil {
  		logger.Infof("Failed to initialize user default configs: %v", err)
  	}
  
  	c.JSON(http.StatusOK, gin.H{
  		"token":   token,
  		"user_id": user.ID,
  		"email":   user.Email,
  		"message": "Registration successful",
  	})
  }
  
  // handleLogin Handle user login request
  func (s *Server) handleLogin(c *gin.Context) {
  	var req struct {
  		Email    string `json:"email" binding:"required,email"`
  		Password string `json:"password" binding:"required"`
  	}
  
  	if err := c.ShouldBindJSON(&req); err != nil {
  		SafeBadRequest(c, "Invalid request parameters")
  		return
  	}
  
  	// Get user information
  	user, err := s.store.User().GetByEmail(req.Email)
  	if err != nil {
  		c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
  		return
  	}
  
  	// Verify password
  	if !auth.CheckPassword(req.Password, user.PasswordHash) {
  		c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
  		return
  	}
  
  	// Issue token directly after password verification.
  	token, err := auth.GenerateJWT(user.ID, user.Email)
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
  		return
  	}
  
  	c.JSON(http.StatusOK, gin.H{
  		"token":   token,
  		"user_id": user.ID,
  		"email":   user.Email,
  		"message": "Login successful",
  	})
  }
  
  // handleChangePassword changes the password for the currently authenticated user.
  func (s *Server) handleChangePassword(c *gin.Context) {
  	userID := c.GetString("user_id")
  	var req struct {
  		NewPassword string `json:"new_password" binding:"required,min=8"`
  	}
  	if err := c.ShouldBindJSON(&req); err != nil {
  		SafeBadRequest(c, "new_password is required (min 8 chars)")
  		return
  	}
  	hash, err := auth.HashPassword(req.NewPassword)
  	if err != nil {
  		SafeInternalError(c, "Password processing failed", err)
  		return
  	}
  	if err := s.store.User().UpdatePassword(userID, hash); err != nil {
  		SafeInternalError(c, "Failed to update password", err)
  		return
  	}
  	c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
  }
  
  // handleResetPassword Reset password via email and new password
  func (s *Server) handleResetPassword(c *gin.Context) {
  	var req struct {
  		Email       string `json:"email" binding:"required,email"`
  		NewPassword string `json:"new_password" binding:"required,min=6"`
  	}
  
  	if err := c.ShouldBindJSON(&req); err != nil {
  		SafeBadRequest(c, "Invalid request parameters")
  		return
  	}
  
  	// Query user
  	user, err := s.store.User().GetByEmail(req.Email)
  	if err != nil {
  		c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
  		return
  	}
  
  	// Generate new password hash
  	newPasswordHash, err := auth.HashPassword(req.NewPassword)
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
  		return
  	}
  
  	// Update password
  	err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
  	if err != nil {
  		c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
  		return
  	}
  
  	logger.Infof("✓ User %s password has been reset", user.Email)
  	c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
  }
  
  // handleResetAccount clears user authentication data so the system returns to
  // uninitialized state for re-registration. Wallet keys (ai_models) are preserved
  // so funds are not lost — they will be adopted by the new account during onboarding.
  func (s *Server) handleResetAccount(c *gin.Context) {
  	err := s.store.Transaction(func(tx *gorm.DB) error {
  		// Delete traders and strategies (config, not funds)
  		tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
  		tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
  		// Delete users — ai_models and exchanges are intentionally kept
  		// so wallet private keys and exchange configs survive re-registration
  		if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
  			return fmt.Errorf("failed to delete users: %w", err)
  		}
  		return nil
  	})
  	if err != nil {
  		SafeInternalError(c, "Failed to reset account", err)
  		return
  	}
  
  	logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
  	c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
  }
  
  // adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
  // exists in the users table. This happens after account reset so the new user
  // inherits the previous wallet keys and exchange configurations.
  func (s *Server) adoptOrphanRecords(newUserID string) {
  	db := s.store.GormDB()
  	result := db.Model(&store.AIModel{}).
  		Where("user_id NOT IN (SELECT id FROM users)").
  		Update("user_id", newUserID)
  	if result.RowsAffected > 0 {
  		logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
  	}
  
  	result = db.Model(&store.Exchange{}).
  		Where("user_id NOT IN (SELECT id FROM users)").
  		Update("user_id", newUserID)
  	if result.RowsAffected > 0 {
  		logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
  	}
  }
  
  // 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
  	})
  }
  