mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 05:51:01 +08:00
Exchange Factory: - CreateTrader() supports Binance/OKX/Bybit/Bitget/KuCoin/Gate - Auto-registers traders from config on startup - Direct import of nofx/trader packages (merged into main module) Web UI: - Dark theme chat interface at :8900 - Quick action sidebar (analyze, watch, positions, balance) - Real-time health check indicator - Mobile responsive Strategy Runner: - /strategy start BTC 1h - AI auto-analyzes on interval - /strategy stop <id> - stop strategy - /strategy list - view active strategies - Notifications pushed to Telegram on signals - Configurable intervals: 15m/30m/1h/4h Docker: - Multi-stage Dockerfile (alpine, ~20MB) - docker-compose.yml with volume persistence Bug fixes: - Fixed panic on empty exchanges config - Fixed thinking mode response parsing (qwen3 content:null) - Added request timeout (55s) in Telegram handler - Better error logging
188 lines
3.9 KiB
Go
188 lines
3.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"nofx/nofxi/internal/memory"
|
|
)
|
|
|
|
// Scheduler handles periodic tasks: daily reports, portfolio checks, etc.
|
|
type Scheduler struct {
|
|
agent *Agent
|
|
logger *slog.Logger
|
|
stopCh chan struct{}
|
|
notifyFn func(userID int64, text string) error
|
|
}
|
|
|
|
// NewScheduler creates a new task scheduler.
|
|
func NewScheduler(agent *Agent, logger *slog.Logger) *Scheduler {
|
|
return &Scheduler{
|
|
agent: agent,
|
|
logger: logger,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// SetNotifyFunc sets the notification callback.
|
|
func (s *Scheduler) SetNotifyFunc(fn func(userID int64, text string) error) {
|
|
s.notifyFn = fn
|
|
}
|
|
|
|
// Start begins the scheduler loop.
|
|
func (s *Scheduler) Start(ctx context.Context) {
|
|
go s.loop(ctx)
|
|
}
|
|
|
|
// Stop stops the scheduler.
|
|
func (s *Scheduler) Stop() {
|
|
close(s.stopCh)
|
|
}
|
|
|
|
func (s *Scheduler) loop(ctx context.Context) {
|
|
// Check every minute for scheduled tasks
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
lastDailyReport := time.Time{}
|
|
lastPortfolioCheck := time.Time{}
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-s.stopCh:
|
|
return
|
|
case now := <-ticker.C:
|
|
hour := now.Hour()
|
|
|
|
// Daily report at 21:00 (9 PM)
|
|
if hour == 21 && now.Sub(lastDailyReport) > 12*time.Hour {
|
|
s.sendDailyReport(ctx)
|
|
lastDailyReport = now
|
|
}
|
|
|
|
// Portfolio check every 4 hours
|
|
if now.Sub(lastPortfolioCheck) > 4*time.Hour {
|
|
s.checkPortfolio(ctx)
|
|
lastPortfolioCheck = now
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) sendDailyReport(ctx context.Context) {
|
|
s.logger.Info("generating daily report")
|
|
|
|
trades, err := s.agent.memory.GetRecentTrades(50)
|
|
if err != nil {
|
|
s.logger.Error("get trades for daily report", "error", err)
|
|
return
|
|
}
|
|
|
|
// Filter today's trades
|
|
today := time.Now().Truncate(24 * time.Hour)
|
|
var todayTrades []memory.TradeRecord
|
|
totalPnL := 0.0
|
|
wins := 0
|
|
|
|
for _, t := range trades {
|
|
if t.CreatedAt.After(today) {
|
|
todayTrades = append(todayTrades, t)
|
|
totalPnL += t.PnL
|
|
if t.PnL > 0 {
|
|
wins++
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(todayTrades) == 0 {
|
|
s.logger.Info("no trades today, skipping daily report")
|
|
return
|
|
}
|
|
|
|
winRate := 0.0
|
|
if len(todayTrades) > 0 {
|
|
winRate = float64(wins) / float64(len(todayTrades)) * 100
|
|
}
|
|
|
|
emoji := "📈"
|
|
if totalPnL < 0 {
|
|
emoji = "📉"
|
|
}
|
|
|
|
report := fmt.Sprintf(`%s *NOFXi Daily Report — %s*
|
|
|
|
📊 *Summary*
|
|
• Trades: %d
|
|
• Win Rate: %.1f%%
|
|
• Total P/L: $%.2f
|
|
|
|
`, emoji, time.Now().Format("2006-01-02"), len(todayTrades), winRate, totalPnL)
|
|
|
|
// Add trade details
|
|
if len(todayTrades) > 0 {
|
|
report += "📋 *Trades*\n"
|
|
for _, t := range todayTrades {
|
|
e := "🟢"
|
|
if t.PnL < 0 {
|
|
e = "🔴"
|
|
}
|
|
report += fmt.Sprintf("%s %s %s — P/L: $%.2f\n", e, t.Side, t.Symbol, t.PnL)
|
|
}
|
|
}
|
|
|
|
report += "\n_Generated by NOFXi 🤖_"
|
|
|
|
s.notify(report)
|
|
}
|
|
|
|
func (s *Scheduler) checkPortfolio(ctx context.Context) {
|
|
if s.agent.bridge == nil {
|
|
return
|
|
}
|
|
|
|
s.logger.Info("checking portfolio")
|
|
|
|
var alerts []string
|
|
|
|
for _, ex := range s.agent.config.Exchanges {
|
|
positions, err := s.agent.bridge.GetPositions(ex.Name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, p := range positions {
|
|
// Alert on large unrealized losses (> 5%)
|
|
if p.EntryPrice > 0 && p.PnL < 0 {
|
|
lossPct := (p.PnL / (p.EntryPrice * p.Size)) * 100
|
|
if lossPct < -5 {
|
|
alerts = append(alerts, fmt.Sprintf(
|
|
"⚠️ *%s* %s: %.1f%% loss ($%.2f)",
|
|
p.Symbol, strings.ToUpper(p.Side), lossPct, p.PnL))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(alerts) > 0 {
|
|
msg := "🚨 *Portfolio Alert*\n\n" + strings.Join(alerts, "\n")
|
|
s.notify(msg)
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) notify(text string) {
|
|
if s.notifyFn == nil {
|
|
s.logger.Warn("no notification function set, cannot send scheduled message")
|
|
return
|
|
}
|
|
for _, uid := range s.agent.config.Telegram.AllowedIDs {
|
|
if err := s.notifyFn(uid, text); err != nil {
|
|
s.logger.Error("send notification", "user_id", uid, "error", err)
|
|
}
|
|
}
|
|
}
|