Files
nofx/api/server.go
T
Zavier b331733e23 feat: improve user onboarding and setup UX (#1436)
* feat: add beginner onboarding and mode switching flow

* chore: ignore local gh auth config

* fix: restore kline fallback and align onboarding language

---------

Co-authored-by: zavier-bin <zhaobbbhhh@gmail.com>
2026-03-28 00:17:37 +08:00

627 lines
30 KiB
Go

package api
import (
"context"
"fmt"
"net"
"net/http"
"nofx/auth"
"nofx/crypto"
"nofx/logger"
"nofx/manager"
"nofx/store"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// Server HTTP API server
type Server struct {
router *gin.Engine
traderManager *manager.TraderManager
store *store.Store
cryptoHandler *CryptoHandler
httpServer *http.Server
port int
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
}
// NewServer Creates API server
func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoService *crypto.CryptoService, port int) *Server {
// Set to Release mode (reduce log output)
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
// Enable CORS
router.Use(corsMiddleware())
// Create crypto handler
cryptoHandler := NewCryptoHandler(cryptoService)
s := &Server{
router: router,
traderManager: traderManager,
store: st,
cryptoHandler: cryptoHandler,
port: port,
}
// Setup routes
s.setupRoutes()
return s
}
// corsMiddleware CORS middleware
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
}
}
// setupRoutes Setup routes
func (s *Server) setupRoutes() {
// API route group
api := s.router.Group("/api")
{
// Health check
api.Any("/health", s.handleHealth)
// Admin login (used in admin mode, public)
// System supported models and exchanges (no authentication required)
s.route(api, "GET", "/supported-models", "List supported AI model providers", s.handleGetSupportedModels)
s.route(api, "GET", "/supported-exchanges", "List supported exchange types", s.handleGetSupportedExchanges)
// System config (no authentication required, for frontend to determine admin mode/registration status)
s.route(api, "GET", "/config", "Get system configuration", s.handleGetSystemConfig)
// Wallet validation (no authentication required — used by frontend config form)
api.POST("/wallet/validate", s.handleWalletValidate)
api.POST("/wallet/generate", s.handleWalletGenerate)
// Crypto related endpoints (no authentication required, not exposed to bot)
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
// Public competition data (no authentication required)
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
s.route(api, "GET", "/competition", "Public competition data", s.handlePublicCompetition)
s.route(api, "GET", "/top-traders", "Top traders leaderboard", s.handleTopTraders)
s.route(api, "GET", "/equity-history", "Equity history for a trader", s.handleEquityHistory)
s.route(api, "POST", "/equity-history-batch", "Batch equity history for multiple traders", s.handleEquityHistoryBatch)
s.route(api, "GET", "/traders/:id/public-config", "Public trader configuration", s.handleGetPublicTraderConfig)
// Market data (no authentication required)
s.route(api, "GET", "/klines", "Candlestick data (?symbol=&interval=&limit=)", s.handleKlines)
s.route(api, "GET", "/symbols", "Available trading symbols", s.handleSymbols)
// 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)
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
// Routes requiring authentication
protected := api.Group("/", s.authMiddleware())
{
// 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",
`Body: {"new_password":"<string, min 8 chars>"}`,
s.handleChangePassword)
// Server IP query (requires authentication, for whitelist configuration)
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
// AI trader management
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this endpoint before querying data.`,
s.handleTraderList)
s.routeWithSchema(protected, "GET", "/traders/:id/config", "Get full trader configuration",
`:id = trader_id from GET /api/my-traders`,
s.handleGetTraderConfig)
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
s.handleCreateTrader)
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
`:id = trader_id from GET /api/my-traders
Body: {"name":"<string>","ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<EXACT id from GET /api/strategies>","scan_interval_minutes":<int, min 3>,"is_cross_margin":<bool>}
Only include fields you want to change.`,
s.handleUpdateTrader)
s.routeWithSchema(protected, "DELETE", "/traders/:id", "Delete trader",
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
s.handleDeleteTrader)
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
s.handleStartTrader)
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
s.handleStopTrader)
s.routeWithSchema(protected, "PUT", "/traders/:id/prompt", "Override the trader's AI system prompt",
`Body: {"prompt":"<string — the full custom prompt text>"}`,
s.handleUpdateTraderPrompt)
s.routeWithSchema(protected, "POST", "/traders/:id/sync-balance", "Sync account balance from exchange",
`:id = trader_id from GET /api/my-traders. No request body needed. Refreshes initial_balance from the exchange.`,
s.handleSyncBalance)
s.routeWithSchema(protected, "POST", "/traders/:id/close-position", "Force-close an open position",
`:id = trader_id from GET /api/my-traders.
Body: {"symbol":"<string, e.g. BTCUSDT — must match an open position symbol from GET /api/positions>"}`,
s.handleClosePosition)
s.routeWithSchema(protected, "PUT", "/traders/:id/competition", "Toggle competition leaderboard visibility",
`:id = trader_id from GET /api/my-traders.
Body: {"show_in_competition":<bool>}`,
s.handleToggleCompetition)
s.routeWithSchema(protected, "GET", "/traders/:id/grid-risk", "Get grid trading risk info",
`:id = trader_id from GET /api/my-traders.`,
s.handleGetGridRiskInfo)
// AI cost tracking
s.route(protected, "GET", "/ai-costs", "Get AI call costs for a trader (?trader_id=xxx&period=today)", s.handleGetAICosts)
s.route(protected, "GET", "/ai-costs/summary", "Get AI cost summary (?period=today)", s.handleGetAICostsSummary)
// AI model configuration
s.routeWithSchema(protected, "GET", "/models", "List AI model configs",
`Returns: [{"id":"<EXACT id — use this as ai_model_id when creating/updating a trader>","name":"<display name>","provider":"<short provider name — NOT a valid id>","enabled":<bool>}]
CRITICAL: The "id" field (e.g. "abc123_deepseek") is what you must use for ai_model_id. The "provider" field ("deepseek") is NOT valid as an id.`,
s.handleGetModelConfigs)
s.routeWithSchema(protected, "PUT", "/models", "Configure an AI model provider",
`Body: {"models":{"<model_id>":{"enabled":<bool>,"api_key":"<string>","custom_api_url":"<string, leave empty to use provider default>","custom_model_name":"<string, leave empty to use provider default>"}}}
model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude"
Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`,
s.handleUpdateModelConfigs)
// Exchange configuration
s.routeWithSchema(protected, "GET", "/exchanges", "List exchange accounts",
`Returns: [{"id":"<EXACT id — use this as exchange_id when creating/updating a trader>","exchange_type":"<e.g. okx, binance>","account_name":"<user label>","enabled":<bool>}]
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
s.handleGetExchangeConfigs)
s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account",
`Body: {"exchange_type":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
Required fields by exchange:
binance/bybit/bitget/indodax: api_key + secret_key
okx/gate/kucoin: api_key + secret_key + passphrase
hyperliquid: hyperliquid_wallet_addr
aster: aster_user + aster_signer + aster_private_key
lighter: lighter_wallet_addr + lighter_private_key + lighter_api_key_private_key + lighter_api_key_index`,
s.handleCreateExchange)
s.routeWithSchema(protected, "PUT", "/exchanges", "Update an existing exchange account configuration",
`Body: {"id":"<EXACT id from GET /api/exchanges>","exchange_type":"<string>","account_name":"<string>","enabled":<bool>,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, for okx/gate/kucoin>"}
Use this to enable/disable an exchange or update API credentials. The "id" field is required to identify which exchange to update.`,
s.handleUpdateExchangeConfigs)
s.routeWithSchema(protected, "DELETE", "/exchanges/:id", "Delete exchange account",
`:id = EXACT id from GET /api/exchanges. Permanently removes the exchange account and disconnects any traders using it.`,
s.handleDeleteExchange)
// Telegram bot configuration
s.routeWithSchema(protected, "GET", "/telegram", "Get Telegram bot configuration",
`Returns: {"bot_token":"<string>","model_id":"<EXACT id of configured AI model>","chat_id":"<bound Telegram chat id, empty if not bound>"}`,
s.handleGetTelegramConfig)
s.routeWithSchema(protected, "POST", "/telegram", "Set Telegram bot token and AI model",
`Body: {"bot_token":"<string — Telegram BotFather token>","model_id":"<EXACT id from GET /api/models>"}
Both fields are required. After saving, the user must send /start in Telegram to bind their account.`,
s.handleUpdateTelegramConfig)
s.routeWithSchema(protected, "POST", "/telegram/model", "Update Telegram bot AI model only",
`Body: {"model_id":"<EXACT id from GET /api/models>"}`,
s.handleUpdateTelegramModel)
s.routeWithSchema(protected, "DELETE", "/telegram/binding", "Unbind Telegram account",
`No body needed. Clears the Telegram chat_id binding so the user can re-bind with /start.`,
s.handleUnbindTelegram)
// Strategy management
s.routeWithSchema(protected, "GET", "/strategies", "List user's strategies",
`Returns: [{"id":"<EXACT id — use as strategy_id when creating/updating a trader>","name":"<string>","is_active":<bool>,"is_default":<bool>}]
CRITICAL: Always use the "id" field for strategy_id.`,
s.handleGetStrategies)
s.routeWithSchema(protected, "GET", "/strategies/active", "Get the currently active strategy",
`Returns the strategy marked is_active=true for this user, or the system default. Use this to find which strategy is currently in use.`,
s.handleGetActiveStrategy)
s.routeWithSchema(protected, "GET", "/strategies/default-config", "Get default strategy config with all fields and sensible values — use as reference for building configs",
`No parameters needed. Returns a complete StrategyConfig object with all fields populated with recommended defaults. Read this before building a custom config.`,
s.handleGetDefaultStrategyConfig)
s.route(protected, "POST", "/strategies/preview-prompt", "Preview the AI prompt that will be generated from a config", s.handlePreviewPrompt)
s.route(protected, "POST", "/strategies/test-run", "Test-run strategy AI analysis", s.handleStrategyTestRun)
s.route(protected, "GET", "/strategies/:id", "Get strategy by ID", s.handleGetStrategy)
s.routeWithSchema(protected, "POST", "/strategies", "Create a new trading strategy",
`Body: {"name":"<string, required>","description":"<string, optional>","lang":"zh|en","config":<StrategyConfig object, OPTIONAL — if omitted the system applies complete working defaults automatically (ai500 top coins, all standard indicators, standard risk control)>}
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
StrategyConfig fields:
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
indicators.klines.primary_count: number of candles (20-100)
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
indicators.klines.selected_timeframes: e.g. ["5m","15m","1h","4h"]
indicators.enable_raw_klines: ALWAYS true (raw OHLCV required)
indicators.enable_ema: true for trend-following (EMA crossover signals)
indicators.enable_macd: true for trend + momentum confirmation
indicators.enable_rsi: true for overbought/oversold, divergence detection
indicators.enable_boll: true for volatility, range trading, breakout strategies
indicators.enable_atr: true for volatility measurement and stop-loss sizing
indicators.enable_volume: ALWAYS true
indicators.enable_oi: ALWAYS true (open interest data)
indicators.enable_funding_rate: ALWAYS true
indicators.ema_periods: [20,50] default, [9,21] for faster signals
indicators.rsi_periods: [7,14] default
indicators.atr_periods: [14] default
indicators.boll_periods: [20] default
indicators.nofxos_api_key: ALWAYS "cm_568c67eae410d912c54c"
indicators.enable_quant_data: ALWAYS true
indicators.enable_quant_oi: ALWAYS true
indicators.enable_quant_netflow: ALWAYS true
indicators.enable_oi_ranking: ALWAYS true, oi_ranking_duration:"1h", oi_ranking_limit:10
indicators.enable_netflow_ranking: ALWAYS true, netflow_ranking_duration:"1h", netflow_ranking_limit:10
indicators.enable_price_ranking: ALWAYS true, price_ranking_duration:"1h,4h,24h", price_ranking_limit:10
risk_control.max_positions: max simultaneous positions (1=single coin, 3=diversified, 5=wide)
risk_control.btc_eth_max_leverage: BTC/ETH leverage (conservative:3-5, moderate:5-10, aggressive:10-20)
risk_control.altcoin_max_leverage: altcoin leverage (usually lower than BTC leverage)
risk_control.btc_eth_max_position_value_ratio: max position size as multiple of equity (default 5)
risk_control.altcoin_max_position_value_ratio: default 1
risk_control.max_margin_usage: 0.5-0.95 (default 0.9 = use up to 90% margin)
risk_control.min_position_size: minimum USDT per trade (default 12)
risk_control.min_risk_reward_ratio: minimum profit/loss ratio required (default 3 = 3:1)
risk_control.min_confidence: minimum AI confidence to open position (default 75, range 60-90)
prompt_sections.role_definition: describe the AI's trading persona and goal
prompt_sections.trading_frequency: guidelines on how often to trade
prompt_sections.entry_standards: conditions that must align before entering a position
prompt_sections.decision_process: step-by-step decision-making framework`,
s.handleCreateStrategy)
s.routeWithSchema(protected, "PUT", "/strategies/:id", "Update an existing strategy — WORKFLOW: 1) GET /api/strategies/:id first to read current config 2) Merge your changes into the full config 3) PUT with complete merged config 4) GET again to verify saved values",
`Body: {"name":"<string>","description":"<string>","config":<complete StrategyConfig — same structure as POST /api/strategies>}
IMPORTANT: config is merged with existing values server-side, but always send the complete section you are modifying.
After updating, always GET /api/strategies/:id to verify and show the user actual saved values.`,
s.handleUpdateStrategy)
s.routeWithSchema(protected, "DELETE", "/strategies/:id", "Delete strategy",
`:id = EXACT id from GET /api/strategies. Cannot delete a strategy that is currently assigned to a running trader.`,
s.handleDeleteStrategy)
s.routeWithSchema(protected, "POST", "/strategies/:id/activate", "Mark a strategy as the active strategy for this user",
`:id = EXACT id from GET /api/strategies.
No request body needed. Sets this strategy as is_active=true (and deactivates the previous active strategy).
After activating, create or update a trader with this strategy_id to apply it.`,
s.handleActivateStrategy)
s.routeWithSchema(protected, "POST", "/strategies/:id/duplicate", "Duplicate an existing strategy",
`:id = EXACT id from GET /api/strategies. Creates a copy with " (copy)" appended to the name.`,
s.handleDuplicateStrategy)
// Data for specified trader (using query parameter ?trader_id=xxx)
// IMPORTANT: All ?trader_id= values must be the EXACT "trader_id" field from GET /api/my-traders
s.routeWithSchema(protected, "GET", "/status", "Trader running status",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"is_running":<bool>,"trader_id":"<string>"}`,
s.handleStatus)
s.routeWithSchema(protected, "GET", "/account", "Account balance and equity",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"balance":<float>,"equity":<float>,"unrealized_pnl":<float>,"initial_balance":<float>,"total_return_pct":<float>}`,
s.handleAccount)
s.routeWithSchema(protected, "GET", "/positions", "Current open positions",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: [{"symbol":"<string>","side":"long|short","size":<float>,"entry_price":<float>,"mark_price":<float>,"unrealized_pnl":<float>,"leverage":<int>}]`,
s.handlePositions)
s.routeWithSchema(protected, "GET", "/positions/history", "Closed position history",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
s.handlePositionHistory)
s.routeWithSchema(protected, "GET", "/trades", "Trade records",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
s.handleTrades)
s.routeWithSchema(protected, "GET", "/orders", "All order records",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>`,
s.handleOrders)
s.routeWithSchema(protected, "GET", "/orders/:id/fills", "Order fill details",
`:id = order id from GET /api/orders`,
s.handleOrderFills)
s.routeWithSchema(protected, "GET", "/open-orders", "Open orders currently on exchange",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>`,
s.handleOpenOrders)
s.routeWithSchema(protected, "GET", "/decisions", "AI trading decisions (decision records)",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>&limit=<int, default 20>
Returns: [{"id":"<string>","symbol":"<string>","action":"open_long|open_short|close_long|close_short|hold","confidence":<int>,"reasoning":"<string>","created_at":"<timestamp>"}]`,
s.handleDecisions)
s.routeWithSchema(protected, "GET", "/decisions/latest", "Latest AI decisions (most recent scan results)",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns the most recent AI decision for each symbol analyzed in the last scan cycle.`,
s.handleLatestDecisions)
s.routeWithSchema(protected, "GET", "/statistics", "Trading performance statistics",
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
s.handleStatistics)
}
}
}
// handleHealth Health check
func (s *Server) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": c.Request.Context().Value("time"),
})
}
// handleGetSystemConfig Get system configuration (configuration that client needs to know)
func (s *Server) handleGetSystemConfig(c *gin.Context) {
userCount, _ := s.store.User().Count()
c.JSON(http.StatusOK, gin.H{
"initialized": userCount > 0,
"btc_eth_leverage": 10,
"altcoin_leverage": 5,
})
}
// handleGetServerIP Get server IP address (for whitelist configuration)
func (s *Server) handleGetServerIP(c *gin.Context) {
// Try to get public IP via third-party API
publicIP := getPublicIPFromAPI()
// If third-party API fails, get first public IP from network interface
if publicIP == "" {
publicIP = getPublicIPFromInterface()
}
// If still cannot get it, return error
if publicIP == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get public IP address"})
return
}
c.JSON(http.StatusOK, gin.H{
"public_ip": publicIP,
"message": "Please add this IP address to the whitelist",
})
}
// getPublicIPFromAPI Get public IP via third-party API (IPv4 only)
func getPublicIPFromAPI() string {
// Try multiple public IP query services (IPv4-only endpoints)
services := []string{
"https://api4.ipify.org?format=text", // IPv4 only
"https://ipv4.icanhazip.com", // IPv4 only
"https://v4.ident.me", // IPv4 only
"https://api.ipify.org?format=text", // May return IPv4 or IPv6
}
client := &http.Client{
Timeout: 5 * time.Second,
}
for _, service := range services {
resp, err := client.Get(service)
if err != nil {
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
body := make([]byte, 128)
n, err := resp.Body.Read(body)
if err != nil && err.Error() != "EOF" {
continue
}
ip := strings.TrimSpace(string(body[:n]))
parsedIP := net.ParseIP(ip)
// Verify if it's a valid IPv4 address (not containing ":")
if parsedIP != nil && parsedIP.To4() != nil {
return ip
}
}
}
return ""
}
// getPublicIPFromInterface Get first public IP from network interface
func getPublicIPFromInterface() string {
interfaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range interfaces {
// Skip disabled interfaces and loopback interfaces
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
// Only consider IPv4 addresses
if ip.To4() != nil {
ipStr := ip.String()
// Exclude private IP address ranges
if !isPrivateIP(ip) {
return ipStr
}
}
}
}
return ""
}
// isPrivateIP Determine if it's a private IP address
func isPrivateIP(ip net.IP) bool {
// Private IP address ranges:
// 10.0.0.0/8
// 172.16.0.0/12
// 192.168.0.0/16
privateRanges := []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
}
for _, cidr := range privateRanges {
_, subnet, _ := net.ParseCIDR(cidr)
if subnet.Contains(ip) {
return true
}
}
return false
}
// getTraderFromQuery Get trader from query parameter
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
userID := c.GetString("user_id")
traderID := c.Query("trader_id")
// Ensure user's traders are loaded into memory
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
}
if traderID == "" {
// If no trader_id specified, return first trader for this user
ids := s.traderManager.GetTraderIDs()
if len(ids) == 0 {
return nil, "", fmt.Errorf("No available traders")
}
// Get user's trader list, prioritize returning user's own traders
userTraders, err := s.store.Trader().List(userID)
if err == nil && len(userTraders) > 0 {
traderID = userTraders[0].ID
} else {
traderID = ids[0]
}
}
return s.traderManager, traderID, nil
}
// authMiddleware JWT authentication middleware
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
c.Abort()
return
}
// Check Bearer token format
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
c.Abort()
return
}
tokenString := tokenParts[1]
// Blacklist check
if auth.IsTokenBlacklisted(tokenString) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired, please login again"})
c.Abort()
return
}
// Validate JWT token
claims, err := auth.ValidateJWT(tokenString)
if err != nil {
logger.Errorf("[Auth] Invalid token: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Store user information in context
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}
// Start Start server
func (s *Server) Start() error {
addr := fmt.Sprintf(":%d", s.port)
logger.Infof("🌐 API server starting at http://localhost%s", addr)
logger.Infof("📊 API Documentation:")
logger.Infof(" • GET /api/health - Health check")
logger.Infof(" • GET /api/traders - Public AI trader leaderboard top 50 (no auth required)")
logger.Infof(" • GET /api/competition - Public competition data (no auth required)")
logger.Infof(" • GET /api/top-traders - Top 5 trader data (no auth required, for performance comparison)")
logger.Infof(" • GET /api/equity-history?trader_id=xxx - Public return rate historical data (no auth required, for competition)")
logger.Infof(" • GET /api/equity-history-batch?trader_ids=a,b,c - Batch get historical data (no auth required, performance comparison optimization)")
logger.Infof(" • GET /api/traders/:id/public-config - Public trader config (no auth required, no sensitive info)")
logger.Infof(" • POST /api/traders - Create new AI trader")
logger.Infof(" • DELETE /api/traders/:id - Delete AI trader")
logger.Infof(" • POST /api/traders/:id/start - Start AI trader")
logger.Infof(" • POST /api/traders/:id/stop - Stop AI trader")
logger.Infof(" • GET /api/models - Get AI model config")
logger.Infof(" • PUT /api/models - Update AI model config")
logger.Infof(" • GET /api/exchanges - Get exchange config")
logger.Infof(" • PUT /api/exchanges - Update exchange config")
logger.Infof(" • GET /api/status?trader_id=xxx - Specified trader's system status")
logger.Infof(" • GET /api/account?trader_id=xxx - Specified trader's account info")
logger.Infof(" • GET /api/positions?trader_id=xxx - Specified trader's position list")
logger.Infof(" • GET /api/decisions?trader_id=xxx - Specified trader's decision log")
logger.Infof(" • GET /api/decisions/latest?trader_id=xxx - Specified trader's latest decisions")
logger.Infof(" • GET /api/statistics?trader_id=xxx - Specified trader's statistics")
logger.Infof(" • GET /api/performance?trader_id=xxx - Specified trader's AI learning performance analysis")
logger.Info()
s.httpServer = &http.Server{
Addr: addr,
Handler: s.router,
}
return s.httpServer.ListenAndServe()
}
// Shutdown Gracefully shutdown server
func (s *Server) Shutdown() error {
if s.httpServer == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.httpServer.Shutdown(ctx)
}
// SetTelegramReloadCh sets the channel used to signal the Telegram bot to reload
func (s *Server) SetTelegramReloadCh(ch chan<- struct{}) {
s.telegramReloadCh = ch
}