mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
2d272bb7b8
- Migrate all store packages from raw database/sql to GORM ORM - Add PostgreSQL support alongside SQLite - Move EncryptedString type to crypto package for cleaner architecture - Add automatic encryption/decryption for sensitive fields (API keys, secrets) - Fix PostgreSQL AutoMigrate conflicts by skipping existing tables - Fix duplicate /klines route registration - Update tests to use GORM database connections - Add database configuration support in config package
422 lines
16 KiB
Go
422 lines
16 KiB
Go
package store
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// StrategyStore strategy storage
|
||
type StrategyStore struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// Strategy strategy configuration
|
||
type Strategy struct {
|
||
ID string `gorm:"primaryKey" json:"id"`
|
||
UserID string `gorm:"column:user_id;not null;default:'';index" json:"user_id"`
|
||
Name string `gorm:"not null" json:"name"`
|
||
Description string `gorm:"default:''" json:"description"`
|
||
IsActive bool `gorm:"column:is_active;default:false;index" json:"is_active"`
|
||
IsDefault bool `gorm:"column:is_default;default:false" json:"is_default"`
|
||
Config string `gorm:"not null;default:'{}'" json:"config"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (Strategy) TableName() string { return "strategies" }
|
||
|
||
// StrategyConfig strategy configuration details (JSON structure)
|
||
type StrategyConfig struct {
|
||
// coin source configuration
|
||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||
// quantitative data configuration
|
||
Indicators IndicatorConfig `json:"indicators"`
|
||
// custom prompt (appended at the end)
|
||
CustomPrompt string `json:"custom_prompt,omitempty"`
|
||
// risk control configuration
|
||
RiskControl RiskControlConfig `json:"risk_control"`
|
||
// editable sections of System Prompt
|
||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||
}
|
||
|
||
// PromptSectionsConfig editable sections of System Prompt
|
||
type PromptSectionsConfig struct {
|
||
// role definition (title + description)
|
||
RoleDefinition string `json:"role_definition,omitempty"`
|
||
// trading frequency awareness
|
||
TradingFrequency string `json:"trading_frequency,omitempty"`
|
||
// entry standards
|
||
EntryStandards string `json:"entry_standards,omitempty"`
|
||
// decision process
|
||
DecisionProcess string `json:"decision_process,omitempty"`
|
||
}
|
||
|
||
// CoinSourceConfig coin source configuration
|
||
type CoinSourceConfig struct {
|
||
// source type: "static" | "coinpool" | "oi_top" | "mixed"
|
||
SourceType string `json:"source_type"`
|
||
// static coin list (used when source_type = "static")
|
||
StaticCoins []string `json:"static_coins,omitempty"`
|
||
// whether to use AI500 coin pool
|
||
UseCoinPool bool `json:"use_coin_pool"`
|
||
// AI500 coin pool maximum count
|
||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
||
// AI500 coin pool API URL (strategy-level configuration)
|
||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
||
// whether to use OI Top
|
||
UseOITop bool `json:"use_oi_top"`
|
||
// OI Top maximum count
|
||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||
// OI Top API URL (strategy-level configuration)
|
||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
||
}
|
||
|
||
// IndicatorConfig indicator configuration
|
||
type IndicatorConfig struct {
|
||
// K-line configuration
|
||
Klines KlineConfig `json:"klines"`
|
||
// raw kline data (OHLCV) - always enabled, required for AI analysis
|
||
EnableRawKlines bool `json:"enable_raw_klines"`
|
||
// technical indicator switches
|
||
EnableEMA bool `json:"enable_ema"`
|
||
EnableMACD bool `json:"enable_macd"`
|
||
EnableRSI bool `json:"enable_rsi"`
|
||
EnableATR bool `json:"enable_atr"`
|
||
EnableBOLL bool `json:"enable_boll"` // Bollinger Bands
|
||
EnableVolume bool `json:"enable_volume"`
|
||
EnableOI bool `json:"enable_oi"` // open interest
|
||
EnableFundingRate bool `json:"enable_funding_rate"` // funding rate
|
||
// EMA period configuration
|
||
EMAPeriods []int `json:"ema_periods,omitempty"` // default [20, 50]
|
||
// RSI period configuration
|
||
RSIPeriods []int `json:"rsi_periods,omitempty"` // default [7, 14]
|
||
// ATR period configuration
|
||
ATRPeriods []int `json:"atr_periods,omitempty"` // default [14]
|
||
// BOLL period configuration (period, standard deviation multiplier is fixed at 2)
|
||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||
// external data sources
|
||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||
// quantitative data sources (capital flow, position changes, price changes)
|
||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
||
OIRankingAPIURL string `json:"oi_ranking_api_url,omitempty"` // OI ranking API base URL
|
||
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
||
}
|
||
|
||
// KlineConfig K-line configuration
|
||
type KlineConfig struct {
|
||
// primary timeframe: "1m", "3m", "5m", "15m", "1h", "4h"
|
||
PrimaryTimeframe string `json:"primary_timeframe"`
|
||
// primary timeframe K-line count
|
||
PrimaryCount int `json:"primary_count"`
|
||
// longer timeframe
|
||
LongerTimeframe string `json:"longer_timeframe,omitempty"`
|
||
// longer timeframe K-line count
|
||
LongerCount int `json:"longer_count,omitempty"`
|
||
// whether to enable multi-timeframe analysis
|
||
EnableMultiTimeframe bool `json:"enable_multi_timeframe"`
|
||
// selected timeframe list (new: supports multi-timeframe selection)
|
||
SelectedTimeframes []string `json:"selected_timeframes,omitempty"`
|
||
}
|
||
|
||
// ExternalDataSource external data source configuration
|
||
type ExternalDataSource struct {
|
||
Name string `json:"name"` // data source name
|
||
Type string `json:"type"` // type: "api" | "webhook"
|
||
URL string `json:"url"` // API URL
|
||
Method string `json:"method"` // HTTP method
|
||
Headers map[string]string `json:"headers,omitempty"`
|
||
DataPath string `json:"data_path,omitempty"` // JSON data path
|
||
RefreshSecs int `json:"refresh_secs,omitempty"` // refresh interval (seconds)
|
||
}
|
||
|
||
// RiskControlConfig risk control configuration
|
||
type RiskControlConfig struct {
|
||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||
MaxPositions int `json:"max_positions"`
|
||
|
||
// BTC/ETH exchange leverage for opening positions (AI guided)
|
||
BTCETHMaxLeverage int `json:"btc_eth_max_leverage"`
|
||
// Altcoin exchange leverage for opening positions (AI guided)
|
||
AltcoinMaxLeverage int `json:"altcoin_max_leverage"`
|
||
|
||
// 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"`
|
||
// Min position size in USDT (CODE ENFORCED)
|
||
MinPositionSize float64 `json:"min_position_size"`
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// NewStrategyStore creates a new StrategyStore
|
||
func NewStrategyStore(db *gorm.DB) *StrategyStore {
|
||
return &StrategyStore{db: db}
|
||
}
|
||
|
||
func (s *StrategyStore) initTables() error {
|
||
// For PostgreSQL with existing table, skip AutoMigrate
|
||
if s.db.Dialector.Name() == "postgres" {
|
||
var tableExists int64
|
||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'strategies'`).Scan(&tableExists)
|
||
if tableExists > 0 {
|
||
return nil
|
||
}
|
||
}
|
||
return s.db.AutoMigrate(&Strategy{})
|
||
}
|
||
|
||
func (s *StrategyStore) initDefaultData() error {
|
||
// No longer pre-populate strategies - create on demand when user configures
|
||
return nil
|
||
}
|
||
|
||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||
config := StrategyConfig{
|
||
CoinSource: CoinSourceConfig{
|
||
SourceType: "coinpool",
|
||
UseCoinPool: true,
|
||
CoinPoolLimit: 10,
|
||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||
UseOITop: false,
|
||
OITopLimit: 20,
|
||
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
|
||
},
|
||
Indicators: IndicatorConfig{
|
||
Klines: KlineConfig{
|
||
PrimaryTimeframe: "5m",
|
||
PrimaryCount: 30,
|
||
LongerTimeframe: "4h",
|
||
LongerCount: 10,
|
||
EnableMultiTimeframe: true,
|
||
SelectedTimeframes: []string{"5m", "15m", "1h", "4h"},
|
||
},
|
||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||
EnableEMA: false,
|
||
EnableMACD: false,
|
||
EnableRSI: false,
|
||
EnableATR: false,
|
||
EnableBOLL: false,
|
||
EnableVolume: true,
|
||
EnableOI: true,
|
||
EnableFundingRate: true,
|
||
EMAPeriods: []int{20, 50},
|
||
RSIPeriods: []int{7, 14},
|
||
ATRPeriods: []int{14},
|
||
BOLLPeriods: []int{20},
|
||
EnableQuantData: true,
|
||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
||
EnableQuantOI: true,
|
||
EnableQuantNetflow: true,
|
||
// OI ranking data - market-wide OI increase/decrease rankings
|
||
EnableOIRanking: true,
|
||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
||
OIRankingDuration: "1h",
|
||
OIRankingLimit: 10,
|
||
},
|
||
RiskControl: RiskControlConfig{
|
||
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)
|
||
},
|
||
}
|
||
|
||
if lang == "zh" {
|
||
config.PromptSections = PromptSectionsConfig{
|
||
RoleDefinition: `# 你是一个专业的加密货币交易AI
|
||
|
||
你的任务是根据提供的市场数据做出交易决策。你是一个经验丰富的量化交易员,擅长技术分析和风险管理。`,
|
||
TradingFrequency: `# ⏱️ 交易频率意识
|
||
|
||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||
- 每小时超过2笔 = 过度交易
|
||
- 单笔持仓时间 ≥ 30-60分钟
|
||
如果你发现自己每个周期都在交易 → 标准太低;如果持仓不到30分钟就平仓 → 太冲动。`,
|
||
EntryStandards: `# 🎯 入场标准(严格)
|
||
|
||
只在多个信号共振时入场。自由使用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、或平仓后立即重新开仓等低质量行为。`,
|
||
DecisionProcess: `# 📋 决策流程
|
||
|
||
1. 检查持仓 → 是否止盈/止损
|
||
2. 扫描候选币种 + 多时间框架 → 是否存在强信号
|
||
3. 先写思维链,再输出结构化JSON`,
|
||
}
|
||
} else {
|
||
config.PromptSections = PromptSectionsConfig{
|
||
RoleDefinition: `# You are a professional cryptocurrency trading AI
|
||
|
||
Your task is to make trading decisions based on the provided market data. You are an experienced quantitative trader skilled in technical analysis and risk management.`,
|
||
TradingFrequency: `# ⏱️ Trading Frequency Awareness
|
||
|
||
- Excellent trader: 2-4 trades per day ≈ 0.1-0.2 trades per hour
|
||
- >2 trades per hour = overtrading
|
||
- Single position holding time ≥ 30-60 minutes
|
||
If you find yourself trading every cycle → standards are too low; if closing positions in <30 minutes → too impulsive.`,
|
||
EntryStandards: `# 🎯 Entry Standards (Strict)
|
||
|
||
Only enter positions when multiple signals resonate. Freely use any effective analysis methods, avoid low-quality behaviors such as single indicators, contradictory signals, sideways oscillation, or immediately restarting after closing positions.`,
|
||
DecisionProcess: `# 📋 Decision Process
|
||
|
||
1. Check positions → whether to take profit/stop loss
|
||
2. Scan candidate coins + multi-timeframe → whether strong signals exist
|
||
3. Write chain of thought first, then output structured JSON`,
|
||
}
|
||
}
|
||
|
||
return config
|
||
}
|
||
|
||
// Create create a strategy
|
||
func (s *StrategyStore) Create(strategy *Strategy) error {
|
||
return s.db.Create(strategy).Error
|
||
}
|
||
|
||
// Update update a strategy
|
||
func (s *StrategyStore) Update(strategy *Strategy) error {
|
||
return s.db.Model(&Strategy{}).
|
||
Where("id = ? AND user_id = ?", strategy.ID, strategy.UserID).
|
||
Updates(map[string]interface{}{
|
||
"name": strategy.Name,
|
||
"description": strategy.Description,
|
||
"config": strategy.Config,
|
||
"updated_at": time.Now(),
|
||
}).Error
|
||
}
|
||
|
||
// Delete delete a strategy
|
||
func (s *StrategyStore) Delete(userID, id string) error {
|
||
// do not allow deleting system default strategy
|
||
var st Strategy
|
||
if err := s.db.Where("id = ?", id).First(&st).Error; err == nil && st.IsDefault {
|
||
return fmt.Errorf("cannot delete system default strategy")
|
||
}
|
||
|
||
return s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&Strategy{}).Error
|
||
}
|
||
|
||
// List get user's strategy list
|
||
func (s *StrategyStore) List(userID string) ([]*Strategy, error) {
|
||
var strategies []*Strategy
|
||
err := s.db.Where("user_id = ? OR is_default = ?", userID, true).
|
||
Order("is_default DESC, created_at DESC").
|
||
Find(&strategies).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return strategies, nil
|
||
}
|
||
|
||
// Get get a single strategy
|
||
func (s *StrategyStore) Get(userID, id string) (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("id = ? AND (user_id = ? OR is_default = ?)", id, userID, true).
|
||
First(&st).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// GetActive get user's currently active strategy
|
||
func (s *StrategyStore) GetActive(userID string) (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("user_id = ? AND is_active = ?", userID, true).First(&st).Error
|
||
if err == gorm.ErrRecordNotFound {
|
||
// no active strategy, return system default strategy
|
||
return s.GetDefault()
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// GetDefault get system default strategy
|
||
func (s *StrategyStore) GetDefault() (*Strategy, error) {
|
||
var st Strategy
|
||
err := s.db.Where("is_default = ?", true).First(&st).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &st, nil
|
||
}
|
||
|
||
// SetActive set active strategy (will first deactivate other strategies)
|
||
func (s *StrategyStore) SetActive(userID, strategyID string) error {
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
// first deactivate all strategies for the user
|
||
if err := tx.Model(&Strategy{}).Where("user_id = ?", userID).
|
||
Update("is_active", false).Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
// activate specified strategy
|
||
return tx.Model(&Strategy{}).
|
||
Where("id = ? AND (user_id = ? OR is_default = ?)", strategyID, userID, true).
|
||
Update("is_active", true).Error
|
||
})
|
||
}
|
||
|
||
// Duplicate duplicate a strategy (used to create custom strategy based on default strategy)
|
||
func (s *StrategyStore) Duplicate(userID, sourceID, newID, newName string) error {
|
||
// get source strategy
|
||
source, err := s.Get(userID, sourceID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to get source strategy: %w", err)
|
||
}
|
||
|
||
// create new strategy
|
||
newStrategy := &Strategy{
|
||
ID: newID,
|
||
UserID: userID,
|
||
Name: newName,
|
||
Description: "Created based on [" + source.Name + "]",
|
||
IsActive: false,
|
||
IsDefault: false,
|
||
Config: source.Config,
|
||
}
|
||
|
||
return s.Create(newStrategy)
|
||
}
|
||
|
||
// ParseConfig parse strategy configuration JSON
|
||
func (s *Strategy) ParseConfig() (*StrategyConfig, error) {
|
||
var config StrategyConfig
|
||
if err := json.Unmarshal([]byte(s.Config), &config); err != nil {
|
||
return nil, fmt.Errorf("failed to parse strategy configuration: %w", err)
|
||
}
|
||
return &config, nil
|
||
}
|
||
|
||
// SetConfig set strategy configuration
|
||
func (s *Strategy) SetConfig(config *StrategyConfig) error {
|
||
data, err := json.Marshal(config)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to serialize strategy configuration: %w", err)
|
||
}
|
||
s.Config = string(data)
|
||
return nil
|
||
}
|