Merge pull request #67 from tinkle-community/main

merge
This commit is contained in:
tinkle-community
2025-10-30 19:57:36 +08:00
committed by GitHub
15 changed files with 874 additions and 172 deletions

300
PM2_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,300 @@
# NoFX Trading Bot - PM2 部署指南
使用 PM2 进行本地开发和生产部署的完整指南。
## 🚀 快速开始
### 1. 安装 PM2
```bash
npm install -g pm2
```
### 2. 一键启动
```bash
./pm2.sh start
```
就这么简单!前后端将自动启动。
---
## 📋 所有命令
### 服务管理
```bash
# 启动服务
./pm2.sh start
# 停止服务
./pm2.sh stop
# 重启服务
./pm2.sh restart
# 查看状态
./pm2.sh status
# 删除服务
./pm2.sh delete
```
### 日志查看
```bash
# 查看所有日志(实时)
./pm2.sh logs
# 只看后端日志
./pm2.sh logs backend
# 只看前端日志
./pm2.sh logs frontend
```
### 构建与编译
```bash
# 编译后端
./pm2.sh build
# 重新编译后端并重启
./pm2.sh rebuild
```
### 监控
```bash
# 打开 PM2 监控面板实时CPU/内存)
./pm2.sh monitor
```
---
## 📊 访问地址
启动成功后:
- **前端 Web 界面**: http://localhost:3000
- **后端 API**: http://localhost:8080
- **健康检查**: http://localhost:8080/health
---
## 🔧 配置文件
### pm2.config.js
PM2 配置文件,定义了前后端的启动参数:
```javascript
const path = require('path');
module.exports = {
apps: [
{
name: 'nofx-backend',
script: './nofx', // Go 二进制文件
cwd: __dirname, // 动态获取当前目录
autorestart: true,
max_memory_restart: '500M'
},
{
name: 'nofx-frontend',
script: 'npm',
args: 'run dev', // Vite 开发服务器
cwd: path.join(__dirname, 'web'), // 动态拼接路径
autorestart: true,
max_memory_restart: '300M'
}
]
};
```
**修改配置后需要重启:**
```bash
./pm2.sh restart
```
---
## 📝 日志文件位置
- **后端日志**: `./logs/backend-error.log``./logs/backend-out.log`
- **前端日志**: `./web/logs/frontend-error.log``./web/logs/frontend-out.log`
---
## 🔄 开机自启动
设置 PM2 开机自启动:
```bash
# 1. 启动服务
./pm2.sh start
# 2. 保存当前进程列表
pm2 save
# 3. 生成启动脚本
pm2 startup
# 4. 按照提示执行命令(需要 sudo
```
**取消开机自启动:**
```bash
pm2 unstartup
```
---
## 🛠️ 常见操作
### 修改代码后重启
**后端修改:**
```bash
./pm2.sh rebuild # 自动编译并重启
```
**前端修改:**
```bash
./pm2.sh restart # Vite 会自动热重载,无需重启
```
### 查看实时资源占用
```bash
./pm2.sh monitor
```
### 查看详细信息
```bash
pm2 info nofx-backend # 后端详情
pm2 info nofx-frontend # 前端详情
```
### 清空日志
```bash
pm2 flush
```
---
## 🐛 故障排查
### 服务启动失败
```bash
# 1. 查看详细错误
./pm2.sh logs
# 2. 检查端口占用
lsof -i :8080 # 后端端口
lsof -i :3000 # 前端端口
# 3. 手动编译测试
go build -o nofx
./nofx
```
### 后端无法启动
```bash
# 检查 config.json 是否存在
ls -l config.json
# 检查权限
chmod +x nofx
# 手动运行看报错
./nofx
```
### 前端无法访问
```bash
# 检查 node_modules
cd web && npm install
# 手动启动测试
npm run dev
```
---
## 🎯 生产环境建议
### 1. 使用生产模式
修改 `pm2.config.js`
```javascript
{
name: 'nofx-frontend',
script: 'npm',
args: 'run preview', // 改为 preview需先 npm run build
env: {
NODE_ENV: 'production'
}
}
```
### 2. 增加实例数(负载均衡)
```javascript
{
name: 'nofx-backend',
script: './nofx',
instances: 2, // 启动 2 个实例
exec_mode: 'cluster'
}
```
### 3. 自动重启策略
```javascript
{
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '500M'
}
```
---
## 📦 与 Docker 部署的对比
| 特性 | PM2 部署 | Docker 部署 |
|------|---------|------------|
| 启动速度 | ⚡ 快 | 🐌 较慢 |
| 资源占用 | 💚 低 | 🟡 中等 |
| 隔离性 | 🟡 中等 | 💚 高 |
| 适用场景 | 开发/单机 | 生产/集群 |
| 配置复杂度 | 💚 简单 | 🟡 中等 |
**建议:**
- **开发环境**: 使用 `./pm2.sh`
- **生产环境**: 使用 `./start.sh` (Docker)
---
## 🆘 获取帮助
```bash
./pm2.sh help
```
或查看 PM2 官方文档https://pm2.keymetrics.io/
---
## 📄 License
MIT

View File

@@ -388,8 +388,9 @@ func (s *Server) handlePerformance(c *gin.Context) {
return
}
// 分析最近20个周期的交易表现
performance, err := trader.GetDecisionLogger().AnalyzePerformance(20)
// 分析最近100个周期的交易表现(避免长期持仓的交易记录丢失)
// 假设每3分钟一个周期100个周期 = 5小时足够覆盖大部分交易
performance, err := trader.GetDecisionLogger().AnalyzePerformance(100)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("分析历史表现失败: %v", err),

View File

@@ -6,6 +6,7 @@
"ai_model": "deepseek",
"exchange": "hyperliquid",
"hyperliquid_private_key": "your_ethereum_private_key_without_0x_prefix",
"hyperliquid_wallet_addr": "your_ethereum_address",
"hyperliquid_testnet": false,
"deepseek_key": "your_deepseek_api_key",
"initial_balance": 1000,

View File

@@ -9,19 +9,20 @@ import (
// TraderConfig 单个trader的配置
type TraderConfig struct {
ID string `json:"id"`
Name string `json:"name"`
AIModel string `json:"ai_model"` // "qwen" or "deepseek"
ID string `json:"id"`
Name string `json:"name"`
AIModel string `json:"ai_model"` // "qwen" or "deepseek"
// 交易平台选择(二选一)
Exchange string `json:"exchange"` // "binance" or "hyperliquid"
Exchange string `json:"exchange"` // "binance" or "hyperliquid"
// 币安配置
BinanceAPIKey string `json:"binance_api_key,omitempty"`
BinanceSecretKey string `json:"binance_secret_key,omitempty"`
BinanceAPIKey string `json:"binance_api_key,omitempty"`
BinanceSecretKey string `json:"binance_secret_key,omitempty"`
// Hyperliquid配置
HyperliquidPrivateKey string `json:"hyperliquid_private_key,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr,omitempty"`
HyperliquidTestnet bool `json:"hyperliquid_testnet,omitempty"`
// Aster配置
@@ -30,13 +31,13 @@ type TraderConfig struct {
AsterPrivateKey string `json:"aster_private_key,omitempty"` // Aster API钱包私钥
// AI配置
QwenKey string `json:"qwen_key,omitempty"`
DeepSeekKey string `json:"deepseek_key,omitempty"`
QwenKey string `json:"qwen_key,omitempty"`
DeepSeekKey string `json:"deepseek_key,omitempty"`
// 自定义AI API配置支持任何OpenAI格式的API
CustomAPIURL string `json:"custom_api_url,omitempty"`
CustomAPIKey string `json:"custom_api_key,omitempty"`
CustomModelName string `json:"custom_model_name,omitempty"`
CustomAPIURL string `json:"custom_api_url,omitempty"`
CustomAPIKey string `json:"custom_api_key,omitempty"`
CustomModelName string `json:"custom_model_name,omitempty"`
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
@@ -44,15 +45,15 @@ type TraderConfig struct {
// LeverageConfig 杠杆配置
type LeverageConfig struct {
BTCETHLeverage int `json:"btc_eth_leverage"` // BTC和ETH的杠杆倍数主账户建议5-50子账户≤5
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数主账户建议5-20子账户≤5
BTCETHLeverage int `json:"btc_eth_leverage"` // BTC和ETH的杠杆倍数主账户建议5-50子账户≤5
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数主账户建议5-20子账户≤5
}
// Config 总配置
type Config struct {
Traders []TraderConfig `json:"traders"`
UseDefaultCoins bool `json:"use_default_coins"` // 是否使用默认主流币种列表
DefaultCoins []string `json:"default_coins"` // 默认主流币种池
UseDefaultCoins bool `json:"use_default_coins"` // 是否使用默认主流币种列表
DefaultCoins []string `json:"default_coins"` // 默认主流币种池
CoinPoolAPIURL string `json:"coin_pool_api_url"`
OITopAPIURL string `json:"oi_top_api_url"`
APIServerPort int `json:"api_server_port"`
@@ -83,13 +84,13 @@ func LoadConfig(filename string) (*Config, error) {
if len(config.DefaultCoins) == 0 {
config.DefaultCoins = []string{
"BTCUSDT",
"ETHUSDT",
"SOLUSDT",
"BNBUSDT",
"XRPUSDT",
"DOGEUSDT",
"ADAUSDT",
"HYPEUSDT",
"ETHUSDT",
"SOLUSDT",
"BNBUSDT",
"XRPUSDT",
"DOGEUSDT",
"ADAUSDT",
"HYPEUSDT",
}
}

View File

@@ -90,7 +90,7 @@ type FullDecision struct {
}
// GetFullDecision 获取AI的完整交易决策批量分析所有币种和持仓
func GetFullDecision(ctx *Context) (*FullDecision, error) {
func GetFullDecision(ctx *Context, mcpClient *mcp.Client) (*FullDecision, error) {
// 1. 为所有币种获取市场数据
if err := fetchMarketDataForContext(ctx); err != nil {
return nil, fmt.Errorf("获取市场数据失败: %w", err)
@@ -101,7 +101,7 @@ func GetFullDecision(ctx *Context) (*FullDecision, error) {
userPrompt := buildUserPrompt(ctx)
// 3. 调用AI API使用 system + user prompt
aiResponse, err := mcp.CallWithMessages(systemPrompt, userPrompt)
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
if err != nil {
return nil, fmt.Errorf("调用AI API失败: %w", err)
}

2
go.sum
View File

@@ -74,6 +74,8 @@ github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=

View File

@@ -269,16 +269,20 @@ type Statistics struct {
// TradeOutcome 单笔交易结果
type TradeOutcome struct {
Symbol string `json:"symbol"` // 币种
Side string `json:"side"` // long/short
OpenPrice float64 `json:"open_price"` // 开仓价
ClosePrice float64 `json:"close_price"` // 平仓价
PnL float64 `json:"pn_l"` // 盈亏USDT
PnLPct float64 `json:"pn_l_pct"` // 盈亏百分比
Duration string `json:"duration"` // 持仓时长
OpenTime time.Time `json:"open_time"` // 开仓时间
CloseTime time.Time `json:"close_time"` // 平仓时间
WasStopLoss bool `json:"was_stop_loss"` // 是否止损
Symbol string `json:"symbol"` // 币种
Side string `json:"side"` // long/short
Quantity float64 `json:"quantity"` // 仓位数量
Leverage int `json:"leverage"` // 杠杆倍数
OpenPrice float64 `json:"open_price"` // 开仓价
ClosePrice float64 `json:"close_price"` // 平仓价
PositionValue float64 `json:"position_value"` // 仓位价值quantity × openPrice
MarginUsed float64 `json:"margin_used"` // 保证金使用positionValue / leverage
PnL float64 `json:"pn_l"` // 盈亏USDT
PnLPct float64 `json:"pn_l_pct"` // 盈亏百分比(相对保证金)
Duration string `json:"duration"` // 持仓时长
OpenTime time.Time `json:"open_time"` // 开仓时间
CloseTime time.Time `json:"close_time"` // 平仓时间
WasStopLoss bool `json:"was_stop_loss"` // 是否止损
}
// PerformanceAnalysis 交易表现分析
@@ -330,7 +334,45 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
// 追踪持仓状态symbol_side -> {side, openPrice, openTime, quantity, leverage}
openPositions := make(map[string]map[string]interface{})
// 遍历所有记录
// 为了避免开仓记录在窗口外导致匹配失败,需要先从所有历史记录中找出未平仓的持仓
// 获取更多历史记录来构建完整的持仓状态(使用更大的窗口)
allRecords, err := l.GetLatestRecords(lookbackCycles * 3) // 扩大3倍窗口
if err == nil && len(allRecords) > len(records) {
// 先从扩大的窗口中收集所有开仓记录
for _, record := range allRecords {
for _, action := range record.Decisions {
if !action.Success {
continue
}
symbol := action.Symbol
side := ""
if action.Action == "open_long" || action.Action == "close_long" {
side = "long"
} else if action.Action == "open_short" || action.Action == "close_short" {
side = "short"
}
posKey := symbol + "_" + side
switch action.Action {
case "open_long", "open_short":
// 记录开仓
openPositions[posKey] = map[string]interface{}{
"side": side,
"openPrice": action.Price,
"openTime": action.Timestamp,
"quantity": action.Quantity,
"leverage": action.Leverage,
}
case "close_long", "close_short":
// 移除已平仓记录
delete(openPositions, posKey)
}
}
}
}
// 遍历分析窗口内的记录,生成交易结果
for _, record := range records {
for _, action := range record.Decisions {
if !action.Success {
@@ -348,7 +390,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
switch action.Action {
case "open_long", "open_short":
// 记录开仓(包括数量和杠杆
// 更新开仓记录(可能已经在预填充时记录过了
openPositions[posKey] = map[string]interface{}{
"side": side,
"openPrice": action.Price,
@@ -358,7 +400,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
}
case "close_long", "close_short":
// 查找对应的开仓记录
// 查找对应的开仓记录(可能来自预填充或当前窗口)
if openPos, exists := openPositions[posKey]; exists {
openPrice := openPos["openPrice"].(float64)
openTime := openPos["openTime"].(time.Time)
@@ -366,42 +408,53 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
quantity := openPos["quantity"].(float64)
leverage := openPos["leverage"].(int)
// 计算盈亏百分比
pnlPct := 0.0
// 计算实际盈亏USDT
// 合约交易 PnL 计算quantity × 价格差
// 注意:杠杆不影响绝对盈亏,只影响保证金需求
var pnl float64
if side == "long" {
pnlPct = ((action.Price - openPrice) / openPrice) * 100
pnl = quantity * (action.Price - openPrice)
} else {
pnlPct = ((openPrice - action.Price) / openPrice) * 100
pnl = quantity * (openPrice - action.Price)
}
// 计算实际盈亏USDT
// PnL = 仓位价值 × 价格变化百分比 × 杠杆倍数
// 计算盈亏百分比(相对保证金
positionValue := quantity * openPrice
pnl := positionValue * (pnlPct / 100) * float64(leverage)
marginUsed := positionValue / float64(leverage)
pnlPct := 0.0
if marginUsed > 0 {
pnlPct = (pnl / marginUsed) * 100
}
// 记录交易结果
outcome := TradeOutcome{
Symbol: symbol,
Side: side,
OpenPrice: openPrice,
ClosePrice: action.Price,
PnL: pnl,
PnLPct: pnlPct,
Duration: action.Timestamp.Sub(openTime).String(),
OpenTime: openTime,
CloseTime: action.Timestamp,
Symbol: symbol,
Side: side,
Quantity: quantity,
Leverage: leverage,
OpenPrice: openPrice,
ClosePrice: action.Price,
PositionValue: positionValue,
MarginUsed: marginUsed,
PnL: pnl,
PnLPct: pnlPct,
Duration: action.Timestamp.Sub(openTime).String(),
OpenTime: openTime,
CloseTime: action.Timestamp,
}
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
analysis.TotalTrades++
// 分类交易盈利、亏损、持平避免将pnl=0算入亏损
if pnl > 0 {
analysis.WinningTrades++
analysis.AvgWin += pnl
} else {
} else if pnl < 0 {
analysis.LosingTrades++
analysis.AvgLoss += pnl
}
// pnl == 0 的交易不计入盈利也不计入亏损,但计入总交易数
// 更新币种统计
if _, exists := analysis.SymbolStats[symbol]; !exists {
@@ -414,7 +467,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
stats.TotalPnL += pnl
if pnl > 0 {
stats.WinningTrades++
} else {
} else if pnl < 0 {
stats.LosingTrades++
}
@@ -444,6 +497,9 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
// 注意totalLossAmount 是负数,所以取负号得到绝对值
if totalLossAmount != 0 {
analysis.ProfitFactor = totalWinAmount / (-totalLossAmount)
} else if totalWinAmount > 0 {
// 只有盈利没有亏损的情况,设置为一个很大的值表示完美策略
analysis.ProfitFactor = 999.0
}
}

View File

@@ -40,6 +40,7 @@ func (tm *TraderManager) AddTrader(cfg config.TraderConfig, coinPoolURL string,
BinanceAPIKey: cfg.BinanceAPIKey,
BinanceSecretKey: cfg.BinanceSecretKey,
HyperliquidPrivateKey: cfg.HyperliquidPrivateKey,
HyperliquidWalletAddr: cfg.HyperliquidWalletAddr,
HyperliquidTestnet: cfg.HyperliquidTestnet,
AsterUser: cfg.AsterUser,
AsterSigner: cfg.AsterSigner,

View File

@@ -19,71 +19,74 @@ const (
ProviderCustom Provider = "custom"
)
// Config AI API配置
type Config struct {
Provider Provider
APIKey string
SecretKey string // 阿里云需要
BaseURL string
Model string
Timeout time.Duration
// Client AI API配置
type Client struct {
Provider Provider
APIKey string
SecretKey string // 阿里云需要
BaseURL string
Model string
Timeout time.Duration
UseFullURL bool // 是否使用完整URL不添加/chat/completions
}
// 默认配置
var defaultConfig = Config{
Provider: ProviderDeepSeek,
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Timeout: 120 * time.Second, // 增加到120秒因为AI需要分析大量数据
func New() *Client {
// 默认配置
var defaultClient = Client{
Provider: ProviderDeepSeek,
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Timeout: 120 * time.Second, // 增加到120秒因为AI需要分析大量数据
}
return &defaultClient
}
// SetDeepSeekAPIKey 设置DeepSeek API密钥
func SetDeepSeekAPIKey(apiKey string) {
defaultConfig.Provider = ProviderDeepSeek
defaultConfig.APIKey = apiKey
defaultConfig.BaseURL = "https://api.deepseek.com/v1"
defaultConfig.Model = "deepseek-chat"
func (cfg *Client) SetDeepSeekAPIKey(apiKey string) {
cfg.Provider = ProviderDeepSeek
cfg.APIKey = apiKey
cfg.BaseURL = "https://api.deepseek.com/v1"
cfg.Model = "deepseek-chat"
}
// SetQwenAPIKey 设置阿里云Qwen API密钥
func SetQwenAPIKey(apiKey, secretKey string) {
defaultConfig.Provider = ProviderQwen
defaultConfig.APIKey = apiKey
defaultConfig.SecretKey = secretKey
defaultConfig.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
defaultConfig.Model = "qwen-plus" // 可选: qwen-turbo, qwen-plus, qwen-max
func (cfg *Client) SetQwenAPIKey(apiKey, secretKey string) {
cfg.Provider = ProviderQwen
cfg.APIKey = apiKey
cfg.SecretKey = secretKey
cfg.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
cfg.Model = "qwen-plus" // 可选: qwen-turbo, qwen-plus, qwen-max
}
// SetCustomAPI 设置自定义OpenAI兼容API
func SetCustomAPI(apiURL, apiKey, modelName string) {
defaultConfig.Provider = ProviderCustom
defaultConfig.APIKey = apiKey
func (cfg *Client) SetCustomAPI(apiURL, apiKey, modelName string) {
cfg.Provider = ProviderCustom
cfg.APIKey = apiKey
// 检查URL是否以#结尾如果是则使用完整URL不添加/chat/completions
if strings.HasSuffix(apiURL, "#") {
defaultConfig.BaseURL = strings.TrimSuffix(apiURL, "#")
defaultConfig.UseFullURL = true
cfg.BaseURL = strings.TrimSuffix(apiURL, "#")
cfg.UseFullURL = true
} else {
defaultConfig.BaseURL = apiURL
defaultConfig.UseFullURL = false
cfg.BaseURL = apiURL
cfg.UseFullURL = false
}
defaultConfig.Model = modelName
defaultConfig.Timeout = 120 * time.Second
cfg.Model = modelName
cfg.Timeout = 120 * time.Second
}
// SetConfig 设置完整的AI配置高级用户
func SetConfig(config Config) {
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
// SetClient 设置完整的AI配置高级用户
func (cfg *Client) SetClient(Client Client) {
if Client.Timeout == 0 {
Client.Timeout = 30 * time.Second
}
defaultConfig = config
cfg = &Client
}
// CallWithMessages 使用 system + user prompt 调用AI API推荐
func CallWithMessages(systemPrompt, userPrompt string) (string, error) {
if defaultConfig.APIKey == "" {
func (cfg *Client) CallWithMessages(systemPrompt, userPrompt string) (string, error) {
if cfg.APIKey == "" {
return "", fmt.Errorf("AI API密钥未设置请先调用 SetDeepSeekAPIKey() 或 SetQwenAPIKey()")
}
@@ -96,7 +99,7 @@ func CallWithMessages(systemPrompt, userPrompt string) (string, error) {
fmt.Printf("⚠️ AI API调用失败正在重试 (%d/%d)...\n", attempt, maxRetries)
}
result, err := callOnce(systemPrompt, userPrompt)
result, err := cfg.callOnce(systemPrompt, userPrompt)
if err == nil {
if attempt > 1 {
fmt.Printf("✓ AI API重试成功\n")
@@ -122,7 +125,7 @@ func CallWithMessages(systemPrompt, userPrompt string) (string, error) {
}
// callOnce 单次调用AI API内部使用
func callOnce(systemPrompt, userPrompt string) (string, error) {
func (cfg *Client) callOnce(systemPrompt, userPrompt string) (string, error) {
// 构建 messages 数组
messages := []map[string]string{}
@@ -142,7 +145,7 @@ func callOnce(systemPrompt, userPrompt string) (string, error) {
// 构建请求体
requestBody := map[string]interface{}{
"model": defaultConfig.Model,
"model": cfg.Model,
"messages": messages,
"temperature": 0.5, // 降低temperature以提高JSON格式稳定性
"max_tokens": 2000,
@@ -158,12 +161,12 @@ func callOnce(systemPrompt, userPrompt string) (string, error) {
// 创建HTTP请求
var url string
if defaultConfig.UseFullURL {
if cfg.UseFullURL {
// 使用完整URL不添加/chat/completions
url = defaultConfig.BaseURL
url = cfg.BaseURL
} else {
// 默认行为:添加/chat/completions
url = fmt.Sprintf("%s/chat/completions", defaultConfig.BaseURL)
url = fmt.Sprintf("%s/chat/completions", cfg.BaseURL)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
@@ -173,19 +176,19 @@ func callOnce(systemPrompt, userPrompt string) (string, error) {
req.Header.Set("Content-Type", "application/json")
// 根据不同的Provider设置认证方式
switch defaultConfig.Provider {
switch cfg.Provider {
case ProviderDeepSeek:
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", defaultConfig.APIKey))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.APIKey))
case ProviderQwen:
// 阿里云Qwen使用API-Key认证
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", defaultConfig.APIKey))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.APIKey))
// 注意:如果使用的不是兼容模式,可能需要不同的认证方式
default:
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", defaultConfig.APIKey))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.APIKey))
}
// 发送请求
client := &http.Client{Timeout: defaultConfig.Timeout}
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送请求失败: %w", err)

41
pm2.config.js Normal file
View File

@@ -0,0 +1,41 @@
const path = require('path');
module.exports = {
apps: [
{
name: 'nofx-backend',
script: './nofx',
cwd: __dirname, // 使用当前目录(配置文件所在目录)
interpreter: 'none', // 不使用解释器,直接执行二进制文件
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production'
},
error_file: './logs/backend-error.log',
out_file: './logs/backend-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true
},
{
name: 'nofx-frontend',
script: 'npm',
args: 'run dev',
cwd: path.join(__dirname, 'web'), // 动态拼接 web 目录
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
NODE_ENV: 'development',
PORT: 3000
},
error_file: './logs/frontend-error.log',
out_file: './logs/frontend-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true
}
]
};

258
pm2.sh Executable file
View File

@@ -0,0 +1,258 @@
#!/bin/bash
# NoFX Trading Bot - PM2 管理脚本
# 用法: ./pm2.sh [start|stop|restart|status|logs|build]
set -e
# 自动获取脚本所在目录(支持符号链接)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 函数:打印带颜色的消息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_header() {
echo -e "${PURPLE}═══════════════════════════════════════${NC}"
echo -e "${PURPLE} 🤖 NoFX Trading Bot - PM2 Manager${NC}"
echo -e "${PURPLE}═══════════════════════════════════════${NC}"
echo ""
}
# 函数:检查 PM2 是否安装
check_pm2() {
if ! command -v pm2 &> /dev/null; then
print_error "PM2 未安装,请先安装: npm install -g pm2"
exit 1
fi
}
# 函数:确保日志目录存在
ensure_log_dirs() {
mkdir -p "$PROJECT_ROOT/logs"
mkdir -p "$PROJECT_ROOT/web/logs"
print_info "日志目录已创建"
}
# 函数:编译后端
build_backend() {
print_info "正在编译后端..."
go build -o nofx
if [ $? -eq 0 ]; then
print_success "后端编译完成"
else
print_error "后端编译失败"
exit 1
fi
}
# 函数:构建前端(生产环境)
build_frontend() {
print_info "正在构建前端..."
cd web
npm run build
if [ $? -eq 0 ]; then
print_success "前端构建完成"
cd ..
else
print_error "前端构建失败"
exit 1
fi
}
# 函数:启动服务
start_services() {
print_header
ensure_log_dirs
# 检查后端二进制文件是否存在
if [ ! -f "./nofx" ]; then
print_warning "后端二进制文件不存在,开始编译..."
build_backend
fi
print_info "正在启动服务..."
pm2 start pm2.config.js
sleep 2
pm2 status
echo ""
print_success "服务启动完成!"
echo ""
echo -e "${CYAN}📊 访问地址:${NC}"
echo -e " ${GREEN}前端:${NC} http://localhost:3000"
echo -e " ${GREEN}后端 API:${NC} http://localhost:8080"
echo ""
echo -e "${CYAN}📝 查看日志:${NC}"
echo -e " ${GREEN}实时日志:${NC} ./pm2.sh logs"
echo -e " ${GREEN}后端日志:${NC} ./pm2.sh logs backend"
echo -e " ${GREEN}前端日志:${NC} ./pm2.sh logs frontend"
echo ""
}
# 函数:停止服务
stop_services() {
print_header
print_info "正在停止服务..."
pm2 stop pm2.config.js
print_success "服务已停止"
}
# 函数:重启服务
restart_services() {
print_header
print_info "正在重启服务..."
pm2 restart pm2.config.js
sleep 2
pm2 status
print_success "服务已重启"
}
# 函数:删除服务
delete_services() {
print_header
print_warning "正在删除 PM2 服务..."
pm2 delete pm2.config.js || true
print_success "PM2 服务已删除"
}
# 函数:查看状态
show_status() {
print_header
pm2 status
echo ""
print_info "详细信息:"
pm2 info nofx-backend
echo ""
pm2 info nofx-frontend
}
# 函数:查看日志
show_logs() {
if [ -z "$2" ]; then
# 显示所有日志
pm2 logs
elif [ "$2" = "backend" ]; then
pm2 logs nofx-backend
elif [ "$2" = "frontend" ]; then
pm2 logs nofx-frontend
else
print_error "未知的日志类型: $2"
print_info "用法: ./pm2.sh logs [backend|frontend]"
exit 1
fi
}
# 函数:监控
show_monitor() {
print_header
print_info "启动 PM2 监控面板..."
pm2 monit
}
# 函数:重新编译并重启
rebuild_and_restart() {
print_header
print_info "正在重新编译后端..."
build_backend
print_info "正在重启后端服务..."
pm2 restart nofx-backend
sleep 2
pm2 status
print_success "后端已重新编译并重启"
}
# 函数:显示帮助
show_help() {
print_header
echo -e "${CYAN}使用方法:${NC}"
echo " ./pm2.sh [command]"
echo ""
echo -e "${CYAN}可用命令:${NC}"
echo -e " ${GREEN}start${NC} - 启动前后端服务"
echo -e " ${GREEN}stop${NC} - 停止所有服务"
echo -e " ${GREEN}restart${NC} - 重启所有服务"
echo -e " ${GREEN}status${NC} - 查看服务状态"
echo -e " ${GREEN}logs${NC} - 查看所有日志 (Ctrl+C 退出)"
echo -e " ${GREEN}logs backend${NC} - 查看后端日志"
echo -e " ${GREEN}logs frontend${NC} - 查看前端日志"
echo -e " ${GREEN}monitor${NC} - 打开 PM2 监控面板"
echo -e " ${GREEN}build${NC} - 编译后端"
echo -e " ${GREEN}rebuild${NC} - 重新编译后端并重启"
echo -e " ${GREEN}delete${NC} - 删除 PM2 服务"
echo -e " ${GREEN}help${NC} - 显示此帮助信息"
echo ""
echo -e "${CYAN}示例:${NC}"
echo " ./pm2.sh start # 启动服务"
echo " ./pm2.sh logs backend # 查看后端日志"
echo " ./pm2.sh rebuild # 重新编译后端并重启"
echo ""
}
# 主逻辑
check_pm2
case "${1:-help}" in
start)
start_services
;;
stop)
stop_services
;;
restart)
restart_services
;;
status)
show_status
;;
logs)
show_logs "$@"
;;
monitor|mon)
show_monitor
;;
build)
build_backend
;;
rebuild)
rebuild_and_restart
;;
delete|remove)
delete_services
;;
help|--help|-h)
show_help
;;
*)
print_error "未知命令: $1"
echo ""
show_help
exit 1
;;
esac

View File

@@ -29,6 +29,7 @@ type AutoTraderConfig struct {
// Hyperliquid配置
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
// Aster配置
@@ -66,21 +67,22 @@ type AutoTraderConfig struct {
// AutoTrader 自动交易器
type AutoTrader struct {
id string // Trader唯一标识
name string // Trader显示名称
aiModel string // AI模型名称
exchange string // 交易平台名称
config AutoTraderConfig
trader Trader // 使用Trader接口支持多平台
decisionLogger *logger.DecisionLogger // 决策日志记录器
initialBalance float64
dailyPnL float64
lastResetTime time.Time
stopUntil time.Time
isRunning bool
startTime time.Time // 系统启动时间
callCount int // AI调用次数
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
id string // Trader唯一标识
name string // Trader显示名称
aiModel string // AI模型名称
exchange string // 交易平台名称
config AutoTraderConfig
trader Trader // 使用Trader接口支持多平台
mcpClient *mcp.Client
decisionLogger *logger.DecisionLogger // 决策日志记录器
initialBalance float64
dailyPnL float64
lastResetTime time.Time
stopUntil time.Time
isRunning bool
startTime time.Time // 系统启动时间
callCount int // AI调用次数
positionFirstSeenTime map[string]int64 // 持仓首次出现时间 (symbol_side -> timestamp毫秒)
}
// NewAutoTrader 创建自动交易器
@@ -100,18 +102,20 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
}
}
mcpClient := mcp.New()
// 初始化AI
if config.AIModel == "custom" {
// 使用自定义API
mcp.SetCustomAPI(config.CustomAPIURL, config.CustomAPIKey, config.CustomModelName)
mcpClient.SetCustomAPI(config.CustomAPIURL, config.CustomAPIKey, config.CustomModelName)
log.Printf("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName)
} else if config.UseQwen || config.AIModel == "qwen" {
// 使用Qwen
mcp.SetQwenAPIKey(config.QwenKey, "")
mcpClient.SetQwenAPIKey(config.QwenKey, "")
log.Printf("🤖 [%s] 使用阿里云Qwen AI", config.Name)
} else {
// 默认使用DeepSeek
mcp.SetDeepSeekAPIKey(config.DeepSeekKey)
mcpClient.SetDeepSeekAPIKey(config.DeepSeekKey)
log.Printf("🤖 [%s] 使用DeepSeek AI", config.Name)
}
@@ -135,7 +139,7 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey)
case "hyperliquid":
log.Printf("🏦 [%s] 使用Hyperliquid交易", config.Name)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidTestnet)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
if err != nil {
return nil, fmt.Errorf("初始化Hyperliquid交易器失败: %w", err)
}
@@ -159,18 +163,19 @@ func NewAutoTrader(config AutoTraderConfig) (*AutoTrader, error) {
decisionLogger := logger.NewDecisionLogger(logDir)
return &AutoTrader{
id: config.ID,
name: config.Name,
aiModel: config.AIModel,
exchange: config.Exchange,
config: config,
trader: trader,
decisionLogger: decisionLogger,
initialBalance: config.InitialBalance,
lastResetTime: time.Now(),
startTime: time.Now(),
callCount: 0,
isRunning: false,
id: config.ID,
name: config.Name,
aiModel: config.AIModel,
exchange: config.Exchange,
config: config,
trader: trader,
mcpClient: mcpClient,
decisionLogger: decisionLogger,
initialBalance: config.InitialBalance,
lastResetTime: time.Now(),
startTime: time.Now(),
callCount: 0,
isRunning: false,
positionFirstSeenTime: make(map[string]int64),
}, nil
}
@@ -282,7 +287,7 @@ func (at *AutoTrader) runCycle() error {
// 4. 调用AI获取完整决策
log.Println("🤖 正在请求AI分析并决策...")
decision, err := decision.GetFullDecision(ctx)
decision, err := decision.GetFullDecision(ctx, at.mcpClient)
// 即使有错误也保存思维链、决策和输入prompt用于debug
if decision != nil {
@@ -505,8 +510,9 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
marginUsedPct = (totalMarginUsed / totalEquity) * 100
}
// 5. 分析历史表现(最近20个周期
performance, err := at.decisionLogger.AnalyzePerformance(20)
// 5. 分析历史表现(最近100个周期,避免长期持仓的交易记录丢失
// 假设每3分钟一个周期100个周期 = 5小时足够覆盖大部分交易
performance, err := at.decisionLogger.AnalyzePerformance(100)
if err != nil {
log.Printf("⚠️ 分析历史表现失败: %v", err)
// 不影响主流程继续执行但设置performance为nil以避免传递错误数据
@@ -515,11 +521,11 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
// 6. 构建上下文
ctx := &decision.Context{
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount,
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
CallCount: at.callCount,
BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数
AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数
Account: decision.AccountInfo{
TotalEquity: totalEquity,
AvailableBalance: availableBalance,

View File

@@ -2,7 +2,6 @@ package trader
import (
"context"
"crypto/ecdsa"
"fmt"
"log"
"strconv"
@@ -20,7 +19,7 @@ type HyperliquidTrader struct {
}
// NewHyperliquidTrader 创建Hyperliquid交易器
func NewHyperliquidTrader(privateKeyHex string, testnet bool) (*HyperliquidTrader, error) {
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
// 解析私钥
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
@@ -33,13 +32,13 @@ func NewHyperliquidTrader(privateKeyHex string, testnet bool) (*HyperliquidTrade
apiURL = hyperliquid.TestnetAPIURL
}
// 从私钥生成钱包地址
pubKey := privateKey.Public()
publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("无法转换公钥")
}
walletAddr := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
// // 从私钥生成钱包地址
// pubKey := privateKey.Public()
// publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
// if !ok {
// return nil, fmt.Errorf("无法转换公钥")
// }
// walletAddr := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
ctx := context.Background()
@@ -99,9 +98,9 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// 钱包余额(已实现)= AccountValue - 未实现盈亏
walletBalance := accountValue - totalUnrealizedPnl
result["totalWalletBalance"] = walletBalance // 钱包余额(已实现部分)
result["availableBalance"] = accountValue - totalMarginUsed // 可用余额
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未实现盈亏
result["totalWalletBalance"] = walletBalance // 钱包余额(已实现部分)
result["availableBalance"] = accountValue - totalMarginUsed // 可用余额
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未实现盈亏
log.Printf("✓ Hyperliquid API返回: 账户净值=%.2f, 钱包余额=%.2f, 可用=%.2f, 未实现盈亏=%.2f",
accountValue,
@@ -515,8 +514,8 @@ func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quan
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // 使用四舍五入后的数量
Price: roundedStopPrice, // 使用处理后的价格
Size: roundedQuantity, // 使用四舍五入后的数量
Price: roundedStopPrice, // 使用处理后的价格
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedStopPrice,
@@ -552,8 +551,8 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity, // 使用四舍五入后的数量
Price: roundedTakeProfitPrice, // 使用处理后的价格
Size: roundedQuantity, // 使用四舍五入后的数量
Price: roundedTakeProfitPrice, // 使用处理后的价格
OrderType: hyperliquid.OrderType{
Trigger: &hyperliquid.TriggerOrderType{
TriggerPx: roundedTakeProfitPrice,
@@ -577,7 +576,7 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu
func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
coin := convertSymbolToHyperliquid(symbol)
szDecimals := t.getSzDecimals(coin)
// 使用szDecimals格式化数量
formatStr := fmt.Sprintf("%%.%df", szDecimals)
return fmt.Sprintf(formatStr, quantity), nil
@@ -604,13 +603,13 @@ func (t *HyperliquidTrader) getSzDecimals(coin string) int {
// roundToSzDecimals 将数量四舍五入到正确的精度
func (t *HyperliquidTrader) roundToSzDecimals(coin string, quantity float64) float64 {
szDecimals := t.getSzDecimals(coin)
// 计算倍数10^szDecimals
multiplier := 1.0
for i := 0; i < szDecimals; i++ {
multiplier *= 10.0
}
// 四舍五入
return float64(int(quantity*multiplier+0.5)) / multiplier
}
@@ -621,9 +620,9 @@ func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 {
if price == 0 {
return 0
}
const sigfigs = 5 // Hyperliquid标准5位有效数字
// 计算价格的数量级
var magnitude float64
if price < 0 {
@@ -631,7 +630,7 @@ func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 {
} else {
magnitude = price
}
// 计算需要的倍数
multiplier := 1.0
for magnitude >= 10 {
@@ -642,12 +641,12 @@ func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 {
magnitude *= 10
multiplier *= 10
}
// 应用有效数字精度
for i := 0; i < sigfigs-1; i++ {
multiplier *= 10
}
// 四舍五入
rounded := float64(int(price*multiplier+0.5)) / multiplier
return rounded

View File

@@ -6,8 +6,12 @@ import { api } from '../lib/api';
interface TradeOutcome {
symbol: string;
side: string;
quantity: number;
leverage: number;
open_price: number;
close_price: number;
position_value: number;
margin_used: number;
pn_l: number;
pn_l_pct: number;
duration: string;
@@ -558,6 +562,34 @@ export default function AILearning({ traderId }: AILearningProps) {
</div>
</div>
{/* Position Details */}
<div className="grid grid-cols-2 gap-2 mb-3 text-xs">
<div>
<div style={{ color: '#94A3B8' }}>Quantity</div>
<div className="font-mono font-semibold" style={{ color: '#CBD5E1' }}>
{trade.quantity ? trade.quantity.toFixed(4) : '-'}
</div>
</div>
<div className="text-right">
<div style={{ color: '#94A3B8' }}>Leverage</div>
<div className="font-mono font-semibold" style={{ color: '#FCD34D' }}>
{trade.leverage ? `${trade.leverage}x` : '-'}
</div>
</div>
<div>
<div style={{ color: '#94A3B8' }}>Position Value</div>
<div className="font-mono font-semibold" style={{ color: '#CBD5E1' }}>
{trade.position_value ? `$${trade.position_value.toFixed(2)}` : '-'}
</div>
</div>
<div className="text-right">
<div style={{ color: '#94A3B8' }}>Margin Used</div>
<div className="font-mono font-semibold" style={{ color: '#A78BFA' }}>
{trade.margin_used ? `$${trade.margin_used.toFixed(2)}` : '-'}
</div>
</div>
</div>
<div className="rounded-lg p-2 mb-2" style={{
background: isProfitable ? 'rgba(16, 185, 129, 0.1)' : 'rgba(248, 113, 113, 0.1)'
}}>

View File

@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {