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,68 @@
package interaction
import (
"fmt"
"strings"
"time"
)
// FormatTradeAlert formats a trade event for Telegram notification.
func FormatTradeAlert(action, symbol, exchange string, price, quantity float64) string {
emoji := "🟢"
if action == "sell" || action == "short" || action == "close" {
emoji = "🔴"
}
return fmt.Sprintf(`%s *Trade Executed*
• Action: %s
• Symbol: %s
• Exchange: %s
• Price: $%.4f
• Quantity: %.6f
• Value: $%.2f
• Time: %s`,
emoji,
strings.ToUpper(action),
symbol,
exchange,
price,
quantity,
price*quantity,
time.Now().Format("15:04:05"),
)
}
// FormatDailyReport formats a daily P/L summary.
func FormatDailyReport(totalPnL float64, trades int, winRate float64) string {
emoji := "📈"
if totalPnL < 0 {
emoji = "📉"
}
return fmt.Sprintf(`%s *Daily Report — %s*
• Trades: %d
• Win Rate: %.1f%%
• P/L: $%.2f
Keep going! 💪`,
emoji,
time.Now().Format("2006-01-02"),
trades,
winRate,
totalPnL,
)
}
// FormatPriceAlert formats a price alert notification.
func FormatPriceAlert(symbol string, price float64, direction string, threshold float64) string {
emoji := "🚨"
if direction == "above" {
emoji = "📈"
} else {
emoji = "📉"
}
return fmt.Sprintf(`%s *Price Alert*
%s hit $%.4f (%s $%.4f threshold)`,
emoji, symbol, price, direction, threshold)
}

View File

@@ -0,0 +1,10 @@
// Package interaction implements the Interaction Layer.
//
// Primary interface: Telegram bot
// - Natural language understanding for trading commands
// - Access control (allowed user IDs)
// - Typing indicators and markdown formatting
// - Proactive notifications
//
// Future: Web UI API endpoints
package interaction

View File

@@ -0,0 +1,128 @@
package interaction
import (
"context"
"fmt"
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// MessageHandler is called for each incoming message.
type MessageHandler func(ctx context.Context, userID int64, text string) (string, error)
// TelegramBot wraps the Telegram Bot API.
type TelegramBot struct {
bot *tgbotapi.BotAPI
handler MessageHandler
allowedIDs map[int64]bool // Empty = allow all
logger *slog.Logger
}
// NewTelegramBot creates a new Telegram bot.
func NewTelegramBot(token string, allowedIDs []int64, handler MessageHandler, logger *slog.Logger) (*TelegramBot, error) {
bot, err := tgbotapi.NewBotAPI(token)
if err != nil {
return nil, fmt.Errorf("create bot: %w", err)
}
allowed := make(map[int64]bool)
for _, id := range allowedIDs {
allowed[id] = true
}
logger.Info("telegram bot authorized", "username", bot.Self.UserName)
return &TelegramBot{
bot: bot,
handler: handler,
allowedIDs: allowed,
logger: logger,
}, nil
}
// Start begins polling for updates. Blocks until context is cancelled.
func (t *TelegramBot) Start(ctx context.Context) error {
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := t.bot.GetUpdatesChan(u)
t.logger.Info("telegram bot started, listening for messages...")
for {
select {
case <-ctx.Done():
t.bot.StopReceivingUpdates()
t.logger.Info("telegram bot stopped")
return nil
case update := <-updates:
if update.Message == nil {
continue
}
go t.handleUpdate(ctx, update)
}
}
}
func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update) {
msg := update.Message
userID := msg.From.ID
// Access control
if len(t.allowedIDs) > 0 && !t.allowedIDs[userID] {
t.logger.Warn("unauthorized user", "user_id", userID, "username", msg.From.UserName)
t.sendText(msg.Chat.ID, "⛔ Unauthorized. Contact the admin to get access.")
return
}
text := msg.Text
if text == "" {
return
}
t.logger.Info("received message",
"user_id", userID,
"username", msg.From.UserName,
"text", text,
)
// Send "typing" indicator
typing := tgbotapi.NewChatAction(msg.Chat.ID, tgbotapi.ChatTyping)
t.bot.Send(typing)
// Process message
response, err := t.handler(ctx, userID, text)
if err != nil {
t.logger.Error("handle message", "error", err)
response = fmt.Sprintf("⚠️ Error: %v", err)
}
t.sendMarkdown(msg.Chat.ID, response)
}
func (t *TelegramBot) sendText(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
if _, err := t.bot.Send(msg); err != nil {
t.logger.Error("send message", "error", err)
}
}
func (t *TelegramBot) sendMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
if _, err := t.bot.Send(msg); err != nil {
// Retry without markdown if parsing fails
t.logger.Warn("markdown send failed, retrying plain", "error", err)
t.sendText(chatID, text)
}
}
// SendNotification sends a proactive message to a user.
func (t *TelegramBot) SendNotification(userID int64, text string) error {
msg := tgbotapi.NewMessage(userID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
_, err := t.bot.Send(msg)
return err
}

View File

@@ -0,0 +1,173 @@
package interaction
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
)
// WebServer provides a REST API for NOFXi.
type WebServer struct {
handler MessageHandler
port int
logger *slog.Logger
server *http.Server
}
// NewWebServer creates a new web API server.
func NewWebServer(port int, handler MessageHandler, logger *slog.Logger) *WebServer {
return &WebServer{
handler: handler,
port: port,
logger: logger,
}
}
// chatRequest is the API request body.
type chatRequest struct {
UserID int64 `json:"user_id"`
Message string `json:"message"`
}
// chatResponse is the API response body.
type chatAPIResponse struct {
Response string `json:"response"`
Error string `json:"error,omitempty"`
}
// Start begins listening. Blocks until context is cancelled.
func (w *WebServer) Start(ctx context.Context) error {
mux := http.NewServeMux()
// Health check
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
json.NewEncoder(rw).Encode(map[string]string{
"status": "ok",
"agent": "NOFXi",
"time": time.Now().Format(time.RFC3339),
})
})
// Chat endpoint
mux.HandleFunc("/api/chat", func(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "invalid request body"})
return
}
if req.Message == "" {
writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "message is required"})
return
}
if req.UserID == 0 {
req.UserID = 1 // Default user for API access
}
resp, err := w.handler(r.Context(), req.UserID, req.Message)
if err != nil {
writeJSON(rw, http.StatusInternalServerError, chatAPIResponse{Error: err.Error()})
return
}
writeJSON(rw, http.StatusOK, chatAPIResponse{Response: resp})
})
// OpenAI-compatible endpoint (so other tools can talk to NOFXi)
mux.HandleFunc("/v1/chat/completions", func(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
// Get the last user message
userMsg := ""
for i := len(body.Messages) - 1; i >= 0; i-- {
if body.Messages[i].Role == "user" {
userMsg = body.Messages[i].Content
break
}
}
if userMsg == "" {
writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "no user message"})
return
}
resp, err := w.handler(r.Context(), 1, userMsg)
if err != nil {
writeJSON(rw, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Return OpenAI-compatible response
writeJSON(rw, http.StatusOK, map[string]interface{}{
"id": fmt.Sprintf("nofxi-%d", time.Now().UnixNano()),
"object": "chat.completion",
"created": time.Now().Unix(),
"model": "nofxi",
"choices": []map[string]interface{}{
{
"index": 0,
"message": map[string]string{"role": "assistant", "content": resp},
"finish_reason": "stop",
},
},
})
})
addr := fmt.Sprintf(":%d", w.port)
w.server = &http.Server{
Addr: addr,
Handler: mux,
}
w.logger.Info("web server starting", "addr", addr)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
w.server.Shutdown(shutdownCtx)
}()
if err := w.server.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
// Port returns the configured port.
func (w *WebServer) Port() int {
return w.port
}
// PortStr returns port as string.
func (w *WebServer) PortStr() string {
return strconv.Itoa(w.port)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}