feat(nofxi): proactive intelligence - Sentinel, Brain, Learner

🧠 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.
This commit is contained in:
shinchan-zhai
2026-03-22 23:02:46 +08:00
parent bdfad190ff
commit 3e5b280987
4 changed files with 836 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
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)
}