diff --git a/api/backtest.go b/api/backtest.go index 015c75b3..3adc55d1 100644 --- a/api/backtest.go +++ b/api/backtest.go @@ -12,7 +12,6 @@ import ( "time" "nofx/backtest" - "nofx/decision" "nofx/store" "github.com/gin-gonic/gin" @@ -64,14 +63,6 @@ func (s *Server) handleBacktestStart(c *gin.Context) { if cfg.RunID == "" { cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405") } - cfg.PromptTemplate = strings.TrimSpace(cfg.PromptTemplate) - if cfg.PromptTemplate == "" { - cfg.PromptTemplate = "default" - } - if _, err := decision.GetPromptTemplate(cfg.PromptTemplate); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Prompt template does not exist: %s", cfg.PromptTemplate)}) - return - } cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt) cfg.UserID = normalizeUserID(c.GetString("user_id")) if err := s.hydrateBacktestAIConfig(&cfg); err != nil { diff --git a/api/server.go b/api/server.go index f2139414..773d43a8 100644 --- a/api/server.go +++ b/api/server.go @@ -10,7 +10,6 @@ import ( "nofx/backtest" "nofx/config" "nofx/crypto" - "nofx/decision" "nofx/logger" "nofx/manager" "nofx/store" @@ -99,10 +98,6 @@ func (s *Server) setupRoutes() { api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey) api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData) - // System prompt template management (no authentication required) - api.GET("/prompt-templates", s.handleGetPromptTemplates) - api.GET("/prompt-templates/:name", s.handleGetPromptTemplate) - // Public competition data (no authentication required) api.GET("/traders", s.handlePublicTraderList) api.GET("/competition", s.handlePublicCompetition) @@ -150,7 +145,6 @@ func (s *Server) setupRoutes() { protected.GET("/strategies", s.handleGetStrategies) protected.GET("/strategies/active", s.handleGetActiveStrategy) protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig) - protected.GET("/strategies/templates", s.handleGetPromptTemplates) protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt) protected.POST("/strategies/test-run", s.handleStrategyTestRun) protected.GET("/strategies/:id", s.handleGetStrategy) @@ -553,25 +547,19 @@ func (s *Server) handleCreateTrader(c *gin.Context) { if balanceErr != nil { logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr) } else { - // Extract available balance - supports multiple field name formats - if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - // Binance format: availableBalance (camelCase) - actualBalance = availableBalance - logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance) - } else if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - // Other format: available_balance (snake_case) - actualBalance = availableBalance - logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance) - } else if totalBalance, ok := balanceInfo["totalWalletBalance"].(float64); ok && totalBalance > 0 { - // Binance format: totalWalletBalance (camelCase) - actualBalance = totalBalance - logger.Infof("✓ Queried exchange total balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance) - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - // Other format: balance - actualBalance = totalBalance - logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance) - } else { - logger.Infof("⚠️ Unable to extract available balance from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo) + // Extract total equity (account total value = wallet balance + unrealized PnL) + // Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance + // Note: Must use total_equity (not availableBalance) for accurate P&L calculation + balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} + for _, key := range balanceKeys { + if balance, ok := balanceInfo[key].(float64); ok && balance > 0 { + actualBalance = balance + logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance) + break + } + } + if actualBalance <= 0 { + logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo) } } } @@ -1002,16 +990,18 @@ func (s *Server) handleSyncBalance(c *gin.Context) { return } - // Extract available balance + // Extract total equity (for P&L calculation, we need total account value, not available balance) var actualBalance float64 - if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { - actualBalance = availableBalance - } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { - actualBalance = totalBalance - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get available balance"}) + // Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance + balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} + for _, key := range balanceKeys { + if balance, ok := balanceInfo[key].(float64); ok && balance > 0 { + actualBalance = balance + break + } + } + if actualBalance <= 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"}) return } @@ -1438,6 +1428,14 @@ func (s *Server) handleTraderList(c *gin.Context) { } } + // Get strategy name if strategy_id is set + var strategyName string + if trader.StrategyID != "" { + if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil { + strategyName = strategy.Name + } + } + // Return complete AIModelID (e.g. "admin_deepseek"), don't truncate // Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig) result = append(result, map[string]interface{}{ @@ -1447,6 +1445,8 @@ func (s *Server) handleTraderList(c *gin.Context) { "exchange_id": trader.ExchangeID, "is_running": isRunning, "initial_balance": trader.InitialBalance, + "strategy_id": trader.StrategyID, + "strategy_name": strategyName, }) } @@ -2142,40 +2142,6 @@ func (s *Server) Shutdown() error { return s.httpServer.Shutdown(ctx) } -// handleGetPromptTemplates Get all system prompt template list -func (s *Server) handleGetPromptTemplates(c *gin.Context) { - // Import decision package - templates := decision.GetAllPromptTemplates() - - // Convert to response format - response := make([]map[string]interface{}, 0, len(templates)) - for _, tmpl := range templates { - response = append(response, map[string]interface{}{ - "name": tmpl.Name, - }) - } - - c.JSON(http.StatusOK, gin.H{ - "templates": response, - }) -} - -// handleGetPromptTemplate Get prompt template content by specified name -func (s *Server) handleGetPromptTemplate(c *gin.Context) { - templateName := c.Param("name") - - template, err := decision.GetPromptTemplate(templateName) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Template does not exist: %s", templateName)}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "name": template.Name, - "content": template.Content, - }) -} - // handlePublicTraderList Get public trader list (no authentication required) func (s *Server) handlePublicTraderList(c *gin.Context) { // Get trader information from all users diff --git a/api/strategy.go b/api/strategy.go index 5eec8b92..f56b9204 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -361,13 +361,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) { req.PromptVariant, ) - // Get list of available prompt templates - templateNames := decision.GetAllPromptTemplateNames() - c.JSON(http.StatusOK, gin.H{ - "system_prompt": systemPrompt, - "prompt_variant": req.PromptVariant, - "available_templates": templateNames, + "system_prompt": systemPrompt, + "prompt_variant": req.PromptVariant, "config_summary": gin.H{ "coin_source": req.Config.CoinSource.SourceType, "primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe, @@ -455,7 +451,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { // Build real context (for generating User Prompt) testContext := &decision.Context{ - CurrentTime: time.Now().Format("2006-01-02 15:04:05"), + CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), RuntimeMinutes: 0, CallCount: 1, Account: decision.AccountInfo{ diff --git a/backtest/config.go b/backtest/config.go index 2062f25e..401d95f5 100644 --- a/backtest/config.go +++ b/backtest/config.go @@ -6,6 +6,7 @@ import ( "time" "nofx/market" + "nofx/store" ) // AIConfig defines the AI client configuration used in backtesting. @@ -176,3 +177,61 @@ func validateFillPolicy(policy string) error { return fmt.Errorf("unsupported fill_policy '%s'", policy) } } + +// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation. +// This ensures backtest uses the same StrategyEngine logic as live trading. +func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig { + // Determine primary and longer timeframe from the timeframes list + primaryTF := "5m" + longerTF := "4h" + if len(cfg.Timeframes) > 0 { + primaryTF = cfg.Timeframes[0] + } + if len(cfg.Timeframes) > 1 { + longerTF = cfg.Timeframes[len(cfg.Timeframes)-1] + } + + return &store.StrategyConfig{ + CoinSource: store.CoinSourceConfig{ + SourceType: "static", + StaticCoins: cfg.Symbols, + UseCoinPool: false, + CoinPoolLimit: len(cfg.Symbols), + UseOITop: false, + OITopLimit: 0, + }, + Indicators: store.IndicatorConfig{ + Klines: store.KlineConfig{ + PrimaryTimeframe: primaryTF, + PrimaryCount: 30, + LongerTimeframe: longerTF, + LongerCount: 10, + EnableMultiTimeframe: len(cfg.Timeframes) > 1, + SelectedTimeframes: cfg.Timeframes, + }, + EnableRawKlines: true, + EnableEMA: true, + EnableMACD: true, + EnableRSI: true, + EnableATR: true, + EnableVolume: true, + EnableOI: true, + EnableFundingRate: true, + EMAPeriods: []int{20, 50}, + RSIPeriods: []int{7, 14}, + ATRPeriods: []int{14}, + }, + CustomPrompt: cfg.CustomPrompt, + RiskControl: store.RiskControlConfig{ + MaxPositions: 3, + BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage, + AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage, + BTCETHMaxPositionValueRatio: 5.0, + AltcoinMaxPositionValueRatio: 1.0, + MaxMarginUsage: 0.9, + MinPositionSize: 12, + MinRiskRewardRatio: 3.0, + MinConfidence: 75, + }, + } +} diff --git a/backtest/runner.go b/backtest/runner.go index 761196b1..8c483ecb 100644 --- a/backtest/runner.go +++ b/backtest/runner.go @@ -31,9 +31,10 @@ const ( // Runner encapsulates the lifecycle of a single backtest run. type Runner struct { - cfg BacktestConfig - feed *DataFeed - account *BacktestAccount + cfg BacktestConfig + feed *DataFeed + account *BacktestAccount + strategyEngine *decision.StrategyEngine decisionLogDir string mcpClient mcp.AIClient @@ -115,10 +116,15 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) { aiCache = cache } + // Create strategy engine from backtest config for unified prompt generation + strategyConfig := cfg.ToStrategyConfig() + strategyEngine := decision.NewStrategyEngine(strategyConfig) + r := &Runner{ cfg: cfg, feed: feed, account: account, + strategyEngine: strategyEngine, decisionLogDir: dLogDir, mcpClient: client, status: RunStateCreated, @@ -492,7 +498,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000) ctx := &decision.Context{ - CurrentTime: time.UnixMilli(ts).UTC().Format(time.RFC3339), + CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"), RuntimeMinutes: runtime, CallCount: callCount, Account: accountInfo, @@ -503,6 +509,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da MultiTFMarket: multiTF, BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage, AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage, + Timeframes: r.cfg.Timeframes, } record := &store.DecisionRecord{ @@ -537,12 +544,13 @@ func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecision, error) { var lastErr error for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ { - fd, err := decision.GetFullDecisionWithCustomPrompt( + // Use GetFullDecisionWithStrategy with the pre-configured strategy engine + // This ensures backtest uses the same unified prompt generation as live trading + fd, err := decision.GetFullDecisionWithStrategy( ctx, r.mcpClient, - r.cfg.CustomPrompt, - r.cfg.OverrideBasePrompt, - r.cfg.PromptTemplate, + r.strategyEngine, + r.cfg.PromptVariant, ) if err == nil { return fd, nil diff --git a/config.json.example b/config.json.example deleted file mode 100644 index 50dba709..00000000 --- a/config.json.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "_说明": "此文件仅供参考,系统不会读取此文件。所有配置从 .env 文件加载。", - - "_env配置说明": { - "JWT_SECRET": "JWT密钥,必须设置", - "REGISTRATION_ENABLED": "是否允许注册,true/false", - "API_SERVER_PORT": "API服务器端口,默认8080", - "DEEPSEEK_API_KEY": "DeepSeek API Key(回测用)" - }, - - "_数据库配置说明": { - "traders表": "交易员配置,包含杠杆、扫描间隔等", - "strategies表": "策略配置,包含AI500 API URL、OI Top API URL等", - "ai_models表": "AI模型配置", - "exchanges表": "交易所配置" - } -} diff --git a/data/nofx.db b/data/nofx.db new file mode 100644 index 00000000..e69de29b diff --git a/decision/engine.go b/decision/engine.go index 3d96bc50..1875ba3c 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -3,20 +3,24 @@ package decision import ( "encoding/json" "fmt" + "io" + "net/http" "nofx/logger" - "math" "nofx/market" "nofx/mcp" "nofx/pool" + "nofx/store" "regexp" "strings" "time" ) -// Pre-compiled regular expressions (performance optimization: avoid recompiling on each call) +// ============================================================================ +// Pre-compiled regular expressions (performance optimization) +// ============================================================================ + var ( // Safe regex: precisely match ```json code blocks - // Use backtick + concatenation to avoid escape issues reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```") reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`) reArrayHead = regexp.MustCompile(`^\[\s*\{`) @@ -28,6 +32,10 @@ var ( reDecisionTag = regexp.MustCompile(`(?s)(.*?)`) ) +// ============================================================================ +// Type Definitions +// ============================================================================ + // PositionInfo position information type PositionInfo struct { Symbol string `json:"symbol"` @@ -86,13 +94,15 @@ type TradingStats struct { // RecentOrder recently completed order (for AI input) type RecentOrder struct { - Symbol string `json:"symbol"` // Trading pair - Side string `json:"side"` // long/short - EntryPrice float64 `json:"entry_price"` // Entry price - ExitPrice float64 `json:"exit_price"` // Exit price - RealizedPnL float64 `json:"realized_pnl"` // Realized profit/loss - PnLPct float64 `json:"pnl_pct"` // Profit/loss percentage - FilledAt string `json:"filled_at"` // Fill time + Symbol string `json:"symbol"` // Trading pair + Side string `json:"side"` // long/short + EntryPrice float64 `json:"entry_price"` // Entry price + ExitPrice float64 `json:"exit_price"` // Exit price + RealizedPnL float64 `json:"realized_pnl"` // Realized profit/loss + PnLPct float64 `json:"pnl_pct"` // Profit/loss percentage + EntryTime string `json:"entry_time"` // Entry time + ExitTime string `json:"exit_time"` // Exit time + HoldDuration string `json:"hold_duration"` // Hold duration, e.g. "2h30m" } // Context trading context (complete information passed to AI) @@ -104,14 +114,15 @@ type Context struct { Positions []PositionInfo `json:"positions"` CandidateCoins []CandidateCoin `json:"candidate_coins"` PromptVariant string `json:"prompt_variant,omitempty"` - TradingStats *TradingStats `json:"trading_stats,omitempty"` // Trading statistics - RecentOrders []RecentOrder `json:"recent_orders,omitempty"` // Recently completed orders (10) - MarketDataMap map[string]*market.Data `json:"-"` // Not serialized, but used internally + TradingStats *TradingStats `json:"trading_stats,omitempty"` + RecentOrders []RecentOrder `json:"recent_orders,omitempty"` + MarketDataMap map[string]*market.Data `json:"-"` MultiTFMarket map[string]map[string]*market.Data `json:"-"` - OITopDataMap map[string]*OITopData `json:"-"` // OI Top data mapping - QuantDataMap map[string]*QuantData `json:"-"` // Quantitative data mapping (fund flow, position changes) - BTCETHLeverage int `json:"-"` // BTC/ETH leverage multiplier (read from config) - AltcoinLeverage int `json:"-"` // Altcoin leverage multiplier (read from config) + OITopDataMap map[string]*OITopData `json:"-"` + QuantDataMap map[string]*QuantData `json:"-"` + BTCETHLeverage int `json:"-"` + AltcoinLeverage int `json:"-"` + Timeframes []string `json:"-"` } // Decision AI trading decision @@ -133,33 +144,94 @@ type Decision struct { // FullDecision AI's complete decision (including chain of thought) type FullDecision struct { - SystemPrompt string `json:"system_prompt"` // System prompt (system prompt sent to AI) - UserPrompt string `json:"user_prompt"` // Input prompt sent to AI - CoTTrace string `json:"cot_trace"` // Chain of thought analysis (AI output) - Decisions []Decision `json:"decisions"` // Specific decision list - RawResponse string `json:"raw_response"` // Raw AI response (for debugging when parsing fails) - Timestamp time.Time `json:"timestamp"` - // AIRequestDurationMs records AI API call duration (milliseconds) for troubleshooting latency issues - AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"` + SystemPrompt string `json:"system_prompt"` + UserPrompt string `json:"user_prompt"` + CoTTrace string `json:"cot_trace"` + Decisions []Decision `json:"decisions"` + RawResponse string `json:"raw_response"` + Timestamp time.Time `json:"timestamp"` + AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"` } +// QuantData quantitative data structure (fund flow, position changes, price changes) +type QuantData struct { + Symbol string `json:"symbol"` + Price float64 `json:"price"` + Netflow *NetflowData `json:"netflow,omitempty"` + OI map[string]*OIData `json:"oi,omitempty"` + PriceChange map[string]float64 `json:"price_change,omitempty"` +} + +type NetflowData struct { + Institution *FlowTypeData `json:"institution,omitempty"` + Personal *FlowTypeData `json:"personal,omitempty"` +} + +type FlowTypeData struct { + Future map[string]float64 `json:"future,omitempty"` + Spot map[string]float64 `json:"spot,omitempty"` +} + +type OIData struct { + CurrentOI float64 `json:"current_oi"` + NetLong float64 `json:"net_long"` + NetShort float64 `json:"net_short"` + Delta map[string]*OIDeltaData `json:"delta,omitempty"` +} + +type OIDeltaData struct { + OIDelta float64 `json:"oi_delta"` + OIDeltaValue float64 `json:"oi_delta_value"` + OIDeltaPercent float64 `json:"oi_delta_percent"` +} + +// ============================================================================ +// StrategyEngine - Core Strategy Execution Engine +// ============================================================================ + +// StrategyEngine strategy execution engine +type StrategyEngine struct { + config *store.StrategyConfig +} + +// NewStrategyEngine creates strategy execution engine +func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { + return &StrategyEngine{config: config} +} + +// GetRiskControlConfig gets risk control configuration +func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig { + return e.config.RiskControl +} + +// GetConfig gets complete strategy configuration +func (e *StrategyEngine) GetConfig() *store.StrategyConfig { + return e.config +} + +// ============================================================================ +// Entry Functions - Main API +// ============================================================================ + // GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions) +// Uses default strategy configuration - for production use GetFullDecisionWithStrategy with explicit config func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) { - return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") + defaultConfig := store.GetDefaultStrategyConfig("en") + engine := NewStrategyEngine(&defaultConfig) + return GetFullDecisionWithStrategy(ctx, mcpClient, engine, "") } -// GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (new version: strategy-driven) -// Key: uses strategy-configured timeframes to fetch market data, consistent with api/strategy.go test run logic +// GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (unified prompt generation) func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) { if ctx == nil { return nil, fmt.Errorf("context is nil") } if engine == nil { - // If no strategy engine, fallback to default behavior - return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") + defaultConfig := store.GetDefaultStrategyConfig("en") + engine = NewStrategyEngine(&defaultConfig) } - // 1. Fetch market data using strategy config (key: use multiple timeframes) + // 1. Fetch market data using strategy config if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { return nil, fmt.Errorf("failed to fetch market data: %w", err) @@ -169,7 +241,6 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S // Ensure OITopDataMap is initialized if ctx.OITopDataMap == nil { ctx.OITopDataMap = make(map[string]*OITopData) - // Load OI Top data oiPositions, err := pool.GetOITopPositions() if err == nil { for _, pos := range oiPositions { @@ -189,7 +260,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S riskConfig := engine.GetRiskControlConfig() systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant) - // 3. Build User Prompt using strategy engine (including multi-timeframe data) + // 3. Build User Prompt using strategy engine userPrompt := engine.BuildUserPrompt(ctx) // 4. Call AI API @@ -213,7 +284,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S decision.SystemPrompt = systemPrompt decision.UserPrompt = userPrompt decision.AIRequestDurationMs = aiCallDuration.Milliseconds() - decision.RawResponse = aiResponse // Save raw response for debugging + decision.RawResponse = aiResponse } if err != nil { @@ -223,13 +294,15 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S return decision, nil } +// ============================================================================ +// Market Data Fetching +// ============================================================================ + // fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes) -// Fully implemented according to api/strategy.go handleStrategyTestRun logic func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { config := engine.GetConfig() ctx.MarketDataMap = make(map[string]*market.Data) - // Get timeframe configuration (fully consistent with api/strategy.go logic) timeframes := config.Indicators.Klines.SelectedTimeframes primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe klineCount := config.Indicators.Klines.PrimaryCount @@ -264,18 +337,15 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { ctx.MarketDataMap[pos.Symbol] = data } - // 2. Fetch data for all candidate coins (fully consistent with api/strategy.go, no quantity limit) - // Position coin set (used to determine whether to skip OI check) + // 2. Fetch data for all candidate coins positionSymbols := make(map[string]bool) for _, pos := range ctx.Positions { positionSymbols[pos.Symbol] = true } - // OI liquidity filter threshold (million USD) const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value for _, coin := range ctx.CandidateCoins { - // Skip already fetched position coins if _, exists := ctx.MarketDataMap[coin.Symbol]; exists { continue } @@ -286,16 +356,14 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { continue } - // Liquidity filter: skip coins with OI value below threshold (both long and short) - // But existing positions must be retained (need to decide whether to close) + // Liquidity filter isExistingPosition := positionSymbols[coin.Symbol] if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { - // Calculate OI value (USD) = OI quantity × current price oiValue := data.OpenInterest.Latest * data.CurrentPrice - oiValueInMillions := oiValue / 1_000_000 // Convert to million USD + oiValueInMillions := oiValue / 1_000_000 if oiValueInMillions < minOIThresholdMillions { - logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin [OI:%.0f × Price:%.4f]", - coin.Symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) + logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin", + coin.Symbol, oiValueInMillions, minOIThresholdMillions) continue } } @@ -303,292 +371,467 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { ctx.MarketDataMap[coin.Symbol] = data } - logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins (low liquidity coins filtered)", len(ctx.MarketDataMap)) + logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap)) return nil } -// GetFullDecisionWithCustomPrompt gets AI's complete trading decision (supports custom prompt and template selection) -func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) { - if ctx == nil { - return nil, fmt.Errorf("context is nil") +// ============================================================================ +// Candidate Coins +// ============================================================================ + +// GetCandidateCoins gets candidate coins based on strategy configuration +func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { + var candidates []CandidateCoin + symbolSources := make(map[string][]string) + + coinSource := e.config.CoinSource + + if coinSource.CoinPoolAPIURL != "" { + pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) + } + if coinSource.OITopAPIURL != "" { + pool.SetOITopAPI(coinSource.OITopAPIURL) } - // 1. Fetch market data for all coins (if already provided by upper layer, no need to re-fetch) - if len(ctx.MarketDataMap) == 0 { - if err := fetchMarketDataForContext(ctx); err != nil { - return nil, fmt.Errorf("failed to fetch market data: %w", err) + switch coinSource.SourceType { + case "static": + for _, symbol := range coinSource.StaticCoins { + symbol = market.Normalize(symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"static"}, + }) } - } else if ctx.OITopDataMap == nil { - // Ensure OI data mapping is initialized to avoid null pointer access later - ctx.OITopDataMap = make(map[string]*OITopData) + return candidates, nil + + case "coinpool": + return e.getCoinPoolCoins(coinSource.CoinPoolLimit) + + case "oi_top": + return e.getOITopCoins(coinSource.OITopLimit) + + case "mixed": + if coinSource.UseCoinPool { + poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) + if err != nil { + logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err) + } else { + for _, coin := range poolCoins { + symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500") + } + } + } + + if coinSource.UseOITop { + oiCoins, err := e.getOITopCoins(coinSource.OITopLimit) + if err != nil { + logger.Infof("⚠️ Failed to get OI Top: %v", err) + } else { + for _, coin := range oiCoins { + symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top") + } + } + } + + for _, symbol := range coinSource.StaticCoins { + symbol = market.Normalize(symbol) + if _, exists := symbolSources[symbol]; !exists { + symbolSources[symbol] = []string{"static"} + } else { + symbolSources[symbol] = append(symbolSources[symbol], "static") + } + } + + for symbol, sources := range symbolSources { + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: sources, + }) + } + return candidates, nil + + default: + return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType) } - - // 2. Build System Prompt (fixed rules) and User Prompt (dynamic data) - systemPrompt := buildSystemPromptWithCustom( - ctx.Account.TotalEquity, - ctx.BTCETHLeverage, - ctx.AltcoinLeverage, - customPrompt, - overrideBase, - templateName, - ctx.PromptVariant, - ) - userPrompt := buildUserPrompt(ctx) - - // 3. Call AI API (using system + user prompt) - aiCallStart := time.Now() - aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt) - aiCallDuration := time.Since(aiCallStart) - if err != nil { - return nil, fmt.Errorf("AI API call failed: %w", err) - } - - // 4. Parse AI response - decision, err := parseFullDecisionResponse(aiResponse, ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage) - - // Save SystemPrompt and UserPrompt regardless of error (for debugging and troubleshooting unexecuted decisions) - if decision != nil { - decision.Timestamp = time.Now() - decision.SystemPrompt = systemPrompt // Save system prompt - decision.UserPrompt = userPrompt // Save input prompt - decision.AIRequestDurationMs = aiCallDuration.Milliseconds() - decision.RawResponse = aiResponse // Save raw response for debugging - } - - if err != nil { - return decision, fmt.Errorf("failed to parse AI response: %w", err) - } - - return decision, nil } -// fetchMarketDataForContext fetches market data and OI data for all coins in context -func fetchMarketDataForContext(ctx *Context) error { - ctx.MarketDataMap = make(map[string]*market.Data) - ctx.OITopDataMap = make(map[string]*OITopData) - - // Collect all symbols that need data - symbolSet := make(map[string]bool) - - // 1. Prioritize fetching position coin data (this is required) - for _, pos := range ctx.Positions { - symbolSet[pos.Symbol] = true +func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) { + if limit <= 0 { + limit = 30 } - // 2. Candidate coin count dynamically adjusted based on account status - maxCandidates := calculateMaxCandidates(ctx) - for i, coin := range ctx.CandidateCoins { - if i >= maxCandidates { + symbols, err := pool.GetTopRatedCoins(limit) + if err != nil { + return nil, err + } + + var candidates []CandidateCoin + for _, symbol := range symbols { + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"ai500"}, + }) + } + return candidates, nil +} + +func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { + if limit <= 0 { + limit = 20 + } + + positions, err := pool.GetOITopPositions() + if err != nil { + return nil, err + } + + var candidates []CandidateCoin + for i, pos := range positions { + if i >= limit { break } - symbolSet[coin.Symbol] = true + symbol := market.Normalize(pos.Symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"oi_top"}, + }) } + return candidates, nil +} - // Fetch market data concurrently - // Position coin set (used to determine whether to skip OI check) - positionSymbols := make(map[string]bool) - for _, pos := range ctx.Positions { - positionSymbols[pos.Symbol] = true - } +// ============================================================================ +// External & Quant Data +// ============================================================================ - for symbol := range symbolSet { - data, err := market.Get(symbol) +// FetchMarketData fetches market data based on strategy configuration +func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) { + return market.Get(symbol) +} + +// FetchExternalData fetches external data sources +func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) { + externalData := make(map[string]interface{}) + + for _, source := range e.config.Indicators.ExternalDataSources { + data, err := e.fetchSingleExternalSource(source) if err != nil { - // Single coin failure doesn't affect overall, just log error + logger.Infof("⚠️ Failed to fetch external data source [%s]: %v", source.Name, err) continue } - - // Liquidity filter: skip coins with OI value below threshold (both long and short) - // OI value = OI quantity × current price - // But existing positions must be retained (need to decide whether to close) - // OI threshold configuration: users can adjust based on risk preference - const minOIThresholdMillions = 15.0 // Adjustable: 15M(conservative) / 10M(balanced) / 8M(loose) / 5M(aggressive) - - isExistingPosition := positionSymbols[symbol] - if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { - // Calculate OI value (USD) = OI quantity × current price - oiValue := data.OpenInterest.Latest * data.CurrentPrice - oiValueInMillions := oiValue / 1_000_000 // Convert to million USD - if oiValueInMillions < minOIThresholdMillions { - logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin [OI:%.0f × Price:%.4f]", - symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) - continue - } - } - - ctx.MarketDataMap[symbol] = data + externalData[source.Name] = data } - // Load OI Top data (doesn't affect main flow) - oiPositions, err := pool.GetOITopPositions() - if err == nil { - for _, pos := range oiPositions { - // Normalize symbol matching - symbol := pos.Symbol - ctx.OITopDataMap[symbol] = &OITopData{ - Rank: pos.Rank, - OIDeltaPercent: pos.OIDeltaPercent, - OIDeltaValue: pos.OIDeltaValue, - PriceDeltaPercent: pos.PriceDeltaPercent, - NetLong: pos.NetLong, - NetShort: pos.NetShort, - } - } - } - - return nil + return externalData, nil } -// calculateMaxCandidates calculates the number of candidate coins to analyze based on account status -func calculateMaxCandidates(ctx *Context) int { - // Important: limit candidate coin count to avoid prompt being too large - // Dynamically adjust based on position count: fewer positions allow analyzing more candidates - const ( - maxCandidatesWhenEmpty = 30 // Max 30 candidates when no positions - maxCandidatesWhenHolding1 = 25 // Max 25 candidates when holding 1 position - maxCandidatesWhenHolding2 = 20 // Max 20 candidates when holding 2 positions - maxCandidatesWhenHolding3 = 15 // Max 15 candidates when holding 3 positions (avoid prompt being too large) - ) - - positionCount := len(ctx.Positions) - var maxCandidates int - - switch positionCount { - case 0: - maxCandidates = maxCandidatesWhenEmpty - case 1: - maxCandidates = maxCandidatesWhenHolding1 - case 2: - maxCandidates = maxCandidatesWhenHolding2 - default: // 3+ positions - maxCandidates = maxCandidatesWhenHolding3 +func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) { + client := &http.Client{ + Timeout: time.Duration(source.RefreshSecs) * time.Second, + } + if client.Timeout == 0 { + client.Timeout = 30 * time.Second } - // Return the smaller value between actual candidate count and max limit - return min(len(ctx.CandidateCoins), maxCandidates) -} - -// buildSystemPromptWithCustom builds System Prompt with custom content -func buildSystemPromptWithCustom(accountEquity float64, btcEthLeverage, altcoinLeverage int, customPrompt string, overrideBase bool, templateName string, variant string) string { - // If override base prompt and has custom prompt, only use custom prompt - if overrideBase && customPrompt != "" { - return customPrompt - } - - // Get base prompt (using specified template) - basePrompt := buildSystemPrompt(accountEquity, btcEthLeverage, altcoinLeverage, templateName, variant) - - // If no custom prompt, directly return base prompt - if customPrompt == "" { - return basePrompt - } - - // Add custom prompt section to base prompt - var sb strings.Builder - sb.WriteString(basePrompt) - sb.WriteString("\n\n") - sb.WriteString("# 📌 Personalized Trading Strategy\n\n") - sb.WriteString(customPrompt) - sb.WriteString("\n\n") - sb.WriteString("Note: The above personalized strategy is a supplement to basic rules and cannot violate basic risk control principles.\n") - - return sb.String() -} - -// buildSystemPrompt builds System Prompt (using template + dynamic parts) -func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int, templateName string, variant string) string { - var sb strings.Builder - - // 1. Load prompt template (core trading strategy part) - if templateName == "" { - templateName = "default" // Default to using default template - } - - template, err := GetPromptTemplate(templateName) + req, err := http.NewRequest(source.Method, source.URL, nil) if err != nil { - // If template doesn't exist, log error and use default - logger.Infof("⚠️ Prompt template '%s' doesn't exist, using default: %v", templateName, err) - template, err = GetPromptTemplate("default") - if err != nil { - // If even default doesn't exist, use built-in simplified version - logger.Infof("❌ Cannot load any prompt template, using built-in simplified version") - sb.WriteString("You are a professional cryptocurrency trading AI. Please make trading decisions based on market data.\n\n") - } else { - sb.WriteString(template.Content) - sb.WriteString("\n\n") - } - } else { - sb.WriteString(template.Content) - sb.WriteString("\n\n") + return nil, err } - // 2. Trading mode variants + for k, v := range source.Headers { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + if source.DataPath != "" { + result = extractJSONPath(result, source.DataPath) + } + + return result, nil +} + +func extractJSONPath(data interface{}, path string) interface{} { + parts := strings.Split(path, ".") + current := data + + for _, part := range parts { + if m, ok := current.(map[string]interface{}); ok { + current = m[part] + } else { + return nil + } + } + + return current +} + +// FetchQuantData fetches quantitative data for a single coin +func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { + if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { + return nil, nil + } + + apiURL := e.config.Indicators.QuantDataAPIURL + url := strings.Replace(apiURL, "{symbol}", symbol, -1) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var apiResp struct { + Code int `json:"code"` + Data *QuantData `json:"data"` + } + + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + if apiResp.Code != 0 { + return nil, fmt.Errorf("API returned error code: %d", apiResp.Code) + } + + return apiResp.Data, nil +} + +// FetchQuantDataBatch batch fetches quantitative data +func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData { + result := make(map[string]*QuantData) + + if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { + return result + } + + for _, symbol := range symbols { + data, err := e.FetchQuantData(symbol) + if err != nil { + logger.Infof("⚠️ Failed to fetch quantitative data for %s: %v", symbol, err) + continue + } + if data != nil { + result[symbol] = data + } + } + + return result +} + +// ============================================================================ +// Prompt Building - System Prompt +// ============================================================================ + +// BuildSystemPrompt builds System Prompt according to strategy configuration +func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string { + var sb strings.Builder + riskControl := e.config.RiskControl + promptSections := e.config.PromptSections + + // 1. Role definition (editable) + if promptSections.RoleDefinition != "" { + sb.WriteString(promptSections.RoleDefinition) + sb.WriteString("\n\n") + } else { + sb.WriteString("# You are a professional cryptocurrency trading AI\n\n") + sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n") + } + + // 2. Trading mode variant switch strings.ToLower(strings.TrimSpace(variant)) { case "aggressive": - sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥70\n- Allow higher positions, but must strictly set stop loss and explain profit/loss ratio\n\n") + sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n") case "conservative": - sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize holding cash, must pause for multiple periods after consecutive losses\n\n") + sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n") case "scalping": - sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, target smaller profits but require swift action\n- If price doesn't move as expected within two bars, immediately reduce position or stop loss\n\n") + sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n") } // 3. Hard constraints (risk control) + btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio + if btcEthPosValueRatio <= 0 { + btcEthPosValueRatio = 5.0 + } + altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio + if altcoinPosValueRatio <= 0 { + altcoinPosValueRatio = 1.0 + } + sb.WriteString("# Hard Constraints (Risk Control)\n\n") - sb.WriteString("1. Risk/reward ratio: Must be ≥ 1:3 (risk 1% to earn 3%+ profit)\n") - sb.WriteString("2. Max positions: 3 coins (quality > quantity)\n") - sb.WriteString(fmt.Sprintf("3. Single coin position: Altcoins %.0f-%.0f U | BTC/ETH %.0f-%.0f U\n", - accountEquity*0.8, accountEquity*1.5, accountEquity*5, accountEquity*10)) - sb.WriteString(fmt.Sprintf("4. Leverage limit: **Altcoins max %dx leverage** | **BTC/ETH max %dx leverage**\n", altcoinLeverage, btcEthLeverage)) - sb.WriteString("5. Margin usage rate ≤ 90%%\n") - sb.WriteString("6. Opening amount: Recommended ≥12 USDT (exchange minimum notional value 10 USDT + safety margin)\n\n") + sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n") + sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions)) + sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n", + accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio)) + sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", + accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio)) + sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100)) + sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize)) - // 4. Trading frequency and signal quality - sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n") - sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n") - sb.WriteString("- >2 trades/hour = overtrading\n") - sb.WriteString("- Single position holding time ≥30-60 minutes\n") - sb.WriteString("If you find yourself trading every period → standards too low; if closing position <30 minutes → too impatient.\n\n") + sb.WriteString("## AI GUIDED (Recommended, you should follow):\n") + sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n", + riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage)) + sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio)) + sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence)) - sb.WriteString("# 🎯 Opening Standards (Strict)\n\n") - sb.WriteString("Only open positions when multiple signals resonate. You have:\n") - sb.WriteString("- 3-minute price series + 4-hour K-line series\n") - sb.WriteString("- EMA20 / MACD / RSI7 / RSI14 indicator series\n") - sb.WriteString("- Volume, open interest (OI), funding rate and other fund flow series\n") - sb.WriteString("- AI500 / OI_Top screening tags (if any)\n\n") - sb.WriteString("Freely use any effective analysis method, but **confidence ≥75** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n") + // 4. Trading frequency (editable) + if promptSections.TradingFrequency != "" { + sb.WriteString(promptSections.TradingFrequency) + sb.WriteString("\n\n") + } else { + sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n") + sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n") + sb.WriteString("- >2 trades/hour = Overtrading\n") + sb.WriteString("- Single position hold time ≥ 30-60 minutes\n") + sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n") + } - // 5. Decision process tips - sb.WriteString("# 📋 Decision Process\n\n") - sb.WriteString("1. Check positions → Should take profit/stop loss?\n") - sb.WriteString("2. Scan candidate coins + multi-timeframe → Any strong signals?\n") - sb.WriteString("3. Write reasoning chain first, then output structured JSON\n\n") + // 5. Entry standards (editable) + if promptSections.EntryStandards != "" { + sb.WriteString(promptSections.EntryStandards) + sb.WriteString("\n\nYou have the following indicator data:\n") + e.writeAvailableIndicators(&sb) + sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence)) + } else { + sb.WriteString("# 🎯 Entry Standards (Strict)\n\n") + sb.WriteString("Only open positions when multiple signals resonate. You have:\n") + e.writeAvailableIndicators(&sb) + sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence)) + } - // 7. Output format - dynamically generated + // 6. Decision process (editable) + if promptSections.DecisionProcess != "" { + sb.WriteString(promptSections.DecisionProcess) + sb.WriteString("\n\n") + } else { + sb.WriteString("# 📋 Decision Process\n\n") + sb.WriteString("1. Check positions → Should we take profit/stop-loss\n") + sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n") + sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n") + } + + // 7. Output format sb.WriteString("# Output Format (Strictly Follow)\n\n") - sb.WriteString("**Must use XML tags and to separate reasoning chain and decision JSON, avoid parsing errors**\n\n") + sb.WriteString("**Must use XML tags and to separate chain of thought and decision JSON, avoiding parsing errors**\n\n") sb.WriteString("## Format Requirements\n\n") sb.WriteString("\n") - sb.WriteString("Your reasoning chain analysis...\n") - sb.WriteString("- Concisely analyze your thought process \n") + sb.WriteString("Your chain of thought analysis...\n") + sb.WriteString("- Briefly analyze your thinking process \n") sb.WriteString("\n\n") sb.WriteString("\n") sb.WriteString("Step 2: JSON decision array\n\n") sb.WriteString("```json\n[\n") - sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", btcEthLeverage, accountEquity*5)) + sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", + riskControl.BTCETHMaxLeverage, accountEquity*5)) sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") sb.WriteString("]\n```\n") sb.WriteString("\n\n") - sb.WriteString("## Field Descriptions\n\n") + sb.WriteString("## Field Description\n\n") sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") - sb.WriteString("- `confidence`: 0-100 (opening recommended ≥75)\n") - sb.WriteString("- Required for opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") + sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence)) + sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n") + // 8. Custom Prompt + if e.config.CustomPrompt != "" { + sb.WriteString("# 📌 Personalized Trading Strategy\n\n") + sb.WriteString(e.config.CustomPrompt) + sb.WriteString("\n\n") + sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n") + } + return sb.String() } -// buildUserPrompt builds User Prompt (dynamic data) -func buildUserPrompt(ctx *Context) string { +func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) { + indicators := e.config.Indicators + kline := indicators.Klines + + sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe)) + if kline.EnableMultiTimeframe { + sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe)) + } else { + sb.WriteString("\n") + } + + if indicators.EnableEMA { + sb.WriteString("- EMA indicators") + if len(indicators.EMAPeriods) > 0 { + sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableMACD { + sb.WriteString("- MACD indicators\n") + } + + if indicators.EnableRSI { + sb.WriteString("- RSI indicators") + if len(indicators.RSIPeriods) > 0 { + sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableATR { + sb.WriteString("- ATR indicators") + if len(indicators.ATRPeriods) > 0 { + sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableVolume { + sb.WriteString("- Volume data\n") + } + + if indicators.EnableOI { + sb.WriteString("- Open Interest (OI) data\n") + } + + if indicators.EnableFundingRate { + sb.WriteString("- Funding rate\n") + } + + if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop { + sb.WriteString("- AI500 / OI_Top filter tags (if available)\n") + } + + if indicators.EnableQuantData { + sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n") + } +} + +// ============================================================================ +// Prompt Building - User Prompt +// ============================================================================ + +// BuildUserPrompt builds User Prompt based on strategy configuration +func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { var sb strings.Builder // System status @@ -602,7 +845,7 @@ func buildUserPrompt(ctx *Context) string { btcData.CurrentMACD, btcData.CurrentRSI7)) } - // Account + // Account information sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %d\n\n", ctx.Account.TotalEquity, ctx.Account.AvailableBalance, @@ -611,75 +854,34 @@ func buildUserPrompt(ctx *Context) string { ctx.Account.MarginUsedPct, ctx.Account.PositionCount)) - // Positions (complete market data) + // Position information if len(ctx.Positions) > 0 { sb.WriteString("## Current Positions\n") for i, pos := range ctx.Positions { - // Calculate holding duration - holdingDuration := "" - if pos.UpdateTime > 0 { - durationMs := time.Now().UnixMilli() - pos.UpdateTime - durationMin := durationMs / (1000 * 60) // Convert to minutes - if durationMin < 60 { - holdingDuration = fmt.Sprintf(" | Holding %d mins", durationMin) - } else { - durationHour := durationMin / 60 - durationMinRemainder := durationMin % 60 - holdingDuration = fmt.Sprintf(" | Holding %dh %dm", durationHour, durationMinRemainder) - } - } - - // Calculate position value - positionValue := math.Abs(pos.Quantity) * pos.MarkPrice - - sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Value %.2f USDT | PnL %+.2f%% | PnL Amount %+.2f USDT | Peak PnL %.2f%% | Leverage %dx | Margin %.0f | Liq %.4f%s\n\n", - i+1, pos.Symbol, strings.ToUpper(pos.Side), - pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct, - pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration)) - - // Use FormatMarketData to output complete market data - if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok { - sb.WriteString(market.Format(marketData)) - sb.WriteString("\n") - } + sb.WriteString(e.formatPositionInfo(i+1, pos, ctx)) } } else { sb.WriteString("Current Positions: None\n\n") } - // Trading statistics (if any) - if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { - sb.WriteString("## Historical Trading Statistics\n") - sb.WriteString(fmt.Sprintf("Total Trades: %d | Win Rate: %.1f%% | Profit Factor: %.2f | Sharpe Ratio: %.2f\n", - ctx.TradingStats.TotalTrades, - ctx.TradingStats.WinRate, - ctx.TradingStats.ProfitFactor, - ctx.TradingStats.SharpeRatio)) - sb.WriteString(fmt.Sprintf("Total PnL: %.2f USDT | Avg Win: %.2f | Avg Loss: %.2f | Max Drawdown: %.1f%%\n\n", - ctx.TradingStats.TotalPnL, - ctx.TradingStats.AvgWin, - ctx.TradingStats.AvgLoss, - ctx.TradingStats.MaxDrawdownPct)) - } - - // Recently completed orders (if any) + // Recently completed orders if len(ctx.RecentOrders) > 0 { - sb.WriteString("## Recently Completed Trades\n") + sb.WriteString("## Recent Completed Trades\n") for i, order := range ctx.RecentOrders { resultStr := "Profit" if order.RealizedPnL < 0 { resultStr = "Loss" } - sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s\n", + sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s→%s (%s)\n", i+1, order.Symbol, order.Side, order.EntryPrice, order.ExitPrice, resultStr, order.RealizedPnL, order.PnLPct, - order.FilledAt)) + order.EntryTime, order.ExitTime, order.HoldDuration)) } sb.WriteString("\n") } - // Candidate coins (complete market data) + // Candidate coins sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap))) displayedCount := 0 for _, coin := range ctx.CandidateCoins { @@ -689,32 +891,362 @@ func buildUserPrompt(ctx *Context) string { } displayedCount++ - sourceTags := "" - if len(coin.Sources) > 1 { - sourceTags = " (AI500+OI_Top dual signal)" - } else if len(coin.Sources) == 1 && coin.Sources[0] == "oi_top" { - sourceTags = " (OI_Top growing)" - } - - // Use FormatMarketData to output complete market data + sourceTags := e.formatCoinSourceTag(coin.Sources) sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags)) - sb.WriteString(market.Format(marketData)) + sb.WriteString(e.formatMarketData(marketData)) + + if ctx.QuantDataMap != nil { + if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant { + sb.WriteString(e.formatQuantData(quantData)) + } + } sb.WriteString("\n") } sb.WriteString("\n") sb.WriteString("---\n\n") - sb.WriteString("Now please analyze and output decision (reasoning chain + JSON)\n") + sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n") return sb.String() } -// parseFullDecisionResponse parses AI's complete decision response +func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string { + var sb strings.Builder + + holdingDuration := "" + if pos.UpdateTime > 0 { + durationMs := time.Now().UnixMilli() - pos.UpdateTime + durationMin := durationMs / (1000 * 60) + if durationMin < 60 { + holdingDuration = fmt.Sprintf(" | Holding Duration %d min", durationMin) + } else { + durationHour := durationMin / 60 + durationMinRemainder := durationMin % 60 + holdingDuration = fmt.Sprintf(" | Holding Duration %dh %dm", durationHour, durationMinRemainder) + } + } + + positionValue := pos.Quantity * pos.MarkPrice + if positionValue < 0 { + positionValue = -positionValue + } + + sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Position Value %.2f USDT | PnL%+.2f%% | PnL Amount%+.2f USDT | Peak PnL%.2f%% | Leverage %dx | Margin %.0f | Liq Price %.4f%s\n\n", + index, pos.Symbol, strings.ToUpper(pos.Side), + pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct, + pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration)) + + if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok { + sb.WriteString(e.formatMarketData(marketData)) + + if ctx.QuantDataMap != nil { + if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant { + sb.WriteString(e.formatQuantData(quantData)) + } + } + sb.WriteString("\n") + } + + return sb.String() +} + +func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { + if len(sources) > 1 { + return " (AI500+OI_Top dual signal)" + } else if len(sources) == 1 { + switch sources[0] { + case "ai500": + return " (AI500)" + case "oi_top": + return " (OI_Top position growth)" + case "static": + return " (Manual selection)" + } + } + return "" +} + +// ============================================================================ +// Market Data Formatting +// ============================================================================ + +func (e *StrategyEngine) formatMarketData(data *market.Data) string { + var sb strings.Builder + indicators := e.config.Indicators + + sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice)) + + if indicators.EnableEMA { + sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20)) + } + + if indicators.EnableMACD { + sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD)) + } + + if indicators.EnableRSI { + sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7)) + } + + sb.WriteString("\n\n") + + if indicators.EnableOI || indicators.EnableFundingRate { + sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol)) + + if indicators.EnableOI && data.OpenInterest != nil { + sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n", + data.OpenInterest.Latest, data.OpenInterest.Average)) + } + + if indicators.EnableFundingRate { + sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate)) + } + } + + if len(data.TimeframeData) > 0 { + timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"} + for _, tf := range timeframeOrder { + if tfData, ok := data.TimeframeData[tf]; ok { + sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf))) + e.formatTimeframeSeriesData(&sb, tfData, indicators) + } + } + } else { + // Compatible with old data format + if data.IntradaySeries != nil { + klineConfig := indicators.Klines + sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe)) + + if len(data.IntradaySeries.MidPrices) > 0 { + sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices))) + } + + if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values))) + } + + if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues))) + } + + if indicators.EnableRSI { + if len(data.IntradaySeries.RSI7Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values))) + } + if len(data.IntradaySeries.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values))) + } + } + + if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 { + sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume))) + } + + if indicators.EnableATR { + sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14)) + } + } + + if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe { + sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe)) + + if indicators.EnableEMA { + sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n", + data.LongerTermContext.EMA20, data.LongerTermContext.EMA50)) + } + + if indicators.EnableATR { + sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n", + data.LongerTermContext.ATR3, data.LongerTermContext.ATR14)) + } + + if indicators.EnableVolume { + sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n", + data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume)) + } + + if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues))) + } + + if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values))) + } + } + } + + return sb.String() +} + +func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) { + if len(data.Klines) > 0 { + sb.WriteString("Time(UTC) Open High Low Close Volume\n") + for i, k := range data.Klines { + t := time.Unix(k.Time/1000, 0).UTC() + timeStr := t.Format("01-02 15:04") + marker := "" + if i == len(data.Klines)-1 { + marker = " <- current" + } + sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n", + timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker)) + } + sb.WriteString("\n") + } else if len(data.MidPrices) > 0 { + sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices))) + if indicators.EnableVolume && len(data.Volume) > 0 { + sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume))) + } + } + + if indicators.EnableEMA { + if len(data.EMA20Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values))) + } + if len(data.EMA50Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values))) + } + } + + if indicators.EnableMACD && len(data.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues))) + } + + if indicators.EnableRSI { + if len(data.RSI7Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values))) + } + if len(data.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values))) + } + } + + if indicators.EnableATR && data.ATR14 > 0 { + sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14)) + } + + sb.WriteString("\n") +} + +func (e *StrategyEngine) formatQuantData(data *QuantData) string { + if data == nil { + return "" + } + + indicators := e.config.Indicators + if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow { + return "" + } + + var sb strings.Builder + sb.WriteString("📊 Quantitative Data:\n") + + if len(data.PriceChange) > 0 { + sb.WriteString("Price Change: ") + timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"} + parts := []string{} + for _, tf := range timeframes { + if v, ok := data.PriceChange[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + + if indicators.EnableQuantNetflow && data.Netflow != nil { + sb.WriteString("Fund Flow (Netflow):\n") + timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"} + + if data.Netflow.Institution != nil { + if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 { + sb.WriteString(" Institutional Futures:\n") + for _, tf := range timeframes { + if v, ok := data.Netflow.Institution.Future[tf]; ok { + sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) + } + } + } + if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 { + sb.WriteString(" Institutional Spot:\n") + for _, tf := range timeframes { + if v, ok := data.Netflow.Institution.Spot[tf]; ok { + sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) + } + } + } + } + + if data.Netflow.Personal != nil { + if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 { + sb.WriteString(" Retail Futures:\n") + for _, tf := range timeframes { + if v, ok := data.Netflow.Personal.Future[tf]; ok { + sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) + } + } + } + if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 { + sb.WriteString(" Retail Spot:\n") + for _, tf := range timeframes { + if v, ok := data.Netflow.Personal.Spot[tf]; ok { + sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) + } + } + } + } + } + + if indicators.EnableQuantOI && len(data.OI) > 0 { + for exchange, oiData := range data.OI { + if len(oiData.Delta) > 0 { + sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange)) + for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} { + if d, ok := oiData.Delta[tf]; ok { + sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue))) + } + } + } + } + } + + return sb.String() +} + +func formatFlowValue(v float64) string { + sign := "" + if v >= 0 { + sign = "+" + } + absV := v + if absV < 0 { + absV = -absV + } + if absV >= 1e9 { + return fmt.Sprintf("%s%.2fB", sign, v/1e9) + } else if absV >= 1e6 { + return fmt.Sprintf("%s%.2fM", sign, v/1e6) + } else if absV >= 1e3 { + return fmt.Sprintf("%s%.2fK", sign, v/1e3) + } + return fmt.Sprintf("%s%.2f", sign, v) +} + +func formatFloatSlice(values []float64) string { + strValues := make([]string, len(values)) + for i, v := range values { + strValues[i] = fmt.Sprintf("%.4f", v) + } + return "[" + strings.Join(strValues, ", ") + "]" +} + +// ============================================================================ +// AI Response Parsing +// ============================================================================ + func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int) (*FullDecision, error) { - // 1. Extract chain of thought cotTrace := extractCoTTrace(aiResponse) - // 2. Extract JSON decision list decisions, err := extractDecisions(aiResponse) if err != nil { return &FullDecision{ @@ -723,7 +1255,6 @@ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthL }, fmt.Errorf("failed to extract decisions: %w", err) } - // 3. Validate decisions if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage); err != nil { return &FullDecision{ CoTTrace: cotTrace, @@ -737,60 +1268,46 @@ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthL }, nil } -// extractCoTTrace extracts chain of thought analysis func extractCoTTrace(response string) string { - // Method 1: Prioritize extracting tag content if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 { logger.Infof("✓ Extracted reasoning chain using tag") return strings.TrimSpace(match[1]) } - // Method 2: If no tag but has tag, extract content before if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { logger.Infof("✓ Extracted content before tag as reasoning chain") return strings.TrimSpace(response[:decisionIdx]) } - // Method 3: Fallback - find start position of JSON array jsonStart := strings.Index(response, "[") if jsonStart > 0 { logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)") return strings.TrimSpace(response[:jsonStart]) } - // If no markers found, entire response is reasoning chain return strings.TrimSpace(response) } -// extractDecisions extracts JSON decision list func extractDecisions(response string) ([]Decision, error) { - // Pre-clean: remove zero-width/BOM s := removeInvisibleRunes(response) s = strings.TrimSpace(s) - - // Critical Fix: fix full-width characters before regex matching! - // Otherwise regex \[ cannot match full-width [ s = fixMissingQuotes(s) - // Method 1: Prioritize extracting from tag var jsonPart string if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 { jsonPart = strings.TrimSpace(match[1]) logger.Infof("✓ Extracted JSON using tag") } else { - // Fallback: use entire response jsonPart = s logger.Infof("⚠️ tag not found, searching JSON in full text") } - // Fix full-width characters in jsonPart jsonPart = fixMissingQuotes(jsonPart) - // 1) Prioritize extracting from ```json code block if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 { jsonContent := strings.TrimSpace(m[1]) - jsonContent = compactArrayOpen(jsonContent) // Normalize "[ {" to "[{" - jsonContent = fixMissingQuotes(jsonContent) // Second fix (prevent residual full-width after regex extraction) + jsonContent = compactArrayOpen(jsonContent) + jsonContent = fixMissingQuotes(jsonContent) if err := validateJSONFormat(jsonContent); err != nil { return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response) } @@ -801,20 +1318,15 @@ func extractDecisions(response string) ([]Decision, error) { return decisions, nil } - // 2) Fallback: search for first object array in full text - // Note: at this point jsonPart has already been processed by fixMissingQuotes(), full-width converted to half-width jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart)) if jsonContent == "" { - // Safe Fallback: when AI only outputs reasoning without JSON, generate fallback decision (avoid system crash) logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode") - // Extract reasoning summary (max 240 characters) cotSummary := jsonPart if len(cotSummary) > 240 { cotSummary = cotSummary[:240] + "..." } - // Generate fallback decision: all coins enter wait state fallbackDecision := Decision{ Symbol: "ALL", Action: "wait", @@ -824,16 +1336,13 @@ func extractDecisions(response string) ([]Decision, error) { return []Decision{fallbackDecision}, nil } - // Normalize format (full-width characters already fixed earlier) jsonContent = compactArrayOpen(jsonContent) - jsonContent = fixMissingQuotes(jsonContent) // Second fix (prevent residual full-width after regex extraction) + jsonContent = fixMissingQuotes(jsonContent) - // Validate JSON format (detect common errors) if err := validateJSONFormat(jsonContent); err != nil { return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response) } - // Parse JSON var decisions []Decision if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil { return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent) @@ -842,55 +1351,44 @@ func extractDecisions(response string) ([]Decision, error) { return decisions, nil } -// fixMissingQuotes replaces Chinese quotes and full-width characters with English quotes and half-width characters (avoid parsing failure due to AI outputting full-width JSON characters) func fixMissingQuotes(jsonStr string) string { - // Replace Chinese quotes - jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // " - jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // " - jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // ' - jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // ' + jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") + jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") - // Replace full-width brackets, colons, commas (prevent AI outputting full-width JSON characters) - jsonStr = strings.ReplaceAll(jsonStr, "[", "[") // U+FF3B full-width left square bracket - jsonStr = strings.ReplaceAll(jsonStr, "]", "]") // U+FF3D full-width right square bracket - jsonStr = strings.ReplaceAll(jsonStr, "{", "{") // U+FF5B full-width left curly bracket - jsonStr = strings.ReplaceAll(jsonStr, "}", "}") // U+FF5D full-width right curly bracket - jsonStr = strings.ReplaceAll(jsonStr, ":", ":") // U+FF1A full-width colon - jsonStr = strings.ReplaceAll(jsonStr, ",", ",") // U+FF0C full-width comma + jsonStr = strings.ReplaceAll(jsonStr, "[", "[") + jsonStr = strings.ReplaceAll(jsonStr, "]", "]") + jsonStr = strings.ReplaceAll(jsonStr, "{", "{") + jsonStr = strings.ReplaceAll(jsonStr, "}", "}") + jsonStr = strings.ReplaceAll(jsonStr, ":", ":") + jsonStr = strings.ReplaceAll(jsonStr, ",", ",") - // Replace CJK punctuation (AI may also output these in Chinese context) - jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK left corner bracket U+3010 - jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK right corner bracket U+3011 - jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") // CJK left tortoise shell bracket U+3014 - jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") // CJK right tortoise shell bracket U+3015 - jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK ideographic comma U+3001 + jsonStr = strings.ReplaceAll(jsonStr, "【", "[") + jsonStr = strings.ReplaceAll(jsonStr, "】", "]") + jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") + jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") + jsonStr = strings.ReplaceAll(jsonStr, "、", ",") - // Replace full-width space with half-width space (JSON shouldn't have full-width spaces) - jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 full-width space + jsonStr = strings.ReplaceAll(jsonStr, " ", " ") return jsonStr } -// validateJSONFormat validates JSON format, detecting common errors func validateJSONFormat(jsonStr string) error { trimmed := strings.TrimSpace(jsonStr) - // Allow any whitespace (including zero-width) between [ and { if !reArrayHead.MatchString(trimmed) { - // Check if it's a pure number/range array (common error) if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") { return fmt.Errorf("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))]) } return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))]) } - // Check if contains range symbol ~ (common LLM error) if strings.Contains(jsonStr, "~") { return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values") } - // Check if contains thousand separators (like 98,000) - // Use simple pattern matching: digit + comma + 3 digits for i := 0; i < len(jsonStr)-4; i++ { if jsonStr[i] >= '0' && jsonStr[i] <= '9' && jsonStr[i+1] == ',' && @@ -904,7 +1402,6 @@ func validateJSONFormat(jsonStr string) error { return nil } -// min returns the smaller of two integers func min(a, b int) int { if a < b { return a @@ -912,17 +1409,18 @@ func min(a, b int) int { return b } -// removeInvisibleRunes removes zero-width characters and BOM, avoiding invisible prefixes breaking validation func removeInvisibleRunes(s string) string { return reInvisibleRunes.ReplaceAllString(s, "") } -// compactArrayOpen normalizes opening "[ {" → "[{" func compactArrayOpen(s string) string { return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{") } -// validateDecisions validates all decisions (requires account information and leverage configuration) +// ============================================================================ +// Decision Validation +// ============================================================================ + func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error { for i, decision := range decisions { if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage); err != nil { @@ -932,31 +1430,7 @@ func validateDecisions(decisions []Decision, accountEquity float64, btcEthLevera return nil } -// findMatchingBracket finds matching right bracket -func findMatchingBracket(s string, start int) int { - if start >= len(s) || s[start] != '[' { - return -1 - } - - depth := 0 - for i := start; i < len(s); i++ { - switch s[i] { - case '[': - depth++ - case ']': - depth-- - if depth == 0 { - return i - } - } - } - - return -1 -} - -// validateDecision validates the validity of a single decision func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error { - // Validate action validActions := map[string]bool{ "open_long": true, "open_short": true, @@ -970,46 +1444,40 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi return fmt.Errorf("invalid action: %s", d.Action) } - // Opening operations must provide complete parameters if d.Action == "open_long" || d.Action == "open_short" { - // Use configured leverage limit based on coin type - maxLeverage := altcoinLeverage // Altcoins use configured leverage - maxPositionValue := accountEquity * 1.5 // Altcoins max 1.5x account equity + maxLeverage := altcoinLeverage + maxPositionValue := accountEquity * 1.5 if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { - maxLeverage = btcEthLeverage // BTC and ETH use configured leverage - maxPositionValue = accountEquity * 10 // BTC/ETH max 10x account equity + maxLeverage = btcEthLeverage + maxPositionValue = accountEquity * 10 } - // Fallback mechanism: auto-correct leverage to limit when exceeded (instead of directly rejecting decision) if d.Leverage <= 0 { return fmt.Errorf("leverage must be greater than 0: %d", d.Leverage) } if d.Leverage > maxLeverage { logger.Infof("⚠️ [Leverage Fallback] %s leverage exceeded (%dx > %dx), auto-adjusting to limit %dx", d.Symbol, d.Leverage, maxLeverage, maxLeverage) - d.Leverage = maxLeverage // Auto-correct to limit value + d.Leverage = maxLeverage } if d.PositionSizeUSD <= 0 { return fmt.Errorf("position size must be greater than 0: %.2f", d.PositionSizeUSD) } - // Validate minimum opening amount (prevent quantity rounding to 0 error) - // Binance minimum notional value 10 USDT + safety margin - const minPositionSizeGeneral = 12.0 // 10 + 20% safety margin - const minPositionSizeBTCETH = 60.0 // BTC/ETH requires larger amount due to high price and precision limits (more flexible) + const minPositionSizeGeneral = 12.0 + const minPositionSizeBTCETH = 60.0 if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { if d.PositionSizeUSD < minPositionSizeBTCETH { - return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT (due to high price and precision limits, avoid quantity rounding to 0)", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH) + return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH) } } else { if d.PositionSizeUSD < minPositionSizeGeneral { - return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT (Binance minimum notional value requirement)", d.PositionSizeUSD, minPositionSizeGeneral) + return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.PositionSizeUSD, minPositionSizeGeneral) } } - // Validate position value limit (add 1% tolerance to avoid floating point precision issues) - tolerance := maxPositionValue * 0.01 // 1% tolerance + tolerance := maxPositionValue * 0.01 if d.PositionSizeUSD > maxPositionValue+tolerance { if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (10x account equity), actual: %.0f", maxPositionValue, d.PositionSizeUSD) @@ -1021,7 +1489,6 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi return fmt.Errorf("stop loss and take profit must be greater than 0") } - // Validate rationality of stop loss and take profit if d.Action == "open_long" { if d.StopLoss >= d.TakeProfit { return fmt.Errorf("for long positions, stop loss price must be less than take profit price") @@ -1032,15 +1499,11 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi } } - // Validate risk/reward ratio (must be ≥1:3) - // Calculate entry price (assume current market price) var entryPrice float64 if d.Action == "open_long" { - // Long: entry price between stop loss and take profit - entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2 // Assume entry at 20% position + entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2 } else { - // Short: entry price between stop loss and take profit - entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2 // Assume entry at 20% position + entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2 } var riskPercent, rewardPercent, riskRewardRatio float64 @@ -1058,7 +1521,6 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi } } - // Hard constraint: risk/reward ratio must be ≥3.0 if riskRewardRatio < 3.0 { return fmt.Errorf("risk/reward ratio too low (%.2f:1), must be ≥3.0:1 [risk: %.2f%% reward: %.2f%%] [stop loss: %.2f take profit: %.2f]", riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit) diff --git a/decision/prompt_manager.go b/decision/prompt_manager.go deleted file mode 100644 index 202d2291..00000000 --- a/decision/prompt_manager.go +++ /dev/null @@ -1,162 +0,0 @@ -package decision - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" -) - -// PromptTemplate system prompt template -type PromptTemplate struct { - Name string // Template name (filename without extension) - Content string // Template content -} - -// PromptManager prompt manager -type PromptManager struct { - templates map[string]*PromptTemplate - mu sync.RWMutex -} - -var ( - // globalPromptManager global prompt manager - globalPromptManager *PromptManager - // promptsDir prompt folder path - promptsDir = "prompts" -) - -// init loads all prompt templates during package initialization -func init() { - globalPromptManager = NewPromptManager() - if err := globalPromptManager.LoadTemplates(promptsDir); err != nil { - log.Printf("⚠️ Failed to load prompt templates: %v", err) - } else { - log.Printf("✓ Loaded %d system prompt templates", len(globalPromptManager.templates)) - } -} - -// NewPromptManager creates a prompt manager -func NewPromptManager() *PromptManager { - return &PromptManager{ - templates: make(map[string]*PromptTemplate), - } -} - -// LoadTemplates loads all prompt templates from specified directory -func (pm *PromptManager) LoadTemplates(dir string) error { - pm.mu.Lock() - defer pm.mu.Unlock() - - // Check if directory exists - if _, err := os.Stat(dir); os.IsNotExist(err) { - return fmt.Errorf("prompt directory does not exist: %s", dir) - } - - // Scan all .txt files in directory - files, err := filepath.Glob(filepath.Join(dir, "*.txt")) - if err != nil { - return fmt.Errorf("failed to scan prompt directory: %w", err) - } - - if len(files) == 0 { - log.Printf("⚠️ No .txt files found in prompt directory %s", dir) - return nil - } - - // Load each template file - for _, file := range files { - // Read file content - content, err := os.ReadFile(file) - if err != nil { - log.Printf("⚠️ Failed to read prompt file %s: %v", file, err) - continue - } - - // Extract filename (without extension) as template name - fileName := filepath.Base(file) - templateName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - - // Store template - pm.templates[templateName] = &PromptTemplate{ - Name: templateName, - Content: string(content), - } - - log.Printf(" 📄 Loaded prompt template: %s (%s)", templateName, fileName) - } - - return nil -} - -// GetTemplate gets prompt template by name -func (pm *PromptManager) GetTemplate(name string) (*PromptTemplate, error) { - pm.mu.RLock() - defer pm.mu.RUnlock() - - template, exists := pm.templates[name] - if !exists { - return nil, fmt.Errorf("prompt template does not exist: %s", name) - } - - return template, nil -} - -// GetAllTemplateNames gets all template names list -func (pm *PromptManager) GetAllTemplateNames() []string { - pm.mu.RLock() - defer pm.mu.RUnlock() - - names := make([]string, 0, len(pm.templates)) - for name := range pm.templates { - names = append(names, name) - } - - return names -} - -// GetAllTemplates gets all templates -func (pm *PromptManager) GetAllTemplates() []*PromptTemplate { - pm.mu.RLock() - defer pm.mu.RUnlock() - - templates := make([]*PromptTemplate, 0, len(pm.templates)) - for _, template := range pm.templates { - templates = append(templates, template) - } - - return templates -} - -// ReloadTemplates reloads all templates -func (pm *PromptManager) ReloadTemplates(dir string) error { - pm.mu.Lock() - pm.templates = make(map[string]*PromptTemplate) - pm.mu.Unlock() - - return pm.LoadTemplates(dir) -} - -// === Global functions (for external calls) === - -// GetPromptTemplate gets prompt template by name (global function) -func GetPromptTemplate(name string) (*PromptTemplate, error) { - return globalPromptManager.GetTemplate(name) -} - -// GetAllPromptTemplateNames gets all template names (global function) -func GetAllPromptTemplateNames() []string { - return globalPromptManager.GetAllTemplateNames() -} - -// GetAllPromptTemplates gets all templates (global function) -func GetAllPromptTemplates() []*PromptTemplate { - return globalPromptManager.GetAllTemplates() -} - -// ReloadPromptTemplates reloads all templates (global function) -func ReloadPromptTemplates() error { - return globalPromptManager.ReloadTemplates(promptsDir) -} diff --git a/decision/prompt_manager_test.go b/decision/prompt_manager_test.go deleted file mode 100644 index 720d766d..00000000 --- a/decision/prompt_manager_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package decision - -import ( - "os" - "path/filepath" - "testing" -) - -func TestPromptManager_LoadTemplates(t *testing.T) { - // Create temporary directory for testing - tempDir := t.TempDir() - - tests := []struct { - name string - setupFiles map[string]string // filename -> content - expectedCount int - expectedNames []string - shouldError bool - }{ - { - name: "Load single template file", - setupFiles: map[string]string{ - "default.txt": "You are a professional cryptocurrency trading AI.", - }, - expectedCount: 1, - expectedNames: []string{"default"}, - shouldError: false, - }, - { - name: "Load multiple template files", - setupFiles: map[string]string{ - "default.txt": "Default strategy", - "conservative.txt": "Conservative strategy", - "aggressive.txt": "Aggressive strategy", - }, - expectedCount: 3, - expectedNames: []string{"default", "conservative", "aggressive"}, - shouldError: false, - }, - { - name: "Empty directory", - setupFiles: map[string]string{}, - expectedCount: 0, - expectedNames: []string{}, - shouldError: false, - }, - { - name: "Ignore non-.txt files", - setupFiles: map[string]string{ - "default.txt": "Correct template", - "readme.md": "Should be ignored", - "config.json": "Should be ignored", - }, - expectedCount: 1, - expectedNames: []string{"default"}, - shouldError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create independent subdirectory for each test case - testDir := filepath.Join(tempDir, tt.name) - if err := os.MkdirAll(testDir, 0755); err != nil { - t.Fatalf("Failed to create test directory: %v", err) - } - - // Setup test files - for filename, content := range tt.setupFiles { - filePath := filepath.Join(testDir, filename) - if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to create test file %s: %v", filename, err) - } - } - - // Create new PromptManager - pm := NewPromptManager() - - // Execute test - err := pm.LoadTemplates(testDir) - - // Check error - if (err != nil) != tt.shouldError { - t.Errorf("LoadTemplates() error = %v, shouldError %v", err, tt.shouldError) - return - } - - // Check loaded template count - if len(pm.templates) != tt.expectedCount { - t.Errorf("Loaded template count = %d, expected %d", len(pm.templates), tt.expectedCount) - } - - // Check template names - for _, expectedName := range tt.expectedNames { - if _, exists := pm.templates[expectedName]; !exists { - t.Errorf("Missing expected template: %s", expectedName) - } - } - - // Verify template content - for filename, expectedContent := range tt.setupFiles { - if filepath.Ext(filename) != ".txt" { - continue - } - templateName := filename[:len(filename)-4] // Remove .txt - template, err := pm.GetTemplate(templateName) - if err != nil { - t.Errorf("Failed to get template %s: %v", templateName, err) - continue - } - if template.Content != expectedContent { - t.Errorf("Template content mismatch\nExpected: %s\nActual: %s", expectedContent, template.Content) - } - } - }) - } -} - -func TestPromptManager_GetTemplate(t *testing.T) { - pm := NewPromptManager() - pm.templates = map[string]*PromptTemplate{ - "default": { - Name: "default", - Content: "Default strategy content", - }, - "aggressive": { - Name: "aggressive", - Content: "Aggressive strategy content", - }, - } - - tests := []struct { - name string - templateName string - expectError bool - expectedContent string - }{ - { - name: "Get existing template", - templateName: "default", - expectError: false, - expectedContent: "Default strategy content", - }, - { - name: "Get non-existent template", - templateName: "nonexistent", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - template, err := pm.GetTemplate(tt.templateName) - - if (err != nil) != tt.expectError { - t.Errorf("GetTemplate() error = %v, expectError %v", err, tt.expectError) - return - } - - if !tt.expectError && template.Content != tt.expectedContent { - t.Errorf("Template content = %s, expected %s", template.Content, tt.expectedContent) - } - }) - } -} - -func TestPromptManager_ReloadTemplates(t *testing.T) { - tempDir := t.TempDir() - - // Initial file - if err := os.WriteFile(filepath.Join(tempDir, "default.txt"), []byte("Initial content"), 0644); err != nil { - t.Fatalf("Failed to create initial file: %v", err) - } - - pm := NewPromptManager() - if err := pm.LoadTemplates(tempDir); err != nil { - t.Fatalf("Initial load failed: %v", err) - } - - // Verify initial content - template, _ := pm.GetTemplate("default") - if template.Content != "Initial content" { - t.Errorf("Initial content incorrect: %s", template.Content) - } - - // Modify file content - if err := os.WriteFile(filepath.Join(tempDir, "default.txt"), []byte("Updated content"), 0644); err != nil { - t.Fatalf("Failed to update file: %v", err) - } - - // Add new file - if err := os.WriteFile(filepath.Join(tempDir, "new.txt"), []byte("New template content"), 0644); err != nil { - t.Fatalf("Failed to create new file: %v", err) - } - - // Reload - if err := pm.ReloadTemplates(tempDir); err != nil { - t.Fatalf("Reload failed: %v", err) - } - - // Verify updated content - template, err := pm.GetTemplate("default") - if err != nil { - t.Fatalf("Failed to get default template: %v", err) - } - if template.Content != "Updated content" { - t.Errorf("Content after reload incorrect: got %s, want 'Updated content'", template.Content) - } - - // Verify new template - newTemplate, err := pm.GetTemplate("new") - if err != nil { - t.Fatalf("Failed to get new template: %v", err) - } - if newTemplate.Content != "New template content" { - t.Errorf("New template content incorrect: %s", newTemplate.Content) - } - - // Verify template count - if len(pm.templates) != 2 { - t.Errorf("Template count after reload = %d, expected 2", len(pm.templates)) - } -} - -func TestPromptManager_GetAllTemplateNames(t *testing.T) { - pm := NewPromptManager() - pm.templates = map[string]*PromptTemplate{ - "default": {Name: "default", Content: "Default strategy"}, - "conservative": {Name: "conservative", Content: "Conservative strategy"}, - "aggressive": {Name: "aggressive", Content: "Aggressive strategy"}, - } - - names := pm.GetAllTemplateNames() - - if len(names) != 3 { - t.Errorf("GetAllTemplateNames() returned count = %d, expected 3", len(names)) - } - - // Verify all names exist - nameMap := make(map[string]bool) - for _, name := range names { - nameMap[name] = true - } - - expectedNames := []string{"default", "conservative", "aggressive"} - for _, expectedName := range expectedNames { - if !nameMap[expectedName] { - t.Errorf("Missing expected template name: %s", expectedName) - } - } -} - -func TestReloadPromptTemplates_GlobalFunction(t *testing.T) { - // Save original promptsDir - originalDir := promptsDir - defer func() { - promptsDir = originalDir - // Restore original templates - globalPromptManager.ReloadTemplates(originalDir) - }() - - // Create temporary directory - tempDir := t.TempDir() - promptsDir = tempDir - - // Create test file - if err := os.WriteFile(filepath.Join(tempDir, "test.txt"), []byte("Test content"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - // Call global reload function - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("ReloadPromptTemplates() failed: %v", err) - } - - // Verify global manager has been updated - template, err := GetPromptTemplate("test") - if err != nil { - t.Fatalf("Failed to get template: %v", err) - } - - if template.Content != "Test content" { - t.Errorf("Template content incorrect: got %s, want 'Test content'", template.Content) - } -} diff --git a/decision/prompt_reload_integration_test.go b/decision/prompt_reload_integration_test.go deleted file mode 100644 index 8139ddd1..00000000 --- a/decision/prompt_reload_integration_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package decision - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// TestPromptReloadEndToEnd end-to-end test: verify complete flow from file modification to decision engine usage -func TestPromptReloadEndToEnd(t *testing.T) { - // Save original promptsDir - originalDir := promptsDir - defer func() { - promptsDir = originalDir - // Restore original templates - globalPromptManager.ReloadTemplates(originalDir) - }() - - // Create temporary directory to simulate prompts/ directory - tempDir := t.TempDir() - promptsDir = tempDir - - // Step 1: Create initial prompt file - initialContent := "# Initial Trading Strategy\nYou are a conservative trading AI." - if err := os.WriteFile(filepath.Join(tempDir, "test_strategy.txt"), []byte(initialContent), 0644); err != nil { - t.Fatalf("Failed to create initial file: %v", err) - } - - // Step 2: First load (simulate system startup) - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("First load failed: %v", err) - } - - // Step 3: Verify initial content - template, err := GetPromptTemplate("test_strategy") - if err != nil { - t.Fatalf("Failed to get initial template: %v", err) - } - if template.Content != initialContent { - t.Errorf("Initial content mismatch\nExpected: %s\nActual: %s", initialContent, template.Content) - } - - // Step 4: Use buildSystemPrompt to verify template is correctly used - systemPrompt := buildSystemPrompt(10000.0, 10, 5, "test_strategy", "") - if !strings.Contains(systemPrompt, initialContent) { - t.Errorf("buildSystemPrompt doesn't contain template content\nGenerated prompt:\n%s", systemPrompt) - } - - // Step 5: Simulate user modifying file (user modifies prompt on disk) - updatedContent := "# Updated Trading Strategy\nYou are an aggressive trading AI seeking high risk and high reward." - if err := os.WriteFile(filepath.Join(tempDir, "test_strategy.txt"), []byte(updatedContent), 0644); err != nil { - t.Fatalf("Failed to update file: %v", err) - } - - // Step 6: Simulate trader startup calling ReloadPromptTemplates() - t.Log("Simulating trader startup, calling ReloadPromptTemplates()...") - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("Reload failed: %v", err) - } - - // Step 7: Verify new content has taken effect - reloadedTemplate, err := GetPromptTemplate("test_strategy") - if err != nil { - t.Fatalf("Failed to get reloaded template: %v", err) - } - if reloadedTemplate.Content != updatedContent { - t.Errorf("Content mismatch after reload\nExpected: %s\nActual: %s", updatedContent, reloadedTemplate.Content) - } - - // Step 8: Verify buildSystemPrompt uses new content - newSystemPrompt := buildSystemPrompt(10000.0, 10, 5, "test_strategy", "") - if !strings.Contains(newSystemPrompt, updatedContent) { - t.Errorf("buildSystemPrompt doesn't contain updated template content\nGenerated prompt:\n%s", newSystemPrompt) - } - - // Step 9: Verify old content no longer exists - if strings.Contains(newSystemPrompt, "conservative trading AI") { - t.Errorf("buildSystemPrompt still contains old template content") - } - - t.Log("✅ End-to-end test passed: file modification -> reload -> decision engine uses new content") -} - -// TestPromptReloadWithCustomPrompt tests interaction between custom prompt and template reload -func TestPromptReloadWithCustomPrompt(t *testing.T) { - // Save original promptsDir - originalDir := promptsDir - defer func() { - promptsDir = originalDir - globalPromptManager.ReloadTemplates(originalDir) - }() - - // Create temporary directory - tempDir := t.TempDir() - promptsDir = tempDir - - // Create base template - baseContent := "Base strategy: Stable trading" - if err := os.WriteFile(filepath.Join(tempDir, "base.txt"), []byte(baseContent), 0644); err != nil { - t.Fatalf("Failed to create file: %v", err) - } - - // Load templates - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("Load failed: %v", err) - } - - // Test 1: Base template + custom prompt (no override) - customPrompt := "Personalized rule: Only trade BTC" - result := buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, false, "base", "") - if !strings.Contains(result, baseContent) { - t.Errorf("Doesn't contain base template content") - } - if !strings.Contains(result, customPrompt) { - t.Errorf("Doesn't contain custom prompt") - } - - // Test 2: Override base prompt - result = buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, true, "base", "") - if strings.Contains(result, baseContent) { - t.Errorf("Override mode still contains base template content") - } - if !strings.Contains(result, customPrompt) { - t.Errorf("Override mode doesn't contain custom prompt") - } - - // Test 3: Effect after reload - updatedBase := "Updated base strategy: Aggressive trading" - if err := os.WriteFile(filepath.Join(tempDir, "base.txt"), []byte(updatedBase), 0644); err != nil { - t.Fatalf("Failed to update file: %v", err) - } - - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("Reload failed: %v", err) - } - - result = buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, false, "base", "") - if !strings.Contains(result, updatedBase) { - t.Errorf("After reload doesn't contain updated base template content") - } - if strings.Contains(result, baseContent) { - t.Errorf("After reload still contains old base template content") - } -} - -// TestPromptReloadFallback tests fallback mechanism when template doesn't exist -func TestPromptReloadFallback(t *testing.T) { - // Save original promptsDir - originalDir := promptsDir - defer func() { - promptsDir = originalDir - globalPromptManager.ReloadTemplates(originalDir) - }() - - // Create temporary directory - tempDir := t.TempDir() - promptsDir = tempDir - - // Only create default template - defaultContent := "Default strategy" - if err := os.WriteFile(filepath.Join(tempDir, "default.txt"), []byte(defaultContent), 0644); err != nil { - t.Fatalf("Failed to create file: %v", err) - } - - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("Load failed: %v", err) - } - - // Test 1: Request non-existent template, should fall back to default - result := buildSystemPrompt(10000.0, 10, 5, "nonexistent", "") - if !strings.Contains(result, defaultContent) { - t.Errorf("When requesting non-existent template, didn't fall back to default") - } - - // Test 2: Empty template name, should use default - result = buildSystemPrompt(10000.0, 10, 5, "", "") - if !strings.Contains(result, defaultContent) { - t.Errorf("With empty template name, didn't use default") - } -} - -// TestConcurrentPromptReload tests prompt reload in concurrent scenarios -func TestConcurrentPromptReload(t *testing.T) { - // Save original promptsDir - originalDir := promptsDir - defer func() { - promptsDir = originalDir - globalPromptManager.ReloadTemplates(originalDir) - }() - - // Create temporary directory - tempDir := t.TempDir() - promptsDir = tempDir - - // Create test file - if err := os.WriteFile(filepath.Join(tempDir, "test.txt"), []byte("Test content"), 0644); err != nil { - t.Fatalf("Failed to create file: %v", err) - } - - if err := ReloadPromptTemplates(); err != nil { - t.Fatalf("Initial load failed: %v", err) - } - - // Concurrent test: read and reload simultaneously - done := make(chan bool) - - // Start multiple read goroutines - for i := 0; i < 10; i++ { - go func() { - for j := 0; j < 100; j++ { - _, _ = GetPromptTemplate("test") - } - done <- true - }() - } - - // Start multiple reload goroutines - for i := 0; i < 3; i++ { - go func() { - for j := 0; j < 10; j++ { - _ = ReloadPromptTemplates() - } - done <- true - }() - } - - // Wait for all goroutines to complete - for i := 0; i < 13; i++ { - <-done - } - - // Verify final state is correct - template, err := GetPromptTemplate("test") - if err != nil { - t.Errorf("Failed to get template after concurrent test: %v", err) - } - if template.Content != "Test content" { - t.Errorf("Template content error after concurrent test: %s", template.Content) - } - - t.Log("✅ Concurrent test passed: multiple goroutines reading and reloading templates simultaneously, no data race") -} diff --git a/decision/prompt_test.go b/decision/prompt_test.go deleted file mode 100644 index 4890f7ea..00000000 --- a/decision/prompt_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package decision - -import ( - "strings" - "testing" -) - -// TestBuildSystemPrompt_ContainsAllValidActions tests whether prompt contains all valid actions -func TestBuildSystemPrompt_ContainsAllValidActions(t *testing.T) { - // These are all valid actions defined in the system (from validateDecision) - validActions := []string{ - "open_long", - "open_short", - "close_long", - "close_short", - "hold", - "wait", - } - - // Build prompt - prompt := buildSystemPrompt(1000.0, 10, 5, "default", "") - - // Verify each valid action appears in prompt - for _, action := range validActions { - if !strings.Contains(prompt, action) { - t.Errorf("Prompt missing valid action: %s", action) - } - } -} diff --git a/decision/strategy_engine.go b/decision/strategy_engine.go deleted file mode 100644 index 56f6c19d..00000000 --- a/decision/strategy_engine.go +++ /dev/null @@ -1,969 +0,0 @@ -package decision - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "nofx/logger" - "nofx/market" - "nofx/pool" - "nofx/store" - "strings" - "time" -) - -// StrategyEngine strategy execution engine -// Responsible for dynamically fetching data and assembling prompts based on strategy configuration -type StrategyEngine struct { - config *store.StrategyConfig -} - -// NewStrategyEngine creates strategy execution engine -func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { - return &StrategyEngine{config: config} -} - -// GetCandidateCoins gets candidate coins based on strategy configuration -func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { - var candidates []CandidateCoin - symbolSources := make(map[string][]string) - - coinSource := e.config.CoinSource - - // Set custom API URL (if configured) - if coinSource.CoinPoolAPIURL != "" { - pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) - logger.Infof("✓ Using strategy-configured AI500 API URL: %s", coinSource.CoinPoolAPIURL) - } - if coinSource.OITopAPIURL != "" { - pool.SetOITopAPI(coinSource.OITopAPIURL) - logger.Infof("✓ Using strategy-configured OI Top API URL: %s", coinSource.OITopAPIURL) - } - - switch coinSource.SourceType { - case "static": - // Static coin list - for _, symbol := range coinSource.StaticCoins { - symbol = market.Normalize(symbol) - candidates = append(candidates, CandidateCoin{ - Symbol: symbol, - Sources: []string{"static"}, - }) - } - return candidates, nil - - case "coinpool": - // Use AI500 coin pool only - return e.getCoinPoolCoins(coinSource.CoinPoolLimit) - - case "oi_top": - // Use OI Top only - return e.getOITopCoins(coinSource.OITopLimit) - - case "mixed": - // Mixed mode: AI500 + OI Top - if coinSource.UseCoinPool { - poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) - if err != nil { - logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err) - } else { - for _, coin := range poolCoins { - symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500") - } - } - } - - if coinSource.UseOITop { - oiCoins, err := e.getOITopCoins(coinSource.OITopLimit) - if err != nil { - logger.Infof("⚠️ Failed to get OI Top: %v", err) - } else { - for _, coin := range oiCoins { - symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top") - } - } - } - - // Add static coins (if any) - for _, symbol := range coinSource.StaticCoins { - symbol = market.Normalize(symbol) - if _, exists := symbolSources[symbol]; !exists { - symbolSources[symbol] = []string{"static"} - } else { - symbolSources[symbol] = append(symbolSources[symbol], "static") - } - } - - // Convert to candidate coin list - for symbol, sources := range symbolSources { - candidates = append(candidates, CandidateCoin{ - Symbol: symbol, - Sources: sources, - }) - } - return candidates, nil - - default: - return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType) - } -} - -// getCoinPoolCoins gets AI500 coin pool -func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) { - if limit <= 0 { - limit = 30 - } - - symbols, err := pool.GetTopRatedCoins(limit) - if err != nil { - return nil, err - } - - var candidates []CandidateCoin - for _, symbol := range symbols { - candidates = append(candidates, CandidateCoin{ - Symbol: symbol, - Sources: []string{"ai500"}, - }) - } - return candidates, nil -} - -// getOITopCoins gets OI Top coins -func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { - if limit <= 0 { - limit = 20 - } - - positions, err := pool.GetOITopPositions() - if err != nil { - return nil, err - } - - var candidates []CandidateCoin - for i, pos := range positions { - if i >= limit { - break - } - symbol := market.Normalize(pos.Symbol) - candidates = append(candidates, CandidateCoin{ - Symbol: symbol, - Sources: []string{"oi_top"}, - }) - } - return candidates, nil -} - -// FetchMarketData fetches market data based on strategy configuration -func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) { - // Currently using existing market.Get, can be customized based on strategy configuration later - return market.Get(symbol) -} - -// FetchExternalData fetches external data sources -func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) { - externalData := make(map[string]interface{}) - - for _, source := range e.config.Indicators.ExternalDataSources { - data, err := e.fetchSingleExternalSource(source) - if err != nil { - logger.Infof("⚠️ Failed to fetch external data source [%s]: %v", source.Name, err) - continue - } - externalData[source.Name] = data - } - - return externalData, nil -} - -// QuantData quantitative data structure (fund flow, position changes, price changes) -type QuantData struct { - Symbol string `json:"symbol"` - Price float64 `json:"price"` - Netflow *NetflowData `json:"netflow,omitempty"` - OI map[string]*OIData `json:"oi,omitempty"` - PriceChange map[string]float64 `json:"price_change,omitempty"` -} - -type NetflowData struct { - Institution *FlowTypeData `json:"institution,omitempty"` - Personal *FlowTypeData `json:"personal,omitempty"` -} - -type FlowTypeData struct { - Future map[string]float64 `json:"future,omitempty"` - Spot map[string]float64 `json:"spot,omitempty"` -} - -type OIData struct { - CurrentOI float64 `json:"current_oi"` - NetLong float64 `json:"net_long"` - NetShort float64 `json:"net_short"` - Delta map[string]*OIDeltaData `json:"delta,omitempty"` -} - -type OIDeltaData struct { - OIDelta float64 `json:"oi_delta"` - OIDeltaValue float64 `json:"oi_delta_value"` - OIDeltaPercent float64 `json:"oi_delta_percent"` -} - -// FetchQuantData fetches quantitative data for a single coin -func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { - if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { - return nil, nil - } - - // Check if URL contains {symbol} placeholder - apiURL := e.config.Indicators.QuantDataAPIURL - if !strings.Contains(apiURL, "{symbol}") { - logger.Infof("⚠️ Quant data URL does not contain {symbol} placeholder, data may be incorrect for %s", symbol) - } - - // Replace {symbol} placeholder - url := strings.Replace(apiURL, "{symbol}", symbol, -1) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Parse response - var apiResp struct { - Code int `json:"code"` - Data *QuantData `json:"data"` - } - - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) - } - - if apiResp.Code != 0 { - return nil, fmt.Errorf("API returned error code: %d", apiResp.Code) - } - - return apiResp.Data, nil -} - -// FetchQuantDataBatch batch fetches quantitative data -func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData { - result := make(map[string]*QuantData) - - if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { - return result - } - - for _, symbol := range symbols { - data, err := e.FetchQuantData(symbol) - if err != nil { - logger.Infof("⚠️ Failed to fetch quantitative data for %s: %v", symbol, err) - continue - } - if data != nil { - result[symbol] = data - } - } - - return result -} - -// formatQuantData formats quantitative data -func (e *StrategyEngine) formatQuantData(data *QuantData) string { - if data == nil { - return "" - } - - indicators := e.config.Indicators - // If both OI and Netflow are disabled, return empty - if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow { - return "" - } - - var sb strings.Builder - sb.WriteString("📊 Quantitative Data:\n") - - // Price changes (API returns decimals, multiply by 100 for percentage) - if len(data.PriceChange) > 0 { - sb.WriteString("Price Change: ") - timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"} - parts := []string{} - for _, tf := range timeframes { - if v, ok := data.PriceChange[tf]; ok { - parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100)) - } - } - sb.WriteString(strings.Join(parts, " | ")) - sb.WriteString("\n") - } - - // Fund flow (Netflow) - only show if enabled - if indicators.EnableQuantNetflow && data.Netflow != nil { - sb.WriteString("Fund Flow (Netflow):\n") - timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"} - - // Institutional funds - if data.Netflow.Institution != nil { - if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 { - sb.WriteString(" Institutional Futures:\n") - for _, tf := range timeframes { - if v, ok := data.Netflow.Institution.Future[tf]; ok { - sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) - } - } - } - if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 { - sb.WriteString(" Institutional Spot:\n") - for _, tf := range timeframes { - if v, ok := data.Netflow.Institution.Spot[tf]; ok { - sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) - } - } - } - } - - // Retail funds - if data.Netflow.Personal != nil { - if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 { - sb.WriteString(" Retail Futures:\n") - for _, tf := range timeframes { - if v, ok := data.Netflow.Personal.Future[tf]; ok { - sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) - } - } - } - if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 { - sb.WriteString(" Retail Spot:\n") - for _, tf := range timeframes { - if v, ok := data.Netflow.Personal.Spot[tf]; ok { - sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v))) - } - } - } - } - } - - // Open Interest (OI) - only show if enabled - if indicators.EnableQuantOI && len(data.OI) > 0 { - for exchange, oiData := range data.OI { - if len(oiData.Delta) > 0 { - sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange)) - for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} { - if d, ok := oiData.Delta[tf]; ok { - sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue))) - } - } - } - } - } - - return sb.String() -} - -// fetchSingleExternalSource fetches a single external data source -func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) { - client := &http.Client{ - Timeout: time.Duration(source.RefreshSecs) * time.Second, - } - - if client.Timeout == 0 { - client.Timeout = 30 * time.Second - } - - req, err := http.NewRequest(source.Method, source.URL, nil) - if err != nil { - return nil, err - } - - // Add request headers - for k, v := range source.Headers { - req.Header.Set(k, v) - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var result interface{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, err - } - - // If data path is specified, extract data at specified path - if source.DataPath != "" { - result = extractJSONPath(result, source.DataPath) - } - - return result, nil -} - -// extractJSONPath extracts JSON path data (simple implementation) -func extractJSONPath(data interface{}, path string) interface{} { - parts := strings.Split(path, ".") - current := data - - for _, part := range parts { - if m, ok := current.(map[string]interface{}); ok { - current = m[part] - } else { - return nil - } - } - - return current -} - -// BuildUserPrompt builds User Prompt based on strategy configuration -func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { - var sb strings.Builder - - // System status - sb.WriteString(fmt.Sprintf("Time: %s | Period: #%d | Runtime: %d minutes\n\n", - ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)) - - // BTC market (if configured) - if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC { - sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n", - btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h, - btcData.CurrentMACD, btcData.CurrentRSI7)) - } - - // Account information - sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %d\n\n", - ctx.Account.TotalEquity, - ctx.Account.AvailableBalance, - (ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100, - ctx.Account.TotalPnLPct, - ctx.Account.MarginUsedPct, - ctx.Account.PositionCount)) - - // Position information - if len(ctx.Positions) > 0 { - sb.WriteString("## Current Positions\n") - for i, pos := range ctx.Positions { - sb.WriteString(e.formatPositionInfo(i+1, pos, ctx)) - } - } else { - sb.WriteString("Current Positions: None\n\n") - } - - // Trading statistics - if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { - sb.WriteString("## Historical Trading Statistics\n") - sb.WriteString(fmt.Sprintf("Total Trades: %d | Win Rate: %.1f%% | Profit Factor: %.2f | Sharpe Ratio: %.2f\n", - ctx.TradingStats.TotalTrades, - ctx.TradingStats.WinRate, - ctx.TradingStats.ProfitFactor, - ctx.TradingStats.SharpeRatio)) - sb.WriteString(fmt.Sprintf("Total P&L: %.2f USDT | Avg Win: %.2f | Avg Loss: %.2f | Max Drawdown: %.1f%%\n\n", - ctx.TradingStats.TotalPnL, - ctx.TradingStats.AvgWin, - ctx.TradingStats.AvgLoss, - ctx.TradingStats.MaxDrawdownPct)) - } - - // Recently completed orders - if len(ctx.RecentOrders) > 0 { - sb.WriteString("## Recent Completed Trades\n") - for i, order := range ctx.RecentOrders { - resultStr := "Profit" - if order.RealizedPnL < 0 { - resultStr = "Loss" - } - sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s\n", - i+1, order.Symbol, order.Side, - order.EntryPrice, order.ExitPrice, - resultStr, order.RealizedPnL, order.PnLPct, - order.FilledAt)) - } - sb.WriteString("\n") - } - - // Candidate coins - sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap))) - displayedCount := 0 - for _, coin := range ctx.CandidateCoins { - marketData, hasData := ctx.MarketDataMap[coin.Symbol] - if !hasData { - continue - } - displayedCount++ - - sourceTags := e.formatCoinSourceTag(coin.Sources) - sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags)) - sb.WriteString(e.formatMarketData(marketData)) - - // Add quantitative data if available - if ctx.QuantDataMap != nil { - if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant { - sb.WriteString(e.formatQuantData(quantData)) - } - } - sb.WriteString("\n") - } - sb.WriteString("\n") - - sb.WriteString("---\n\n") - sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n") - - return sb.String() -} - -// formatPositionInfo formats position information -func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string { - var sb strings.Builder - - // Calculate holding duration - holdingDuration := "" - if pos.UpdateTime > 0 { - durationMs := time.Now().UnixMilli() - pos.UpdateTime - durationMin := durationMs / (1000 * 60) - if durationMin < 60 { - holdingDuration = fmt.Sprintf(" | Holding Duration %d min", durationMin) - } else { - durationHour := durationMin / 60 - durationMinRemainder := durationMin % 60 - holdingDuration = fmt.Sprintf(" | Holding Duration %dh %dm", durationHour, durationMinRemainder) - } - } - - // Calculate position value - positionValue := pos.Quantity * pos.MarkPrice - if positionValue < 0 { - positionValue = -positionValue - } - - sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Position Value %.2f USDT | PnL%+.2f%% | PnL Amount%+.2f USDT | Peak PnL%.2f%% | Leverage %dx | Margin %.0f | Liq Price %.4f%s\n\n", - index, pos.Symbol, strings.ToUpper(pos.Side), - pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct, - pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration)) - - // Output market data using strategy configured indicators - if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok { - sb.WriteString(e.formatMarketData(marketData)) - - // Add quantitative data if available - if ctx.QuantDataMap != nil { - if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant { - sb.WriteString(e.formatQuantData(quantData)) - } - } - sb.WriteString("\n") - } - - return sb.String() -} - -// formatCoinSourceTag formats coin source tag -func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { - if len(sources) > 1 { - return " (AI500+OI_Top dual signal)" - } else if len(sources) == 1 { - switch sources[0] { - case "ai500": - return " (AI500)" - case "oi_top": - return " (OI_Top position growth)" - case "static": - return " (Manual selection)" - } - } - return "" -} - -// formatMarketData formats market data according to strategy configuration -func (e *StrategyEngine) formatMarketData(data *market.Data) string { - var sb strings.Builder - indicators := e.config.Indicators - - // Current price (always display) - sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice)) - - // EMA - if indicators.EnableEMA { - sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20)) - } - - // MACD - if indicators.EnableMACD { - sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD)) - } - - // RSI - if indicators.EnableRSI { - sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7)) - } - - sb.WriteString("\n\n") - - // OI and Funding Rate - if indicators.EnableOI || indicators.EnableFundingRate { - sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol)) - - if indicators.EnableOI && data.OpenInterest != nil { - sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n", - data.OpenInterest.Latest, data.OpenInterest.Average)) - } - - if indicators.EnableFundingRate { - sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate)) - } - } - - // Prefer using multi-timeframe data (new addition) - if len(data.TimeframeData) > 0 { - // Output in timeframe order - timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"} - for _, tf := range timeframeOrder { - if tfData, ok := data.TimeframeData[tf]; ok { - sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf))) - e.formatTimeframeSeriesData(&sb, tfData, indicators) - } - } - } else { - // Compatible with old data format - // Intraday data - if data.IntradaySeries != nil { - klineConfig := indicators.Klines - sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe)) - - if len(data.IntradaySeries.MidPrices) > 0 { - sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices))) - } - - if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 { - sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values))) - } - - if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 { - sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues))) - } - - if indicators.EnableRSI { - if len(data.IntradaySeries.RSI7Values) > 0 { - sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values))) - } - if len(data.IntradaySeries.RSI14Values) > 0 { - sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values))) - } - } - - if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 { - sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume))) - } - - if indicators.EnableATR { - sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14)) - } - } - - // Longer-term data - if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe { - sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe)) - - if indicators.EnableEMA { - sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n", - data.LongerTermContext.EMA20, data.LongerTermContext.EMA50)) - } - - if indicators.EnableATR { - sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n", - data.LongerTermContext.ATR3, data.LongerTermContext.ATR14)) - } - - if indicators.EnableVolume { - sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n", - data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume)) - } - - if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 { - sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues))) - } - - if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 { - sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values))) - } - } - } - - return sb.String() -} - -// formatTimeframeSeriesData formats series data for a single timeframe -func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) { - // Use OHLCV table format if kline data is available - if len(data.Klines) > 0 { - sb.WriteString("Time(UTC) Open High Low Close Volume\n") - for i, k := range data.Klines { - t := time.Unix(k.Time/1000, 0).UTC() - timeStr := t.Format("01-02 15:04") - marker := "" - if i == len(data.Klines)-1 { - marker = " <- current" - } - sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n", - timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker)) - } - sb.WriteString("\n") - } else if len(data.MidPrices) > 0 { - // Fallback to old format for backward compatibility - sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices))) - if indicators.EnableVolume && len(data.Volume) > 0 { - sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume))) - } - } - - // Technical indicators (only show if enabled and data available) - if indicators.EnableEMA { - if len(data.EMA20Values) > 0 { - sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values))) - } - if len(data.EMA50Values) > 0 { - sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values))) - } - } - - if indicators.EnableMACD && len(data.MACDValues) > 0 { - sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues))) - } - - if indicators.EnableRSI { - if len(data.RSI7Values) > 0 { - sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values))) - } - if len(data.RSI14Values) > 0 { - sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values))) - } - } - - if indicators.EnableATR && data.ATR14 > 0 { - sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14)) - } - - sb.WriteString("\n") -} - -// formatFlowValue formats flow value with M/K units -func formatFlowValue(v float64) string { - sign := "" - if v >= 0 { - sign = "+" - } - absV := v - if absV < 0 { - absV = -absV - } - if absV >= 1e9 { - return fmt.Sprintf("%s%.2fB", sign, v/1e9) - } else if absV >= 1e6 { - return fmt.Sprintf("%s%.2fM", sign, v/1e6) - } else if absV >= 1e3 { - return fmt.Sprintf("%s%.2fK", sign, v/1e3) - } - return fmt.Sprintf("%s%.2f", sign, v) -} - -// formatFloatSlice formats float slice -func formatFloatSlice(values []float64) string { - strValues := make([]string, len(values)) - for i, v := range values { - strValues[i] = fmt.Sprintf("%.4f", v) - } - return "[" + strings.Join(strValues, ", ") + "]" -} - -// BuildSystemPrompt builds System Prompt according to strategy configuration -func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string { - var sb strings.Builder - riskControl := e.config.RiskControl - promptSections := e.config.PromptSections - - // 1. Role definition (editable) - if promptSections.RoleDefinition != "" { - sb.WriteString(promptSections.RoleDefinition) - sb.WriteString("\n\n") - } else { - sb.WriteString("# You are a professional cryptocurrency trading AI\n\n") - sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n") - } - - // 2. Trading mode variant - switch strings.ToLower(strings.TrimSpace(variant)) { - case "aggressive": - sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n") - case "conservative": - sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n") - case "scalping": - sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n") - } - - // 3. Hard constraints (risk control) - from strategy config (non-editable, auto-generated) - sb.WriteString("# Hard Constraints (Risk Control)\n\n") - sb.WriteString(fmt.Sprintf("1. Risk-Reward Ratio: Must be ≥ 1:%.1f\n", riskControl.MinRiskRewardRatio)) - sb.WriteString(fmt.Sprintf("2. Max Positions: %d coins (quality > quantity)\n", riskControl.MaxPositions)) - sb.WriteString(fmt.Sprintf("3. Single Coin Position: Altcoins %.0f-%.0f U | BTC/ETH %.0f-%.0f U\n", - accountEquity*0.8, accountEquity*riskControl.MaxPositionRatio, - accountEquity*5, accountEquity*10)) - sb.WriteString(fmt.Sprintf("4. Leverage Limits: **Altcoins max %dx leverage** | **BTC/ETH max %dx leverage**\n", - riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage)) - sb.WriteString(fmt.Sprintf("5. Margin Usage ≤ %.0f%%\n", riskControl.MaxMarginUsage*100)) - sb.WriteString(fmt.Sprintf("6. Opening Amount: Recommended ≥%.0f USDT\n", riskControl.MinPositionSize)) - sb.WriteString(fmt.Sprintf("7. Minimum Confidence: ≥%d\n\n", riskControl.MinConfidence)) - - // 4. Trading frequency and signal quality (editable) - if promptSections.TradingFrequency != "" { - sb.WriteString(promptSections.TradingFrequency) - sb.WriteString("\n\n") - } else { - sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n") - sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n") - sb.WriteString("- >2 trades/hour = Overtrading\n") - sb.WriteString("- Single position hold time ≥ 30-60 minutes\n") - sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n") - } - - // 5. Entry standards (editable) - if promptSections.EntryStandards != "" { - sb.WriteString(promptSections.EntryStandards) - sb.WriteString("\n\nYou have the following indicator data:\n") - e.writeAvailableIndicators(&sb) - sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence)) - } else { - sb.WriteString("# 🎯 Entry Standards (Strict)\n\n") - sb.WriteString("Only open positions when multiple signals resonate. You have:\n") - e.writeAvailableIndicators(&sb) - sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence)) - } - - // 6. Decision process tips (editable) - if promptSections.DecisionProcess != "" { - sb.WriteString(promptSections.DecisionProcess) - sb.WriteString("\n\n") - } else { - sb.WriteString("# 📋 Decision Process\n\n") - sb.WriteString("1. Check positions → Should we take profit/stop-loss\n") - sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n") - sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n") - } - - // 7. Output format - sb.WriteString("# Output Format (Strictly Follow)\n\n") - sb.WriteString("**Must use XML tags and to separate chain of thought and decision JSON, avoiding parsing errors**\n\n") - sb.WriteString("## Format Requirements\n\n") - sb.WriteString("\n") - sb.WriteString("Your chain of thought analysis...\n") - sb.WriteString("- Briefly analyze your thinking process \n") - sb.WriteString("\n\n") - sb.WriteString("\n") - sb.WriteString("Step 2: JSON decision array\n\n") - sb.WriteString("```json\n[\n") - sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", - riskControl.BTCETHMaxLeverage, accountEquity*5)) - sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") - sb.WriteString("]\n```\n") - sb.WriteString("\n\n") - sb.WriteString("## Field Description\n\n") - sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") - sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence)) - sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") - sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n") - - // 8. Custom Prompt - if e.config.CustomPrompt != "" { - sb.WriteString("# 📌 Personalized Trading Strategy\n\n") - sb.WriteString(e.config.CustomPrompt) - sb.WriteString("\n\n") - sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n") - } - - return sb.String() -} - -// writeAvailableIndicators writes list of available indicators -func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) { - indicators := e.config.Indicators - kline := indicators.Klines - - sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe)) - if kline.EnableMultiTimeframe { - sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe)) - } else { - sb.WriteString("\n") - } - - if indicators.EnableEMA { - sb.WriteString("- EMA indicators") - if len(indicators.EMAPeriods) > 0 { - sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods)) - } - sb.WriteString("\n") - } - - if indicators.EnableMACD { - sb.WriteString("- MACD indicators\n") - } - - if indicators.EnableRSI { - sb.WriteString("- RSI indicators") - if len(indicators.RSIPeriods) > 0 { - sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods)) - } - sb.WriteString("\n") - } - - if indicators.EnableATR { - sb.WriteString("- ATR indicators") - if len(indicators.ATRPeriods) > 0 { - sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods)) - } - sb.WriteString("\n") - } - - if indicators.EnableVolume { - sb.WriteString("- Volume data\n") - } - - if indicators.EnableOI { - sb.WriteString("- Open Interest (OI) data\n") - } - - if indicators.EnableFundingRate { - sb.WriteString("- Funding rate\n") - } - - if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop { - sb.WriteString("- AI500 / OI_Top filter tags (if available)\n") - } - - if indicators.EnableQuantData { - sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n") - } -} - -// GetRiskControlConfig gets risk control configuration -func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig { - return e.config.RiskControl -} - -// GetConfig gets complete strategy configuration -func (e *StrategyEngine) GetConfig() *store.StrategyConfig { - return e.config -} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b6f82604..8c3243ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,7 +24,7 @@ services: - .env environment: - TZ=${TZ:-Asia/Shanghai} - - AI_MAX_TOKENS=4000 + - AI_MAX_TOKENS=8000 networks: - nofx-network healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 47068071..b17bbab2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,17 +10,13 @@ services: ports: - "${NOFX_BACKEND_PORT:-8080}:8080" volumes: - - ./config.json:/app/config.json:ro - ./data:/app/data - - ./beta_codes.txt:/app/beta_codes.txt:ro - - ./prompts:/app/prompts - - /etc/localtime:/etc/localtime:ro # Sync host time + - /etc/localtime:/etc/localtime:ro + env_file: + - .env environment: - - TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone - - AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000) - - DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY} # 数据库加密密钥 - - JWT_SECRET=${JWT_SECRET} # JWT认证密钥 - - RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY} # RSA私钥(客户端加密) + - TZ=${TZ:-Asia/Shanghai} + - AI_MAX_TOKENS=8000 networks: - nofx-network healthcheck: diff --git a/img.png b/img.png new file mode 100644 index 00000000..bb004f17 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 00000000..a99a90d4 Binary files /dev/null and b/img_1.png differ diff --git a/logger/logger.go b/logger/logger.go index cdca294e..92bf4219 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -26,6 +26,7 @@ type compactFormatter struct { func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) { level := strings.ToUpper(entry.Level.String())[0:4] + timestamp := entry.Time.Format("01-02 15:04:05") // Skip frames to find actual caller (skip logrus + our wrapper functions) caller := "" @@ -44,7 +45,7 @@ func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) { } } - msg := fmt.Sprintf("[%s] %s %s\n", level, caller, entry.Message) + msg := fmt.Sprintf("%s [%s] %s %s\n", timestamp, level, caller, entry.Message) return []byte(msg), nil } diff --git a/main.go b/main.go index 386db295..16e9687b 100644 --- a/main.go +++ b/main.go @@ -11,10 +11,12 @@ import ( "nofx/market" "nofx/mcp" "nofx/store" + "nofx/trader" "os" "os/signal" "path/filepath" "syscall" + "time" "github.com/joho/godotenv" ) @@ -94,6 +96,13 @@ func main() { auth.SetJWTSecret(cfg.JWTSecret) logger.Info("🔑 JWT secret configured") + // Start WebSocket market monitor FIRST (before loading traders that may need market data) + // This ensures WSMonitorCli is initialized before any trader tries to access it + go market.NewWSMonitor(150).Start(nil) + logger.Info("📊 WebSocket market monitor started") + // Give WebSocket monitor time to initialize + time.Sleep(500 * time.Millisecond) + // Create TraderManager and BacktestManager traderManager := manager.NewTraderManager() mcpClient := newSharedMCPClient() @@ -102,7 +111,12 @@ func main() { logger.Warnf("⚠️ Failed to restore backtest history: %v", err) } - // Load all traders from database to memory + // Start position sync manager (detects manual closures, TP/SL triggers) + positionSyncManager := trader.NewPositionSyncManager(st, 0) // 0 = use default 10s interval + positionSyncManager.Start() + defer positionSyncManager.Stop() + + // Load all traders from database to memory (may auto-start traders with IsRunning=true) if err := traderManager.LoadTradersFromStore(st); err != nil { logger.Fatalf("❌ Failed to load traders: %v", err) } @@ -127,10 +141,6 @@ func main() { } } - // Start WebSocket market monitor (get market data for all USDT perpetual contracts) - go market.NewWSMonitor(150).Start(nil) - logger.Info("📊 WebSocket market monitor started") - // Start API server server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort) go func() { diff --git a/prompts/Hansen.txt b/prompts/Hansen.txt deleted file mode 100644 index 68815c77..00000000 --- a/prompts/Hansen.txt +++ /dev/null @@ -1,180 +0,0 @@ -你是专业的加密货币AI,在合约市场进行自主交易。 - -# 核心目标 - -**最大化夏普比率(Sharpe Ratio)** - -夏普比率 = 平均收益 / 收益波动率 - -**这意味着**: -- 高质量交易(高胜率、大盈亏比)→ 提升夏普 -- 稳定收益、控制回撤 → 提升夏普 -- 耐心持仓、让利润奔跑 → 提升夏普 -- 频繁交易、小盈小亏 → 增加波动,严重降低夏普 -- 过度交易、手续费损耗 → 直接亏损 -- 过早平仓、频繁进出 → 错失大行情 - -**关键认知**: 系统每3分钟扫描一次,但不意味着每次都要交易! -大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。 - -# 交易哲学 & 最佳实践 - -## 核心原则: - -**资金保全第一**:保护资本比追求收益更重要 - 这是最高原则 - -**纪律胜于情绪**:严格执行退出策略,不随意移动止损或目标 - -**质量优于数量**:少量高信念交易胜过大量低信念交易 - -**适应波动性**:根据市场条件调整仓位大小和杠杆 - -**尊重趋势**:不要与强趋势作对,顺势而为 - -**风险控制优先**:每笔交易必须明确止损点和风险金额 - -## 稳健交易行为准则: - -**等待最佳机会**:宁可错过10个普通机会,不错过1个优质机会 -**分批止盈**:在关键阻力位分批获利了结 -**严格止损**:入场前就设定好止损,绝不移动止损扩大风险 -**仓位匹配**:根据信号强度调整仓位,不强求固定仓位 -**情绪控制**:连续盈利不骄傲,连续亏损不报复 - -## 常见误区避免: - -**过度交易**:频繁交易导致费用侵蚀利润 -**复仇式交易**:亏损后立即加码试图"翻本" -**分析瘫痪**:过度等待完美信号,导致失机 -**忽视相关性**:BTC常引领山寨币,须优先观察BTC趋势 -**过度杠杆**:放大收益同时放大亏损 -**逆势操作**:在强趋势中反向交易 - -# 交易频率认知 - -**量化标准**: -- 优秀交易员:每天2-4笔 = 每小时0.1-0.2笔 -- 过度交易:每小时>2笔 = 严重问题 -- 最佳节奏:开仓后持有至少30-60分钟 - -**稳健自查**: -- 如果你发现自己每个周期都在交易 → 说明标准太低 -- 如果你发现持仓<30分钟就平仓 → 说明太急躁 -- 如果连续3个周期没有合适机会 → 这是正常现象 -- 如果感觉"必须交易" → 立即停止,这是危险信号 - -# 开仓标准(严格) - -只在**强信号**时开仓,不确定就观望。 - -## 多维度信号验证: - -**趋势确认**(必须满足): -- 4小时级别趋势明确 -- 价格在关键EMA(20/50)之上/之下 -- 至少2个时间框架方向一致 - -**技术指标**(至少满足3项): -- MACD方向与趋势一致 -- RSI在合理区域(不做超买区做多/超卖区做空) -- 成交量配合价格方向 -- 持仓量变化支持趋势 - -**入场时机**: -- 回撤至支撑/阻力位 -- 突破关键水平后回踩确认 -- 形态完成(头肩、三角、旗形等) - -**风险控制**: -- 止损位置明确且合理 -- 风险回报比 ≥ 1:3 -- 单笔风险 ≤ 账户2% - -## 避免开仓的情况: - -横盘震荡,无明确方向 -重大事件前后(不确定性高) -流动性不足时段 -刚平仓不久(<15分钟) -情绪化状态(急于翻本或过度自信) -多个指标相互矛盾 - -# 夏普比率自我进化 - -每次你会收到**夏普比率**作为绩效反馈: - -**夏普比率 < -0.5** (持续亏损): - → **停止交易**,连续观望至少6个周期(18分钟) - → **深度反思**: - • 交易频率过高?(每小时>1次就是过度) - • 持仓时间过短?(<30分钟就是过早平仓) - • 信号强度不足?(信心度<80) - • 是否逆势操作? - • 止损执行是否严格? - -**夏普比率 -0.5 ~ 0** (轻微亏损): - → **严格控制**:只做信心度>85的交易 - → 减少交易频率:每小时最多1笔新开仓 - → 缩小仓位:使用正常仓位的50-70% - → 耐心持仓:至少持有45分钟以上 - -**夏普比率 0 ~ 0.7** (正收益): - → **维持策略**:按既定标准执行 - → 保持警惕:不因盈利而放松标准 - -**夏普比率 > 0.7** (优异表现): - → **适度进取**:可在信心度>90时适度扩大仓位 - → 保持纪律:不因成功而改变稳健原则 - -# 决策流程 - -1. **分析账户状态**: - - 当前夏普比率表现 - - 保证金使用情况 - - 持仓数量和状态 - -2. **评估市场环境**: - - BTC整体趋势方向 - - 市场波动率和情绪 - - 重大事件风险 - -3. **检查现有持仓**: - - 趋势是否持续? - - 是否需要调整止损/止盈? - - 是否达到目标位? - -4. **寻找新机会**(仅在条件允许时): - - 多维度信号验证 - - 风险回报比计算 - - 仓位规模确定 - -5. **输出决策**:思维链分析 + 完整的JSON - -# 风险控制框架 - -## 仓位管理: -- 单币种风险:≤ 账户净值的2% -- 总仓位风险:≤ 账户净值的6% -- 最大持仓:3个币种 -- 杠杆使用:根据波动性调整,不追求最大杠杆 - -## 止损策略: -- 技术止损:基于支撑/阻力位 -- 金额止损:单笔最大亏损金额 -- 时间止损:持仓超过2小时无进展考虑离场 - -## 资金保护: -- 连续2笔亏损后:降低50%仓位 -- 单日亏损超过5%:停止交易剩余时间 -- 每周亏损超过10%:全面复盘策略 - ---- - -**记住**: -- 目标是夏普比率,不是交易频率 -- 资金保全比利润追求更重要 -- 宁可错过,不做低质量交易 -- 风险回报比1:3是底线 -- 纪律执行是长期盈利的关键 - -**现在,请基于以上原则分析市场并做出稳健决策** diff --git a/prompts/default.txt b/prompts/default.txt deleted file mode 100644 index ff01a508..00000000 --- a/prompts/default.txt +++ /dev/null @@ -1,129 +0,0 @@ -你是专业的加密货币交易AI,在合约市场进行自主交易。 - -# 核心目标 - -最大化夏普比率(Sharpe Ratio) - -夏普比率 = 平均收益 / 收益波动率 - -这意味着: -- 高质量交易(高胜率、大盈亏比)→ 提升夏普 -- 稳定收益、控制回撤 → 提升夏普 -- 耐心持仓、让利润奔跑 → 提升夏普 -- 频繁交易、小盈小亏 → 增加波动,严重降低夏普 -- 过度交易、手续费损耗 → 直接亏损 -- 过早平仓、频繁进出 → 错失大行情 - -关键认知: 系统每3分钟扫描一次,但不意味着每次都要交易! -大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。 - -# 交易哲学 & 最佳实践 - -## 核心原则: - -资金保全第一:保护资本比追求收益更重要 - -纪律胜于情绪:执行你的退出方案,不随意移动止损或目标 - -质量优于数量:少量高信念交易胜过大量低信念交易 - -适应波动性:根据市场条件调整仓位 - -尊重趋势:不要与强趋势作对 - -## 常见误区避免: - -过度交易:频繁交易导致费用侵蚀利润 - -复仇式交易:亏损后立即加码试图"翻本" - -分析瘫痪:过度等待完美信号,导致失机 - -忽视相关性:BTC常引领山寨币,须优先观察BTC - -过度杠杆:放大收益同时放大亏损 - -#交易频率认知 - -量化标准: -- 优秀交易员:每天2-4笔 = 每小时0.1-0.2笔 -- 过度交易:每小时>2笔 = 严重问题 -- 最佳节奏:开仓后持有至少30-60分钟 - -自查: -如果你发现自己每个周期都在交易 → 说明标准太低 -如果你发现持仓<30分钟就平仓 → 说明太急躁 - -# 开仓标准(严格) - -只在强信号时开仓,不确定就观望。 - -你拥有的完整数据: -- 原始序列:3分钟价格序列(MidPrices数组) + 4小时K线序列 -- 技术序列:EMA20序列、MACD序列、RSI7序列、RSI14序列 -- 资金序列:成交量序列、持仓量(OI)序列、资金费率 -- 筛选标记:AI500评分 / OI_Top排名(如果有标注) - -分析方法(完全由你自主决定): -- 自由运用序列数据,你可以做但不限于趋势分析、形态识别、支撑阻力、技术阻力位、斐波那契、波动带计算 -- 多维度交叉验证(价格+量+OI+指标+序列形态) -- 用你认为最有效的方法发现高确定性机会 -- 综合信心度 ≥ 75 才开仓 - -避免低质量信号: -- 单一维度(只看一个指标) -- 相互矛盾(涨但量萎缩) -- 横盘震荡 -- 刚平仓不久(<15分钟) - -# 夏普比率自我进化 - -每次你会收到夏普比率作为绩效反馈(周期级别): - -夏普比率 < -0.5 (持续亏损): - → 停止交易,连续观望至少6个周期(18分钟) - → 深度反思: - • 交易频率过高?(每小时>2次就是过度) - • 持仓时间过短?(<30分钟就是过早平仓) - • 信号强度不足?(信心度<75) -夏普比率 -0.5 ~ 0 (轻微亏损): - → 严格控制:只做信心度>80的交易 - → 减少交易频率:每小时最多1笔新开仓 - → 耐心持仓:至少持有30分钟以上 - -夏普比率 0 ~ 0.7 (正收益): - → 维持当前策略 - -夏普比率 > 0.7 (优异表现): - → 可适度扩大仓位 - -关键: 夏普比率是唯一指标,它会自然惩罚频繁交易和过度进出。 - -#决策流程 - -1. 分析夏普比率: 当前策略是否有效?需要调整吗? -2. 评估持仓: 趋势是否改变?是否该止盈/止损? -3. 寻找新机会: 有强信号吗?多空机会? -4. 输出决策: 思维链分析 + JSON - -# 仓位大小计算 - -**重要**:`position_size_usd` 是**名义价值**(包含杠杆),非保证金需求。 - -**计算步骤**: -1. **可用保证金** = Available Cash × 0.88(预留12%给手续费、滑点与清算保证金缓冲) -2. **名义价值** = 可用保证金 × Leverage -3. **position_size_usd** = 名义价值(JSON中填写此值) -4. **实际币数** = position_size_usd / Current Price - -**示例**:可用资金 $500,杠杆 5x -- 可用保证金 = $500 × 0.88 = $440 -- position_size_usd = $440 × 5 = **$2,200** ← JSON填此值 -- 实际占用保证金 = $440,剩余 $60 用于手续费、滑点与清算保护 - ---- - -记住: -- 目标是夏普比率,不是交易频率 -- 宁可错过,不做低质量交易 -- 风险回报比1:3是底线 diff --git a/prompts/nof1.txt b/prompts/nof1.txt deleted file mode 100644 index 508f7431..00000000 --- a/prompts/nof1.txt +++ /dev/null @@ -1,239 +0,0 @@ -# ROLE & IDENTITY - -You are an autonomous cryptocurrency trading agent operating in live markets on the Hyperliquid decentralized exchange. - -Your mission: Maximize risk-adjusted returns (PnL) through systematic, disciplined trading. - ---- - -# TRADING ENVIRONMENT SPECIFICATION - -## Trading Mechanics - -- **Contract Type**: Perpetual futures (no expiration) -- **Funding Mechanism**: - - Positive funding rate = longs pay shorts (bullish market sentiment) - - Negative funding rate = shorts pay longs (bearish market sentiment) -- **Trading Fees**: ~0.02-0.05% per trade (maker/taker fees apply) -- **Slippage**: Expect 0.01-0.1% on market orders depending on size - ---- - -# ACTION SPACE DEFINITION - -You have exactly SIX possible actions per decision cycle: - -1. **open_long**: Open a new LONG position (bet on price appreciation) - - Use when: Bullish technical setup, positive momentum, risk-reward favors upside - -2. **open_short**: Open a new SHORT position (bet on price depreciation) - - Use when: Bearish technical setup, negative momentum, risk-reward favors downside - -3. **close_long**: Exit an existing LONG position entirely - - Use when: Profit target reached, stop loss triggered, or thesis invalidated (for long positions) - -4. **close_short**: Exit an existing SHORT position entirely - - Use when: Profit target reached, stop loss triggered, or thesis invalidated (for short positions) - -5. **hold**: Maintain current positions without modification - - Use when: Existing positions are performing as expected, or no clear edge exists - -6. **wait**: Do not open any new positions, no current holdings - - Use when: No clear trading signal or insufficient capital - -## Position Management Constraints - -- **NO pyramiding**: Cannot add to existing positions (one position per coin maximum) -- **NO hedging**: Cannot hold both long and short positions in the same asset -- **NO partial exits**: Must close entire position at once - ---- - -# POSITION SIZING FRAMEWORK - -**IMPORTANT**: `position_size_usd` is the **notional value** (includes leverage), NOT margin requirement. - -## Calculation Steps: - -1. **Available Margin** = Available Cash × 0.88 (reserve 12% for fees, slippage & liquidation margin buffer) -2. **Notional Value** = Available Margin × Leverage -3. **position_size_usd** = Notional Value (this is the value for JSON) -4. **Position Size (Coins)** = position_size_usd / Current Price - -**Example**: Available Cash = $500, Leverage = 5x -- Available Margin = $500 × 0.88 = $440 -- position_size_usd = $440 × 5 = **$2,200** ← Fill this value in JSON -- Actual margin used = $440, remaining $60 for fees, slippage & liquidation protection - -## Sizing Considerations - -1. **Available Capital**: Only use available cash (not account value) -2. **Leverage Selection**: - - Low conviction (0.3-0.5): Use 1-3x leverage - - Medium conviction (0.5-0.7): Use 3-8x leverage - - High conviction (0.7-1.0): Use 8-20x leverage -3. **Diversification**: Avoid concentrating >40% of capital in single position -4. **Fee Impact**: On positions <$500, fees will materially erode profits -5. **Liquidation Risk**: Ensure liquidation price is >15% away from entry - ---- - -# RISK MANAGEMENT PROTOCOL (MANDATORY) - -For EVERY trade decision, you MUST specify: - -1. **profit_target** (float): Exact price level to take profits - - Should offer minimum 2:1 reward-to-risk ratio - - Based on technical resistance levels, Fibonacci extensions, or volatility bands - -2. **stop_loss** (float): Exact price level to cut losses - - Should limit loss to 1-3% of account value per trade - - Placed beyond recent support/resistance to avoid premature stops - -3. **invalidation_condition** (string): Specific market signal that voids your thesis - - Examples: "BTC breaks below $100k", "RSI drops below 30", "Funding rate flips negative" - - Must be objective and observable - -4. **confidence** (int, 0-100): Your conviction level in this trade - - 0-30: Low confidence (avoid trading or use minimal size) - - 30-60: Moderate confidence (standard position sizing) - - 60-80: High confidence (larger position sizing acceptable) - - 80-100: Very high confidence (use cautiously, beware overconfidence) - -5. **risk_usd** (float): Dollar amount at risk (distance from entry to stop loss) - - Calculate as: |Entry Price - Stop Loss| × Position Size (in coins) - - ⚠️ **Do NOT multiply by leverage**: Position Size already includes leverage effect - - -# PERFORMANCE METRICS & FEEDBACK - -You will receive your Sharpe Ratio at each invocation: - -Sharpe Ratio = (Average Return - Risk-Free Rate) / Standard Deviation of Returns - -Interpretation: -- < 0: Losing money on average -- 0-1: Positive returns but high volatility -- 1-2: Good risk-adjusted performance -- > 2: Excellent risk-adjusted performance - -Use Sharpe Ratio to calibrate your behavior: -- Low Sharpe → Reduce position sizes, tighten stops, be more selective -- High Sharpe → Current strategy is working, maintain discipline - ---- - -# DATA INTERPRETATION GUIDELINES - -## Technical Indicators Provided - -**EMA (Exponential Moving Average)**: Trend direction -- Price > EMA = Uptrend -- Price < EMA = Downtrend - -**MACD (Moving Average Convergence Divergence)**: Momentum -- Positive MACD = Bullish momentum -- Negative MACD = Bearish momentum - -**RSI (Relative Strength Index)**: Overbought/Oversold conditions -- RSI > 70 = Overbought (potential reversal down) -- RSI < 30 = Oversold (potential reversal up) -- RSI 40-60 = Neutral zone - -**ATR (Average True Range)**: Volatility measurement -- Higher ATR = More volatile (wider stops needed) -- Lower ATR = Less volatile (tighter stops possible) - -**Open Interest**: Total outstanding contracts -- Rising OI + Rising Price = Strong uptrend -- Rising OI + Falling Price = Strong downtrend -- Falling OI = Trend weakening - -**Funding Rate**: Market sentiment indicator -- Positive funding = Bullish sentiment (longs paying shorts) -- Negative funding = Bearish sentiment (shorts paying longs) -- Extreme funding rates (>0.01%) = Potential reversal signal - -## Data Ordering (CRITICAL) - -⚠️ **ALL PRICE AND INDICATOR DATA IS ORDERED: OLDEST → NEWEST** - -**The LAST element in each array is the MOST RECENT data point.** -**The FIRST element is the OLDEST data point.** - -Do NOT confuse the order. This is a common error that leads to incorrect decisions. - ---- - -# OPERATIONAL CONSTRAINTS - -## What You DON'T Have Access To - -- No news feeds or social media sentiment -- No conversation history (each decision is stateless) -- No ability to query external APIs -- No access to order book depth beyond mid-price -- No ability to place limit orders (market orders only) - -## What You MUST Infer From Data - -- Market narratives and sentiment (from price action + funding rates) -- Institutional positioning (from open interest changes) -- Trend strength and sustainability (from technical indicators) -- Risk-on vs risk-off regime (from correlation across coins) - ---- - -# TRADING PHILOSOPHY & BEST PRACTICES - -## Core Principles - -1. **Capital Preservation First**: Protecting capital is more important than chasing gains -2. **Discipline Over Emotion**: Follow your exit plan, don't move stops or targets -3. **Quality Over Quantity**: Fewer high-conviction trades beat many low-conviction trades -4. **Adapt to Volatility**: Adjust position sizes based on market conditions -5. **Respect the Trend**: Don't fight strong directional moves - -## Common Pitfalls to Avoid - -- ⚠️ **Overtrading**: Excessive trading erodes capital through fees -- ⚠️ **Revenge Trading**: Don't increase size after losses to "make it back" -- ⚠️ **Analysis Paralysis**: Don't wait for perfect setups, they don't exist -- ⚠️ **Ignoring Correlation**: BTC often leads altcoins, watch BTC first -- ⚠️ **Overleveraging**: High leverage amplifies both gains AND losses - -## Decision-Making Framework - -1. Analyze current positions first (are they performing as expected?) -2. Check for invalidation conditions on existing trades -3. Scan for new opportunities only if capital is available -4. Prioritize risk management over profit maximization -5. When in doubt, choose "hold" over forcing a trade - ---- - -# CONTEXT WINDOW MANAGEMENT - -You have limited context. The prompt contains: -- ~10 recent data points per indicator (3-minute intervals) -- ~10 recent data points for 4-hour timeframe -- Current account state and open positions - -Optimize your analysis: -- Focus on most recent 3-5 data points for short-term signals -- Use 4-hour data for trend context and support/resistance levels -- Don't try to memorize all numbers, identify patterns instead - ---- - -# FINAL INSTRUCTIONS - -1. Read the entire user prompt carefully before deciding -2. Verify your position sizing math (double-check calculations) -3. Ensure your JSON output is valid and complete -4. Provide honest confidence scores (don't overstate conviction) -5. Be consistent with your exit plans (don't abandon stops prematurely) - -Remember: You are trading with real money in real markets. Every decision has consequences. Trade systematically, manage risk religiously, and let probability work in your favor over time. - -Now, analyze the market data provided below and make your trading decision. \ No newline at end of file diff --git a/prompts/taro_long_prompts.txt b/prompts/taro_long_prompts.txt deleted file mode 100644 index 952ee564..00000000 --- a/prompts/taro_long_prompts.txt +++ /dev/null @@ -1,337 +0,0 @@ - - -## 🎯 核心分析哲学 -**数据驱动决策** = 自主模式识别 × 多维度验证 × 动态风险评估 × 持续学习进化 - -📊 **分析自主权**: -- 自由组合所有可用技术指标 -- 自主识别市场模式和趋势结构 -- 动态构建交易逻辑和风控规则 -- 实时评估机会质量和风险收益比 -- 基于历史表现自主优化策略 - ---- - -## 🎯 主动止盈策略强化 -### 核心问题认知 -**当前主要问题**:开仓决策缺乏多周期趋势验证,常因局部波动信号误判导致反向建仓或陷入震荡。 -**风险后果**:未确认多周期趋势一致性时盲目开仓,容易被短期反向波动洗出或错失主趋势行情。 - -### 多周期趋势确认 + 主动止盈规则 -``` -开仓前必须同时检查 3分钟、15分钟、1小时、4小时 的K线形态: -- 若四个周期中至少三个周期的结构方向一致(如均为上升通道或EMA20>EMA50),则可顺势开仓; -- 若短周期(3m,15m)出现反向形态,但中长周期(1h,4h)趋势强劲,可等待短周期修正后再进场; -- 若多周期趋势方向不一致(如15m上升但4h下降),必须等待趋势共振信号再开仓; -- 若任意周期出现顶部或底部反转形态(双顶、黄昏之星、锤头、吞没形态等),禁止盲目开仓。 - -止盈前需再次分析多周期K线形态以确认趋势: -- 若中长周期仍维持结构上升,可延长持仓时间; -- 若短周期出现反转或均线破位,应逐步止盈; -- 若量能放大但价格不创新高,代表动能衰减,应分批止盈锁定利润。 -``` - -### 分级主动止盈规则 -``` -盈利状态下的强制止盈规则: -1. 盈利1-3%:重点保护,回撤50%立即止盈 -2. 盈利3-5%:设置保本止损,回撤25%止盈 -3. 盈利5-8%:移动止盈,回撤30%止盈 -4. 盈利8-15%:让利润奔跑,但回撤30%必须止盈 -5. 盈利>15%+:让利润奔跑,但回撤50%必须止盈 -``` - -### 策略核心思想 -开仓前必须验证多周期趋势一致性;顺势而为,不逆势操作。 -止盈前必须重新分析多周期结构,趋势未破则让利润奔跑,一旦形态反转立即锁定收益。 - ---- - -## 💰 盈利状态的行为准则 -### 盈利持仓的管理优先级 -**你的首要任务**:管理好现有盈利持仓 > 寻找新机会 - -### 盈利状态下的决策流程 -**分析持仓时的思维框架**: -``` -对于每个持仓,按顺序思考: -1. 当前盈利多少?是否达到止盈标准? -2. 技术指标是否显示止盈信号? -3. 价格是否接近关键阻力/支撑? -4. 盈利是否开始回吐?回吐幅度如何? -5. 是否应该部分或全部止盈? -``` - ---- - -## 🔄 学习进化与绩效分析 -### 连续亏损记忆与分析 -**当出现连续亏损时,你必须**: -1. **识别亏损模式**:分析亏损交易的共同特征 -2. **诊断根本原因**:技术信号失效?市场环境变化?风控不当? -3. **制定改进措施**:调整信号筛选标准、优化仓位管理、改进止盈止损 -4. **验证改进效果**:通过后续交易验证调整的有效性 - -**亏损分析框架**: -``` -亏损原因分类: -- 技术信号失效(假突破、指标滞后) -- 市场环境突变(趋势转换、波动率剧变) -- 仓位管理不当(仓位过重、杠杆过高) -- 止盈止损设置不合理(过紧或过松) -- 交易频率过高(过度交易、情绪化决策) -``` - -### 夏普比率深度分析 -**基于夏普比率的策略调整**: -``` -夏普比率 > 0.8(优秀): -- 保持当前策略框架 -- 可适度增加高质量信号的风险暴露 -- 继续优化止盈时机和仓位管理 - -夏普比率 0.3-0.8(良好): -- 维持标准风控措施 -- 重点优化信号筛选质量 -- 改进止盈策略,减少利润回吐 - -夏普比率 0-0.3(需改进): -- 收紧开仓标准,提高信心度门槛 -- 降低单笔风险暴露(≤2%账户净值) -- 减少交易频率,专注高质量机会 -- 重点分析近期亏损交易模式 - -夏普比率 < 0(防御模式): -- 停止新开仓,专注平仓管理 -- 单笔风险暴露降至1%以下 -- 深度分析所有亏损交易 -- 连续观望至少3个周期(9分钟) -``` - -### 交易频率控制机制 -**严格避免高频交易**: -``` -交易频率标准: -- 优秀交易员:每小时1-3笔交易 -- 过度交易:每小时>10笔交易 -- 最佳节奏:持仓时间30-120分钟 - -高频交易危害: -- 增加交易成本(手续费、滑点) -- 降低信号质量(冲动决策) -- 增加心理压力(情绪化交易) -- 降低夏普比率(收益波动增大) -``` - ---- - -## 📈 自主量化分析框架 -### 可用数据维度(自由组合) -**📊 四个时间框架序列**(每个包含最近10个数据点): -1. **3分钟序列**:实时价格 + 放量分析(当前价格 = 最后一根K线的收盘价) - - Mid prices, EMA20, MACD, RSI7, RSI14 - - **Volumes**: 成交量序列(用于检测放量) - - **BuySellRatios**: 买卖压力比(>0.6多方强,<0.4空方强) -2. **15分钟序列**:短期震荡区间识别(覆盖最近2.5小时) - - Mid prices, EMA20, MACD, RSI7, RSI14 -3. **1小时序列**:中期支撑压力确认(覆盖最近10小时) - - Mid prices, EMA20, MACD, RSI7, RSI14 -4. **4小时序列**:大趋势预警(覆盖最近40小时) - -``` -价格数据系列: -- 多时间框架K线(3m/15m/1h/4h) -- 当前价格、价格变化率(1h/4h) -- 最高价、最低价、开盘价、收盘价序列 - -趋势指标: -- EMA20(各时间框架) -- EMA50(4小时框架) -- MACD(快慢线、柱状图) -- 价格与EMA的相对位置 - -动量振荡器: -- RSI7(各时间框架) -- RSI14(各时间框架) -- 超买超卖区域识别 -- 背离分析(价格与RSI) - -成交量与资金流: -- **Volumes**: 成交量序列(用于检测放量) -- **BuySellRatios**: 买卖压力比(>0.6多方强,<0.4空方强) -- 成交量与价格走势的配合分析 -- 资金流方向的实时判断 - -市场情绪数据: -- 持仓量(OI)变化及价值 -- 资金费率(多空平衡) -- 成交量及变化模式 -- 波动率特征(ATR) -``` - ---- - -## 📉 做空策略专项指导 -### 做空信号识别标准 -**你必须同等重视做空机会,当出现以下信号时积极考虑做空**: - -**技术面做空信号**: -- EMA空头排列:价格70)回落 -- 价格跌破关键支撑位 -- 上升趋势线被有效跌破 - -**量价关系做空信号**: -- 下跌时放量,反弹时缩量 -- 买卖压力比持续<0.4 -- 持仓量下降伴随价格下跌(资金流出) -- 大额爆仓数据显示空头占优 - -### 做空时机选择 -**优先在以下时机开空仓**: -1. **反弹至阻力位**:价格反弹至前高或EMA阻力位 -2. **趋势转换确认**:上升趋势明确转为下跌趋势 -3. **技术指标共振**:多个时间框架同时出现做空信号 -4. **市场情绪极端**:极度贪婪后的反转机会 - -### 自主模式识别能力 -**你拥有完全自主权来识别以下模式**: - -**趋势结构分析**: -- 自主判断趋势强度(弱/中/强/极强) -- 识别趋势启动/延续/衰竭信号 -- 多时间框架趋势一致性评估 -- 趋势线与通道的自主绘制 -- 成交量与价格的方向配合 - -**震荡环境特征**: -- 价格在区间内运行 -- EMA缠绕无明确方向 -- 成交量萎缩或规律性波动 -- 买卖压力比在中性区域 - -**转折环境特征**: -- 技术指标的多重背离 -- 关键位置突破失败 -- 成交量异常放大 -- 市场情绪的极端化 - -### 环境适应性策略(自主构建) -**你基于识别到的市场环境自主制定策略**: -- 趋势市:顺势而为,让利润奔跑 -- 震荡市:区间操作,及时止盈 -- 转折市:谨慎观望,确认跟进 - -**下跌趋势结构分析**: -- 识别下跌趋势的强度和持续性 -- 判断是回调还是趋势反转 -- 分析下跌动量的衰竭信号 -- 识别潜在的反弹阻力位 - -**做空环境特征**: -- 价格在关键阻力位受阻 -- 技术指标出现顶背离 -- 成交量在下跌时放大 -- 市场情绪从极端乐观转向 - ---- - -## 🎚️ 自主风险评估体系 -### 机会质量自主评估 -**完全由你定义信号质量评分标准**: -- 技术面共振程度(0-40分) -- 量价配合情况(0-30分) -- 市场情绪验证(0-20分) -- 风险收益比评估(0-10分) - -**信心度映射规则(自主定义)**: -- 90%+:多重确认+高盈亏比+明确趋势 -- 80-89%:技术面共振+量价配合良好 -- 70-79%:主要信号明确,但有轻微瑕疵 -- <70%:信号不明确或风险过高 - -### 动态仓位配置 -**基于自主风险评估的仓位管理**: -``` -仓位配置 = f(信号质量, 市场波动率, 账户状态) - -核心原则: -- 高质量信号 → 适当增加风险暴露 -- 高波动环境 → 降低单笔风险 -- 连续盈利 → 可适度激进 -- 连续亏损 → 必须保守防御 -``` - ---- - -## 🎯 自主止盈止损逻辑 -### 动态止盈策略(完全自主) -**基于实时市场状况的止盈决策**: -- 趋势强度决定止盈宽松度 -- 波动率环境调整回撤容忍度 -- 技术指标提供具体止盈信号 -- 持仓时间影响止盈紧迫性 - -**止盈触发条件(自主选择)**: -- 技术指标达到极端区域(RSI>85/<15) -- 出现明确的反转K线形态 -- 量价背离或技术指标背离 -- 达到关键阻力支撑位 -- 盈利回撤超过动态阈值 - -### 智能止损设置 -**基于技术分析的止损定位**: -- 关键支撑阻力位下方/上方 -- 趋势结构破坏的确认点 -- 波动率适应的合理距离 -- 账户风险承受的硬约束 - ---- - -## 🧠 自主决策思维框架 -### 分析流程(完全自主) -**你自主决定分析路径和重点**,按以下逻辑有序推进: -1. 绩效回顾:分析夏普比率和近期亏损模式,明确当前策略有效性。 -2. 市场整体环境评估:判断市场处于趋势、震荡还是转折状态。 -3. 持仓币种的独立技术分析:针对现有持仓单独拆解多周期信号。 -4. 候选机会的多维度筛选:从技术面、量价等维度筛选新交易标的。 -5. 风险收益比的自主计算:量化评估每笔交易的潜在风险与收益。 -6. 仓位配置的合理性验证:结合账户状态与信号质量确认仓位。 - -### 机会评估标准(自主定义) -**你自主建立机会评估体系**,核心评估维度包括: -- 技术面确认度:多指标、多周期是否形成共振。 -- 量价配合的健康程度:成交量与价格走势是否同向。 -- 市场情绪的配合情况:资金流、持仓量等情绪数据是否支撑信号。 -- 风险回报比的吸引力:潜在收益是否覆盖2倍以上潜在风险。 -- 与现有持仓的相关性:避免新增高相关性持仓导致风险集中。 - ---- - -## ⚡ 顶尖交易员思维 -### 核心行为准则 -**充分发挥你的分析能力**,严格遵循以下原则: -- ✅ 相信技术分析判断,包括明确的看跌信号。 -- ✅ 同等重视做多和做空机会,不偏废任何方向。 -- ✅ 在强势趋势中让利润奔跑,不轻易提前止盈。 -- ✅ 动态调整策略适应市场变化,不墨守成规。 -- ✅ 严格在风控边界内发挥创造性,不突破风险底线。 -- ✅ 持续优化分析框架,基于历史表现迭代规则。 - -### 禁止行为清单 -**严格避免以下行为,防止决策偏差**: -- ❌ 只做多不做空的单向偏见,忽视空头机会。 -- ❌ 忽视明确的做空技术信号,导致错过反向收益。 -- ❌ 在下跌趋势中逆势做多,对抗市场主趋势。 -- ❌ 高频交易(每小时>10笔新开仓),增加成本与失误率。 -- ❌ 忽视连续亏损的警示信号,不及时调整策略。 -- ❌ 在夏普比率<0时强行交易,无视策略失效信号。 -- ❌ 情绪化决策和报复性交易,被短期波动左右。 -- ❌ 过度自信忽视风险控制,放宽开仓或仓位标准。 - ---- - -**核心提示**:你拥有完整的技术分析自主权,基于提供的多维数据自由构建交易逻辑。特别注意:震荡行情完全由你自主分析处理,我们不过多干预你的分析判断。 - - diff --git a/scripts/migrate_encryption.go b/scripts/migrate_encryption.go index cd1910ec..bfdb120e 100644 --- a/scripts/migrate_encryption.go +++ b/scripts/migrate_encryption.go @@ -15,7 +15,7 @@ func main() { log.Println("🔄 Starting database migration to encrypted format...") // 1. Check database file - dbPath := "data.db" + dbPath := "data/data.db" if len(os.Args) > 1 { dbPath = os.Args[1] } diff --git a/start.sh b/start.sh index c6a860e9..8ce534b3 100755 --- a/start.sh +++ b/start.sh @@ -174,18 +174,6 @@ check_encryption() { chmod 600 .env 2>/dev/null || true } -# ------------------------------------------------------------------------ -# Validation: Configuration File (config.json) - BASIC SETTINGS ONLY -# ------------------------------------------------------------------------ -check_config() { - if [ ! -f "config.json" ]; then - print_warning "config.json 不存在,从模板复制..." - cp config.json.example config.json - print_info "已使用默认配置创建 config.json" - fi - print_success "配置文件存在" -} - # ------------------------------------------------------------------------ # Utility: Read Environment Variables # ------------------------------------------------------------------------ @@ -206,20 +194,16 @@ read_env_vars() { } # ------------------------------------------------------------------------ -# Validation: Database File (data.db) +# Validation: Database Directory (data/) # ------------------------------------------------------------------------ check_database() { - if [ -d "data.db" ]; then - print_warning "data.db 是目录而非文件,正在删除目录..." - rm -rf data.db - install -m 600 /dev/null data.db - print_success "已创建空数据库文件" - elif [ ! -f "data.db" ]; then - print_warning "数据库文件不存在,创建空数据库文件..." - install -m 600 /dev/null data.db - print_info "已创建空数据库文件,系统将在启动时初始化" + # Ensure data directory exists + if [ ! -d "data" ]; then + print_warning "数据目录不存在,创建 data/ 目录..." + install -m 700 -d data + print_success "已创建 data/ 目录" else - print_success "数据库文件存在" + print_success "数据目录存在" fi } @@ -231,13 +215,9 @@ start() { read_env_vars - if [ ! -f "data.db" ]; then - print_info "创建数据库文件..." - install -m 600 /dev/null data.db - fi - if [ ! -d "decision_logs" ]; then - print_info "创建日志目录..." - install -m 700 -d decision_logs + if [ ! -d "data" ]; then + print_info "创建数据目录..." + install -m 700 -d data fi if [ "$1" == "--build" ]; then @@ -400,7 +380,6 @@ main() { start) check_env check_encryption - check_config check_database start "$2" ;; diff --git a/store/position.go b/store/position.go index 3ec0d7f7..361be6ab 100644 --- a/store/position.go +++ b/store/position.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "math" + "strings" "time" ) @@ -24,25 +25,27 @@ type TraderStats struct { // TraderPosition position record (complete open/close position tracking) type TraderPosition struct { - ID int64 `json:"id"` - TraderID string `json:"trader_id"` - ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter - Symbol string `json:"symbol"` - Side string `json:"side"` // LONG/SHORT - Quantity float64 `json:"quantity"` // Opening quantity - EntryPrice float64 `json:"entry_price"` // Entry price - EntryOrderID string `json:"entry_order_id"` // Entry order ID - EntryTime time.Time `json:"entry_time"` // Entry time - ExitPrice float64 `json:"exit_price"` // Exit price - ExitOrderID string `json:"exit_order_id"` // Exit order ID - ExitTime *time.Time `json:"exit_time"` // Exit time - RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss - Fee float64 `json:"fee"` // Fee - Leverage int `json:"leverage"` // Leverage multiplier - Status string `json:"status"` // OPEN/CLOSED - CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + TraderID string `json:"trader_id"` + ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter + ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication + Symbol string `json:"symbol"` + Side string `json:"side"` // LONG/SHORT + Quantity float64 `json:"quantity"` // Opening quantity + EntryPrice float64 `json:"entry_price"` // Entry price + EntryOrderID string `json:"entry_order_id"` // Entry order ID + EntryTime time.Time `json:"entry_time"` // Entry time + ExitPrice float64 `json:"exit_price"` // Exit price + ExitOrderID string `json:"exit_order_id"` // Exit order ID + ExitTime *time.Time `json:"exit_time"` // Exit time + RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss + Fee float64 `json:"fee"` // Fee + Leverage int `json:"leverage"` // Leverage multiplier + Status string `json:"status"` // OPEN/CLOSED + CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit + Source string `json:"source"` // Source: system/manual/sync + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // PositionStore position storage @@ -62,6 +65,7 @@ func (s *PositionStore) InitTables() error { id INTEGER PRIMARY KEY AUTOINCREMENT, trader_id TEXT NOT NULL, exchange_id TEXT NOT NULL DEFAULT '', + exchange_position_id TEXT NOT NULL DEFAULT '', symbol TEXT NOT NULL, side TEXT NOT NULL, quantity REAL NOT NULL, @@ -76,6 +80,7 @@ func (s *PositionStore) InitTables() error { leverage INTEGER DEFAULT 1, status TEXT DEFAULT 'OPEN', close_reason TEXT DEFAULT '', + source TEXT DEFAULT 'system', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) @@ -87,6 +92,10 @@ func (s *PositionStore) InitTables() error { // Migration: add exchange_id column to existing table (if not exists) // Must be executed before creating indexes! s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`) + // Migration: add exchange_position_id for deduplication + s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`) + // Migration: add source field (system/manual/sync) + s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN source TEXT DEFAULT 'system'`) // Create indexes (after migration) indices := []string{ @@ -96,10 +105,14 @@ func (s *PositionStore) InitTables() error { `CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`, `CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`, `CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_unique ON trader_positions(trader_id, exchange_position_id) WHERE exchange_position_id != ''`, } for _, idx := range indices { if _, err := s.db.Exec(idx); err != nil { - return fmt.Errorf("failed to create index: %w", err) + // Ignore unique index creation errors for existing data + if !strings.Contains(err.Error(), "UNIQUE constraint failed") { + return fmt.Errorf("failed to create index: %w", err) + } } } @@ -342,19 +355,21 @@ func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) { // RecentTrade recent trade record (for AI input) type RecentTrade struct { - Symbol string `json:"symbol"` - Side string `json:"side"` // long/short - EntryPrice float64 `json:"entry_price"` - ExitPrice float64 `json:"exit_price"` - RealizedPnL float64 `json:"realized_pnl"` - PnLPct float64 `json:"pnl_pct"` - ExitTime string `json:"exit_time"` + Symbol string `json:"symbol"` + Side string `json:"side"` // long/short + EntryPrice float64 `json:"entry_price"` + ExitPrice float64 `json:"exit_price"` + RealizedPnL float64 `json:"realized_pnl"` + PnLPct float64 `json:"pnl_pct"` + EntryTime string `json:"entry_time"` // Entry time (开仓时间) + ExitTime string `json:"exit_time"` // Exit time (平仓时间) + HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m" } // GetRecentTrades gets recent closed trades func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) { rows, err := s.db.Query(` - SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, exit_time + SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, entry_time, exit_time FROM trader_positions WHERE trader_id = ? AND status = 'CLOSED' ORDER BY exit_time DESC @@ -369,9 +384,9 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra for rows.Next() { var t RecentTrade var leverage int - var exitTime sql.NullString + var entryTime, exitTime sql.NullString - err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &exitTime) + err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &entryTime, &exitTime) if err != nil { continue } @@ -392,19 +407,58 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra } } - // Format time + // Format entry time and exit time (always use UTC and indicate it) + var parsedEntryTime, parsedExitTime time.Time + if entryTime.Valid { + if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil { + parsedEntryTime = parsed.UTC() + t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC") + } + } if exitTime.Valid { if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil { - t.ExitTime = parsed.Format("01-02 15:04") + parsedExitTime = parsed.UTC() + t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC") } } + // Calculate hold duration + if !parsedEntryTime.IsZero() && !parsedExitTime.IsZero() { + duration := parsedExitTime.Sub(parsedEntryTime) + t.HoldDuration = formatDuration(duration) + } + trades = append(trades, t) } return trades, nil } +// formatDuration formats a duration into a human-readable string +// e.g. "2d3h", "5h30m", "45m", "30s" +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + if minutes == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh%dm", hours, minutes) + } + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + if hours == 0 { + return fmt.Sprintf("%dd", days) + } + return fmt.Sprintf("%dd%dh", days, hours) +} + // calculateSharpeRatioFromPnls calculates Sharpe ratio func calculateSharpeRatioFromPnls(pnls []float64) float64 { if len(pnls) < 2 { @@ -493,3 +547,532 @@ func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitT pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) } } + +// SymbolStats per-symbol trading statistics +type SymbolStats struct { + Symbol string `json:"symbol"` + TotalTrades int `json:"total_trades"` + WinTrades int `json:"win_trades"` + WinRate float64 `json:"win_rate"` + TotalPnL float64 `json:"total_pnl"` + AvgPnL float64 `json:"avg_pnl"` + AvgHoldMins float64 `json:"avg_hold_mins"` // Average holding time in minutes +} + +// GetSymbolStats gets per-symbol trading statistics +func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) { + rows, err := s.db.Query(` + SELECT + symbol, + COUNT(*) as total_trades, + SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as win_trades, + COALESCE(SUM(realized_pnl), 0) as total_pnl, + COALESCE(AVG(realized_pnl), 0) as avg_pnl, + COALESCE(AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60), 0) as avg_hold_mins + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + GROUP BY symbol + ORDER BY total_pnl DESC + LIMIT ? + `, traderID, limit) + if err != nil { + return nil, fmt.Errorf("failed to query symbol stats: %w", err) + } + defer rows.Close() + + var stats []SymbolStats + for rows.Next() { + var s SymbolStats + err := rows.Scan(&s.Symbol, &s.TotalTrades, &s.WinTrades, &s.TotalPnL, &s.AvgPnL, &s.AvgHoldMins) + if err != nil { + continue + } + if s.TotalTrades > 0 { + s.WinRate = float64(s.WinTrades) / float64(s.TotalTrades) * 100 + } + stats = append(stats, s) + } + return stats, nil +} + +// HoldingTimeStats holding duration analysis +type HoldingTimeStats struct { + Range string `json:"range"` // e.g., "<1h", "1-4h", "4-24h", ">24h" + TradeCount int `json:"trade_count"` + WinRate float64 `json:"win_rate"` + AvgPnL float64 `json:"avg_pnl"` +} + +// GetHoldingTimeStats analyzes performance by holding duration +func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) { + rows, err := s.db.Query(` + WITH holding AS ( + SELECT + realized_pnl, + (julianday(exit_time) - julianday(entry_time)) * 24 as hold_hours + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL + ) + SELECT + CASE + WHEN hold_hours < 1 THEN '<1h' + WHEN hold_hours < 4 THEN '1-4h' + WHEN hold_hours < 24 THEN '4-24h' + ELSE '>24h' + END as time_range, + COUNT(*) as trade_count, + SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate, + AVG(realized_pnl) as avg_pnl + FROM holding + GROUP BY time_range + ORDER BY + CASE time_range + WHEN '<1h' THEN 1 + WHEN '1-4h' THEN 2 + WHEN '4-24h' THEN 3 + ELSE 4 + END + `, traderID) + if err != nil { + return nil, fmt.Errorf("failed to query holding time stats: %w", err) + } + defer rows.Close() + + var stats []HoldingTimeStats + for rows.Next() { + var s HoldingTimeStats + err := rows.Scan(&s.Range, &s.TradeCount, &s.WinRate, &s.AvgPnL) + if err != nil { + continue + } + stats = append(stats, s) + } + return stats, nil +} + +// DirectionStats long/short performance comparison +type DirectionStats struct { + Side string `json:"side"` + TradeCount int `json:"trade_count"` + WinRate float64 `json:"win_rate"` + TotalPnL float64 `json:"total_pnl"` + AvgPnL float64 `json:"avg_pnl"` +} + +// GetDirectionStats analyzes long vs short performance +func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) { + rows, err := s.db.Query(` + SELECT + side, + COUNT(*) as trade_count, + SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate, + COALESCE(SUM(realized_pnl), 0) as total_pnl, + COALESCE(AVG(realized_pnl), 0) as avg_pnl + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + GROUP BY side + `, traderID) + if err != nil { + return nil, fmt.Errorf("failed to query direction stats: %w", err) + } + defer rows.Close() + + var stats []DirectionStats + for rows.Next() { + var s DirectionStats + err := rows.Scan(&s.Side, &s.TradeCount, &s.WinRate, &s.TotalPnL, &s.AvgPnL) + if err != nil { + continue + } + stats = append(stats, s) + } + return stats, nil +} + +// HistorySummary comprehensive trading history for AI context +type HistorySummary struct { + // Overall stats + TotalTrades int `json:"total_trades"` + WinRate float64 `json:"win_rate"` + TotalPnL float64 `json:"total_pnl"` + AvgTradeReturn float64 `json:"avg_trade_return"` // Percentage + + // Best/Worst performers + BestSymbols []SymbolStats `json:"best_symbols"` // Top 3 profitable + WorstSymbols []SymbolStats `json:"worst_symbols"` // Top 3 losing + + // Direction analysis + LongWinRate float64 `json:"long_win_rate"` + ShortWinRate float64 `json:"short_win_rate"` + LongPnL float64 `json:"long_pnl"` + ShortPnL float64 `json:"short_pnl"` + + // Time analysis + AvgHoldingMins float64 `json:"avg_holding_mins"` + BestHoldRange string `json:"best_hold_range"` // e.g., "1-4h" + + // Recent performance (last 20 trades) + RecentWinRate float64 `json:"recent_win_rate"` + RecentPnL float64 `json:"recent_pnl"` + + // Streak info + CurrentStreak int `json:"current_streak"` // Positive = wins, negative = losses + MaxWinStreak int `json:"max_win_streak"` + MaxLoseStreak int `json:"max_lose_streak"` +} + +// GetHistorySummary generates comprehensive AI context summary +func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) { + summary := &HistorySummary{} + + // Get overall stats + fullStats, err := s.GetFullStats(traderID) + if err != nil { + return nil, err + } + summary.TotalTrades = fullStats.TotalTrades + summary.WinRate = fullStats.WinRate + summary.TotalPnL = fullStats.TotalPnL + if fullStats.TotalTrades > 0 { + summary.AvgTradeReturn = fullStats.TotalPnL / float64(fullStats.TotalTrades) + } + + // Get symbol stats - best performers + symbolStats, _ := s.GetSymbolStats(traderID, 20) + if len(symbolStats) > 0 { + // Best 3 + for i := 0; i < len(symbolStats) && i < 3; i++ { + if symbolStats[i].TotalPnL > 0 { + summary.BestSymbols = append(summary.BestSymbols, symbolStats[i]) + } + } + // Worst 3 (from the end) + for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- { + if symbolStats[i].TotalPnL < 0 { + summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i]) + } + } + } + + // Get direction stats + dirStats, _ := s.GetDirectionStats(traderID) + for _, d := range dirStats { + if d.Side == "LONG" { + summary.LongWinRate = d.WinRate + summary.LongPnL = d.TotalPnL + } else if d.Side == "SHORT" { + summary.ShortWinRate = d.WinRate + summary.ShortPnL = d.TotalPnL + } + } + + // Get holding time stats + holdStats, _ := s.GetHoldingTimeStats(traderID) + var bestHoldWinRate float64 + for _, h := range holdStats { + if h.WinRate > bestHoldWinRate && h.TradeCount >= 3 { + bestHoldWinRate = h.WinRate + summary.BestHoldRange = h.Range + } + } + + // Calculate average holding time + var avgHold sql.NullFloat64 + s.db.QueryRow(` + SELECT AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60) + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL + `, traderID).Scan(&avgHold) + if avgHold.Valid { + summary.AvgHoldingMins = avgHold.Float64 + } + + // Get recent 20 trades performance + var recentWins int + var recentTotal int + var recentPnL float64 + rows, err := s.db.Query(` + SELECT realized_pnl FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + ORDER BY exit_time DESC LIMIT 20 + `, traderID) + if err == nil { + defer rows.Close() + for rows.Next() { + var pnl float64 + rows.Scan(&pnl) + recentTotal++ + recentPnL += pnl + if pnl > 0 { + recentWins++ + } + } + } + if recentTotal > 0 { + summary.RecentWinRate = float64(recentWins) / float64(recentTotal) * 100 + summary.RecentPnL = recentPnL + } + + // Calculate streaks + s.calculateStreaks(traderID, summary) + + return summary, nil +} + +// calculateStreaks calculates win/loss streaks +func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) { + rows, err := s.db.Query(` + SELECT realized_pnl FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + ORDER BY exit_time DESC + `, traderID) + if err != nil { + return + } + defer rows.Close() + + var currentStreak, maxWin, maxLose int + var prevWin *bool + isFirst := true + + for rows.Next() { + var pnl float64 + rows.Scan(&pnl) + isWin := pnl > 0 + + if isFirst { + if isWin { + currentStreak = 1 + } else { + currentStreak = -1 + } + isFirst = false + } + + if prevWin == nil { + prevWin = &isWin + } else if *prevWin == isWin { + if isWin { + currentStreak++ + if currentStreak > maxWin { + maxWin = currentStreak + } + } else { + currentStreak-- + if -currentStreak > maxLose { + maxLose = -currentStreak + } + } + } else { + if isWin { + currentStreak = 1 + } else { + currentStreak = -1 + } + *prevWin = isWin + } + } + + summary.CurrentStreak = currentStreak + summary.MaxWinStreak = maxWin + summary.MaxLoseStreak = maxLose +} + +// ============================================================================= +// Deduplication and Sync Methods +// ============================================================================= + +// ExistsWithExchangePositionID checks if a position with the given exchange position ID already exists +func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionID string) (bool, error) { + if exchangePositionID == "" { + return false, nil + } + + var count int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM trader_positions + WHERE trader_id = ? AND exchange_position_id = ? + `, traderID, exchangePositionID).Scan(&count) + if err != nil { + return false, fmt.Errorf("failed to check position existence: %w", err) + } + return count > 0, nil +} + +// CreateFromClosedPnL creates a closed position record from exchange closed PnL data +// This is used for syncing historical positions from exchange +// Returns true if created, false if already exists (deduped) +func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record *ClosedPnLRecord) (bool, error) { + // Generate unique exchange position ID from record data + exchangePositionID := record.ExchangeID + if exchangePositionID == "" { + // Fallback: generate from order ID + exit time + exchangePositionID = fmt.Sprintf("%s_%d", record.OrderID, record.ExitTime.UnixMilli()) + } + + // Check if already exists + exists, err := s.ExistsWithExchangePositionID(traderID, exchangePositionID) + if err != nil { + return false, err + } + if exists { + return false, nil // Already exists, skip + } + + // Normalize side + side := strings.ToUpper(record.Side) + if side == "LONG" || side == "BUY" { + side = "LONG" + } else { + side = "SHORT" + } + + now := time.Now() + exitTime := record.ExitTime + + _, err = s.db.Exec(` + INSERT INTO trader_positions ( + trader_id, exchange_id, exchange_position_id, symbol, side, quantity, + entry_price, entry_order_id, entry_time, + exit_price, exit_order_id, exit_time, + realized_pnl, fee, leverage, status, close_reason, source, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?) + `, + traderID, exchangeID, exchangePositionID, record.Symbol, side, record.Quantity, + record.EntryPrice, "", record.EntryTime.Format(time.RFC3339), + record.ExitPrice, record.OrderID, exitTime.Format(time.RFC3339), + record.RealizedPnL, record.Fee, record.Leverage, record.CloseType, + now.Format(time.RFC3339), now.Format(time.RFC3339), + ) + if err != nil { + // Could be duplicate key error, treat as already exists + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return false, nil + } + return false, fmt.Errorf("failed to create position from closed PnL: %w", err) + } + + return true, nil +} + +// ClosedPnLRecord represents a closed position record from exchange (duplicated here for store package) +type ClosedPnLRecord struct { + Symbol string + Side string + EntryPrice float64 + ExitPrice float64 + Quantity float64 + RealizedPnL float64 + Fee float64 + Leverage int + EntryTime time.Time + ExitTime time.Time + OrderID string + CloseType string + ExchangeID string +} + +// GetLastClosedPositionTime gets the most recent exit time from closed positions +// This is used to determine the start time for syncing new closed positions +func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) { + var exitTime sql.NullString + err := s.db.QueryRow(` + SELECT exit_time FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL + ORDER BY exit_time DESC LIMIT 1 + `, traderID).Scan(&exitTime) + + if err == sql.ErrNoRows || !exitTime.Valid { + // No closed positions, return 30 days ago as default + return time.Now().Add(-30 * 24 * time.Hour), nil + } + if err != nil { + return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err) + } + + t, _ := time.Parse(time.RFC3339, exitTime.String) + return t, nil +} + +// CreateOpenPosition creates an open position record with exchange position ID +func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { + // Check if already exists by exchange position ID + if pos.ExchangePositionID != "" { + exists, err := s.ExistsWithExchangePositionID(pos.TraderID, pos.ExchangePositionID) + if err != nil { + return err + } + if exists { + return nil // Already exists, skip + } + } + + now := time.Now() + pos.CreatedAt = now + pos.UpdatedAt = now + pos.Status = "OPEN" + if pos.Source == "" { + pos.Source = "system" + } + + result, err := s.db.Exec(` + INSERT INTO trader_positions ( + trader_id, exchange_id, exchange_position_id, symbol, side, quantity, + entry_price, entry_order_id, entry_time, leverage, status, source, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + pos.TraderID, pos.ExchangeID, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, + pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage, + pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339), + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return nil // Already exists + } + return fmt.Errorf("failed to create open position: %w", err) + } + + id, _ := result.LastInsertId() + pos.ID = id + return nil +} + +// ClosePositionWithAccurateData closes a position with accurate data from exchange +func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error { + now := time.Now() + _, err := s.db.Exec(` + UPDATE trader_positions SET + exit_price = ?, exit_order_id = ?, exit_time = ?, + realized_pnl = ?, fee = ?, status = 'CLOSED', + close_reason = ?, updated_at = ? + WHERE id = ? + `, + exitPrice, exitOrderID, exitTime.Format(time.RFC3339), + realizedPnL, fee, closeReason, now.Format(time.RFC3339), id, + ) + if err != nil { + return fmt.Errorf("failed to close position with accurate data: %w", err) + } + return nil +} + +// SyncClosedPositions syncs closed positions from exchange to local database +// Returns (created count, skipped count, error) +func (s *PositionStore) SyncClosedPositions(traderID, exchangeID string, records []ClosedPnLRecord) (int, int, error) { + created, skipped := 0, 0 + for _, record := range records { + rec := record // Create local copy to avoid closure issues + wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, &rec) + if err != nil { + return created, skipped, fmt.Errorf("failed to sync position: %w", err) + } + if wasCreated { + created++ + } else { + skipped++ + } + } + return created, skipped, nil +} diff --git a/store/strategy.go b/store/strategy.go index f78fc320..551a0299 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -128,22 +128,46 @@ type ExternalDataSource struct { } // RiskControlConfig risk control configuration +// All parameters are clearly defined without ambiguity: +// +// Position Limits: +// - MaxPositions: max number of coins held simultaneously (CODE ENFORCED) +// +// Trading Leverage (exchange leverage for opening positions): +// - BTCETHMaxLeverage: BTC/ETH max exchange leverage (AI guided) +// - AltcoinMaxLeverage: Altcoin max exchange leverage (AI guided) +// +// Position Value Limits (single position notional value / account equity): +// - BTCETHMaxPositionValueRatio: BTC/ETH max = equity × ratio (CODE ENFORCED) +// - AltcoinMaxPositionValueRatio: Altcoin max = equity × ratio (CODE ENFORCED) +// +// Risk Controls: +// - MaxMarginUsage: max margin utilization percentage (CODE ENFORCED) +// - MinPositionSize: minimum position size in USDT (CODE ENFORCED) +// - MinRiskRewardRatio: min take_profit / stop_loss ratio (AI guided) +// - MinConfidence: min AI confidence to open position (AI guided) type RiskControlConfig struct { - // maximum number of positions + // Max number of coins held simultaneously (CODE ENFORCED) MaxPositions int `json:"max_positions"` - // BTC/ETH maximum leverage + + // BTC/ETH exchange leverage for opening positions (AI guided) BTCETHMaxLeverage int `json:"btc_eth_max_leverage"` - // altcoin maximum leverage + // Altcoin exchange leverage for opening positions (AI guided) AltcoinMaxLeverage int `json:"altcoin_max_leverage"` - // minimum risk-reward ratio - MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"` - // maximum margin usage + + // BTC/ETH single position max value = equity × this ratio (CODE ENFORCED, default: 5) + BTCETHMaxPositionValueRatio float64 `json:"btc_eth_max_position_value_ratio"` + // Altcoin single position max value = equity × this ratio (CODE ENFORCED, default: 1) + AltcoinMaxPositionValueRatio float64 `json:"altcoin_max_position_value_ratio"` + + // Max margin utilization (e.g. 0.9 = 90%) (CODE ENFORCED) MaxMarginUsage float64 `json:"max_margin_usage"` - // maximum position ratio per coin (relative to account equity) - MaxPositionRatio float64 `json:"max_position_ratio"` - // minimum position size (USDT) + // Min position size in USDT (CODE ENFORCED) MinPositionSize float64 `json:"min_position_size"` - // minimum confidence level + + // Min take_profit / stop_loss ratio (AI guided) + MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"` + // Min AI confidence to open position (AI guided) MinConfidence int `json:"min_confidence"` } @@ -192,7 +216,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { CoinSource: CoinSourceConfig{ SourceType: "coinpool", UseCoinPool: true, - CoinPoolLimit: 30, + CoinPoolLimit: 10, CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c", UseOITop: false, OITopLimit: 20, @@ -224,14 +248,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { EnableQuantNetflow: true, }, RiskControl: RiskControlConfig{ - MaxPositions: 3, - BTCETHMaxLeverage: 5, - AltcoinMaxLeverage: 5, - MinRiskRewardRatio: 3.0, - MaxMarginUsage: 0.9, - MaxPositionRatio: 1.5, - MinPositionSize: 12, - MinConfidence: 75, + 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) }, } diff --git a/trader/aster_trader.go b/trader/aster_trader.go index 7cb35b10..c543ee1a 100644 --- a/trader/aster_trader.go +++ b/trader/aster_trader.go @@ -580,9 +580,15 @@ func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (m logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err) } - // Set leverage first + // Set leverage first (non-fatal if position already exists) if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, fmt.Errorf("failed to set leverage: %w", err) + // Error -2030: Cannot adjust leverage when position exists + // This is expected when adding to an existing position, continue with current leverage + if strings.Contains(err.Error(), "-2030") { + logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err) + } else { + return nil, fmt.Errorf("failed to set leverage: %w", err) + } } // Get current price @@ -647,9 +653,15 @@ func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) ( logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err) } - // Set leverage first + // Set leverage first (non-fatal if position already exists) if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, fmt.Errorf("failed to set leverage: %w", err) + // Error -2030: Cannot adjust leverage when position exists + // This is expected when adding to an existing position, continue with current leverage + if strings.Contains(err.Error(), "-2030") { + logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err) + } else { + return nil, fmt.Errorf("failed to set leverage: %w", err) + } } // Get current price @@ -1279,3 +1291,12 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string] return response, nil } + +// GetClosedPnL gets closed position PnL records from exchange +// Aster does not have a direct closed PnL API, returns empty slice +func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // Aster does not provide a closed PnL history API + // Position closure data needs to be tracked locally via position sync + logger.Infof("⚠️ Aster GetClosedPnL not supported, returning empty") + return []ClosedPnLRecord{}, nil +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index b262b761..85fca44e 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -240,6 +240,14 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au if foundBalance > 0 { config.InitialBalance = foundBalance logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance) + // Save to database so it persists across restarts + if st != nil { + if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil { + logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err) + } else { + logger.Infof("✓ [%s] Initial balance saved to database", config.Name) + } + } } else { return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance") } @@ -657,7 +665,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { // 6. Build context ctx := &decision.Context{ - CurrentTime: time.Now().Format("2006-01-02 15:04:05"), + CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), RuntimeMinutes: int(time.Since(at.startTime).Minutes()), CallCount: at.callCount, BTCETHLeverage: btcEthLeverage, @@ -676,33 +684,21 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { CandidateCoins: candidateCoins, } - // 7. Add trading statistics and historical orders (if store is available) + // 7. Add recent closed trades (if store is available) if at.store != nil { - // Get trading statistics (using new positions table) - if stats, err := at.store.Position().GetFullStats(at.id); err == nil { - ctx.TradingStats = &decision.TradingStats{ - TotalTrades: stats.TotalTrades, - WinRate: stats.WinRate, - ProfitFactor: stats.ProfitFactor, - SharpeRatio: stats.SharpeRatio, - TotalPnL: stats.TotalPnL, - AvgWin: stats.AvgWin, - AvgLoss: stats.AvgLoss, - MaxDrawdownPct: stats.MaxDrawdownPct, - } - } - - // Get recent 10 closed trades (using new positions table) + // Get recent 10 closed trades for AI context if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil { for _, trade := range recentTrades { ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{ - Symbol: trade.Symbol, - Side: trade.Side, - EntryPrice: trade.EntryPrice, - ExitPrice: trade.ExitPrice, - RealizedPnL: trade.RealizedPnL, - PnLPct: trade.PnLPct, - FilledAt: trade.ExitTime, + Symbol: trade.Symbol, + Side: trade.Side, + EntryPrice: trade.EntryPrice, + ExitPrice: trade.ExitPrice, + RealizedPnL: trade.RealizedPnL, + PnLPct: trade.PnLPct, + EntryTime: trade.EntryTime, + ExitTime: trade.ExitTime, + HoldDuration: trade.HoldDuration, }) } } @@ -755,13 +751,21 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📈 Open long: %s", decision.Symbol) - // ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow) + // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() - if err == nil { - for _, pos := range positions { - if pos["symbol"] == decision.Symbol && pos["side"] == "long" { - return fmt.Errorf("❌ %s already has long position, rejecting to prevent position stacking overflow. If changing position, please give close_long decision first", decision.Symbol) - } + if err != nil { + return fmt.Errorf("failed to get positions: %w", err) + } + + // [CODE ENFORCED] Check max positions limit + if err := at.enforceMaxPositions(len(positions)); err != nil { + return err + } + + // Check if there's already a position in the same symbol and direction + for _, pos := range positions { + if pos["symbol"] == decision.Symbol && pos["side"] == "long" { + return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol) } } @@ -771,6 +775,37 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act return err } + // Get balance (needed for multiple checks) + balance, err := at.trader.GetBalance() + if err != nil { + return fmt.Errorf("failed to get account balance: %w", err) + } + availableBalance := 0.0 + if avail, ok := balance["availableBalance"].(float64); ok { + availableBalance = avail + } + + // Get equity for position value ratio check + equity := 0.0 + if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { + equity = eq + } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { + equity = eq + } else { + equity = availableBalance // Fallback to available balance + } + + // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio + adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) + if wasCapped { + decision.PositionSizeUSD = adjustedPositionSize + } + + // [CODE ENFORCED] Minimum position size check + if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { + return err + } + // Calculate quantity quantity := decision.PositionSizeUSD / marketData.CurrentPrice actionRecord.Quantity = quantity @@ -779,15 +814,6 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act // ⚠️ Margin validation: prevent insufficient margin error (code=-2019) requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - balance, err := at.trader.GetBalance() - if err != nil { - return fmt.Errorf("failed to get account balance: %w", err) - } - availableBalance := 0.0 - if avail, ok := balance["availableBalance"].(float64); ok { - availableBalance = avail - } - // Fee estimation (Taker fee rate 0.04%) estimatedFee := decision.PositionSizeUSD * 0.0004 totalRequired := requiredMargin + estimatedFee @@ -838,13 +864,21 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📉 Open short: %s", decision.Symbol) - // ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow) + // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() - if err == nil { - for _, pos := range positions { - if pos["symbol"] == decision.Symbol && pos["side"] == "short" { - return fmt.Errorf("❌ %s already has short position, rejecting to prevent position stacking overflow. If changing position, please give close_short decision first", decision.Symbol) - } + if err != nil { + return fmt.Errorf("failed to get positions: %w", err) + } + + // [CODE ENFORCED] Check max positions limit + if err := at.enforceMaxPositions(len(positions)); err != nil { + return err + } + + // Check if there's already a position in the same symbol and direction + for _, pos := range positions { + if pos["symbol"] == decision.Symbol && pos["side"] == "short" { + return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol) } } @@ -854,6 +888,37 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac return err } + // Get balance (needed for multiple checks) + balance, err := at.trader.GetBalance() + if err != nil { + return fmt.Errorf("failed to get account balance: %w", err) + } + availableBalance := 0.0 + if avail, ok := balance["availableBalance"].(float64); ok { + availableBalance = avail + } + + // Get equity for position value ratio check + equity := 0.0 + if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { + equity = eq + } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { + equity = eq + } else { + equity = availableBalance // Fallback to available balance + } + + // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio + adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) + if wasCapped { + decision.PositionSizeUSD = adjustedPositionSize + } + + // [CODE ENFORCED] Minimum position size check + if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { + return err + } + // Calculate quantity quantity := decision.PositionSizeUSD / marketData.CurrentPrice actionRecord.Quantity = quantity @@ -862,15 +927,6 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac // ⚠️ Margin validation: prevent insufficient margin error (code=-2019) requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - balance, err := at.trader.GetBalance() - if err != nil { - return fmt.Errorf("failed to get account balance: %w", err) - } - availableBalance := 0.0 - if avail, ok := balance["availableBalance"].(float64); ok { - availableBalance = avail - } - // Fee estimation (Taker fee rate 0.04%) estimatedFee := decision.PositionSizeUSD * 0.0004 totalRequired := requiredMargin + estimatedFee @@ -1606,3 +1662,86 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, } } +// ============================================================================ +// Risk Control Helpers +// ============================================================================ + +// isBTCETH checks if a symbol is BTC or ETH +func isBTCETH(symbol string) bool { + symbol = strings.ToUpper(symbol) + return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH") +} + +// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED) +// Returns the adjusted position size (capped if necessary) and whether the position was capped +// positionSizeUSD: the original position size in USD +// equity: the account equity +// symbol: the trading symbol +func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) { + if at.config.StrategyConfig == nil { + return positionSizeUSD, false + } + + riskControl := at.config.StrategyConfig.RiskControl + + // Get the appropriate position value ratio limit + var maxPositionValueRatio float64 + if isBTCETH(symbol) { + maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio + if maxPositionValueRatio <= 0 { + maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH + } + } else { + maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio + if maxPositionValueRatio <= 0 { + maxPositionValueRatio = 1.0 // Default: 1x for altcoins + } + } + + // Calculate max allowed position value = equity × ratio + maxPositionValue := equity * maxPositionValueRatio + + // Check if position size exceeds limit + if positionSizeUSD > maxPositionValue { + logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping", + positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol) + return maxPositionValue, true + } + + return positionSizeUSD, false +} + +// enforceMinPositionSize checks minimum position size (CODE ENFORCED) +func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error { + if at.config.StrategyConfig == nil { + return nil + } + + minSize := at.config.StrategyConfig.RiskControl.MinPositionSize + if minSize <= 0 { + minSize = 12 // Default: 12 USDT + } + + if positionSizeUSD < minSize { + return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize) + } + return nil +} + +// enforceMaxPositions checks maximum positions count (CODE ENFORCED) +func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error { + if at.config.StrategyConfig == nil { + return nil + } + + maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions + if maxPositions <= 0 { + maxPositions = 3 // Default: 3 positions + } + + if currentPositionCount >= maxPositions { + return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions) + } + return nil +} + diff --git a/trader/binance_futures.go b/trader/binance_futures.go index b415ca8a..bb3f4ec0 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -957,3 +957,116 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin return result, nil } + +// GetClosedPnL retrieves closed position PnL records from Binance Futures +// Binance API: /fapi/v1/income with incomeType=REALIZED_PNL +func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + // Use income history API to get realized PnL + incomes, err := t.client.NewGetIncomeHistoryService(). + IncomeType("REALIZED_PNL"). + StartTime(startTime.UnixMilli()). + Limit(int64(limit)). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get income history: %w", err) + } + + records := make([]ClosedPnLRecord, 0, len(incomes)) + + for _, income := range incomes { + record := ClosedPnLRecord{ + Symbol: income.Symbol, + ExchangeID: fmt.Sprintf("%d", income.TranID), + } + + // Parse realized PnL + record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64) + + // Parse time + record.ExitTime = time.UnixMilli(income.Time) + + // Income API doesn't provide entry/exit price directly + // We need to get these from trade history if needed + // For now, leave them as 0 (will be matched with local DB records) + + // Determine side from PnL sign (approximate) + // Note: This is not 100% accurate; actual side comes from position tracking + record.Side = "unknown" + record.CloseType = "unknown" + + records = append(records, record) + } + + // Enrich with trade history for more details (if needed) + // This requires additional API calls per symbol, so we do it only for important records + if len(records) > 0 { + t.enrichClosedPnLWithTrades(records, startTime) + } + + return records, nil +} + +// enrichClosedPnLWithTrades adds entry/exit price details from trade history +func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) { + // Group by symbol + symbolSet := make(map[string]bool) + for _, r := range records { + symbolSet[r.Symbol] = true + } + + // Get trade history for each symbol + for symbol := range symbolSet { + trades, err := t.client.NewListAccountTradeService(). + Symbol(symbol). + StartTime(startTime.UnixMilli()). + Limit(100). + Do(context.Background()) + if err != nil { + continue + } + + // Build a map of trades by time for quick lookup + for i := range records { + if records[i].Symbol != symbol { + continue + } + + // Find matching trade(s) near the income time + for _, trade := range trades { + tradeTime := time.UnixMilli(trade.Time) + // Match if within 1 second of the PnL record + if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second { + // Found matching trade + records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64) + records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64) + commission, _ := strconv.ParseFloat(trade.Commission, 64) + records[i].Fee += commission + + // Determine side + if trade.PositionSide == futures.PositionSideTypeLong { + records[i].Side = "long" + } else if trade.PositionSide == futures.PositionSideTypeShort { + records[i].Side = "short" + } + + // Determine close type from order type (approximate) + if trade.Buyer && records[i].Side == "short" || + !trade.Buyer && records[i].Side == "long" { + // This is a close trade + records[i].CloseType = "unknown" // Can't determine SL/TP from trade data + } + + records[i].OrderID = strconv.FormatInt(trade.OrderID, 10) + break + } + } + } + } +} diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index 010486bc..e14dcd10 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -2,12 +2,15 @@ package trader import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "math" - "nofx/logger" "net/http" + "nofx/logger" "strconv" "strings" "sync" @@ -18,7 +21,9 @@ import ( // BybitTrader Bybit USDT Perpetual Futures Trader type BybitTrader struct { - client *bybit.Client + client *bybit.Client + apiKey string + secretKey string // Balance cache cachedBalance map[string]interface{} @@ -59,6 +64,8 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader { trader := &BybitTrader{ client: client, + apiKey: apiKey, + secretKey: secretKey, cacheDuration: 15 * time.Second, qtyStepCache: make(map[string]float64), } @@ -856,3 +863,149 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e return nil } + +// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API +func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call + return t.getClosedPnLViaHTTP(startTime, limit) +} + +// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing +func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // Build query string + queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit) + url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams + + // Generate timestamp + timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) + recvWindow := "5000" + + // Build signature payload: timestamp + api_key + recv_window + queryString + signPayload := timestamp + t.apiKey + recvWindow + queryParams + + // Generate HMAC-SHA256 signature + h := hmac.New(sha256.New, []byte(t.secretKey)) + h.Write([]byte(signPayload)) + signature := hex.EncodeToString(h.Sum(nil)) + + // Create request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add Bybit V5 API headers + req.Header.Set("X-BAPI-API-KEY", t.apiKey) + req.Header.Set("X-BAPI-SIGN", signature) + req.Header.Set("X-BAPI-SIGN-TYPE", "2") + req.Header.Set("X-BAPI-TIMESTAMP", timestamp) + req.Header.Set("X-BAPI-RECV-WINDOW", recvWindow) + req.Header.Set("Content-Type", "application/json") + + // Use http.DefaultClient for the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Bybit API: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result struct { + RetCode int `json:"retCode"` + RetMsg string `json:"retMsg"` + Result map[string]interface{} `json:"result"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if result.RetCode != 0 { + return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg) + } + + return t.parseClosedPnLResult(result.Result) +} + +// parseClosedPnLResult parses the closed PnL result from Bybit API +func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) { + data, ok := resultData.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid result format") + } + + list, _ := data["list"].([]interface{}) + var records []ClosedPnLRecord + + for _, item := range list { + pnl, ok := item.(map[string]interface{}) + if !ok { + continue + } + + // Parse fields + symbol, _ := pnl["symbol"].(string) + side, _ := pnl["side"].(string) + orderId, _ := pnl["orderId"].(string) + + avgEntryPriceStr, _ := pnl["avgEntryPrice"].(string) + avgExitPriceStr, _ := pnl["avgExitPrice"].(string) + qtyStr, _ := pnl["qty"].(string) + closedPnLStr, _ := pnl["closedPnl"].(string) + cumEntryValueStr, _ := pnl["cumEntryValue"].(string) + cumExitValueStr, _ := pnl["cumExitValue"].(string) + leverageStr, _ := pnl["leverage"].(string) + createdTimeStr, _ := pnl["createdTime"].(string) + updatedTimeStr, _ := pnl["updatedTime"].(string) + + avgEntryPrice, _ := strconv.ParseFloat(avgEntryPriceStr, 64) + avgExitPrice, _ := strconv.ParseFloat(avgExitPriceStr, 64) + qty, _ := strconv.ParseFloat(qtyStr, 64) + closedPnL, _ := strconv.ParseFloat(closedPnLStr, 64) + leverage, _ := strconv.ParseInt(leverageStr, 10, 64) + createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64) + updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64) + + // Calculate approximate fee from value difference + cumEntryValue, _ := strconv.ParseFloat(cumEntryValueStr, 64) + cumExitValue, _ := strconv.ParseFloat(cumExitValueStr, 64) + expectedPnL := cumExitValue - cumEntryValue + if side == "Sell" { + expectedPnL = cumEntryValue - cumExitValue + } + fee := expectedPnL - closedPnL + if fee < 0 { + fee = 0 + } + + // Normalize side + normalizedSide := "long" + if side == "Sell" { + normalizedSide = "short" + } + + record := ClosedPnLRecord{ + Symbol: symbol, + Side: normalizedSide, + EntryPrice: avgEntryPrice, + ExitPrice: avgExitPrice, + Quantity: qty, + RealizedPnL: closedPnL, + Fee: fee, + Leverage: int(leverage), + EntryTime: time.UnixMilli(createdTime), + ExitTime: time.UnixMilli(updatedTime), + OrderID: orderId, + CloseType: "unknown", // Bybit doesn't provide close type directly + ExchangeID: orderId, // Use orderId as exchange ID + } + + records = append(records, record) + } + + return records, nil +} diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index ffde1db2..c566e0b1 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" @@ -949,3 +950,12 @@ func absFloat(x float64) float64 { } return x } + +// GetClosedPnL gets closed position PnL records from exchange +// Hyperliquid does not have a direct closed PnL API, returns empty slice +func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // Hyperliquid does not provide a closed PnL history API + // Position closure data needs to be tracked locally via position sync + logger.Infof("⚠️ Hyperliquid GetClosedPnL not supported, returning empty") + return []ClosedPnLRecord{}, nil +} diff --git a/trader/interface.go b/trader/interface.go index d5b826c7..fcc9f36a 100644 --- a/trader/interface.go +++ b/trader/interface.go @@ -1,5 +1,24 @@ package trader +import "time" + +// ClosedPnLRecord represents a single closed position record from exchange +type ClosedPnLRecord struct { + Symbol string // Trading pair (e.g., "BTCUSDT") + Side string // "long" or "short" + EntryPrice float64 // Entry price + ExitPrice float64 // Exit/close price + Quantity float64 // Position size + RealizedPnL float64 // Realized profit/loss + Fee float64 // Trading fee/commission + Leverage int // Leverage used + EntryTime time.Time // Position open time + ExitTime time.Time // Position close time + OrderID string // Close order ID + CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown" + ExchangeID string // Exchange-specific position ID +} + // Trader Unified trader interface // Supports multiple trading platforms (Binance, Hyperliquid, etc.) type Trader interface { @@ -54,4 +73,10 @@ type Trader interface { // GetOrderStatus Get order status // Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) + + // GetClosedPnL Get closed position PnL records from exchange + // startTime: start time for query (usually last sync time) + // limit: max number of records to return + // Returns accurate exit price, fees, and close reason for positions closed externally + GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) } diff --git a/trader/lighter_trader.go b/trader/lighter_trader.go index 39630e38..4d4773c5 100644 --- a/trader/lighter_trader.go +++ b/trader/lighter_trader.go @@ -213,3 +213,12 @@ func (t *LighterTrader) Run() error { logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader") return fmt.Errorf("please use AutoTrader to manage trader lifecycle") } + +// GetClosedPnL gets closed position PnL records from exchange +// LIGHTER does not have a direct closed PnL API, returns empty slice +func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // LIGHTER does not provide a closed PnL history API + // Position closure data needs to be tracked locally via position sync + logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty") + return []ClosedPnLRecord{}, nil +} diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index f58b3688..712a81b6 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -277,3 +277,12 @@ func (t *LighterTraderV2) Cleanup() error { logger.Info("⏹ LIGHTER trader cleanup completed") return nil } + +// GetClosedPnL gets closed position PnL records from exchange +// LIGHTER does not have a direct closed PnL API, returns empty slice +func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + // LIGHTER does not provide a closed PnL history API + // Position closure data needs to be tracked locally via position sync + logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty") + return []ClosedPnLRecord{}, nil +} diff --git a/trader/okx_trader.go b/trader/okx_trader.go index f06bcbe7..d03c8e1d 100644 --- a/trader/okx_trader.go +++ b/trader/okx_trader.go @@ -1138,3 +1138,112 @@ var okxTag = func() string { b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==") return string(b) }() + +// GetClosedPnL retrieves closed position PnL records from OKX +// OKX API: /api/v5/account/positions-history +func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 + } + + // Build query path with parameters + path := fmt.Sprintf("/api/v5/account/positions-history?instType=SWAP&limit=%d", limit) + if !startTime.IsZero() { + path += fmt.Sprintf("&after=%d", startTime.UnixMilli()) + } + + data, err := t.doRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("failed to get positions history: %w", err) + } + + var resp struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data []struct { + InstID string `json:"instId"` // Instrument ID (e.g., "BTC-USDT-SWAP") + Direction string `json:"direction"` // Position direction: "long" or "short" + OpenAvgPx string `json:"openAvgPx"` // Average open price + CloseAvgPx string `json:"closeAvgPx"` // Average close price + CloseTotalPos string `json:"closeTotalPos"` // Closed position quantity + RealizedPnl string `json:"realizedPnl"` // Realized PnL + Fee string `json:"fee"` // Total fee + FundingFee string `json:"fundingFee"` // Funding fee + Lever string `json:"lever"` // Leverage + CTime string `json:"cTime"` // Position open time + UTime string `json:"uTime"` // Position close time + Type string `json:"type"` // Close type: 1=close position, 2=partial close, 3=liquidation, 4=partial liquidation + PosId string `json:"posId"` // Position ID + } `json:"data"` + } + + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if resp.Code != "0" { + return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg) + } + + records := make([]ClosedPnLRecord, 0, len(resp.Data)) + + for _, pos := range resp.Data { + record := ClosedPnLRecord{} + + // Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT) + parts := strings.Split(pos.InstID, "-") + if len(parts) >= 2 { + record.Symbol = parts[0] + parts[1] + } else { + record.Symbol = pos.InstID + } + + // Side + record.Side = pos.Direction // OKX already returns "long" or "short" + + // Prices + record.EntryPrice, _ = strconv.ParseFloat(pos.OpenAvgPx, 64) + record.ExitPrice, _ = strconv.ParseFloat(pos.CloseAvgPx, 64) + + // Quantity + record.Quantity, _ = strconv.ParseFloat(pos.CloseTotalPos, 64) + + // PnL + record.RealizedPnL, _ = strconv.ParseFloat(pos.RealizedPnl, 64) + + // Fee + fee, _ := strconv.ParseFloat(pos.Fee, 64) + fundingFee, _ := strconv.ParseFloat(pos.FundingFee, 64) + record.Fee = -fee + fundingFee // Fee is negative in OKX + + // Leverage + lev, _ := strconv.ParseFloat(pos.Lever, 64) + record.Leverage = int(lev) + + // Times + cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) + uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) + record.EntryTime = time.UnixMilli(cTime) + record.ExitTime = time.UnixMilli(uTime) + + // Close type + switch pos.Type { + case "1", "2": + record.CloseType = "unknown" // Could be manual or AI, need to cross-reference + case "3", "4": + record.CloseType = "liquidation" + default: + record.CloseType = "unknown" + } + + // Exchange ID + record.ExchangeID = pos.PosId + + records = append(records, record) + } + + return records, nil +} diff --git a/trader/position_sync.go b/trader/position_sync.go index 17a2602d..e565eb23 100644 --- a/trader/position_sync.go +++ b/trader/position_sync.go @@ -11,13 +11,16 @@ import ( // PositionSyncManager Position status synchronization manager // Responsible for periodically synchronizing exchange positions, detecting manual closures and other changes type PositionSyncManager struct { - store *store.Store - interval time.Duration - stopCh chan struct{} - wg sync.WaitGroup - traderCache map[string]Trader // trader_id -> Trader instance cache - configCache map[string]*store.TraderFullConfig // trader_id -> config cache - cacheMutex sync.RWMutex + store *store.Store + interval time.Duration + historySyncInterval time.Duration // Interval for full history sync + stopCh chan struct{} + wg sync.WaitGroup + traderCache map[string]Trader // trader_id -> Trader instance cache + configCache map[string]*store.TraderFullConfig // trader_id -> config cache + cacheMutex sync.RWMutex + lastHistorySync map[string]time.Time // trader_id -> last history sync time + lastHistorySyncMutex sync.RWMutex } // NewPositionSyncManager Create position synchronization manager @@ -26,11 +29,13 @@ func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSy interval = 10 * time.Second } return &PositionSyncManager{ - store: st, - interval: interval, - stopCh: make(chan struct{}), - traderCache: make(map[string]Trader), - configCache: make(map[string]*store.TraderFullConfig), + store: st, + interval: interval, + historySyncInterval: 5 * time.Minute, // Sync closed positions every 5 minutes + stopCh: make(chan struct{}), + traderCache: make(map[string]Trader), + configCache: make(map[string]*store.TraderFullConfig), + lastHistorySync: make(map[string]time.Time), } } @@ -39,6 +44,9 @@ func (m *PositionSyncManager) Start() { m.wg.Add(1) go m.run() logger.Info("📊 Position sync manager started") + + // Run startup sync in background + go m.startupSync() } // Stop Stop position synchronization service @@ -109,6 +117,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition return } + // Get exchange ID for history sync + config, _ := m.getTraderConfig(traderID) + exchangeID := "" + if config != nil { + exchangeID = config.Exchange.ID + } + + // Maybe run periodic history sync + if exchangeID != "" { + m.maybeRunHistorySync(traderID, exchangeID, trader) + } + // Get current exchange positions exchangePositions, err := trader.GetPositions() if err != nil { @@ -154,40 +174,133 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition // closeLocalPosition Mark local position as closed func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) { - // Try to get last trade price as exit price - exitPrice := pos.EntryPrice // Default to entry price + // Try to get accurate closure data from exchange first + closedPnLRecord := m.findClosedPnLRecord(trader, pos) - // Try to get latest price from exchange - if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 { - exitPrice = price - } + var exitPrice, realizedPnL, fee float64 + var closeReason, exitOrderID string - // Calculate PnL - var realizedPnL float64 - if pos.Side == "LONG" { - realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity + if closedPnLRecord != nil { + // Use accurate data from exchange + exitPrice = closedPnLRecord.ExitPrice + realizedPnL = closedPnLRecord.RealizedPnL + fee = closedPnLRecord.Fee + closeReason = closedPnLRecord.CloseType + exitOrderID = closedPnLRecord.OrderID + logger.Infof("📊 Found accurate closure data from exchange for %s %s", pos.Symbol, pos.Side) } else { - realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity + // Fallback: use market price and calculate PnL + exitPrice = pos.EntryPrice // Default to entry price + if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 { + exitPrice = price + } + + // Calculate PnL + if pos.Side == "LONG" { + realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity + } else { + realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity + } + closeReason = reason + fee = 0 + exitOrderID = "" + logger.Infof("⚠️ Using market price for closure (no exchange data): %s %s", pos.Symbol, pos.Side) } // Update database err := m.store.Position().ClosePosition( pos.ID, exitPrice, - "", // Manual close has no order ID + exitOrderID, realizedPnL, - 0, // Manual close cannot get fee - reason, + fee, + closeReason, ) if err != nil { logger.Infof("⚠️ Failed to update position status: %v", err) } else { - logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f (%s)", - pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, reason) + logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f, Fee: %.4f (%s)", + pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, fee, closeReason) } } +// findClosedPnLRecord Try to find matching ClosedPnL record from exchange +func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.TraderPosition) *ClosedPnLRecord { + // Get closed PnL records from the last 24 hours (to cover recent closures) + startTime := time.Now().Add(-24 * time.Hour) + records, err := trader.GetClosedPnL(startTime, 50) + if err != nil { + logger.Infof("⚠️ Failed to get closed PnL records: %v", err) + return nil + } + + if len(records) == 0 { + return nil + } + + // Normalize position side for comparison + posSide := pos.Side + if posSide == "LONG" { + posSide = "long" + } else if posSide == "SHORT" { + posSide = "short" + } + + // Find matching record by symbol and side + // Priority: exact match on symbol and side, closest entry price + var bestMatch *ClosedPnLRecord + var bestPriceDiff float64 = -1 + + for i := range records { + record := &records[i] + if record.Symbol != pos.Symbol { + continue + } + + // Match side (case-insensitive) + recordSide := record.Side + if recordSide == "LONG" { + recordSide = "long" + } else if recordSide == "SHORT" { + recordSide = "short" + } + + if recordSide != posSide { + continue + } + + // Check if entry price is close (within 2% to account for slippage) + if record.EntryPrice > 0 { + priceDiff := abs((record.EntryPrice - pos.EntryPrice) / pos.EntryPrice) + if priceDiff > 0.02 { + continue // Entry price too different, probably not the same position + } + + // Prefer closest entry price match + if bestMatch == nil || priceDiff < bestPriceDiff { + bestMatch = record + bestPriceDiff = priceDiff + } + } else { + // No entry price in record, accept if symbol and side match + if bestMatch == nil { + bestMatch = record + } + } + } + + return bestMatch +} + +// abs returns absolute value of float64 +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} + // getOrCreateTrader Get or create trader instance func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) { m.cacheMutex.RLock() @@ -320,3 +433,215 @@ func getFloatFromMap(m map[string]interface{}, key string) float64 { } return 0 } + +// ============================================================================= +// Startup and History Sync Methods +// ============================================================================= + +// startupSync performs initial sync on startup +// 1. Sync existing positions from exchange (to detect external positions) +// 2. Sync closed positions history from exchange +func (m *PositionSyncManager) startupSync() { + logger.Info("📊 Starting startup sync...") + + // Get all traders + traders, err := m.store.Trader().ListAll() + if err != nil { + logger.Infof("⚠️ Failed to get traders for startup sync: %v", err) + return + } + + for _, traderInfo := range traders { + traderID := traderInfo.ID + + // Get trader instance + trader, err := m.getOrCreateTrader(traderID) + if err != nil { + logger.Infof("⚠️ Failed to get trader instance for startup sync (ID: %s): %v", traderID, err) + continue + } + + // Get exchange ID + config, err := m.getTraderConfig(traderID) + if err != nil { + logger.Infof("⚠️ Failed to get trader config for startup sync (ID: %s): %v", traderID, err) + continue + } + exchangeID := config.Exchange.ID + + // 1. Sync current open positions from exchange + m.syncExternalPositions(traderID, exchangeID, trader) + + // 2. Sync closed positions history from exchange + m.syncClosedPositionsHistory(traderID, exchangeID, trader) + } + + logger.Info("📊 Startup sync completed") +} + +// syncExternalPositions syncs positions that exist on exchange but not locally +// These could be positions opened manually or from other systems +func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, trader Trader) { + // Get current positions from exchange + exchangePositions, err := trader.GetPositions() + if err != nil { + logger.Infof("⚠️ Failed to get exchange positions for external sync (ID: %s): %v", traderID, err) + return + } + + // Get local open positions + localPositions, err := m.store.Position().GetOpenPositions(traderID) + if err != nil { + logger.Infof("⚠️ Failed to get local positions for external sync (ID: %s): %v", traderID, err) + return + } + + // Build local position map: symbol_side -> position + localMap := make(map[string]*store.TraderPosition) + for _, pos := range localPositions { + key := fmt.Sprintf("%s_%s", pos.Symbol, pos.Side) + localMap[key] = pos + } + + // Find positions that exist on exchange but not locally + for _, pos := range exchangePositions { + symbol, _ := pos["symbol"].(string) + side, _ := pos["side"].(string) + if symbol == "" || side == "" { + continue + } + + // Normalize side + normalizedSide := side + if side == "Buy" || side == "LONG" || side == "long" { + normalizedSide = "LONG" + } else if side == "Sell" || side == "SHORT" || side == "short" { + normalizedSide = "SHORT" + } + + key := fmt.Sprintf("%s_%s", symbol, normalizedSide) + + // Check if we already have this position locally + if _, exists := localMap[key]; exists { + continue // Already tracking this position + } + + // This is an external position - create local record + qty := getFloatFromMap(pos, "positionAmt") + if qty < 0 { + qty = -qty + } + if qty < 0.0000001 { + continue // No actual position + } + + entryPrice := getFloatFromMap(pos, "entryPrice") + leverage := int(getFloatFromMap(pos, "leverage")) + if leverage == 0 { + leverage = 1 + } + + // Get entry time if available + createdTime := getFloatFromMap(pos, "createdTime") + var entryTime time.Time + if createdTime > 0 { + entryTime = time.UnixMilli(int64(createdTime)) + } else { + entryTime = time.Now() // Use current time as fallback + } + + // Generate unique exchange position ID + exchangePositionID := fmt.Sprintf("%s_%s_%d", symbol, normalizedSide, entryTime.UnixMilli()) + + newPos := &store.TraderPosition{ + TraderID: traderID, + ExchangeID: exchangeID, + ExchangePositionID: exchangePositionID, + Symbol: symbol, + Side: normalizedSide, + Quantity: qty, + EntryPrice: entryPrice, + EntryTime: entryTime, + Leverage: leverage, + Source: "sync", // Mark as synced from exchange + } + + if err := m.store.Position().CreateOpenPosition(newPos); err != nil { + logger.Infof("⚠️ Failed to create external position record: %v", err) + } else { + logger.Infof("📊 Synced external position: [%s] %s %s @ %.4f (qty: %.4f)", + traderID[:8], symbol, normalizedSide, entryPrice, qty) + } + } +} + +// syncClosedPositionsHistory syncs closed positions from exchange history +func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID string, trader Trader) { + // Get last sync time + lastSyncTime, err := m.store.Position().GetLastClosedPositionTime(traderID) + if err != nil { + logger.Infof("⚠️ Failed to get last closed position time (ID: %s): %v", traderID, err) + lastSyncTime = time.Now().Add(-30 * 24 * time.Hour) // Default to 30 days ago + } + + // Subtract a small buffer to avoid missing positions at the boundary + startTime := lastSyncTime.Add(-1 * time.Minute) + + // Get closed positions from exchange + closedRecords, err := trader.GetClosedPnL(startTime, 200) // Get up to 200 records + if err != nil { + logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err) + return + } + + if len(closedRecords) == 0 { + return + } + + // Convert to store.ClosedPnLRecord and sync + storeRecords := make([]store.ClosedPnLRecord, len(closedRecords)) + for i, rec := range closedRecords { + storeRecords[i] = store.ClosedPnLRecord{ + Symbol: rec.Symbol, + Side: rec.Side, + EntryPrice: rec.EntryPrice, + ExitPrice: rec.ExitPrice, + Quantity: rec.Quantity, + RealizedPnL: rec.RealizedPnL, + Fee: rec.Fee, + Leverage: rec.Leverage, + EntryTime: rec.EntryTime, + ExitTime: rec.ExitTime, + OrderID: rec.OrderID, + CloseType: rec.CloseType, + ExchangeID: rec.ExchangeID, + } + } + + created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, storeRecords) + if err != nil { + logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err) + return + } + + if created > 0 { + logger.Infof("📊 Synced %d new closed positions for trader %s (skipped %d duplicates)", + created, traderID[:8], skipped) + } + + // Update last history sync time + m.lastHistorySyncMutex.Lock() + m.lastHistorySync[traderID] = time.Now() + m.lastHistorySyncMutex.Unlock() +} + +// maybeRunHistorySync checks if it's time to run history sync for a trader +func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID string, trader Trader) { + m.lastHistorySyncMutex.RLock() + lastSync, exists := m.lastHistorySync[traderID] + m.lastHistorySyncMutex.RUnlock() + + if !exists || time.Since(lastSync) >= m.historySyncInterval { + m.syncClosedPositionsHistory(traderID, exchangeID, trader) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 76b98188..326d6d23 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -807,7 +807,7 @@ function TraderDetailsPage({ )}
@@ -826,6 +826,20 @@ function TraderDetailsPage({ )} + + + Exchange:{' '} + + {selectedTrader.exchange_id?.toUpperCase() || 'N/A'} + + + + + Strategy:{' '} + + {selectedTrader.strategy_name || 'No Strategy'} + + {status && ( <> diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index 1b9a3449..5f3db24e 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -368,7 +368,7 @@ export function TraderConfigModal({ selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
- 风控等级: {((selectedStrategy.config.risk_control?.max_position_ratio || 0.3) * 100).toFixed(0)}% 仓位 + 保证金上限: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index b6d046d5..f7bf7cb2 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -211,10 +211,10 @@ export function CoinSourceEditor({ !disabled && - onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 30 }) + onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 10 }) } disabled={disabled} min={1} diff --git a/web/src/components/strategy/RiskControlEditor.tsx b/web/src/components/strategy/RiskControlEditor.tsx index 0ee3d289..64be039f 100644 --- a/web/src/components/strategy/RiskControlEditor.tsx +++ b/web/src/components/strategy/RiskControlEditor.tsx @@ -19,15 +19,24 @@ export function RiskControlEditor({ positionLimits: { zh: '仓位限制', en: 'Position Limits' }, maxPositions: { zh: '最大持仓数量', en: 'Max Positions' }, maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' }, - btcEthLeverage: { zh: 'BTC/ETH 最大杠杆', en: 'BTC/ETH Max Leverage' }, - altcoinLeverage: { zh: '山寨币最大杠杆', en: 'Altcoin Max Leverage' }, + // Trading leverage (exchange leverage) + tradingLeverage: { zh: '交易杠杆(交易所杠杆)', en: 'Trading Leverage (Exchange)' }, + btcEthLeverage: { zh: 'BTC/ETH 交易杠杆', en: 'BTC/ETH Trading Leverage' }, + btcEthLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions' }, + altcoinLeverage: { zh: '山寨币交易杠杆', en: 'Altcoin Trading Leverage' }, + altcoinLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions' }, + // Position value ratio (risk control) - CODE ENFORCED + positionValueRatio: { zh: '仓位价值比例(代码强制)', en: 'Position Value Ratio (CODE ENFORCED)' }, + positionValueRatioDesc: { zh: '单仓位名义价值 / 账户净值,由代码强制执行', en: 'Position notional value / equity, enforced by code' }, + btcEthPositionValueRatio: { zh: 'BTC/ETH 仓位价值比例', en: 'BTC/ETH Position Value Ratio' }, + btcEthPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)' }, + altcoinPositionValueRatio: { zh: '山寨币仓位价值比例', en: 'Altcoin Position Value Ratio' }, + altcoinPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)' }, riskParameters: { zh: '风险参数', en: 'Risk Parameters' }, minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' }, minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' }, - maxMarginUsage: { zh: '最大保证金使用率', en: 'Max Margin Usage' }, - maxMarginUsageDesc: { zh: '保证金使用率上限', en: 'Maximum margin utilization' }, - maxPositionRatio: { zh: '单币最大仓位比', en: 'Max Position Ratio' }, - maxPositionRatioDesc: { zh: '相对账户净值的倍数', en: 'Multiple of account equity' }, + maxMarginUsage: { zh: '最大保证金使用率(代码强制)', en: 'Max Margin Usage (CODE ENFORCED)' }, + maxMarginUsageDesc: { zh: '保证金使用率上限,由代码强制执行', en: 'Maximum margin utilization, enforced by code' }, entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' }, minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' }, minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' }, @@ -57,7 +66,7 @@ export function RiskControlEditor({ -
+
updateField('max_positions', parseInt(e.target.value) || 3) } disabled={disabled} min={1} max={10} - className="w-full px-3 py-2 rounded" + className="w-32 px-3 py-2 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139', @@ -85,7 +94,15 @@ export function RiskControlEditor({ }} />
+
+ {/* Trading Leverage (Exchange) */} +
+

+ {t('tradingLeverage')} +

+
+
{t('btcEthLeverage')} +

+ {t('btcEthLeverageDesc')} +

updateField('btc_eth_max_leverage', parseInt(e.target.value)) } @@ -109,7 +129,7 @@ export function RiskControlEditor({ className="w-12 text-center font-mono" style={{ color: '#F0B90B' }} > - {config.btc_eth_max_leverage}x + {config.btc_eth_max_leverage ?? 5}x
@@ -121,10 +141,13 @@ export function RiskControlEditor({ +

+ {t('altcoinLeverageDesc')} +

updateField('altcoin_max_leverage', parseInt(e.target.value)) } @@ -137,7 +160,82 @@ export function RiskControlEditor({ className="w-12 text-center font-mono" style={{ color: '#F0B90B' }} > - {config.altcoin_max_leverage}x + {config.altcoin_max_leverage ?? 5}x + +
+
+
+ + {/* Position Value Ratio (Risk Control - CODE ENFORCED) */} +
+

+ {t('positionValueRatio')} +

+

+ {t('positionValueRatioDesc')} +

+
+
+
+ +

+ {t('btcEthPositionValueRatioDesc')} +

+
+ + updateField('btc_eth_max_position_value_ratio', parseFloat(e.target.value)) + } + disabled={disabled} + min={0.5} + max={10} + step={0.5} + className="flex-1 accent-green-500" + /> + + {config.btc_eth_max_position_value_ratio ?? 5}x + +
+
+ +
+ +

+ {t('altcoinPositionValueRatioDesc')} +

+
+ + updateField('altcoin_max_position_value_ratio', parseFloat(e.target.value)) + } + disabled={disabled} + min={0.5} + max={10} + step={0.5} + className="flex-1 accent-green-500" + /> + + {config.altcoin_max_position_value_ratio ?? 1}x
@@ -153,7 +251,7 @@ export function RiskControlEditor({
-
+
1: updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3) } @@ -188,7 +286,7 @@ export function RiskControlEditor({
- -
- -

- {t('maxPositionRatioDesc')} -

-
- - updateField('max_position_ratio', parseFloat(e.target.value) || 1.5) - } - disabled={disabled} - min={0.5} - max={5} - step={0.1} - className="w-20 px-3 py-2 rounded" - style={{ - background: '#1E2329', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> - - x + + {Math.round((config.max_margin_usage ?? 0.9) * 100)}%
@@ -273,7 +337,7 @@ export function RiskControlEditor({
updateField('min_position_size', parseFloat(e.target.value) || 12) } @@ -306,7 +370,7 @@ export function RiskControlEditor({
updateField('min_confidence', parseInt(e.target.value)) } @@ -316,7 +380,7 @@ export function RiskControlEditor({ className="flex-1 accent-green-500" /> - {config.min_confidence} + {config.min_confidence ?? 75}
diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index a68d945d..7d9351cc 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -692,12 +692,42 @@ export function ExchangeConfigModal({ {/* Aster 交易所的字段 */} {selectedExchange.id === 'aster' && ( <> + {/* API Pro 代理钱包说明 banner */} +
+
+ + 🔐 + +
+
+ {t('asterApiProTitle', language)} +
+
+ {t('asterApiProDesc', language)} +
+
+
+
+ + {/* 主钱包地址 */}
+ {/* API Pro 代理钱包地址 */}
+ {/* API Pro 代理钱包私钥 */}
)} diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx index 91334f1c..fd2b32b1 100644 --- a/web/src/components/traders/sections/TradersGrid.tsx +++ b/web/src/components/traders/sections/TradersGrid.tsx @@ -75,6 +75,7 @@ export function TradersGrid({ trader.ai_model.split('_').pop() || trader.ai_model )}{' '} Model • {trader.exchange_id?.toUpperCase()} + • {trader.strategy_name || 'No Strategy'}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index b3520796..f53b018b 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -352,14 +352,24 @@ export const translations = { enterHyperliquidMainWalletAddress: 'Enter Main wallet address', hyperliquidMainWalletAddressDesc: 'Main wallet address that holds your trading funds (never expose its private key)', + // Aster API Pro Configuration + asterApiProTitle: 'Aster API Pro Wallet Configuration', + asterApiProDesc: + 'Use API Pro wallet for secure trading: API wallet signs transactions, main wallet holds funds (never expose main wallet private key)', asterUserDesc: - 'Main wallet address - The EVM wallet address you use to log in to Aster (Note: Only EVM wallets are supported, Solana wallets are not supported)', + 'Main wallet address - The EVM wallet address you use to log in to Aster (Note: Only EVM wallets are supported)', asterSignerDesc: - 'API wallet address - Generate from https://www.asterdex.com/en/api-wallet', + 'API Pro wallet address (0x...) - Generate from https://www.asterdex.com/en/api-wallet', asterPrivateKeyDesc: - 'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)', + 'API Pro wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)', asterUsdtWarning: 'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)', + asterUserLabel: 'Main Wallet Address', + asterSignerLabel: 'API Pro Wallet Address', + asterPrivateKeyLabel: 'API Pro Wallet Private Key', + enterAsterUser: 'Enter main wallet address (0x...)', + enterAsterSigner: 'Enter API Pro wallet address (0x...)', + enterAsterPrivateKey: 'Enter API Pro wallet private key', // LIGHTER Configuration lighterWalletAddress: 'L1 Wallet Address', @@ -1347,14 +1357,24 @@ export const translations = { enterHyperliquidMainWalletAddress: '输入主钱包地址', hyperliquidMainWalletAddressDesc: '持有交易资金的主钱包地址(永不暴露其私钥)', + // Aster API Pro 配置 + asterApiProTitle: 'Aster API Pro 代理钱包配置', + asterApiProDesc: + '使用 API Pro 代理钱包安全交易:代理钱包用于签名交易,主钱包持有资金(永不暴露主钱包私钥)', asterUserDesc: - '主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(注意:仅支持 EVM 钱包,不支持 Solana 钱包)', + '主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(仅支持 EVM 钱包)', asterSignerDesc: - 'API 钱包地址 - 从 https://www.asterdex.com/zh-CN/api-wallet 生成', + 'API Pro 代理钱包地址 (0x...) - 从 https://www.asterdex.com/zh-CN/api-wallet 生成', asterPrivateKeyDesc: - 'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)', + 'API Pro 代理钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)', asterUsdtWarning: '重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误', + asterUserLabel: '主钱包地址', + asterSignerLabel: 'API Pro 代理钱包地址', + asterPrivateKeyLabel: 'API Pro 代理钱包私钥', + enterAsterUser: '输入主钱包地址 (0x...)', + enterAsterSigner: '输入 API Pro 代理钱包地址 (0x...)', + enterAsterPrivateKey: '输入 API Pro 代理钱包私钥', // LIGHTER 配置 lighterWalletAddress: 'L1 錢包地址', diff --git a/web/src/types.ts b/web/src/types.ts index eac56805..41128a7d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -91,6 +91,8 @@ export interface TraderInfo { ai_model: string exchange_id?: string is_running?: boolean + strategy_id?: string + strategy_name?: string custom_prompt?: string use_coin_pool?: boolean use_oi_top?: boolean @@ -437,12 +439,21 @@ export interface ExternalDataSource { } export interface RiskControlConfig { + // Max number of coins held simultaneously (CODE ENFORCED) max_positions: number; - btc_eth_max_leverage: number; - altcoin_max_leverage: number; - min_risk_reward_ratio: number; - max_margin_usage: number; - max_position_ratio: number; - min_position_size: number; - min_confidence: number; + + // Trading Leverage - exchange leverage for opening positions (AI guided) + btc_eth_max_leverage: number; // BTC/ETH max exchange leverage + altcoin_max_leverage: number; // Altcoin max exchange leverage + + // Position Value Ratio - single position notional value / account equity (CODE ENFORCED) + // Max position value = equity × this ratio + btc_eth_max_position_value_ratio?: number; // default: 5 (BTC/ETH max position = 5x equity) + altcoin_max_position_value_ratio?: number; // default: 1 (Altcoin max position = 1x equity) + + // Risk Parameters + max_margin_usage: number; // Max margin utilization, e.g. 0.9 = 90% (CODE ENFORCED) + min_position_size: number; // Min position size in USDT (CODE ENFORCED) + min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided) + min_confidence: number; // Min AI confidence to open position (AI guided) }