Files
nofx/nofxi/internal/agent/agent.go
shinchan-zhai 34f5e6fe71 feat(nofxi): Phase 3 complete - Exchange factory, Web UI, Strategy runner, Docker
Exchange Factory:
- CreateTrader() supports Binance/OKX/Bybit/Bitget/KuCoin/Gate
- Auto-registers traders from config on startup
- Direct import of nofx/trader packages (merged into main module)

Web UI:
- Dark theme chat interface at :8900
- Quick action sidebar (analyze, watch, positions, balance)
- Real-time health check indicator
- Mobile responsive

Strategy Runner:
- /strategy start BTC 1h - AI auto-analyzes on interval
- /strategy stop <id> - stop strategy
- /strategy list - view active strategies
- Notifications pushed to Telegram on signals
- Configurable intervals: 15m/30m/1h/4h

Docker:
- Multi-stage Dockerfile (alpine, ~20MB)
- docker-compose.yml with volume persistence

Bug fixes:
- Fixed panic on empty exchanges config
- Fixed thinking mode response parsing (qwen3 content:null)
- Added request timeout (55s) in Telegram handler
- Better error logging
2026-03-22 22:04:37 +08:00

567 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package agent implements the NOFXi Agent Core.
//
// The Agent is the central orchestrator that:
// 1. Receives user messages (via Interaction layer)
// 2. Routes intents (trade, query, analyze, chat)
// 3. Uses Thinking layer for AI decisions
// 4. Stores context in Memory layer
// 5. Executes trades via Execution bridge (→ NOFX engine)
// 6. Monitors markets via Perception layer
package agent
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"nofx/nofxi/internal/execution"
"nofx/nofxi/internal/memory"
"nofx/nofxi/internal/perception"
"nofx/nofxi/internal/thinking"
)
// Agent is the NOFXi agent core.
type Agent struct {
config *Config
memory *memory.Store
thinker thinking.Engine
bridge *execution.Bridge
monitor *perception.MarketMonitor
logger *slog.Logger
// NotifyFunc sends proactive notifications to users (set by interaction layer)
NotifyFunc func(userID int64, text string) error
// Strategy runner
strategyRunner *StrategyRunner
}
// New creates a new Agent with the given config.
func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.Logger) *Agent {
a := &Agent{
config: cfg,
memory: mem,
thinker: thinker,
logger: logger,
}
a.strategyRunner = NewStrategyRunner(a, logger)
return a
}
// SetBridge attaches the execution bridge.
func (a *Agent) SetBridge(bridge *execution.Bridge) {
a.bridge = bridge
}
// SetMonitor attaches the market monitor and wires up alert notifications.
func (a *Agent) SetMonitor(monitor *perception.MarketMonitor) {
a.monitor = monitor
}
// HandleMessage processes a user message and returns a response.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
a.logger.Info("incoming message", "user_id", userID, "text", text)
// Save user message to memory
if err := a.memory.SaveMessage(userID, "user", text); err != nil {
a.logger.Error("save user message", "error", err)
}
// Route intent
intent := Route(text)
a.logger.Info("routed intent", "type", intent.Type, "params", intent.Params)
var response string
var err error
switch intent.Type {
case IntentHelp:
response = a.handleHelp()
case IntentStatus:
response = a.handleStatus()
case IntentQuery:
response, err = a.handleQuery(ctx, intent)
case IntentAnalyze:
response, err = a.handleAnalyze(ctx, intent)
case IntentTrade:
response, err = a.handleTrade(ctx, userID, intent)
case IntentWatch:
response = a.HandleWatchCommand(intent.Raw)
case IntentStrategy:
response = a.handleStrategyCommand(intent.Raw)
case IntentSettings:
response = a.handleSettings(intent)
default:
response, err = a.handleChat(ctx, userID, text)
}
if err != nil {
a.logger.Error("handle message", "intent", intent.Type, "error", err)
response = fmt.Sprintf("⚠️ Error: %v", err)
}
// Save assistant response to memory
if err := a.memory.SaveMessage(userID, "assistant", response); err != nil {
a.logger.Error("save assistant message", "error", err)
}
return response, nil
}
func (a *Agent) handleHelp() string {
return `🤖 *NOFXi — Your AI Trading Agent*
*Trading:*
/buy BTC 0.01 — Open long (market order)
/sell BTC 0.01 — Open short
/close BTC — Close position
/positions — View open positions
/balance — Check balance
/pnl — Profit & Loss history
*Analysis:*
/analyze BTC — AI market analysis
/watch BTC — Monitor price
/alert BTC above 100000 — Price alert
*System:*
/status — Agent status
/settings — Preferences
/help — This menu
*Natural Language:*
• "帮我做多 ETH2x 杠杆0.1 个"
• "分析一下 BTC 走势"
• "现在持仓情况怎样"
• "BTC 到 10 万提醒我"
Just talk to me 💬`
}
func (a *Agent) handleStatus() string {
// Market monitor status
watchCount := 0
if a.monitor != nil {
watchCount = len(a.monitor.GetAllSnapshots())
}
bridgeStatus := "❌ Not connected"
if a.bridge != nil {
bridgeStatus = "✅ Connected"
}
return fmt.Sprintf(`📊 *NOFXi Status*
• Agent: %s
• Model: %s
• Provider: %s
• Memory: ✅ Online
• Execution: %s
• Watching: %d symbols
• Time: %s`,
a.config.Agent.Name,
a.config.LLM.Model,
a.config.LLM.Provider,
bridgeStatus,
watchCount,
time.Now().Format("2006-01-02 15:04:05"),
)
}
func (a *Agent) handleQuery(ctx context.Context, intent Intent) (string, error) {
raw := strings.ToLower(intent.Raw)
// Try live positions from exchange first
if a.bridge != nil && (strings.Contains(raw, "position") || strings.Contains(raw, "持仓")) {
return a.queryPositions()
}
if a.bridge != nil && (strings.Contains(raw, "balance") || strings.Contains(raw, "余额")) {
return a.queryBalance()
}
// Fall back to trade history from memory
trades, err := a.memory.GetRecentTrades(10)
if err != nil {
return "", fmt.Errorf("get trades: %w", err)
}
if len(trades) == 0 {
return "📭 No trades yet. Start with `/buy BTC 0.01` or ask me to `/analyze BTC`.", nil
}
var sb strings.Builder
sb.WriteString("📋 *Recent Trades*\n\n")
totalPnL := 0.0
for _, t := range trades {
emoji := "🟢"
if t.PnL < 0 {
emoji = "🔴"
}
sb.WriteString(fmt.Sprintf("%s %s %s %s — $%.2f (P/L: $%.2f)\n",
emoji, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL))
totalPnL += t.PnL
}
sb.WriteString(fmt.Sprintf("\n💰 Total P/L: $%.2f", totalPnL))
return sb.String(), nil
}
func (a *Agent) queryPositions() (string, error) {
var allPositions []execution.Position
for _, ex := range a.config.Exchanges {
positions, err := a.bridge.GetPositions(ex.Name)
if err != nil {
a.logger.Error("get positions", "exchange", ex.Name, "error", err)
continue
}
allPositions = append(allPositions, positions...)
}
if len(allPositions) == 0 {
return "📭 No open positions.", nil
}
var sb strings.Builder
sb.WriteString("📊 *Open Positions*\n\n")
totalPnL := 0.0
for _, p := range allPositions {
emoji := "🟢"
if p.PnL < 0 {
emoji = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* %s\n", emoji, p.Symbol, strings.ToUpper(p.Side)))
sb.WriteString(fmt.Sprintf(" Size: %.4f | Entry: $%.2f\n", p.Size, p.EntryPrice))
sb.WriteString(fmt.Sprintf(" Mark: $%.2f | P/L: $%.2f\n", p.MarkPrice, p.PnL))
if p.Leverage > 0 {
sb.WriteString(fmt.Sprintf(" Leverage: %.0fx | Exchange: %s\n", p.Leverage, p.Exchange))
}
sb.WriteString("\n")
totalPnL += p.PnL
}
sb.WriteString(fmt.Sprintf("💰 *Total Unrealized P/L: $%.2f*", totalPnL))
return sb.String(), nil
}
func (a *Agent) queryBalance() (string, error) {
var sb strings.Builder
sb.WriteString("💰 *Account Balance*\n\n")
for _, ex := range a.config.Exchanges {
bal, err := a.bridge.GetBalance(ex.Name)
if err != nil {
a.logger.Error("get balance", "exchange", ex.Name, "error", err)
sb.WriteString(fmt.Sprintf("• %s: ⚠️ Error\n", ex.Name))
continue
}
sb.WriteString(fmt.Sprintf("*%s*\n", strings.Title(ex.Name)))
sb.WriteString(fmt.Sprintf(" Total: $%.2f\n", bal.Total))
sb.WriteString(fmt.Sprintf(" Available: $%.2f\n", bal.Available))
sb.WriteString(fmt.Sprintf(" In Position: $%.2f\n\n", bal.InPosition))
}
return sb.String(), nil
}
func (a *Agent) handleAnalyze(ctx context.Context, intent Intent) (string, error) {
symbol := "BTC"
if detail, ok := intent.Params["detail"]; ok && detail != "" {
symbol = strings.ToUpper(strings.TrimSpace(detail))
}
// Add live price if available
priceInfo := ""
if a.monitor != nil {
if snap, ok := a.monitor.GetSnapshot(symbol + "USDT"); ok && snap.LastPrice > 0 {
priceInfo = fmt.Sprintf("\nCurrent price: $%.2f", snap.LastPrice)
}
}
if priceInfo == "" && a.bridge != nil {
for _, ex := range a.config.Exchanges {
if price, err := a.bridge.GetPrice(ex.Name, symbol+"USDT"); err == nil && price > 0 {
priceInfo = fmt.Sprintf("\nCurrent price: $%.2f (from %s)", price, ex.Name)
break
}
}
}
prompt := fmt.Sprintf(
"Analyze %s/USDT for trading. %s\n"+
"Consider: trend, support/resistance, momentum indicators, volume, sentiment.\n"+
"Give specific entry/exit levels and stop loss. Be concise.",
symbol, priceInfo)
analysis, err := a.thinker.Analyze(ctx, prompt)
if err != nil {
return "", fmt.Errorf("AI analyze: %w", err)
}
emoji := map[string]string{
"buy": "🟢 BUY",
"sell": "🔴 SELL",
"hold": "🟡 HOLD",
"wait": "⏳ WAIT",
}
action := emoji[analysis.Action]
if action == "" {
action = "🤔 " + analysis.Action
}
result := fmt.Sprintf("🔍 *%s/USDT Analysis*\n\nSignal: %s\nConfidence: %.0f%%\n\n%s",
symbol, action, analysis.Confidence*100, analysis.Reasoning)
if analysis.StopLoss > 0 {
result += fmt.Sprintf("\n\n🛑 Stop Loss: $%.2f", analysis.StopLoss)
}
if analysis.TakeProfit > 0 {
result += fmt.Sprintf("\n🎯 Take Profit: $%.2f", analysis.TakeProfit)
}
return result, nil
}
func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (string, error) {
action := strings.ToLower(intent.Params["action"])
detail := intent.Params["detail"]
if a.bridge == nil {
return fmt.Sprintf("⚡ *Trade: %s %s*\n\n🔧 No exchange connected. Configure exchanges in config.yaml first.",
strings.ToUpper(action), detail), nil
}
// Parse: "BTC 0.01" or "BTCUSDT 0.01 2x"
parts := strings.Fields(detail)
if len(parts) < 1 {
return "❓ Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`", nil
}
symbol := strings.ToUpper(parts[0])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
quantity := 0.0
leverage := 1
if len(parts) >= 2 {
q, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return fmt.Sprintf("❓ Invalid quantity: %s", parts[1]), nil
}
quantity = q
}
if len(parts) >= 3 {
levStr := strings.TrimSuffix(strings.ToLower(parts[2]), "x")
l, err := strconv.Atoi(levStr)
if err == nil {
leverage = l
}
}
if quantity <= 0 && (action == "buy" || action == "sell" || action == "long" || action == "short") {
return "❓ Please specify quantity: `/buy BTC 0.01`", nil
}
// Map action to execution side
var side string
switch action {
case "buy", "long", "open_long":
side = "LONG"
case "sell", "short", "open_short":
side = "SHORT"
case "close":
side = "CLOSE_LONG" // TODO: detect which side to close
default:
side = strings.ToUpper(action)
}
// Use first configured exchange
if len(a.config.Exchanges) == 0 {
return "⚠️ 还没有配置交易所。请在 config.yaml 的 exchanges 中添加交易所 API Key。", nil
}
exchange := a.config.Exchanges[0].Name
// Confirm with user before executing
confirmMsg := fmt.Sprintf("⚡ *Confirm Trade*\n\n"+
"• Action: %s\n"+
"• Symbol: %s\n"+
"• Quantity: %.6f\n"+
"• Leverage: %dx\n"+
"• Exchange: %s\n\n"+
"Reply 'yes' to execute.",
side, symbol, quantity, leverage, exchange)
// Store pending trade for confirmation
a.memory.SetPreference(userID, "pending_trade",
fmt.Sprintf("%s|%s|%f|%d|%s", side, symbol, quantity, leverage, exchange))
return confirmMsg, nil
}
// ExecutePendingTrade executes a trade that was previously confirmed.
func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string, error) {
pending, err := a.memory.GetPreference(userID, "pending_trade")
if err != nil || pending == "" {
return "", fmt.Errorf("no pending trade")
}
// Clear pending
a.memory.SetPreference(userID, "pending_trade", "")
parts := strings.Split(pending, "|")
if len(parts) != 5 {
return "", fmt.Errorf("invalid pending trade data")
}
side := parts[0]
symbol := parts[1]
quantity, _ := strconv.ParseFloat(parts[2], 64)
leverage, _ := strconv.Atoi(parts[3])
exchange := parts[4]
result, err := a.bridge.PlaceOrder(exchange, symbol, side, quantity, leverage)
if err != nil {
return "", fmt.Errorf("execute trade: %w", err)
}
// Save trade to memory
tradeRecord := &memory.TradeRecord{
Exchange: exchange,
Symbol: symbol,
Side: strings.ToLower(side),
Type: "market",
Quantity: quantity,
Status: "open",
}
if price, ok := result["avgPrice"].(float64); ok {
tradeRecord.Price = price
}
a.memory.SaveTrade(tradeRecord)
return fmt.Sprintf("✅ *Trade Executed!*\n\n"+
"• %s %s\n"+
"• Qty: %.6f\n"+
"• Leverage: %dx\n"+
"• Exchange: %s\n"+
"• Result: %v",
side, symbol, quantity, leverage, exchange, result), nil
}
func (a *Agent) handleStrategyCommand(text string) string {
parts := strings.Fields(text)
if len(parts) < 2 {
return a.strategyRunner.FormatStrategyList()
}
subcmd := strings.ToLower(parts[1])
switch subcmd {
case "list":
return a.strategyRunner.FormatStrategyList()
case "start":
if len(parts) < 3 {
return "Usage: `/strategy start BTC 1h` or `/strategy start ETH 4h binance`"
}
symbol := strings.ToUpper(parts[2])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
interval := 1 * time.Hour
if len(parts) >= 4 {
switch parts[3] {
case "15m":
interval = 15 * time.Minute
case "30m":
interval = 30 * time.Minute
case "1h":
interval = 1 * time.Hour
case "4h":
interval = 4 * time.Hour
}
}
exchange := "binance"
if len(parts) >= 5 {
exchange = parts[4]
}
name := fmt.Sprintf("AI-%s", symbol)
id, err := a.strategyRunner.StartStrategy(name, symbol, exchange, interval)
if err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return fmt.Sprintf("🚀 Strategy started!\n\n• ID: `%s`\n• Symbol: %s\n• Interval: %s\n• Exchange: %s\n\nStop with: `/strategy stop %s`",
id, symbol, interval, exchange, id)
case "stop":
if len(parts) < 3 {
return "Usage: `/strategy stop <id>`"
}
if err := a.strategyRunner.StopStrategy(parts[2]); err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return "✅ Strategy stopped."
case "stopall":
a.strategyRunner.StopAll()
return "✅ All strategies stopped."
default:
return "Unknown subcommand. Use: `/strategy list|start|stop|stopall`"
}
}
func (a *Agent) handleSettings(intent Intent) string {
return `⚙️ *Settings*
• Language: ` + a.config.Agent.Language + `
• Model: ` + a.config.LLM.Model + `
• Provider: ` + a.config.LLM.Provider + `
• Exchanges: ` + fmt.Sprintf("%d configured", len(a.config.Exchanges))
}
func (a *Agent) handleChat(ctx context.Context, userID int64, text string) (string, error) {
// Check for trade confirmation
lower := strings.ToLower(text)
if lower == "yes" || lower == "y" || lower == "确认" || lower == "是" {
pending, _ := a.memory.GetPreference(userID, "pending_trade")
if pending != "" {
return a.ExecutePendingTrade(ctx, userID)
}
}
// Get conversation history for context
history, err := a.memory.GetRecentMessages(userID, 20)
if err != nil {
a.logger.Error("get history", "error", err)
}
// Build messages with system prompt
messages := []thinking.Message{
{
Role: "system",
Content: fmt.Sprintf(`You are NOFXi, an AI trading agent built on NOFX.
Your capabilities:
- Market analysis and trading recommendations
- Real-time position and balance monitoring
- Trade execution (open/close positions on exchanges)
- Price alerts and market monitoring
- Risk management advice
You support multiple exchanges: Binance, OKX, Bybit, Bitget, KuCoin, Gate, Hyperliquid, etc.
Be concise, confident, and action-oriented. Use trading emojis.
Respond in the same language the user uses.
Current time: %s`, time.Now().Format("2006-01-02 15:04:05")),
},
}
for _, msg := range history {
messages = append(messages, thinking.Message{
Role: msg.Role,
Content: msg.Content,
})
}
messages = append(messages, thinking.Message{
Role: "user",
Content: text,
})
return a.thinker.Chat(ctx, messages)
}