mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
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:
39
nofxi/.gitignore
vendored
Normal file
39
nofxi/.gitignore
vendored
Normal 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
13
nofxi/LICENSE
Normal 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
143
nofxi/README.md
Normal 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(引擎) | 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
|
||||
35
nofxi/config.example.yaml
Normal file
35
nofxi/config.example.yaml
Normal 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
25
nofxi/go.mod
Normal 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
47
nofxi/go.sum
Normal 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=
|
||||
499
nofxi/internal/agent/agent.go
Normal file
499
nofxi/internal/agent/agent.go
Normal 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:*
|
||||
• "帮我做多 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)
|
||||
}
|
||||
98
nofxi/internal/agent/config.go
Normal file
98
nofxi/internal/agent/config.go
Normal 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
|
||||
}
|
||||
153
nofxi/internal/agent/router.go
Normal file
153
nofxi/internal/agent/router.go
Normal 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}
|
||||
}
|
||||
}
|
||||
187
nofxi/internal/agent/scheduler.go
Normal file
187
nofxi/internal/agent/scheduler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
nofxi/internal/agent/watchcmd.go
Normal file
125
nofxi/internal/agent/watchcmd.go
Normal 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()
|
||||
}
|
||||
192
nofxi/internal/execution/bridge.go
Normal file
192
nofxi/internal/execution/bridge.go
Normal 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)
|
||||
}
|
||||
47
nofxi/internal/execution/execution.go
Normal file
47
nofxi/internal/execution/execution.go
Normal 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"`
|
||||
}
|
||||
68
nofxi/internal/interaction/formatter.go
Normal file
68
nofxi/internal/interaction/formatter.go
Normal 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)
|
||||
}
|
||||
10
nofxi/internal/interaction/interaction.go
Normal file
10
nofxi/internal/interaction/interaction.go
Normal 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
|
||||
128
nofxi/internal/interaction/telegram.go
Normal file
128
nofxi/internal/interaction/telegram.go
Normal 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
|
||||
}
|
||||
173
nofxi/internal/interaction/web.go
Normal file
173
nofxi/internal/interaction/web.go
Normal 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)
|
||||
}
|
||||
11
nofxi/internal/memory/memory.go
Normal file
11
nofxi/internal/memory/memory.go
Normal 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
|
||||
64
nofxi/internal/memory/models.go
Normal file
64
nofxi/internal/memory/models.go
Normal 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"`
|
||||
}
|
||||
216
nofxi/internal/memory/store.go
Normal file
216
nofxi/internal/memory/store.go
Normal 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
|
||||
}
|
||||
214
nofxi/internal/perception/market.go
Normal file
214
nofxi/internal/perception/market.go
Normal 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
|
||||
}
|
||||
20
nofxi/internal/perception/perception.go
Normal file
20
nofxi/internal/perception/perception.go
Normal 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()
|
||||
}
|
||||
28
nofxi/internal/thinking/engine.go
Normal file
28
nofxi/internal/thinking/engine.go
Normal 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"`
|
||||
}
|
||||
144
nofxi/internal/thinking/llm.go
Normal file
144
nofxi/internal/thinking/llm.go
Normal 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
|
||||
}
|
||||
9
nofxi/internal/thinking/thinking.go
Normal file
9
nofxi/internal/thinking/thinking.go
Normal 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
|
||||
Reference in New Issue
Block a user