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:
shinchan-zhai
2026-03-22 22:04:37 +08:00
parent cf7bf16c28
commit 34f5e6fe71
16 changed files with 784 additions and 87 deletions

View File

@@ -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*

View File

@@ -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 := ""

View File

@@ -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.

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

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

View File

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

View File

@@ -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,

View File

@@ -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.