mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
fix: initial balance calculation and UI improvements
- Fix initial balance using available_balance instead of total_equity - Fix WSMonitor nil pointer by starting market monitor before loading traders - Add strategy name display on traders list and dashboard pages - Various position sync and trading improvements
This commit is contained in:
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
"nofx/decision"
|
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -64,14 +63,6 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
if cfg.RunID == "" {
|
if cfg.RunID == "" {
|
||||||
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
|
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.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||||
cfg.UserID = normalizeUserID(c.GetString("user_id"))
|
cfg.UserID = normalizeUserID(c.GetString("user_id"))
|
||||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||||
|
|||||||
+34
-68
@@ -10,7 +10,6 @@ import (
|
|||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
"nofx/config"
|
"nofx/config"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/decision"
|
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
@@ -99,10 +98,6 @@ func (s *Server) setupRoutes() {
|
|||||||
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
||||||
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
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)
|
// Public competition data (no authentication required)
|
||||||
api.GET("/traders", s.handlePublicTraderList)
|
api.GET("/traders", s.handlePublicTraderList)
|
||||||
api.GET("/competition", s.handlePublicCompetition)
|
api.GET("/competition", s.handlePublicCompetition)
|
||||||
@@ -150,7 +145,6 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.GET("/strategies", s.handleGetStrategies)
|
protected.GET("/strategies", s.handleGetStrategies)
|
||||||
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
||||||
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
||||||
protected.GET("/strategies/templates", s.handleGetPromptTemplates)
|
|
||||||
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
||||||
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
||||||
protected.GET("/strategies/:id", s.handleGetStrategy)
|
protected.GET("/strategies/:id", s.handleGetStrategy)
|
||||||
@@ -553,25 +547,19 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
if balanceErr != nil {
|
if balanceErr != nil {
|
||||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||||
} else {
|
} else {
|
||||||
// Extract available balance - supports multiple field name formats
|
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
||||||
if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||||
// Binance format: availableBalance (camelCase)
|
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
||||||
actualBalance = availableBalance
|
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||||
logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
for _, key := range balanceKeys {
|
||||||
} else if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||||
// Other format: available_balance (snake_case)
|
actualBalance = balance
|
||||||
actualBalance = availableBalance
|
logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance)
|
||||||
logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
break
|
||||||
} else if totalBalance, ok := balanceInfo["totalWalletBalance"].(float64); ok && totalBalance > 0 {
|
}
|
||||||
// Binance format: totalWalletBalance (camelCase)
|
}
|
||||||
actualBalance = totalBalance
|
if actualBalance <= 0 {
|
||||||
logger.Infof("✓ Queried exchange total balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1002,16 +990,18 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract available balance
|
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||||
var actualBalance float64
|
var actualBalance float64
|
||||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||||
actualBalance = availableBalance
|
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||||
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
for _, key := range balanceKeys {
|
||||||
actualBalance = availableBalance
|
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
actualBalance = balance
|
||||||
actualBalance = totalBalance
|
break
|
||||||
} else {
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get available balance"})
|
}
|
||||||
|
if actualBalance <= 0 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||||
return
|
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
|
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
|
||||||
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
||||||
result = append(result, map[string]interface{}{
|
result = append(result, map[string]interface{}{
|
||||||
@@ -1447,6 +1445,8 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
|||||||
"exchange_id": trader.ExchangeID,
|
"exchange_id": trader.ExchangeID,
|
||||||
"is_running": isRunning,
|
"is_running": isRunning,
|
||||||
"initial_balance": trader.InitialBalance,
|
"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)
|
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)
|
// handlePublicTraderList Get public trader list (no authentication required)
|
||||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||||
// Get trader information from all users
|
// Get trader information from all users
|
||||||
|
|||||||
+3
-7
@@ -361,13 +361,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
|||||||
req.PromptVariant,
|
req.PromptVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get list of available prompt templates
|
|
||||||
templateNames := decision.GetAllPromptTemplateNames()
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"system_prompt": systemPrompt,
|
"system_prompt": systemPrompt,
|
||||||
"prompt_variant": req.PromptVariant,
|
"prompt_variant": req.PromptVariant,
|
||||||
"available_templates": templateNames,
|
|
||||||
"config_summary": gin.H{
|
"config_summary": gin.H{
|
||||||
"coin_source": req.Config.CoinSource.SourceType,
|
"coin_source": req.Config.CoinSource.SourceType,
|
||||||
"primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe,
|
"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)
|
// Build real context (for generating User Prompt)
|
||||||
testContext := &decision.Context{
|
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,
|
RuntimeMinutes: 0,
|
||||||
CallCount: 1,
|
CallCount: 1,
|
||||||
Account: decision.AccountInfo{
|
Account: decision.AccountInfo{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
|
"nofx/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AIConfig defines the AI client configuration used in backtesting.
|
// 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)
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-8
@@ -31,9 +31,10 @@ const (
|
|||||||
|
|
||||||
// Runner encapsulates the lifecycle of a single backtest run.
|
// Runner encapsulates the lifecycle of a single backtest run.
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
cfg BacktestConfig
|
cfg BacktestConfig
|
||||||
feed *DataFeed
|
feed *DataFeed
|
||||||
account *BacktestAccount
|
account *BacktestAccount
|
||||||
|
strategyEngine *decision.StrategyEngine
|
||||||
|
|
||||||
decisionLogDir string
|
decisionLogDir string
|
||||||
mcpClient mcp.AIClient
|
mcpClient mcp.AIClient
|
||||||
@@ -115,10 +116,15 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) {
|
|||||||
aiCache = cache
|
aiCache = cache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create strategy engine from backtest config for unified prompt generation
|
||||||
|
strategyConfig := cfg.ToStrategyConfig()
|
||||||
|
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||||
|
|
||||||
r := &Runner{
|
r := &Runner{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
feed: feed,
|
feed: feed,
|
||||||
account: account,
|
account: account,
|
||||||
|
strategyEngine: strategyEngine,
|
||||||
decisionLogDir: dLogDir,
|
decisionLogDir: dLogDir,
|
||||||
mcpClient: client,
|
mcpClient: client,
|
||||||
status: RunStateCreated,
|
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)
|
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
|
||||||
ctx := &decision.Context{
|
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,
|
RuntimeMinutes: runtime,
|
||||||
CallCount: callCount,
|
CallCount: callCount,
|
||||||
Account: accountInfo,
|
Account: accountInfo,
|
||||||
@@ -503,6 +509,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
|||||||
MultiTFMarket: multiTF,
|
MultiTFMarket: multiTF,
|
||||||
BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage,
|
BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage,
|
||||||
AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage,
|
AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage,
|
||||||
|
Timeframes: r.cfg.Timeframes,
|
||||||
}
|
}
|
||||||
|
|
||||||
record := &store.DecisionRecord{
|
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) {
|
func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecision, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
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,
|
ctx,
|
||||||
r.mcpClient,
|
r.mcpClient,
|
||||||
r.cfg.CustomPrompt,
|
r.strategyEngine,
|
||||||
r.cfg.OverrideBasePrompt,
|
r.cfg.PromptVariant,
|
||||||
r.cfg.PromptTemplate,
|
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return fd, nil
|
return fd, nil
|
||||||
|
|||||||
@@ -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表": "交易所配置"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+905
-443
File diff suppressed because it is too large
Load Diff
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
|
||||||
sb.WriteString("## Format Requirements\n\n")
|
|
||||||
sb.WriteString("<reasoning>\n")
|
|
||||||
sb.WriteString("Your chain of thought analysis...\n")
|
|
||||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
|
||||||
sb.WriteString("</reasoning>\n\n")
|
|
||||||
sb.WriteString("<decision>\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("</decision>\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
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Shanghai}
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- AI_MAX_TOKENS=4000
|
- AI_MAX_TOKENS=8000
|
||||||
networks:
|
networks:
|
||||||
- nofx-network
|
- nofx-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+5
-9
@@ -10,17 +10,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.json:/app/config.json:ro
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./beta_codes.txt:/app/beta_codes.txt:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
- ./prompts:/app/prompts
|
env_file:
|
||||||
- /etc/localtime:/etc/localtime:ro # Sync host time
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
- AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000)
|
- AI_MAX_TOKENS=8000
|
||||||
- DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY} # 数据库加密密钥
|
|
||||||
- JWT_SECRET=${JWT_SECRET} # JWT认证密钥
|
|
||||||
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY} # RSA私钥(客户端加密)
|
|
||||||
networks:
|
networks:
|
||||||
- nofx-network
|
- nofx-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ type compactFormatter struct {
|
|||||||
|
|
||||||
func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||||
level := strings.ToUpper(entry.Level.String())[0:4]
|
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)
|
// Skip frames to find actual caller (skip logrus + our wrapper functions)
|
||||||
caller := ""
|
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
|
return []byte(msg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import (
|
|||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/trader"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -94,6 +96,13 @@ func main() {
|
|||||||
auth.SetJWTSecret(cfg.JWTSecret)
|
auth.SetJWTSecret(cfg.JWTSecret)
|
||||||
logger.Info("🔑 JWT secret configured")
|
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
|
// Create TraderManager and BacktestManager
|
||||||
traderManager := manager.NewTraderManager()
|
traderManager := manager.NewTraderManager()
|
||||||
mcpClient := newSharedMCPClient()
|
mcpClient := newSharedMCPClient()
|
||||||
@@ -102,7 +111,12 @@ func main() {
|
|||||||
logger.Warnf("⚠️ Failed to restore backtest history: %v", err)
|
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 {
|
if err := traderManager.LoadTradersFromStore(st); err != nil {
|
||||||
logger.Fatalf("❌ Failed to load traders: %v", err)
|
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
|
// Start API server
|
||||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -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是底线
|
|
||||||
- 纪律执行是长期盈利的关键
|
|
||||||
|
|
||||||
**现在,请基于以上原则分析市场并做出稳健决策**
|
|
||||||
@@ -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是底线
|
|
||||||
@@ -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.
|
|
||||||
@@ -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空头排列:价格<EMA20<EMA50
|
|
||||||
- MACD死叉且柱状图转负
|
|
||||||
- RSI从超买区域(>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时强行交易,无视策略失效信号。
|
|
||||||
- ❌ 情绪化决策和报复性交易,被短期波动左右。
|
|
||||||
- ❌ 过度自信忽视风险控制,放宽开仓或仓位标准。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**核心提示**:你拥有完整的技术分析自主权,基于提供的多维数据自由构建交易逻辑。特别注意:震荡行情完全由你自主分析处理,我们不过多干预你的分析判断。
|
|
||||||
|
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ func main() {
|
|||||||
log.Println("🔄 Starting database migration to encrypted format...")
|
log.Println("🔄 Starting database migration to encrypted format...")
|
||||||
|
|
||||||
// 1. Check database file
|
// 1. Check database file
|
||||||
dbPath := "data.db"
|
dbPath := "data/data.db"
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
dbPath = os.Args[1]
|
dbPath = os.Args[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,18 +174,6 @@ check_encryption() {
|
|||||||
chmod 600 .env 2>/dev/null || true
|
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
|
# Utility: Read Environment Variables
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -206,20 +194,16 @@ read_env_vars() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Validation: Database File (data.db)
|
# Validation: Database Directory (data/)
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
check_database() {
|
check_database() {
|
||||||
if [ -d "data.db" ]; then
|
# Ensure data directory exists
|
||||||
print_warning "data.db 是目录而非文件,正在删除目录..."
|
if [ ! -d "data" ]; then
|
||||||
rm -rf data.db
|
print_warning "数据目录不存在,创建 data/ 目录..."
|
||||||
install -m 600 /dev/null data.db
|
install -m 700 -d data
|
||||||
print_success "已创建空数据库文件"
|
print_success "已创建 data/ 目录"
|
||||||
elif [ ! -f "data.db" ]; then
|
|
||||||
print_warning "数据库文件不存在,创建空数据库文件..."
|
|
||||||
install -m 600 /dev/null data.db
|
|
||||||
print_info "已创建空数据库文件,系统将在启动时初始化"
|
|
||||||
else
|
else
|
||||||
print_success "数据库文件存在"
|
print_success "数据目录存在"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,13 +215,9 @@ start() {
|
|||||||
|
|
||||||
read_env_vars
|
read_env_vars
|
||||||
|
|
||||||
if [ ! -f "data.db" ]; then
|
if [ ! -d "data" ]; then
|
||||||
print_info "创建数据库文件..."
|
print_info "创建数据目录..."
|
||||||
install -m 600 /dev/null data.db
|
install -m 700 -d data
|
||||||
fi
|
|
||||||
if [ ! -d "decision_logs" ]; then
|
|
||||||
print_info "创建日志目录..."
|
|
||||||
install -m 700 -d decision_logs
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$1" == "--build" ]; then
|
if [ "$1" == "--build" ]; then
|
||||||
@@ -400,7 +380,6 @@ main() {
|
|||||||
start)
|
start)
|
||||||
check_env
|
check_env
|
||||||
check_encryption
|
check_encryption
|
||||||
check_config
|
|
||||||
check_database
|
check_database
|
||||||
start "$2"
|
start "$2"
|
||||||
;;
|
;;
|
||||||
|
|||||||
+615
-32
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,25 +25,27 @@ type TraderStats struct {
|
|||||||
|
|
||||||
// TraderPosition position record (complete open/close position tracking)
|
// TraderPosition position record (complete open/close position tracking)
|
||||||
type TraderPosition struct {
|
type TraderPosition struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TraderID string `json:"trader_id"`
|
TraderID string `json:"trader_id"`
|
||||||
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
||||||
Symbol string `json:"symbol"`
|
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
|
||||||
Side string `json:"side"` // LONG/SHORT
|
Symbol string `json:"symbol"`
|
||||||
Quantity float64 `json:"quantity"` // Opening quantity
|
Side string `json:"side"` // LONG/SHORT
|
||||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
Quantity float64 `json:"quantity"` // Opening quantity
|
||||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||||
Fee float64 `json:"fee"` // Fee
|
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||||
Leverage int `json:"leverage"` // Leverage multiplier
|
Fee float64 `json:"fee"` // Fee
|
||||||
Status string `json:"status"` // OPEN/CLOSED
|
Leverage int `json:"leverage"` // Leverage multiplier
|
||||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
Status string `json:"status"` // OPEN/CLOSED
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
Source string `json:"source"` // Source: system/manual/sync
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PositionStore position storage
|
// PositionStore position storage
|
||||||
@@ -62,6 +65,7 @@ func (s *PositionStore) InitTables() error {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trader_id TEXT NOT NULL,
|
trader_id TEXT NOT NULL,
|
||||||
exchange_id TEXT NOT NULL DEFAULT '',
|
exchange_id TEXT NOT NULL DEFAULT '',
|
||||||
|
exchange_position_id TEXT NOT NULL DEFAULT '',
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
side TEXT NOT NULL,
|
side TEXT NOT NULL,
|
||||||
quantity REAL NOT NULL,
|
quantity REAL NOT NULL,
|
||||||
@@ -76,6 +80,7 @@ func (s *PositionStore) InitTables() error {
|
|||||||
leverage INTEGER DEFAULT 1,
|
leverage INTEGER DEFAULT 1,
|
||||||
status TEXT DEFAULT 'OPEN',
|
status TEXT DEFAULT 'OPEN',
|
||||||
close_reason TEXT DEFAULT '',
|
close_reason TEXT DEFAULT '',
|
||||||
|
source TEXT DEFAULT 'system',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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)
|
// Migration: add exchange_id column to existing table (if not exists)
|
||||||
// Must be executed before creating indexes!
|
// Must be executed before creating indexes!
|
||||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
|
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)
|
// Create indexes (after migration)
|
||||||
indices := []string{
|
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_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_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 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 {
|
for _, idx := range indices {
|
||||||
if _, err := s.db.Exec(idx); err != nil {
|
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)
|
// RecentTrade recent trade record (for AI input)
|
||||||
type RecentTrade struct {
|
type RecentTrade struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Side string `json:"side"` // long/short
|
Side string `json:"side"` // long/short
|
||||||
EntryPrice float64 `json:"entry_price"`
|
EntryPrice float64 `json:"entry_price"`
|
||||||
ExitPrice float64 `json:"exit_price"`
|
ExitPrice float64 `json:"exit_price"`
|
||||||
RealizedPnL float64 `json:"realized_pnl"`
|
RealizedPnL float64 `json:"realized_pnl"`
|
||||||
PnLPct float64 `json:"pnl_pct"`
|
PnLPct float64 `json:"pnl_pct"`
|
||||||
ExitTime string `json:"exit_time"`
|
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
|
// GetRecentTrades gets recent closed trades
|
||||||
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
|
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
|
||||||
rows, err := s.db.Query(`
|
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
|
FROM trader_positions
|
||||||
WHERE trader_id = ? AND status = 'CLOSED'
|
WHERE trader_id = ? AND status = 'CLOSED'
|
||||||
ORDER BY exit_time DESC
|
ORDER BY exit_time DESC
|
||||||
@@ -369,9 +384,9 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t RecentTrade
|
var t RecentTrade
|
||||||
var leverage int
|
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 {
|
if err != nil {
|
||||||
continue
|
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 exitTime.Valid {
|
||||||
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
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)
|
trades = append(trades, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
return trades, nil
|
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
|
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||||
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
||||||
if len(pnls) < 2 {
|
if len(pnls) < 2 {
|
||||||
@@ -493,3 +547,532 @@ func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitT
|
|||||||
pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+44
-19
@@ -128,22 +128,46 @@ type ExternalDataSource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RiskControlConfig risk control configuration
|
// 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 {
|
type RiskControlConfig struct {
|
||||||
// maximum number of positions
|
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||||
MaxPositions int `json:"max_positions"`
|
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"`
|
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||||||
// altcoin maximum leverage
|
// Altcoin exchange leverage for opening positions (AI guided)
|
||||||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||||||
// minimum risk-reward ratio
|
|
||||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
// BTC/ETH single position max value = equity × this ratio (CODE ENFORCED, default: 5)
|
||||||
// maximum margin usage
|
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"`
|
MaxMarginUsage float64 `json:"max_margin_usage"`
|
||||||
// maximum position ratio per coin (relative to account equity)
|
// Min position size in USDT (CODE ENFORCED)
|
||||||
MaxPositionRatio float64 `json:"max_position_ratio"`
|
|
||||||
// minimum position size (USDT)
|
|
||||||
MinPositionSize float64 `json:"min_position_size"`
|
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"`
|
MinConfidence int `json:"min_confidence"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +216,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
CoinSource: CoinSourceConfig{
|
CoinSource: CoinSourceConfig{
|
||||||
SourceType: "coinpool",
|
SourceType: "coinpool",
|
||||||
UseCoinPool: true,
|
UseCoinPool: true,
|
||||||
CoinPoolLimit: 30,
|
CoinPoolLimit: 10,
|
||||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 20,
|
OITopLimit: 20,
|
||||||
@@ -224,14 +248,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
EnableQuantNetflow: true,
|
EnableQuantNetflow: true,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 3,
|
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||||
BTCETHMaxLeverage: 5,
|
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||||
AltcoinMaxLeverage: 5,
|
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||||
MinRiskRewardRatio: 3.0,
|
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||||
MaxMarginUsage: 0.9,
|
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||||
MaxPositionRatio: 1.5,
|
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||||
MinPositionSize: 12,
|
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||||
MinConfidence: 75,
|
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||||
|
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-4
@@ -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)
|
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 {
|
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
|
// 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)
|
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 {
|
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
|
// Get current price
|
||||||
@@ -1279,3 +1291,12 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
|
|||||||
|
|
||||||
return response, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
+193
-54
@@ -240,6 +240,14 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
if foundBalance > 0 {
|
if foundBalance > 0 {
|
||||||
config.InitialBalance = foundBalance
|
config.InitialBalance = foundBalance
|
||||||
logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, 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 {
|
} else {
|
||||||
return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance")
|
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
|
// 6. Build context
|
||||||
ctx := &decision.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()),
|
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||||
CallCount: at.callCount,
|
CallCount: at.callCount,
|
||||||
BTCETHLeverage: btcEthLeverage,
|
BTCETHLeverage: btcEthLeverage,
|
||||||
@@ -676,33 +684,21 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
CandidateCoins: candidateCoins,
|
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 {
|
if at.store != nil {
|
||||||
// Get trading statistics (using new positions table)
|
// Get recent 10 closed trades for AI context
|
||||||
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)
|
|
||||||
if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
|
if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
|
||||||
for _, trade := range recentTrades {
|
for _, trade := range recentTrades {
|
||||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||||
Symbol: trade.Symbol,
|
Symbol: trade.Symbol,
|
||||||
Side: trade.Side,
|
Side: trade.Side,
|
||||||
EntryPrice: trade.EntryPrice,
|
EntryPrice: trade.EntryPrice,
|
||||||
ExitPrice: trade.ExitPrice,
|
ExitPrice: trade.ExitPrice,
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
PnLPct: trade.PnLPct,
|
PnLPct: trade.PnLPct,
|
||||||
FilledAt: trade.ExitTime,
|
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 {
|
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
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()
|
positions, err := at.trader.GetPositions()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
for _, pos := range positions {
|
return fmt.Errorf("failed to get positions: %w", err)
|
||||||
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)
|
|
||||||
}
|
// [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
|
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
|
// Calculate quantity
|
||||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||||
actionRecord.Quantity = quantity
|
actionRecord.Quantity = quantity
|
||||||
@@ -779,15 +814,6 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
|||||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
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%)
|
// Fee estimation (Taker fee rate 0.04%)
|
||||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||||
totalRequired := requiredMargin + estimatedFee
|
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 {
|
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
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()
|
positions, err := at.trader.GetPositions()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
for _, pos := range positions {
|
return fmt.Errorf("failed to get positions: %w", err)
|
||||||
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)
|
|
||||||
}
|
// [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
|
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
|
// Calculate quantity
|
||||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||||
actionRecord.Quantity = quantity
|
actionRecord.Quantity = quantity
|
||||||
@@ -862,15 +927,6 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
|||||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
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%)
|
// Fee estimation (Taker fee rate 0.04%)
|
||||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||||
totalRequired := requiredMargin + estimatedFee
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -957,3 +957,116 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
|
|||||||
|
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+155
-2
@@ -2,12 +2,15 @@ package trader
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"nofx/logger"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"nofx/logger"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,7 +21,9 @@ import (
|
|||||||
|
|
||||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||||
type BybitTrader struct {
|
type BybitTrader struct {
|
||||||
client *bybit.Client
|
client *bybit.Client
|
||||||
|
apiKey string
|
||||||
|
secretKey string
|
||||||
|
|
||||||
// Balance cache
|
// Balance cache
|
||||||
cachedBalance map[string]interface{}
|
cachedBalance map[string]interface{}
|
||||||
@@ -59,6 +64,8 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
|
|||||||
|
|
||||||
trader := &BybitTrader{
|
trader := &BybitTrader{
|
||||||
client: client,
|
client: client,
|
||||||
|
apiKey: apiKey,
|
||||||
|
secretKey: secretKey,
|
||||||
cacheDuration: 15 * time.Second,
|
cacheDuration: 15 * time.Second,
|
||||||
qtyStepCache: make(map[string]float64),
|
qtyStepCache: make(map[string]float64),
|
||||||
}
|
}
|
||||||
@@ -856,3 +863,149 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/sonirico/go-hyperliquid"
|
"github.com/sonirico/go-hyperliquid"
|
||||||
@@ -949,3 +950,12 @@ func absFloat(x float64) float64 {
|
|||||||
}
|
}
|
||||||
return x
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
package trader
|
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
|
// Trader Unified trader interface
|
||||||
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
|
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
|
||||||
type Trader interface {
|
type Trader interface {
|
||||||
@@ -54,4 +73,10 @@ type Trader interface {
|
|||||||
// GetOrderStatus Get order status
|
// GetOrderStatus Get order status
|
||||||
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
|
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
|
||||||
GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,3 +213,12 @@ func (t *LighterTrader) Run() error {
|
|||||||
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
||||||
return fmt.Errorf("please use AutoTrader to manage trader lifecycle")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -277,3 +277,12 @@ func (t *LighterTraderV2) Cleanup() error {
|
|||||||
logger.Info("⏹ LIGHTER trader cleanup completed")
|
logger.Info("⏹ LIGHTER trader cleanup completed")
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1138,3 +1138,112 @@ var okxTag = func() string {
|
|||||||
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
|
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
|
||||||
return string(b)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+353
-28
@@ -11,13 +11,16 @@ import (
|
|||||||
// PositionSyncManager Position status synchronization manager
|
// PositionSyncManager Position status synchronization manager
|
||||||
// Responsible for periodically synchronizing exchange positions, detecting manual closures and other changes
|
// Responsible for periodically synchronizing exchange positions, detecting manual closures and other changes
|
||||||
type PositionSyncManager struct {
|
type PositionSyncManager struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
stopCh chan struct{}
|
historySyncInterval time.Duration // Interval for full history sync
|
||||||
wg sync.WaitGroup
|
stopCh chan struct{}
|
||||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
wg sync.WaitGroup
|
||||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
traderCache map[string]Trader // trader_id -> Trader instance cache
|
||||||
cacheMutex sync.RWMutex
|
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
|
// NewPositionSyncManager Create position synchronization manager
|
||||||
@@ -26,11 +29,13 @@ func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSy
|
|||||||
interval = 10 * time.Second
|
interval = 10 * time.Second
|
||||||
}
|
}
|
||||||
return &PositionSyncManager{
|
return &PositionSyncManager{
|
||||||
store: st,
|
store: st,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
stopCh: make(chan struct{}),
|
historySyncInterval: 5 * time.Minute, // Sync closed positions every 5 minutes
|
||||||
traderCache: make(map[string]Trader),
|
stopCh: make(chan struct{}),
|
||||||
configCache: make(map[string]*store.TraderFullConfig),
|
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)
|
m.wg.Add(1)
|
||||||
go m.run()
|
go m.run()
|
||||||
logger.Info("📊 Position sync manager started")
|
logger.Info("📊 Position sync manager started")
|
||||||
|
|
||||||
|
// Run startup sync in background
|
||||||
|
go m.startupSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop Stop position synchronization service
|
// Stop Stop position synchronization service
|
||||||
@@ -109,6 +117,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
|||||||
return
|
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
|
// Get current exchange positions
|
||||||
exchangePositions, err := trader.GetPositions()
|
exchangePositions, err := trader.GetPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,40 +174,133 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
|||||||
|
|
||||||
// closeLocalPosition Mark local position as closed
|
// closeLocalPosition Mark local position as closed
|
||||||
func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) {
|
func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) {
|
||||||
// Try to get last trade price as exit price
|
// Try to get accurate closure data from exchange first
|
||||||
exitPrice := pos.EntryPrice // Default to entry price
|
closedPnLRecord := m.findClosedPnLRecord(trader, pos)
|
||||||
|
|
||||||
// Try to get latest price from exchange
|
var exitPrice, realizedPnL, fee float64
|
||||||
if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 {
|
var closeReason, exitOrderID string
|
||||||
exitPrice = price
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate PnL
|
if closedPnLRecord != nil {
|
||||||
var realizedPnL float64
|
// Use accurate data from exchange
|
||||||
if pos.Side == "LONG" {
|
exitPrice = closedPnLRecord.ExitPrice
|
||||||
realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity
|
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 {
|
} 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
|
// Update database
|
||||||
err := m.store.Position().ClosePosition(
|
err := m.store.Position().ClosePosition(
|
||||||
pos.ID,
|
pos.ID,
|
||||||
exitPrice,
|
exitPrice,
|
||||||
"", // Manual close has no order ID
|
exitOrderID,
|
||||||
realizedPnL,
|
realizedPnL,
|
||||||
0, // Manual close cannot get fee
|
fee,
|
||||||
reason,
|
closeReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to update position status: %v", err)
|
logger.Infof("⚠️ Failed to update position status: %v", err)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f (%s)",
|
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, reason)
|
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
|
// getOrCreateTrader Get or create trader instance
|
||||||
func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) {
|
func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) {
|
||||||
m.cacheMutex.RLock()
|
m.cacheMutex.RLock()
|
||||||
@@ -320,3 +433,215 @@ func getFloatFromMap(m map[string]interface{}, key string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -807,7 +807,7 @@ function TraderDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-4 text-sm"
|
className="flex items-center gap-4 text-sm flex-wrap"
|
||||||
style={{ color: '#848E9C' }}
|
style={{ color: '#848E9C' }}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
@@ -826,6 +826,20 @@ function TraderDetailsPage({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Exchange:{' '}
|
||||||
|
<span className="font-semibold" style={{ color: '#EAECEF' }}>
|
||||||
|
{selectedTrader.exchange_id?.toUpperCase() || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
Strategy:{' '}
|
||||||
|
<span className="font-semibold" style={{ color: '#F0B90B' }}>
|
||||||
|
{selectedTrader.strategy_name || 'No Strategy'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
{status && (
|
{status && (
|
||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export function TraderConfigModal({
|
|||||||
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
风控等级: {((selectedStrategy.config.risk_control?.max_position_ratio || 0.3) * 100).toFixed(0)}% 仓位
|
保证金上限: {((selectedStrategy.config.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -211,10 +211,10 @@ export function CoinSourceEditor({
|
|||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.coin_pool_limit || 30}
|
value={config.coin_pool_limit || 10}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 30 })
|
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 10 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -19,15 +19,24 @@ export function RiskControlEditor({
|
|||||||
positionLimits: { zh: '仓位限制', en: 'Position Limits' },
|
positionLimits: { zh: '仓位限制', en: 'Position Limits' },
|
||||||
maxPositions: { zh: '最大持仓数量', en: 'Max Positions' },
|
maxPositions: { zh: '最大持仓数量', en: 'Max Positions' },
|
||||||
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' },
|
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' },
|
||||||
btcEthLeverage: { zh: 'BTC/ETH 最大杠杆', en: 'BTC/ETH Max Leverage' },
|
// Trading leverage (exchange leverage)
|
||||||
altcoinLeverage: { zh: '山寨币最大杠杆', en: 'Altcoin Max 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' },
|
riskParameters: { zh: '风险参数', en: 'Risk Parameters' },
|
||||||
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' },
|
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' },
|
||||||
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' },
|
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' },
|
||||||
maxMarginUsage: { zh: '最大保证金使用率', en: 'Max Margin Usage' },
|
maxMarginUsage: { zh: '最大保证金使用率(代码强制)', en: 'Max Margin Usage (CODE ENFORCED)' },
|
||||||
maxMarginUsageDesc: { zh: '保证金使用率上限', en: 'Maximum margin utilization' },
|
maxMarginUsageDesc: { zh: '保证金使用率上限,由代码强制执行', en: 'Maximum margin utilization, enforced by code' },
|
||||||
maxPositionRatio: { zh: '单币最大仓位比', en: 'Max Position Ratio' },
|
|
||||||
maxPositionRatioDesc: { zh: '相对账户净值的倍数', en: 'Multiple of account equity' },
|
|
||||||
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' },
|
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' },
|
||||||
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' },
|
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' },
|
||||||
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' },
|
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' },
|
||||||
@@ -57,7 +66,7 @@ export function RiskControlEditor({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg"
|
className="p-4 rounded-lg"
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||||
@@ -70,14 +79,14 @@ export function RiskControlEditor({
|
|||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.max_positions}
|
value={config.max_positions ?? 3}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('max_positions', parseInt(e.target.value) || 3)
|
updateField('max_positions', parseInt(e.target.value) || 3)
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-32 px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#1E2329',
|
background: '#1E2329',
|
||||||
border: '1px solid #2B3139',
|
border: '1px solid #2B3139',
|
||||||
@@ -85,7 +94,15 @@ export function RiskControlEditor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trading Leverage (Exchange) */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-xs font-medium mb-2" style={{ color: '#F0B90B' }}>
|
||||||
|
{t('tradingLeverage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg"
|
className="p-4 rounded-lg"
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||||
@@ -93,10 +110,13 @@ export function RiskControlEditor({
|
|||||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||||
{t('btcEthLeverage')}
|
{t('btcEthLeverage')}
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
|
{t('btcEthLeverageDesc')}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.btc_eth_max_leverage}
|
value={config.btc_eth_max_leverage ?? 5}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('btc_eth_max_leverage', parseInt(e.target.value))
|
updateField('btc_eth_max_leverage', parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -109,7 +129,7 @@ export function RiskControlEditor({
|
|||||||
className="w-12 text-center font-mono"
|
className="w-12 text-center font-mono"
|
||||||
style={{ color: '#F0B90B' }}
|
style={{ color: '#F0B90B' }}
|
||||||
>
|
>
|
||||||
{config.btc_eth_max_leverage}x
|
{config.btc_eth_max_leverage ?? 5}x
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,10 +141,13 @@ export function RiskControlEditor({
|
|||||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||||
{t('altcoinLeverage')}
|
{t('altcoinLeverage')}
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
|
{t('altcoinLeverageDesc')}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.altcoin_max_leverage}
|
value={config.altcoin_max_leverage ?? 5}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('altcoin_max_leverage', parseInt(e.target.value))
|
updateField('altcoin_max_leverage', parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -137,7 +160,82 @@ export function RiskControlEditor({
|
|||||||
className="w-12 text-center font-mono"
|
className="w-12 text-center font-mono"
|
||||||
style={{ color: '#F0B90B' }}
|
style={{ color: '#F0B90B' }}
|
||||||
>
|
>
|
||||||
{config.altcoin_max_leverage}x
|
{config.altcoin_max_leverage ?? 5}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position Value Ratio (Risk Control - CODE ENFORCED) */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-xs font-medium" style={{ color: '#0ECB81' }}>
|
||||||
|
{t('positionValueRatio')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||||
|
{t('positionValueRatioDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-lg"
|
||||||
|
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('btcEthPositionValueRatio')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
|
{t('btcEthPositionValueRatioDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={config.btc_eth_max_position_value_ratio ?? 5}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-12 text-center font-mono"
|
||||||
|
style={{ color: '#0ECB81' }}
|
||||||
|
>
|
||||||
|
{config.btc_eth_max_position_value_ratio ?? 5}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="p-4 rounded-lg"
|
||||||
|
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
|
||||||
|
>
|
||||||
|
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||||
|
{t('altcoinPositionValueRatio')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||||
|
{t('altcoinPositionValueRatioDesc')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
value={config.altcoin_max_position_value_ratio ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="w-12 text-center font-mono"
|
||||||
|
style={{ color: '#0ECB81' }}
|
||||||
|
>
|
||||||
|
{config.altcoin_max_position_value_ratio ?? 1}x
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +251,7 @@ export function RiskControlEditor({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg"
|
className="p-4 rounded-lg"
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||||
@@ -168,7 +266,7 @@ export function RiskControlEditor({
|
|||||||
<span style={{ color: '#848E9C' }}>1:</span>
|
<span style={{ color: '#848E9C' }}>1:</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.min_risk_reward_ratio}
|
value={config.min_risk_reward_ratio ?? 3}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
|
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
|
||||||
}
|
}
|
||||||
@@ -188,7 +286,7 @@ export function RiskControlEditor({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg"
|
className="p-4 rounded-lg"
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
|
||||||
>
|
>
|
||||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||||
{t('maxMarginUsage')}
|
{t('maxMarginUsage')}
|
||||||
@@ -199,51 +297,17 @@ export function RiskControlEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.max_margin_usage * 100}
|
value={(config.max_margin_usage ?? 0.9) * 100}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('max_margin_usage', parseInt(e.target.value) / 100)
|
updateField('max_margin_usage', parseInt(e.target.value) / 100)
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={10}
|
min={10}
|
||||||
max={100}
|
max={100}
|
||||||
className="flex-1 accent-red-500"
|
className="flex-1 accent-green-500"
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-center font-mono" style={{ color: '#F6465D' }}>
|
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||||
{Math.round(config.max_margin_usage * 100)}%
|
{Math.round((config.max_margin_usage ?? 0.9) * 100)}%
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="p-4 rounded-lg"
|
|
||||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
|
||||||
>
|
|
||||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
|
||||||
{t('maxPositionRatio')}
|
|
||||||
</label>
|
|
||||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
|
||||||
{t('maxPositionRatioDesc')}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.max_position_ratio}
|
|
||||||
onChange={(e) =>
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="ml-2" style={{ color: '#848E9C' }}>
|
|
||||||
x
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,7 +337,7 @@ export function RiskControlEditor({
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={config.min_position_size}
|
value={config.min_position_size ?? 12}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('min_position_size', parseFloat(e.target.value) || 12)
|
updateField('min_position_size', parseFloat(e.target.value) || 12)
|
||||||
}
|
}
|
||||||
@@ -306,7 +370,7 @@ export function RiskControlEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
value={config.min_confidence}
|
value={config.min_confidence ?? 75}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField('min_confidence', parseInt(e.target.value))
|
updateField('min_confidence', parseInt(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -316,7 +380,7 @@ export function RiskControlEditor({
|
|||||||
className="flex-1 accent-green-500"
|
className="flex-1 accent-green-500"
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||||
{config.min_confidence}
|
{config.min_confidence ?? 75}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -692,12 +692,42 @@ export function ExchangeConfigModal({
|
|||||||
{/* Aster 交易所的字段 */}
|
{/* Aster 交易所的字段 */}
|
||||||
{selectedExchange.id === 'aster' && (
|
{selectedExchange.id === 'aster' && (
|
||||||
<>
|
<>
|
||||||
|
{/* API Pro 代理钱包说明 banner */}
|
||||||
|
<div
|
||||||
|
className="p-3 rounded mb-4"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(240, 185, 11, 0.1)',
|
||||||
|
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span style={{ color: '#F0B90B', fontSize: '16px' }}>
|
||||||
|
🔐
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold mb-1"
|
||||||
|
style={{ color: '#F0B90B' }}
|
||||||
|
>
|
||||||
|
{t('asterApiProTitle', language)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: '#848E9C', lineHeight: '1.5' }}
|
||||||
|
>
|
||||||
|
{t('asterApiProDesc', language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主钱包地址 */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{t('user', language)}
|
{t('asterUserLabel', language)}
|
||||||
<Tooltip content={t('asterUserDesc', language)}>
|
<Tooltip content={t('asterUserDesc', language)}>
|
||||||
<HelpCircle
|
<HelpCircle
|
||||||
className="w-4 h-4 cursor-help"
|
className="w-4 h-4 cursor-help"
|
||||||
@@ -709,7 +739,7 @@ export function ExchangeConfigModal({
|
|||||||
type="text"
|
type="text"
|
||||||
value={asterUser}
|
value={asterUser}
|
||||||
onChange={(e) => setAsterUser(e.target.value)}
|
onChange={(e) => setAsterUser(e.target.value)}
|
||||||
placeholder={t('enterUser', language)}
|
placeholder={t('enterAsterUser', language)}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
@@ -718,14 +748,21 @@ export function ExchangeConfigModal({
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="text-xs mt-1"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{t('asterUserDesc', language)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Pro 代理钱包地址 */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{t('signer', language)}
|
{t('asterSignerLabel', language)}
|
||||||
<Tooltip content={t('asterSignerDesc', language)}>
|
<Tooltip content={t('asterSignerDesc', language)}>
|
||||||
<HelpCircle
|
<HelpCircle
|
||||||
className="w-4 h-4 cursor-help"
|
className="w-4 h-4 cursor-help"
|
||||||
@@ -737,7 +774,7 @@ export function ExchangeConfigModal({
|
|||||||
type="text"
|
type="text"
|
||||||
value={asterSigner}
|
value={asterSigner}
|
||||||
onChange={(e) => setAsterSigner(e.target.value)}
|
onChange={(e) => setAsterSigner(e.target.value)}
|
||||||
placeholder={t('enterSigner', language)}
|
placeholder={t('enterAsterSigner', language)}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
@@ -746,14 +783,21 @@ export function ExchangeConfigModal({
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="text-xs mt-1"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{t('asterSignerDesc', language)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* API Pro 代理钱包私钥 */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||||
style={{ color: '#EAECEF' }}
|
style={{ color: '#EAECEF' }}
|
||||||
>
|
>
|
||||||
{t('privateKey', language)}
|
{t('asterPrivateKeyLabel', language)}
|
||||||
<Tooltip content={t('asterPrivateKeyDesc', language)}>
|
<Tooltip content={t('asterPrivateKeyDesc', language)}>
|
||||||
<HelpCircle
|
<HelpCircle
|
||||||
className="w-4 h-4 cursor-help"
|
className="w-4 h-4 cursor-help"
|
||||||
@@ -765,7 +809,7 @@ export function ExchangeConfigModal({
|
|||||||
type="password"
|
type="password"
|
||||||
value={asterPrivateKey}
|
value={asterPrivateKey}
|
||||||
onChange={(e) => setAsterPrivateKey(e.target.value)}
|
onChange={(e) => setAsterPrivateKey(e.target.value)}
|
||||||
placeholder={t('enterPrivateKey', language)}
|
placeholder={t('enterAsterPrivateKey', language)}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: '#0B0E11',
|
background: '#0B0E11',
|
||||||
@@ -774,6 +818,12 @@ export function ExchangeConfigModal({
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className="text-xs mt-1"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{t('asterPrivateKeyDesc', language)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function TradersGrid({
|
|||||||
trader.ai_model.split('_').pop() || trader.ai_model
|
trader.ai_model.split('_').pop() || trader.ai_model
|
||||||
)}{' '}
|
)}{' '}
|
||||||
Model • {trader.exchange_id?.toUpperCase()}
|
Model • {trader.exchange_id?.toUpperCase()}
|
||||||
|
<span style={{ color: '#F0B90B' }}> • {trader.strategy_name || 'No Strategy'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -352,14 +352,24 @@ export const translations = {
|
|||||||
enterHyperliquidMainWalletAddress: 'Enter Main wallet address',
|
enterHyperliquidMainWalletAddress: 'Enter Main wallet address',
|
||||||
hyperliquidMainWalletAddressDesc:
|
hyperliquidMainWalletAddressDesc:
|
||||||
'Main wallet address that holds your trading funds (never expose its private key)',
|
'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:
|
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:
|
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:
|
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:
|
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.)',
|
'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
|
// LIGHTER Configuration
|
||||||
lighterWalletAddress: 'L1 Wallet Address',
|
lighterWalletAddress: 'L1 Wallet Address',
|
||||||
@@ -1347,14 +1357,24 @@ export const translations = {
|
|||||||
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
||||||
hyperliquidMainWalletAddressDesc:
|
hyperliquidMainWalletAddressDesc:
|
||||||
'持有交易资金的主钱包地址(永不暴露其私钥)',
|
'持有交易资金的主钱包地址(永不暴露其私钥)',
|
||||||
|
// Aster API Pro 配置
|
||||||
|
asterApiProTitle: 'Aster API Pro 代理钱包配置',
|
||||||
|
asterApiProDesc:
|
||||||
|
'使用 API Pro 代理钱包安全交易:代理钱包用于签名交易,主钱包持有资金(永不暴露主钱包私钥)',
|
||||||
asterUserDesc:
|
asterUserDesc:
|
||||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(注意:仅支持 EVM 钱包,不支持 Solana 钱包)',
|
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(仅支持 EVM 钱包)',
|
||||||
asterSignerDesc:
|
asterSignerDesc:
|
||||||
'API 钱包地址 - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
|
'API Pro 代理钱包地址 (0x...) - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
|
||||||
asterPrivateKeyDesc:
|
asterPrivateKeyDesc:
|
||||||
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
'API Pro 代理钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||||
asterUsdtWarning:
|
asterUsdtWarning:
|
||||||
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
||||||
|
asterUserLabel: '主钱包地址',
|
||||||
|
asterSignerLabel: 'API Pro 代理钱包地址',
|
||||||
|
asterPrivateKeyLabel: 'API Pro 代理钱包私钥',
|
||||||
|
enterAsterUser: '输入主钱包地址 (0x...)',
|
||||||
|
enterAsterSigner: '输入 API Pro 代理钱包地址 (0x...)',
|
||||||
|
enterAsterPrivateKey: '输入 API Pro 代理钱包私钥',
|
||||||
|
|
||||||
// LIGHTER 配置
|
// LIGHTER 配置
|
||||||
lighterWalletAddress: 'L1 錢包地址',
|
lighterWalletAddress: 'L1 錢包地址',
|
||||||
|
|||||||
+18
-7
@@ -91,6 +91,8 @@ export interface TraderInfo {
|
|||||||
ai_model: string
|
ai_model: string
|
||||||
exchange_id?: string
|
exchange_id?: string
|
||||||
is_running?: boolean
|
is_running?: boolean
|
||||||
|
strategy_id?: string
|
||||||
|
strategy_name?: string
|
||||||
custom_prompt?: string
|
custom_prompt?: string
|
||||||
use_coin_pool?: boolean
|
use_coin_pool?: boolean
|
||||||
use_oi_top?: boolean
|
use_oi_top?: boolean
|
||||||
@@ -437,12 +439,21 @@ export interface ExternalDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RiskControlConfig {
|
export interface RiskControlConfig {
|
||||||
|
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||||
max_positions: number;
|
max_positions: number;
|
||||||
btc_eth_max_leverage: number;
|
|
||||||
altcoin_max_leverage: number;
|
// Trading Leverage - exchange leverage for opening positions (AI guided)
|
||||||
min_risk_reward_ratio: number;
|
btc_eth_max_leverage: number; // BTC/ETH max exchange leverage
|
||||||
max_margin_usage: number;
|
altcoin_max_leverage: number; // Altcoin max exchange leverage
|
||||||
max_position_ratio: number;
|
|
||||||
min_position_size: number;
|
// Position Value Ratio - single position notional value / account equity (CODE ENFORCED)
|
||||||
min_confidence: number;
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user