mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48: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"
|
||||
|
||||
"nofx/backtest"
|
||||
"nofx/decision"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -64,14 +63,6 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if cfg.RunID == "" {
|
||||
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
|
||||
}
|
||||
cfg.PromptTemplate = strings.TrimSpace(cfg.PromptTemplate)
|
||||
if cfg.PromptTemplate == "" {
|
||||
cfg.PromptTemplate = "default"
|
||||
}
|
||||
if _, err := decision.GetPromptTemplate(cfg.PromptTemplate); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Prompt template does not exist: %s", cfg.PromptTemplate)})
|
||||
return
|
||||
}
|
||||
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||
cfg.UserID = normalizeUserID(c.GetString("user_id"))
|
||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||
|
||||
+34
-68
@@ -10,7 +10,6 @@ import (
|
||||
"nofx/backtest"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/decision"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/store"
|
||||
@@ -99,10 +98,6 @@ func (s *Server) setupRoutes() {
|
||||
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
||||
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
||||
|
||||
// System prompt template management (no authentication required)
|
||||
api.GET("/prompt-templates", s.handleGetPromptTemplates)
|
||||
api.GET("/prompt-templates/:name", s.handleGetPromptTemplate)
|
||||
|
||||
// Public competition data (no authentication required)
|
||||
api.GET("/traders", s.handlePublicTraderList)
|
||||
api.GET("/competition", s.handlePublicCompetition)
|
||||
@@ -150,7 +145,6 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/strategies", s.handleGetStrategies)
|
||||
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
||||
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
||||
protected.GET("/strategies/templates", s.handleGetPromptTemplates)
|
||||
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
||||
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
||||
protected.GET("/strategies/:id", s.handleGetStrategy)
|
||||
@@ -553,25 +547,19 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||
} else {
|
||||
// Extract available balance - supports multiple field name formats
|
||||
if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
// Binance format: availableBalance (camelCase)
|
||||
actualBalance = availableBalance
|
||||
logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
// Other format: available_balance (snake_case)
|
||||
actualBalance = availableBalance
|
||||
logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if totalBalance, ok := balanceInfo["totalWalletBalance"].(float64); ok && totalBalance > 0 {
|
||||
// Binance format: totalWalletBalance (camelCase)
|
||||
actualBalance = totalBalance
|
||||
logger.Infof("✓ Queried exchange total balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
// Other format: balance
|
||||
actualBalance = totalBalance
|
||||
logger.Infof("✓ Queried exchange actual balance: %.2f USDT (user input: %.2f USDT)", actualBalance, req.InitialBalance)
|
||||
} else {
|
||||
logger.Infof("⚠️ Unable to extract available balance from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||
for _, key := range balanceKeys {
|
||||
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||
actualBalance = balance
|
||||
logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance)
|
||||
break
|
||||
}
|
||||
}
|
||||
if actualBalance <= 0 {
|
||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1002,16 +990,18 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract available balance
|
||||
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||
var actualBalance float64
|
||||
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
|
||||
actualBalance = availableBalance
|
||||
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
|
||||
actualBalance = totalBalance
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get available balance"})
|
||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
||||
for _, key := range balanceKeys {
|
||||
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
||||
actualBalance = balance
|
||||
break
|
||||
}
|
||||
}
|
||||
if actualBalance <= 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1438,6 +1428,14 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get strategy name if strategy_id is set
|
||||
var strategyName string
|
||||
if trader.StrategyID != "" {
|
||||
if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil {
|
||||
strategyName = strategy.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
|
||||
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
||||
result = append(result, map[string]interface{}{
|
||||
@@ -1447,6 +1445,8 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"is_running": isRunning,
|
||||
"initial_balance": trader.InitialBalance,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"strategy_name": strategyName,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2142,40 +2142,6 @@ func (s *Server) Shutdown() error {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// handleGetPromptTemplates Get all system prompt template list
|
||||
func (s *Server) handleGetPromptTemplates(c *gin.Context) {
|
||||
// Import decision package
|
||||
templates := decision.GetAllPromptTemplates()
|
||||
|
||||
// Convert to response format
|
||||
response := make([]map[string]interface{}, 0, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
response = append(response, map[string]interface{}{
|
||||
"name": tmpl.Name,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"templates": response,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetPromptTemplate Get prompt template content by specified name
|
||||
func (s *Server) handleGetPromptTemplate(c *gin.Context) {
|
||||
templateName := c.Param("name")
|
||||
|
||||
template, err := decision.GetPromptTemplate(templateName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Template does not exist: %s", templateName)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": template.Name,
|
||||
"content": template.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// handlePublicTraderList Get public trader list (no authentication required)
|
||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
|
||||
+3
-7
@@ -361,13 +361,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
req.PromptVariant,
|
||||
)
|
||||
|
||||
// Get list of available prompt templates
|
||||
templateNames := decision.GetAllPromptTemplateNames()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"system_prompt": systemPrompt,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"available_templates": templateNames,
|
||||
"system_prompt": systemPrompt,
|
||||
"prompt_variant": req.PromptVariant,
|
||||
"config_summary": gin.H{
|
||||
"coin_source": req.Config.CoinSource.SourceType,
|
||||
"primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe,
|
||||
@@ -455,7 +451,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
|
||||
// Build real context (for generating User Prompt)
|
||||
testContext := &decision.Context{
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// AIConfig defines the AI client configuration used in backtesting.
|
||||
@@ -176,3 +177,61 @@ func validateFillPolicy(policy string) error {
|
||||
return fmt.Errorf("unsupported fill_policy '%s'", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation.
|
||||
// This ensures backtest uses the same StrategyEngine logic as live trading.
|
||||
func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
// Determine primary and longer timeframe from the timeframes list
|
||||
primaryTF := "5m"
|
||||
longerTF := "4h"
|
||||
if len(cfg.Timeframes) > 0 {
|
||||
primaryTF = cfg.Timeframes[0]
|
||||
}
|
||||
if len(cfg.Timeframes) > 1 {
|
||||
longerTF = cfg.Timeframes[len(cfg.Timeframes)-1]
|
||||
}
|
||||
|
||||
return &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseCoinPool: false,
|
||||
CoinPoolLimit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
},
|
||||
Indicators: store.IndicatorConfig{
|
||||
Klines: store.KlineConfig{
|
||||
PrimaryTimeframe: primaryTF,
|
||||
PrimaryCount: 30,
|
||||
LongerTimeframe: longerTF,
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: len(cfg.Timeframes) > 1,
|
||||
SelectedTimeframes: cfg.Timeframes,
|
||||
},
|
||||
EnableRawKlines: true,
|
||||
EnableEMA: true,
|
||||
EnableMACD: true,
|
||||
EnableRSI: true,
|
||||
EnableATR: true,
|
||||
EnableVolume: true,
|
||||
EnableOI: true,
|
||||
EnableFundingRate: true,
|
||||
EMAPeriods: []int{20, 50},
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
},
|
||||
CustomPrompt: cfg.CustomPrompt,
|
||||
RiskControl: store.RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage,
|
||||
AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage,
|
||||
BTCETHMaxPositionValueRatio: 5.0,
|
||||
AltcoinMaxPositionValueRatio: 1.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MinPositionSize: 12,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MinConfidence: 75,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+16
-8
@@ -31,9 +31,10 @@ const (
|
||||
|
||||
// Runner encapsulates the lifecycle of a single backtest run.
|
||||
type Runner struct {
|
||||
cfg BacktestConfig
|
||||
feed *DataFeed
|
||||
account *BacktestAccount
|
||||
cfg BacktestConfig
|
||||
feed *DataFeed
|
||||
account *BacktestAccount
|
||||
strategyEngine *decision.StrategyEngine
|
||||
|
||||
decisionLogDir string
|
||||
mcpClient mcp.AIClient
|
||||
@@ -115,10 +116,15 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) {
|
||||
aiCache = cache
|
||||
}
|
||||
|
||||
// Create strategy engine from backtest config for unified prompt generation
|
||||
strategyConfig := cfg.ToStrategyConfig()
|
||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||
|
||||
r := &Runner{
|
||||
cfg: cfg,
|
||||
feed: feed,
|
||||
account: account,
|
||||
strategyEngine: strategyEngine,
|
||||
decisionLogDir: dLogDir,
|
||||
mcpClient: client,
|
||||
status: RunStateCreated,
|
||||
@@ -492,7 +498,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
|
||||
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
|
||||
ctx := &decision.Context{
|
||||
CurrentTime: time.UnixMilli(ts).UTC().Format(time.RFC3339),
|
||||
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: runtime,
|
||||
CallCount: callCount,
|
||||
Account: accountInfo,
|
||||
@@ -503,6 +509,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
MultiTFMarket: multiTF,
|
||||
BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage,
|
||||
AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage,
|
||||
Timeframes: r.cfg.Timeframes,
|
||||
}
|
||||
|
||||
record := &store.DecisionRecord{
|
||||
@@ -537,12 +544,13 @@ func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision
|
||||
func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecision, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
||||
fd, err := decision.GetFullDecisionWithCustomPrompt(
|
||||
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
|
||||
// This ensures backtest uses the same unified prompt generation as live trading
|
||||
fd, err := decision.GetFullDecisionWithStrategy(
|
||||
ctx,
|
||||
r.mcpClient,
|
||||
r.cfg.CustomPrompt,
|
||||
r.cfg.OverrideBasePrompt,
|
||||
r.cfg.PromptTemplate,
|
||||
r.strategyEngine,
|
||||
r.cfg.PromptVariant,
|
||||
)
|
||||
if err == nil {
|
||||
return fd, nil
|
||||
|
||||
@@ -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
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- AI_MAX_TOKENS=4000
|
||||
- AI_MAX_TOKENS=8000
|
||||
networks:
|
||||
- nofx-network
|
||||
healthcheck:
|
||||
|
||||
+5
-9
@@ -10,17 +10,13 @@ services:
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./data:/app/data
|
||||
- ./beta_codes.txt:/app/beta_codes.txt:ro
|
||||
- ./prompts:/app/prompts
|
||||
- /etc/localtime:/etc/localtime:ro # Sync host time
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone
|
||||
- AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000)
|
||||
- DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY} # 数据库加密密钥
|
||||
- JWT_SECRET=${JWT_SECRET} # JWT认证密钥
|
||||
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY} # RSA私钥(客户端加密)
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- AI_MAX_TOKENS=8000
|
||||
networks:
|
||||
- nofx-network
|
||||
healthcheck:
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ type compactFormatter struct {
|
||||
|
||||
func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
level := strings.ToUpper(entry.Level.String())[0:4]
|
||||
timestamp := entry.Time.Format("01-02 15:04:05")
|
||||
|
||||
// Skip frames to find actual caller (skip logrus + our wrapper functions)
|
||||
caller := ""
|
||||
@@ -44,7 +45,7 @@ func (f *compactFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("[%s] %s %s\n", level, caller, entry.Message)
|
||||
msg := fmt.Sprintf("%s [%s] %s %s\n", timestamp, level, caller, entry.Message)
|
||||
return []byte(msg), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -94,6 +96,13 @@ func main() {
|
||||
auth.SetJWTSecret(cfg.JWTSecret)
|
||||
logger.Info("🔑 JWT secret configured")
|
||||
|
||||
// Start WebSocket market monitor FIRST (before loading traders that may need market data)
|
||||
// This ensures WSMonitorCli is initialized before any trader tries to access it
|
||||
go market.NewWSMonitor(150).Start(nil)
|
||||
logger.Info("📊 WebSocket market monitor started")
|
||||
// Give WebSocket monitor time to initialize
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Create TraderManager and BacktestManager
|
||||
traderManager := manager.NewTraderManager()
|
||||
mcpClient := newSharedMCPClient()
|
||||
@@ -102,7 +111,12 @@ func main() {
|
||||
logger.Warnf("⚠️ Failed to restore backtest history: %v", err)
|
||||
}
|
||||
|
||||
// Load all traders from database to memory
|
||||
// Start position sync manager (detects manual closures, TP/SL triggers)
|
||||
positionSyncManager := trader.NewPositionSyncManager(st, 0) // 0 = use default 10s interval
|
||||
positionSyncManager.Start()
|
||||
defer positionSyncManager.Stop()
|
||||
|
||||
// Load all traders from database to memory (may auto-start traders with IsRunning=true)
|
||||
if err := traderManager.LoadTradersFromStore(st); err != nil {
|
||||
logger.Fatalf("❌ Failed to load traders: %v", err)
|
||||
}
|
||||
@@ -127,10 +141,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start WebSocket market monitor (get market data for all USDT perpetual contracts)
|
||||
go market.NewWSMonitor(150).Start(nil)
|
||||
logger.Info("📊 WebSocket market monitor started")
|
||||
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||
go func() {
|
||||
|
||||
@@ -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...")
|
||||
|
||||
// 1. Check database file
|
||||
dbPath := "data.db"
|
||||
dbPath := "data/data.db"
|
||||
if len(os.Args) > 1 {
|
||||
dbPath = os.Args[1]
|
||||
}
|
||||
|
||||
@@ -174,18 +174,6 @@ check_encryption() {
|
||||
chmod 600 .env 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Validation: Configuration File (config.json) - BASIC SETTINGS ONLY
|
||||
# ------------------------------------------------------------------------
|
||||
check_config() {
|
||||
if [ ! -f "config.json" ]; then
|
||||
print_warning "config.json 不存在,从模板复制..."
|
||||
cp config.json.example config.json
|
||||
print_info "已使用默认配置创建 config.json"
|
||||
fi
|
||||
print_success "配置文件存在"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Utility: Read Environment Variables
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -206,20 +194,16 @@ read_env_vars() {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Validation: Database File (data.db)
|
||||
# Validation: Database Directory (data/)
|
||||
# ------------------------------------------------------------------------
|
||||
check_database() {
|
||||
if [ -d "data.db" ]; then
|
||||
print_warning "data.db 是目录而非文件,正在删除目录..."
|
||||
rm -rf data.db
|
||||
install -m 600 /dev/null data.db
|
||||
print_success "已创建空数据库文件"
|
||||
elif [ ! -f "data.db" ]; then
|
||||
print_warning "数据库文件不存在,创建空数据库文件..."
|
||||
install -m 600 /dev/null data.db
|
||||
print_info "已创建空数据库文件,系统将在启动时初始化"
|
||||
# Ensure data directory exists
|
||||
if [ ! -d "data" ]; then
|
||||
print_warning "数据目录不存在,创建 data/ 目录..."
|
||||
install -m 700 -d data
|
||||
print_success "已创建 data/ 目录"
|
||||
else
|
||||
print_success "数据库文件存在"
|
||||
print_success "数据目录存在"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -231,13 +215,9 @@ start() {
|
||||
|
||||
read_env_vars
|
||||
|
||||
if [ ! -f "data.db" ]; then
|
||||
print_info "创建数据库文件..."
|
||||
install -m 600 /dev/null data.db
|
||||
fi
|
||||
if [ ! -d "decision_logs" ]; then
|
||||
print_info "创建日志目录..."
|
||||
install -m 700 -d decision_logs
|
||||
if [ ! -d "data" ]; then
|
||||
print_info "创建数据目录..."
|
||||
install -m 700 -d data
|
||||
fi
|
||||
|
||||
if [ "$1" == "--build" ]; then
|
||||
@@ -400,7 +380,6 @@ main() {
|
||||
start)
|
||||
check_env
|
||||
check_encryption
|
||||
check_config
|
||||
check_database
|
||||
start "$2"
|
||||
;;
|
||||
|
||||
+615
-32
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -24,25 +25,27 @@ type TraderStats struct {
|
||||
|
||||
// TraderPosition position record (complete open/close position tracking)
|
||||
type TraderPosition struct {
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // LONG/SHORT
|
||||
Quantity float64 `json:"quantity"` // Opening quantity
|
||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||
Fee float64 `json:"fee"` // Fee
|
||||
Leverage int `json:"leverage"` // Leverage multiplier
|
||||
Status string `json:"status"` // OPEN/CLOSED
|
||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
TraderID string `json:"trader_id"`
|
||||
ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter
|
||||
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // LONG/SHORT
|
||||
Quantity float64 `json:"quantity"` // Opening quantity
|
||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||
Fee float64 `json:"fee"` // Fee
|
||||
Leverage int `json:"leverage"` // Leverage multiplier
|
||||
Status string `json:"status"` // OPEN/CLOSED
|
||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||
Source string `json:"source"` // Source: system/manual/sync
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PositionStore position storage
|
||||
@@ -62,6 +65,7 @@ func (s *PositionStore) InitTables() error {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trader_id TEXT NOT NULL,
|
||||
exchange_id TEXT NOT NULL DEFAULT '',
|
||||
exchange_position_id TEXT NOT NULL DEFAULT '',
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
@@ -76,6 +80,7 @@ func (s *PositionStore) InitTables() error {
|
||||
leverage INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'OPEN',
|
||||
close_reason TEXT DEFAULT '',
|
||||
source TEXT DEFAULT 'system',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -87,6 +92,10 @@ func (s *PositionStore) InitTables() error {
|
||||
// Migration: add exchange_id column to existing table (if not exists)
|
||||
// Must be executed before creating indexes!
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`)
|
||||
// Migration: add exchange_position_id for deduplication
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`)
|
||||
// Migration: add source field (system/manual/sync)
|
||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN source TEXT DEFAULT 'system'`)
|
||||
|
||||
// Create indexes (after migration)
|
||||
indices := []string{
|
||||
@@ -96,10 +105,14 @@ func (s *PositionStore) InitTables() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_unique ON trader_positions(trader_id, exchange_position_id) WHERE exchange_position_id != ''`,
|
||||
}
|
||||
for _, idx := range indices {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
// Ignore unique index creation errors for existing data
|
||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("failed to create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,19 +355,21 @@ func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
|
||||
// RecentTrade recent trade record (for AI input)
|
||||
type RecentTrade struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // long/short
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
ExitPrice float64 `json:"exit_price"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
ExitTime string `json:"exit_time"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // long/short
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
ExitPrice float64 `json:"exit_price"`
|
||||
RealizedPnL float64 `json:"realized_pnl"`
|
||||
PnLPct float64 `json:"pnl_pct"`
|
||||
EntryTime string `json:"entry_time"` // Entry time (开仓时间)
|
||||
ExitTime string `json:"exit_time"` // Exit time (平仓时间)
|
||||
HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m"
|
||||
}
|
||||
|
||||
// GetRecentTrades gets recent closed trades
|
||||
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, exit_time
|
||||
SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, entry_time, exit_time
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
@@ -369,9 +384,9 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
for rows.Next() {
|
||||
var t RecentTrade
|
||||
var leverage int
|
||||
var exitTime sql.NullString
|
||||
var entryTime, exitTime sql.NullString
|
||||
|
||||
err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &exitTime)
|
||||
err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &entryTime, &exitTime)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -392,19 +407,58 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
}
|
||||
}
|
||||
|
||||
// Format time
|
||||
// Format entry time and exit time (always use UTC and indicate it)
|
||||
var parsedEntryTime, parsedExitTime time.Time
|
||||
if entryTime.Valid {
|
||||
if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil {
|
||||
parsedEntryTime = parsed.UTC()
|
||||
t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC")
|
||||
}
|
||||
}
|
||||
if exitTime.Valid {
|
||||
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
||||
t.ExitTime = parsed.Format("01-02 15:04")
|
||||
parsedExitTime = parsed.UTC()
|
||||
t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC")
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate hold duration
|
||||
if !parsedEntryTime.IsZero() && !parsedExitTime.IsZero() {
|
||||
duration := parsedExitTime.Sub(parsedEntryTime)
|
||||
t.HoldDuration = formatDuration(duration)
|
||||
}
|
||||
|
||||
trades = append(trades, t)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a human-readable string
|
||||
// e.g. "2d3h", "5h30m", "45m", "30s"
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
if minutes == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
if hours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd%dh", days, hours)
|
||||
}
|
||||
|
||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
||||
if len(pnls) < 2 {
|
||||
@@ -493,3 +547,532 @@ func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitT
|
||||
pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
||||
}
|
||||
}
|
||||
|
||||
// SymbolStats per-symbol trading statistics
|
||||
type SymbolStats struct {
|
||||
Symbol string `json:"symbol"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinTrades int `json:"win_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
AvgHoldMins float64 `json:"avg_hold_mins"` // Average holding time in minutes
|
||||
}
|
||||
|
||||
// GetSymbolStats gets per-symbol trading statistics
|
||||
func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
symbol,
|
||||
COUNT(*) as total_trades,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as win_trades,
|
||||
COALESCE(SUM(realized_pnl), 0) as total_pnl,
|
||||
COALESCE(AVG(realized_pnl), 0) as avg_pnl,
|
||||
COALESCE(AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60), 0) as avg_hold_mins
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
GROUP BY symbol
|
||||
ORDER BY total_pnl DESC
|
||||
LIMIT ?
|
||||
`, traderID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query symbol stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []SymbolStats
|
||||
for rows.Next() {
|
||||
var s SymbolStats
|
||||
err := rows.Scan(&s.Symbol, &s.TotalTrades, &s.WinTrades, &s.TotalPnL, &s.AvgPnL, &s.AvgHoldMins)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s.TotalTrades > 0 {
|
||||
s.WinRate = float64(s.WinTrades) / float64(s.TotalTrades) * 100
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// HoldingTimeStats holding duration analysis
|
||||
type HoldingTimeStats struct {
|
||||
Range string `json:"range"` // e.g., "<1h", "1-4h", "4-24h", ">24h"
|
||||
TradeCount int `json:"trade_count"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
}
|
||||
|
||||
// GetHoldingTimeStats analyzes performance by holding duration
|
||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
WITH holding AS (
|
||||
SELECT
|
||||
realized_pnl,
|
||||
(julianday(exit_time) - julianday(entry_time)) * 24 as hold_hours
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN hold_hours < 1 THEN '<1h'
|
||||
WHEN hold_hours < 4 THEN '1-4h'
|
||||
WHEN hold_hours < 24 THEN '4-24h'
|
||||
ELSE '>24h'
|
||||
END as time_range,
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate,
|
||||
AVG(realized_pnl) as avg_pnl
|
||||
FROM holding
|
||||
GROUP BY time_range
|
||||
ORDER BY
|
||||
CASE time_range
|
||||
WHEN '<1h' THEN 1
|
||||
WHEN '1-4h' THEN 2
|
||||
WHEN '4-24h' THEN 3
|
||||
ELSE 4
|
||||
END
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []HoldingTimeStats
|
||||
for rows.Next() {
|
||||
var s HoldingTimeStats
|
||||
err := rows.Scan(&s.Range, &s.TradeCount, &s.WinRate, &s.AvgPnL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// DirectionStats long/short performance comparison
|
||||
type DirectionStats struct {
|
||||
Side string `json:"side"`
|
||||
TradeCount int `json:"trade_count"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgPnL float64 `json:"avg_pnl"`
|
||||
}
|
||||
|
||||
// GetDirectionStats analyzes long vs short performance
|
||||
func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
side,
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN realized_pnl > 0 THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100 as win_rate,
|
||||
COALESCE(SUM(realized_pnl), 0) as total_pnl,
|
||||
COALESCE(AVG(realized_pnl), 0) as avg_pnl
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
GROUP BY side
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query direction stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []DirectionStats
|
||||
for rows.Next() {
|
||||
var s DirectionStats
|
||||
err := rows.Scan(&s.Side, &s.TradeCount, &s.WinRate, &s.TotalPnL, &s.AvgPnL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// HistorySummary comprehensive trading history for AI context
|
||||
type HistorySummary struct {
|
||||
// Overall stats
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
AvgTradeReturn float64 `json:"avg_trade_return"` // Percentage
|
||||
|
||||
// Best/Worst performers
|
||||
BestSymbols []SymbolStats `json:"best_symbols"` // Top 3 profitable
|
||||
WorstSymbols []SymbolStats `json:"worst_symbols"` // Top 3 losing
|
||||
|
||||
// Direction analysis
|
||||
LongWinRate float64 `json:"long_win_rate"`
|
||||
ShortWinRate float64 `json:"short_win_rate"`
|
||||
LongPnL float64 `json:"long_pnl"`
|
||||
ShortPnL float64 `json:"short_pnl"`
|
||||
|
||||
// Time analysis
|
||||
AvgHoldingMins float64 `json:"avg_holding_mins"`
|
||||
BestHoldRange string `json:"best_hold_range"` // e.g., "1-4h"
|
||||
|
||||
// Recent performance (last 20 trades)
|
||||
RecentWinRate float64 `json:"recent_win_rate"`
|
||||
RecentPnL float64 `json:"recent_pnl"`
|
||||
|
||||
// Streak info
|
||||
CurrentStreak int `json:"current_streak"` // Positive = wins, negative = losses
|
||||
MaxWinStreak int `json:"max_win_streak"`
|
||||
MaxLoseStreak int `json:"max_lose_streak"`
|
||||
}
|
||||
|
||||
// GetHistorySummary generates comprehensive AI context summary
|
||||
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
|
||||
summary := &HistorySummary{}
|
||||
|
||||
// Get overall stats
|
||||
fullStats, err := s.GetFullStats(traderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary.TotalTrades = fullStats.TotalTrades
|
||||
summary.WinRate = fullStats.WinRate
|
||||
summary.TotalPnL = fullStats.TotalPnL
|
||||
if fullStats.TotalTrades > 0 {
|
||||
summary.AvgTradeReturn = fullStats.TotalPnL / float64(fullStats.TotalTrades)
|
||||
}
|
||||
|
||||
// Get symbol stats - best performers
|
||||
symbolStats, _ := s.GetSymbolStats(traderID, 20)
|
||||
if len(symbolStats) > 0 {
|
||||
// Best 3
|
||||
for i := 0; i < len(symbolStats) && i < 3; i++ {
|
||||
if symbolStats[i].TotalPnL > 0 {
|
||||
summary.BestSymbols = append(summary.BestSymbols, symbolStats[i])
|
||||
}
|
||||
}
|
||||
// Worst 3 (from the end)
|
||||
for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- {
|
||||
if symbolStats[i].TotalPnL < 0 {
|
||||
summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get direction stats
|
||||
dirStats, _ := s.GetDirectionStats(traderID)
|
||||
for _, d := range dirStats {
|
||||
if d.Side == "LONG" {
|
||||
summary.LongWinRate = d.WinRate
|
||||
summary.LongPnL = d.TotalPnL
|
||||
} else if d.Side == "SHORT" {
|
||||
summary.ShortWinRate = d.WinRate
|
||||
summary.ShortPnL = d.TotalPnL
|
||||
}
|
||||
}
|
||||
|
||||
// Get holding time stats
|
||||
holdStats, _ := s.GetHoldingTimeStats(traderID)
|
||||
var bestHoldWinRate float64
|
||||
for _, h := range holdStats {
|
||||
if h.WinRate > bestHoldWinRate && h.TradeCount >= 3 {
|
||||
bestHoldWinRate = h.WinRate
|
||||
summary.BestHoldRange = h.Range
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average holding time
|
||||
var avgHold sql.NullFloat64
|
||||
s.db.QueryRow(`
|
||||
SELECT AVG((julianday(exit_time) - julianday(entry_time)) * 24 * 60)
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
`, traderID).Scan(&avgHold)
|
||||
if avgHold.Valid {
|
||||
summary.AvgHoldingMins = avgHold.Float64
|
||||
}
|
||||
|
||||
// Get recent 20 trades performance
|
||||
var recentWins int
|
||||
var recentTotal int
|
||||
var recentPnL float64
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC LIMIT 20
|
||||
`, traderID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pnl float64
|
||||
rows.Scan(&pnl)
|
||||
recentTotal++
|
||||
recentPnL += pnl
|
||||
if pnl > 0 {
|
||||
recentWins++
|
||||
}
|
||||
}
|
||||
}
|
||||
if recentTotal > 0 {
|
||||
summary.RecentWinRate = float64(recentWins) / float64(recentTotal) * 100
|
||||
summary.RecentPnL = recentPnL
|
||||
}
|
||||
|
||||
// Calculate streaks
|
||||
s.calculateStreaks(traderID, summary)
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// calculateStreaks calculates win/loss streaks
|
||||
func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT realized_pnl FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED'
|
||||
ORDER BY exit_time DESC
|
||||
`, traderID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var currentStreak, maxWin, maxLose int
|
||||
var prevWin *bool
|
||||
isFirst := true
|
||||
|
||||
for rows.Next() {
|
||||
var pnl float64
|
||||
rows.Scan(&pnl)
|
||||
isWin := pnl > 0
|
||||
|
||||
if isFirst {
|
||||
if isWin {
|
||||
currentStreak = 1
|
||||
} else {
|
||||
currentStreak = -1
|
||||
}
|
||||
isFirst = false
|
||||
}
|
||||
|
||||
if prevWin == nil {
|
||||
prevWin = &isWin
|
||||
} else if *prevWin == isWin {
|
||||
if isWin {
|
||||
currentStreak++
|
||||
if currentStreak > maxWin {
|
||||
maxWin = currentStreak
|
||||
}
|
||||
} else {
|
||||
currentStreak--
|
||||
if -currentStreak > maxLose {
|
||||
maxLose = -currentStreak
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if isWin {
|
||||
currentStreak = 1
|
||||
} else {
|
||||
currentStreak = -1
|
||||
}
|
||||
*prevWin = isWin
|
||||
}
|
||||
}
|
||||
|
||||
summary.CurrentStreak = currentStreak
|
||||
summary.MaxWinStreak = maxWin
|
||||
summary.MaxLoseStreak = maxLose
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deduplication and Sync Methods
|
||||
// =============================================================================
|
||||
|
||||
// ExistsWithExchangePositionID checks if a position with the given exchange position ID already exists
|
||||
func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionID string) (bool, error) {
|
||||
if exchangePositionID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM trader_positions
|
||||
WHERE trader_id = ? AND exchange_position_id = ?
|
||||
`, traderID, exchangePositionID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check position existence: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CreateFromClosedPnL creates a closed position record from exchange closed PnL data
|
||||
// This is used for syncing historical positions from exchange
|
||||
// Returns true if created, false if already exists (deduped)
|
||||
func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record *ClosedPnLRecord) (bool, error) {
|
||||
// Generate unique exchange position ID from record data
|
||||
exchangePositionID := record.ExchangeID
|
||||
if exchangePositionID == "" {
|
||||
// Fallback: generate from order ID + exit time
|
||||
exchangePositionID = fmt.Sprintf("%s_%d", record.OrderID, record.ExitTime.UnixMilli())
|
||||
}
|
||||
|
||||
// Check if already exists
|
||||
exists, err := s.ExistsWithExchangePositionID(traderID, exchangePositionID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil // Already exists, skip
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
side := strings.ToUpper(record.Side)
|
||||
if side == "LONG" || side == "BUY" {
|
||||
side = "LONG"
|
||||
} else {
|
||||
side = "SHORT"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
exitTime := record.ExitTime
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
|
||||
entry_price, entry_order_id, entry_time,
|
||||
exit_price, exit_order_id, exit_time,
|
||||
realized_pnl, fee, leverage, status, close_reason, source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?)
|
||||
`,
|
||||
traderID, exchangeID, exchangePositionID, record.Symbol, side, record.Quantity,
|
||||
record.EntryPrice, "", record.EntryTime.Format(time.RFC3339),
|
||||
record.ExitPrice, record.OrderID, exitTime.Format(time.RFC3339),
|
||||
record.RealizedPnL, record.Fee, record.Leverage, record.CloseType,
|
||||
now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
// Could be duplicate key error, treat as already exists
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to create position from closed PnL: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ClosedPnLRecord represents a closed position record from exchange (duplicated here for store package)
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string
|
||||
Side string
|
||||
EntryPrice float64
|
||||
ExitPrice float64
|
||||
Quantity float64
|
||||
RealizedPnL float64
|
||||
Fee float64
|
||||
Leverage int
|
||||
EntryTime time.Time
|
||||
ExitTime time.Time
|
||||
OrderID string
|
||||
CloseType string
|
||||
ExchangeID string
|
||||
}
|
||||
|
||||
// GetLastClosedPositionTime gets the most recent exit time from closed positions
|
||||
// This is used to determine the start time for syncing new closed positions
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
||||
var exitTime sql.NullString
|
||||
err := s.db.QueryRow(`
|
||||
SELECT exit_time FROM trader_positions
|
||||
WHERE trader_id = ? AND status = 'CLOSED' AND exit_time IS NOT NULL
|
||||
ORDER BY exit_time DESC LIMIT 1
|
||||
`, traderID).Scan(&exitTime)
|
||||
|
||||
if err == sql.ErrNoRows || !exitTime.Valid {
|
||||
// No closed positions, return 30 days ago as default
|
||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
}
|
||||
|
||||
t, _ := time.Parse(time.RFC3339, exitTime.String)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateOpenPosition creates an open position record with exchange position ID
|
||||
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
// Check if already exists by exchange position ID
|
||||
if pos.ExchangePositionID != "" {
|
||||
exists, err := s.ExistsWithExchangePositionID(pos.TraderID, pos.ExchangePositionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil // Already exists, skip
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
pos.CreatedAt = now
|
||||
pos.UpdatedAt = now
|
||||
pos.Status = "OPEN"
|
||||
if pos.Source == "" {
|
||||
pos.Source = "system"
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, exchange_id, exchange_position_id, symbol, side, quantity,
|
||||
entry_price, entry_order_id, entry_time, leverage, status, source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
pos.TraderID, pos.ExchangeID, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity,
|
||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||
pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil // Already exists
|
||||
}
|
||||
return fmt.Errorf("failed to create open position: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
pos.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClosePositionWithAccurateData closes a position with accurate data from exchange
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
exit_price = ?, exit_order_id = ?, exit_time = ?,
|
||||
realized_pnl = ?, fee = ?, status = 'CLOSED',
|
||||
close_reason = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
exitPrice, exitOrderID, exitTime.Format(time.RFC3339),
|
||||
realizedPnL, fee, closeReason, now.Format(time.RFC3339), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close position with accurate data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncClosedPositions syncs closed positions from exchange to local database
|
||||
// Returns (created count, skipped count, error)
|
||||
func (s *PositionStore) SyncClosedPositions(traderID, exchangeID string, records []ClosedPnLRecord) (int, int, error) {
|
||||
created, skipped := 0, 0
|
||||
for _, record := range records {
|
||||
rec := record // Create local copy to avoid closure issues
|
||||
wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, &rec)
|
||||
if err != nil {
|
||||
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
|
||||
}
|
||||
if wasCreated {
|
||||
created++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
return created, skipped, nil
|
||||
}
|
||||
|
||||
+44
-19
@@ -128,22 +128,46 @@ type ExternalDataSource struct {
|
||||
}
|
||||
|
||||
// RiskControlConfig risk control configuration
|
||||
// All parameters are clearly defined without ambiguity:
|
||||
//
|
||||
// Position Limits:
|
||||
// - MaxPositions: max number of coins held simultaneously (CODE ENFORCED)
|
||||
//
|
||||
// Trading Leverage (exchange leverage for opening positions):
|
||||
// - BTCETHMaxLeverage: BTC/ETH max exchange leverage (AI guided)
|
||||
// - AltcoinMaxLeverage: Altcoin max exchange leverage (AI guided)
|
||||
//
|
||||
// Position Value Limits (single position notional value / account equity):
|
||||
// - BTCETHMaxPositionValueRatio: BTC/ETH max = equity × ratio (CODE ENFORCED)
|
||||
// - AltcoinMaxPositionValueRatio: Altcoin max = equity × ratio (CODE ENFORCED)
|
||||
//
|
||||
// Risk Controls:
|
||||
// - MaxMarginUsage: max margin utilization percentage (CODE ENFORCED)
|
||||
// - MinPositionSize: minimum position size in USDT (CODE ENFORCED)
|
||||
// - MinRiskRewardRatio: min take_profit / stop_loss ratio (AI guided)
|
||||
// - MinConfidence: min AI confidence to open position (AI guided)
|
||||
type RiskControlConfig struct {
|
||||
// maximum number of positions
|
||||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||
MaxPositions int `json:"max_positions"`
|
||||
// BTC/ETH maximum leverage
|
||||
|
||||
// BTC/ETH exchange leverage for opening positions (AI guided)
|
||||
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||||
// altcoin maximum leverage
|
||||
// Altcoin exchange leverage for opening positions (AI guided)
|
||||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||||
// minimum risk-reward ratio
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// maximum margin usage
|
||||
|
||||
// BTC/ETH single position max value = equity × this ratio (CODE ENFORCED, default: 5)
|
||||
BTCETHMaxPositionValueRatio float64 `json:"btc_eth_max_position_value_ratio"`
|
||||
// Altcoin single position max value = equity × this ratio (CODE ENFORCED, default: 1)
|
||||
AltcoinMaxPositionValueRatio float64 `json:"altcoin_max_position_value_ratio"`
|
||||
|
||||
// Max margin utilization (e.g. 0.9 = 90%) (CODE ENFORCED)
|
||||
MaxMarginUsage float64 `json:"max_margin_usage"`
|
||||
// maximum position ratio per coin (relative to account equity)
|
||||
MaxPositionRatio float64 `json:"max_position_ratio"`
|
||||
// minimum position size (USDT)
|
||||
// Min position size in USDT (CODE ENFORCED)
|
||||
MinPositionSize float64 `json:"min_position_size"`
|
||||
// minimum confidence level
|
||||
|
||||
// Min take_profit / stop_loss ratio (AI guided)
|
||||
MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"`
|
||||
// Min AI confidence to open position (AI guided)
|
||||
MinConfidence int `json:"min_confidence"`
|
||||
}
|
||||
|
||||
@@ -192,7 +216,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 30,
|
||||
CoinPoolLimit: 10,
|
||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||
UseOITop: false,
|
||||
OITopLimit: 20,
|
||||
@@ -224,14 +248,15 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
EnableQuantNetflow: true,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3,
|
||||
BTCETHMaxLeverage: 5,
|
||||
AltcoinMaxLeverage: 5,
|
||||
MinRiskRewardRatio: 3.0,
|
||||
MaxMarginUsage: 0.9,
|
||||
MaxPositionRatio: 1.5,
|
||||
MinPositionSize: 12,
|
||||
MinConfidence: 75,
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
// Set leverage first
|
||||
// Set leverage first (non-fatal if position already exists)
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
// Error -2030: Cannot adjust leverage when position exists
|
||||
// This is expected when adding to an existing position, continue with current leverage
|
||||
if strings.Contains(err.Error(), "-2030") {
|
||||
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current price
|
||||
@@ -647,9 +653,15 @@ func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) (
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err)
|
||||
}
|
||||
|
||||
// Set leverage first
|
||||
// Set leverage first (non-fatal if position already exists)
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
// Error -2030: Cannot adjust leverage when position exists
|
||||
// This is expected when adding to an existing position, continue with current leverage
|
||||
if strings.Contains(err.Error(), "-2030") {
|
||||
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to set leverage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current price
|
||||
@@ -1279,3 +1291,12 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records from exchange
|
||||
// Aster does not have a direct closed PnL API, returns empty slice
|
||||
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// Aster does not provide a closed PnL history API
|
||||
// Position closure data needs to be tracked locally via position sync
|
||||
logger.Infof("⚠️ Aster GetClosedPnL not supported, returning empty")
|
||||
return []ClosedPnLRecord{}, nil
|
||||
}
|
||||
|
||||
+193
-54
@@ -240,6 +240,14 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
if foundBalance > 0 {
|
||||
config.InitialBalance = foundBalance
|
||||
logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance)
|
||||
// Save to database so it persists across restarts
|
||||
if st != nil {
|
||||
if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil {
|
||||
logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err)
|
||||
} else {
|
||||
logger.Infof("✓ [%s] Initial balance saved to database", config.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance")
|
||||
}
|
||||
@@ -657,7 +665,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
|
||||
// 6. Build context
|
||||
ctx := &decision.Context{
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||
CallCount: at.callCount,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
@@ -676,33 +684,21 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
CandidateCoins: candidateCoins,
|
||||
}
|
||||
|
||||
// 7. Add trading statistics and historical orders (if store is available)
|
||||
// 7. Add recent closed trades (if store is available)
|
||||
if at.store != nil {
|
||||
// Get trading statistics (using new positions table)
|
||||
if stats, err := at.store.Position().GetFullStats(at.id); err == nil {
|
||||
ctx.TradingStats = &decision.TradingStats{
|
||||
TotalTrades: stats.TotalTrades,
|
||||
WinRate: stats.WinRate,
|
||||
ProfitFactor: stats.ProfitFactor,
|
||||
SharpeRatio: stats.SharpeRatio,
|
||||
TotalPnL: stats.TotalPnL,
|
||||
AvgWin: stats.AvgWin,
|
||||
AvgLoss: stats.AvgLoss,
|
||||
MaxDrawdownPct: stats.MaxDrawdownPct,
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent 10 closed trades (using new positions table)
|
||||
// Get recent 10 closed trades for AI context
|
||||
if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil {
|
||||
for _, trade := range recentTrades {
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
ExitPrice: trade.ExitPrice,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
PnLPct: trade.PnLPct,
|
||||
FilledAt: trade.ExitTime,
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
ExitPrice: trade.ExitPrice,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
PnLPct: trade.PnLPct,
|
||||
EntryTime: trade.EntryTime,
|
||||
ExitTime: trade.ExitTime,
|
||||
HoldDuration: trade.HoldDuration,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -755,13 +751,21 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow)
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err == nil {
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||||
return fmt.Errorf("❌ %s already has long position, rejecting to prevent position stacking overflow. If changing position, please give close_long decision first", decision.Symbol)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Check max positions limit
|
||||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if there's already a position in the same symbol and direction
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||||
return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -771,6 +775,37 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
return err
|
||||
}
|
||||
|
||||
// Get balance (needed for multiple checks)
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Get equity for position value ratio check
|
||||
equity := 0.0
|
||||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else {
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
decision.PositionSizeUSD = adjustedPositionSize
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Minimum position size check
|
||||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate quantity
|
||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||
actionRecord.Quantity = quantity
|
||||
@@ -779,15 +814,6 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Fee estimation (Taker fee rate 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
@@ -838,13 +864,21 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Critical: Check if there's already a position in the same symbol and direction, reject if exists (prevent position stacking overflow)
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
positions, err := at.trader.GetPositions()
|
||||
if err == nil {
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||||
return fmt.Errorf("❌ %s already has short position, rejecting to prevent position stacking overflow. If changing position, please give close_short decision first", decision.Symbol)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Check max positions limit
|
||||
if err := at.enforceMaxPositions(len(positions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if there's already a position in the same symbol and direction
|
||||
for _, pos := range positions {
|
||||
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||||
return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +888,37 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
return err
|
||||
}
|
||||
|
||||
// Get balance (needed for multiple checks)
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Get equity for position value ratio check
|
||||
equity := 0.0
|
||||
if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 {
|
||||
equity = eq
|
||||
} else {
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
decision.PositionSizeUSD = adjustedPositionSize
|
||||
}
|
||||
|
||||
// [CODE ENFORCED] Minimum position size check
|
||||
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate quantity
|
||||
quantity := decision.PositionSizeUSD / marketData.CurrentPrice
|
||||
actionRecord.Quantity = quantity
|
||||
@@ -862,15 +927,6 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
|
||||
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
|
||||
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account balance: %w", err)
|
||||
}
|
||||
availableBalance := 0.0
|
||||
if avail, ok := balance["availableBalance"].(float64); ok {
|
||||
availableBalance = avail
|
||||
}
|
||||
|
||||
// Fee estimation (Taker fee rate 0.04%)
|
||||
estimatedFee := decision.PositionSizeUSD * 0.0004
|
||||
totalRequired := requiredMargin + estimatedFee
|
||||
@@ -1606,3 +1662,86 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Risk Control Helpers
|
||||
// ============================================================================
|
||||
|
||||
// isBTCETH checks if a symbol is BTC or ETH
|
||||
func isBTCETH(symbol string) bool {
|
||||
symbol = strings.ToUpper(symbol)
|
||||
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
|
||||
}
|
||||
|
||||
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
|
||||
// Returns the adjusted position size (capped if necessary) and whether the position was capped
|
||||
// positionSizeUSD: the original position size in USD
|
||||
// equity: the account equity
|
||||
// symbol: the trading symbol
|
||||
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return positionSizeUSD, false
|
||||
}
|
||||
|
||||
riskControl := at.config.StrategyConfig.RiskControl
|
||||
|
||||
// Get the appropriate position value ratio limit
|
||||
var maxPositionValueRatio float64
|
||||
if isBTCETH(symbol) {
|
||||
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
|
||||
if maxPositionValueRatio <= 0 {
|
||||
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH
|
||||
}
|
||||
} else {
|
||||
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
|
||||
if maxPositionValueRatio <= 0 {
|
||||
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate max allowed position value = equity × ratio
|
||||
maxPositionValue := equity * maxPositionValueRatio
|
||||
|
||||
// Check if position size exceeds limit
|
||||
if positionSizeUSD > maxPositionValue {
|
||||
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
|
||||
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
|
||||
return maxPositionValue, true
|
||||
}
|
||||
|
||||
return positionSizeUSD, false
|
||||
}
|
||||
|
||||
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
|
||||
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
|
||||
if minSize <= 0 {
|
||||
minSize = 12 // Default: 12 USDT
|
||||
}
|
||||
|
||||
if positionSizeUSD < minSize {
|
||||
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
|
||||
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
||||
if at.config.StrategyConfig == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
|
||||
if maxPositions <= 0 {
|
||||
maxPositions = 3 // Default: 3 positions
|
||||
}
|
||||
|
||||
if currentPositionCount >= maxPositions {
|
||||
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -957,3 +957,116 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from Binance Futures
|
||||
// Binance API: /fapi/v1/income with incomeType=REALIZED_PNL
|
||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
// Use income history API to get realized PnL
|
||||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||
IncomeType("REALIZED_PNL").
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(int64(limit)).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get income history: %w", err)
|
||||
}
|
||||
|
||||
records := make([]ClosedPnLRecord, 0, len(incomes))
|
||||
|
||||
for _, income := range incomes {
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: income.Symbol,
|
||||
ExchangeID: fmt.Sprintf("%d", income.TranID),
|
||||
}
|
||||
|
||||
// Parse realized PnL
|
||||
record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64)
|
||||
|
||||
// Parse time
|
||||
record.ExitTime = time.UnixMilli(income.Time)
|
||||
|
||||
// Income API doesn't provide entry/exit price directly
|
||||
// We need to get these from trade history if needed
|
||||
// For now, leave them as 0 (will be matched with local DB records)
|
||||
|
||||
// Determine side from PnL sign (approximate)
|
||||
// Note: This is not 100% accurate; actual side comes from position tracking
|
||||
record.Side = "unknown"
|
||||
record.CloseType = "unknown"
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
// Enrich with trade history for more details (if needed)
|
||||
// This requires additional API calls per symbol, so we do it only for important records
|
||||
if len(records) > 0 {
|
||||
t.enrichClosedPnLWithTrades(records, startTime)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// enrichClosedPnLWithTrades adds entry/exit price details from trade history
|
||||
func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) {
|
||||
// Group by symbol
|
||||
symbolSet := make(map[string]bool)
|
||||
for _, r := range records {
|
||||
symbolSet[r.Symbol] = true
|
||||
}
|
||||
|
||||
// Get trade history for each symbol
|
||||
for symbol := range symbolSet {
|
||||
trades, err := t.client.NewListAccountTradeService().
|
||||
Symbol(symbol).
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build a map of trades by time for quick lookup
|
||||
for i := range records {
|
||||
if records[i].Symbol != symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find matching trade(s) near the income time
|
||||
for _, trade := range trades {
|
||||
tradeTime := time.UnixMilli(trade.Time)
|
||||
// Match if within 1 second of the PnL record
|
||||
if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second {
|
||||
// Found matching trade
|
||||
records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64)
|
||||
records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64)
|
||||
commission, _ := strconv.ParseFloat(trade.Commission, 64)
|
||||
records[i].Fee += commission
|
||||
|
||||
// Determine side
|
||||
if trade.PositionSide == futures.PositionSideTypeLong {
|
||||
records[i].Side = "long"
|
||||
} else if trade.PositionSide == futures.PositionSideTypeShort {
|
||||
records[i].Side = "short"
|
||||
}
|
||||
|
||||
// Determine close type from order type (approximate)
|
||||
if trade.Buyer && records[i].Side == "short" ||
|
||||
!trade.Buyer && records[i].Side == "long" {
|
||||
// This is a close trade
|
||||
records[i].CloseType = "unknown" // Can't determine SL/TP from trade data
|
||||
}
|
||||
|
||||
records[i].OrderID = strconv.FormatInt(trade.OrderID, 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+155
-2
@@ -2,12 +2,15 @@ package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,7 +21,9 @@ import (
|
||||
|
||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||
type BybitTrader struct {
|
||||
client *bybit.Client
|
||||
client *bybit.Client
|
||||
apiKey string
|
||||
secretKey string
|
||||
|
||||
// Balance cache
|
||||
cachedBalance map[string]interface{}
|
||||
@@ -59,6 +64,8 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader {
|
||||
|
||||
trader := &BybitTrader{
|
||||
client: client,
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
cacheDuration: 15 * time.Second,
|
||||
qtyStepCache: make(map[string]float64),
|
||||
}
|
||||
@@ -856,3 +863,149 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
|
||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
|
||||
return t.getClosedPnLViaHTTP(startTime, limit)
|
||||
}
|
||||
|
||||
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
|
||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// Build query string
|
||||
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
||||
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
|
||||
|
||||
// Generate timestamp
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
recvWindow := "5000"
|
||||
|
||||
// Build signature payload: timestamp + api_key + recv_window + queryString
|
||||
signPayload := timestamp + t.apiKey + recvWindow + queryParams
|
||||
|
||||
// Generate HMAC-SHA256 signature
|
||||
h := hmac.New(sha256.New, []byte(t.secretKey))
|
||||
h.Write([]byte(signPayload))
|
||||
signature := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add Bybit V5 API headers
|
||||
req.Header.Set("X-BAPI-API-KEY", t.apiKey)
|
||||
req.Header.Set("X-BAPI-SIGN", signature)
|
||||
req.Header.Set("X-BAPI-SIGN-TYPE", "2")
|
||||
req.Header.Set("X-BAPI-TIMESTAMP", timestamp)
|
||||
req.Header.Set("X-BAPI-RECV-WINDOW", recvWindow)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Use http.DefaultClient for the request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call Bybit API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
return t.parseClosedPnLResult(result.Result)
|
||||
}
|
||||
|
||||
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
|
||||
data, ok := resultData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result format")
|
||||
}
|
||||
|
||||
list, _ := data["list"].([]interface{})
|
||||
var records []ClosedPnLRecord
|
||||
|
||||
for _, item := range list {
|
||||
pnl, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse fields
|
||||
symbol, _ := pnl["symbol"].(string)
|
||||
side, _ := pnl["side"].(string)
|
||||
orderId, _ := pnl["orderId"].(string)
|
||||
|
||||
avgEntryPriceStr, _ := pnl["avgEntryPrice"].(string)
|
||||
avgExitPriceStr, _ := pnl["avgExitPrice"].(string)
|
||||
qtyStr, _ := pnl["qty"].(string)
|
||||
closedPnLStr, _ := pnl["closedPnl"].(string)
|
||||
cumEntryValueStr, _ := pnl["cumEntryValue"].(string)
|
||||
cumExitValueStr, _ := pnl["cumExitValue"].(string)
|
||||
leverageStr, _ := pnl["leverage"].(string)
|
||||
createdTimeStr, _ := pnl["createdTime"].(string)
|
||||
updatedTimeStr, _ := pnl["updatedTime"].(string)
|
||||
|
||||
avgEntryPrice, _ := strconv.ParseFloat(avgEntryPriceStr, 64)
|
||||
avgExitPrice, _ := strconv.ParseFloat(avgExitPriceStr, 64)
|
||||
qty, _ := strconv.ParseFloat(qtyStr, 64)
|
||||
closedPnL, _ := strconv.ParseFloat(closedPnLStr, 64)
|
||||
leverage, _ := strconv.ParseInt(leverageStr, 10, 64)
|
||||
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
|
||||
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
|
||||
|
||||
// Calculate approximate fee from value difference
|
||||
cumEntryValue, _ := strconv.ParseFloat(cumEntryValueStr, 64)
|
||||
cumExitValue, _ := strconv.ParseFloat(cumExitValueStr, 64)
|
||||
expectedPnL := cumExitValue - cumEntryValue
|
||||
if side == "Sell" {
|
||||
expectedPnL = cumEntryValue - cumExitValue
|
||||
}
|
||||
fee := expectedPnL - closedPnL
|
||||
if fee < 0 {
|
||||
fee = 0
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
normalizedSide := "long"
|
||||
if side == "Sell" {
|
||||
normalizedSide = "short"
|
||||
}
|
||||
|
||||
record := ClosedPnLRecord{
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
EntryPrice: avgEntryPrice,
|
||||
ExitPrice: avgExitPrice,
|
||||
Quantity: qty,
|
||||
RealizedPnL: closedPnL,
|
||||
Fee: fee,
|
||||
Leverage: int(leverage),
|
||||
EntryTime: time.UnixMilli(createdTime),
|
||||
ExitTime: time.UnixMilli(updatedTime),
|
||||
OrderID: orderId,
|
||||
CloseType: "unknown", // Bybit doesn't provide close type directly
|
||||
ExchangeID: orderId, // Use orderId as exchange ID
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
@@ -949,3 +950,12 @@ func absFloat(x float64) float64 {
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records from exchange
|
||||
// Hyperliquid does not have a direct closed PnL API, returns empty slice
|
||||
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// Hyperliquid does not provide a closed PnL history API
|
||||
// Position closure data needs to be tracked locally via position sync
|
||||
logger.Infof("⚠️ Hyperliquid GetClosedPnL not supported, returning empty")
|
||||
return []ClosedPnLRecord{}, nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
package trader
|
||||
|
||||
import "time"
|
||||
|
||||
// ClosedPnLRecord represents a single closed position record from exchange
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string // Trading pair (e.g., "BTCUSDT")
|
||||
Side string // "long" or "short"
|
||||
EntryPrice float64 // Entry price
|
||||
ExitPrice float64 // Exit/close price
|
||||
Quantity float64 // Position size
|
||||
RealizedPnL float64 // Realized profit/loss
|
||||
Fee float64 // Trading fee/commission
|
||||
Leverage int // Leverage used
|
||||
EntryTime time.Time // Position open time
|
||||
ExitTime time.Time // Position close time
|
||||
OrderID string // Close order ID
|
||||
CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown"
|
||||
ExchangeID string // Exchange-specific position ID
|
||||
}
|
||||
|
||||
// Trader Unified trader interface
|
||||
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
|
||||
type Trader interface {
|
||||
@@ -54,4 +73,10 @@ type Trader interface {
|
||||
// GetOrderStatus Get order status
|
||||
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
|
||||
GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error)
|
||||
|
||||
// GetClosedPnL Get closed position PnL records from exchange
|
||||
// startTime: start time for query (usually last sync time)
|
||||
// limit: max number of records to return
|
||||
// Returns accurate exit price, fees, and close reason for positions closed externally
|
||||
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
|
||||
}
|
||||
|
||||
@@ -213,3 +213,12 @@ func (t *LighterTrader) Run() error {
|
||||
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
||||
return fmt.Errorf("please use AutoTrader to manage trader lifecycle")
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records from exchange
|
||||
// LIGHTER does not have a direct closed PnL API, returns empty slice
|
||||
func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// LIGHTER does not provide a closed PnL history API
|
||||
// Position closure data needs to be tracked locally via position sync
|
||||
logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty")
|
||||
return []ClosedPnLRecord{}, nil
|
||||
}
|
||||
|
||||
@@ -277,3 +277,12 @@ func (t *LighterTraderV2) Cleanup() error {
|
||||
logger.Info("⏹ LIGHTER trader cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records from exchange
|
||||
// LIGHTER does not have a direct closed PnL API, returns empty slice
|
||||
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
// LIGHTER does not provide a closed PnL history API
|
||||
// Position closure data needs to be tracked locally via position sync
|
||||
logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty")
|
||||
return []ClosedPnLRecord{}, nil
|
||||
}
|
||||
|
||||
@@ -1138,3 +1138,112 @@ var okxTag = func() string {
|
||||
b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==")
|
||||
return string(b)
|
||||
}()
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from OKX
|
||||
// OKX API: /api/v5/account/positions-history
|
||||
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Build query path with parameters
|
||||
path := fmt.Sprintf("/api/v5/account/positions-history?instType=SWAP&limit=%d", limit)
|
||||
if !startTime.IsZero() {
|
||||
path += fmt.Sprintf("&after=%d", startTime.UnixMilli())
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions history: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data []struct {
|
||||
InstID string `json:"instId"` // Instrument ID (e.g., "BTC-USDT-SWAP")
|
||||
Direction string `json:"direction"` // Position direction: "long" or "short"
|
||||
OpenAvgPx string `json:"openAvgPx"` // Average open price
|
||||
CloseAvgPx string `json:"closeAvgPx"` // Average close price
|
||||
CloseTotalPos string `json:"closeTotalPos"` // Closed position quantity
|
||||
RealizedPnl string `json:"realizedPnl"` // Realized PnL
|
||||
Fee string `json:"fee"` // Total fee
|
||||
FundingFee string `json:"fundingFee"` // Funding fee
|
||||
Lever string `json:"lever"` // Leverage
|
||||
CTime string `json:"cTime"` // Position open time
|
||||
UTime string `json:"uTime"` // Position close time
|
||||
Type string `json:"type"` // Close type: 1=close position, 2=partial close, 3=liquidation, 4=partial liquidation
|
||||
PosId string `json:"posId"` // Position ID
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.Code != "0" {
|
||||
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
records := make([]ClosedPnLRecord, 0, len(resp.Data))
|
||||
|
||||
for _, pos := range resp.Data {
|
||||
record := ClosedPnLRecord{}
|
||||
|
||||
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
|
||||
parts := strings.Split(pos.InstID, "-")
|
||||
if len(parts) >= 2 {
|
||||
record.Symbol = parts[0] + parts[1]
|
||||
} else {
|
||||
record.Symbol = pos.InstID
|
||||
}
|
||||
|
||||
// Side
|
||||
record.Side = pos.Direction // OKX already returns "long" or "short"
|
||||
|
||||
// Prices
|
||||
record.EntryPrice, _ = strconv.ParseFloat(pos.OpenAvgPx, 64)
|
||||
record.ExitPrice, _ = strconv.ParseFloat(pos.CloseAvgPx, 64)
|
||||
|
||||
// Quantity
|
||||
record.Quantity, _ = strconv.ParseFloat(pos.CloseTotalPos, 64)
|
||||
|
||||
// PnL
|
||||
record.RealizedPnL, _ = strconv.ParseFloat(pos.RealizedPnl, 64)
|
||||
|
||||
// Fee
|
||||
fee, _ := strconv.ParseFloat(pos.Fee, 64)
|
||||
fundingFee, _ := strconv.ParseFloat(pos.FundingFee, 64)
|
||||
record.Fee = -fee + fundingFee // Fee is negative in OKX
|
||||
|
||||
// Leverage
|
||||
lev, _ := strconv.ParseFloat(pos.Lever, 64)
|
||||
record.Leverage = int(lev)
|
||||
|
||||
// Times
|
||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||
record.EntryTime = time.UnixMilli(cTime)
|
||||
record.ExitTime = time.UnixMilli(uTime)
|
||||
|
||||
// Close type
|
||||
switch pos.Type {
|
||||
case "1", "2":
|
||||
record.CloseType = "unknown" // Could be manual or AI, need to cross-reference
|
||||
case "3", "4":
|
||||
record.CloseType = "liquidation"
|
||||
default:
|
||||
record.CloseType = "unknown"
|
||||
}
|
||||
|
||||
// Exchange ID
|
||||
record.ExchangeID = pos.PosId
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
+353
-28
@@ -11,13 +11,16 @@ import (
|
||||
// PositionSyncManager Position status synchronization manager
|
||||
// Responsible for periodically synchronizing exchange positions, detecting manual closures and other changes
|
||||
type PositionSyncManager struct {
|
||||
store *store.Store
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
||||
cacheMutex sync.RWMutex
|
||||
store *store.Store
|
||||
interval time.Duration
|
||||
historySyncInterval time.Duration // Interval for full history sync
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
||||
cacheMutex sync.RWMutex
|
||||
lastHistorySync map[string]time.Time // trader_id -> last history sync time
|
||||
lastHistorySyncMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPositionSyncManager Create position synchronization manager
|
||||
@@ -26,11 +29,13 @@ func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSy
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
return &PositionSyncManager{
|
||||
store: st,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
traderCache: make(map[string]Trader),
|
||||
configCache: make(map[string]*store.TraderFullConfig),
|
||||
store: st,
|
||||
interval: interval,
|
||||
historySyncInterval: 5 * time.Minute, // Sync closed positions every 5 minutes
|
||||
stopCh: make(chan struct{}),
|
||||
traderCache: make(map[string]Trader),
|
||||
configCache: make(map[string]*store.TraderFullConfig),
|
||||
lastHistorySync: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +44,9 @@ func (m *PositionSyncManager) Start() {
|
||||
m.wg.Add(1)
|
||||
go m.run()
|
||||
logger.Info("📊 Position sync manager started")
|
||||
|
||||
// Run startup sync in background
|
||||
go m.startupSync()
|
||||
}
|
||||
|
||||
// Stop Stop position synchronization service
|
||||
@@ -109,6 +117,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
||||
return
|
||||
}
|
||||
|
||||
// Get exchange ID for history sync
|
||||
config, _ := m.getTraderConfig(traderID)
|
||||
exchangeID := ""
|
||||
if config != nil {
|
||||
exchangeID = config.Exchange.ID
|
||||
}
|
||||
|
||||
// Maybe run periodic history sync
|
||||
if exchangeID != "" {
|
||||
m.maybeRunHistorySync(traderID, exchangeID, trader)
|
||||
}
|
||||
|
||||
// Get current exchange positions
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
@@ -154,40 +174,133 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition
|
||||
|
||||
// closeLocalPosition Mark local position as closed
|
||||
func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) {
|
||||
// Try to get last trade price as exit price
|
||||
exitPrice := pos.EntryPrice // Default to entry price
|
||||
// Try to get accurate closure data from exchange first
|
||||
closedPnLRecord := m.findClosedPnLRecord(trader, pos)
|
||||
|
||||
// Try to get latest price from exchange
|
||||
if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 {
|
||||
exitPrice = price
|
||||
}
|
||||
var exitPrice, realizedPnL, fee float64
|
||||
var closeReason, exitOrderID string
|
||||
|
||||
// Calculate PnL
|
||||
var realizedPnL float64
|
||||
if pos.Side == "LONG" {
|
||||
realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity
|
||||
if closedPnLRecord != nil {
|
||||
// Use accurate data from exchange
|
||||
exitPrice = closedPnLRecord.ExitPrice
|
||||
realizedPnL = closedPnLRecord.RealizedPnL
|
||||
fee = closedPnLRecord.Fee
|
||||
closeReason = closedPnLRecord.CloseType
|
||||
exitOrderID = closedPnLRecord.OrderID
|
||||
logger.Infof("📊 Found accurate closure data from exchange for %s %s", pos.Symbol, pos.Side)
|
||||
} else {
|
||||
realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity
|
||||
// Fallback: use market price and calculate PnL
|
||||
exitPrice = pos.EntryPrice // Default to entry price
|
||||
if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 {
|
||||
exitPrice = price
|
||||
}
|
||||
|
||||
// Calculate PnL
|
||||
if pos.Side == "LONG" {
|
||||
realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity
|
||||
} else {
|
||||
realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity
|
||||
}
|
||||
closeReason = reason
|
||||
fee = 0
|
||||
exitOrderID = ""
|
||||
logger.Infof("⚠️ Using market price for closure (no exchange data): %s %s", pos.Symbol, pos.Side)
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := m.store.Position().ClosePosition(
|
||||
pos.ID,
|
||||
exitPrice,
|
||||
"", // Manual close has no order ID
|
||||
exitOrderID,
|
||||
realizedPnL,
|
||||
0, // Manual close cannot get fee
|
||||
reason,
|
||||
fee,
|
||||
closeReason,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to update position status: %v", err)
|
||||
} else {
|
||||
logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f (%s)",
|
||||
pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, reason)
|
||||
logger.Infof("📊 Position closed [%s] %s %s @ %.4f → %.4f, PnL: %.2f, Fee: %.4f (%s)",
|
||||
pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, fee, closeReason)
|
||||
}
|
||||
}
|
||||
|
||||
// findClosedPnLRecord Try to find matching ClosedPnL record from exchange
|
||||
func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.TraderPosition) *ClosedPnLRecord {
|
||||
// Get closed PnL records from the last 24 hours (to cover recent closures)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
records, err := trader.GetClosedPnL(startTime, 50)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get closed PnL records: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize position side for comparison
|
||||
posSide := pos.Side
|
||||
if posSide == "LONG" {
|
||||
posSide = "long"
|
||||
} else if posSide == "SHORT" {
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
// Find matching record by symbol and side
|
||||
// Priority: exact match on symbol and side, closest entry price
|
||||
var bestMatch *ClosedPnLRecord
|
||||
var bestPriceDiff float64 = -1
|
||||
|
||||
for i := range records {
|
||||
record := &records[i]
|
||||
if record.Symbol != pos.Symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match side (case-insensitive)
|
||||
recordSide := record.Side
|
||||
if recordSide == "LONG" {
|
||||
recordSide = "long"
|
||||
} else if recordSide == "SHORT" {
|
||||
recordSide = "short"
|
||||
}
|
||||
|
||||
if recordSide != posSide {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if entry price is close (within 2% to account for slippage)
|
||||
if record.EntryPrice > 0 {
|
||||
priceDiff := abs((record.EntryPrice - pos.EntryPrice) / pos.EntryPrice)
|
||||
if priceDiff > 0.02 {
|
||||
continue // Entry price too different, probably not the same position
|
||||
}
|
||||
|
||||
// Prefer closest entry price match
|
||||
if bestMatch == nil || priceDiff < bestPriceDiff {
|
||||
bestMatch = record
|
||||
bestPriceDiff = priceDiff
|
||||
}
|
||||
} else {
|
||||
// No entry price in record, accept if symbol and side match
|
||||
if bestMatch == nil {
|
||||
bestMatch = record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
// abs returns absolute value of float64
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// getOrCreateTrader Get or create trader instance
|
||||
func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) {
|
||||
m.cacheMutex.RLock()
|
||||
@@ -320,3 +433,215 @@ func getFloatFromMap(m map[string]interface{}, key string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup and History Sync Methods
|
||||
// =============================================================================
|
||||
|
||||
// startupSync performs initial sync on startup
|
||||
// 1. Sync existing positions from exchange (to detect external positions)
|
||||
// 2. Sync closed positions history from exchange
|
||||
func (m *PositionSyncManager) startupSync() {
|
||||
logger.Info("📊 Starting startup sync...")
|
||||
|
||||
// Get all traders
|
||||
traders, err := m.store.Trader().ListAll()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get traders for startup sync: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, traderInfo := range traders {
|
||||
traderID := traderInfo.ID
|
||||
|
||||
// Get trader instance
|
||||
trader, err := m.getOrCreateTrader(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get trader instance for startup sync (ID: %s): %v", traderID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get exchange ID
|
||||
config, err := m.getTraderConfig(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get trader config for startup sync (ID: %s): %v", traderID, err)
|
||||
continue
|
||||
}
|
||||
exchangeID := config.Exchange.ID
|
||||
|
||||
// 1. Sync current open positions from exchange
|
||||
m.syncExternalPositions(traderID, exchangeID, trader)
|
||||
|
||||
// 2. Sync closed positions history from exchange
|
||||
m.syncClosedPositionsHistory(traderID, exchangeID, trader)
|
||||
}
|
||||
|
||||
logger.Info("📊 Startup sync completed")
|
||||
}
|
||||
|
||||
// syncExternalPositions syncs positions that exist on exchange but not locally
|
||||
// These could be positions opened manually or from other systems
|
||||
func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, trader Trader) {
|
||||
// Get current positions from exchange
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get exchange positions for external sync (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get local open positions
|
||||
localPositions, err := m.store.Position().GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get local positions for external sync (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build local position map: symbol_side -> position
|
||||
localMap := make(map[string]*store.TraderPosition)
|
||||
for _, pos := range localPositions {
|
||||
key := fmt.Sprintf("%s_%s", pos.Symbol, pos.Side)
|
||||
localMap[key] = pos
|
||||
}
|
||||
|
||||
// Find positions that exist on exchange but not locally
|
||||
for _, pos := range exchangePositions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
if symbol == "" || side == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize side
|
||||
normalizedSide := side
|
||||
if side == "Buy" || side == "LONG" || side == "long" {
|
||||
normalizedSide = "LONG"
|
||||
} else if side == "Sell" || side == "SHORT" || side == "short" {
|
||||
normalizedSide = "SHORT"
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s_%s", symbol, normalizedSide)
|
||||
|
||||
// Check if we already have this position locally
|
||||
if _, exists := localMap[key]; exists {
|
||||
continue // Already tracking this position
|
||||
}
|
||||
|
||||
// This is an external position - create local record
|
||||
qty := getFloatFromMap(pos, "positionAmt")
|
||||
if qty < 0 {
|
||||
qty = -qty
|
||||
}
|
||||
if qty < 0.0000001 {
|
||||
continue // No actual position
|
||||
}
|
||||
|
||||
entryPrice := getFloatFromMap(pos, "entryPrice")
|
||||
leverage := int(getFloatFromMap(pos, "leverage"))
|
||||
if leverage == 0 {
|
||||
leverage = 1
|
||||
}
|
||||
|
||||
// Get entry time if available
|
||||
createdTime := getFloatFromMap(pos, "createdTime")
|
||||
var entryTime time.Time
|
||||
if createdTime > 0 {
|
||||
entryTime = time.UnixMilli(int64(createdTime))
|
||||
} else {
|
||||
entryTime = time.Now() // Use current time as fallback
|
||||
}
|
||||
|
||||
// Generate unique exchange position ID
|
||||
exchangePositionID := fmt.Sprintf("%s_%s_%d", symbol, normalizedSide, entryTime.UnixMilli())
|
||||
|
||||
newPos := &store.TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangePositionID: exchangePositionID,
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
Quantity: qty,
|
||||
EntryPrice: entryPrice,
|
||||
EntryTime: entryTime,
|
||||
Leverage: leverage,
|
||||
Source: "sync", // Mark as synced from exchange
|
||||
}
|
||||
|
||||
if err := m.store.Position().CreateOpenPosition(newPos); err != nil {
|
||||
logger.Infof("⚠️ Failed to create external position record: %v", err)
|
||||
} else {
|
||||
logger.Infof("📊 Synced external position: [%s] %s %s @ %.4f (qty: %.4f)",
|
||||
traderID[:8], symbol, normalizedSide, entryPrice, qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncClosedPositionsHistory syncs closed positions from exchange history
|
||||
func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID string, trader Trader) {
|
||||
// Get last sync time
|
||||
lastSyncTime, err := m.store.Position().GetLastClosedPositionTime(traderID)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get last closed position time (ID: %s): %v", traderID, err)
|
||||
lastSyncTime = time.Now().Add(-30 * 24 * time.Hour) // Default to 30 days ago
|
||||
}
|
||||
|
||||
// Subtract a small buffer to avoid missing positions at the boundary
|
||||
startTime := lastSyncTime.Add(-1 * time.Minute)
|
||||
|
||||
// Get closed positions from exchange
|
||||
closedRecords, err := trader.GetClosedPnL(startTime, 200) // Get up to 200 records
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(closedRecords) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to store.ClosedPnLRecord and sync
|
||||
storeRecords := make([]store.ClosedPnLRecord, len(closedRecords))
|
||||
for i, rec := range closedRecords {
|
||||
storeRecords[i] = store.ClosedPnLRecord{
|
||||
Symbol: rec.Symbol,
|
||||
Side: rec.Side,
|
||||
EntryPrice: rec.EntryPrice,
|
||||
ExitPrice: rec.ExitPrice,
|
||||
Quantity: rec.Quantity,
|
||||
RealizedPnL: rec.RealizedPnL,
|
||||
Fee: rec.Fee,
|
||||
Leverage: rec.Leverage,
|
||||
EntryTime: rec.EntryTime,
|
||||
ExitTime: rec.ExitTime,
|
||||
OrderID: rec.OrderID,
|
||||
CloseType: rec.CloseType,
|
||||
ExchangeID: rec.ExchangeID,
|
||||
}
|
||||
}
|
||||
|
||||
created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, storeRecords)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if created > 0 {
|
||||
logger.Infof("📊 Synced %d new closed positions for trader %s (skipped %d duplicates)",
|
||||
created, traderID[:8], skipped)
|
||||
}
|
||||
|
||||
// Update last history sync time
|
||||
m.lastHistorySyncMutex.Lock()
|
||||
m.lastHistorySync[traderID] = time.Now()
|
||||
m.lastHistorySyncMutex.Unlock()
|
||||
}
|
||||
|
||||
// maybeRunHistorySync checks if it's time to run history sync for a trader
|
||||
func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID string, trader Trader) {
|
||||
m.lastHistorySyncMutex.RLock()
|
||||
lastSync, exists := m.lastHistorySync[traderID]
|
||||
m.lastHistorySyncMutex.RUnlock()
|
||||
|
||||
if !exists || time.Since(lastSync) >= m.historySyncInterval {
|
||||
m.syncClosedPositionsHistory(traderID, exchangeID, trader)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -807,7 +807,7 @@ function TraderDetailsPage({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-4 text-sm"
|
||||
className="flex items-center gap-4 text-sm flex-wrap"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
@@ -826,6 +826,20 @@ function TraderDetailsPage({
|
||||
)}
|
||||
</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 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
|
||||
@@ -368,7 +368,7 @@ export function TraderConfigModal({
|
||||
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||
</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>
|
||||
|
||||
@@ -211,10 +211,10 @@ export function CoinSourceEditor({
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.coin_pool_limit || 30}
|
||||
value={config.coin_pool_limit || 10}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 30 })
|
||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 10 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
|
||||
@@ -19,15 +19,24 @@ export function RiskControlEditor({
|
||||
positionLimits: { zh: '仓位限制', en: 'Position Limits' },
|
||||
maxPositions: { zh: '最大持仓数量', en: 'Max Positions' },
|
||||
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' },
|
||||
btcEthLeverage: { zh: 'BTC/ETH 最大杠杆', en: 'BTC/ETH Max Leverage' },
|
||||
altcoinLeverage: { zh: '山寨币最大杠杆', en: 'Altcoin Max Leverage' },
|
||||
// Trading leverage (exchange leverage)
|
||||
tradingLeverage: { zh: '交易杠杆(交易所杠杆)', en: 'Trading Leverage (Exchange)' },
|
||||
btcEthLeverage: { zh: 'BTC/ETH 交易杠杆', en: 'BTC/ETH Trading Leverage' },
|
||||
btcEthLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions' },
|
||||
altcoinLeverage: { zh: '山寨币交易杠杆', en: 'Altcoin Trading Leverage' },
|
||||
altcoinLeverageDesc: { zh: '交易所开仓使用的杠杆倍数', en: 'Exchange leverage for opening positions' },
|
||||
// Position value ratio (risk control) - CODE ENFORCED
|
||||
positionValueRatio: { zh: '仓位价值比例(代码强制)', en: 'Position Value Ratio (CODE ENFORCED)' },
|
||||
positionValueRatioDesc: { zh: '单仓位名义价值 / 账户净值,由代码强制执行', en: 'Position notional value / equity, enforced by code' },
|
||||
btcEthPositionValueRatio: { zh: 'BTC/ETH 仓位价值比例', en: 'BTC/ETH Position Value Ratio' },
|
||||
btcEthPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)' },
|
||||
altcoinPositionValueRatio: { zh: '山寨币仓位价值比例', en: 'Altcoin Position Value Ratio' },
|
||||
altcoinPositionValueRatioDesc: { zh: '单仓最大名义价值 = 净值 × 此值(代码强制)', en: 'Max position value = equity × this ratio (CODE ENFORCED)' },
|
||||
riskParameters: { zh: '风险参数', en: 'Risk Parameters' },
|
||||
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' },
|
||||
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' },
|
||||
maxMarginUsage: { zh: '最大保证金使用率', en: 'Max Margin Usage' },
|
||||
maxMarginUsageDesc: { zh: '保证金使用率上限', en: 'Maximum margin utilization' },
|
||||
maxPositionRatio: { zh: '单币最大仓位比', en: 'Max Position Ratio' },
|
||||
maxPositionRatioDesc: { zh: '相对账户净值的倍数', en: 'Multiple of account equity' },
|
||||
maxMarginUsage: { zh: '最大保证金使用率(代码强制)', en: 'Max Margin Usage (CODE ENFORCED)' },
|
||||
maxMarginUsageDesc: { zh: '保证金使用率上限,由代码强制执行', en: 'Maximum margin utilization, enforced by code' },
|
||||
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' },
|
||||
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' },
|
||||
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' },
|
||||
@@ -57,7 +66,7 @@ export function RiskControlEditor({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 mb-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
@@ -70,14 +79,14 @@ export function RiskControlEditor({
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_positions}
|
||||
value={config.max_positions ?? 3}
|
||||
onChange={(e) =>
|
||||
updateField('max_positions', parseInt(e.target.value) || 3)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
className="w-32 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
@@ -85,7 +94,15 @@ export function RiskControlEditor({
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
@@ -93,10 +110,13 @@ export function RiskControlEditor({
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('btcEthLeverage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('btcEthLeverageDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.btc_eth_max_leverage}
|
||||
value={config.btc_eth_max_leverage ?? 5}
|
||||
onChange={(e) =>
|
||||
updateField('btc_eth_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
@@ -109,7 +129,7 @@ export function RiskControlEditor({
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.btc_eth_max_leverage}x
|
||||
{config.btc_eth_max_leverage ?? 5}x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,10 +141,13 @@ export function RiskControlEditor({
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('altcoinLeverage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('altcoinLeverageDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.altcoin_max_leverage}
|
||||
value={config.altcoin_max_leverage ?? 5}
|
||||
onChange={(e) =>
|
||||
updateField('altcoin_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
@@ -137,7 +160,82 @@ export function RiskControlEditor({
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.altcoin_max_leverage}x
|
||||
{config.altcoin_max_leverage ?? 5}x
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,7 +251,7 @@ export function RiskControlEditor({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
@@ -168,7 +266,7 @@ export function RiskControlEditor({
|
||||
<span style={{ color: '#848E9C' }}>1:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_risk_reward_ratio}
|
||||
value={config.min_risk_reward_ratio ?? 3}
|
||||
onChange={(e) =>
|
||||
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
|
||||
}
|
||||
@@ -188,7 +286,7 @@ export function RiskControlEditor({
|
||||
|
||||
<div
|
||||
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' }}>
|
||||
{t('maxMarginUsage')}
|
||||
@@ -199,51 +297,17 @@ export function RiskControlEditor({
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.max_margin_usage * 100}
|
||||
value={(config.max_margin_usage ?? 0.9) * 100}
|
||||
onChange={(e) =>
|
||||
updateField('max_margin_usage', parseInt(e.target.value) / 100)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
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' }}>
|
||||
{Math.round(config.max_margin_usage * 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 className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||
{Math.round((config.max_margin_usage ?? 0.9) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,7 +337,7 @@ export function RiskControlEditor({
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_position_size}
|
||||
value={config.min_position_size ?? 12}
|
||||
onChange={(e) =>
|
||||
updateField('min_position_size', parseFloat(e.target.value) || 12)
|
||||
}
|
||||
@@ -306,7 +370,7 @@ export function RiskControlEditor({
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.min_confidence}
|
||||
value={config.min_confidence ?? 75}
|
||||
onChange={(e) =>
|
||||
updateField('min_confidence', parseInt(e.target.value))
|
||||
}
|
||||
@@ -316,7 +380,7 @@ export function RiskControlEditor({
|
||||
className="flex-1 accent-green-500"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||
{config.min_confidence}
|
||||
{config.min_confidence ?? 75}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -692,12 +692,42 @@ export function ExchangeConfigModal({
|
||||
{/* 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>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('user', language)}
|
||||
{t('asterUserLabel', language)}
|
||||
<Tooltip content={t('asterUserDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
@@ -709,7 +739,7 @@ export function ExchangeConfigModal({
|
||||
type="text"
|
||||
value={asterUser}
|
||||
onChange={(e) => setAsterUser(e.target.value)}
|
||||
placeholder={t('enterUser', language)}
|
||||
placeholder={t('enterAsterUser', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
@@ -718,14 +748,21 @@ export function ExchangeConfigModal({
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('asterUserDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Pro 代理钱包地址 */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('signer', language)}
|
||||
{t('asterSignerLabel', language)}
|
||||
<Tooltip content={t('asterSignerDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
@@ -737,7 +774,7 @@ export function ExchangeConfigModal({
|
||||
type="text"
|
||||
value={asterSigner}
|
||||
onChange={(e) => setAsterSigner(e.target.value)}
|
||||
placeholder={t('enterSigner', language)}
|
||||
placeholder={t('enterAsterSigner', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
@@ -746,14 +783,21 @@ export function ExchangeConfigModal({
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('asterSignerDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Pro 代理钱包私钥 */}
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('privateKey', language)}
|
||||
{t('asterPrivateKeyLabel', language)}
|
||||
<Tooltip content={t('asterPrivateKeyDesc', language)}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
@@ -765,7 +809,7 @@ export function ExchangeConfigModal({
|
||||
type="password"
|
||||
value={asterPrivateKey}
|
||||
onChange={(e) => setAsterPrivateKey(e.target.value)}
|
||||
placeholder={t('enterPrivateKey', language)}
|
||||
placeholder={t('enterAsterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
@@ -774,6 +818,12 @@ export function ExchangeConfigModal({
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
className="text-xs mt-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('asterPrivateKeyDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -75,6 +75,7 @@ export function TradersGrid({
|
||||
trader.ai_model.split('_').pop() || trader.ai_model
|
||||
)}{' '}
|
||||
Model • {trader.exchange_id?.toUpperCase()}
|
||||
<span style={{ color: '#F0B90B' }}> • {trader.strategy_name || 'No Strategy'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,14 +352,24 @@ export const translations = {
|
||||
enterHyperliquidMainWalletAddress: 'Enter Main wallet address',
|
||||
hyperliquidMainWalletAddressDesc:
|
||||
'Main wallet address that holds your trading funds (never expose its private key)',
|
||||
// Aster API Pro Configuration
|
||||
asterApiProTitle: 'Aster API Pro Wallet Configuration',
|
||||
asterApiProDesc:
|
||||
'Use API Pro wallet for secure trading: API wallet signs transactions, main wallet holds funds (never expose main wallet private key)',
|
||||
asterUserDesc:
|
||||
'Main wallet address - The EVM wallet address you use to log in to Aster (Note: Only EVM wallets are supported, Solana wallets are not supported)',
|
||||
'Main wallet address - The EVM wallet address you use to log in to Aster (Note: Only EVM wallets are supported)',
|
||||
asterSignerDesc:
|
||||
'API wallet address - Generate from https://www.asterdex.com/en/api-wallet',
|
||||
'API Pro wallet address (0x...) - Generate from https://www.asterdex.com/en/api-wallet',
|
||||
asterPrivateKeyDesc:
|
||||
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
|
||||
'API Pro wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
|
||||
asterUsdtWarning:
|
||||
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
|
||||
asterUserLabel: 'Main Wallet Address',
|
||||
asterSignerLabel: 'API Pro Wallet Address',
|
||||
asterPrivateKeyLabel: 'API Pro Wallet Private Key',
|
||||
enterAsterUser: 'Enter main wallet address (0x...)',
|
||||
enterAsterSigner: 'Enter API Pro wallet address (0x...)',
|
||||
enterAsterPrivateKey: 'Enter API Pro wallet private key',
|
||||
|
||||
// LIGHTER Configuration
|
||||
lighterWalletAddress: 'L1 Wallet Address',
|
||||
@@ -1347,14 +1357,24 @@ export const translations = {
|
||||
enterHyperliquidMainWalletAddress: '输入主钱包地址',
|
||||
hyperliquidMainWalletAddressDesc:
|
||||
'持有交易资金的主钱包地址(永不暴露其私钥)',
|
||||
// Aster API Pro 配置
|
||||
asterApiProTitle: 'Aster API Pro 代理钱包配置',
|
||||
asterApiProDesc:
|
||||
'使用 API Pro 代理钱包安全交易:代理钱包用于签名交易,主钱包持有资金(永不暴露主钱包私钥)',
|
||||
asterUserDesc:
|
||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(注意:仅支持 EVM 钱包,不支持 Solana 钱包)',
|
||||
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址(仅支持 EVM 钱包)',
|
||||
asterSignerDesc:
|
||||
'API 钱包地址 - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
|
||||
'API Pro 代理钱包地址 (0x...) - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
|
||||
asterPrivateKeyDesc:
|
||||
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||
'API Pro 代理钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
|
||||
asterUsdtWarning:
|
||||
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
|
||||
asterUserLabel: '主钱包地址',
|
||||
asterSignerLabel: 'API Pro 代理钱包地址',
|
||||
asterPrivateKeyLabel: 'API Pro 代理钱包私钥',
|
||||
enterAsterUser: '输入主钱包地址 (0x...)',
|
||||
enterAsterSigner: '输入 API Pro 代理钱包地址 (0x...)',
|
||||
enterAsterPrivateKey: '输入 API Pro 代理钱包私钥',
|
||||
|
||||
// LIGHTER 配置
|
||||
lighterWalletAddress: 'L1 錢包地址',
|
||||
|
||||
+18
-7
@@ -91,6 +91,8 @@ export interface TraderInfo {
|
||||
ai_model: string
|
||||
exchange_id?: string
|
||||
is_running?: boolean
|
||||
strategy_id?: string
|
||||
strategy_name?: string
|
||||
custom_prompt?: string
|
||||
use_coin_pool?: boolean
|
||||
use_oi_top?: boolean
|
||||
@@ -437,12 +439,21 @@ export interface ExternalDataSource {
|
||||
}
|
||||
|
||||
export interface RiskControlConfig {
|
||||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||
max_positions: number;
|
||||
btc_eth_max_leverage: number;
|
||||
altcoin_max_leverage: number;
|
||||
min_risk_reward_ratio: number;
|
||||
max_margin_usage: number;
|
||||
max_position_ratio: number;
|
||||
min_position_size: number;
|
||||
min_confidence: number;
|
||||
|
||||
// Trading Leverage - exchange leverage for opening positions (AI guided)
|
||||
btc_eth_max_leverage: number; // BTC/ETH max exchange leverage
|
||||
altcoin_max_leverage: number; // Altcoin max exchange leverage
|
||||
|
||||
// Position Value Ratio - single position notional value / account equity (CODE ENFORCED)
|
||||
// Max position value = equity × this ratio
|
||||
btc_eth_max_position_value_ratio?: number; // default: 5 (BTC/ETH max position = 5x equity)
|
||||
altcoin_max_position_value_ratio?: number; // default: 1 (Altcoin max position = 1x equity)
|
||||
|
||||
// Risk Parameters
|
||||
max_margin_usage: number; // Max margin utilization, e.g. 0.9 = 90% (CODE ENFORCED)
|
||||
min_position_size: number; // Min position size in USDT (CODE ENFORCED)
|
||||
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||
min_confidence: number; // Min AI confidence to open position (AI guided)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user