mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
🧠 Brain (proactive intelligence): - Receives signals from Sentinel and decides when to notify - Signal debouncing (same type+symbol within 10min) - Morning brief at 08:30, evening brief at 20:30 (AI-generated) - Crypto news scanning every 5 minutes - Auto-filters high-impact news by sentiment + relevance 👁️ Sentinel (market anomaly detection): - Watches BTC/ETH/SOL by default (expandable) - Price breakout detection (>3% in 5 minutes) - Volume spike detection (>3x average) - Funding rate anomaly detection (>0.1%) - 60-second scan interval, 1-hour price history buffer 📰 News Monitor: - CryptoCompare API (free, no auth) - Keyword-based sentiment classification (bullish/bearish/neutral) - Symbol extraction from headlines - Deduplication via seen URLs 📚 Learner (trading memory): - User profile analysis (win rate, preferred side, risk tolerance) - Trading lessons storage (win/loss patterns, strategy notes) - AI prediction tracking (log predictions → resolve with actual P/L) - Prediction accuracy metrics per model NOFXi is no longer a passive chatbot. It watches, thinks, learns, and acts.
190 lines
4.3 KiB
Go
190 lines
4.3 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
"nofx/nofxi/internal/perception"
|
||
"nofx/nofxi/internal/thinking"
|
||
)
|
||
|
||
// Brain is the proactive intelligence layer.
|
||
// It receives signals from Sentinel, processes news, and decides
|
||
// when to proactively notify the user.
|
||
type Brain struct {
|
||
agent *Agent
|
||
news *perception.NewsMonitor
|
||
logger *slog.Logger
|
||
stopCh chan struct{}
|
||
|
||
// Debounce: don't spam the same signal
|
||
recentSignals map[string]time.Time
|
||
}
|
||
|
||
// NewBrain creates the proactive brain.
|
||
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
|
||
return &Brain{
|
||
agent: agent,
|
||
news: perception.NewNewsMonitor(logger),
|
||
logger: logger,
|
||
stopCh: make(chan struct{}),
|
||
recentSignals: make(map[string]time.Time),
|
||
}
|
||
}
|
||
|
||
// HandleSignal processes a market signal from the Sentinel.
|
||
func (b *Brain) HandleSignal(signal perception.Signal) {
|
||
// Debounce: same signal type + symbol within 10 minutes
|
||
key := fmt.Sprintf("%s:%s", signal.Type, signal.Symbol)
|
||
if last, ok := b.recentSignals[key]; ok && time.Since(last) < 10*time.Minute {
|
||
return
|
||
}
|
||
b.recentSignals[key] = time.Now()
|
||
|
||
// Format alert message
|
||
emoji := map[string]string{
|
||
"info": "ℹ️",
|
||
"warning": "⚠️",
|
||
"critical": "🚨",
|
||
}
|
||
e := emoji[signal.Severity]
|
||
if e == "" {
|
||
e = "📊"
|
||
}
|
||
|
||
msg := fmt.Sprintf("%s *%s*\n\n%s\n\n_%s_",
|
||
e, signal.Title, signal.Detail, signal.Timestamp.Format("15:04:05"))
|
||
|
||
b.notifyAll(msg)
|
||
}
|
||
|
||
// StartNewsScan begins periodic news scanning.
|
||
func (b *Brain) StartNewsScan(interval time.Duration) {
|
||
go func() {
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-b.stopCh:
|
||
return
|
||
case <-ticker.C:
|
||
b.scanNews()
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// StartMarketBrief sends a morning/evening market brief.
|
||
func (b *Brain) StartMarketBrief() {
|
||
go func() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-b.stopCh:
|
||
return
|
||
case now := <-ticker.C:
|
||
hour := now.Hour()
|
||
minute := now.Minute()
|
||
|
||
// Morning brief at 08:30
|
||
if hour == 8 && minute == 30 {
|
||
b.sendMarketBrief("morning")
|
||
}
|
||
// Evening brief at 20:30
|
||
if hour == 20 && minute == 30 {
|
||
b.sendMarketBrief("evening")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (b *Brain) scanNews() {
|
||
items, err := b.news.FetchNews()
|
||
if err != nil {
|
||
b.logger.Error("fetch news", "error", err)
|
||
return
|
||
}
|
||
|
||
// Filter for high-impact news related to watched symbols
|
||
for _, item := range items {
|
||
if item.Sentiment == "neutral" {
|
||
continue
|
||
}
|
||
if len(item.Symbols) == 0 {
|
||
continue
|
||
}
|
||
// Only alert on recent news (last 10 minutes)
|
||
if time.Since(item.Timestamp) > 10*time.Minute {
|
||
continue
|
||
}
|
||
|
||
emoji := "📰"
|
||
if item.Sentiment == "bullish" {
|
||
emoji = "🟢"
|
||
} else if item.Sentiment == "bearish" {
|
||
emoji = "🔴"
|
||
}
|
||
|
||
msg := fmt.Sprintf("%s *%s*\n\n%s\n\n• Source: %s\n• Symbols: %s\n• Sentiment: %s",
|
||
emoji, "News Alert",
|
||
item.Title,
|
||
item.Source,
|
||
strings.Join(item.Symbols, ", "),
|
||
strings.ToUpper(item.Sentiment),
|
||
)
|
||
b.notifyAll(msg)
|
||
}
|
||
}
|
||
|
||
func (b *Brain) sendMarketBrief(timeOfDay string) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
prompt := fmt.Sprintf(`Generate a brief %s market summary for crypto trading.
|
||
Include: BTC/ETH price direction, key levels, market sentiment, any notable events.
|
||
Be concise (under 200 words). Use trading emojis. Respond in Chinese.
|
||
Current time: %s`, timeOfDay, time.Now().Format("2006-01-02 15:04:05"))
|
||
|
||
resp, err := b.agent.thinker.Chat(ctx, []thinking.Message{
|
||
{Role: "system", Content: "You are NOFXi, a professional crypto trading AI. Respond in Chinese."},
|
||
{Role: "user", Content: prompt},
|
||
})
|
||
if err != nil {
|
||
b.logger.Error("generate market brief", "error", err)
|
||
return
|
||
}
|
||
|
||
title := "☀️ *早间市场简报*"
|
||
if timeOfDay == "evening" {
|
||
title = "🌙 *晚间市场简报*"
|
||
}
|
||
|
||
msg := fmt.Sprintf("%s\n\n%s\n\n_Generated by NOFXi 🤖_", title, resp)
|
||
b.notifyAll(msg)
|
||
}
|
||
|
||
func (b *Brain) notifyAll(text string) {
|
||
if b.agent.NotifyFunc == nil {
|
||
return
|
||
}
|
||
for _, uid := range b.agent.config.Telegram.AllowedIDs {
|
||
if err := b.agent.NotifyFunc(uid, text); err != nil {
|
||
b.logger.Error("notify", "user_id", uid, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Stop stops the brain.
|
||
func (b *Brain) Stop() {
|
||
close(b.stopCh)
|
||
}
|
||
|
||
|