diff --git a/nofxi/.gitignore b/nofxi/.gitignore
new file mode 100644
index 00000000..f5238788
--- /dev/null
+++ b/nofxi/.gitignore
@@ -0,0 +1,39 @@
+# Binaries
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+nofxi
+
+# Build
+/build/
+/dist/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Environment
+.env
+.env.local
+
+# Data
+*.db
+*.sqlite
+/data/
+/logs/
+
+# Dependencies
+/vendor/
+
+# Node (web frontend)
+node_modules/
+web/dist/
diff --git a/nofxi/LICENSE b/nofxi/LICENSE
new file mode 100644
index 00000000..673f6e47
--- /dev/null
+++ b/nofxi/LICENSE
@@ -0,0 +1,13 @@
+Proprietary License
+
+Copyright (c) 2026 NoFxAiOS. All rights reserved.
+
+This software and associated documentation files (the "Software") are the
+proprietary property of NoFxAiOS. No part of this Software may be reproduced,
+distributed, or transmitted in any form or by any means without the prior
+written permission of NoFxAiOS.
+
+Unauthorized copying, modification, distribution, or use of this Software,
+via any medium, is strictly prohibited.
+
+For licensing inquiries, contact: https://github.com/NoFxAiOS
diff --git a/nofxi/README.md b/nofxi/README.md
new file mode 100644
index 00000000..58fd9013
--- /dev/null
+++ b/nofxi/README.md
@@ -0,0 +1,143 @@
+
NOFXi
+
+
+ Your AI Trading Agent.
+ Not a tool. A partner that trades with you.
+
+
+
+
+
+
+
+
+
+ English ·
+ 中文
+
+
+---
+
+
+
+## What is NOFXi?
+
+NOFXi is an **AI Trading Agent** built on top of [NOFX](https://github.com/NoFxAiOS/nofx). While NOFX is an open-source AI trading engine, NOFXi elevates it into a fully autonomous agent that perceives, thinks, remembers, and acts.
+
+**NOFX** = Engine. **NOFXi** = Intelligence.
+
+### The Difference
+
+| | NOFX (Engine) | NOFXi (Agent) |
+|:--|:--|:--|
+| **Interaction** | Configure → Start → Watch Dashboard | "Open a BTC long, 2x leverage, 3% stop loss" |
+| **Intelligence** | AI makes trade decisions | AI understands context, learns from history |
+| **Memory** | Stateless per session | Remembers your preferences, past trades, lessons |
+| **Proactivity** | Executes when triggered | Monitors markets, alerts you, acts autonomously |
+| **Communication** | Dashboard & logs | Natural language via Telegram, proactive notifications |
+
+### Core Capabilities
+
+- 🧠 **Agent Core** — Context-aware conversation, intent recognition, memory management
+- 👁️ **Perception** — Market monitoring, anomaly detection, position tracking, sentiment analysis
+- 💭 **Thinking** — Multi-model AI decisions, strategy matching, risk assessment
+- 📝 **Memory** — Trade history, strategy performance, user preferences, market patterns
+- ⚡ **Execution** — Multi-exchange trading, position management, x402 payments
+- 💬 **Interaction** — Natural language trading, proactive alerts, report generation
+
+### Architecture
+
+```
+Interaction Layer (Telegram / Web UI / API)
+ │
+ Agent Core (Conversation + Intent + Context)
+ │
+ ┌────┴───────────┬────────────┐
+ │ │ │
+Perception Thinking Memory
+(Market Monitor) (AI Engine) (Experience DB)
+ │ │ │
+ └────┬───────────┴────────────┘
+ │
+ Execution Layer (Multi-Exchange + x402 Payment)
+```
+
+### Built On
+
+- **[NOFX](https://github.com/NoFxAiOS/nofx)** — Open-source AI trading engine (9 exchanges, 7+ AI models)
+- **[Claw402](https://claw402.ai)** — x402 payment gateway (USDC micropayments)
+- **[x402](https://x402.org)** — Pay-per-request with USDC, no API keys
+
+---
+
+
+
+## NOFXi 是什么?
+
+NOFXi 是基于 [NOFX](https://github.com/NoFxAiOS/nofx) 构建的 **AI 交易 Agent**。NOFX 是开源的 AI 交易引擎,而 NOFXi 将其升级为一个能感知、思考、记忆、行动的自主 Agent。
+
+**NOFX** = 引擎。**NOFXi** = 智能。
+
+### 核心区别
+
+| | NOFX(引擎) | NOFXi(Agent) |
+|:--|:--|:--|
+| **交互方式** | 配置 → 启动 → 看仪表盘 | "帮我开 BTC 多单,2x 杠杆,3% 止损" |
+| **智能程度** | AI 做交易决策 | AI 理解上下文,从历史中学习 |
+| **记忆** | 每次无状态 | 记住你的偏好、交易历史、教训 |
+| **主动性** | 被触发时执行 | 主动监控市场、通知你、自主行动 |
+| **沟通方式** | 仪表盘和日志 | Telegram 自然语言对话、主动推送 |
+
+### 核心能力
+
+- 🧠 **Agent 核心** — 上下文对话、意图识别、记忆管理
+- 👁️ **感知层** — 市场监控、异动检测、持仓监控、舆情分析
+- 💭 **思考层** — 多模型 AI 决策、策略匹配、风险评估
+- 📝 **记忆层** — 交易历史、策略效果、用户偏好、市场规律
+- ⚡ **执行层** — 多交易所下单、仓位管理、x402 支付
+- 💬 **交互层** — 自然语言交易、主动通知、报告生成
+
+### 技术架构
+
+```
+交互层(Telegram / Web UI / API)
+ │
+ Agent Core(对话理解 + 意图识别 + 上下文管理)
+ │
+ ┌────┴───────────┬────────────┐
+ │ │ │
+ 感知层 思考层 记忆层
+(市场监控) (AI 决策引擎) (经验库)
+ │ │ │
+ └────┬───────────┴────────────┘
+ │
+ 执行层(多交易所 + x402 支付)
+```
+
+### 构建基础
+
+- **[NOFX](https://github.com/NoFxAiOS/nofx)** — 开源 AI 交易引擎(9 家交易所,7+ AI 模型)
+- **[Claw402](https://claw402.ai)** — x402 支付网关(USDC 微支付)
+- **[x402](https://x402.org)** — USDC 按量付费,无需 API Key
+
+---
+
+## Development
+
+```bash
+# Clone
+git clone https://github.com/NoFxAiOS/nofxi.git
+cd nofxi
+
+# Build
+go build ./...
+
+# Run
+go run cmd/nofxi/main.go
+```
+
+## License
+
+Proprietary. All rights reserved.
+
+© 2026 NoFxAiOS
diff --git a/nofxi/config.example.yaml b/nofxi/config.example.yaml
new file mode 100644
index 00000000..2d70f424
--- /dev/null
+++ b/nofxi/config.example.yaml
@@ -0,0 +1,35 @@
+# NOFXi Configuration
+# Copy to config.yaml and fill in your values
+
+agent:
+ name: "NOFXi"
+ language: "zh" # "en" or "zh"
+ log_level: "info" # "debug", "info", "warn", "error"
+ web_port: 8900 # REST API port (0 = disabled)
+
+telegram:
+ token: "YOUR_TELEGRAM_BOT_TOKEN"
+ allowed_ids: # Leave empty to allow all users
+ # - 123456789
+
+llm:
+ provider: "claw402" # "openai", "claw402", "deepseek"
+ base_url: "https://claw402.ai/v1"
+ api_key: "YOUR_API_KEY"
+ model: "deepseek-chat"
+
+database:
+ path: "nofxi.db"
+
+exchanges:
+ - name: "binance"
+ api_key: ""
+ api_secret: ""
+ testnet: true
+ # - name: "okx"
+ # api_key: ""
+ # api_secret: ""
+ # passphrase: ""
+ # - name: "bybit"
+ # api_key: ""
+ # api_secret: ""
diff --git a/nofxi/go.mod b/nofxi/go.mod
new file mode 100644
index 00000000..96aa979c
--- /dev/null
+++ b/nofxi/go.mod
@@ -0,0 +1,25 @@
+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
new file mode 100644
index 00000000..9bf935dc
--- /dev/null
+++ b/nofxi/go.sum
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 00000000..53dcc98e
--- /dev/null
+++ b/nofxi/internal/agent/agent.go
@@ -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:*
+• "帮我做多 ETH,2x 杠杆,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)
+}
diff --git a/nofxi/internal/agent/config.go b/nofxi/internal/agent/config.go
new file mode 100644
index 00000000..f24855ec
--- /dev/null
+++ b/nofxi/internal/agent/config.go
@@ -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
+}
diff --git a/nofxi/internal/agent/router.go b/nofxi/internal/agent/router.go
new file mode 100644
index 00000000..a4c33a71
--- /dev/null
+++ b/nofxi/internal/agent/router.go
@@ -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}
+ }
+}
diff --git a/nofxi/internal/agent/scheduler.go b/nofxi/internal/agent/scheduler.go
new file mode 100644
index 00000000..70aa806e
--- /dev/null
+++ b/nofxi/internal/agent/scheduler.go
@@ -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)
+ }
+ }
+}
diff --git a/nofxi/internal/agent/watchcmd.go b/nofxi/internal/agent/watchcmd.go
new file mode 100644
index 00000000..d6a6c453
--- /dev/null
+++ b/nofxi/internal/agent/watchcmd.go
@@ -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()
+}
diff --git a/nofxi/internal/execution/bridge.go b/nofxi/internal/execution/bridge.go
new file mode 100644
index 00000000..81818e34
--- /dev/null
+++ b/nofxi/internal/execution/bridge.go
@@ -0,0 +1,192 @@
+package execution
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+)
+
+// TraderFactory creates a NOFX Trader by exchange name.
+// Injected from main.go where nofx exchange packages are available.
+type TraderFactory func(exchange, apiKey, apiSecret, passphrase string, testnet bool) (NofxTrader, error)
+
+// NofxTrader mirrors nofx/trader/types.Trader interface.
+// We redefine it here to avoid a direct import cycle with the parent module.
+type NofxTrader interface {
+ GetBalance() (map[string]interface{}, error)
+ GetPositions() ([]map[string]interface{}, error)
+ OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
+ OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
+ CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
+ CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
+ SetLeverage(symbol string, leverage int) error
+ GetMarketPrice(symbol string) (float64, error)
+ SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error
+ SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error
+ CancelAllOrders(symbol string) error
+}
+
+// Bridge connects NOFXi to NOFX trading engine.
+type Bridge struct {
+ mu sync.RWMutex
+ traders map[string]NofxTrader // exchange name → trader
+ factory TraderFactory
+ logger *slog.Logger
+}
+
+// NewBridge creates a new execution bridge.
+func NewBridge(factory TraderFactory, logger *slog.Logger) *Bridge {
+ return &Bridge{
+ traders: make(map[string]NofxTrader),
+ factory: factory,
+ logger: logger,
+ }
+}
+
+// RegisterTrader registers a pre-configured trader for an exchange.
+func (b *Bridge) RegisterTrader(exchange string, trader NofxTrader) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.traders[strings.ToLower(exchange)] = trader
+ b.logger.Info("trader registered", "exchange", exchange)
+}
+
+// getTrader returns the trader for the given exchange.
+func (b *Bridge) getTrader(exchange string) (NofxTrader, error) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ t, ok := b.traders[strings.ToLower(exchange)]
+ if !ok {
+ return nil, fmt.Errorf("exchange %q not configured", exchange)
+ }
+ return t, nil
+}
+
+// PlaceOrder executes a trade via the NOFX trader.
+func (b *Bridge) PlaceOrder(exchange, symbol, side string, quantity float64, leverage int) (map[string]interface{}, error) {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return nil, err
+ }
+
+ // Set leverage first
+ if leverage > 0 {
+ if err := trader.SetLeverage(symbol, leverage); err != nil {
+ b.logger.Warn("set leverage failed", "error", err)
+ }
+ }
+
+ side = strings.ToUpper(side)
+ switch side {
+ case "BUY", "LONG", "OPEN_LONG":
+ b.logger.Info("opening long", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage)
+ return trader.OpenLong(symbol, quantity, leverage)
+ case "SELL", "SHORT", "OPEN_SHORT":
+ b.logger.Info("opening short", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage)
+ return trader.OpenShort(symbol, quantity, leverage)
+ case "CLOSE_LONG":
+ b.logger.Info("closing long", "exchange", exchange, "symbol", symbol, "qty", quantity)
+ return trader.CloseLong(symbol, quantity)
+ case "CLOSE_SHORT":
+ b.logger.Info("closing short", "exchange", exchange, "symbol", symbol, "qty", quantity)
+ return trader.CloseShort(symbol, quantity)
+ default:
+ return nil, fmt.Errorf("unknown side: %s", side)
+ }
+}
+
+// GetPositions returns all open positions from an exchange.
+func (b *Bridge) GetPositions(exchange string) ([]Position, error) {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return nil, err
+ }
+
+ raw, err := trader.GetPositions()
+ if err != nil {
+ return nil, fmt.Errorf("get positions: %w", err)
+ }
+
+ var positions []Position
+ for _, p := range raw {
+ pos := Position{
+ Exchange: exchange,
+ Symbol: fmt.Sprint(p["symbol"]),
+ Side: fmt.Sprint(p["side"]),
+ }
+ if v, ok := p["size"].(float64); ok {
+ pos.Size = v
+ }
+ if v, ok := p["entryPrice"].(float64); ok {
+ pos.EntryPrice = v
+ }
+ if v, ok := p["markPrice"].(float64); ok {
+ pos.MarkPrice = v
+ }
+ if v, ok := p["unrealizedPnl"].(float64); ok {
+ pos.PnL = v
+ }
+ if v, ok := p["leverage"].(float64); ok {
+ pos.Leverage = v
+ }
+ // Skip empty positions
+ if pos.Size != 0 {
+ positions = append(positions, pos)
+ }
+ }
+ return positions, nil
+}
+
+// GetBalance returns account balance from an exchange.
+func (b *Bridge) GetBalance(exchange string) (*Balance, error) {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return nil, err
+ }
+
+ raw, err := trader.GetBalance()
+ if err != nil {
+ return nil, fmt.Errorf("get balance: %w", err)
+ }
+
+ bal := &Balance{
+ Exchange: exchange,
+ Currency: "USDT",
+ }
+ if v, ok := raw["totalBalance"].(float64); ok {
+ bal.Total = v
+ }
+ if v, ok := raw["availableBalance"].(float64); ok {
+ bal.Available = v
+ }
+ bal.InPosition = bal.Total - bal.Available
+ return bal, nil
+}
+
+// GetPrice returns the current market price for a symbol.
+func (b *Bridge) GetPrice(exchange, symbol string) (float64, error) {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return 0, err
+ }
+ return trader.GetMarketPrice(symbol)
+}
+
+// SetStopLoss sets a stop-loss order.
+func (b *Bridge) SetStopLoss(exchange, symbol, positionSide string, quantity, price float64) error {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return err
+ }
+ return trader.SetStopLoss(symbol, positionSide, quantity, price)
+}
+
+// SetTakeProfit sets a take-profit order.
+func (b *Bridge) SetTakeProfit(exchange, symbol, positionSide string, quantity, price float64) error {
+ trader, err := b.getTrader(exchange)
+ if err != nil {
+ return err
+ }
+ return trader.SetTakeProfit(symbol, positionSide, quantity, price)
+}
diff --git a/nofxi/internal/execution/execution.go b/nofxi/internal/execution/execution.go
new file mode 100644
index 00000000..192af2ca
--- /dev/null
+++ b/nofxi/internal/execution/execution.go
@@ -0,0 +1,47 @@
+// Package execution implements the Execution Layer.
+//
+// Phase 2 — stubs for now.
+//
+// Planned capabilities:
+// - Multi-exchange order execution (via NOFX engine)
+// - Position management (open/close/adjust)
+// - Stop-loss and take-profit management
+// - x402 payment integration via Claw402
+// - Safe mode and risk controls
+package execution
+
+// Executor is the trade execution interface.
+type Executor interface {
+ // PlaceOrder places a trade order on the exchange.
+ PlaceOrder(exchange, symbol, side string, price, quantity float64) (string, error)
+
+ // ClosePosition closes an existing position.
+ ClosePosition(exchange, symbol string) error
+
+ // GetPositions returns current open positions.
+ GetPositions(exchange string) ([]Position, error)
+
+ // GetBalance returns account balance.
+ GetBalance(exchange string) (*Balance, error)
+}
+
+// Position represents an open position.
+type Position struct {
+ Exchange string `json:"exchange"`
+ Symbol string `json:"symbol"`
+ Side string `json:"side"`
+ Size float64 `json:"size"`
+ EntryPrice float64 `json:"entry_price"`
+ MarkPrice float64 `json:"mark_price"`
+ PnL float64 `json:"pnl"`
+ Leverage float64 `json:"leverage"`
+}
+
+// Balance represents account balance.
+type Balance struct {
+ Exchange string `json:"exchange"`
+ Total float64 `json:"total"`
+ Available float64 `json:"available"`
+ InPosition float64 `json:"in_position"`
+ Currency string `json:"currency"`
+}
diff --git a/nofxi/internal/interaction/formatter.go b/nofxi/internal/interaction/formatter.go
new file mode 100644
index 00000000..0cab57fc
--- /dev/null
+++ b/nofxi/internal/interaction/formatter.go
@@ -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)
+}
diff --git a/nofxi/internal/interaction/interaction.go b/nofxi/internal/interaction/interaction.go
new file mode 100644
index 00000000..ae051bd8
--- /dev/null
+++ b/nofxi/internal/interaction/interaction.go
@@ -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
diff --git a/nofxi/internal/interaction/telegram.go b/nofxi/internal/interaction/telegram.go
new file mode 100644
index 00000000..f3fb727b
--- /dev/null
+++ b/nofxi/internal/interaction/telegram.go
@@ -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
+}
diff --git a/nofxi/internal/interaction/web.go b/nofxi/internal/interaction/web.go
new file mode 100644
index 00000000..2a3573cd
--- /dev/null
+++ b/nofxi/internal/interaction/web.go
@@ -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)
+}
diff --git a/nofxi/internal/memory/memory.go b/nofxi/internal/memory/memory.go
new file mode 100644
index 00000000..6f8460d7
--- /dev/null
+++ b/nofxi/internal/memory/memory.go
@@ -0,0 +1,11 @@
+// Package memory implements the Memory Layer.
+//
+// Provides persistent storage for:
+// - Trade history and P/L tracking
+// - Conversation context (per-user message history)
+// - User preferences and risk profiles
+// - Strategy performance metrics
+// - Market pattern snapshots
+//
+// Backend: SQLite (pure Go driver, zero CGO)
+package memory
diff --git a/nofxi/internal/memory/models.go b/nofxi/internal/memory/models.go
new file mode 100644
index 00000000..06624c5f
--- /dev/null
+++ b/nofxi/internal/memory/models.go
@@ -0,0 +1,64 @@
+package memory
+
+import "time"
+
+// TradeRecord stores a completed or active trade.
+type TradeRecord struct {
+ ID int64 `json:"id"`
+ Exchange string `json:"exchange"`
+ Symbol string `json:"symbol"`
+ Side string `json:"side"` // "buy" or "sell"
+ Type string `json:"type"` // "market", "limit"
+ Price float64 `json:"price"`
+ Quantity float64 `json:"quantity"`
+ PnL float64 `json:"pnl"` // Realized P/L
+ Fee float64 `json:"fee"`
+ Status string `json:"status"` // "open", "closed", "cancelled"
+ AIModel string `json:"ai_model"` // Which AI model made the decision
+ AIReason string `json:"ai_reason"` // Why AI made this decision
+ StrategyID string `json:"strategy_id"`
+ CreatedAt time.Time `json:"created_at"`
+ ClosedAt *time.Time `json:"closed_at,omitempty"`
+}
+
+// UserPreference stores user settings and preferences.
+type UserPreference struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"` // Telegram user ID
+ Key string `json:"key"`
+ Value string `json:"value"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Conversation stores chat messages for context.
+type Conversation struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ Role string `json:"role"` // "user", "assistant", "system"
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// StrategyPerformance tracks how well a strategy has performed.
+type StrategyPerformance struct {
+ ID int64 `json:"id"`
+ StrategyID string `json:"strategy_id"`
+ StrategyName string `json:"strategy_name"`
+ TotalTrades int `json:"total_trades"`
+ WinRate float64 `json:"win_rate"`
+ TotalPnL float64 `json:"total_pnl"`
+ MaxDrawdown float64 `json:"max_drawdown"`
+ SharpeRatio float64 `json:"sharpe_ratio"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// MarketSnapshot stores a point-in-time market state for pattern recognition.
+type MarketSnapshot struct {
+ ID int64 `json:"id"`
+ Symbol string `json:"symbol"`
+ Price float64 `json:"price"`
+ Volume24h float64 `json:"volume_24h"`
+ Change24h float64 `json:"change_24h"`
+ Note string `json:"note"` // AI observation about this snapshot
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/nofxi/internal/memory/store.go b/nofxi/internal/memory/store.go
new file mode 100644
index 00000000..80641d8f
--- /dev/null
+++ b/nofxi/internal/memory/store.go
@@ -0,0 +1,216 @@
+package memory
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// Store is the SQLite-backed memory store.
+type Store struct {
+ db *sql.DB
+}
+
+// NewStore opens (or creates) the SQLite database.
+func NewStore(dbPath string) (*Store, error) {
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ return nil, fmt.Errorf("open db: %w", err)
+ }
+
+ // Enable WAL mode for better concurrency
+ if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
+ return nil, fmt.Errorf("set WAL: %w", err)
+ }
+
+ s := &Store{db: db}
+ if err := s.migrate(); err != nil {
+ return nil, fmt.Errorf("migrate: %w", err)
+ }
+
+ return s, nil
+}
+
+// Close closes the database.
+func (s *Store) Close() error {
+ return s.db.Close()
+}
+
+func (s *Store) migrate() error {
+ queries := []string{
+ `CREATE TABLE IF NOT EXISTS trades (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ exchange TEXT NOT NULL,
+ symbol TEXT NOT NULL,
+ side TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT 'market',
+ price REAL NOT NULL,
+ quantity REAL NOT NULL,
+ pnl REAL DEFAULT 0,
+ fee REAL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'open',
+ ai_model TEXT,
+ ai_reason TEXT,
+ strategy_id TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ closed_at DATETIME
+ )`,
+ `CREATE TABLE IF NOT EXISTS conversations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`,
+ `CREATE TABLE IF NOT EXISTS user_preferences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, key)
+ )`,
+ `CREATE TABLE IF NOT EXISTS strategy_performance (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ strategy_id TEXT NOT NULL UNIQUE,
+ strategy_name TEXT,
+ total_trades INTEGER DEFAULT 0,
+ win_rate REAL DEFAULT 0,
+ total_pnl REAL DEFAULT 0,
+ max_drawdown REAL DEFAULT 0,
+ sharpe_ratio REAL DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`,
+ `CREATE TABLE IF NOT EXISTS market_snapshots (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ symbol TEXT NOT NULL,
+ price REAL NOT NULL,
+ volume_24h REAL,
+ change_24h REAL,
+ note TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol)`,
+ `CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, created_at)`,
+ `CREATE INDEX IF NOT EXISTS idx_snapshots_symbol ON market_snapshots(symbol, created_at)`,
+ }
+
+ for _, q := range queries {
+ if _, err := s.db.Exec(q); err != nil {
+ return fmt.Errorf("exec %q: %w", q[:40], err)
+ }
+ }
+ return nil
+}
+
+// --- Trade Operations ---
+
+// SaveTrade inserts a new trade record.
+func (s *Store) SaveTrade(t *TradeRecord) (int64, error) {
+ res, err := s.db.Exec(
+ `INSERT INTO trades (exchange, symbol, side, type, price, quantity, pnl, fee, status, ai_model, ai_reason, strategy_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.Exchange, t.Symbol, t.Side, t.Type, t.Price, t.Quantity,
+ t.PnL, t.Fee, t.Status, t.AIModel, t.AIReason, t.StrategyID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// GetRecentTrades returns the last N trades.
+func (s *Store) GetRecentTrades(limit int) ([]TradeRecord, error) {
+ rows, err := s.db.Query(
+ `SELECT id, exchange, symbol, side, type, price, quantity, pnl, fee, status,
+ COALESCE(ai_model,''), COALESCE(ai_reason,''), COALESCE(strategy_id,''), created_at, closed_at
+ FROM trades ORDER BY created_at DESC LIMIT ?`, limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var trades []TradeRecord
+ for rows.Next() {
+ var t TradeRecord
+ var closedAt sql.NullTime
+ if err := rows.Scan(&t.ID, &t.Exchange, &t.Symbol, &t.Side, &t.Type,
+ &t.Price, &t.Quantity, &t.PnL, &t.Fee, &t.Status,
+ &t.AIModel, &t.AIReason, &t.StrategyID, &t.CreatedAt, &closedAt); err != nil {
+ return nil, err
+ }
+ if closedAt.Valid {
+ t.ClosedAt = &closedAt.Time
+ }
+ trades = append(trades, t)
+ }
+ return trades, nil
+}
+
+// --- Conversation Operations ---
+
+// SaveMessage stores a conversation message.
+func (s *Store) SaveMessage(userID int64, role, content string) error {
+ _, err := s.db.Exec(
+ `INSERT INTO conversations (user_id, role, content) VALUES (?, ?, ?)`,
+ userID, role, content,
+ )
+ return err
+}
+
+// GetRecentMessages returns the last N messages for a user.
+func (s *Store) GetRecentMessages(userID int64, limit int) ([]Conversation, error) {
+ rows, err := s.db.Query(
+ `SELECT id, user_id, role, content, created_at FROM conversations
+ WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var msgs []Conversation
+ for rows.Next() {
+ var c Conversation
+ if err := rows.Scan(&c.ID, &c.UserID, &c.Role, &c.Content, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ msgs = append(msgs, c)
+ }
+
+ // Reverse to get chronological order
+ for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
+ msgs[i], msgs[j] = msgs[j], msgs[i]
+ }
+ return msgs, nil
+}
+
+// --- Preference Operations ---
+
+// SetPreference upserts a user preference.
+func (s *Store) SetPreference(userID int64, key, value string) error {
+ _, err := s.db.Exec(
+ `INSERT INTO user_preferences (user_id, key, value, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
+ userID, key, value, time.Now(),
+ )
+ return err
+}
+
+// GetPreference retrieves a user preference.
+func (s *Store) GetPreference(userID int64, key string) (string, error) {
+ var value string
+ err := s.db.QueryRow(
+ `SELECT value FROM user_preferences WHERE user_id = ? AND key = ?`,
+ userID, key,
+ ).Scan(&value)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return value, err
+}
diff --git a/nofxi/internal/perception/market.go b/nofxi/internal/perception/market.go
new file mode 100644
index 00000000..4bf5dc80
--- /dev/null
+++ b/nofxi/internal/perception/market.go
@@ -0,0 +1,214 @@
+package perception
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// PriceAlert triggers when a symbol hits a threshold.
+type PriceAlert struct {
+ Symbol string
+ Price float64
+ Direction string // "above" or "below"
+ Threshold float64
+ Triggered time.Time
+}
+
+// AlertCallback is called when a price alert triggers.
+type AlertCallback func(alert PriceAlert)
+
+// MarketMonitor watches symbols and detects price anomalies.
+type MarketMonitor struct {
+ mu sync.RWMutex
+ watching map[string]*watchState
+ alerts []alertRule
+ onAlert AlertCallback
+ httpClient *http.Client
+ logger *slog.Logger
+ stopCh chan struct{}
+ pollInterval time.Duration
+}
+
+type watchState struct {
+ Symbol string
+ LastPrice float64
+ Change1h float64
+ Change24h float64
+ UpdatedAt time.Time
+}
+
+type alertRule struct {
+ Symbol string
+ Direction string // "above" or "below"
+ Threshold float64
+ Fired bool
+}
+
+// NewMarketMonitor creates a new market monitor.
+func NewMarketMonitor(onAlert AlertCallback, logger *slog.Logger) *MarketMonitor {
+ return &MarketMonitor{
+ watching: make(map[string]*watchState),
+ onAlert: onAlert,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ logger: logger,
+ stopCh: make(chan struct{}),
+ pollInterval: 30 * time.Second,
+ }
+}
+
+// Watch starts monitoring a symbol.
+func (m *MarketMonitor) Watch(symbol string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.watching[symbol] = &watchState{Symbol: symbol}
+ m.logger.Info("watching symbol", "symbol", symbol)
+}
+
+// Unwatch stops monitoring a symbol.
+func (m *MarketMonitor) Unwatch(symbol string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.watching, symbol)
+}
+
+// AddAlert adds a price alert rule.
+func (m *MarketMonitor) AddAlert(symbol, direction string, threshold float64) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.alerts = append(m.alerts, alertRule{
+ Symbol: symbol,
+ Direction: direction,
+ Threshold: threshold,
+ })
+ m.logger.Info("alert added", "symbol", symbol, "direction", direction, "threshold", threshold)
+}
+
+// GetSnapshot returns the latest state for a symbol.
+func (m *MarketMonitor) GetSnapshot(symbol string) (*watchState, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ s, ok := m.watching[symbol]
+ return s, ok
+}
+
+// GetAllSnapshots returns all watched symbols.
+func (m *MarketMonitor) GetAllSnapshots() map[string]*watchState {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ result := make(map[string]*watchState)
+ for k, v := range m.watching {
+ result[k] = v
+ }
+ return result
+}
+
+// Start begins the monitoring loop.
+func (m *MarketMonitor) Start() {
+ go m.loop()
+}
+
+// Stop stops the monitoring loop.
+func (m *MarketMonitor) Stop() {
+ close(m.stopCh)
+}
+
+func (m *MarketMonitor) loop() {
+ ticker := time.NewTicker(m.pollInterval)
+ defer ticker.Stop()
+
+ // Initial fetch
+ m.updatePrices()
+
+ for {
+ select {
+ case <-m.stopCh:
+ return
+ case <-ticker.C:
+ m.updatePrices()
+ }
+ }
+}
+
+func (m *MarketMonitor) updatePrices() {
+ m.mu.RLock()
+ symbols := make([]string, 0, len(m.watching))
+ for s := range m.watching {
+ symbols = append(symbols, s)
+ }
+ m.mu.RUnlock()
+
+ for _, symbol := range symbols {
+ price, err := m.fetchPrice(symbol)
+ if err != nil {
+ m.logger.Error("fetch price", "symbol", symbol, "error", err)
+ continue
+ }
+
+ m.mu.Lock()
+ if state, ok := m.watching[symbol]; ok {
+ state.LastPrice = price
+ state.UpdatedAt = time.Now()
+ }
+
+ // Check alerts
+ for i := range m.alerts {
+ a := &m.alerts[i]
+ if a.Symbol != symbol || a.Fired {
+ continue
+ }
+ triggered := false
+ if a.Direction == "above" && price >= a.Threshold {
+ triggered = true
+ } else if a.Direction == "below" && price <= a.Threshold {
+ triggered = true
+ }
+ if triggered {
+ a.Fired = true
+ if m.onAlert != nil {
+ m.onAlert(PriceAlert{
+ Symbol: symbol,
+ Price: price,
+ Direction: a.Direction,
+ Threshold: a.Threshold,
+ Triggered: time.Now(),
+ })
+ }
+ }
+ }
+ m.mu.Unlock()
+ }
+}
+
+// fetchPrice gets current price from Binance public API (no auth needed).
+func (m *MarketMonitor) fetchPrice(symbol string) (float64, error) {
+ url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/price?symbol=%s", symbol)
+ resp, err := m.httpClient.Get(url)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return 0, err
+ }
+
+ var result struct {
+ Price string `json:"price"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return 0, err
+ }
+
+ var price float64
+ fmt.Sscanf(result.Price, "%f", &price)
+ if price == 0 {
+ return 0, fmt.Errorf("invalid price for %s", symbol)
+ }
+ return price, nil
+}
diff --git a/nofxi/internal/perception/perception.go b/nofxi/internal/perception/perception.go
new file mode 100644
index 00000000..0957027b
--- /dev/null
+++ b/nofxi/internal/perception/perception.go
@@ -0,0 +1,20 @@
+// Package perception implements the Perception Layer.
+//
+// Phase 2 — stubs for now.
+//
+// Planned capabilities:
+// - Real-time market data monitoring
+// - Price anomaly detection
+// - Position tracking and P/L monitoring
+// - News and sentiment analysis
+// - Custom alert triggers
+package perception
+
+// Monitor is the market perception interface.
+type Monitor interface {
+ // WatchSymbol starts monitoring a symbol for price changes.
+ WatchSymbol(symbol string) error
+
+ // Stop stops all monitoring.
+ Stop()
+}
diff --git a/nofxi/internal/thinking/engine.go b/nofxi/internal/thinking/engine.go
new file mode 100644
index 00000000..4c4e73dc
--- /dev/null
+++ b/nofxi/internal/thinking/engine.go
@@ -0,0 +1,28 @@
+package thinking
+
+import "context"
+
+// Engine is the AI decision-making interface.
+type Engine interface {
+ // Chat sends a message to the LLM with conversation context and returns a response.
+ Chat(ctx context.Context, messages []Message) (string, error)
+
+ // Analyze asks the AI to analyze market data and provide a trading recommendation.
+ Analyze(ctx context.Context, prompt string) (*Analysis, error)
+}
+
+// Message represents a chat message.
+type Message struct {
+ Role string `json:"role"` // "system", "user", "assistant"
+ Content string `json:"content"`
+}
+
+// Analysis holds an AI trading recommendation.
+type Analysis struct {
+ Action string `json:"action"` // "buy", "sell", "hold", "wait"
+ Symbol string `json:"symbol"`
+ Confidence float64 `json:"confidence"` // 0.0 - 1.0
+ Reasoning string `json:"reasoning"`
+ StopLoss float64 `json:"stop_loss,omitempty"`
+ TakeProfit float64 `json:"take_profit,omitempty"`
+}
diff --git a/nofxi/internal/thinking/llm.go b/nofxi/internal/thinking/llm.go
new file mode 100644
index 00000000..78f06e7f
--- /dev/null
+++ b/nofxi/internal/thinking/llm.go
@@ -0,0 +1,144 @@
+package thinking
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// LLMEngine implements Engine using an OpenAI-compatible API.
+// Works with OpenAI, claw402 (x402), DeepSeek, Qwen, etc.
+type LLMEngine struct {
+ baseURL string
+ apiKey string
+ model string
+ httpClient *http.Client
+}
+
+// NewLLMEngine creates a new LLM-backed thinking engine.
+func NewLLMEngine(baseURL, apiKey, model string) *LLMEngine {
+ if baseURL == "" {
+ baseURL = "https://api.openai.com/v1"
+ }
+ return &LLMEngine{
+ baseURL: baseURL,
+ apiKey: apiKey,
+ model: model,
+ httpClient: &http.Client{
+ Timeout: 120 * time.Second,
+ },
+ }
+}
+
+// chatRequest is the OpenAI chat completions request body.
+type chatRequest struct {
+ Model string `json:"model"`
+ Messages []Message `json:"messages"`
+}
+
+// chatResponse is the OpenAI chat completions response body.
+type chatResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+}
+
+// Chat sends messages to the LLM and returns the response.
+func (e *LLMEngine) Chat(ctx context.Context, messages []Message) (string, error) {
+ reqBody := chatRequest{
+ Model: e.model,
+ Messages: messages,
+ }
+
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return "", fmt.Errorf("marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", e.baseURL+"/chat/completions", bytes.NewReader(body))
+ if err != nil {
+ return "", fmt.Errorf("create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if e.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+e.apiKey)
+ }
+
+ resp, err := e.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("http request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ var chatResp chatResponse
+ if err := json.Unmarshal(respBody, &chatResp); err != nil {
+ return "", fmt.Errorf("unmarshal response: %w", err)
+ }
+
+ if chatResp.Error != nil {
+ return "", fmt.Errorf("LLM error: %s", chatResp.Error.Message)
+ }
+
+ if len(chatResp.Choices) == 0 {
+ return "", fmt.Errorf("LLM returned no choices")
+ }
+
+ return chatResp.Choices[0].Message.Content, nil
+}
+
+// Analyze sends an analysis prompt and parses the AI response.
+func (e *LLMEngine) Analyze(ctx context.Context, prompt string) (*Analysis, error) {
+ systemPrompt := `You are NOFXi, an expert AI trading analyst. Analyze the given market data and provide a trading recommendation.
+
+Respond in JSON format:
+{
+ "action": "buy|sell|hold|wait",
+ "symbol": "BTC/USDT",
+ "confidence": 0.85,
+ "reasoning": "Brief explanation",
+ "stop_loss": 0.0,
+ "take_profit": 0.0
+}
+
+Be concise. Only recommend high-confidence trades.`
+
+ messages := []Message{
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: prompt},
+ }
+
+ resp, err := e.Chat(ctx, messages)
+ if err != nil {
+ return nil, err
+ }
+
+ var analysis Analysis
+ if err := json.Unmarshal([]byte(resp), &analysis); err != nil {
+ // If JSON parsing fails, return the raw text as reasoning
+ return &Analysis{
+ Action: "hold",
+ Reasoning: resp,
+ }, nil
+ }
+
+ return &analysis, nil
+}
diff --git a/nofxi/internal/thinking/thinking.go b/nofxi/internal/thinking/thinking.go
new file mode 100644
index 00000000..aabe28ca
--- /dev/null
+++ b/nofxi/internal/thinking/thinking.go
@@ -0,0 +1,9 @@
+// Package thinking implements the Thinking Layer.
+//
+// Provides AI-powered decision making via OpenAI-compatible APIs.
+// Supports multiple providers: OpenAI, claw402 (x402), DeepSeek, Qwen, etc.
+//
+// Key interfaces:
+// - Engine: core AI decision interface (Chat + Analyze)
+// - LLMEngine: concrete implementation using HTTP/REST
+package thinking