diff --git a/nofxi/internal/agent/brain.go b/nofxi/internal/agent/brain.go new file mode 100644 index 00000000..9ad5780c --- /dev/null +++ b/nofxi/internal/agent/brain.go @@ -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) +} + + diff --git a/nofxi/internal/memory/learner.go b/nofxi/internal/memory/learner.go new file mode 100644 index 00000000..5473b067 --- /dev/null +++ b/nofxi/internal/memory/learner.go @@ -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 +} diff --git a/nofxi/internal/perception/news.go b/nofxi/internal/perception/news.go new file mode 100644 index 00000000..fef77f82 --- /dev/null +++ b/nofxi/internal/perception/news.go @@ -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 +} diff --git a/nofxi/internal/perception/sentinel.go b/nofxi/internal/perception/sentinel.go new file mode 100644 index 00000000..67d772ec --- /dev/null +++ b/nofxi/internal/perception/sentinel.go @@ -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) + } +}