mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
4f0a922779
Add account reset functionality for users who forgot their login credentials. The reset clears authentication data while preserving wallet private keys and exchange configs, which are automatically adopted by the new account on re-registration to prevent fund loss. - Add POST /api/reset-account endpoint - Add "Forgot account?" button on login page (zh/en/id) - Orphan ai_models and exchanges are re-assigned to new user on register - Onboarding reuses existing claw402 wallet instead of generating new one Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
634 lines
31 KiB
Go
634 lines
31 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
|
|
exchangeAccountStateCache *ExchangeAccountStateCache
|
|
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,
|
|
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
|
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)
|
|
s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount)
|
|
|
|
// 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, "GET", "/exchanges/account-state", "Get connection and balance state for each exchange account",
|
|
`Returns: {"states":{"<exchange_id>":{"status":"ok|disabled|missing_credentials|invalid_credentials|permission_denied|unavailable","display_balance":"<string>","total_equity":<number>,"available_balance":<number>,"asset":"USDT|USDC","checked_at":"<RFC3339>","error_code":"<string>","error_message":"<string>"}}}
|
|
Use this endpoint to show balance and health in the exchange list without depending on traders.`,
|
|
s.handleGetExchangeAccountStates)
|
|
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
|
|
}
|