diff --git a/nofxi/Dockerfile b/nofxi/Dockerfile new file mode 100644 index 00000000..a77b88b1 --- /dev/null +++ b/nofxi/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nofxi ./nofxi/cmd/nofxi/ + +# Runtime stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /nofxi . +COPY nofxi/config.example.yaml ./config.example.yaml + +# Create data directory for SQLite +RUN mkdir -p /app/data + +ENV TZ=Asia/Shanghai + +EXPOSE 8900 + +ENTRYPOINT ["./nofxi"] +CMD ["--config", "/app/config.yaml"] diff --git a/nofxi/config.yaml b/nofxi/config.yaml new file mode 100644 index 00000000..bfc06b17 --- /dev/null +++ b/nofxi/config.yaml @@ -0,0 +1,20 @@ +agent: + name: "NOFXi" + language: "zh" + log_level: "info" + web_port: 8900 + +telegram: + token: "8486603557:AAEqw9whg6uG63ROGCRJbz8D_J0lsXeQKps" + allowed_ids: [] + +llm: + provider: "dashscope" + base_url: "https://coding.dashscope.aliyuncs.com/v1" + api_key: "sk-sp-0511a0b965af4866831a0345e8109ea0" + model: "qwen3-coder-plus" + +database: + path: "nofxi.db" + +exchanges: [] diff --git a/nofxi/docker-compose.yml b/nofxi/docker-compose.yml new file mode 100644 index 00000000..4c502025 --- /dev/null +++ b/nofxi/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + nofxi: + build: + context: .. + dockerfile: nofxi/Dockerfile + container_name: nofxi + restart: unless-stopped + ports: + - "8900:8900" + volumes: + - ./config.yaml:/app/config.yaml:ro + - nofxi-data:/app/data + environment: + - TZ=Asia/Shanghai + # Override config via env vars: + # - NOFXI_TELEGRAM_TOKEN=xxx + # - NOFXI_LLM_API_KEY=xxx + # - NOFXI_LLM_BASE_URL=xxx + +volumes: + nofxi-data: diff --git a/nofxi/go.mod b/nofxi/go.mod deleted file mode 100644 index 96aa979c..00000000 --- a/nofxi/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/NoFxAiOS/nofx/nofxi - -go 1.21 - -require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 - gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.29.6 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.3.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.16.0 // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.41.0 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.7.2 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect -) diff --git a/nofxi/go.sum b/nofxi/go.sum deleted file mode 100644 index 9bf935dc..00000000 --- a/nofxi/go.sum +++ /dev/null @@ -1,47 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= -modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= -modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= -modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= -modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/nofxi/internal/agent/agent.go b/nofxi/internal/agent/agent.go index 53dcc98e..50cd05bd 100644 --- a/nofxi/internal/agent/agent.go +++ b/nofxi/internal/agent/agent.go @@ -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 `" + } + 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* diff --git a/nofxi/internal/agent/router.go b/nofxi/internal/agent/router.go index a4c33a71..0336cc5d 100644 --- a/nofxi/internal/agent/router.go +++ b/nofxi/internal/agent/router.go @@ -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 := "" diff --git a/nofxi/internal/agent/scheduler.go b/nofxi/internal/agent/scheduler.go index 70aa806e..5a479c0f 100644 --- a/nofxi/internal/agent/scheduler.go +++ b/nofxi/internal/agent/scheduler.go @@ -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. diff --git a/nofxi/internal/agent/strategy.go b/nofxi/internal/agent/strategy.go new file mode 100644 index 00000000..ec1294df --- /dev/null +++ b/nofxi/internal/agent/strategy.go @@ -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 `") + return sb.String() +} diff --git a/nofxi/internal/execution/factory.go b/nofxi/internal/execution/factory.go new file mode 100644 index 00000000..7fe35ec3 --- /dev/null +++ b/nofxi/internal/execution/factory.go @@ -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) + } +} diff --git a/nofxi/internal/interaction/telegram.go b/nofxi/internal/interaction/telegram.go index f3fb727b..5063b441 100644 --- a/nofxi/internal/interaction/telegram.go +++ b/nofxi/internal/interaction/telegram.go @@ -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) } diff --git a/nofxi/internal/interaction/web.go b/nofxi/internal/interaction/web.go index 2a3573cd..06018b99 100644 --- a/nofxi/internal/interaction/web.go +++ b/nofxi/internal/interaction/web.go @@ -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, diff --git a/nofxi/internal/thinking/llm.go b/nofxi/internal/thinking/llm.go index 78f06e7f..04321439 100644 --- a/nofxi/internal/thinking/llm.go +++ b/nofxi/internal/thinking/llm.go @@ -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. diff --git a/nofxi/nofxi.db-shm b/nofxi/nofxi.db-shm new file mode 100644 index 00000000..fe9ac284 Binary files /dev/null and b/nofxi/nofxi.db-shm differ diff --git a/nofxi/nofxi.db-wal b/nofxi/nofxi.db-wal new file mode 100644 index 00000000..e69de29b diff --git a/nofxi/web/index.html b/nofxi/web/index.html new file mode 100644 index 00000000..8e8569a6 --- /dev/null +++ b/nofxi/web/index.html @@ -0,0 +1,335 @@ + + + + + + NOFXi — AI Trading Agent + + + +
+

🤖 NOFXi

+
+
+ Connecting... +
+
+ +
+ + +
+
+
+
🤖
+
Hey! I'm NOFXi, your AI trading agent. 🚀 + +Ask me anything about markets, or use quick actions on the left. + +Type /help to see all commands.
+
+
+ +
+
+ + +
+
+
+
+ + + +