feat: init nofxi - AI Trading Agent module

NOFXi — Your AI Trading Agent, built on NOFX.

Architecture:
- Agent Core: intent routing, conversation memory, trade confirmation
- Memory: SQLite (trades, conversations, preferences, strategies)
- Thinking: LLM client (OpenAI/claw402/DeepSeek compatible)
- Execution: Bridge to NOFX trader engine (9 exchanges)
- Perception: Market monitor, price alerts, anomaly detection
- Interaction: Telegram bot + REST API + OpenAI-compatible endpoint

Features:
- Natural language trading (中英文)
- /buy /sell /analyze /watch /alert /positions /balance
- Daily P/L reports, portfolio risk checks
- Trade confirmation flow (safety first)
- Price polling and alert notifications
This commit is contained in:
shinchan-zhai
2026-03-22 17:48:14 +08:00
parent 9b14c5c84d
commit 6e19ada7fc
25 changed files with 2688 additions and 0 deletions

View File

@@ -0,0 +1,499 @@
// 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"
"github.com/NoFxAiOS/nofx/nofxi/internal/execution"
"github.com/NoFxAiOS/nofx/nofxi/internal/memory"
"github.com/NoFxAiOS/nofx/nofxi/internal/perception"
"github.com/NoFxAiOS/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
}
// 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,
}
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 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
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) 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)
}