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) } } }