mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 21:12:00 +08:00
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
567 lines
15 KiB
Go
567 lines
15 KiB
Go
// 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:*
|
||
• "帮我做多 ETH,2x 杠杆,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)
|
||
}
|