feat: init nofxi - AI Trading Agent module

NOFXi — Your AI Trading Agent, built on NOFX.

Architecture:
- Agent Core: intent routing, conversation memory, trade confirmation
- Memory: SQLite (trades, conversations, preferences, strategies)
- Thinking: LLM client (OpenAI/claw402/DeepSeek compatible)
- Execution: Bridge to NOFX trader engine (9 exchanges)
- Perception: Market monitor, price alerts, anomaly detection
- Interaction: Telegram bot + REST API + OpenAI-compatible endpoint

Features:
- Natural language trading (中英文)
- /buy /sell /analyze /watch /alert /positions /balance
- Daily P/L reports, portfolio risk checks
- Trade confirmation flow (safety first)
- Price polling and alert notifications
This commit is contained in:
shinchan-zhai
2026-03-22 17:48:14 +08:00
parent 9b14c5c84d
commit 6e19ada7fc
25 changed files with 2688 additions and 0 deletions

39
nofxi/.gitignore vendored Normal file
View File

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

13
nofxi/LICENSE Normal file
View File

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

143
nofxi/README.md Normal file
View File

@@ -0,0 +1,143 @@
<h1 align="center">NOFXi</h1>
<p align="center">
<strong>Your AI Trading Agent.</strong><br/>
<strong>Not a tool. A partner that trades with you.</strong>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
</p>
<p align="center">
<a href="#english">English</a> ·
<a href="#中文">中文</a>
</p>
---
<a id="english"></a>
## 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
---
<a id="中文"></a>
## NOFXi 是什么?
NOFXi 是基于 [NOFX](https://github.com/NoFxAiOS/nofx) 构建的 **AI 交易 Agent**。NOFX 是开源的 AI 交易引擎,而 NOFXi 将其升级为一个能感知、思考、记忆、行动的自主 Agent。
**NOFX** = 引擎。**NOFXi** = 智能。
### 核心区别
| | NOFX引擎 | NOFXiAgent |
|:--|:--|:--|
| **交互方式** | 配置 → 启动 → 看仪表盘 | "帮我开 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

35
nofxi/config.example.yaml Normal file
View File

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

25
nofxi/go.mod Normal file
View File

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

47
nofxi/go.sum Normal file
View File

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

View File

@@ -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:*
• "帮我做多 ETH2x 杠杆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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

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