Files
nofx/store/strategy.go
T
tinkle-community 319ccb8ca3 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
2025-12-10 14:40:08 +08:00

505 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
// StrategyStore strategy storage
type StrategyStore struct {
db *sql.DB
}
// Strategy strategy configuration
type Strategy struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
IsActive bool `json:"is_active"` // whether it is active (a user can only have one active strategy)
IsDefault bool `json:"is_default"` // whether it is a system default strategy
Config string `json:"config"` // strategy configuration in JSON format
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// 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"`
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]
// 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
}
// 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
// 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 {
// 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"`
}
func (s *StrategyStore) initTables() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS strategies (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
description TEXT DEFAULT '',
is_active BOOLEAN DEFAULT 0,
is_default BOOLEAN DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// create indexes
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON strategies(user_id)`)
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_is_active ON strategies(is_active)`)
// trigger: automatically update updated_at on update
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS update_strategies_updated_at
AFTER UPDATE ON strategies
BEGIN
UPDATE strategies SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END
`)
return err
}
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,
EnableVolume: true,
EnableOI: true,
EnableFundingRate: true,
EMAPeriods: []int{20, 50},
RSIPeriods: []int{7, 14},
ATRPeriods: []int{14},
EnableQuantData: true,
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
EnableQuantOI: true,
EnableQuantNetflow: true,
},
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 {
_, err := s.db.Exec(`
INSERT INTO strategies (id, user_id, name, description, is_active, is_default, config)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, strategy.ID, strategy.UserID, strategy.Name, strategy.Description, strategy.IsActive, strategy.IsDefault, strategy.Config)
return err
}
// Update update a strategy
func (s *StrategyStore) Update(strategy *Strategy) error {
_, err := s.db.Exec(`
UPDATE strategies SET
name = ?, description = ?, config = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
`, strategy.Name, strategy.Description, strategy.Config, strategy.ID, strategy.UserID)
return err
}
// Delete delete a strategy
func (s *StrategyStore) Delete(userID, id string) error {
// do not allow deleting system default strategy
var isDefault bool
s.db.QueryRow(`SELECT is_default FROM strategies WHERE id = ?`, id).Scan(&isDefault)
if isDefault {
return fmt.Errorf("cannot delete system default strategy")
}
_, err := s.db.Exec(`DELETE FROM strategies WHERE id = ? AND user_id = ?`, id, userID)
return err
}
// List get user's strategy list
func (s *StrategyStore) List(userID string) ([]*Strategy, error) {
// get user's own strategies + system default strategy
rows, err := s.db.Query(`
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
FROM strategies
WHERE user_id = ? OR is_default = 1
ORDER BY is_default DESC, created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var strategies []*Strategy
for rows.Next() {
var st Strategy
var createdAt, updatedAt string
err := rows.Scan(
&st.ID, &st.UserID, &st.Name, &st.Description,
&st.IsActive, &st.IsDefault, &st.Config,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
strategies = append(strategies, &st)
}
return strategies, nil
}
// Get get a single strategy
func (s *StrategyStore) Get(userID, id string) (*Strategy, error) {
var st Strategy
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
FROM strategies
WHERE id = ? AND (user_id = ? OR is_default = 1)
`, id, userID).Scan(
&st.ID, &st.UserID, &st.Name, &st.Description,
&st.IsActive, &st.IsDefault, &st.Config,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return &st, nil
}
// GetActive get user's currently active strategy
func (s *StrategyStore) GetActive(userID string) (*Strategy, error) {
var st Strategy
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
FROM strategies
WHERE user_id = ? AND is_active = 1
`, userID).Scan(
&st.ID, &st.UserID, &st.Name, &st.Description,
&st.IsActive, &st.IsDefault, &st.Config,
&createdAt, &updatedAt,
)
if err == sql.ErrNoRows {
// no active strategy, return system default strategy
return s.GetDefault()
}
if err != nil {
return nil, err
}
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return &st, nil
}
// GetDefault get system default strategy
func (s *StrategyStore) GetDefault() (*Strategy, error) {
var st Strategy
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at
FROM strategies
WHERE is_default = 1
LIMIT 1
`).Scan(
&st.ID, &st.UserID, &st.Name, &st.Description,
&st.IsActive, &st.IsDefault, &st.Config,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
return &st, nil
}
// SetActive set active strategy (will first deactivate other strategies)
func (s *StrategyStore) SetActive(userID, strategyID string) error {
// begin transaction
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// first deactivate all strategies for the user
_, err = tx.Exec(`UPDATE strategies SET is_active = 0 WHERE user_id = ?`, userID)
if err != nil {
return err
}
// activate specified strategy
_, err = tx.Exec(`UPDATE strategies SET is_active = 1 WHERE id = ? AND (user_id = ? OR is_default = 1)`, strategyID, userID)
if err != nil {
return err
}
return tx.Commit()
}
// 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
}