Files
nofx/nofxi/internal/agent/scheduler.go
shinchan-zhai 34f5e6fe71 feat(nofxi): Phase 3 complete - Exchange factory, Web UI, Strategy runner, Docker
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
2026-03-22 22:04:37 +08:00

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