mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-18 01:44:38 +08:00
feat: init nofxi - AI Trading Agent module
NOFXi — Your AI Trading Agent, built on NOFX. Architecture: - Agent Core: intent routing, conversation memory, trade confirmation - Memory: SQLite (trades, conversations, preferences, strategies) - Thinking: LLM client (OpenAI/claw402/DeepSeek compatible) - Execution: Bridge to NOFX trader engine (9 exchanges) - Perception: Market monitor, price alerts, anomaly detection - Interaction: Telegram bot + REST API + OpenAI-compatible endpoint Features: - Natural language trading (中英文) - /buy /sell /analyze /watch /alert /positions /balance - Daily P/L reports, portfolio risk checks - Trade confirmation flow (safety first) - Price polling and alert notifications
This commit is contained in:
187
nofxi/internal/agent/scheduler.go
Normal file
187
nofxi/internal/agent/scheduler.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NoFxAiOS/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user