mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
change v1
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,16 +19,16 @@ type AIModelStore struct {
|
||||
|
||||
// AIModel AI model configuration
|
||||
type AIModel struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Provider string `gorm:"not null" json:"provider"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Provider string `gorm:"not null" json:"provider"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
|
||||
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
|
||||
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AIModel) TableName() string { return "ai_models" }
|
||||
@@ -145,32 +146,64 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
}
|
||||
|
||||
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
|
||||
var models []AIModel
|
||||
err := s.db.Where("user_id = ? AND enabled = ?", userID, true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
Find(&models).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
for i := range models {
|
||||
if hasUsableAPIKey(models[i]) {
|
||||
return &models[i], nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
var models []AIModel
|
||||
err := s.db.Where("enabled = ?", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
Find(&models).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
for i := range models {
|
||||
if hasUsableAPIKey(models[i]) {
|
||||
return &models[i], nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func hasUsableAPIKey(model AIModel) bool {
|
||||
if strings.TrimSpace(string(model.APIKey)) != "" {
|
||||
return true
|
||||
}
|
||||
envKeyByProvider := map[string]string{
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"claude": "ANTHROPIC_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"grok": "XAI_API_KEY",
|
||||
"kimi": "MOONSHOT_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"qwen": "DASHSCOPE_API_KEY",
|
||||
}
|
||||
envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))]
|
||||
return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != ""
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
return s.UpdateWithName(userID, id, "", enabled, apiKey, customAPIURL, customModelName)
|
||||
}
|
||||
|
||||
func (s *AIModelStore) UpdateWithName(userID, id, name string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
// Try exact ID match first
|
||||
var existingModel AIModel
|
||||
err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error
|
||||
@@ -182,6 +215,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
@@ -200,6 +236,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
}
|
||||
@@ -218,31 +257,35 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get name from existing model with same provider
|
||||
// Try to get a sensible default name from an existing model with the same provider.
|
||||
var refModel AIModel
|
||||
var name string
|
||||
defaultName := ""
|
||||
if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil {
|
||||
name = refModel.Name
|
||||
defaultName = refModel.Name
|
||||
} else {
|
||||
if provider == "deepseek" {
|
||||
name = "DeepSeek AI"
|
||||
defaultName = "DeepSeek AI"
|
||||
} else if provider == "qwen" {
|
||||
name = "Qwen AI"
|
||||
defaultName = "Qwen AI"
|
||||
} else {
|
||||
name = provider + " AI"
|
||||
defaultName = provider + " AI"
|
||||
}
|
||||
}
|
||||
finalName := strings.TrimSpace(name)
|
||||
if finalName == "" {
|
||||
finalName = strings.TrimSpace(defaultName)
|
||||
}
|
||||
|
||||
newModelID := id
|
||||
if id == provider {
|
||||
newModelID = fmt.Sprintf("%s_%s", userID, provider)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
|
||||
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, finalName)
|
||||
newModel := &AIModel{
|
||||
ID: newModelID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Name: finalName,
|
||||
Provider: provider,
|
||||
Enabled: enabled,
|
||||
APIKey: crypto.EncryptedString(apiKey),
|
||||
|
||||
@@ -17,6 +17,19 @@ const (
|
||||
MaxTimeframes = 4
|
||||
MinKlineCount = 10
|
||||
MaxKlineCount = 30
|
||||
MinLeverage = 1
|
||||
MaxBTCETHLeverage = 20
|
||||
MaxAltLeverage = 20
|
||||
MinPositionRatio = 0.5
|
||||
MaxPositionRatio = 10.0
|
||||
MinRiskReward = 1.0
|
||||
MaxRiskReward = 10.0
|
||||
MinMarginUsage = 0.1
|
||||
MaxMarginUsage = 1.0
|
||||
MinPositionSize = 10.0
|
||||
MaxPositionSize = 1000.0
|
||||
MinConfidence = 50
|
||||
MaxConfidence = 100
|
||||
)
|
||||
|
||||
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
|
||||
@@ -54,10 +67,143 @@ func (c *StrategyConfig) ClampLimits() {
|
||||
}
|
||||
|
||||
// Clamp max positions
|
||||
if c.RiskControl.MaxPositions < 1 {
|
||||
c.RiskControl.MaxPositions = 1
|
||||
}
|
||||
if c.RiskControl.MaxPositions > MaxPositions {
|
||||
c.RiskControl.MaxPositions = MaxPositions
|
||||
}
|
||||
|
||||
// Clamp leverage limits to the same bounds as the manual config UI.
|
||||
if c.RiskControl.BTCETHMaxLeverage < MinLeverage {
|
||||
c.RiskControl.BTCETHMaxLeverage = MinLeverage
|
||||
}
|
||||
if c.RiskControl.BTCETHMaxLeverage > MaxBTCETHLeverage {
|
||||
c.RiskControl.BTCETHMaxLeverage = MaxBTCETHLeverage
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxLeverage < MinLeverage {
|
||||
c.RiskControl.AltcoinMaxLeverage = MinLeverage
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxLeverage > MaxAltLeverage {
|
||||
c.RiskControl.AltcoinMaxLeverage = MaxAltLeverage
|
||||
}
|
||||
|
||||
// Clamp position value ratio limits.
|
||||
if c.RiskControl.BTCETHMaxPositionValueRatio < MinPositionRatio {
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = MinPositionRatio
|
||||
}
|
||||
if c.RiskControl.BTCETHMaxPositionValueRatio > MaxPositionRatio {
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = MaxPositionRatio
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxPositionValueRatio < MinPositionRatio {
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = MinPositionRatio
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxPositionValueRatio > MaxPositionRatio {
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = MaxPositionRatio
|
||||
}
|
||||
|
||||
// Clamp risk parameters and entry requirements.
|
||||
if c.RiskControl.MinRiskRewardRatio < MinRiskReward {
|
||||
c.RiskControl.MinRiskRewardRatio = MinRiskReward
|
||||
}
|
||||
if c.RiskControl.MinRiskRewardRatio > MaxRiskReward {
|
||||
c.RiskControl.MinRiskRewardRatio = MaxRiskReward
|
||||
}
|
||||
if c.RiskControl.MaxMarginUsage < MinMarginUsage {
|
||||
c.RiskControl.MaxMarginUsage = MinMarginUsage
|
||||
}
|
||||
if c.RiskControl.MaxMarginUsage > MaxMarginUsage {
|
||||
c.RiskControl.MaxMarginUsage = MaxMarginUsage
|
||||
}
|
||||
if c.RiskControl.MinPositionSize < MinPositionSize {
|
||||
c.RiskControl.MinPositionSize = MinPositionSize
|
||||
}
|
||||
if c.RiskControl.MinPositionSize > MaxPositionSize {
|
||||
c.RiskControl.MinPositionSize = MaxPositionSize
|
||||
}
|
||||
if c.RiskControl.MinConfidence < MinConfidence {
|
||||
c.RiskControl.MinConfidence = MinConfidence
|
||||
}
|
||||
if c.RiskControl.MinConfidence > MaxConfidence {
|
||||
c.RiskControl.MinConfidence = MaxConfidence
|
||||
}
|
||||
}
|
||||
|
||||
// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config.
|
||||
// Nested objects are merged recursively so omitted fields keep their previous values.
|
||||
func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) {
|
||||
baseJSON, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
var mergedMap map[string]any
|
||||
if err := json.Unmarshal(baseJSON, &mergedMap); err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
mergeJSONMaps(mergedMap, patch)
|
||||
|
||||
mergedJSON, err := json.Marshal(mergedMap)
|
||||
if err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
var merged StrategyConfig
|
||||
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func mergeJSONMaps(dst, src map[string]any) {
|
||||
for key, srcVal := range src {
|
||||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||||
dstMap, dstIsMap := dst[key].(map[string]any)
|
||||
if srcIsMap && dstIsMap {
|
||||
mergeJSONMaps(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
dst[key] = srcVal
|
||||
}
|
||||
}
|
||||
|
||||
func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
|
||||
if lang != "zh" {
|
||||
lang = "en"
|
||||
}
|
||||
warnings := make([]string, 0, 8)
|
||||
appendInt := func(labelZH, labelEN string, from, to int) {
|
||||
if from == to {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to))
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to))
|
||||
}
|
||||
appendFloat := func(labelZH, labelEN string, from, to float64) {
|
||||
if from == to {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to))
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to))
|
||||
}
|
||||
|
||||
appendInt("最大持仓数", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
|
||||
appendInt("BTC/ETH 最大杠杆", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
|
||||
appendInt("山寨币最大杠杆", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
|
||||
appendFloat("BTC/ETH 最大仓位价值倍数", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
|
||||
appendFloat("山寨币最大仓位价值倍数", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
appendFloat("最小盈亏比", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
|
||||
appendFloat("最大保证金使用率", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
|
||||
appendFloat("最小开仓金额", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
|
||||
appendInt("最低置信度", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence)
|
||||
return warnings
|
||||
}
|
||||
|
||||
// StrategyStore strategy storage
|
||||
|
||||
@@ -110,12 +110,20 @@ func (s *TraderStore) Update(trader *Trader) error {
|
||||
trader.ID, trader.Name, trader.AIModelID, trader.StrategyID)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"name": trader.Name,
|
||||
"ai_model_id": trader.AIModelID,
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"is_cross_margin": trader.IsCrossMargin,
|
||||
"show_in_competition": trader.ShowInCompetition,
|
||||
"name": trader.Name,
|
||||
"ai_model_id": trader.AIModelID,
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"is_cross_margin": trader.IsCrossMargin,
|
||||
"show_in_competition": trader.ShowInCompetition,
|
||||
"btc_eth_leverage": trader.BTCETHLeverage,
|
||||
"altcoin_leverage": trader.AltcoinLeverage,
|
||||
"trading_symbols": trader.TradingSymbols,
|
||||
"use_coin_pool": trader.UseAI500,
|
||||
"use_oi_top": trader.UseOITop,
|
||||
"custom_prompt": trader.CustomPrompt,
|
||||
"override_base_prompt": trader.OverrideBasePrompt,
|
||||
"system_prompt_template": trader.SystemPromptTemplate,
|
||||
}
|
||||
|
||||
// Only update these if > 0
|
||||
|
||||
48
store/visibility.go
Normal file
48
store/visibility.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import "strings"
|
||||
|
||||
func IsVisibleAIModel(model *AIModel) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
}
|
||||
return model.Enabled ||
|
||||
strings.TrimSpace(string(model.APIKey)) != "" ||
|
||||
strings.TrimSpace(model.CustomAPIURL) != "" ||
|
||||
strings.TrimSpace(model.CustomModelName) != ""
|
||||
}
|
||||
|
||||
func IsVisibleExchange(exchange *Exchange) bool {
|
||||
if exchange == nil {
|
||||
return false
|
||||
}
|
||||
return exchange.Enabled ||
|
||||
strings.TrimSpace(string(exchange.APIKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.SecretKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.Passphrase)) != "" ||
|
||||
strings.TrimSpace(exchange.HyperliquidWalletAddr) != "" ||
|
||||
strings.TrimSpace(exchange.AsterUser) != "" ||
|
||||
strings.TrimSpace(exchange.AsterSigner) != "" ||
|
||||
strings.TrimSpace(string(exchange.AsterPrivateKey)) != "" ||
|
||||
strings.TrimSpace(exchange.LighterWalletAddr) != "" ||
|
||||
strings.TrimSpace(string(exchange.LighterPrivateKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)) != "" ||
|
||||
exchange.LighterAPIKeyIndex != 0
|
||||
}
|
||||
|
||||
func IsVisibleTrader(trader *Trader) bool {
|
||||
if trader == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(trader.Name) != "" &&
|
||||
strings.TrimSpace(trader.AIModelID) != "" &&
|
||||
strings.TrimSpace(trader.ExchangeID) != ""
|
||||
}
|
||||
|
||||
func IsVisibleStrategy(strategy *Strategy) bool {
|
||||
if strategy == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strategy.Name) != ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user