mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
Applied safe.GoNamed to: - 9 exchange order_sync goroutines (OKX, Hyperliquid, Aster, Bybit, KuCoin, Gate, Bitget, Lighter, Binance×2) - Drawdown monitor (auto_trader_risk.go) - Brain news scanner + market briefs (agent/brain.go) - Sentinel scanner (agent/sentinel.go) - Agent scheduler (agent/scheduler.go) - x402 idle watchdog (mcp/payment/x402.go) - MCP stream idle watchdog (mcp/client.go) - Rate limiter cleanup (api/rate_limiter.go) - 3 telemetry fire-and-forget sends (telemetry/experience.go) - CoinAnk WS handler (provider/coinank/coinank_api/kline_ws.go) - API server goroutine (main.go) Added manual defer/recover with error reporting to: - Telegram AI agent handler (sends error msg to user on panic) - Trader data fetch (returns error result on panic to prevent deadlock) Before: a panic in ANY of these 27 goroutines would crash the entire trading process with zero diagnostics. Now all panics are caught, logged with stack traces, and the process continues running.
164 lines
4.5 KiB
Go
164 lines
4.5 KiB
Go
package agent
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"nofx/safe"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Brain handles proactive intelligence: signals, news, market briefs.
|
||
type Brain struct {
|
||
agent *Agent
|
||
logger *slog.Logger
|
||
http *http.Client
|
||
stopCh chan struct{}
|
||
recentSignals sync.Map // debounce
|
||
}
|
||
|
||
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
|
||
return &Brain{
|
||
agent: agent,
|
||
logger: logger,
|
||
http: &http.Client{Timeout: 15 * time.Second},
|
||
stopCh: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
func (b *Brain) Stop() { close(b.stopCh) }
|
||
|
||
func (b *Brain) HandleSignal(sig Signal) {
|
||
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
|
||
if v, ok := b.recentSignals.Load(key); ok {
|
||
if time.Since(v.(time.Time)) < 10*time.Minute {
|
||
return
|
||
}
|
||
}
|
||
b.recentSignals.Store(key, time.Now())
|
||
|
||
emoji := map[string]string{"info": "ℹ️", "warning": "⚠️", "critical": "🚨"}
|
||
e := emoji[sig.Severity]
|
||
if e == "" { e = "📊" }
|
||
|
||
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
|
||
}
|
||
|
||
func (b *Brain) StartNewsScan(interval time.Duration) {
|
||
seen := make(map[string]bool)
|
||
safe.GoNamed("brain-news-scan", func() {
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-b.stopCh: return
|
||
case <-ticker.C:
|
||
b.scanNews(seen)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func (b *Brain) scanNews(seen map[string]bool) {
|
||
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
||
if err != nil { return }
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit
|
||
if err != nil { return }
|
||
|
||
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 }
|
||
|
||
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
|
||
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
|
||
|
||
for _, d := range result.Data {
|
||
if seen[d.URL] { continue }
|
||
seen[d.URL] = true
|
||
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
|
||
|
||
lower := strings.ToLower(d.Title + " " + d.Body)
|
||
bc, brc := 0, 0
|
||
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
|
||
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
|
||
|
||
if bc == 0 && brc == 0 { continue }
|
||
|
||
emoji := "📰"
|
||
sentiment := "NEUTRAL"
|
||
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
|
||
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
|
||
|
||
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
|
||
emoji, d.Title, d.Source, sentiment))
|
||
}
|
||
|
||
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
|
||
if len(seen) > 1000 {
|
||
i, half := 0, len(seen)/2
|
||
for k := range seen {
|
||
if i >= half { break }
|
||
delete(seen, k)
|
||
i++
|
||
}
|
||
}
|
||
}
|
||
|
||
func (b *Brain) StartMarketBriefs(hours []int) {
|
||
safe.GoNamed("brain-market-briefs", func() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
sent := make(map[string]bool)
|
||
for {
|
||
select {
|
||
case <-b.stopCh: return
|
||
case now := <-ticker.C:
|
||
key := now.Format("2006-01-02-15")
|
||
for _, h := range hours {
|
||
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
|
||
sent[key] = true
|
||
b.sendBrief(h)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func (b *Brain) sendBrief(hour int) {
|
||
title := "☀️ *早间市场简报*"
|
||
if hour >= 18 { title = "🌙 *晚间市场简报*" }
|
||
|
||
// Fetch BTC/ETH prices for the brief
|
||
var btcPrice, ethPrice, btcChg, ethChg string
|
||
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
|
||
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
||
if err != nil { continue }
|
||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64KB limit
|
||
resp.Body.Close()
|
||
if readErr != nil { continue }
|
||
var t map[string]string
|
||
if err := json.Unmarshal(body, &t); err != nil { continue }
|
||
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
|
||
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
|
||
}
|
||
|
||
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
|
||
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
|
||
|
||
b.agent.notifyAll(brief)
|
||
}
|