mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
Refactor/trading actions (#1169)
* refactor: 简化交易动作,移除 update_stop_loss/update_take_profit/partial_close - 移除 Decision 结构体中的 NewStopLoss, NewTakeProfit, ClosePercentage 字段 - 删除 executeUpdateStopLossWithRecord, executeUpdateTakeProfitWithRecord, executePartialCloseWithRecord 函数 - 简化 logger 中的 partial_close 聚合逻辑 - 更新 AI prompt 和验证逻辑,只保留 6 个核心动作 - 清理相关测试代码 保留的交易动作: open_long, open_short, close_long, close_short, hold, wait * refactor: 移除 AI学习与反思 模块 - 删除前端 AILearning.tsx 组件和相关引用 - 删除后端 /performance API 接口 - 删除 logger 中 AnalyzePerformance、calculateSharpeRatio 等函数 - 删除 PerformanceAnalysis、TradeOutcome、SymbolPerformance 等结构体 - 删除 Context 中的 Performance 字段 - 移除 AI prompt 中夏普比率自我进化相关内容 - 清理 i18n 翻译文件中的相关条目 该模块基于磁盘存储计算,经常出错,做减法移除 * refactor: 将数据库操作统一迁移到 store 包 - 新增 store/ 包,统一管理所有数据库操作 - store.go: 主 Store 结构,懒加载各子模块 - user.go, ai_model.go, exchange.go, trader.go 等子模块 - 支持加密/解密函数注入 (SetCryptoFuncs) - 更新 main.go 使用 store.New() 替代 config.NewDatabase() - 更新 api/server.go 使用 *store.Store 替代 *config.Database - 更新 manager/trader_manager.go: - 新增 LoadTradersFromStore, LoadUserTradersFromStore 方法 - 删除旧版 LoadUserTraders, LoadTraderByID, loadSingleTrader 等方法 - 移除 nofx/config 依赖 - 删除 config/database.go 和 config/database_test.go - 更新 api/server_test.go 使用 store.Trader 类型 - 清理 logger/ 包中未使用的 telegram 相关代码 * refactor: unify encryption key management via .env - Remove redundant EncryptionManager and SecureStorage - Simplify CryptoService to load keys from environment variables only - RSA_PRIVATE_KEY: RSA private key for client-server encryption - DATA_ENCRYPTION_KEY: AES-256 key for database encryption - JWT_SECRET: JWT signing key for authentication - Update start.sh to auto-generate missing keys on first run - Remove secrets/ directory and file-based key storage - Delete obsolete encryption setup scripts - Update .env.example with all required keys * refactor: unify logger usage across mcp package - Add MCPLogger adapter in logger package to implement mcp.Logger interface - Update mcp/config.go to use global logger by default - Remove redundant defaultLogger from mcp/logger.go - Keep noopLogger for testing purposes * chore: remove leftover test RSA key file * chore: remove unused bootstrap package * refactor: unify logging to use logger package instead of fmt/log - Replace all fmt.Print/log.Print calls with logger package - Add auto-initialization in logger package init() for test compatibility - Update main.go to initialize logger at startup - Migrate all packages: api, backtest, config, decision, manager, market, store, trader * refactor: rename database file from config.db to data.db - Update main.go, start.sh, docker-compose.yml - Update migration script and documentation - Update .gitignore and translations * fix: add RSA_PRIVATE_KEY to docker-compose environment * fix: add registration_enabled to /api/config response * fix: Fix navigation between login and register pages Use window.location.href instead of react-router's navigate() to fix the issue where URL changes but the page doesn't reload due to App.tsx using custom route state management. * fix: Switch SQLite from WAL to DELETE mode for Docker compatibility WAL mode causes data sync issues with Docker bind mounts on macOS due to incompatible file locking mechanisms between the container and host. DELETE mode (traditional journaling) ensures data is written directly to the main database file. * refactor: Remove default user from database initialization The default user was a legacy placeholder that is no longer needed now that proper user registration is in place. * feat: Add order tracking system with centralized status sync - Add trader_orders table for tracking all order lifecycle - Implement GetOrderStatus interface for all exchanges (Binance, Bybit, Hyperliquid, Aster, Lighter) - Create OrderSyncManager for centralized order status polling - Add trading statistics (Sharpe ratio, win rate, profit factor) to AI context - Include recent completed orders in AI decision input - Remove per-order goroutine polling in favor of global sync manager * feat: Add TradingView K-line chart to dashboard - Create TradingViewChart component with exchange/symbol selectors - Support Binance, Bybit, OKX, Coinbase, Kraken, KuCoin exchanges - Add popular symbols quick selection - Support multiple timeframes (1m to 1W) - Add fullscreen mode - Integrate with Dashboard page below equity chart - Add i18n translations for zh/en * refactor: Replace separate charts with tabbed ChartTabs component - Create ChartTabs component with tab switching between equity curve and K-line - Add embedded mode support for EquityChart and TradingViewChart - User can now switch between account equity and market chart in same area * fix: Use ChartTabs in App.tsx and fix embedded mode in EquityChart - Replace EquityChart with ChartTabs in App.tsx (the actual dashboard renderer) - Fix EquityChart embedded mode for error and empty data states - Rename interval state to timeInterval to avoid shadowing window.setInterval - Add debug logging to ChartTabs component * feat: Add position tracking system for accurate trade history - Add trader_positions table to track complete open/close trades - Add PositionSyncManager to detect manual closes via polling - Record position on open, update on close with PnL calculation - Use positions table for trading stats and recent trades (replacing orders table) - Fix TradingView chart symbol format (add .P suffix for futures) - Fix DecisionCard wait/hold action color (gray instead of red) - Auto-append USDT suffix for custom symbol input * update ---------
This commit is contained in:
@@ -1,21 +1,8 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Config 日志配置(简化版)
|
||||
type Config struct {
|
||||
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
|
||||
Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选)
|
||||
}
|
||||
|
||||
// TelegramConfig Telegram推送配置(简化版,高级参数使用默认值)
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled"` // 是否启用(默认: false)
|
||||
BotToken string `json:"bot_token"` // Bot Token
|
||||
ChatID int64 `json:"chat_id"` // Chat ID
|
||||
MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error)
|
||||
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
|
||||
}
|
||||
|
||||
// SetDefaults 设置默认值
|
||||
@@ -24,41 +11,3 @@ func (c *Config) SetDefaults() {
|
||||
c.Level = "info"
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogrusLevels 返回要推送到Telegram的日志级别
|
||||
// 根据配置的MinLevel返回该级别及以上的所有日志级别
|
||||
// 如果未配置或配置无效,默认返回error, fatal, panic(向后兼容)
|
||||
func (tc *TelegramConfig) GetLogrusLevels() []logrus.Level {
|
||||
// 如果未配置,使用默认值error(向后兼容)
|
||||
minLevelStr := tc.MinLevel
|
||||
if minLevelStr == "" {
|
||||
minLevelStr = "error"
|
||||
}
|
||||
|
||||
// 解析配置的日志级别
|
||||
minLevel, err := logrus.ParseLevel(minLevelStr)
|
||||
if err != nil {
|
||||
// 如果解析失败,使用默认值error(向后兼容)
|
||||
minLevel = logrus.ErrorLevel
|
||||
}
|
||||
|
||||
// 定义所有日志级别(从高到低:panic, fatal, error, warn, info, debug)
|
||||
allLevels := []logrus.Level{
|
||||
logrus.PanicLevel,
|
||||
logrus.FatalLevel,
|
||||
logrus.ErrorLevel,
|
||||
logrus.WarnLevel,
|
||||
logrus.InfoLevel,
|
||||
logrus.DebugLevel,
|
||||
}
|
||||
|
||||
// 返回所有大于等于minLevel的日志级别
|
||||
var result []logrus.Level
|
||||
for _, level := range allLevels {
|
||||
if level <= minLevel {
|
||||
result = append(result, level)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"traders": [
|
||||
{
|
||||
"id": "trader1",
|
||||
"name": "AI Trader 1",
|
||||
"enabled": true,
|
||||
"ai_model": "deepseek",
|
||||
"exchange": "binance",
|
||||
"binance_api_key": "your_api_key",
|
||||
"binance_secret_key": "your_secret_key",
|
||||
"deepseek_key": "your_deepseek_key",
|
||||
"initial_balance": 1000,
|
||||
"scan_interval_minutes": 3
|
||||
}
|
||||
],
|
||||
"use_default_coins": true,
|
||||
"default_coins": ["BTCUSDT", "ETHUSDT", "SOLUSDT"],
|
||||
"api_server_port": 8080,
|
||||
"leverage": {
|
||||
"btc_eth_leverage": 5,
|
||||
"altcoin_leverage": 5
|
||||
},
|
||||
"log": {
|
||||
"level": "info",
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"bot_token": "79472419:feafe231414",
|
||||
"chat_id": -100323252626,
|
||||
"min_level": "error"
|
||||
}
|
||||
},
|
||||
"_comment": "日志配置说明:level 可选值为 debug/info/warn/error,默认 info。telegram 部分作为可选配置, Telegram 推送默认为 error/fatal/panic 级别,min_level 如果设置为warn,则推送warn级别及以上的日志"
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DecisionRecord 决策记录
|
||||
type DecisionRecord struct {
|
||||
Timestamp time.Time `json:"timestamp"` // 决策时间
|
||||
CycleNumber int `json:"cycle_number"` // 周期编号
|
||||
SystemPrompt string `json:"system_prompt"` // 系统提示词(发送给AI的系统prompt)
|
||||
InputPrompt string `json:"input_prompt"` // 发送给AI的输入prompt
|
||||
CoTTrace string `json:"cot_trace"` // AI思维链(输出)
|
||||
DecisionJSON string `json:"decision_json"` // 决策JSON
|
||||
AccountState AccountSnapshot `json:"account_state"` // 账户状态快照
|
||||
Positions []PositionSnapshot `json:"positions"` // 持仓快照
|
||||
CandidateCoins []string `json:"candidate_coins"` // 候选币种列表
|
||||
Decisions []DecisionAction `json:"decisions"` // 执行的决策
|
||||
ExecutionLog []string `json:"execution_log"` // 执行日志
|
||||
Success bool `json:"success"` // 是否成功
|
||||
ErrorMessage string `json:"error_message"` // 错误信息(如果有)
|
||||
// AIRequestDurationMs 记录 AI API 调用耗时(毫秒),方便评估调用性能
|
||||
AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// AccountSnapshot 账户状态快照
|
||||
type AccountSnapshot struct {
|
||||
TotalBalance float64 `json:"total_balance"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
TotalUnrealizedProfit float64 `json:"total_unrealized_profit"`
|
||||
PositionCount int `json:"position_count"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
InitialBalance float64 `json:"initial_balance"` // 记录当时的初始余额基准
|
||||
}
|
||||
|
||||
// PositionSnapshot 持仓快照
|
||||
type PositionSnapshot struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionAmt float64 `json:"position_amt"`
|
||||
EntryPrice float64 `json:"entry_price"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
UnrealizedProfit float64 `json:"unrealized_profit"`
|
||||
Leverage float64 `json:"leverage"`
|
||||
LiquidationPrice float64 `json:"liquidation_price"`
|
||||
}
|
||||
|
||||
// DecisionAction 决策动作
|
||||
type DecisionAction struct {
|
||||
Action string `json:"action"` // open_long, open_short, close_long, close_short, update_stop_loss, update_take_profit, partial_close
|
||||
Symbol string `json:"symbol"` // 币种
|
||||
Quantity float64 `json:"quantity"` // 数量(部分平仓时使用)
|
||||
Leverage int `json:"leverage"` // 杠杆(开仓时)
|
||||
Price float64 `json:"price"` // 执行价格
|
||||
OrderID int64 `json:"order_id"` // 订单ID
|
||||
Timestamp time.Time `json:"timestamp"` // 执行时间
|
||||
Success bool `json:"success"` // 是否成功
|
||||
Error string `json:"error"` // 错误信息
|
||||
}
|
||||
|
||||
// IDecisionLogger 决策日志记录器接口
|
||||
type IDecisionLogger interface {
|
||||
// LogDecision 记录决策
|
||||
LogDecision(record *DecisionRecord) error
|
||||
// GetLatestRecords 获取最近N条记录(按时间正序:从旧到新)
|
||||
GetLatestRecords(n int) ([]*DecisionRecord, error)
|
||||
// GetRecordByDate 获取指定日期的所有记录
|
||||
GetRecordByDate(date time.Time) ([]*DecisionRecord, error)
|
||||
// CleanOldRecords 清理N天前的旧记录
|
||||
CleanOldRecords(days int) error
|
||||
// GetStatistics 获取统计信息
|
||||
GetStatistics() (*Statistics, error)
|
||||
// AnalyzePerformance 分析最近N个周期的交易表现
|
||||
AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error)
|
||||
// SetCycleNumber 允许恢复内部计数(用于回测恢复)
|
||||
SetCycleNumber(n int)
|
||||
}
|
||||
|
||||
// DecisionLogger 决策日志记录器
|
||||
type DecisionLogger struct {
|
||||
logDir string
|
||||
cycleNumber int
|
||||
}
|
||||
|
||||
// NewDecisionLogger 创建决策日志记录器
|
||||
func NewDecisionLogger(logDir string) IDecisionLogger {
|
||||
if logDir == "" {
|
||||
logDir = "decision_logs"
|
||||
}
|
||||
|
||||
// 确保日志目录存在(使用安全权限:只有所有者可访问)
|
||||
if err := os.MkdirAll(logDir, 0700); err != nil {
|
||||
fmt.Printf("⚠ 创建日志目录失败: %v\n", err)
|
||||
}
|
||||
|
||||
// 强制设置目录权限(即使目录已存在)- 确保安全
|
||||
if err := os.Chmod(logDir, 0700); err != nil {
|
||||
fmt.Printf("⚠ 设置日志目录权限失败: %v\n", err)
|
||||
}
|
||||
|
||||
return &DecisionLogger{
|
||||
logDir: logDir,
|
||||
cycleNumber: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCycleNumber 允许外部恢复内部的周期计数(用于回测恢复)。
|
||||
func (l *DecisionLogger) SetCycleNumber(n int) {
|
||||
if n > 0 {
|
||||
l.cycleNumber = n
|
||||
}
|
||||
}
|
||||
|
||||
// LogDecision 记录决策
|
||||
func (l *DecisionLogger) LogDecision(record *DecisionRecord) error {
|
||||
l.cycleNumber++
|
||||
record.CycleNumber = l.cycleNumber
|
||||
if record.Timestamp.IsZero() {
|
||||
record.Timestamp = time.Now().UTC()
|
||||
} else {
|
||||
record.Timestamp = record.Timestamp.UTC()
|
||||
}
|
||||
|
||||
// 生成文件名:decision_YYYYMMDD_HHMMSS_cycleN.json
|
||||
filename := fmt.Sprintf("decision_%s_cycle%d.json",
|
||||
record.Timestamp.Format("20060102_150405"),
|
||||
record.CycleNumber)
|
||||
|
||||
filepath := filepath.Join(l.logDir, filename)
|
||||
|
||||
// 序列化为JSON(带缩进,方便阅读)
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化决策记录失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入文件(使用安全权限:只有所有者可读写)
|
||||
if err := ioutil.WriteFile(filepath, data, 0600); err != nil {
|
||||
return fmt.Errorf("写入决策记录失败: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📝 决策记录已保存: %s\n", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLatestRecords 获取最近N条记录(按时间正序:从旧到新)
|
||||
func (l *DecisionLogger) GetLatestRecords(n int) ([]*DecisionRecord, error) {
|
||||
files, err := ioutil.ReadDir(l.logDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取日志目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 先按修改时间倒序收集(最新的在前)
|
||||
var records []*DecisionRecord
|
||||
count := 0
|
||||
for i := len(files) - 1; i >= 0 && count < n; i-- {
|
||||
file := files[i]
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filepath := filepath.Join(l.logDir, file.Name())
|
||||
data, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var record DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, &record)
|
||||
count++
|
||||
}
|
||||
|
||||
// 反转数组,让时间从旧到新排列(用于图表显示)
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetRecordByDate 获取指定日期的所有记录
|
||||
func (l *DecisionLogger) GetRecordByDate(date time.Time) ([]*DecisionRecord, error) {
|
||||
dateStr := date.Format("20060102")
|
||||
pattern := filepath.Join(l.logDir, fmt.Sprintf("decision_%s_*.json", dateStr))
|
||||
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找日志文件失败: %w", err)
|
||||
}
|
||||
|
||||
var records []*DecisionRecord
|
||||
for _, filepath := range files {
|
||||
data, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var record DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, &record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CleanOldRecords 清理N天前的旧记录
|
||||
func (l *DecisionLogger) CleanOldRecords(days int) error {
|
||||
cutoffTime := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
files, err := ioutil.ReadDir(l.logDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取日志目录失败: %w", err)
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if file.ModTime().Before(cutoffTime) {
|
||||
filepath := filepath.Join(l.logDir, file.Name())
|
||||
if err := os.Remove(filepath); err != nil {
|
||||
fmt.Printf("⚠ 删除旧记录失败 %s: %v\n", file.Name(), err)
|
||||
continue
|
||||
}
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if removedCount > 0 {
|
||||
fmt.Printf("🗑️ 已清理 %d 条旧记录(%d天前)\n", removedCount, days)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatistics 获取统计信息
|
||||
func (l *DecisionLogger) GetStatistics() (*Statistics, error) {
|
||||
files, err := ioutil.ReadDir(l.logDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取日志目录失败: %w", err)
|
||||
}
|
||||
|
||||
stats := &Statistics{}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filepath := filepath.Join(l.logDir, file.Name())
|
||||
data, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var record DecisionRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stats.TotalCycles++
|
||||
|
||||
for _, action := range record.Decisions {
|
||||
if action.Success {
|
||||
switch action.Action {
|
||||
case "open_long", "open_short":
|
||||
stats.TotalOpenPositions++
|
||||
case "close_long", "close_short", "auto_close_long", "auto_close_short":
|
||||
stats.TotalClosePositions++
|
||||
// 🔧 BUG FIX:partial_close 不計入 TotalClosePositions,避免重複計數
|
||||
// case "partial_close": // 不計數,因為只有完全平倉才算一次
|
||||
// update_stop_loss 和 update_take_profit 不計入統計
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if record.Success {
|
||||
stats.SuccessfulCycles++
|
||||
} else {
|
||||
stats.FailedCycles++
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Statistics 统计信息
|
||||
type Statistics struct {
|
||||
TotalCycles int `json:"total_cycles"`
|
||||
SuccessfulCycles int `json:"successful_cycles"`
|
||||
FailedCycles int `json:"failed_cycles"`
|
||||
TotalOpenPositions int `json:"total_open_positions"`
|
||||
TotalClosePositions int `json:"total_close_positions"`
|
||||
}
|
||||
|
||||
// TradeOutcome 单笔交易结果
|
||||
type TradeOutcome struct {
|
||||
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 交易表现分析
|
||||
type PerformanceAnalysis struct {
|
||||
TotalTrades int `json:"total_trades"` // 总交易数
|
||||
WinningTrades int `json:"winning_trades"` // 盈利交易数
|
||||
LosingTrades int `json:"losing_trades"` // 亏损交易数
|
||||
WinRate float64 `json:"win_rate"` // 胜率
|
||||
AvgWin float64 `json:"avg_win"` // 平均盈利
|
||||
AvgLoss float64 `json:"avg_loss"` // 平均亏损
|
||||
ProfitFactor float64 `json:"profit_factor"` // 盈亏比
|
||||
SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比率(风险调整后收益)
|
||||
RecentTrades []TradeOutcome `json:"recent_trades"` // 最近N笔交易
|
||||
SymbolStats map[string]*SymbolPerformance `json:"symbol_stats"` // 各币种表现
|
||||
BestSymbol string `json:"best_symbol"` // 表现最好的币种
|
||||
WorstSymbol string `json:"worst_symbol"` // 表现最差的币种
|
||||
}
|
||||
|
||||
// SymbolPerformance 币种表现统计
|
||||
type SymbolPerformance struct {
|
||||
Symbol string `json:"symbol"` // 币种
|
||||
TotalTrades int `json:"total_trades"` // 交易次数
|
||||
WinningTrades int `json:"winning_trades"` // 盈利次数
|
||||
LosingTrades int `json:"losing_trades"` // 亏损次数
|
||||
WinRate float64 `json:"win_rate"` // 胜率
|
||||
TotalPnL float64 `json:"total_pn_l"` // 总盈亏
|
||||
AvgPnL float64 `json:"avg_pn_l"` // 平均盈亏
|
||||
}
|
||||
|
||||
// AnalyzePerformance 分析最近N个周期的交易表现
|
||||
func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error) {
|
||||
records, err := l.GetLatestRecords(lookbackCycles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取历史记录失败: %w", err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return &PerformanceAnalysis{
|
||||
RecentTrades: []TradeOutcome{},
|
||||
SymbolStats: make(map[string]*SymbolPerformance),
|
||||
}, nil
|
||||
}
|
||||
|
||||
analysis := &PerformanceAnalysis{
|
||||
RecentTrades: []TradeOutcome{},
|
||||
SymbolStats: make(map[string]*SymbolPerformance),
|
||||
}
|
||||
|
||||
// 追踪持仓状态: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" || action.Action == "partial_close" || action.Action == "auto_close_long" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// partial_close 需要根據持倉判斷方向
|
||||
if action.Action == "partial_close" && side == "" {
|
||||
for key, pos := range openPositions {
|
||||
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
|
||||
side = posSymbol
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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", "auto_close_long", "auto_close_short":
|
||||
// 移除已平仓记录
|
||||
delete(openPositions, posKey)
|
||||
// partial_close 不處理,保留持倉記錄
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历分析窗口内的记录,生成交易结果
|
||||
for _, record := range records {
|
||||
for _, action := range record.Decisions {
|
||||
if !action.Success {
|
||||
continue
|
||||
}
|
||||
|
||||
symbol := action.Symbol
|
||||
side := ""
|
||||
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" || action.Action == "auto_close_long" {
|
||||
side = "long"
|
||||
} else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// partial_close 需要根據持倉判斷方向
|
||||
if action.Action == "partial_close" {
|
||||
// 從 openPositions 中查找持倉方向
|
||||
for key, pos := range openPositions {
|
||||
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
|
||||
side = posSymbol
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
posKey := symbol + "_" + side // 使用symbol_side作为key,区分多空持仓
|
||||
|
||||
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,
|
||||
"remainingQuantity": action.Quantity, // 🔧 BUG FIX:追蹤剩餘數量
|
||||
"accumulatedPnL": 0.0, // 🔧 BUG FIX:累積部分平倉盈虧
|
||||
"partialCloseCount": 0, // 🔧 BUG FIX:部分平倉次數
|
||||
"partialCloseVolume": 0.0, // 🔧 BUG FIX:部分平倉總量
|
||||
}
|
||||
|
||||
case "close_long", "close_short", "partial_close", "auto_close_long", "auto_close_short":
|
||||
// 查找对应的开仓记录(可能来自预填充或当前窗口)
|
||||
if openPos, exists := openPositions[posKey]; exists {
|
||||
openPrice := openPos["openPrice"].(float64)
|
||||
openTime := openPos["openTime"].(time.Time)
|
||||
side := openPos["side"].(string)
|
||||
quantity := openPos["quantity"].(float64)
|
||||
leverage := openPos["leverage"].(int)
|
||||
|
||||
// 🔧 BUG FIX:取得追蹤字段(若不存在則初始化)
|
||||
remainingQty, _ := openPos["remainingQuantity"].(float64)
|
||||
if remainingQty == 0 {
|
||||
remainingQty = quantity // 兼容舊數據(沒有 remainingQuantity 字段)
|
||||
}
|
||||
accumulatedPnL, _ := openPos["accumulatedPnL"].(float64)
|
||||
partialCloseCount, _ := openPos["partialCloseCount"].(int)
|
||||
partialCloseVolume, _ := openPos["partialCloseVolume"].(float64)
|
||||
|
||||
// 对于 partial_close,使用实际平仓数量;否则使用剩余仓位数量
|
||||
actualQuantity := remainingQty
|
||||
if action.Action == "partial_close" {
|
||||
actualQuantity = action.Quantity
|
||||
}
|
||||
|
||||
// 计算本次平仓的盈亏(USDT)
|
||||
var pnl float64
|
||||
if side == "long" {
|
||||
pnl = actualQuantity * (action.Price - openPrice)
|
||||
} else {
|
||||
pnl = actualQuantity * (openPrice - action.Price)
|
||||
}
|
||||
|
||||
// 🔧 BUG FIX:處理 partial_close 聚合邏輯
|
||||
if action.Action == "partial_close" {
|
||||
// 累積盈虧和數量
|
||||
accumulatedPnL += pnl
|
||||
remainingQty -= actualQuantity
|
||||
partialCloseCount++
|
||||
partialCloseVolume += actualQuantity
|
||||
|
||||
// 更新 openPositions(保留持倉記錄,但更新追蹤數據)
|
||||
openPos["remainingQuantity"] = remainingQty
|
||||
openPos["accumulatedPnL"] = accumulatedPnL
|
||||
openPos["partialCloseCount"] = partialCloseCount
|
||||
openPos["partialCloseVolume"] = partialCloseVolume
|
||||
|
||||
// 判斷是否已完全平倉
|
||||
if remainingQty <= 0.0001 { // 使用小閾值避免浮點誤差
|
||||
// ✅ 完全平倉:記錄為一筆完整交易
|
||||
positionValue := quantity * openPrice
|
||||
marginUsed := positionValue / float64(leverage)
|
||||
pnlPct := 0.0
|
||||
if marginUsed > 0 {
|
||||
pnlPct = (accumulatedPnL / marginUsed) * 100
|
||||
}
|
||||
|
||||
outcome := TradeOutcome{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity, // 使用原始總量
|
||||
Leverage: leverage,
|
||||
OpenPrice: openPrice,
|
||||
ClosePrice: action.Price, // 最後一次平倉價格
|
||||
PositionValue: positionValue,
|
||||
MarginUsed: marginUsed,
|
||||
PnL: accumulatedPnL, // 🔧 使用累積盈虧
|
||||
PnLPct: pnlPct,
|
||||
Duration: action.Timestamp.Sub(openTime).String(),
|
||||
OpenTime: openTime,
|
||||
CloseTime: action.Timestamp,
|
||||
}
|
||||
|
||||
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
|
||||
analysis.TotalTrades++ // 🔧 只在完全平倉時計數
|
||||
|
||||
// 分类交易
|
||||
if accumulatedPnL > 0 {
|
||||
analysis.WinningTrades++
|
||||
analysis.AvgWin += accumulatedPnL
|
||||
} else if accumulatedPnL < 0 {
|
||||
analysis.LosingTrades++
|
||||
analysis.AvgLoss += accumulatedPnL
|
||||
}
|
||||
|
||||
// 更新币种统计
|
||||
if _, exists := analysis.SymbolStats[symbol]; !exists {
|
||||
analysis.SymbolStats[symbol] = &SymbolPerformance{
|
||||
Symbol: symbol,
|
||||
}
|
||||
}
|
||||
stats := analysis.SymbolStats[symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += accumulatedPnL
|
||||
if accumulatedPnL > 0 {
|
||||
stats.WinningTrades++
|
||||
} else if accumulatedPnL < 0 {
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
// 刪除持倉記錄
|
||||
delete(openPositions, posKey)
|
||||
}
|
||||
// ⚠️ 否則不做任何操作(等待後續 partial_close 或 full close)
|
||||
|
||||
} else {
|
||||
// 🔧 完全平倉(close_long/close_short/auto_close)
|
||||
// 如果之前有部分平倉,需要加上累積的 PnL
|
||||
totalPnL := accumulatedPnL + pnl
|
||||
|
||||
positionValue := quantity * openPrice
|
||||
marginUsed := positionValue / float64(leverage)
|
||||
pnlPct := 0.0
|
||||
if marginUsed > 0 {
|
||||
pnlPct = (totalPnL / marginUsed) * 100
|
||||
}
|
||||
|
||||
outcome := TradeOutcome{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity, // 使用原始總量
|
||||
Leverage: leverage,
|
||||
OpenPrice: openPrice,
|
||||
ClosePrice: action.Price,
|
||||
PositionValue: positionValue,
|
||||
MarginUsed: marginUsed,
|
||||
PnL: totalPnL, // 🔧 包含之前部分平倉的 PnL
|
||||
PnLPct: pnlPct,
|
||||
Duration: action.Timestamp.Sub(openTime).String(),
|
||||
OpenTime: openTime,
|
||||
CloseTime: action.Timestamp,
|
||||
}
|
||||
|
||||
analysis.RecentTrades = append(analysis.RecentTrades, outcome)
|
||||
analysis.TotalTrades++
|
||||
|
||||
// 分类交易
|
||||
if totalPnL > 0 {
|
||||
analysis.WinningTrades++
|
||||
analysis.AvgWin += totalPnL
|
||||
} else if totalPnL < 0 {
|
||||
analysis.LosingTrades++
|
||||
analysis.AvgLoss += totalPnL
|
||||
}
|
||||
|
||||
// 更新币种统计
|
||||
if _, exists := analysis.SymbolStats[symbol]; !exists {
|
||||
analysis.SymbolStats[symbol] = &SymbolPerformance{
|
||||
Symbol: symbol,
|
||||
}
|
||||
}
|
||||
stats := analysis.SymbolStats[symbol]
|
||||
stats.TotalTrades++
|
||||
stats.TotalPnL += totalPnL
|
||||
if totalPnL > 0 {
|
||||
stats.WinningTrades++
|
||||
} else if totalPnL < 0 {
|
||||
stats.LosingTrades++
|
||||
}
|
||||
|
||||
// 刪除持倉記錄
|
||||
delete(openPositions, posKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算统计指标
|
||||
if analysis.TotalTrades > 0 {
|
||||
analysis.WinRate = (float64(analysis.WinningTrades) / float64(analysis.TotalTrades)) * 100
|
||||
|
||||
// 计算总盈利和总亏损
|
||||
totalWinAmount := analysis.AvgWin // 当前是累加的总和
|
||||
totalLossAmount := analysis.AvgLoss // 当前是累加的总和(负数)
|
||||
|
||||
if analysis.WinningTrades > 0 {
|
||||
analysis.AvgWin /= float64(analysis.WinningTrades)
|
||||
}
|
||||
if analysis.LosingTrades > 0 {
|
||||
analysis.AvgLoss /= float64(analysis.LosingTrades)
|
||||
}
|
||||
|
||||
// Profit Factor = 总盈利 / 总亏损(绝对值)
|
||||
// 注意:totalLossAmount 是负数,所以取负号得到绝对值
|
||||
if totalLossAmount != 0 {
|
||||
analysis.ProfitFactor = totalWinAmount / (-totalLossAmount)
|
||||
} else if totalWinAmount > 0 {
|
||||
// 只有盈利没有亏损的情况,设置为一个很大的值表示完美策略
|
||||
analysis.ProfitFactor = 999.0
|
||||
}
|
||||
}
|
||||
|
||||
// 计算各币种胜率和平均盈亏
|
||||
bestPnL := -999999.0
|
||||
worstPnL := 999999.0
|
||||
for symbol, stats := range analysis.SymbolStats {
|
||||
if stats.TotalTrades > 0 {
|
||||
stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100
|
||||
stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades)
|
||||
|
||||
if stats.TotalPnL > bestPnL {
|
||||
bestPnL = stats.TotalPnL
|
||||
analysis.BestSymbol = symbol
|
||||
}
|
||||
if stats.TotalPnL < worstPnL {
|
||||
worstPnL = stats.TotalPnL
|
||||
analysis.WorstSymbol = symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留最近的交易(倒序:最新的在前)
|
||||
if len(analysis.RecentTrades) > 10 {
|
||||
// 反转数组,让最新的在前
|
||||
for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 {
|
||||
analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i]
|
||||
}
|
||||
analysis.RecentTrades = analysis.RecentTrades[:10]
|
||||
} else if len(analysis.RecentTrades) > 0 {
|
||||
// 反转数组
|
||||
for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 {
|
||||
analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i]
|
||||
}
|
||||
}
|
||||
|
||||
// 计算夏普比率(需要至少2个数据点)
|
||||
analysis.SharpeRatio = l.calculateSharpeRatio(records)
|
||||
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
// calculateSharpeRatio 计算夏普比率
|
||||
// 基于账户净值的变化计算风险调整后收益
|
||||
func (l *DecisionLogger) calculateSharpeRatio(records []*DecisionRecord) float64 {
|
||||
if len(records) < 2 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 提取每个周期的账户净值
|
||||
// 注意:TotalBalance字段实际存储的是TotalEquity(账户总净值)
|
||||
// TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额的盈亏)
|
||||
var equities []float64
|
||||
for _, record := range records {
|
||||
// 直接使用TotalBalance,因为它已经是完整的账户净值
|
||||
equity := record.AccountState.TotalBalance
|
||||
if equity > 0 {
|
||||
equities = append(equities, equity)
|
||||
}
|
||||
}
|
||||
|
||||
if len(equities) < 2 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 计算周期收益率(period returns)
|
||||
var returns []float64
|
||||
for i := 1; i < len(equities); i++ {
|
||||
if equities[i-1] > 0 {
|
||||
periodReturn := (equities[i] - equities[i-1]) / equities[i-1]
|
||||
returns = append(returns, periodReturn)
|
||||
}
|
||||
}
|
||||
|
||||
if len(returns) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 计算平均收益率
|
||||
sumReturns := 0.0
|
||||
for _, r := range returns {
|
||||
sumReturns += r
|
||||
}
|
||||
meanReturn := sumReturns / float64(len(returns))
|
||||
|
||||
// 计算收益率标准差
|
||||
sumSquaredDiff := 0.0
|
||||
for _, r := range returns {
|
||||
diff := r - meanReturn
|
||||
sumSquaredDiff += diff * diff
|
||||
}
|
||||
variance := sumSquaredDiff / float64(len(returns))
|
||||
stdDev := math.Sqrt(variance)
|
||||
|
||||
// 避免除以零
|
||||
if stdDev == 0 {
|
||||
if meanReturn > 0 {
|
||||
return 999.0 // 无波动的正收益
|
||||
} else if meanReturn < 0 {
|
||||
return -999.0 // 无波动的负收益
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 计算夏普比率(假设无风险利率为0)
|
||||
// 注:直接返回周期级别的夏普比率(非年化),正常范围 -2 到 +2
|
||||
sharpeRatio := meanReturn / stdDev
|
||||
return sharpeRatio
|
||||
}
|
||||
129
logger/logger.go
129
logger/logger.go
@@ -1,7 +1,6 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"nofx/config"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -10,11 +9,20 @@ import (
|
||||
var (
|
||||
// Log 全局logger实例
|
||||
Log *logrus.Logger
|
||||
|
||||
// telegramHook 保存hook引用,用于优雅关闭
|
||||
telegramHook *TelegramHook
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 自动初始化默认 logger,确保在 Init 被调用前也能使用
|
||||
Log = logrus.New()
|
||||
Log.SetLevel(logrus.InfoLevel)
|
||||
Log.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
ForceColors: true,
|
||||
})
|
||||
Log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 初始化函数
|
||||
// ============================================================================
|
||||
@@ -52,26 +60,6 @@ func Init(cfg *Config) error {
|
||||
// 启用调用位置信息
|
||||
Log.SetReportCaller(true)
|
||||
|
||||
// 添加Telegram Hook(可选)
|
||||
if cfg.Telegram != nil && cfg.Telegram.Enabled {
|
||||
if err := setupTelegramHook(cfg.Telegram); err != nil {
|
||||
Log.Warnf("初始化Telegram推送失败,将继续使用普通日志: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupTelegramHook 设置Telegram Hook
|
||||
func setupTelegramHook(telegramCfg *TelegramConfig) error {
|
||||
hook, err := NewTelegramHook(telegramCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Log.AddHook(hook)
|
||||
telegramHook = hook
|
||||
Log.Info("✅ Telegram日志推送已启用")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -81,69 +69,9 @@ func InitWithSimpleConfig(level string) error {
|
||||
return Init(&Config{Level: level})
|
||||
}
|
||||
|
||||
// InitWithTelegram 使用Telegram配置初始化logger
|
||||
func InitWithTelegram(botToken string, chatID int64) error {
|
||||
return Init(&Config{
|
||||
Level: "info",
|
||||
Telegram: &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// InitFromLogConfig 从config.LogConfig初始化logger
|
||||
func InitFromLogConfig(logConfig *config.LogConfig) error {
|
||||
if logConfig == nil {
|
||||
return InitWithSimpleConfig("info")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Level: logConfig.Level,
|
||||
}
|
||||
|
||||
if cfg.Level == "" {
|
||||
cfg.Level = "info"
|
||||
}
|
||||
|
||||
// 如果启用了Telegram,添加配置
|
||||
if logConfig.Telegram != nil && logConfig.Telegram.Enabled {
|
||||
if botToken := logConfig.Telegram.BotToken; botToken != "" && logConfig.Telegram.ChatID != 0 {
|
||||
cfg.Telegram = &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: logConfig.Telegram.ChatID,
|
||||
MinLevel: logConfig.Telegram.MinLevel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Init(cfg)
|
||||
}
|
||||
|
||||
// InitFromParams 从参数初始化logger
|
||||
// 适用于不依赖config包的场景
|
||||
func InitFromParams(level string, telegramEnabled bool, botToken string, chatID int64) error {
|
||||
cfg := &Config{Level: level}
|
||||
|
||||
if telegramEnabled && botToken != "" && chatID != 0 {
|
||||
cfg.Telegram = &TelegramConfig{
|
||||
Enabled: true,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
}
|
||||
}
|
||||
|
||||
return Init(cfg)
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭logger(主要用于关闭Telegram发送器)
|
||||
// Shutdown 优雅关闭logger
|
||||
func Shutdown() {
|
||||
if telegramHook != nil {
|
||||
telegramHook.Stop()
|
||||
telegramHook = nil
|
||||
}
|
||||
// 预留用于未来扩展
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -208,3 +136,32 @@ func Panic(args ...interface{}) {
|
||||
func Panicf(format string, args ...interface{}) {
|
||||
Log.Panicf(format, args...)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Logger 适配器
|
||||
// ============================================================================
|
||||
|
||||
// MCPLogger 适配器,使 MCP 包使用全局 logger
|
||||
// 实现 mcp.Logger 接口
|
||||
type MCPLogger struct{}
|
||||
|
||||
// NewMCPLogger 创建 MCP 日志适配器
|
||||
func NewMCPLogger() *MCPLogger {
|
||||
return &MCPLogger{}
|
||||
}
|
||||
|
||||
func (l *MCPLogger) Debugf(format string, args ...any) {
|
||||
Log.Debugf(format, args...)
|
||||
}
|
||||
|
||||
func (l *MCPLogger) Infof(format string, args ...any) {
|
||||
Log.Infof(format, args...)
|
||||
}
|
||||
|
||||
func (l *MCPLogger) Warnf(format string, args ...any) {
|
||||
Log.Warnf(format, args...)
|
||||
}
|
||||
|
||||
func (l *MCPLogger) Errorf(format string, args ...any) {
|
||||
Log.Errorf(format, args...)
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TelegramHook 实现logrus.Hook接口,将日志推送到Telegram
|
||||
type TelegramHook struct {
|
||||
sender *TelegramSender
|
||||
levels []logrus.Level
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewTelegramHook 创建Telegram Hook
|
||||
func NewTelegramHook(config *TelegramConfig) (*TelegramHook, error) {
|
||||
if !config.Enabled {
|
||||
return &TelegramHook{enabled: false}, nil
|
||||
}
|
||||
|
||||
if config.BotToken == "" || config.ChatID == 0 {
|
||||
return nil, fmt.Errorf("telegram配置不完整: bot_token和chat_id不能为空")
|
||||
}
|
||||
|
||||
// 创建发送器(使用默认参数)
|
||||
sender, err := NewTelegramSender(config.BotToken, config.ChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建telegram发送器失败: %w", err)
|
||||
}
|
||||
|
||||
hook := &TelegramHook{
|
||||
sender: sender,
|
||||
levels: config.GetLogrusLevels(),
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
// Levels 返回需要触发的日志级别
|
||||
func (h *TelegramHook) Levels() []logrus.Level {
|
||||
if !h.enabled {
|
||||
return []logrus.Level{}
|
||||
}
|
||||
return h.levels
|
||||
}
|
||||
|
||||
// Fire 当日志触发时调用
|
||||
func (h *TelegramHook) Fire(entry *logrus.Entry) error {
|
||||
if !h.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 格式化消息
|
||||
message := h.formatMessage(entry)
|
||||
|
||||
// 异步发送(非阻塞)
|
||||
h.sender.SendAsync(message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatMessage 格式化日志消息为Telegram格式
|
||||
func (h *TelegramHook) formatMessage(entry *logrus.Entry) string {
|
||||
// 级别emoji
|
||||
levelEmoji := h.getLevelEmoji(entry.Level)
|
||||
|
||||
// 基本信息
|
||||
var builder strings.Builder
|
||||
builder.WriteString(fmt.Sprintf("%s *%s*: 系统日志警报\n", levelEmoji, strings.ToUpper(entry.Level.String())))
|
||||
builder.WriteString(fmt.Sprintf("📝 消息: `%s`\n", escapeMarkdown(entry.Message)))
|
||||
|
||||
// 字段信息
|
||||
if len(entry.Data) > 0 {
|
||||
builder.WriteString("📊 字段:\n")
|
||||
for key, value := range entry.Data {
|
||||
builder.WriteString(fmt.Sprintf(" • %s: `%v`\n", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 调用位置
|
||||
if entry.HasCaller() {
|
||||
file := entry.Caller.File
|
||||
// 只保留相对路径
|
||||
if idx := strings.Index(file, "nofx/"); idx >= 0 {
|
||||
file = file[idx:]
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, entry.Caller.Line))
|
||||
} else {
|
||||
// 如果entry没有caller,手动获取
|
||||
if _, file, line, ok := runtime.Caller(8); ok {
|
||||
if idx := strings.Index(file, "nofx/"); idx >= 0 {
|
||||
file = file[idx:]
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, line))
|
||||
}
|
||||
}
|
||||
|
||||
// 时间戳
|
||||
builder.WriteString(fmt.Sprintf("🕐 时间: `%s`", entry.Time.Format("2006-01-02 15:04:05")))
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// getLevelEmoji 获取日志级别对应的emoji
|
||||
func (h *TelegramHook) getLevelEmoji(level logrus.Level) string {
|
||||
switch level {
|
||||
case logrus.PanicLevel:
|
||||
return "🔴"
|
||||
case logrus.FatalLevel:
|
||||
return "🔴"
|
||||
case logrus.ErrorLevel:
|
||||
return "🟠"
|
||||
case logrus.WarnLevel:
|
||||
return "🟡"
|
||||
case logrus.InfoLevel:
|
||||
return "🟢"
|
||||
case logrus.DebugLevel:
|
||||
return "🔵"
|
||||
default:
|
||||
return "⚪"
|
||||
}
|
||||
}
|
||||
|
||||
// escapeMarkdown 转义Markdown特殊字符
|
||||
func escapeMarkdown(text string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"_", "\\_",
|
||||
"*", "\\*",
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"~", "\\~",
|
||||
"`", "\\`",
|
||||
">", "\\>",
|
||||
"#", "\\#",
|
||||
"+", "\\+",
|
||||
"-", "\\-",
|
||||
"=", "\\=",
|
||||
"|", "\\|",
|
||||
"{", "\\{",
|
||||
"}", "\\}",
|
||||
".", "\\.",
|
||||
"!", "\\!",
|
||||
)
|
||||
return replacer.Replace(text)
|
||||
}
|
||||
|
||||
// Stop 停止Hook(优雅关闭)
|
||||
func (h *TelegramHook) Stop() {
|
||||
if h.enabled && h.sender != nil {
|
||||
h.sender.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// TelegramSender Telegram消息发送器(异步)
|
||||
type TelegramSender struct {
|
||||
bot *tgbotapi.BotAPI
|
||||
chatID int64
|
||||
msgChan chan string
|
||||
retryCount int
|
||||
retryInterval time.Duration
|
||||
wg sync.WaitGroup
|
||||
stopChan chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewTelegramSender 创建Telegram发送器(使用默认参数)
|
||||
func NewTelegramSender(botToken string, chatID int64) (*TelegramSender, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(botToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建telegram bot失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置为静默模式(不打印bot信息)
|
||||
bot.Debug = false
|
||||
|
||||
sender := &TelegramSender{
|
||||
bot: bot,
|
||||
chatID: chatID,
|
||||
msgChan: make(chan string, 20), // 固定缓冲区大小: 20
|
||||
retryCount: 3, // 固定重试次数: 3
|
||||
retryInterval: 3 * time.Second, // 固定重试间隔: 3秒
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
// 启动异步发送协程
|
||||
sender.Start()
|
||||
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// Start 启动异步发送协程
|
||||
func (s *TelegramSender) Start() {
|
||||
s.wg.Add(1)
|
||||
go s.listenAndSend()
|
||||
}
|
||||
|
||||
// SendAsync 异步发送消息(非阻塞)
|
||||
func (s *TelegramSender) SendAsync(message string) {
|
||||
select {
|
||||
case s.msgChan <- message:
|
||||
// 成功写入缓冲区
|
||||
default:
|
||||
// 缓冲区满,丢弃消息(不阻塞主流程)
|
||||
fmt.Printf("[Telegram] 消息缓冲区已满,消息被丢弃\n")
|
||||
}
|
||||
}
|
||||
|
||||
// listenAndSend 监听channel并发送消息
|
||||
func (s *TelegramSender) listenAndSend() {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-s.msgChan:
|
||||
s.sendWithRetry(msg)
|
||||
case <-s.stopChan:
|
||||
// 清空缓冲区后退出
|
||||
for len(s.msgChan) > 0 {
|
||||
msg := <-s.msgChan
|
||||
s.sendWithRetry(msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendWithRetry 发送消息(带重试)
|
||||
func (s *TelegramSender) sendWithRetry(message string) {
|
||||
var err error
|
||||
for i := 0; i < s.retryCount; i++ {
|
||||
err = s.send(message)
|
||||
if err == nil {
|
||||
return // 发送成功
|
||||
}
|
||||
|
||||
// 重试前等待
|
||||
if i < s.retryCount-1 {
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
if err != nil {
|
||||
fmt.Printf("[Telegram] 发送消息失败(已重试%d次): %v\n", s.retryCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
// send 发送单条消息
|
||||
func (s *TelegramSender) send(message string) error {
|
||||
msg := tgbotapi.NewMessage(s.chatID, message)
|
||||
msg.ParseMode = tgbotapi.ModeMarkdown
|
||||
|
||||
_, err := s.bot.Send(msg)
|
||||
return err
|
||||
}
|
||||
|
||||
// Stop 停止发送器(优雅关闭)
|
||||
func (s *TelegramSender) Stop() {
|
||||
s.once.Do(func() {
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user