mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 23:36:55 +08:00
feat(nofxi): Phase 3 complete - Exchange factory, Web UI, Strategy runner, Docker
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
This commit is contained in:
@@ -17,10 +17,10 @@ import (
|
||||
"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"
|
||||
"nofx/nofxi/internal/execution"
|
||||
"nofx/nofxi/internal/memory"
|
||||
"nofx/nofxi/internal/perception"
|
||||
"nofx/nofxi/internal/thinking"
|
||||
)
|
||||
|
||||
// Agent is the NOFXi agent core.
|
||||
@@ -34,6 +34,9 @@ type Agent struct {
|
||||
|
||||
// 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.
|
||||
@@ -44,6 +47,7 @@ func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.L
|
||||
thinker: thinker,
|
||||
logger: logger,
|
||||
}
|
||||
a.strategyRunner = NewStrategyRunner(a, logger)
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -86,6 +90,8 @@ func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (s
|
||||
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:
|
||||
@@ -369,6 +375,9 @@ func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (s
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -437,6 +446,64 @@ func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string,
|
||||
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*
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
IntentHelp // Help / command list
|
||||
IntentStatus // Check agent/system status
|
||||
IntentWatch // Watch symbols, price alerts
|
||||
IntentStrategy // Start/stop/list strategies
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -134,6 +135,8 @@ func routeCommand(text string) Intent {
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
case "/watch", "/unwatch", "/alert", "/price":
|
||||
return Intent{Type: IntentWatch, Raw: text}
|
||||
case "/strategy":
|
||||
return Intent{Type: IntentStrategy, Raw: text}
|
||||
case "/analyze":
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
detail := ""
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/NoFxAiOS/nofx/nofxi/internal/memory"
|
||||
"nofx/nofxi/internal/memory"
|
||||
)
|
||||
|
||||
// Scheduler handles periodic tasks: daily reports, portfolio checks, etc.
|
||||
|
||||
199
nofxi/internal/agent/strategy.go
Normal file
199
nofxi/internal/agent/strategy.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StrategyRunner manages automated trading strategies.
|
||||
type StrategyRunner struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
|
||||
// Active strategies
|
||||
activeStrategies map[string]*RunningStrategy
|
||||
}
|
||||
|
||||
// RunningStrategy represents an active automated strategy.
|
||||
type RunningStrategy struct {
|
||||
ID string
|
||||
Name string
|
||||
Symbol string
|
||||
Interval time.Duration
|
||||
Exchange string
|
||||
StopCh chan struct{}
|
||||
Running bool
|
||||
}
|
||||
|
||||
// NewStrategyRunner creates a new strategy runner.
|
||||
func NewStrategyRunner(agent *Agent, logger *slog.Logger) *StrategyRunner {
|
||||
return &StrategyRunner{
|
||||
agent: agent,
|
||||
logger: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
activeStrategies: make(map[string]*RunningStrategy),
|
||||
}
|
||||
}
|
||||
|
||||
// StartStrategy begins an AI-driven trading strategy.
|
||||
// The AI will periodically analyze the market and suggest/execute trades.
|
||||
func (r *StrategyRunner) StartStrategy(name, symbol, exchange string, interval time.Duration) (string, error) {
|
||||
id := fmt.Sprintf("%s-%s-%d", strings.ToLower(symbol), strings.ToLower(exchange), time.Now().Unix())
|
||||
|
||||
if _, exists := r.activeStrategies[id]; exists {
|
||||
return "", fmt.Errorf("strategy already running: %s", id)
|
||||
}
|
||||
|
||||
strategy := &RunningStrategy{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Symbol: symbol,
|
||||
Interval: interval,
|
||||
Exchange: exchange,
|
||||
StopCh: make(chan struct{}),
|
||||
Running: true,
|
||||
}
|
||||
|
||||
r.activeStrategies[id] = strategy
|
||||
|
||||
go r.runStrategy(strategy)
|
||||
|
||||
r.logger.Info("strategy started",
|
||||
"id", id,
|
||||
"symbol", symbol,
|
||||
"exchange", exchange,
|
||||
"interval", interval,
|
||||
)
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// StopStrategy stops a running strategy.
|
||||
func (r *StrategyRunner) StopStrategy(id string) error {
|
||||
s, ok := r.activeStrategies[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("strategy not found: %s", id)
|
||||
}
|
||||
close(s.StopCh)
|
||||
s.Running = false
|
||||
delete(r.activeStrategies, id)
|
||||
r.logger.Info("strategy stopped", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAll stops all running strategies.
|
||||
func (r *StrategyRunner) StopAll() {
|
||||
for id, s := range r.activeStrategies {
|
||||
close(s.StopCh)
|
||||
s.Running = false
|
||||
r.logger.Info("strategy stopped", "id", id)
|
||||
}
|
||||
r.activeStrategies = make(map[string]*RunningStrategy)
|
||||
}
|
||||
|
||||
// ListStrategies returns all active strategies.
|
||||
func (r *StrategyRunner) ListStrategies() []*RunningStrategy {
|
||||
result := make([]*RunningStrategy, 0, len(r.activeStrategies))
|
||||
for _, s := range r.activeStrategies {
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *StrategyRunner) runStrategy(s *RunningStrategy) {
|
||||
ticker := time.NewTicker(s.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Initial analysis
|
||||
r.executeStrategyTick(s)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.StopCh:
|
||||
return
|
||||
case <-r.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.executeStrategyTick(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *StrategyRunner) executeStrategyTick(s *RunningStrategy) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r.logger.Info("strategy tick", "id", s.ID, "symbol", s.Symbol)
|
||||
|
||||
// Get AI analysis
|
||||
prompt := fmt.Sprintf(
|
||||
"You are running an automated trading strategy for %s on %s.\n"+
|
||||
"Analyze the current market and decide: should we BUY, SELL, or HOLD?\n"+
|
||||
"Consider risk management. Only trade on high confidence signals.\n"+
|
||||
"Respond with a brief analysis and your recommendation.",
|
||||
s.Symbol, s.Exchange,
|
||||
)
|
||||
|
||||
analysis, err := r.agent.thinker.Analyze(ctx, prompt)
|
||||
if err != nil {
|
||||
r.logger.Error("strategy analysis failed", "id", s.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Info("strategy analysis",
|
||||
"id", s.ID,
|
||||
"action", analysis.Action,
|
||||
"confidence", analysis.Confidence,
|
||||
)
|
||||
|
||||
// Only execute on high confidence
|
||||
if analysis.Confidence < 0.75 {
|
||||
r.logger.Info("strategy: confidence too low, holding", "id", s.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Notify user about the signal
|
||||
if r.agent.NotifyFunc != nil {
|
||||
msg := fmt.Sprintf("🤖 *Strategy Signal: %s*\n\n"+
|
||||
"Symbol: %s\n"+
|
||||
"Action: %s\n"+
|
||||
"Confidence: %.0f%%\n\n"+
|
||||
"%s",
|
||||
s.Name, s.Symbol,
|
||||
strings.ToUpper(analysis.Action),
|
||||
analysis.Confidence*100,
|
||||
analysis.Reasoning,
|
||||
)
|
||||
for _, uid := range r.agent.config.Telegram.AllowedIDs {
|
||||
r.agent.NotifyFunc(uid, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Auto-execute trades based on strategy config
|
||||
// For now, just notify. Users can enable auto-execution in Phase 4.
|
||||
}
|
||||
|
||||
// FormatStrategyList formats active strategies for display.
|
||||
func (r *StrategyRunner) FormatStrategyList() string {
|
||||
strategies := r.ListStrategies()
|
||||
if len(strategies) == 0 {
|
||||
return "📭 No active strategies.\n\nUse `/strategy start BTC 1h` to start one."
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🤖 *Active Strategies*\n\n")
|
||||
for _, s := range strategies {
|
||||
status := "🟢"
|
||||
if !s.Running {
|
||||
status = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* — %s on %s (every %s)\n ID: `%s`\n\n",
|
||||
status, s.Name, s.Symbol, s.Exchange, s.Interval, s.ID))
|
||||
}
|
||||
sb.WriteString("Stop with: `/strategy stop <id>`")
|
||||
return sb.String()
|
||||
}
|
||||
52
nofxi/internal/execution/factory.go
Normal file
52
nofxi/internal/execution/factory.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/okx"
|
||||
)
|
||||
|
||||
// ExchangeConfig holds credentials for creating a trader.
|
||||
type ExchangeConfig struct {
|
||||
Name string
|
||||
APIKey string
|
||||
APISecret string
|
||||
Passphrase string
|
||||
Testnet bool
|
||||
}
|
||||
|
||||
// CreateTrader creates a NofxTrader for the given exchange.
|
||||
func CreateTrader(cfg ExchangeConfig) (NofxTrader, error) {
|
||||
switch strings.ToLower(cfg.Name) {
|
||||
case "binance":
|
||||
return binance.NewFuturesTrader(cfg.APIKey, cfg.APISecret, "nofxi"), nil
|
||||
|
||||
case "okx":
|
||||
return okx.NewOKXTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
|
||||
|
||||
case "bybit":
|
||||
return bybit.NewBybitTrader(cfg.APIKey, cfg.APISecret), nil
|
||||
|
||||
case "bitget":
|
||||
return bitget.NewBitgetTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
|
||||
|
||||
case "kucoin":
|
||||
return kucoin.NewKuCoinTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
|
||||
|
||||
case "gate":
|
||||
return gate.NewGateTrader(cfg.APIKey, cfg.APISecret), nil
|
||||
|
||||
// Hyperliquid needs private key, not API key/secret
|
||||
// case "hyperliquid":
|
||||
// return hyperliquid.NewHyperliquidTrader(cfg.APIKey, "", cfg.Testnet)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported exchange: %s", cfg.Name)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
@@ -65,7 +66,7 @@ func (t *TelegramBot) Start(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update) {
|
||||
func (t *TelegramBot) handleUpdate(parentCtx context.Context, update tgbotapi.Update) {
|
||||
msg := update.Message
|
||||
userID := msg.From.ID
|
||||
|
||||
@@ -91,13 +92,21 @@ func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update)
|
||||
typing := tgbotapi.NewChatAction(msg.Chat.ID, tgbotapi.ChatTyping)
|
||||
t.bot.Send(typing)
|
||||
|
||||
// Process message
|
||||
// Process message with timeout
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 55*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := t.handler(ctx, userID, text)
|
||||
if err != nil {
|
||||
t.logger.Error("handle message", "error", err)
|
||||
response = fmt.Sprintf("⚠️ Error: %v", err)
|
||||
if ctx.Err() != nil {
|
||||
response = "⏱️ AI 响应超时,请稍后再试。"
|
||||
} else {
|
||||
response = fmt.Sprintf("⚠️ Error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.logger.Info("sending response", "user_id", userID, "len", len(response))
|
||||
t.sendMarkdown(msg.Chat.ID, response)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,23 +6,28 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebServer provides a REST API for NOFXi.
|
||||
// WebServer provides a REST API and Web UI for NOFXi.
|
||||
type WebServer struct {
|
||||
handler MessageHandler
|
||||
port int
|
||||
webDir string // Path to web/ directory for static files
|
||||
logger *slog.Logger
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewWebServer creates a new web API server.
|
||||
func NewWebServer(port int, handler MessageHandler, logger *slog.Logger) *WebServer {
|
||||
// webDir is the path to the web/ directory containing index.html.
|
||||
func NewWebServer(port int, handler MessageHandler, webDir string, logger *slog.Logger) *WebServer {
|
||||
return &WebServer{
|
||||
handler: handler,
|
||||
port: port,
|
||||
webDir: webDir,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -135,6 +140,14 @@ func (w *WebServer) Start(ctx context.Context) error {
|
||||
})
|
||||
})
|
||||
|
||||
// Serve web UI static files
|
||||
if w.webDir != "" {
|
||||
if _, err := os.Stat(filepath.Join(w.webDir, "index.html")); err == nil {
|
||||
mux.Handle("/", http.FileServer(http.Dir(w.webDir)))
|
||||
w.logger.Info("serving web UI", "dir", w.webDir)
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", w.port)
|
||||
w.server = &http.Server{
|
||||
Addr: addr,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// LLMEngine implements Engine using an OpenAI-compatible API.
|
||||
// Works with OpenAI, claw402 (x402), DeepSeek, Qwen, etc.
|
||||
// Works with OpenAI, claw402 (x402), DeepSeek, Dashscope (Qwen), etc.
|
||||
type LLMEngine struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
@@ -29,7 +29,7 @@ func NewLLMEngine(baseURL, apiKey, model string) *LLMEngine {
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,12 @@ type chatRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
// chatResponse is the OpenAI chat completions response body.
|
||||
// chatResponse handles both standard and thinking-mode responses.
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content *string `json:"content"` // Can be null in thinking mode
|
||||
ReasoningContent string `json:"reasoning_content"` // Qwen3 thinking mode
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
@@ -102,7 +103,23 @@ func (e *LLMEngine) Chat(ctx context.Context, messages []Message) (string, error
|
||||
return "", fmt.Errorf("LLM returned no choices")
|
||||
}
|
||||
|
||||
return chatResp.Choices[0].Message.Content, nil
|
||||
// Extract content — handle thinking mode where content can be null
|
||||
choice := chatResp.Choices[0]
|
||||
content := ""
|
||||
if choice.Message.Content != nil {
|
||||
content = *choice.Message.Content
|
||||
}
|
||||
|
||||
// If content is empty but reasoning_content exists, use that
|
||||
if content == "" && choice.Message.ReasoningContent != "" {
|
||||
content = choice.Message.ReasoningContent
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return "🤔 (AI returned empty response)", nil
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Analyze sends an analysis prompt and parses the AI response.
|
||||
|
||||
Reference in New Issue
Block a user