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)
}

View 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
}

View 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}
}
}

View 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)
}
}
}

View 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()
}