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:
tinkle-community
2025-12-10 14:40:08 +08:00
parent c19ee51dee
commit 319ccb8ca3
45 changed files with 2951 additions and 3392 deletions
-9
View File
@@ -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
View File
@@ -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
+1 -5
View File
@@ -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,
"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{
+59
View File
@@ -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,
},
}
}
+13 -5
View File
@@ -34,6 +34,7 @@ type Runner struct {
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
-17
View File
@@ -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表": "交易所配置"
}
}
View File
+876 -414
View File
File diff suppressed because it is too large Load Diff
-162
View File
@@ -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)
}
-285
View File
@@ -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)
}
}
-243
View File
@@ -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")
}
-29
View File
@@ -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)
}
}
}
-969
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

+2 -1
View File
@@ -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
}
+15 -5
View File
@@ -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() {
-180
View File
@@ -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是底线
- 纪律执行是长期盈利的关键
**现在,请基于以上原则分析市场并做出稳健决策**
-129
View File
@@ -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是底线
-239
View File
@@ -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.
-337
View File
@@ -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(各时间框架)
- EMA504小时框架)
- 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时强行交易,无视策略失效信号。
- ❌ 情绪化决策和报复性交易,被短期波动左右。
- ❌ 过度自信忽视风险控制,放宽开仓或仓位标准。
---
**核心提示**:你拥有完整的技术分析自主权,基于提供的多维数据自由构建交易逻辑。特别注意:震荡行情完全由你自主分析处理,我们不过多干预你的分析判断。
+1 -1
View File
@@ -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]
}
+10 -31
View File
@@ -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"
;;
+589 -6
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"math"
"strings"
"time"
)
@@ -27,6 +28,7 @@ type TraderPosition struct {
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
@@ -41,6 +43,7 @@ type TraderPosition struct {
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"`
}
@@ -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,12 +105,16 @@ 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 {
// 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)
}
}
}
return nil
}
@@ -348,13 +361,15 @@ type RecentTrade struct {
ExitPrice float64 `json:"exit_price"`
RealizedPnL float64 `json:"realized_pnl"`
PnLPct float64 `json:"pnl_pct"`
ExitTime string `json:"exit_time"`
EntryTime string `json:"entry_time"` // Entry time (开仓时间)
ExitTime string `json:"exit_time"` // Exit time (平仓时间)
HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m"
}
// GetRecentTrades gets recent closed trades
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
View File
@@ -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)
},
}
+23 -2
View File
@@ -580,10 +580,16 @@ 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 {
// 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
price, err := t.GetMarketPrice(symbol)
@@ -647,10 +653,16 @@ 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 {
// 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
price, err := t.GetMarketPrice(symbol)
@@ -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
}
+183 -44
View File
@@ -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,23 +684,9 @@ 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{
@@ -702,7 +696,9 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
ExitPrice: trade.ExitPrice,
RealizedPnL: trade.RealizedPnL,
PnLPct: trade.PnLPct,
FilledAt: trade.ExitTime,
EntryTime: trade.EntryTime,
ExitTime: trade.ExitTime,
HoldDuration: trade.HoldDuration,
})
}
}
@@ -755,13 +751,21 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
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 {
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, rejecting to prevent position stacking overflow. If changing position, please give close_long decision first", decision.Symbol)
}
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 {
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, rejecting to prevent position stacking overflow. If changing position, please give close_short decision first", decision.Symbol)
}
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
}
+113
View File
@@ -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
}
}
}
}
}
+154 -1
View File
@@ -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"
@@ -19,6 +22,8 @@ import (
// BybitTrader Bybit USDT Perpetual Futures Trader
type BybitTrader struct {
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
}
+10
View File
@@ -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
}
+25
View File
@@ -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)
}
+9
View File
@@ -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
}
+9
View File
@@ -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
}
+109
View File
@@ -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
}
+334 -9
View File
@@ -13,11 +13,14 @@ import (
type PositionSyncManager struct {
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
@@ -28,9 +31,11 @@ func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSy
return &PositionSyncManager{
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
var exitPrice, realizedPnL, fee float64
var closeReason, exitOrderID string
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 {
// 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
var realizedPnL float64
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
View File
@@ -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>
+1 -1
View File
@@ -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}
+121 -57
View File
@@ -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>
+26 -6
View File
@@ -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
View File
@@ -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)
}