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 7cbdb4e6e0
commit 656016c5a8
4 changed files with 836 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
package perception
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// NewsItem represents a crypto news headline.
type NewsItem struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Sentiment string `json:"sentiment"` // "bullish", "bearish", "neutral"
Symbols []string `json:"symbols"` // Related symbols
Timestamp time.Time `json:"timestamp"`
}
// NewsMonitor fetches crypto news and detects sentiment shifts.
type NewsMonitor struct {
httpClient *http.Client
logger *slog.Logger
lastCheck time.Time
seenURLs map[string]bool
}
// NewNewsMonitor creates a new news monitor.
func NewNewsMonitor(logger *slog.Logger) *NewsMonitor {
return &NewsMonitor{
httpClient: &http.Client{Timeout: 15 * time.Second},
logger: logger,
seenURLs: make(map[string]bool),
}
}
// FetchNews gets recent crypto news from CryptoCompare (free, no auth needed).
func (n *NewsMonitor) FetchNews() ([]NewsItem, error) {
url := "https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest"
resp, err := n.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch news: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse news: %w", err)
}
var items []NewsItem
for _, d := range result.Data {
if n.seenURLs[d.URL] {
continue
}
n.seenURLs[d.URL] = true
item := NewsItem{
Title: d.Title,
Source: d.Source,
URL: d.URL,
Sentiment: classifySentiment(d.Title + " " + d.Body),
Symbols: extractSymbols(d.Title + " " + d.Categories),
Timestamp: time.Unix(d.PublishedOn, 0),
}
items = append(items, item)
}
// Keep seen URLs map from growing forever
if len(n.seenURLs) > 1000 {
n.seenURLs = make(map[string]bool)
}
n.lastCheck = time.Now()
return items, nil
}
// classifySentiment does basic keyword-based sentiment analysis.
func classifySentiment(text string) string {
lower := strings.ToLower(text)
bullish := []string{"surge", "rally", "soar", "bullish", "breakout", "all-time high", "ath",
"pump", "moon", "gain", "rise", "uptrend", "buy signal", "accumulate", "adoption"}
bearish := []string{"crash", "dump", "plunge", "bearish", "sell-off", "selloff", "decline",
"drop", "fall", "liquidat", "hack", "exploit", "ban", "fraud", "scam", "risk"}
bullCount, bearCount := 0, 0
for _, w := range bullish {
if strings.Contains(lower, w) {
bullCount++
}
}
for _, w := range bearish {
if strings.Contains(lower, w) {
bearCount++
}
}
if bullCount > bearCount {
return "bullish"
}
if bearCount > bullCount {
return "bearish"
}
return "neutral"
}
// extractSymbols finds crypto symbols mentioned in text.
func extractSymbols(text string) []string {
upper := strings.ToUpper(text)
known := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK", "MATIC", "UNI", "AAVE"}
var found []string
for _, s := range known {
if strings.Contains(upper, s) {
found = append(found, s)
}
}
return found
}

View File

@@ -0,0 +1,282 @@
package perception
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"strconv"
"sync"
"time"
)
// Signal types for proactive notifications.
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout" // Sudden price move
SignalVolumeSpike SignalType = "volume_spike" // Abnormal volume
SignalFundingRate SignalType = "funding_rate" // Extreme funding rate
SignalLiquidation SignalType = "liquidation_wave" // Mass liquidations
SignalTrendReversal SignalType = "trend_reversal" // Potential reversal
SignalPositionRisk SignalType = "position_risk" // User's position at risk
)
// Signal is a proactive market event detected by the sentinel.
type Signal struct {
Type SignalType `json:"type"`
Symbol string `json:"symbol"`
Severity string `json:"severity"` // "info", "warning", "critical"
Title string `json:"title"`
Detail string `json:"detail"`
Price float64 `json:"price"`
Change float64 `json:"change"` // Percentage
Timestamp time.Time `json:"timestamp"`
}
// SignalCallback is called when the sentinel detects something.
type SignalCallback func(signal Signal)
// Sentinel continuously monitors markets and detects anomalies.
// This is the "eyes" of NOFXi — always watching, always analyzing.
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePoint // symbol → recent prices
onSignal SignalCallback
httpClient *http.Client
logger *slog.Logger
stopCh chan struct{}
// Thresholds
priceBreakoutPct float64 // Price move % to trigger alert (default 3%)
volumeSpikeMult float64 // Volume multiplier vs average (default 3x)
fundingThreshold float64 // Extreme funding rate threshold (default 0.1%)
}
type pricePoint struct {
Price float64
Volume float64
Timestamp time.Time
}
// NewSentinel creates a new market sentinel.
func NewSentinel(symbols []string, onSignal SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePoint),
onSignal: onSignal,
httpClient: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
priceBreakoutPct: 3.0,
volumeSpikeMult: 3.0,
fundingThreshold: 0.1,
}
}
// Start begins the sentinel loop. Checks every 60 seconds.
func (s *Sentinel) Start() {
go s.loop()
s.logger.Info("sentinel started", "symbols", s.symbols)
}
// Stop stops the sentinel.
func (s *Sentinel) Stop() {
close(s.stopCh)
}
// AddSymbol adds a symbol to watch.
func (s *Sentinel) AddSymbol(symbol string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, sym := range s.symbols {
if sym == symbol {
return
}
}
s.symbols = append(s.symbols, symbol)
}
func (s *Sentinel) loop() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Initial scan
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
}
func (s *Sentinel) scan() {
s.mu.RLock()
symbols := make([]string, len(s.symbols))
copy(symbols, s.symbols)
s.mu.RUnlock()
for _, sym := range symbols {
s.checkSymbol(sym)
}
s.checkFundingRates()
}
func (s *Sentinel) checkSymbol(symbol string) {
// Fetch current ticker
ticker, err := s.fetchTicker(symbol)
if err != nil {
return
}
price, _ := strconv.ParseFloat(ticker["lastPrice"].(string), 64)
volume, _ := strconv.ParseFloat(ticker["quoteVolume"].(string), 64)
changePct, _ := strconv.ParseFloat(ticker["priceChangePercent"].(string), 64)
now := time.Now()
point := pricePoint{Price: price, Volume: volume, Timestamp: now}
s.mu.Lock()
hist := s.history[symbol]
hist = append(hist, point)
// Keep last 60 points (1 hour at 1min intervals)
if len(hist) > 60 {
hist = hist[len(hist)-60:]
}
s.history[symbol] = hist
s.mu.Unlock()
// Need at least 5 data points to detect anomalies
if len(hist) < 5 {
return
}
// === Detect Price Breakout ===
// Compare current price to 5-minute-ago price
fiveAgo := hist[len(hist)-5]
pctMove := ((price - fiveAgo.Price) / fiveAgo.Price) * 100
if math.Abs(pctMove) >= s.priceBreakoutPct {
direction := "📈 上涨"
severity := "warning"
if pctMove < 0 {
direction = "📉 下跌"
}
if math.Abs(pctMove) >= s.priceBreakoutPct*2 {
severity = "critical"
}
s.emit(Signal{
Type: SignalPriceBreakout,
Symbol: symbol,
Severity: severity,
Title: fmt.Sprintf("%s %s 急速%s %.1f%%", symbol, direction, map[bool]string{true: "拉升", false: "下跌"}[pctMove > 0], math.Abs(pctMove)),
Detail: fmt.Sprintf("5分钟内从 $%.2f → $%.2f,变动 %.1f%%\n24h 涨跌: %.1f%%", fiveAgo.Price, price, pctMove, changePct),
Price: price,
Change: pctMove,
})
}
// === Detect Volume Spike ===
if len(hist) >= 10 {
var avgVol float64
for i := 0; i < len(hist)-1; i++ {
avgVol += hist[i].Volume
}
avgVol /= float64(len(hist) - 1)
if avgVol > 0 && volume > avgVol*s.volumeSpikeMult {
mult := volume / avgVol
s.emit(Signal{
Type: SignalVolumeSpike,
Symbol: symbol,
Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常放大 %.1fx", symbol, mult),
Detail: fmt.Sprintf("当前成交量是平均值的 %.1f 倍\n价格: $%.2f (24h: %.1f%%)", mult, price, changePct),
Price: price,
Change: changePct,
})
}
}
}
func (s *Sentinel) checkFundingRates() {
url := "https://fapi.binance.com/fapi/v1/premiumIndex"
resp, err := s.httpClient.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
var indexes []map[string]interface{}
if err := json.Unmarshal(body, &indexes); err != nil {
return
}
s.mu.RLock()
watchSet := make(map[string]bool)
for _, sym := range s.symbols {
watchSet[sym] = true
}
s.mu.RUnlock()
for _, idx := range indexes {
symbol, _ := idx["symbol"].(string)
if !watchSet[symbol] {
continue
}
rateStr, _ := idx["lastFundingRate"].(string)
rate, _ := strconv.ParseFloat(rateStr, 64)
ratePct := rate * 100
if math.Abs(ratePct) >= s.fundingThreshold {
direction := "多头主导"
if ratePct < 0 {
direction = "空头主导"
}
s.emit(Signal{
Type: SignalFundingRate,
Symbol: symbol,
Severity: "info",
Title: fmt.Sprintf("%s 资金费率异常: %.4f%%", symbol, ratePct),
Detail: fmt.Sprintf("当前资金费率 %.4f%% (%s)\n极端费率可能预示反转", ratePct, direction),
Change: ratePct,
})
}
}
}
func (s *Sentinel) fetchTicker(symbol string) (map[string]interface{}, error) {
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
resp, err := s.httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
return result, nil
}
func (s *Sentinel) emit(sig Signal) {
sig.Timestamp = time.Now()
s.logger.Info("signal detected",
"type", sig.Type,
"symbol", sig.Symbol,
"severity", sig.Severity,
"title", sig.Title,
)
if s.onSignal != nil {
s.onSignal(sig)
}
}