Merge branch 'dev' into beta

# Conflicts:
#	config/database.go
#	main.go
This commit is contained in:
icy
2025-11-02 22:11:07 +08:00
21 changed files with 1234 additions and 284 deletions
+6 -6
View File
@@ -8,12 +8,16 @@ on:
# These checks are advisory only - they won't block PR merging
# Results will be posted as comments to help contributors improve their PRs
permissions:
contents: read
pull-requests: write
checks: write
issues: write
jobs:
pr-info:
name: PR Information
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check PR title format
id: check-title
@@ -98,8 +102,6 @@ jobs:
backend-checks:
name: Backend Checks (Advisory)
runs-on: ubuntu-latest
permissions:
pull-requests: write
continue-on-error: true
steps:
- uses: actions/checkout@v4
@@ -208,8 +210,6 @@ jobs:
frontend-checks:
name: Frontend Checks (Advisory)
runs-on: ubuntu-latest
permissions:
pull-requests: write
continue-on-error: true
steps:
- uses: actions/checkout@v4
+14 -12
View File
@@ -124,11 +124,12 @@ A Binance-compatible decentralized perpetual futures exchange!
- 🌐 **Multi-chain support** - trade on your preferred EVM chain
**Quick Start:**
1. Visit [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Connect your main wallet and create an API wallet
3. Copy the API Signer address and Private Key
4. ~~Set `"exchange": "aster"` in config.json~~ *Configure through web interface*
5. Add `"aster_user"`, `"aster_signer"`, and `"aster_private_key"`
1. Register via [Aster Referral Link](https://www.asterdex.com/en/referral/fdfc0e) (get fee discounts!)
2. Visit [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Connect your main wallet and create an API wallet
4. Copy the API Signer address and Private Key
5. Set `"exchange": "aster"` in config.json
6. Add `"aster_user"`, `"aster_signer"`, and `"aster_private_key"`
---
@@ -406,7 +407,7 @@ Before configuring the system, you need to obtain AI API keys. Choose one of the
**How to get Qwen API Key:**
1. **Visit**: [https://dashscope.aliyuncs.com](https://dashscope.aliyuncs.com)
1. **Visit**: [https://dashscope.console.aliyun.com](https://dashscope.console.aliyun.com)
2. **Register**: Sign up with Alibaba Cloud account
3. **Enable Service**: Activate DashScope service
4. **Create API Key**:
@@ -543,12 +544,13 @@ Open your browser and visit: **🌐 http://localhost:3000**
- 🌐 Multi-chain support (ETH, BSC, Polygon)
- 🌍 No KYC required
**Step 1**: Create Aster API Wallet
**Step 1**: Register and Create Aster API Wallet
1. Visit [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Connect your main wallet (MetaMask, WalletConnect, etc.)
3. Click "Create API Wallet"
4. **Save these 3 items immediately:**
1. Register via [Aster Referral Link](https://www.asterdex.com/en/referral/fdfc0e) (get fee discounts!)
2. Visit [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Connect your main wallet (MetaMask, WalletConnect, etc.)
4. Click "Create API Wallet"
5. **Save these 3 items immediately:**
- Main Wallet address (User)
- API Wallet address (Signer)
- API Wallet Private Key (⚠️ shown only once!)
@@ -1271,7 +1273,7 @@ We welcome contributions from the community! See our comprehensive guides:
- [Binance API](https://binance-docs.github.io/apidocs/futures/en/) - Binance Futures API
- [DeepSeek](https://platform.deepseek.com/) - DeepSeek AI API
- [Qwen](https://dashscope.aliyuncs.com/) - Alibaba Cloud Qwen
- [Qwen](https://dashscope.console.aliyun.com/) - Alibaba Cloud Qwen
- [TA-Lib](https://ta-lib.org/) - Technical indicator library
- [Recharts](https://recharts.org/) - React chart library
+1 -1
View File
@@ -11,7 +11,7 @@ import (
type TraderConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"` // 是否启用该trader
Enabled bool `json:"enabled"` // 是否启用该trader
AIModel string `json:"ai_model"` // "qwen" or "deepseek"
// 交易平台选择(二选一)
+114 -89
View File
@@ -4,9 +4,12 @@ import (
"crypto/rand"
"database/sql"
"encoding/base32"
"encoding/json"
"fmt"
"log"
"nofx/market"
"os"
"slices"
"strings"
"time"
@@ -187,17 +190,17 @@ func (d *Database) createTables() error {
`ALTER TABLE exchanges ADD COLUMN aster_private_key TEXT DEFAULT ''`,
`ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`,
`ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`,
`ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`, // 默认为全仓模式
`ALTER TABLE traders ADD COLUMN use_default_coins BOOLEAN DEFAULT 1`, // 默认使用默认币种
`ALTER TABLE traders ADD COLUMN custom_coins TEXT DEFAULT ''`, // 自定义币种列表(JSON格式)
`ALTER TABLE traders ADD COLUMN btc_eth_leverage INTEGER DEFAULT 5`, // BTC/ETH杠杆倍数
`ALTER TABLE traders ADD COLUMN altcoin_leverage INTEGER DEFAULT 5`, // 山寨币杠杆倍数
`ALTER TABLE traders ADD COLUMN trading_symbols TEXT DEFAULT ''`, // 交易币种,逗号分隔
`ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`, // 是否使用COIN POOL信号源
`ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, // 是否使用OI TOP信号源
`ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`, // 默认为全仓模式
`ALTER TABLE traders ADD COLUMN use_default_coins BOOLEAN DEFAULT 1`, // 默认使用默认币种
`ALTER TABLE traders ADD COLUMN custom_coins TEXT DEFAULT ''`, // 自定义币种列表(JSON格式)
`ALTER TABLE traders ADD COLUMN btc_eth_leverage INTEGER DEFAULT 5`, // BTC/ETH杠杆倍数
`ALTER TABLE traders ADD COLUMN altcoin_leverage INTEGER DEFAULT 5`, // 山寨币杠杆倍数
`ALTER TABLE traders ADD COLUMN trading_symbols TEXT DEFAULT ''`, // 交易币种,逗号分隔
`ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`, // 是否使用COIN POOL信号源
`ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, // 是否使用OI TOP信号源
`ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`, // 系统提示词模板名称
`ALTER TABLE ai_models ADD COLUMN custom_api_url TEXT DEFAULT ''`, // 自定义API地址
`ALTER TABLE ai_models ADD COLUMN custom_model_name TEXT DEFAULT ''`, // 自定义模型名称
`ALTER TABLE ai_models ADD COLUMN custom_api_url TEXT DEFAULT ''`, // 自定义API地址
`ALTER TABLE ai_models ADD COLUMN custom_model_name TEXT DEFAULT ''`, // 自定义模型名称
}
for _, query := range alterQueries {
@@ -255,17 +258,17 @@ func (d *Database) initDefaultData() error {
// 初始化系统配置 - 创建所有字段,设置默认值,后续由config.json同步更新
systemConfigs := map[string]string{
"admin_mode": "true", // 默认开启管理员模式,便于首次使用
"beta_mode": "false", // 默认关闭内测模式
"api_server_port": "8080", // 默认API端口
"use_default_coins": "true", // 默认使用内置币种列表
"admin_mode": "true", // 默认开启管理员模式,便于首次使用
"beta_mode": "false", // 默认关闭内测模式
"api_server_port": "8080", // 默认API端口
"use_default_coins": "true", // 默认使用内置币种列表
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, // 默认币种列表(JSON格式)
"max_daily_loss": "10.0", // 最大日损失百分比
"max_drawdown": "20.0", // 最大回撤百分比
"stop_trading_minutes": "60", // 停止交易时间(分钟)
"btc_eth_leverage": "5", // BTC/ETH杠杆倍数
"altcoin_leverage": "5", // 山寨币杠杆倍数
"jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成
"max_daily_loss": "10.0", // 最大日损失百分比
"max_drawdown": "20.0", // 最大回撤百分比
"stop_trading_minutes": "60", // 停止交易时间(分钟)
"btc_eth_leverage": "5", // BTC/ETH杠杆倍数
"altcoin_leverage": "5", // 山寨币杠杆倍数
"jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成
}
for key, value := range systemConfigs {
@@ -292,14 +295,14 @@ func (d *Database) migrateExchangesTable() error {
if err != nil {
return err
}
// 如果已经迁移过,直接返回
if count > 0 {
return nil
}
log.Printf("🔄 开始迁移exchanges表...")
// 创建新的exchanges表,使用复合主键
_, err = d.db.Exec(`
CREATE TABLE exchanges_new (
@@ -324,7 +327,7 @@ func (d *Database) migrateExchangesTable() error {
if err != nil {
return fmt.Errorf("创建新exchanges表失败: %w", err)
}
// 复制数据到新表
_, err = d.db.Exec(`
INSERT INTO exchanges_new
@@ -333,19 +336,19 @@ func (d *Database) migrateExchangesTable() error {
if err != nil {
return fmt.Errorf("复制数据失败: %w", err)
}
// 删除旧表
_, err = d.db.Exec(`DROP TABLE exchanges`)
if err != nil {
return fmt.Errorf("删除旧表失败: %w", err)
}
// 重命名新表
_, err = d.db.Exec(`ALTER TABLE exchanges_new RENAME TO exchanges`)
if err != nil {
return fmt.Errorf("重命名表失败: %w", err)
}
// 重新创建触发器
_, err = d.db.Exec(`
CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at
@@ -358,20 +361,20 @@ func (d *Database) migrateExchangesTable() error {
if err != nil {
return fmt.Errorf("创建触发器失败: %w", err)
}
log.Printf("✅ exchanges表迁移完成")
return nil
}
// User 用户配置
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // 不返回到前端
OTPSecret string `json:"-"` // 不返回到前端
OTPVerified bool `json:"otp_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // 不返回到前端
OTPSecret string `json:"-"` // 不返回到前端
OTPVerified bool `json:"otp_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AIModelConfig AI模型配置
@@ -390,36 +393,36 @@ type AIModelConfig struct {
// ExchangeConfig 交易所配置
type ExchangeConfig struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
Testnet bool `json:"testnet"`
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
SecretKey string `json:"secretKey"`
Testnet bool `json:"testnet"`
// Hyperliquid 特定字段
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"`
// Aster 特定字段
AsterUser string `json:"asterUser"`
AsterSigner string `json:"asterSigner"`
AsterPrivateKey string `json:"asterPrivateKey"`
AsterUser string `json:"asterUser"`
AsterSigner string `json:"asterSigner"`
AsterPrivateKey string `json:"asterPrivateKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TraderRecord 交易员配置(数据库实体)
type TraderRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
BTCETHLeverage int `json:"btc_eth_leverage"` // BTC/ETH杠杆倍数
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币杠杆倍数
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
AIModelID string `json:"ai_model_id"`
ExchangeID string `json:"exchange_id"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"`
BTCETHLeverage int `json:"btc_eth_leverage"` // BTC/ETH杠杆倍数
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币杠杆倍数
TradingSymbols string `json:"trading_symbols"` // 交易币种,逗号分隔
UseCoinPool bool `json:"use_coin_pool"` // 是否使用COIN POOL信号源
UseOITop bool `json:"use_oi_top"` // 是否使用OI TOP信号源
@@ -433,12 +436,12 @@ type TraderRecord struct {
// UserSignalSource 用户信号源配置
type UserSignalSource struct {
ID int `json:"id"`
UserID string `json:"user_id"`
CoinPoolURL string `json:"coin_pool_url"`
OITopURL string `json:"oi_top_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int `json:"id"`
UserID string `json:"user_id"`
CoinPoolURL string `json:"coin_pool_url"`
OITopURL string `json:"oi_top_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GenerateOTPSecret 生成OTP密钥
@@ -468,12 +471,12 @@ func (d *Database) EnsureAdminUser() error {
if err != nil {
return err
}
// 如果已存在,直接返回
if count > 0 {
return nil
}
// 创建admin用户(密码为空,因为管理员模式下不需要密码)
adminUser := &User{
ID: "admin",
@@ -482,7 +485,7 @@ func (d *Database) EnsureAdminUser() error {
OTPSecret: "",
OTPVerified: true,
}
return d.CreateUser(adminUser)
}
@@ -493,7 +496,7 @@ func (d *Database) GetUserByEmail(email string) (*User, error) {
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
FROM users WHERE email = ?
`, email).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
@@ -509,7 +512,7 @@ func (d *Database) GetUserByID(userID string) (*User, error) {
SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at
FROM users WHERE id = ?
`, userID).Scan(
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret,
&user.OTPVerified, &user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
@@ -679,7 +682,7 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
err := rows.Scan(
&exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type,
&exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
&exchange.HyperliquidWalletAddr, &exchange.AsterUser,
&exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.CreatedAt, &exchange.UpdatedAt,
)
@@ -695,7 +698,7 @@ func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) {
// UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置
func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error {
log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled)
// 首先尝试更新现有的用户配置
result, err := d.db.Exec(`
UPDATE exchanges SET enabled = ?, api_key = ?, secret_key = ?, testnet = ?,
@@ -706,20 +709,20 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre
log.Printf("❌ UpdateExchange: 更新失败: %v", err)
return err
}
// 检查是否有行被更新
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("❌ UpdateExchange: 获取影响行数失败: %v", err)
return err
}
log.Printf("📊 UpdateExchange: 影响行数 = %d", rowsAffected)
// 如果没有行被更新,说明用户没有这个交易所的配置,需要创建
if rowsAffected == 0 {
log.Printf("💡 UpdateExchange: 没有现有记录,创建新记录")
// 根据交易所ID确定基本信息
var name, typ string
if id == "binance" {
@@ -735,16 +738,16 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre
name = id + " Exchange"
typ = "cex"
}
log.Printf("🆕 UpdateExchange: 创建新记录 ID=%s, name=%s, type=%s", id, name, typ)
// 创建用户特定的配置,使用原始的交易所ID
_, err = d.db.Exec(`
INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet,
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, id, userID, name, typ, enabled, apiKey, secretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey)
if err != nil {
log.Printf("❌ UpdateExchange: 创建记录失败: %v", err)
} else {
@@ -752,7 +755,7 @@ func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secre
}
return err
}
log.Printf("✅ UpdateExchange: 更新现有记录成功")
return nil
}
@@ -801,9 +804,9 @@ func (d *Database) GetTraders(userID string) ([]*TraderRecord, error) {
}
defer rows.Close()
var traders []*TraderRecord
var traders []*TraderRecord
for rows.Next() {
var trader TraderRecord
var trader TraderRecord
err := rows.Scan(
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
@@ -858,18 +861,13 @@ func (d *Database) DeleteTrader(userID, id string) error {
// GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息)
func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIModelConfig, *ExchangeConfig, error) {
var trader TraderRecord
var trader TraderRecord
var aiModel AIModelConfig
var exchange ExchangeConfig
err := d.db.QueryRow(`
SELECT
t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running,
COALESCE(t.btc_eth_leverage, 5) as btc_eth_leverage, COALESCE(t.altcoin_leverage, 5) as altcoin_leverage,
COALESCE(t.trading_symbols, '') as trading_symbols, COALESCE(t.use_coin_pool, 0) as use_coin_pool,
COALESCE(t.use_oi_top, 0) as use_oi_top, COALESCE(t.custom_prompt, '') as custom_prompt,
COALESCE(t.override_base_prompt, 0) as override_base_prompt, COALESCE(t.is_cross_margin, 1) as is_cross_margin,
t.created_at, t.updated_at,
t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, t.created_at, t.updated_at,
a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, a.created_at, a.updated_at,
e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet,
COALESCE(e.hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr,
@@ -884,8 +882,6 @@ func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIM
`, traderID, userID).Scan(
&trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID,
&trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning,
&trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, &trader.UseCoinPool,
&trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.IsCrossMargin,
&trader.CreatedAt, &trader.UpdatedAt,
&aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey,
&aiModel.CreatedAt, &aiModel.UpdatedAt,
@@ -951,6 +947,35 @@ func (d *Database) UpdateUserSignalSource(userID, coinPoolURL, oiTopURL string)
return err
}
// GetCustomCoins 获取所有交易员自定义币种 / Get all trader-customized currencies
func (d *Database) GetCustomCoins() []string {
var symbol string
var symbols []string
_ = d.db.QueryRow(`
SELECT GROUP_CONCAT(custom_coins , ',') as symbol
FROM main.traders where custom_coins != ''
`).Scan(&symbol)
// 检测用户是否未配置币种 - 兼容性
if symbol == "" {
symbolJSON, _ := d.GetSystemConfig("default_coins")
if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil {
log.Printf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err)
symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"}
}
}
// filter Symbol
for _, s := range strings.Split(symbol, ",") {
if s == "" {
continue
}
coin := market.Normalize(s)
if !slices.Contains(symbols, coin) {
symbols = append(symbols, coin)
}
}
return symbols
}
// Close 关闭数据库连接
func (d *Database) Close() error {
return d.db.Close()
@@ -1056,4 +1081,4 @@ func (d *Database) GetBetaCodeStats() (total, used int, err error) {
}
return total, used, nil
}
}
+3 -3
View File
@@ -115,7 +115,7 @@ func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient *mcp.Client, custom
// 4. 解析AI响应
decision, err := parseFullDecisionResponse(aiResponse, ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage)
if err != nil {
return nil, fmt.Errorf("解析AI响应失败: %w", err)
return decision, fmt.Errorf("解析AI响应失败: %w", err)
}
decision.Timestamp = time.Now()
@@ -397,7 +397,7 @@ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthL
return &FullDecision{
CoTTrace: cotTrace,
Decisions: []Decision{},
}, fmt.Errorf("提取决策失败: %w\n\n=== AI思维链分析 ===\n%s", err, cotTrace)
}, fmt.Errorf("提取决策失败: %w", err)
}
// 3. 验证决策
@@ -405,7 +405,7 @@ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthL
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, fmt.Errorf("决策验证失败: %w\n\n=== AI思维链分析 ===\n%s", err, cotTrace)
}, fmt.Errorf("决策验证失败: %w", err)
}
return &FullDecision{
+6 -1
View File
@@ -51,7 +51,12 @@ nofx/
├── market/ # Market data fetching
│ └── data.go # Market data & technical indicators (TA-Lib)
└── api_client.go # Market data acquisition API
│ └── websocket_client.go # Market data acquisition WebSocket interface
│ └── combined_streams.go # Market data acquisition: Combined streaming (single link to subscribe to multiple cryptocurrencies)
│ └── monitor.go # Market data cache
│ └── types.go # market structure
├── pool/ # Coin pool management
│ └── coin_pool.go # AI500 + OI Top merged pool
+5
View File
@@ -51,6 +51,11 @@ nofx/
├── market/ # 市场数据获取
│ └── data.go # 市场数据与技术指标(TA-Lib)
│ └── api_client.go # 行情获取 Api接口
│ └── websocket_client.go # 行情获取 Websocket接口
│ └── combined_streams.go # 行情获取 组合流式(单链接订阅多个币种)
│ └── monitor.go # 行情数据缓存
│ └── types.go # market结构体
├── pool/ # 币种池管理
│ └── coin_pool.go # AI500 + OI Top 合并池
+14 -12
View File
@@ -117,11 +117,12 @@ NOFX теперь поддерживает **три основные биржи*
- 🌐 **Поддержка нескольких цепей** - торгуйте на вашей любимой EVM цепи
**Быстрый старт:**
1. Посетите [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Подключите основной кошелек и создайте API кошелек
3. Скопируйте адрес API Signer и приватный ключ
4. Установите `"exchange": "aster"` в config.json
5. Добавьте `"aster_user"`, `"aster_signer"` и `"aster_private_key"`
1. Зарегистрируйтесь по [реферальной ссылке Aster](https://www.asterdex.com/en/referral/fdfc0e) (получите скидку на комиссии!)
2. Посетите [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Подключите основной кошелек и создайте API кошелек
4. Скопируйте адрес API Signer и приватный ключ
5. Установите `"exchange": "aster"` в config.json
6. Добавьте `"aster_user"`, `"aster_signer"` и `"aster_private_key"`
---
@@ -399,7 +400,7 @@ cd ..
**Как получить Qwen API ключ:**
1. **Посетите**: [https://dashscope.aliyuncs.com](https://dashscope.aliyuncs.com)
1. **Посетите**: [https://dashscope.console.aliyun.com](https://dashscope.console.aliyun.com)
2. **Зарегистрируйтесь**: Используя аккаунт Alibaba Cloud
3. **Активируйте сервис**: Активируйте DashScope сервис
4. **Создайте API ключ**:
@@ -534,12 +535,13 @@ cp config.example.jsonc config.json
- 🌐 Поддержка нескольких цепей (ETH, BSC, Polygon)
- 🌍 Не нужна KYC
**Шаг 1**: Создайте Aster API кошелек
**Шаг 1**: Зарегистрируйтесь и создайте Aster API кошелек
1. Посетите [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Подключите основной кошелек (MetaMask, WalletConnect и т.д.)
3. Нажмите "Создать API кошелек"
4. **Сохраните эти 3 элемента немедленно:**
1. Зарегистрируйтесь по [реферальной ссылке Aster](https://www.asterdex.com/en/referral/fdfc0e) (получите скидку на комиссии!)
2. Посетите [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Подключите основной кошелек (MetaMask, WalletConnect и т.д.)
4. Нажмите "Создать API кошелек"
5. **Сохраните эти 3 элемента немедленно:**
- Адрес основного кошелька (User)
- Адрес API кошелька (Signer)
- Приватный ключ API кошелька (⚠️ показывается только один раз!)
@@ -1094,7 +1096,7 @@ sudo apt-get install libta-lib0-dev
- [Binance API](https://binance-docs.github.io/apidocs/futures/en/) - Binance Futures API
- [DeepSeek](https://platform.deepseek.com/) - DeepSeek AI API
- [Qwen](https://dashscope.aliyuncs.com/) - Alibaba Cloud Qwen
- [Qwen](https://dashscope.console.aliyun.com/) - Alibaba Cloud Qwen
- [TA-Lib](https://ta-lib.org/) - Библиотека технических индикаторов
- [Recharts](https://recharts.org/) - Библиотека графиков React
+13 -11
View File
@@ -118,11 +118,12 @@ NOFX тепер підтримує **три основні біржі**: Binance
- 🌐 **Підтримка кількох ланцюгів** - торгуйте на вашому улюбленому EVM ланцюзі
**Швидкий старт:**
1. Відвідайте [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Підключіть основний гаманець і створіть API гаманець
3. Скопіюйте адресу API Signer та приватний ключ
4. Встановіть `"exchange": "aster"` в config.json
5. Додайте `"aster_user"`, `"aster_signer"` та `"aster_private_key"`
1. Зареєструйтеся за [реферальним посиланням Aster](https://www.asterdex.com/en/referral/fdfc0e) (отримайте знижку на комісії!)
2. Відвідайте [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Підключіть основний гаманець і створіть API гаманець
4. Скопіюйте адресу API Signer та приватний ключ
5. Встановіть `"exchange": "aster"` в config.json
6. Додайте `"aster_user"`, `"aster_signer"` та `"aster_private_key"`
---
@@ -402,7 +403,7 @@ cd ..
**Як отримати Qwen API ключ:**
1. **Відвідайте**: [https://dashscope.aliyuncs.com](https://dashscope.aliyuncs.com)
1. **Відвідайте**: [https://dashscope.console.aliyun.com](https://dashscope.console.aliyun.com)
2. **Зареєструйтеся**: Використовуючи акаунт Alibaba Cloud
3. **Активуйте сервіс**: Активуйте DashScope сервіс
4. **Створіть API ключ**:
@@ -537,12 +538,13 @@ cp config.example.jsonc config.json
- 🌐 Підтримка кількох ланцюгів (ETH, BSC, Polygon)
- 🌍 Не потрібна KYC
**Крок 1**: Створіть Aster API гаманець
**Крок 1**: Зареєструйтеся та створіть Aster API гаманець
1. Відвідайте [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
2. Підключіть основний гаманець (MetaMask, WalletConnect тощо)
3. Натисніть "Створити API гаманець"
4. **Збережіть ці 3 елементи негайно:**
1. Зареєструйтеся за [реферальним посиланням Aster](https://www.asterdex.com/en/referral/fdfc0e) (отримайте знижку на комісії!)
2. Відвідайте [Aster API Wallet](https://www.asterdex.com/en/api-wallet)
3. Підключіть основний гаманець (MetaMask, WalletConnect тощо)
4. Натисніть "Створити API гаманець"
5. **Збережіть ці 3 елементи негайно:**
- Адреса основного гаманця (User)
- Адреса API гаманця (Signer)
- Приватний ключ API гаманця (⚠️ показується лише один раз!)
+14 -12
View File
@@ -126,11 +126,12 @@ NOFX现已支持**三大交易所**Binance、Hyperliquid和Aster DEX
- 🌐 **多链支持** - 在你喜欢的EVM链上交易
**快速开始:**
1. 访问[Aster API钱包](https://www.asterdex.com/en/api-wallet)
2. 连接你的主钱包并创建API钱包
3. 复制API Signer地址和私钥
4. ~~在config.json中设置`"exchange": "aster"`~~ *通过Web界面配置*
5. 添加`"aster_user"``"aster_signer"``"aster_private_key"`
1. 通过[推荐链接注册Aster](https://www.asterdex.com/en/referral/fdfc0e)(享手续费优惠)
2. 访问[Aster API钱包](https://www.asterdex.com/en/api-wallet)
3. 连接你的主钱包并创建API钱包
4. 复制API Signer地址和私钥
5. 在config.json中设置`"exchange": "aster"`
6. 添加`"aster_user"``"aster_signer"``"aster_private_key"`
---
@@ -398,7 +399,7 @@ cd ..
**如何获取Qwen API密钥:**
1. **访问**[https://dashscope.aliyuncs.com](https://dashscope.aliyuncs.com)
1. **访问**[https://dashscope.console.aliyun.com](https://dashscope.console.aliyun.com)
2. **注册**:使用阿里云账户注册
3. **开通服务**:激活DashScope服务
4. **创建API密钥**
@@ -535,12 +536,13 @@ cp config.example.jsonc config.json
- 🌐 多链支持(ETH、BSC、Polygon
- 🌍 无需KYC
**步骤1**:创建Aster API钱包
**步骤1**注册并创建Aster API钱包
1. 访问[Aster API钱包](https://www.asterdex.com/en/api-wallet)
2. 连接你的主钱包(MetaMask、WalletConnect等)
3. 点击"创建API钱包"
4. **立即保存这3项:**
1. 通过[推荐链接注册Aster](https://www.asterdex.com/en/referral/fdfc0e)(享手续费优惠)
2. 访问[Aster API钱包](https://www.asterdex.com/en/api-wallet)
3. 连接你的主钱包(MetaMask、WalletConnect等)
4. 点击"创建API钱包"
5. **立即保存这3项:**
- 主钱包地址(User
- API钱包地址(Signer
- API钱包私钥(⚠️ 仅显示一次!)
@@ -1290,7 +1292,7 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
- [Binance API](https://binance-docs.github.io/apidocs/futures/cn/) - 币安合约API
- [DeepSeek](https://platform.deepseek.com/) - DeepSeek AI API
- [Qwen](https://dashscope.aliyuncs.com/) - 阿里云通义千问
- [Qwen](https://dashscope.console.aliyun.com/) - 阿里云通义千问
- [TA-Lib](https://ta-lib.org/) - 技术指标库
- [Recharts](https://recharts.org/) - React图表库
+2 -2
View File
@@ -8,7 +8,8 @@ require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.16
github.com/pquerna/otp v1.4.0
github.com/sonirico/go-hyperliquid v0.17.0
golang.org/x/crypto v0.42.0
@@ -37,7 +38,6 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
+2 -2
View File
@@ -118,8 +118,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
+19 -26
View File
@@ -8,6 +8,7 @@ import (
"nofx/auth"
"nofx/config"
"nofx/manager"
"nofx/market"
"nofx/pool"
"os"
"os/signal"
@@ -36,6 +37,7 @@ type ConfigFile struct {
StopTradingMinutes int `json:"stop_trading_minutes"`
Leverage LeverageConfig `json:"leverage"`
JWTSecret string `json:"jwt_secret"`
DataKLineTime string `json:"data_k_line_time"`
}
// syncConfigToDatabase 从config.json读取配置并同步到数据库
@@ -175,11 +177,11 @@ func main() {
useDefaultCoinsStr, _ := database.GetSystemConfig("use_default_coins")
useDefaultCoins := useDefaultCoinsStr == "true"
apiPortStr, _ := database.GetSystemConfig("api_server_port")
// 获取管理员模式配置
adminModeStr, _ := database.GetSystemConfig("admin_mode")
adminMode := adminModeStr != "false" // 默认为true
// 设置JWT密钥
jwtSecret, _ := database.GetSystemConfig("jwt_secret")
if jwtSecret == "" {
@@ -187,7 +189,7 @@ func main() {
log.Printf("⚠️ 使用默认JWT密钥,建议在生产环境中配置")
}
auth.SetJWTSecret(jwtSecret)
// 在管理员模式下,确保admin用户存在
if adminMode {
err := database.EnsureAdminUser()
@@ -198,7 +200,7 @@ func main() {
}
auth.SetAdminMode(true)
}
log.Printf("✓ 配置数据库初始化成功")
fmt.Println()
@@ -221,7 +223,6 @@ func main() {
}
pool.SetDefaultCoins(defaultCoins)
// 设置是否使用默认主流币种
pool.SetUseDefaultCoins(useDefaultCoins)
if useDefaultCoins {
@@ -234,7 +235,7 @@ func main() {
pool.SetCoinPoolAPI(coinPoolAPIURL)
log.Printf("✓ 已配置AI500币种池API")
}
oiTopAPIURL, _ := database.GetSystemConfig("oi_top_api_url")
if oiTopAPIURL != "" {
pool.SetOITopAPI(oiTopAPIURL)
@@ -250,37 +251,26 @@ func main() {
log.Fatalf("❌ 加载交易员失败: %v", err)
}
// 获取所有用户的交易员配置(用于显示)
userIDs, err := database.GetAllUsers()
// 获取数据库中的所有交易员配置(用于显示,使用default用户
traders, err := database.GetTraders("default")
if err != nil {
log.Printf("⚠️ 获取用户列表失败: %v", err)
userIDs = []string{"default"} // 回退到default用户
}
var allTraders []*config.TraderRecord
for _, userID := range userIDs {
traders, err := database.GetTraders(userID)
if err != nil {
log.Printf("⚠️ 获取用户 %s 的交易员失败: %v", userID, err)
continue
}
allTraders = append(allTraders, traders...)
log.Fatalf(" 获取交易员列表失败: %v", err)
}
// 显示加载的交易员信息
fmt.Println()
fmt.Println("🤖 数据库中的AI交易员配置:")
if len(allTraders) == 0 {
if len(traders) == 0 {
fmt.Println(" • 暂无配置的交易员,请通过Web界面创建")
} else {
for _, trader := range allTraders {
for _, trader := range traders {
status := "停止"
if trader.IsRunning {
status = "运行中"
}
fmt.Printf(" • %s (%s + %s) - 用户: %s - 初始资金: %.0f USDT [%s]\n",
trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID),
trader.UserID, trader.InitialBalance, status)
fmt.Printf(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]\n",
trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID),
trader.InitialBalance, status)
}
}
@@ -298,7 +288,7 @@ func main() {
fmt.Println()
// 获取API服务器端口
apiPort := 8080 // 默认端口
apiPort := 8080 // 默认端口
if apiPortStr != "" {
if port, err := strconv.Atoi(apiPortStr); err == nil {
apiPort = port
@@ -313,6 +303,9 @@ func main() {
}
}()
// 启动流行情数据 - 默认使用所有交易员设置的币种 如果没有设置币种 则优先使用系统默认
go market.NewWSMonitor(150).Start(database.GetCustomCoins())
//go market.NewWSMonitor(150).Start([]string{}) //这里是一个使用方式 传入空的话 则使用market市场的所有币种
// 设置优雅退出
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+150
View File
@@ -0,0 +1,150 @@
package market
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
)
const (
baseURL = "https://fapi.binance.com"
)
type APIClient struct {
client *http.Client
}
func NewAPIClient() *APIClient {
return &APIClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *APIClient) GetExchangeInfo() (*ExchangeInfo, error) {
url := fmt.Sprintf("%s/fapi/v1/exchangeInfo", baseURL)
resp, err := c.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var exchangeInfo ExchangeInfo
err = json.Unmarshal(body, &exchangeInfo)
if err != nil {
return nil, err
}
return &exchangeInfo, nil
}
func (c *APIClient) GetKlines(symbol, interval string, limit int) ([]Kline, error) {
url := fmt.Sprintf("%s/fapi/v1/klines", baseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Add("symbol", symbol)
q.Add("interval", interval)
q.Add("limit", strconv.Itoa(limit))
req.URL.RawQuery = q.Encode()
resp, err := c.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 klineResponses []KlineResponse
err = json.Unmarshal(body, &klineResponses)
if err != nil {
return nil, err
}
var klines []Kline
for _, kr := range klineResponses {
kline, err := parseKline(kr)
if err != nil {
log.Printf("解析K线数据失败: %v", err)
continue
}
klines = append(klines, kline)
}
return klines, nil
}
func parseKline(kr KlineResponse) (Kline, error) {
var kline Kline
if len(kr) < 11 {
return kline, fmt.Errorf("invalid kline data")
}
// 解析各个字段
kline.OpenTime = int64(kr[0].(float64))
kline.Open, _ = strconv.ParseFloat(kr[1].(string), 64)
kline.High, _ = strconv.ParseFloat(kr[2].(string), 64)
kline.Low, _ = strconv.ParseFloat(kr[3].(string), 64)
kline.Close, _ = strconv.ParseFloat(kr[4].(string), 64)
kline.Volume, _ = strconv.ParseFloat(kr[5].(string), 64)
kline.CloseTime = int64(kr[6].(float64))
kline.QuoteVolume, _ = strconv.ParseFloat(kr[7].(string), 64)
kline.Trades = int(kr[8].(float64))
kline.TakerBuyBaseVolume, _ = strconv.ParseFloat(kr[9].(string), 64)
kline.TakerBuyQuoteVolume, _ = strconv.ParseFloat(kr[10].(string), 64)
return kline, nil
}
func (c *APIClient) GetCurrentPrice(symbol string) (float64, error) {
url := fmt.Sprintf("%s/fapi/v1/ticker/price", baseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return 0, err
}
q := req.URL.Query()
q.Add("symbol", symbol)
req.URL.RawQuery = q.Encode()
resp, err := c.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, err
}
var ticker PriceTicker
err = json.Unmarshal(body, &ticker)
if err != nil {
return 0, err
}
price, err := strconv.ParseFloat(ticker.Price, 64)
if err != nil {
return 0, err
}
return price, nil
}
+202
View File
@@ -0,0 +1,202 @@
package market
import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type CombinedStreamsClient struct {
conn *websocket.Conn
mu sync.RWMutex
subscribers map[string]chan []byte
reconnect bool
done chan struct{}
batchSize int // 每批订阅的流数量
}
func NewCombinedStreamsClient(batchSize int) *CombinedStreamsClient {
return &CombinedStreamsClient{
subscribers: make(map[string]chan []byte),
reconnect: true,
done: make(chan struct{}),
batchSize: batchSize,
}
}
func (c *CombinedStreamsClient) Connect() error {
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
// 组合流使用不同的端点
conn, _, err := dialer.Dial("wss://fstream.binance.com/stream", nil)
if err != nil {
return fmt.Errorf("组合流WebSocket连接失败: %v", err)
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
log.Println("组合流WebSocket连接成功")
go c.readMessages()
return nil
}
// BatchSubscribeKlines 批量订阅K线
func (c *CombinedStreamsClient) BatchSubscribeKlines(symbols []string, interval string) error {
// 将symbols分批处理
batches := c.splitIntoBatches(symbols, c.batchSize)
for i, batch := range batches {
log.Printf("订阅第 %d 批, 数量: %d", i+1, len(batch))
streams := make([]string, len(batch))
for j, symbol := range batch {
streams[j] = fmt.Sprintf("%s@kline_%s", strings.ToLower(symbol), interval)
}
if err := c.subscribeStreams(streams); err != nil {
return fmt.Errorf("第 %d 批订阅失败: %v", i+1, err)
}
// 批次间延迟,避免被限制
if i < len(batches)-1 {
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
// splitIntoBatches 将切片分成指定大小的批次
func (c *CombinedStreamsClient) splitIntoBatches(symbols []string, batchSize int) [][]string {
var batches [][]string
for i := 0; i < len(symbols); i += batchSize {
end := i + batchSize
if end > len(symbols) {
end = len(symbols)
}
batches = append(batches, symbols[i:end])
}
return batches
}
// subscribeStreams 订阅多个流
func (c *CombinedStreamsClient) subscribeStreams(streams []string) error {
subscribeMsg := map[string]interface{}{
"method": "SUBSCRIBE",
"params": streams,
"id": time.Now().UnixNano(),
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.conn == nil {
return fmt.Errorf("WebSocket未连接")
}
log.Printf("订阅流: %v", streams)
return c.conn.WriteJSON(subscribeMsg)
}
func (c *CombinedStreamsClient) readMessages() {
for {
select {
case <-c.done:
return
default:
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
time.Sleep(1 * time.Second)
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("读取组合流消息失败: %v", err)
c.handleReconnect()
return
}
c.handleCombinedMessage(message)
}
}
}
func (c *CombinedStreamsClient) handleCombinedMessage(message []byte) {
var combinedMsg struct {
Stream string `json:"stream"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(message, &combinedMsg); err != nil {
log.Printf("解析组合消息失败: %v", err)
return
}
c.mu.RLock()
ch, exists := c.subscribers[combinedMsg.Stream]
c.mu.RUnlock()
if exists {
select {
case ch <- combinedMsg.Data:
default:
log.Printf("订阅者通道已满: %s", combinedMsg.Stream)
}
}
}
func (c *CombinedStreamsClient) AddSubscriber(stream string, bufferSize int) <-chan []byte {
ch := make(chan []byte, bufferSize)
c.mu.Lock()
c.subscribers[stream] = ch
c.mu.Unlock()
return ch
}
func (c *CombinedStreamsClient) handleReconnect() {
if !c.reconnect {
return
}
log.Println("组合流尝试重新连接...")
time.Sleep(3 * time.Second)
if err := c.Connect(); err != nil {
log.Printf("组合流重新连接失败: %v", err)
go c.handleReconnect()
}
}
func (c *CombinedStreamsClient) Close() {
c.reconnect = false
close(c.done)
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
for stream, ch := range c.subscribers {
close(ch)
delete(c.subscribers, stream)
}
}
+4 -101
View File
@@ -10,72 +10,20 @@ import (
"strings"
)
// Data 市场数据结构
type Data struct {
Symbol string
CurrentPrice float64
PriceChange1h float64 // 1小时价格变化百分比
PriceChange4h float64 // 4小时价格变化百分比
CurrentEMA20 float64
CurrentMACD float64
CurrentRSI7 float64
OpenInterest *OIData
FundingRate float64
IntradaySeries *IntradayData
LongerTermContext *LongerTermData
}
// OIData Open Interest数据
type OIData struct {
Latest float64
Average float64
}
// IntradayData 日内数据(3分钟间隔)
type IntradayData struct {
MidPrices []float64
EMA20Values []float64
MACDValues []float64
RSI7Values []float64
RSI14Values []float64
}
// LongerTermData 长期数据(4小时时间框架)
type LongerTermData struct {
EMA20 float64
EMA50 float64
ATR3 float64
ATR14 float64
CurrentVolume float64
AverageVolume float64
MACDValues []float64
RSI14Values []float64
}
// Kline K线数据
type Kline struct {
OpenTime int64
Open float64
High float64
Low float64
Close float64
Volume float64
CloseTime int64
}
// Get 获取指定代币的市场数据
func Get(symbol string) (*Data, error) {
var klines3m, klines4h []Kline
var err error
// 标准化symbol
symbol = Normalize(symbol)
// 获取3分钟K线数据 (最近10个)
klines3m, err := getKlines(symbol, "3m", 40) // 多获取一些用于计算
klines3m, err = WSMonitorCli.GetCurrentKlines(symbol, "3m") // 多获取一些用于计算
if err != nil {
return nil, fmt.Errorf("获取3分钟K线失败: %v", err)
}
// 获取4小时K线数据 (最近10个)
klines4h, err := getKlines(symbol, "4h", 60) // 多获取用于计算指标
klines4h, err = WSMonitorCli.GetCurrentKlines(symbol, "4h") // 多获取用于计算指标
if err != nil {
return nil, fmt.Errorf("获取4小时K线失败: %v", err)
}
@@ -136,51 +84,6 @@ func Get(symbol string) (*Data, error) {
}, nil
}
// getKlines 从Binance获取K线数据
func getKlines(symbol, interval string, limit int) ([]Kline, error) {
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=%d",
symbol, interval, limit)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rawData [][]interface{}
if err := json.Unmarshal(body, &rawData); err != nil {
return nil, err
}
klines := make([]Kline, len(rawData))
for i, item := range rawData {
openTime := int64(item[0].(float64))
open, _ := parseFloat(item[1])
high, _ := parseFloat(item[2])
low, _ := parseFloat(item[3])
close, _ := parseFloat(item[4])
volume, _ := parseFloat(item[5])
closeTime := int64(item[6].(float64))
klines[i] = Kline{
OpenTime: openTime,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume,
CloseTime: closeTime,
}
}
return klines, nil
}
// calculateEMA 计算EMA
func calculateEMA(klines []Kline, period int) float64 {
if len(klines) < period {
+260
View File
@@ -0,0 +1,260 @@
package market
import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
)
type WSMonitor struct {
wsClient *WSClient
combinedClient *CombinedStreamsClient
symbols []string
featuresMap sync.Map
alertsChan chan Alert
klineDataMap3m sync.Map // 存储每个交易对的K线历史数据
klineDataMap4h sync.Map // 存储每个交易对的K线历史数据
tickerDataMap sync.Map // 存储每个交易对的ticker数据
batchSize int
filterSymbols sync.Map // 使用sync.Map来存储需要监控的币种和其状态
symbolStats sync.Map // 存储币种统计信息
FilterSymbol []string //经过筛选的币种
}
type SymbolStats struct {
LastActiveTime time.Time
AlertCount int
VolumeSpikeCount int
LastAlertTime time.Time
Score float64 // 综合评分
}
var WSMonitorCli *WSMonitor
var subKlineTime = []string{"3m", "4h"} // 管理订阅流的K线周期
func NewWSMonitor(batchSize int) *WSMonitor {
WSMonitorCli = &WSMonitor{
wsClient: NewWSClient(),
combinedClient: NewCombinedStreamsClient(batchSize),
alertsChan: make(chan Alert, 1000),
batchSize: batchSize,
}
return WSMonitorCli
}
func (m *WSMonitor) Initialize(coins []string) error {
log.Println("初始化WebSocket监控器...")
// 获取交易对信息
apiClient := NewAPIClient()
// 如果不指定交易对,则使用market市场的所有交易对币种
if len(coins) == 0 {
exchangeInfo, err := apiClient.GetExchangeInfo()
if err != nil {
return err
}
// 筛选永续合约交易对 --仅测试时使用
//exchangeInfo.Symbols = exchangeInfo.Symbols[0:2]
for _, symbol := range exchangeInfo.Symbols {
if symbol.Status == "TRADING" && symbol.ContractType == "PERPETUAL" && strings.ToUpper(symbol.Symbol[len(symbol.Symbol)-4:]) == "USDT" {
m.symbols = append(m.symbols, symbol.Symbol)
m.filterSymbols.Store(symbol.Symbol, true)
}
}
} else {
m.symbols = coins
}
log.Printf("找到 %d 个交易对", len(m.symbols))
// 初始化历史数据
if err := m.initializeHistoricalData(); err != nil {
log.Printf("初始化历史数据失败: %v", err)
}
return nil
}
func (m *WSMonitor) initializeHistoricalData() error {
apiClient := NewAPIClient()
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5) // 限制并发数
for _, symbol := range m.symbols {
wg.Add(1)
semaphore <- struct{}{}
go func(s string) {
defer wg.Done()
defer func() { <-semaphore }()
// 获取历史K线数据
klines, err := apiClient.GetKlines(s, "3m", 100)
if err != nil {
log.Printf("获取 %s 历史数据失败: %v", s, err)
return
}
if len(klines) > 0 {
m.klineDataMap3m.Store(s, klines)
log.Printf("已加载 %s 的历史K线数据-3m: %d 条", s, len(klines))
}
// 获取历史K线数据
klines4h, err := apiClient.GetKlines(s, "4h", 100)
if err != nil {
log.Printf("获取 %s 历史数据失败: %v", s, err)
return
}
if len(klines4h) > 0 {
m.klineDataMap4h.Store(s, klines)
log.Printf("已加载 %s 的历史K线数据-4h: %d 条", s, len(klines))
}
}(symbol)
}
wg.Wait()
return nil
}
func (m *WSMonitor) Start(coins []string) {
log.Printf("启动WebSocket实时监控...")
// 初始化交易对
err := m.Initialize(coins)
if err != nil {
log.Fatalf("❌ 初始化币种: %v", err)
return
}
err = m.combinedClient.Connect()
if err != nil {
log.Fatalf("❌ 批量订阅流: %v", err)
return
}
// 订阅所有交易对
err = m.subscribeAll()
if err != nil {
log.Fatalf("❌ 订阅币种交易对: %v", err)
return
}
}
// subscribeSymbol 注册监听
func (m *WSMonitor) subscribeSymbol(symbol, st string) []string {
var streams []string
stream := fmt.Sprintf("%s@kline_%s", strings.ToLower(symbol), st)
ch := m.combinedClient.AddSubscriber(stream, 100)
streams = append(streams, stream)
go m.handleKlineData(symbol, ch, st)
return streams
}
func (m *WSMonitor) subscribeAll() error {
// 执行批量订阅
log.Println("开始订阅所有交易对...")
for _, symbol := range m.symbols {
for _, st := range subKlineTime {
m.subscribeSymbol(symbol, st)
}
}
for _, st := range subKlineTime {
err := m.combinedClient.BatchSubscribeKlines(m.symbols, st)
if err != nil {
log.Fatalf("❌ 订阅3m K线: %v", err)
return err
}
}
log.Println("所有交易对订阅完成")
return nil
}
func (m *WSMonitor) handleKlineData(symbol string, ch <-chan []byte, _time string) {
for data := range ch {
var klineData KlineWSData
if err := json.Unmarshal(data, &klineData); err != nil {
log.Printf("解析Kline数据失败: %v", err)
continue
}
m.processKlineUpdate(symbol, klineData, _time)
}
}
func (m *WSMonitor) getKlineDataMap(_time string) *sync.Map {
var klineDataMap *sync.Map
if _time == "3m" {
klineDataMap = &m.klineDataMap3m
} else if _time == "4h" {
klineDataMap = &m.klineDataMap4h
} else {
klineDataMap = &sync.Map{}
}
return klineDataMap
}
func (m *WSMonitor) processKlineUpdate(symbol string, wsData KlineWSData, _time string) {
// 转换WebSocket数据为Kline结构
kline := Kline{
OpenTime: wsData.Kline.StartTime,
CloseTime: wsData.Kline.CloseTime,
Trades: wsData.Kline.NumberOfTrades,
}
kline.Open, _ = parseFloat(wsData.Kline.OpenPrice)
kline.High, _ = parseFloat(wsData.Kline.HighPrice)
kline.Low, _ = parseFloat(wsData.Kline.LowPrice)
kline.Close, _ = parseFloat(wsData.Kline.ClosePrice)
kline.Volume, _ = parseFloat(wsData.Kline.Volume)
kline.High, _ = parseFloat(wsData.Kline.HighPrice)
kline.QuoteVolume, _ = parseFloat(wsData.Kline.QuoteVolume)
kline.TakerBuyBaseVolume, _ = parseFloat(wsData.Kline.TakerBuyBaseVolume)
kline.TakerBuyQuoteVolume, _ = parseFloat(wsData.Kline.TakerBuyQuoteVolume)
// 更新K线数据
var klineDataMap = m.getKlineDataMap(_time)
value, exists := klineDataMap.Load(symbol)
var klines []Kline
if exists {
klines = value.([]Kline)
// 检查是否是新的K线
if len(klines) > 0 && klines[len(klines)-1].OpenTime == kline.OpenTime {
// 更新当前K线
klines[len(klines)-1] = kline
} else {
// 添加新K线
klines = append(klines, kline)
// 保持数据长度
if len(klines) > 100 {
klines = klines[1:]
}
}
} else {
klines = []Kline{kline}
}
klineDataMap.Store(symbol, klines)
}
func (m *WSMonitor) GetCurrentKlines(symbol string, _time string) ([]Kline, error) {
// 对每一个进来的symbol检测是否存在内类 是否的话就订阅它
value, exists := m.getKlineDataMap(_time).Load(symbol)
if !exists {
// 如果Ws数据未初始化完成时,单独使用api获取 - 兼容性代码 (防止在未初始化完成是,已经有交易员运行)
apiClient := NewAPIClient()
klines, err := apiClient.GetKlines(symbol, _time, 100)
m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines) //动态缓存进缓存
subStr := m.subscribeSymbol(symbol, _time)
subErr := m.combinedClient.subscribeStreams(subStr)
log.Printf("动态订阅流: %v", subStr)
if subErr != nil {
return nil, fmt.Errorf("动态订阅%v分钟K线失败: %v", _time, subErr)
}
if err != nil {
return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err)
}
return klines, fmt.Errorf("symbol不存在")
}
return value.([]Kline), nil
}
func (m *WSMonitor) Close() {
m.wsClient.Close()
close(m.alertsChan)
}
+157
View File
@@ -0,0 +1,157 @@
package market
import "time"
// Data 市场数据结构
type Data struct {
Symbol string
CurrentPrice float64
PriceChange1h float64 // 1小时价格变化百分比
PriceChange4h float64 // 4小时价格变化百分比
CurrentEMA20 float64
CurrentMACD float64
CurrentRSI7 float64
OpenInterest *OIData
FundingRate float64
IntradaySeries *IntradayData
LongerTermContext *LongerTermData
}
// OIData Open Interest数据
type OIData struct {
Latest float64
Average float64
}
// IntradayData 日内数据(3分钟间隔)
type IntradayData struct {
MidPrices []float64
EMA20Values []float64
MACDValues []float64
RSI7Values []float64
RSI14Values []float64
}
// LongerTermData 长期数据(4小时时间框架)
type LongerTermData struct {
EMA20 float64
EMA50 float64
ATR3 float64
ATR14 float64
CurrentVolume float64
AverageVolume float64
MACDValues []float64
RSI14Values []float64
}
// Binance API 响应结构
type ExchangeInfo struct {
Symbols []SymbolInfo `json:"symbols"`
}
type SymbolInfo struct {
Symbol string `json:"symbol"`
Status string `json:"status"`
BaseAsset string `json:"baseAsset"`
QuoteAsset string `json:"quoteAsset"`
ContractType string `json:"contractType"`
PricePrecision int `json:"pricePrecision"`
QuantityPrecision int `json:"quantityPrecision"`
}
type Kline struct {
OpenTime int64 `json:"openTime"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
CloseTime int64 `json:"closeTime"`
QuoteVolume float64 `json:"quoteVolume"`
Trades int `json:"trades"`
TakerBuyBaseVolume float64 `json:"takerBuyBaseVolume"`
TakerBuyQuoteVolume float64 `json:"takerBuyQuoteVolume"`
}
type KlineResponse []interface{}
type PriceTicker struct {
Symbol string `json:"symbol"`
Price string `json:"price"`
}
type Ticker24hr struct {
Symbol string `json:"symbol"`
PriceChange string `json:"priceChange"`
PriceChangePercent string `json:"priceChangePercent"`
Volume string `json:"volume"`
QuoteVolume string `json:"quoteVolume"`
}
// 特征数据结构
type SymbolFeatures struct {
Symbol string `json:"symbol"`
Timestamp time.Time `json:"timestamp"`
Price float64 `json:"price"`
PriceChange15Min float64 `json:"price_change_15min"`
PriceChange1H float64 `json:"price_change_1h"`
PriceChange4H float64 `json:"price_change_4h"`
Volume float64 `json:"volume"`
VolumeRatio5 float64 `json:"volume_ratio_5"`
VolumeRatio20 float64 `json:"volume_ratio_20"`
VolumeTrend float64 `json:"volume_trend"`
RSI14 float64 `json:"rsi_14"`
SMA5 float64 `json:"sma_5"`
SMA10 float64 `json:"sma_10"`
SMA20 float64 `json:"sma_20"`
HighLowRatio float64 `json:"high_low_ratio"`
Volatility20 float64 `json:"volatility_20"`
PositionInRange float64 `json:"position_in_range"`
}
// 警报数据结构
type Alert struct {
Type string `json:"type"`
Symbol string `json:"symbol"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
}
type Config struct {
AlertThresholds AlertThresholds `json:"alert_thresholds"`
UpdateInterval int `json:"update_interval"` // seconds
CleanupConfig CleanupConfig `json:"cleanup_config"`
}
type AlertThresholds struct {
VolumeSpike float64 `json:"volume_spike"`
PriceChange15Min float64 `json:"price_change_15min"`
VolumeTrend float64 `json:"volume_trend"`
RSIOverbought float64 `json:"rsi_overbought"`
RSIOversold float64 `json:"rsi_oversold"`
}
type CleanupConfig struct {
InactiveTimeout time.Duration `json:"inactive_timeout"` // 不活跃超时时间
MinScoreThreshold float64 `json:"min_score_threshold"` // 最低评分阈值
NoAlertTimeout time.Duration `json:"no_alert_timeout"` // 无警报超时时间
CheckInterval time.Duration `json:"check_interval"` // 检查间隔
}
var config = Config{
AlertThresholds: AlertThresholds{
VolumeSpike: 3.0,
PriceChange15Min: 0.05,
VolumeTrend: 2.0,
RSIOverbought: 70,
RSIOversold: 30,
},
CleanupConfig: CleanupConfig{
InactiveTimeout: 30 * time.Minute,
MinScoreThreshold: 15.0,
NoAlertTimeout: 20 * time.Minute,
CheckInterval: 5 * time.Minute,
},
UpdateInterval: 60, // 1 minute
}
+231
View File
@@ -0,0 +1,231 @@
package market
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
type WSClient struct {
conn *websocket.Conn
mu sync.RWMutex
subscribers map[string]chan []byte
reconnect bool
done chan struct{}
}
type WSMessage struct {
Stream string `json:"stream"`
Data json.RawMessage `json:"data"`
}
type KlineWSData struct {
EventType string `json:"e"`
EventTime int64 `json:"E"`
Symbol string `json:"s"`
Kline struct {
StartTime int64 `json:"t"`
CloseTime int64 `json:"T"`
Symbol string `json:"s"`
Interval string `json:"i"`
FirstTradeID int64 `json:"f"`
LastTradeID int64 `json:"L"`
OpenPrice string `json:"o"`
ClosePrice string `json:"c"`
HighPrice string `json:"h"`
LowPrice string `json:"l"`
Volume string `json:"v"`
NumberOfTrades int `json:"n"`
IsFinal bool `json:"x"`
QuoteVolume string `json:"q"`
TakerBuyBaseVolume string `json:"V"`
TakerBuyQuoteVolume string `json:"Q"`
} `json:"k"`
}
type TickerWSData struct {
EventType string `json:"e"`
EventTime int64 `json:"E"`
Symbol string `json:"s"`
PriceChange string `json:"p"`
PriceChangePercent string `json:"P"`
WeightedAvgPrice string `json:"w"`
LastPrice string `json:"c"`
LastQty string `json:"Q"`
OpenPrice string `json:"o"`
HighPrice string `json:"h"`
LowPrice string `json:"l"`
Volume string `json:"v"`
QuoteVolume string `json:"q"`
OpenTime int64 `json:"O"`
CloseTime int64 `json:"C"`
FirstID int64 `json:"F"`
LastID int64 `json:"L"`
Count int `json:"n"`
}
func NewWSClient() *WSClient {
return &WSClient{
subscribers: make(map[string]chan []byte),
reconnect: true,
done: make(chan struct{}),
}
}
func (w *WSClient) Connect() error {
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial("wss://ws-fapi.binance.com/ws-fapi/v1", nil)
if err != nil {
return fmt.Errorf("WebSocket连接失败: %v", err)
}
w.mu.Lock()
w.conn = conn
w.mu.Unlock()
log.Println("WebSocket连接成功")
// 启动消息读取循环
go w.readMessages()
return nil
}
func (w *WSClient) SubscribeKline(symbol, interval string) error {
stream := fmt.Sprintf("%s@kline_%s", symbol, interval)
return w.subscribe(stream)
}
func (w *WSClient) SubscribeTicker(symbol string) error {
stream := fmt.Sprintf("%s@ticker", symbol)
return w.subscribe(stream)
}
func (w *WSClient) SubscribeMiniTicker(symbol string) error {
stream := fmt.Sprintf("%s@miniTicker", symbol)
return w.subscribe(stream)
}
func (w *WSClient) subscribe(stream string) error {
subscribeMsg := map[string]interface{}{
"method": "SUBSCRIBE",
"params": []string{stream},
"id": time.Now().Unix(),
}
w.mu.RLock()
defer w.mu.RUnlock()
if w.conn == nil {
return fmt.Errorf("WebSocket未连接")
}
err := w.conn.WriteJSON(subscribeMsg)
if err != nil {
return err
}
log.Printf("订阅流: %s", stream)
return nil
}
func (w *WSClient) readMessages() {
for {
select {
case <-w.done:
return
default:
w.mu.RLock()
conn := w.conn
w.mu.RUnlock()
if conn == nil {
time.Sleep(1 * time.Second)
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("读取WebSocket消息失败: %v", err)
w.handleReconnect()
return
}
w.handleMessage(message)
}
}
}
func (w *WSClient) handleMessage(message []byte) {
var wsMsg WSMessage
if err := json.Unmarshal(message, &wsMsg); err != nil {
// 可能是其他格式的消息
return
}
w.mu.RLock()
ch, exists := w.subscribers[wsMsg.Stream]
w.mu.RUnlock()
if exists {
select {
case ch <- wsMsg.Data:
default:
log.Printf("订阅者通道已满: %s", wsMsg.Stream)
}
}
}
func (w *WSClient) handleReconnect() {
if !w.reconnect {
return
}
log.Println("尝试重新连接...")
time.Sleep(3 * time.Second)
if err := w.Connect(); err != nil {
log.Printf("重新连接失败: %v", err)
go w.handleReconnect()
}
}
func (w *WSClient) AddSubscriber(stream string, bufferSize int) <-chan []byte {
ch := make(chan []byte, bufferSize)
w.mu.Lock()
w.subscribers[stream] = ch
w.mu.Unlock()
return ch
}
func (w *WSClient) RemoveSubscriber(stream string) {
w.mu.Lock()
delete(w.subscribers, stream)
w.mu.Unlock()
}
func (w *WSClient) Close() {
w.reconnect = false
close(w.done)
w.mu.Lock()
defer w.mu.Unlock()
if w.conn != nil {
w.conn.Close()
w.conn = nil
}
// 关闭所有订阅者通道
for stream, ch := range w.subscribers {
close(ch)
delete(w.subscribers, stream)
}
}
+11
View File
@@ -2,11 +2,22 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-TM429527');</script>
<!-- End Google Tag Manager -->
<link rel="icon" type="image/svg+xml" href="/icons/nofx.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NOFX - AI Auto Trading Dashboard</title>
</head>
<body>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-TM429527"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+6 -6
View File
@@ -277,17 +277,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const handleSaveModelConfig = async (modelId: string, apiKey: string, customApiUrl?: string, customModelName?: string) => {
try {
// 找到要配置的模型(从supportedModels中)
const modelToUpdate = supportedModels?.find(m => m.id === modelId);
// 创建或更新用户的模型配置
const existingModel = allModels?.find(m => m.id === modelId);
let updatedModels;
// 找到要配置的模型(优先从已配置列表,其次从支持列表)
const modelToUpdate = existingModel || supportedModels?.find(m => m.id === modelId);
if (!modelToUpdate) {
alert(t('modelNotExist', language));
return;
}
// 创建或更新用户的模型配置
const existingModel = allModels?.find(m => m.id === modelId);
let updatedModels;
if (existingModel) {
// 更新现有配置
updatedModels = allModels?.map(m =>