mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +08:00
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:
499
nofxi/internal/agent/agent.go
Normal file
499
nofxi/internal/agent/agent.go
Normal 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:*
|
||||
• "帮我做多 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
|
||||
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)
|
||||
}
|
||||
98
nofxi/internal/agent/config.go
Normal file
98
nofxi/internal/agent/config.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the complete NOFXi configuration.
|
||||
type Config struct {
|
||||
Agent AgentConfig `yaml:"agent"`
|
||||
Telegram TelegramConfig `yaml:"telegram"`
|
||||
LLM LLMConfig `yaml:"llm"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Exchanges []ExchangeConfig `yaml:"exchanges"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
Name string `yaml:"name"` // Agent display name
|
||||
Language string `yaml:"language"` // "en" or "zh"
|
||||
LogLevel string `yaml:"log_level"` // "debug", "info", "warn", "error"
|
||||
WebPort int `yaml:"web_port"` // REST API port (0 = disabled)
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
AllowedIDs []int64 `yaml:"allowed_ids"` // Allowed Telegram user IDs (empty = allow all)
|
||||
}
|
||||
|
||||
type LLMConfig struct {
|
||||
Provider string `yaml:"provider"` // "openai", "claw402"
|
||||
BaseURL string `yaml:"base_url"` // API base URL
|
||||
APIKey string `yaml:"api_key"`
|
||||
Model string `yaml:"model"` // e.g. "gpt-4o", "deepseek-chat"
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"` // SQLite file path
|
||||
}
|
||||
|
||||
type ExchangeConfig struct {
|
||||
Name string `yaml:"name"` // "binance", "okx", "bybit", etc.
|
||||
APIKey string `yaml:"api_key"`
|
||||
APISecret string `yaml:"api_secret"`
|
||||
Passphrase string `yaml:"passphrase"` // OKX needs this
|
||||
Testnet bool `yaml:"testnet"`
|
||||
}
|
||||
|
||||
// LoadConfig reads config from a YAML file.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Agent: AgentConfig{
|
||||
Name: "NOFXi",
|
||||
Language: "en",
|
||||
LogLevel: "info",
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "nofxi.db",
|
||||
},
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
// Override with env vars if set
|
||||
if v := os.Getenv("NOFXI_TELEGRAM_TOKEN"); v != "" {
|
||||
cfg.Telegram.Token = v
|
||||
}
|
||||
if v := os.Getenv("NOFXI_LLM_API_KEY"); v != "" {
|
||||
cfg.LLM.APIKey = v
|
||||
}
|
||||
if v := os.Getenv("NOFXI_LLM_BASE_URL"); v != "" {
|
||||
cfg.LLM.BaseURL = v
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks required fields.
|
||||
func (c *Config) Validate() error {
|
||||
if c.Telegram.Token == "" {
|
||||
return fmt.Errorf("telegram.token is required")
|
||||
}
|
||||
if c.LLM.APIKey == "" {
|
||||
return fmt.Errorf("llm.api_key is required")
|
||||
}
|
||||
if c.LLM.Model == "" {
|
||||
return fmt.Errorf("llm.model is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
153
nofxi/internal/agent/router.go
Normal file
153
nofxi/internal/agent/router.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Intent represents the parsed user intent.
|
||||
type Intent struct {
|
||||
Type IntentType
|
||||
Params map[string]string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// IntentType identifies what the user wants to do.
|
||||
type IntentType int
|
||||
|
||||
const (
|
||||
IntentChat IntentType = iota // General conversation
|
||||
IntentTrade // Open/close a trade
|
||||
IntentQuery // Query positions, balance, P/L
|
||||
IntentAnalyze // Ask for market analysis
|
||||
IntentSettings // Change preferences
|
||||
IntentHelp // Help / command list
|
||||
IntentStatus // Check agent/system status
|
||||
IntentWatch // Watch symbols, price alerts
|
||||
)
|
||||
|
||||
var (
|
||||
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|持仓|余额|盈亏)`),
|
||||
regexp.MustCompile(`(?i)(show|list|查看)\s*(position|trade|order|持仓|订单)`),
|
||||
}
|
||||
analyzePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(analyze|analysis|分析|看看)\s+(.+)`),
|
||||
regexp.MustCompile(`(?i)(what.*think|怎么看|你觉得)\s*(.+)?`),
|
||||
}
|
||||
settingsPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(set|config|设置|配置)\s+(.+)`),
|
||||
}
|
||||
)
|
||||
|
||||
// Route parses user input and determines intent.
|
||||
func Route(text string) Intent {
|
||||
text = strings.TrimSpace(text)
|
||||
lower := strings.ToLower(text)
|
||||
|
||||
// Commands
|
||||
if strings.HasPrefix(lower, "/") {
|
||||
return routeCommand(text)
|
||||
}
|
||||
|
||||
// Trade intent
|
||||
for _, p := range 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query intent
|
||||
for _, p := range queryPatterns {
|
||||
if p.MatchString(text) {
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze intent
|
||||
for _, p := range analyzePatterns {
|
||||
if m := p.FindStringSubmatch(text); m != nil {
|
||||
detail := ""
|
||||
if len(m) > 2 {
|
||||
detail = m[2]
|
||||
}
|
||||
return Intent{
|
||||
Type: IntentAnalyze,
|
||||
Params: map[string]string{
|
||||
"detail": detail,
|
||||
},
|
||||
Raw: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings intent
|
||||
for _, p := range settingsPatterns {
|
||||
if m := p.FindStringSubmatch(text); m != nil {
|
||||
return Intent{
|
||||
Type: IntentSettings,
|
||||
Params: map[string]string{
|
||||
"detail": m[2],
|
||||
},
|
||||
Raw: text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: chat
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
|
||||
func routeCommand(text string) Intent {
|
||||
cmd := strings.ToLower(strings.Fields(text)[0])
|
||||
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":
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
detail := ""
|
||||
if len(parts) > 1 {
|
||||
detail = parts[1]
|
||||
}
|
||||
return Intent{
|
||||
Type: IntentTrade,
|
||||
Params: map[string]string{
|
||||
"action": strings.TrimPrefix(cmd, "/"),
|
||||
"detail": detail,
|
||||
},
|
||||
Raw: text,
|
||||
}
|
||||
case "/positions", "/balance", "/pnl":
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
case "/watch", "/unwatch", "/alert", "/price":
|
||||
return Intent{Type: IntentWatch, Raw: text}
|
||||
case "/analyze":
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
detail := ""
|
||||
if len(parts) > 1 {
|
||||
detail = parts[1]
|
||||
}
|
||||
return Intent{
|
||||
Type: IntentAnalyze,
|
||||
Params: map[string]string{"detail": detail},
|
||||
Raw: text,
|
||||
}
|
||||
case "/settings", "/config":
|
||||
return Intent{Type: IntentSettings, Raw: text}
|
||||
default:
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
}
|
||||
187
nofxi/internal/agent/scheduler.go
Normal file
187
nofxi/internal/agent/scheduler.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NoFxAiOS/nofx/nofxi/internal/memory"
|
||||
)
|
||||
|
||||
// Scheduler handles periodic tasks: daily reports, portfolio checks, etc.
|
||||
type Scheduler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
notifyFn func(userID int64, text string) error
|
||||
}
|
||||
|
||||
// NewScheduler creates a new task scheduler.
|
||||
func NewScheduler(agent *Agent, logger *slog.Logger) *Scheduler {
|
||||
return &Scheduler{
|
||||
agent: agent,
|
||||
logger: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotifyFunc sets the notification callback.
|
||||
func (s *Scheduler) SetNotifyFunc(fn func(userID int64, text string) error) {
|
||||
s.notifyFn = fn
|
||||
}
|
||||
|
||||
// Start begins the scheduler loop.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop stops the scheduler.
|
||||
func (s *Scheduler) Stop() {
|
||||
close(s.stopCh)
|
||||
}
|
||||
|
||||
func (s *Scheduler) loop(ctx context.Context) {
|
||||
// Check every minute for scheduled tasks
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastDailyReport := time.Time{}
|
||||
lastPortfolioCheck := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
hour := now.Hour()
|
||||
|
||||
// Daily report at 21:00 (9 PM)
|
||||
if hour == 21 && now.Sub(lastDailyReport) > 12*time.Hour {
|
||||
s.sendDailyReport(ctx)
|
||||
lastDailyReport = now
|
||||
}
|
||||
|
||||
// Portfolio check every 4 hours
|
||||
if now.Sub(lastPortfolioCheck) > 4*time.Hour {
|
||||
s.checkPortfolio(ctx)
|
||||
lastPortfolioCheck = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) sendDailyReport(ctx context.Context) {
|
||||
s.logger.Info("generating daily report")
|
||||
|
||||
trades, err := s.agent.memory.GetRecentTrades(50)
|
||||
if err != nil {
|
||||
s.logger.Error("get trades for daily report", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter today's trades
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
var todayTrades []memory.TradeRecord
|
||||
totalPnL := 0.0
|
||||
wins := 0
|
||||
|
||||
for _, t := range trades {
|
||||
if t.CreatedAt.After(today) {
|
||||
todayTrades = append(todayTrades, t)
|
||||
totalPnL += t.PnL
|
||||
if t.PnL > 0 {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(todayTrades) == 0 {
|
||||
s.logger.Info("no trades today, skipping daily report")
|
||||
return
|
||||
}
|
||||
|
||||
winRate := 0.0
|
||||
if len(todayTrades) > 0 {
|
||||
winRate = float64(wins) / float64(len(todayTrades)) * 100
|
||||
}
|
||||
|
||||
emoji := "📈"
|
||||
if totalPnL < 0 {
|
||||
emoji = "📉"
|
||||
}
|
||||
|
||||
report := fmt.Sprintf(`%s *NOFXi Daily Report — %s*
|
||||
|
||||
📊 *Summary*
|
||||
• Trades: %d
|
||||
• Win Rate: %.1f%%
|
||||
• Total P/L: $%.2f
|
||||
|
||||
`, emoji, time.Now().Format("2006-01-02"), len(todayTrades), winRate, totalPnL)
|
||||
|
||||
// Add trade details
|
||||
if len(todayTrades) > 0 {
|
||||
report += "📋 *Trades*\n"
|
||||
for _, t := range todayTrades {
|
||||
e := "🟢"
|
||||
if t.PnL < 0 {
|
||||
e = "🔴"
|
||||
}
|
||||
report += fmt.Sprintf("%s %s %s — P/L: $%.2f\n", e, t.Side, t.Symbol, t.PnL)
|
||||
}
|
||||
}
|
||||
|
||||
report += "\n_Generated by NOFXi 🤖_"
|
||||
|
||||
s.notify(report)
|
||||
}
|
||||
|
||||
func (s *Scheduler) checkPortfolio(ctx context.Context) {
|
||||
if s.agent.bridge == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("checking portfolio")
|
||||
|
||||
var alerts []string
|
||||
|
||||
for _, ex := range s.agent.config.Exchanges {
|
||||
positions, err := s.agent.bridge.GetPositions(ex.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, p := range positions {
|
||||
// Alert on large unrealized losses (> 5%)
|
||||
if p.EntryPrice > 0 && p.PnL < 0 {
|
||||
lossPct := (p.PnL / (p.EntryPrice * p.Size)) * 100
|
||||
if lossPct < -5 {
|
||||
alerts = append(alerts, fmt.Sprintf(
|
||||
"⚠️ *%s* %s: %.1f%% loss ($%.2f)",
|
||||
p.Symbol, strings.ToUpper(p.Side), lossPct, p.PnL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(alerts) > 0 {
|
||||
msg := "🚨 *Portfolio Alert*\n\n" + strings.Join(alerts, "\n")
|
||||
s.notify(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) notify(text string) {
|
||||
if s.notifyFn == nil {
|
||||
s.logger.Warn("no notification function set, cannot send scheduled message")
|
||||
return
|
||||
}
|
||||
for _, uid := range s.agent.config.Telegram.AllowedIDs {
|
||||
if err := s.notifyFn(uid, text); err != nil {
|
||||
s.logger.Error("send notification", "user_id", uid, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
nofxi/internal/agent/watchcmd.go
Normal file
125
nofxi/internal/agent/watchcmd.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HandleWatchCommand processes /watch and /alert commands.
|
||||
// Returns response text. Called from HandleMessage when intent is detected.
|
||||
func (a *Agent) HandleWatchCommand(text string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
|
||||
// /watch BTC
|
||||
if strings.HasPrefix(lower, "/watch") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
return a.listWatched()
|
||||
}
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
if a.monitor != nil {
|
||||
a.monitor.Watch(symbol)
|
||||
return fmt.Sprintf("👁️ Now watching *%s*. I'll track the price.", symbol)
|
||||
}
|
||||
return "⚠️ Market monitor not available."
|
||||
}
|
||||
|
||||
// /unwatch BTC
|
||||
if strings.HasPrefix(lower, "/unwatch") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
return "Usage: `/unwatch BTC`"
|
||||
}
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
if a.monitor != nil {
|
||||
a.monitor.Unwatch(symbol)
|
||||
return fmt.Sprintf("🚫 Stopped watching *%s*.", symbol)
|
||||
}
|
||||
return "⚠️ Market monitor not available."
|
||||
}
|
||||
|
||||
// /alert BTC above 100000
|
||||
if strings.HasPrefix(lower, "/alert") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 4 {
|
||||
return "Usage: `/alert BTC above 100000` or `/alert ETH below 3000`"
|
||||
}
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
direction := strings.ToLower(parts[2])
|
||||
if direction != "above" && direction != "below" {
|
||||
return "Direction must be `above` or `below`."
|
||||
}
|
||||
threshold, err := strconv.ParseFloat(parts[3], 64)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Invalid price: %s", parts[3])
|
||||
}
|
||||
|
||||
if a.monitor != nil {
|
||||
a.monitor.Watch(symbol) // Ensure we're watching it
|
||||
a.monitor.AddAlert(symbol, direction, threshold)
|
||||
emoji := "📈"
|
||||
if direction == "below" {
|
||||
emoji = "📉"
|
||||
}
|
||||
return fmt.Sprintf("%s Alert set: *%s* %s $%.2f\nI'll notify you when it triggers.",
|
||||
emoji, symbol, direction, threshold)
|
||||
}
|
||||
return "⚠️ Market monitor not available."
|
||||
}
|
||||
|
||||
// /price BTC
|
||||
if strings.HasPrefix(lower, "/price") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
return "Usage: `/price BTC`"
|
||||
}
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
if a.monitor != nil {
|
||||
if snap, ok := a.monitor.GetSnapshot(symbol); ok && snap.LastPrice > 0 {
|
||||
return fmt.Sprintf("💰 *%s*: $%.4f\n_Updated: %s_",
|
||||
symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05"))
|
||||
}
|
||||
// Not watching yet, watch and return message
|
||||
a.monitor.Watch(symbol)
|
||||
return fmt.Sprintf("👁️ Started watching *%s*. Price will be available in ~30s.", symbol)
|
||||
}
|
||||
return "⚠️ Market monitor not available."
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Agent) listWatched() string {
|
||||
if a.monitor == nil {
|
||||
return "⚠️ Market monitor not available."
|
||||
}
|
||||
snaps := a.monitor.GetAllSnapshots()
|
||||
if len(snaps) == 0 {
|
||||
return "📭 Not watching any symbols. Use `/watch BTC` to start."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("👁️ *Watching*\n\n")
|
||||
for symbol, snap := range snaps {
|
||||
if snap.LastPrice > 0 {
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n",
|
||||
symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: waiting for data...\n", symbol))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user