mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
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:
189
nofxi/internal/agent/brain.go
Normal file
189
nofxi/internal/agent/brain.go
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
229
nofxi/internal/memory/learner.go
Normal file
229
nofxi/internal/memory/learner.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UserProfile captures what the AI has learned about a user's trading behavior.
|
||||
type UserProfile struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
AvgHoldTime float64 `json:"avg_hold_time_hours"`
|
||||
PreferredSide string `json:"preferred_side"` // "long", "short", "balanced"
|
||||
RiskTolerance string `json:"risk_tolerance"` // "conservative", "moderate", "aggressive"
|
||||
FavoriteSymbols []string `json:"favorite_symbols"`
|
||||
AvgLeverage float64 `json:"avg_leverage"`
|
||||
BestStrategy string `json:"best_strategy"`
|
||||
WorstStrategy string `json:"worst_strategy"`
|
||||
TotalPnL float64 `json:"total_pnl"`
|
||||
BiggestWin float64 `json:"biggest_win"`
|
||||
BiggestLoss float64 `json:"biggest_loss"`
|
||||
LastAnalyzed time.Time `json:"last_analyzed"`
|
||||
}
|
||||
|
||||
// Lesson is an insight learned from past trading.
|
||||
type Lesson struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Type string `json:"type"` // "win_pattern", "loss_pattern", "risk_insight", "strategy_note"
|
||||
Content string `json:"content"` // Natural language description
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// InitLearnerTables creates the learner-specific tables.
|
||||
func (s *Store) InitLearnerTables() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
total_trades INTEGER DEFAULT 0,
|
||||
win_rate REAL DEFAULT 0,
|
||||
avg_hold_time REAL DEFAULT 0,
|
||||
preferred_side TEXT DEFAULT 'balanced',
|
||||
risk_tolerance TEXT DEFAULT 'moderate',
|
||||
favorite_symbols TEXT DEFAULT '',
|
||||
avg_leverage REAL DEFAULT 1,
|
||||
best_strategy TEXT DEFAULT '',
|
||||
worst_strategy TEXT DEFAULT '',
|
||||
total_pnl REAL DEFAULT 0,
|
||||
biggest_win REAL DEFAULT 0,
|
||||
biggest_loss REAL DEFAULT 0,
|
||||
last_analyzed DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS ai_predictions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
predicted_action TEXT NOT NULL,
|
||||
predicted_confidence REAL,
|
||||
actual_result TEXT,
|
||||
actual_pnl REAL,
|
||||
model TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at DATETIME
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_lessons_user ON lessons(user_id, type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_predictions_symbol ON ai_predictions(symbol, created_at)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("learner migration: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLesson stores a trading lesson.
|
||||
func (s *Store) SaveLesson(userID int64, lessonType, content, symbol string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO lessons (user_id, type, content, symbol) VALUES (?, ?, ?, ?)`,
|
||||
userID, lessonType, content, symbol,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLessons retrieves lessons for a user.
|
||||
func (s *Store) GetLessons(userID int64, limit int) ([]Lesson, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, type, content, COALESCE(symbol,''), created_at
|
||||
FROM lessons WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||
userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lessons []Lesson
|
||||
for rows.Next() {
|
||||
var l Lesson
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.Type, &l.Content, &l.Symbol, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lessons = append(lessons, l)
|
||||
}
|
||||
return lessons, nil
|
||||
}
|
||||
|
||||
// SavePrediction logs an AI prediction for later evaluation.
|
||||
func (s *Store) SavePrediction(symbol, action string, confidence float64, model string) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO ai_predictions (symbol, predicted_action, predicted_confidence, model) VALUES (?, ?, ?, ?)`,
|
||||
symbol, action, confidence, model,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// ResolvePrediction updates a prediction with the actual result.
|
||||
func (s *Store) ResolvePrediction(id int64, result string, pnl float64) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE ai_predictions SET actual_result = ?, actual_pnl = ?, resolved_at = ? WHERE id = ?`,
|
||||
result, pnl, time.Now(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPredictionAccuracy returns the accuracy of AI predictions.
|
||||
func (s *Store) GetPredictionAccuracy(model string) (total int, correct int, avgPnL float64, err error) {
|
||||
var pnl sql.NullFloat64
|
||||
err = s.db.QueryRow(
|
||||
`SELECT COUNT(*), SUM(CASE WHEN actual_pnl > 0 THEN 1 ELSE 0 END), AVG(actual_pnl)
|
||||
FROM ai_predictions WHERE resolved_at IS NOT NULL AND (? = '' OR model = ?)`,
|
||||
model, model,
|
||||
).Scan(&total, &correct, &pnl)
|
||||
if pnl.Valid {
|
||||
avgPnL = pnl.Float64
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// AnalyzeUserProfile builds a profile from trading history.
|
||||
func (s *Store) AnalyzeUserProfile(userID int64) (*UserProfile, error) {
|
||||
trades, err := s.GetRecentTrades(1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(trades) == 0 {
|
||||
return &UserProfile{UserID: userID}, nil
|
||||
}
|
||||
|
||||
profile := &UserProfile{
|
||||
UserID: userID,
|
||||
TotalTrades: len(trades),
|
||||
}
|
||||
|
||||
wins := 0
|
||||
symbolCount := make(map[string]int)
|
||||
var totalPnL, bigWin, bigLoss, totalLev float64
|
||||
longCount, shortCount := 0, 0
|
||||
|
||||
for _, t := range trades {
|
||||
totalPnL += t.PnL
|
||||
if t.PnL > 0 {
|
||||
wins++
|
||||
}
|
||||
if t.PnL > bigWin {
|
||||
bigWin = t.PnL
|
||||
}
|
||||
if t.PnL < bigLoss {
|
||||
bigLoss = t.PnL
|
||||
}
|
||||
symbolCount[t.Symbol]++
|
||||
if t.Side == "long" || t.Side == "buy" {
|
||||
longCount++
|
||||
} else {
|
||||
shortCount++
|
||||
}
|
||||
}
|
||||
|
||||
profile.WinRate = float64(wins) / float64(len(trades)) * 100
|
||||
profile.TotalPnL = totalPnL
|
||||
profile.BiggestWin = bigWin
|
||||
profile.BiggestLoss = bigLoss
|
||||
profile.AvgLeverage = totalLev / float64(len(trades))
|
||||
|
||||
if longCount > shortCount*2 {
|
||||
profile.PreferredSide = "long"
|
||||
} else if shortCount > longCount*2 {
|
||||
profile.PreferredSide = "short"
|
||||
} else {
|
||||
profile.PreferredSide = "balanced"
|
||||
}
|
||||
|
||||
// Top symbols
|
||||
var favs []string
|
||||
for sym := range symbolCount {
|
||||
favs = append(favs, sym)
|
||||
}
|
||||
if len(favs) > 5 {
|
||||
favs = favs[:5]
|
||||
}
|
||||
profile.FavoriteSymbols = favs
|
||||
|
||||
// Risk tolerance based on leverage and loss patterns
|
||||
if profile.BiggestLoss < -500 || profile.AvgLeverage > 10 {
|
||||
profile.RiskTolerance = "aggressive"
|
||||
} else if profile.BiggestLoss < -100 || profile.AvgLeverage > 3 {
|
||||
profile.RiskTolerance = "moderate"
|
||||
} else {
|
||||
profile.RiskTolerance = "conservative"
|
||||
}
|
||||
|
||||
profile.LastAnalyzed = time.Now()
|
||||
return profile, nil
|
||||
}
|
||||
136
nofxi/internal/perception/news.go
Normal file
136
nofxi/internal/perception/news.go
Normal 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
|
||||
}
|
||||
282
nofxi/internal/perception/sentinel.go
Normal file
282
nofxi/internal/perception/sentinel.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user