mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 22:36:58 +08:00
feat: NOFXi Agent integrated into NOFX core
This is the key architectural change: NOFXi is no longer a separate sub-project in nofxi/, but a first-class module in agent/ that sits on top of NOFX's existing engine. agent/ package: - agent.go: Core orchestrator, directly uses NOFX's TraderManager, Store, and Market packages. No wrapper/bridge needed. - brain.go: Proactive intelligence (news scan, market briefs, signal handling) - sentinel.go: Market anomaly detection (price breakout, volume spike, funding rate) - scheduler.go: Daily reports, position risk checks - router.go: Intent routing for natural language (中/英 bilingual) - i18n.go: Full Chinese/English message catalog - web.go: Agent REST API on :8900 (chat, klines, ticker) main.go changes: - Banner: NOFX → NOFXi - Agent auto-starts with NOFX (sentinel + brain + scheduler + web) - Zero breaking changes to existing NOFX functionality Key difference from old nofxi/: - Agent reads LIVE data from TraderManager (positions, balances, status) - Uses market.Get() for full technical indicators (EMA/MACD/RSI/BB) - No duplicate code — kernel, trader, store all used directly - Old nofxi/ sub-project will be removed once migration is verified Architecture: NOFX (engine) → kernel + trader + market + store + mcp + telegram NOFXi (brain) → agent/ package → uses all of the above
This commit is contained in:
499
agent/agent.go
Normal file
499
agent/agent.go
Normal file
@@ -0,0 +1,499 @@
|
||||
// Package agent implements the NOFXi Agent Core.
|
||||
//
|
||||
// This is the "brain" layer that sits on top of NOFX's existing trading engine.
|
||||
// It adds: natural language interaction, proactive market monitoring,
|
||||
// trading memory/learning, and autonomous decision-making.
|
||||
//
|
||||
// Architecture:
|
||||
// NOFX (engine) provides: kernel, trader, market, store, mcp
|
||||
// Agent (brain) adds: perception, thinking, memory, interaction
|
||||
//
|
||||
// The agent does NOT replace any NOFX functionality — it enhances it.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/manager"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// Agent is the NOFXi intelligence layer on top of NOFX.
|
||||
type Agent struct {
|
||||
// NOFX core (injected)
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
|
||||
// Agent components
|
||||
config *Config
|
||||
sentinel *Sentinel
|
||||
brain *Brain
|
||||
scheduler *Scheduler
|
||||
router *Router
|
||||
logger *slog.Logger
|
||||
|
||||
// Notification callback (set by telegram/web)
|
||||
NotifyFunc func(userID int64, text string) error
|
||||
}
|
||||
|
||||
// Config holds agent-specific configuration.
|
||||
type Config struct {
|
||||
Language string `json:"language"` // "zh" or "en"
|
||||
WatchSymbols []string `json:"watch_symbols"` // Default symbols to watch
|
||||
EnableBriefs bool `json:"enable_briefs"` // Morning/evening market briefs
|
||||
EnableNews bool `json:"enable_news"` // News scanning
|
||||
EnableSentinel bool `json:"enable_sentinel"` // Market anomaly detection
|
||||
BriefTimes []int `json:"brief_times"` // Hours to send briefs (e.g. [8, 20])
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Language: "zh",
|
||||
WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
|
||||
EnableBriefs: true,
|
||||
EnableNews: true,
|
||||
EnableSentinel: true,
|
||||
BriefTimes: []int{8, 20},
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new NOFXi Agent.
|
||||
func New(
|
||||
traderMgr *manager.TraderManager,
|
||||
st *store.Store,
|
||||
cfg *Config,
|
||||
logger *slog.Logger,
|
||||
) *Agent {
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
traderManager: traderMgr,
|
||||
store: st,
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
a.router = NewRouter()
|
||||
a.scheduler = NewScheduler(a, logger)
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Start starts all agent services.
|
||||
func (a *Agent) Start() {
|
||||
a.logger.Info("starting NOFXi agent...")
|
||||
|
||||
// Start sentinel (market anomaly detection)
|
||||
if a.config.EnableSentinel {
|
||||
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
|
||||
a.sentinel.Start()
|
||||
a.logger.Info("sentinel started", "symbols", a.config.WatchSymbols)
|
||||
}
|
||||
|
||||
// Start brain (proactive intelligence)
|
||||
a.brain = NewBrain(a, a.logger)
|
||||
if a.config.EnableNews {
|
||||
a.brain.StartNewsScan(5 * time.Minute)
|
||||
a.logger.Info("news scanner started")
|
||||
}
|
||||
if a.config.EnableBriefs {
|
||||
a.brain.StartMarketBriefs(a.config.BriefTimes)
|
||||
a.logger.Info("market briefs enabled", "hours", a.config.BriefTimes)
|
||||
}
|
||||
|
||||
// Start scheduler
|
||||
a.scheduler.Start(context.Background())
|
||||
a.logger.Info("scheduler started")
|
||||
|
||||
a.logger.Info("NOFXi agent is online 🚀")
|
||||
}
|
||||
|
||||
// Stop stops all agent services.
|
||||
func (a *Agent) Stop() {
|
||||
if a.sentinel != nil {
|
||||
a.sentinel.Stop()
|
||||
}
|
||||
if a.brain != nil {
|
||||
a.brain.Stop()
|
||||
}
|
||||
a.scheduler.Stop()
|
||||
a.logger.Info("NOFXi agent stopped")
|
||||
}
|
||||
|
||||
// HandleMessage processes a user message and returns a response.
|
||||
// This is the main entry point for Telegram/Web interaction.
|
||||
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
|
||||
// Extract language from prefix [lang:xx]
|
||||
lang := a.config.Language
|
||||
if strings.HasPrefix(text, "[lang:") {
|
||||
if end := strings.Index(text, "] "); end > 0 {
|
||||
lang = text[6:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("agent message", "user_id", userID, "text", text)
|
||||
|
||||
intent := a.router.Route(text)
|
||||
|
||||
switch intent.Type {
|
||||
case IntentHelp:
|
||||
return a.msg(lang, "help"), nil
|
||||
case IntentStatus:
|
||||
return a.handleStatus(lang), nil
|
||||
case IntentQuery:
|
||||
return a.handleQuery(lang, intent)
|
||||
case IntentAnalyze:
|
||||
return a.handleAnalyze(ctx, lang, intent)
|
||||
case IntentTrade:
|
||||
return a.handleTrade(lang, intent)
|
||||
case IntentWatch:
|
||||
return a.handleWatch(lang, intent), nil
|
||||
case IntentStrategy:
|
||||
return a.handleStrategyCmd(lang, intent), nil
|
||||
default:
|
||||
return a.handleChat(ctx, lang, userID, text)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers using NOFX core ---
|
||||
|
||||
func (a *Agent) handleStatus(L string) string {
|
||||
traderCount := 0
|
||||
runningCount := 0
|
||||
if a.traderManager != nil {
|
||||
all := a.traderManager.GetAllTraders()
|
||||
traderCount = len(all)
|
||||
for _, t := range all {
|
||||
status := t.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
watchCount := 0
|
||||
if a.sentinel != nil {
|
||||
watchCount = a.sentinel.SymbolCount()
|
||||
}
|
||||
|
||||
return fmt.Sprintf(a.msg(L, "status"),
|
||||
runningCount, traderCount, watchCount, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
func (a *Agent) handleQuery(L string, intent Intent) (string, error) {
|
||||
raw := strings.ToLower(intent.Raw)
|
||||
|
||||
// Get live data from NOFX trader manager
|
||||
if a.traderManager == nil {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
// List all positions across all traders
|
||||
if strings.Contains(raw, "position") || strings.Contains(raw, "持仓") {
|
||||
return a.queryAllPositions(L)
|
||||
}
|
||||
if strings.Contains(raw, "balance") || strings.Contains(raw, "余额") {
|
||||
return a.queryAllBalances(L)
|
||||
}
|
||||
if strings.Contains(raw, "trader") || strings.Contains(raw, "交易员") {
|
||||
return a.queryTraders(L, nil)
|
||||
}
|
||||
|
||||
return a.queryAllPositions(L)
|
||||
}
|
||||
|
||||
func (a *Agent) queryAllPositions(L string) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "positions_header"))
|
||||
totalPnL := 0.0
|
||||
hasPosition := false
|
||||
|
||||
for id, t := range traders {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range positions {
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
hasPosition = true
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
e := "🟢"
|
||||
if pnl < 0 {
|
||||
e = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* %s\n Entry: $%.4f → $%.4f | P/L: $%.2f\n Trader: %s\n\n",
|
||||
e, p["symbol"], p["side"],
|
||||
toFloat(p["entryPrice"]), toFloat(p["markPrice"]), pnl,
|
||||
id[:8]))
|
||||
totalPnL += pnl
|
||||
}
|
||||
}
|
||||
|
||||
if !hasPosition {
|
||||
return a.msg(L, "no_positions"), nil
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(a.msg(L, "total_pnl"), totalPnL))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryAllBalances(L string) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "balance_header"))
|
||||
|
||||
for id, t := range traders {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("*%s* (%s)\n Total: $%.2f | Available: $%.2f | P/L: $%.2f\n\n",
|
||||
t.GetName(), id[:8],
|
||||
toFloat(info["total_equity"]),
|
||||
toFloat(info["available_balance"]),
|
||||
toFloat(info["unrealized_pnl"])))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryTraders(L string, _ interface{}) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "traders_header"))
|
||||
|
||||
for id, t := range traders {
|
||||
s := t.GetStatus()
|
||||
running, _ := s["is_running"].(bool)
|
||||
icon := "⏹"
|
||||
if running {
|
||||
icon = "▶️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* `%s`\n Exchange: %s | AI: %s\n\n",
|
||||
icon, t.GetName(), id[:8], t.GetExchange(), t.GetAIModel()))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(x, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *Agent) handleAnalyze(ctx context.Context, L string, intent Intent) (string, error) {
|
||||
symbol := "BTCUSDT"
|
||||
if d, ok := intent.Params["detail"]; ok && d != "" {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(d))
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
}
|
||||
|
||||
// Use NOFX's market data — full technical indicators
|
||||
md, err := market.Get(symbol)
|
||||
if err != nil {
|
||||
a.logger.Error("get market data", "symbol", symbol, "error", err)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("🔍 *%s 分析*\n\n⚠️ 市场数据暂不可用,请稍后再试。", strings.TrimSuffix(symbol, "USDT")), nil
|
||||
}
|
||||
return fmt.Sprintf("🔍 *%s Analysis*\n\n⚠️ Market data unavailable. Try again later.", strings.TrimSuffix(symbol, "USDT")), nil
|
||||
}
|
||||
|
||||
// Build rich analysis prompt with real data
|
||||
prompt := buildAnalysisPrompt(symbol, md, L)
|
||||
|
||||
// For now, format the raw market data as analysis
|
||||
return fmt.Sprintf(a.msg(L, "analysis_header"), strings.TrimSuffix(symbol, "USDT")) + "\n\n" + prompt, nil
|
||||
}
|
||||
|
||||
func buildAnalysisPrompt(symbol string, md *market.Data, L string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Analyze %s for trading.\n\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Current Price: $%.4f\n", md.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("1h Change: %.2f%%\n", md.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("4h Change: %.2f%%\n", md.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("EMA20: %.4f\n", md.CurrentEMA20))
|
||||
sb.WriteString(fmt.Sprintf("MACD: %.4f\n", md.CurrentMACD))
|
||||
sb.WriteString(fmt.Sprintf("RSI7: %.2f\n", md.CurrentRSI7))
|
||||
sb.WriteString(fmt.Sprintf("Funding Rate: %.4f%%\n", md.FundingRate*100))
|
||||
if md.OpenInterest != nil {
|
||||
sb.WriteString(fmt.Sprintf("OI: %.0f (avg: %.0f)\n", md.OpenInterest.Latest, md.OpenInterest.Average))
|
||||
}
|
||||
if L == "zh" {
|
||||
sb.WriteString("\n请用中文给出交易建议,包括方向、入场价、止损、止盈。简洁专业。")
|
||||
} else {
|
||||
sb.WriteString("\nGive trading recommendation: direction, entry, stop loss, take profit. Be concise and professional.")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (a *Agent) handleTrade(L string, intent Intent) (string, error) {
|
||||
action := strings.ToLower(intent.Params["action"])
|
||||
detail := intent.Params["detail"]
|
||||
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
// Parse symbol and quantity
|
||||
parts := strings.Fields(detail)
|
||||
if len(parts) < 1 {
|
||||
return a.msg(L, "trade_usage"), nil
|
||||
}
|
||||
|
||||
symbol := strings.ToUpper(parts[0])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
qty := 0.0
|
||||
if len(parts) >= 2 {
|
||||
q, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(a.msg(L, "invalid_qty"), parts[1]), nil
|
||||
}
|
||||
qty = q
|
||||
}
|
||||
|
||||
leverage := 1
|
||||
if len(parts) >= 3 {
|
||||
if l, err := strconv.Atoi(strings.TrimSuffix(strings.ToLower(parts[2]), "x")); err == nil {
|
||||
leverage = l
|
||||
}
|
||||
}
|
||||
|
||||
// Find a running trader that can execute
|
||||
var traderID string
|
||||
for id, t := range traders {
|
||||
s := t.GetStatus()
|
||||
if running, ok := s["is_running"].(bool); ok && running {
|
||||
traderID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if traderID == "" {
|
||||
return a.msg(L, "no_running_trader"), nil
|
||||
}
|
||||
|
||||
_ = qty
|
||||
_ = leverage
|
||||
_ = action
|
||||
|
||||
// For now, acknowledge but don't execute (safety)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("⚡ *交易请求*\n\n• 操作: %s\n• 交易对: %s\n• 数量: %.6f\n• 杠杆: %dx\n• Trader: %s\n\n⚠️ 自动执行功能开发中。请在 Web UI 中操作。",
|
||||
strings.ToUpper(action), symbol, qty, leverage, traderID[:8]), nil
|
||||
}
|
||||
return fmt.Sprintf("⚡ *Trade Request*\n\n• Action: %s\n• Symbol: %s\n• Qty: %.6f\n• Leverage: %dx\n• Trader: %s\n\n⚠️ Auto-execution coming soon. Please use Web UI.",
|
||||
strings.ToUpper(action), symbol, qty, leverage, traderID[:8]), nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleWatch(L string, intent Intent) string {
|
||||
parts := strings.Fields(intent.Raw)
|
||||
if len(parts) < 2 {
|
||||
if a.sentinel == nil {
|
||||
return a.msg(L, "sentinel_off")
|
||||
}
|
||||
return a.sentinel.FormatWatchlist(L)
|
||||
}
|
||||
|
||||
cmd := strings.ToLower(parts[0])
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
if a.sentinel == nil {
|
||||
return a.msg(L, "sentinel_off")
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "/watch":
|
||||
a.sentinel.AddSymbol(symbol)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("👁️ 已添加 *%s* 到监控列表", symbol)
|
||||
}
|
||||
return fmt.Sprintf("👁️ Now watching *%s*", symbol)
|
||||
case "/unwatch":
|
||||
a.sentinel.RemoveSymbol(symbol)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("🚫 已移除 *%s*", symbol)
|
||||
}
|
||||
return fmt.Sprintf("🚫 Removed *%s* from watchlist", symbol)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Agent) handleStrategyCmd(L string, intent Intent) string {
|
||||
parts := strings.Fields(intent.Raw)
|
||||
if len(parts) < 2 {
|
||||
result, _ := a.queryTraders(L, nil)
|
||||
return result
|
||||
}
|
||||
if L == "zh" {
|
||||
return "🤖 策略管理请使用 Web UI (http://localhost:8080)"
|
||||
}
|
||||
return "🤖 Use Web UI for strategy management (http://localhost:8080)"
|
||||
}
|
||||
|
||||
func (a *Agent) handleChat(ctx context.Context, L string, userID int64, text string) (string, error) {
|
||||
// TODO: integrate with NOFX's MCP client for AI chat
|
||||
// For now, route to the existing Telegram agent or return guidance
|
||||
if L == "zh" {
|
||||
return "🤖 收到。AI 对话功能正在接入 NOFX 的 AI 引擎。\n\n你可以试试:\n• `/analyze BTC` — 市场分析\n• `/positions` — 查看持仓\n• `/status` — 系统状态", nil
|
||||
}
|
||||
return "🤖 Got it. AI chat is being integrated with NOFX's AI engine.\n\nTry:\n• `/analyze BTC` — market analysis\n• `/positions` — view positions\n• `/status` — system status", nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSignal(sig Signal) {
|
||||
if a.brain != nil {
|
||||
a.brain.HandleSignal(sig)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) notifyAll(text string) {
|
||||
// Notify via Telegram (using existing NOFX telegram system)
|
||||
if a.NotifyFunc != nil {
|
||||
a.NotifyFunc(0, text)
|
||||
}
|
||||
}
|
||||
146
agent/brain.go
Normal file
146
agent/brain.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brain handles proactive intelligence: signals, news, market briefs.
|
||||
type Brain struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
recentSignals sync.Map // debounce
|
||||
}
|
||||
|
||||
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
|
||||
return &Brain{agent: agent, logger: logger, 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)
|
||||
go 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 := http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
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"`
|
||||
}
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
if len(seen) > 1000 { for k := range seen { delete(seen, k) } }
|
||||
}
|
||||
|
||||
func (b *Brain) StartMarketBriefs(hours []int) {
|
||||
go 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 := http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
||||
if err != nil { continue }
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var t map[string]string
|
||||
json.Unmarshal(body, &t)
|
||||
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)
|
||||
}
|
||||
86
agent/i18n.go
Normal file
86
agent/i18n.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package agent
|
||||
|
||||
var i18nMessages = map[string]map[string]string{
|
||||
"help": {
|
||||
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
|
||||
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
|
||||
"*查询:* /positions /balance /pnl /traders\n" +
|
||||
"*分析:* /analyze BTC\n" +
|
||||
"*监控:* /watch BTC · /unwatch BTC\n" +
|
||||
"*策略:* /strategy\n" +
|
||||
"*系统:* /status /help\n\n" +
|
||||
"直接跟我说话就行,中英文都可以 💬",
|
||||
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
|
||||
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
|
||||
"*Query:* /positions /balance /pnl /traders\n" +
|
||||
"*Analyze:* /analyze BTC\n" +
|
||||
"*Monitor:* /watch BTC · /unwatch BTC\n" +
|
||||
"*Strategy:* /strategy\n" +
|
||||
"*System:* /status /help\n\n" +
|
||||
"Just talk to me in any language 💬",
|
||||
},
|
||||
"status": {
|
||||
"zh": "📊 *NOFXi 状态*\n\n• 运行中 Traders: %d/%d\n• 监控交易对: %d\n• 时间: %s",
|
||||
"en": "📊 *NOFXi Status*\n\n• Running Traders: %d/%d\n• Watching: %d symbols\n• Time: %s",
|
||||
},
|
||||
"no_traders": {
|
||||
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
|
||||
"en": "📭 No traders configured. Create one in Web UI.",
|
||||
},
|
||||
"no_running_trader": {
|
||||
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
|
||||
"en": "⚠️ No running trader. Start one in Web UI.",
|
||||
},
|
||||
"no_positions": {
|
||||
"zh": "📭 当前没有持仓。",
|
||||
"en": "📭 No open positions.",
|
||||
},
|
||||
"positions_header": {
|
||||
"zh": "📊 *当前持仓*\n\n",
|
||||
"en": "📊 *Open Positions*\n\n",
|
||||
},
|
||||
"total_pnl": {
|
||||
"zh": "💰 *总未实现盈亏: $%.2f*",
|
||||
"en": "💰 *Total Unrealized P/L: $%.2f*",
|
||||
},
|
||||
"balance_header": {
|
||||
"zh": "💰 *账户余额*\n\n",
|
||||
"en": "💰 *Account Balances*\n\n",
|
||||
},
|
||||
"traders_header": {
|
||||
"zh": "🤖 *Traders*\n\n",
|
||||
"en": "🤖 *Traders*\n\n",
|
||||
},
|
||||
"trade_usage": {
|
||||
"zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
|
||||
"en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
|
||||
},
|
||||
"invalid_qty": {
|
||||
"zh": "❓ 无效数量: %s",
|
||||
"en": "❓ Invalid quantity: %s",
|
||||
},
|
||||
"analysis_header": {
|
||||
"zh": "🔍 *%s 市场分析*",
|
||||
"en": "🔍 *%s Analysis*",
|
||||
},
|
||||
"sentinel_off": {
|
||||
"zh": "⚠️ Sentinel 未启用。",
|
||||
"en": "⚠️ Sentinel not enabled.",
|
||||
},
|
||||
"system_prompt": {
|
||||
"zh": "你是 NOFXi,一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
|
||||
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
|
||||
},
|
||||
}
|
||||
|
||||
func (a *Agent) msg(lang, key string) string {
|
||||
if m, ok := i18nMessages[key]; ok {
|
||||
if s, ok := m[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := m["en"]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
102
agent/router.go
Normal file
102
agent/router.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type IntentType int
|
||||
|
||||
const (
|
||||
IntentChat IntentType = iota
|
||||
IntentTrade
|
||||
IntentQuery
|
||||
IntentAnalyze
|
||||
IntentSettings
|
||||
IntentHelp
|
||||
IntentStatus
|
||||
IntentWatch
|
||||
IntentStrategy
|
||||
)
|
||||
|
||||
type Intent struct {
|
||||
Type IntentType
|
||||
Params map[string]string
|
||||
Raw string
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
tradePatterns []*regexp.Regexp
|
||||
queryPatterns []*regexp.Regexp
|
||||
analyzePatterns []*regexp.Regexp
|
||||
}
|
||||
|
||||
func NewRouter() *Router {
|
||||
return &Router{
|
||||
tradePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(buy|sell|long|short|open|close)\s+(.+)`),
|
||||
regexp.MustCompile(`(?i)(做多|做空|买入|卖出|开仓|平仓)\s*(.+)`),
|
||||
},
|
||||
queryPatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(position|balance|pnl|profit|loss|持仓|余额|盈亏|trader|交易员)`),
|
||||
},
|
||||
analyzePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(analyze|analysis|分析|看看)\s+(.+)`),
|
||||
regexp.MustCompile(`(?i)(what.*think|怎么看|你觉得)\s*(.+)?`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Route(text string) Intent {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if strings.HasPrefix(text, "/") {
|
||||
return r.routeCommand(text)
|
||||
}
|
||||
|
||||
for _, p := range r.tradePatterns {
|
||||
if m := p.FindStringSubmatch(text); m != nil {
|
||||
return Intent{Type: IntentTrade, Params: map[string]string{"action": strings.ToLower(m[1]), "detail": m[2]}, Raw: text}
|
||||
}
|
||||
}
|
||||
for _, p := range r.queryPatterns {
|
||||
if p.MatchString(text) {
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
}
|
||||
}
|
||||
for _, p := range r.analyzePatterns {
|
||||
if m := p.FindStringSubmatch(text); m != nil {
|
||||
d := ""
|
||||
if len(m) > 2 { d = m[2] }
|
||||
return Intent{Type: IntentAnalyze, Params: map[string]string{"detail": d}, Raw: text}
|
||||
}
|
||||
}
|
||||
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
|
||||
func (r *Router) routeCommand(text string) Intent {
|
||||
cmd := strings.ToLower(strings.Fields(text)[0])
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
detail := ""
|
||||
if len(parts) > 1 { detail = parts[1] }
|
||||
|
||||
switch cmd {
|
||||
case "/start", "/help":
|
||||
return Intent{Type: IntentHelp, Raw: text}
|
||||
case "/status":
|
||||
return Intent{Type: IntentStatus, Raw: text}
|
||||
case "/buy", "/sell", "/long", "/short", "/open", "/close":
|
||||
return Intent{Type: IntentTrade, Params: map[string]string{"action": strings.TrimPrefix(cmd, "/"), "detail": detail}, Raw: text}
|
||||
case "/positions", "/balance", "/pnl", "/traders":
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
case "/analyze":
|
||||
return Intent{Type: IntentAnalyze, Params: map[string]string{"detail": detail}, Raw: text}
|
||||
case "/watch", "/unwatch":
|
||||
return Intent{Type: IntentWatch, Raw: text}
|
||||
case "/strategy":
|
||||
return Intent{Type: IntentStrategy, Raw: text}
|
||||
default:
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
}
|
||||
99
agent/scheduler.go
Normal file
99
agent/scheduler.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
|
||||
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastReport := time.Time{}
|
||||
lastCheck := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): return
|
||||
case <-s.stopCh: return
|
||||
case now := <-ticker.C:
|
||||
// Daily report at 21:00
|
||||
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
|
||||
s.dailyReport()
|
||||
lastReport = now
|
||||
}
|
||||
// Position risk check every 4h
|
||||
if now.Sub(lastCheck) > 4*time.Hour {
|
||||
s.riskCheck()
|
||||
lastCheck = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() { close(s.stopCh) }
|
||||
|
||||
func (s *Scheduler) dailyReport() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
traders := s.agent.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 { return }
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
|
||||
|
||||
totalPnL := 0.0
|
||||
for _, t := range traders {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil { continue }
|
||||
equity := toFloat(info["total_equity"])
|
||||
pnl := toFloat(info["unrealized_pnl"])
|
||||
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
|
||||
totalPnL += pnl
|
||||
}
|
||||
e := "📈"
|
||||
if totalPnL < 0 { e = "📉" }
|
||||
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
|
||||
|
||||
s.agent.notifyAll(sb.String())
|
||||
}
|
||||
|
||||
func (s *Scheduler) riskCheck() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
var alerts []string
|
||||
for _, t := range s.agent.traderManager.GetAllTraders() {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil { continue }
|
||||
for _, p := range positions {
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 { continue }
|
||||
entry := toFloat(p["entryPrice"])
|
||||
if entry > 0 {
|
||||
pnlPct := (pnl / (entry * size)) * 100
|
||||
if pnlPct < -5 {
|
||||
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
|
||||
p["symbol"], p["side"], pnlPct, pnl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
|
||||
}
|
||||
}
|
||||
167
agent/sentinel.go
Normal file
167
agent/sentinel.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SignalType string
|
||||
|
||||
const (
|
||||
SignalPriceBreakout SignalType = "price_breakout"
|
||||
SignalVolumeSpike SignalType = "volume_spike"
|
||||
SignalFundingRate SignalType = "funding_rate"
|
||||
)
|
||||
|
||||
type Signal struct {
|
||||
Type SignalType
|
||||
Symbol string
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
Price float64
|
||||
Change float64
|
||||
}
|
||||
|
||||
type SignalCallback func(Signal)
|
||||
|
||||
type Sentinel struct {
|
||||
mu sync.RWMutex
|
||||
symbols []string
|
||||
history map[string][]pricePt
|
||||
onSignal SignalCallback
|
||||
http *http.Client
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type pricePt struct {
|
||||
Price float64
|
||||
Volume float64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
|
||||
return &Sentinel{
|
||||
symbols: symbols,
|
||||
history: make(map[string][]pricePt),
|
||||
onSignal: cb,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
logger: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) Start() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
s.scan()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.scan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Sentinel) Stop() { close(s.stopCh) }
|
||||
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
|
||||
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
|
||||
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
|
||||
|
||||
func (s *Sentinel) FormatWatchlist(L string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.symbols) == 0 {
|
||||
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
|
||||
return "📭 Watchlist empty. Use `/watch BTC` to add."
|
||||
}
|
||||
var sb strings.Builder
|
||||
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
|
||||
for _, sym := range s.symbols {
|
||||
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
|
||||
last := pts[len(pts)-1]
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Sentinel) scan() {
|
||||
s.mu.RLock()
|
||||
syms := make([]string, len(s.symbols))
|
||||
copy(syms, s.symbols)
|
||||
s.mu.RUnlock()
|
||||
for _, sym := range syms {
|
||||
s.check(sym)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) check(symbol string) {
|
||||
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var t map[string]interface{}
|
||||
json.Unmarshal(body, &t)
|
||||
|
||||
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
|
||||
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
|
||||
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
|
||||
|
||||
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
|
||||
s.mu.Lock()
|
||||
h := s.history[symbol]
|
||||
h = append(h, pt)
|
||||
if len(h) > 60 { h = h[len(h)-60:] }
|
||||
s.history[symbol] = h
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(h) < 5 { return }
|
||||
|
||||
// Price breakout (>3% in 5 min)
|
||||
old := h[len(h)-5]
|
||||
pct := ((price - old.Price) / old.Price) * 100
|
||||
if math.Abs(pct) >= 3.0 {
|
||||
sev := "warning"
|
||||
if math.Abs(pct) >= 6.0 { sev = "critical" }
|
||||
dir := "📈 拉升"
|
||||
if pct < 0 { dir = "📉 下跌" }
|
||||
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
|
||||
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
|
||||
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
|
||||
Price: price, Change: pct})
|
||||
}
|
||||
|
||||
// Volume spike (>3x avg)
|
||||
if len(h) >= 10 {
|
||||
var avg float64
|
||||
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
|
||||
avg /= float64(len(h) - 1)
|
||||
if avg > 0 && vol > avg*3 {
|
||||
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
|
||||
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
|
||||
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
|
||||
Price: price, Change: chg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) emit(sig Signal) {
|
||||
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
|
||||
if s.onSignal != nil { s.onSignal(sig) }
|
||||
}
|
||||
141
agent/web.go
Normal file
141
agent/web.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebHandler provides HTTP endpoints for the NOFXi agent.
|
||||
// These are registered on the existing NOFX API server.
|
||||
type WebHandler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebHandler creates a new web handler.
|
||||
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
|
||||
return &WebHandler{agent: agent, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers agent API routes on an existing mux or gin router.
|
||||
// For now we use a standalone http server on a separate port.
|
||||
func (w *WebHandler) StartStandalone(port int) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health
|
||||
mux.HandleFunc("/api/agent/health", func(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
|
||||
})
|
||||
|
||||
// Chat endpoint
|
||||
mux.HandleFunc("/api/agent/chat", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = 1
|
||||
}
|
||||
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||
})
|
||||
|
||||
// Kline data
|
||||
mux.HandleFunc("/api/agent/klines", handleKlines)
|
||||
|
||||
// Ticker
|
||||
mux.HandleFunc("/api/agent/ticker", handleTicker)
|
||||
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
w.logger.Info("NOFXi agent web API starting", "port", port)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
w.logger.Error("agent web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func handleKlines(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
interval := r.URL.Query().Get("interval")
|
||||
if interval == "" { interval = "1h" }
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 32768)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
|
||||
func handleTicker(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" { symbol = "BTCUSDT" }
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user