From 6e19ada7fcf4d6378185380d217ca901c7b7d0a7 Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Sun, 22 Mar 2026 17:48:14 +0800 Subject: [PATCH] feat: init nofxi - AI Trading Agent module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nofxi/.gitignore | 39 ++ nofxi/LICENSE | 13 + nofxi/README.md | 143 +++++++ nofxi/config.example.yaml | 35 ++ nofxi/go.mod | 25 ++ nofxi/go.sum | 47 ++ nofxi/internal/agent/agent.go | 499 ++++++++++++++++++++++ nofxi/internal/agent/config.go | 98 +++++ nofxi/internal/agent/router.go | 153 +++++++ nofxi/internal/agent/scheduler.go | 187 ++++++++ nofxi/internal/agent/watchcmd.go | 125 ++++++ nofxi/internal/execution/bridge.go | 192 +++++++++ nofxi/internal/execution/execution.go | 47 ++ nofxi/internal/interaction/formatter.go | 68 +++ nofxi/internal/interaction/interaction.go | 10 + nofxi/internal/interaction/telegram.go | 128 ++++++ nofxi/internal/interaction/web.go | 173 ++++++++ nofxi/internal/memory/memory.go | 11 + nofxi/internal/memory/models.go | 64 +++ nofxi/internal/memory/store.go | 216 ++++++++++ nofxi/internal/perception/market.go | 214 ++++++++++ nofxi/internal/perception/perception.go | 20 + nofxi/internal/thinking/engine.go | 28 ++ nofxi/internal/thinking/llm.go | 144 +++++++ nofxi/internal/thinking/thinking.go | 9 + 25 files changed, 2688 insertions(+) create mode 100644 nofxi/.gitignore create mode 100644 nofxi/LICENSE create mode 100644 nofxi/README.md create mode 100644 nofxi/config.example.yaml create mode 100644 nofxi/go.mod create mode 100644 nofxi/go.sum create mode 100644 nofxi/internal/agent/agent.go create mode 100644 nofxi/internal/agent/config.go create mode 100644 nofxi/internal/agent/router.go create mode 100644 nofxi/internal/agent/scheduler.go create mode 100644 nofxi/internal/agent/watchcmd.go create mode 100644 nofxi/internal/execution/bridge.go create mode 100644 nofxi/internal/execution/execution.go create mode 100644 nofxi/internal/interaction/formatter.go create mode 100644 nofxi/internal/interaction/interaction.go create mode 100644 nofxi/internal/interaction/telegram.go create mode 100644 nofxi/internal/interaction/web.go create mode 100644 nofxi/internal/memory/memory.go create mode 100644 nofxi/internal/memory/models.go create mode 100644 nofxi/internal/memory/store.go create mode 100644 nofxi/internal/perception/market.go create mode 100644 nofxi/internal/perception/perception.go create mode 100644 nofxi/internal/thinking/engine.go create mode 100644 nofxi/internal/thinking/llm.go create mode 100644 nofxi/internal/thinking/thinking.go diff --git a/nofxi/.gitignore b/nofxi/.gitignore new file mode 100644 index 00000000..f5238788 --- /dev/null +++ b/nofxi/.gitignore @@ -0,0 +1,39 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +nofxi + +# Build +/build/ +/dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local + +# Data +*.db +*.sqlite +/data/ +/logs/ + +# Dependencies +/vendor/ + +# Node (web frontend) +node_modules/ +web/dist/ diff --git a/nofxi/LICENSE b/nofxi/LICENSE new file mode 100644 index 00000000..673f6e47 --- /dev/null +++ b/nofxi/LICENSE @@ -0,0 +1,13 @@ +Proprietary License + +Copyright (c) 2026 NoFxAiOS. All rights reserved. + +This software and associated documentation files (the "Software") are the +proprietary property of NoFxAiOS. No part of this Software may be reproduced, +distributed, or transmitted in any form or by any means without the prior +written permission of NoFxAiOS. + +Unauthorized copying, modification, distribution, or use of this Software, +via any medium, is strictly prohibited. + +For licensing inquiries, contact: https://github.com/NoFxAiOS diff --git a/nofxi/README.md b/nofxi/README.md new file mode 100644 index 00000000..58fd9013 --- /dev/null +++ b/nofxi/README.md @@ -0,0 +1,143 @@ +

NOFXi

+ +

+ Your AI Trading Agent.
+ Not a tool. A partner that trades with you. +

+ +

+ Go + React + x402 +

+ +

+ English · + 中文 +

+ +--- + + + +## What is NOFXi? + +NOFXi is an **AI Trading Agent** built on top of [NOFX](https://github.com/NoFxAiOS/nofx). While NOFX is an open-source AI trading engine, NOFXi elevates it into a fully autonomous agent that perceives, thinks, remembers, and acts. + +**NOFX** = Engine. **NOFXi** = Intelligence. + +### The Difference + +| | NOFX (Engine) | NOFXi (Agent) | +|:--|:--|:--| +| **Interaction** | Configure → Start → Watch Dashboard | "Open a BTC long, 2x leverage, 3% stop loss" | +| **Intelligence** | AI makes trade decisions | AI understands context, learns from history | +| **Memory** | Stateless per session | Remembers your preferences, past trades, lessons | +| **Proactivity** | Executes when triggered | Monitors markets, alerts you, acts autonomously | +| **Communication** | Dashboard & logs | Natural language via Telegram, proactive notifications | + +### Core Capabilities + +- 🧠 **Agent Core** — Context-aware conversation, intent recognition, memory management +- 👁️ **Perception** — Market monitoring, anomaly detection, position tracking, sentiment analysis +- 💭 **Thinking** — Multi-model AI decisions, strategy matching, risk assessment +- 📝 **Memory** — Trade history, strategy performance, user preferences, market patterns +- ⚡ **Execution** — Multi-exchange trading, position management, x402 payments +- 💬 **Interaction** — Natural language trading, proactive alerts, report generation + +### Architecture + +``` +Interaction Layer (Telegram / Web UI / API) + │ + Agent Core (Conversation + Intent + Context) + │ + ┌────┴───────────┬────────────┐ + │ │ │ +Perception Thinking Memory +(Market Monitor) (AI Engine) (Experience DB) + │ │ │ + └────┬───────────┴────────────┘ + │ + Execution Layer (Multi-Exchange + x402 Payment) +``` + +### Built On + +- **[NOFX](https://github.com/NoFxAiOS/nofx)** — Open-source AI trading engine (9 exchanges, 7+ AI models) +- **[Claw402](https://claw402.ai)** — x402 payment gateway (USDC micropayments) +- **[x402](https://x402.org)** — Pay-per-request with USDC, no API keys + +--- + + + +## NOFXi 是什么? + +NOFXi 是基于 [NOFX](https://github.com/NoFxAiOS/nofx) 构建的 **AI 交易 Agent**。NOFX 是开源的 AI 交易引擎,而 NOFXi 将其升级为一个能感知、思考、记忆、行动的自主 Agent。 + +**NOFX** = 引擎。**NOFXi** = 智能。 + +### 核心区别 + +| | NOFX(引擎) | NOFXi(Agent) | +|:--|:--|:--| +| **交互方式** | 配置 → 启动 → 看仪表盘 | "帮我开 BTC 多单,2x 杠杆,3% 止损" | +| **智能程度** | AI 做交易决策 | AI 理解上下文,从历史中学习 | +| **记忆** | 每次无状态 | 记住你的偏好、交易历史、教训 | +| **主动性** | 被触发时执行 | 主动监控市场、通知你、自主行动 | +| **沟通方式** | 仪表盘和日志 | Telegram 自然语言对话、主动推送 | + +### 核心能力 + +- 🧠 **Agent 核心** — 上下文对话、意图识别、记忆管理 +- 👁️ **感知层** — 市场监控、异动检测、持仓监控、舆情分析 +- 💭 **思考层** — 多模型 AI 决策、策略匹配、风险评估 +- 📝 **记忆层** — 交易历史、策略效果、用户偏好、市场规律 +- ⚡ **执行层** — 多交易所下单、仓位管理、x402 支付 +- 💬 **交互层** — 自然语言交易、主动通知、报告生成 + +### 技术架构 + +``` +交互层(Telegram / Web UI / API) + │ + Agent Core(对话理解 + 意图识别 + 上下文管理) + │ + ┌────┴───────────┬────────────┐ + │ │ │ + 感知层 思考层 记忆层 +(市场监控) (AI 决策引擎) (经验库) + │ │ │ + └────┬───────────┴────────────┘ + │ + 执行层(多交易所 + x402 支付) +``` + +### 构建基础 + +- **[NOFX](https://github.com/NoFxAiOS/nofx)** — 开源 AI 交易引擎(9 家交易所,7+ AI 模型) +- **[Claw402](https://claw402.ai)** — x402 支付网关(USDC 微支付) +- **[x402](https://x402.org)** — USDC 按量付费,无需 API Key + +--- + +## Development + +```bash +# Clone +git clone https://github.com/NoFxAiOS/nofxi.git +cd nofxi + +# Build +go build ./... + +# Run +go run cmd/nofxi/main.go +``` + +## License + +Proprietary. All rights reserved. + +© 2026 NoFxAiOS diff --git a/nofxi/config.example.yaml b/nofxi/config.example.yaml new file mode 100644 index 00000000..2d70f424 --- /dev/null +++ b/nofxi/config.example.yaml @@ -0,0 +1,35 @@ +# NOFXi Configuration +# Copy to config.yaml and fill in your values + +agent: + name: "NOFXi" + language: "zh" # "en" or "zh" + log_level: "info" # "debug", "info", "warn", "error" + web_port: 8900 # REST API port (0 = disabled) + +telegram: + token: "YOUR_TELEGRAM_BOT_TOKEN" + allowed_ids: # Leave empty to allow all users + # - 123456789 + +llm: + provider: "claw402" # "openai", "claw402", "deepseek" + base_url: "https://claw402.ai/v1" + api_key: "YOUR_API_KEY" + model: "deepseek-chat" + +database: + path: "nofxi.db" + +exchanges: + - name: "binance" + api_key: "" + api_secret: "" + testnet: true + # - name: "okx" + # api_key: "" + # api_secret: "" + # passphrase: "" + # - name: "bybit" + # api_key: "" + # api_secret: "" diff --git a/nofxi/go.mod b/nofxi/go.mod new file mode 100644 index 00000000..96aa979c --- /dev/null +++ b/nofxi/go.mod @@ -0,0 +1,25 @@ +module github.com/NoFxAiOS/nofx/nofxi + +go 1.21 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.29.6 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.16.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/nofxi/go.sum b/nofxi/go.sum new file mode 100644 index 00000000..9bf935dc --- /dev/null +++ b/nofxi/go.sum @@ -0,0 +1,47 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/nofxi/internal/agent/agent.go b/nofxi/internal/agent/agent.go new file mode 100644 index 00000000..53dcc98e --- /dev/null +++ b/nofxi/internal/agent/agent.go @@ -0,0 +1,499 @@ +// Package agent implements the NOFXi Agent Core. +// +// The Agent is the central orchestrator that: +// 1. Receives user messages (via Interaction layer) +// 2. Routes intents (trade, query, analyze, chat) +// 3. Uses Thinking layer for AI decisions +// 4. Stores context in Memory layer +// 5. Executes trades via Execution bridge (→ NOFX engine) +// 6. Monitors markets via Perception layer +package agent + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + + "github.com/NoFxAiOS/nofx/nofxi/internal/execution" + "github.com/NoFxAiOS/nofx/nofxi/internal/memory" + "github.com/NoFxAiOS/nofx/nofxi/internal/perception" + "github.com/NoFxAiOS/nofx/nofxi/internal/thinking" +) + +// Agent is the NOFXi agent core. +type Agent struct { + config *Config + memory *memory.Store + thinker thinking.Engine + bridge *execution.Bridge + monitor *perception.MarketMonitor + logger *slog.Logger + + // NotifyFunc sends proactive notifications to users (set by interaction layer) + NotifyFunc func(userID int64, text string) error +} + +// New creates a new Agent with the given config. +func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.Logger) *Agent { + a := &Agent{ + config: cfg, + memory: mem, + thinker: thinker, + logger: logger, + } + return a +} + +// SetBridge attaches the execution bridge. +func (a *Agent) SetBridge(bridge *execution.Bridge) { + a.bridge = bridge +} + +// SetMonitor attaches the market monitor and wires up alert notifications. +func (a *Agent) SetMonitor(monitor *perception.MarketMonitor) { + a.monitor = monitor +} + +// HandleMessage processes a user message and returns a response. +func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) { + a.logger.Info("incoming message", "user_id", userID, "text", text) + + // Save user message to memory + if err := a.memory.SaveMessage(userID, "user", text); err != nil { + a.logger.Error("save user message", "error", err) + } + + // Route intent + intent := Route(text) + a.logger.Info("routed intent", "type", intent.Type, "params", intent.Params) + + var response string + var err error + + switch intent.Type { + case IntentHelp: + response = a.handleHelp() + case IntentStatus: + response = a.handleStatus() + case IntentQuery: + response, err = a.handleQuery(ctx, intent) + case IntentAnalyze: + response, err = a.handleAnalyze(ctx, intent) + case IntentTrade: + response, err = a.handleTrade(ctx, userID, intent) + case IntentWatch: + response = a.HandleWatchCommand(intent.Raw) + case IntentSettings: + response = a.handleSettings(intent) + default: + response, err = a.handleChat(ctx, userID, text) + } + + if err != nil { + a.logger.Error("handle message", "intent", intent.Type, "error", err) + response = fmt.Sprintf("⚠️ Error: %v", err) + } + + // Save assistant response to memory + if err := a.memory.SaveMessage(userID, "assistant", response); err != nil { + a.logger.Error("save assistant message", "error", err) + } + + return response, nil +} + +func (a *Agent) handleHelp() string { + return `🤖 *NOFXi — Your AI Trading Agent* + +*Trading:* +/buy BTC 0.01 — Open long (market order) +/sell BTC 0.01 — Open short +/close BTC — Close position +/positions — View open positions +/balance — Check balance +/pnl — Profit & Loss history + +*Analysis:* +/analyze BTC — AI market analysis +/watch BTC — Monitor price +/alert BTC above 100000 — Price alert + +*System:* +/status — Agent status +/settings — Preferences +/help — This menu + +*Natural Language:* +• "帮我做多 ETH,2x 杠杆,0.1 个" +• "分析一下 BTC 走势" +• "现在持仓情况怎样" +• "BTC 到 10 万提醒我" + +Just talk to me 💬` +} + +func (a *Agent) handleStatus() string { + // Market monitor status + watchCount := 0 + if a.monitor != nil { + watchCount = len(a.monitor.GetAllSnapshots()) + } + bridgeStatus := "❌ Not connected" + if a.bridge != nil { + bridgeStatus = "✅ Connected" + } + + return fmt.Sprintf(`📊 *NOFXi Status* + +• Agent: %s +• Model: %s +• Provider: %s +• Memory: ✅ Online +• Execution: %s +• Watching: %d symbols +• Time: %s`, + a.config.Agent.Name, + a.config.LLM.Model, + a.config.LLM.Provider, + bridgeStatus, + watchCount, + time.Now().Format("2006-01-02 15:04:05"), + ) +} + +func (a *Agent) handleQuery(ctx context.Context, intent Intent) (string, error) { + raw := strings.ToLower(intent.Raw) + + // Try live positions from exchange first + if a.bridge != nil && (strings.Contains(raw, "position") || strings.Contains(raw, "持仓")) { + return a.queryPositions() + } + if a.bridge != nil && (strings.Contains(raw, "balance") || strings.Contains(raw, "余额")) { + return a.queryBalance() + } + + // Fall back to trade history from memory + trades, err := a.memory.GetRecentTrades(10) + if err != nil { + return "", fmt.Errorf("get trades: %w", err) + } + + if len(trades) == 0 { + return "📭 No trades yet. Start with `/buy BTC 0.01` or ask me to `/analyze BTC`.", nil + } + + var sb strings.Builder + sb.WriteString("📋 *Recent Trades*\n\n") + totalPnL := 0.0 + for _, t := range trades { + emoji := "🟢" + if t.PnL < 0 { + emoji = "🔴" + } + sb.WriteString(fmt.Sprintf("%s %s %s %s — $%.2f (P/L: $%.2f)\n", + emoji, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL)) + totalPnL += t.PnL + } + sb.WriteString(fmt.Sprintf("\n💰 Total P/L: $%.2f", totalPnL)) + return sb.String(), nil +} + +func (a *Agent) queryPositions() (string, error) { + var allPositions []execution.Position + for _, ex := range a.config.Exchanges { + positions, err := a.bridge.GetPositions(ex.Name) + if err != nil { + a.logger.Error("get positions", "exchange", ex.Name, "error", err) + continue + } + allPositions = append(allPositions, positions...) + } + + if len(allPositions) == 0 { + return "📭 No open positions.", nil + } + + var sb strings.Builder + sb.WriteString("📊 *Open Positions*\n\n") + totalPnL := 0.0 + for _, p := range allPositions { + emoji := "🟢" + if p.PnL < 0 { + emoji = "🔴" + } + sb.WriteString(fmt.Sprintf("%s *%s* %s\n", emoji, p.Symbol, strings.ToUpper(p.Side))) + sb.WriteString(fmt.Sprintf(" Size: %.4f | Entry: $%.2f\n", p.Size, p.EntryPrice)) + sb.WriteString(fmt.Sprintf(" Mark: $%.2f | P/L: $%.2f\n", p.MarkPrice, p.PnL)) + if p.Leverage > 0 { + sb.WriteString(fmt.Sprintf(" Leverage: %.0fx | Exchange: %s\n", p.Leverage, p.Exchange)) + } + sb.WriteString("\n") + totalPnL += p.PnL + } + sb.WriteString(fmt.Sprintf("💰 *Total Unrealized P/L: $%.2f*", totalPnL)) + return sb.String(), nil +} + +func (a *Agent) queryBalance() (string, error) { + var sb strings.Builder + sb.WriteString("💰 *Account Balance*\n\n") + + for _, ex := range a.config.Exchanges { + bal, err := a.bridge.GetBalance(ex.Name) + if err != nil { + a.logger.Error("get balance", "exchange", ex.Name, "error", err) + sb.WriteString(fmt.Sprintf("• %s: ⚠️ Error\n", ex.Name)) + continue + } + sb.WriteString(fmt.Sprintf("*%s*\n", strings.Title(ex.Name))) + sb.WriteString(fmt.Sprintf(" Total: $%.2f\n", bal.Total)) + sb.WriteString(fmt.Sprintf(" Available: $%.2f\n", bal.Available)) + sb.WriteString(fmt.Sprintf(" In Position: $%.2f\n\n", bal.InPosition)) + } + return sb.String(), nil +} + +func (a *Agent) handleAnalyze(ctx context.Context, intent Intent) (string, error) { + symbol := "BTC" + if detail, ok := intent.Params["detail"]; ok && detail != "" { + symbol = strings.ToUpper(strings.TrimSpace(detail)) + } + + // Add live price if available + priceInfo := "" + if a.monitor != nil { + if snap, ok := a.monitor.GetSnapshot(symbol + "USDT"); ok && snap.LastPrice > 0 { + priceInfo = fmt.Sprintf("\nCurrent price: $%.2f", snap.LastPrice) + } + } + if priceInfo == "" && a.bridge != nil { + for _, ex := range a.config.Exchanges { + if price, err := a.bridge.GetPrice(ex.Name, symbol+"USDT"); err == nil && price > 0 { + priceInfo = fmt.Sprintf("\nCurrent price: $%.2f (from %s)", price, ex.Name) + break + } + } + } + + prompt := fmt.Sprintf( + "Analyze %s/USDT for trading. %s\n"+ + "Consider: trend, support/resistance, momentum indicators, volume, sentiment.\n"+ + "Give specific entry/exit levels and stop loss. Be concise.", + symbol, priceInfo) + + analysis, err := a.thinker.Analyze(ctx, prompt) + if err != nil { + return "", fmt.Errorf("AI analyze: %w", err) + } + + emoji := map[string]string{ + "buy": "🟢 BUY", + "sell": "🔴 SELL", + "hold": "🟡 HOLD", + "wait": "⏳ WAIT", + } + + action := emoji[analysis.Action] + if action == "" { + action = "🤔 " + analysis.Action + } + + result := fmt.Sprintf("🔍 *%s/USDT Analysis*\n\nSignal: %s\nConfidence: %.0f%%\n\n%s", + symbol, action, analysis.Confidence*100, analysis.Reasoning) + + if analysis.StopLoss > 0 { + result += fmt.Sprintf("\n\n🛑 Stop Loss: $%.2f", analysis.StopLoss) + } + if analysis.TakeProfit > 0 { + result += fmt.Sprintf("\n🎯 Take Profit: $%.2f", analysis.TakeProfit) + } + + return result, nil +} + +func (a *Agent) handleTrade(ctx context.Context, userID int64, intent Intent) (string, error) { + action := strings.ToLower(intent.Params["action"]) + detail := intent.Params["detail"] + + if a.bridge == nil { + return fmt.Sprintf("⚡ *Trade: %s %s*\n\n🔧 No exchange connected. Configure exchanges in config.yaml first.", + strings.ToUpper(action), detail), nil + } + + // Parse: "BTC 0.01" or "BTCUSDT 0.01 2x" + parts := strings.Fields(detail) + if len(parts) < 1 { + return "❓ Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`", nil + } + + symbol := strings.ToUpper(parts[0]) + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + + quantity := 0.0 + leverage := 1 + if len(parts) >= 2 { + q, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + return fmt.Sprintf("❓ Invalid quantity: %s", parts[1]), nil + } + quantity = q + } + if len(parts) >= 3 { + levStr := strings.TrimSuffix(strings.ToLower(parts[2]), "x") + l, err := strconv.Atoi(levStr) + if err == nil { + leverage = l + } + } + + if quantity <= 0 && (action == "buy" || action == "sell" || action == "long" || action == "short") { + return "❓ Please specify quantity: `/buy BTC 0.01`", nil + } + + // Map action to execution side + var side string + switch action { + case "buy", "long", "open_long": + side = "LONG" + case "sell", "short", "open_short": + side = "SHORT" + case "close": + side = "CLOSE_LONG" // TODO: detect which side to close + default: + side = strings.ToUpper(action) + } + + // Use first configured exchange + exchange := a.config.Exchanges[0].Name + + // Confirm with user before executing + confirmMsg := fmt.Sprintf("⚡ *Confirm Trade*\n\n"+ + "• Action: %s\n"+ + "• Symbol: %s\n"+ + "• Quantity: %.6f\n"+ + "• Leverage: %dx\n"+ + "• Exchange: %s\n\n"+ + "Reply 'yes' to execute.", + side, symbol, quantity, leverage, exchange) + + // Store pending trade for confirmation + a.memory.SetPreference(userID, "pending_trade", + fmt.Sprintf("%s|%s|%f|%d|%s", side, symbol, quantity, leverage, exchange)) + + return confirmMsg, nil +} + +// ExecutePendingTrade executes a trade that was previously confirmed. +func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64) (string, error) { + pending, err := a.memory.GetPreference(userID, "pending_trade") + if err != nil || pending == "" { + return "", fmt.Errorf("no pending trade") + } + + // Clear pending + a.memory.SetPreference(userID, "pending_trade", "") + + parts := strings.Split(pending, "|") + if len(parts) != 5 { + return "", fmt.Errorf("invalid pending trade data") + } + + side := parts[0] + symbol := parts[1] + quantity, _ := strconv.ParseFloat(parts[2], 64) + leverage, _ := strconv.Atoi(parts[3]) + exchange := parts[4] + + result, err := a.bridge.PlaceOrder(exchange, symbol, side, quantity, leverage) + if err != nil { + return "", fmt.Errorf("execute trade: %w", err) + } + + // Save trade to memory + tradeRecord := &memory.TradeRecord{ + Exchange: exchange, + Symbol: symbol, + Side: strings.ToLower(side), + Type: "market", + Quantity: quantity, + Status: "open", + } + if price, ok := result["avgPrice"].(float64); ok { + tradeRecord.Price = price + } + a.memory.SaveTrade(tradeRecord) + + return fmt.Sprintf("✅ *Trade Executed!*\n\n"+ + "• %s %s\n"+ + "• Qty: %.6f\n"+ + "• Leverage: %dx\n"+ + "• Exchange: %s\n"+ + "• Result: %v", + side, symbol, quantity, leverage, exchange, result), nil +} + +func (a *Agent) handleSettings(intent Intent) string { + return `⚙️ *Settings* + +• Language: ` + a.config.Agent.Language + ` +• Model: ` + a.config.LLM.Model + ` +• Provider: ` + a.config.LLM.Provider + ` +• Exchanges: ` + fmt.Sprintf("%d configured", len(a.config.Exchanges)) +} + +func (a *Agent) handleChat(ctx context.Context, userID int64, text string) (string, error) { + // Check for trade confirmation + lower := strings.ToLower(text) + if lower == "yes" || lower == "y" || lower == "确认" || lower == "是" { + pending, _ := a.memory.GetPreference(userID, "pending_trade") + if pending != "" { + return a.ExecutePendingTrade(ctx, userID) + } + } + + // Get conversation history for context + history, err := a.memory.GetRecentMessages(userID, 20) + if err != nil { + a.logger.Error("get history", "error", err) + } + + // Build messages with system prompt + messages := []thinking.Message{ + { + Role: "system", + Content: fmt.Sprintf(`You are NOFXi, an AI trading agent built on NOFX. + +Your capabilities: +- Market analysis and trading recommendations +- Real-time position and balance monitoring +- Trade execution (open/close positions on exchanges) +- Price alerts and market monitoring +- Risk management advice + +You support multiple exchanges: Binance, OKX, Bybit, Bitget, KuCoin, Gate, Hyperliquid, etc. + +Be concise, confident, and action-oriented. Use trading emojis. +Respond in the same language the user uses. +Current time: %s`, time.Now().Format("2006-01-02 15:04:05")), + }, + } + + for _, msg := range history { + messages = append(messages, thinking.Message{ + Role: msg.Role, + Content: msg.Content, + }) + } + + messages = append(messages, thinking.Message{ + Role: "user", + Content: text, + }) + + return a.thinker.Chat(ctx, messages) +} diff --git a/nofxi/internal/agent/config.go b/nofxi/internal/agent/config.go new file mode 100644 index 00000000..f24855ec --- /dev/null +++ b/nofxi/internal/agent/config.go @@ -0,0 +1,98 @@ +package agent + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config holds the complete NOFXi configuration. +type Config struct { + Agent AgentConfig `yaml:"agent"` + Telegram TelegramConfig `yaml:"telegram"` + LLM LLMConfig `yaml:"llm"` + Database DatabaseConfig `yaml:"database"` + Exchanges []ExchangeConfig `yaml:"exchanges"` +} + +type AgentConfig struct { + Name string `yaml:"name"` // Agent display name + Language string `yaml:"language"` // "en" or "zh" + LogLevel string `yaml:"log_level"` // "debug", "info", "warn", "error" + WebPort int `yaml:"web_port"` // REST API port (0 = disabled) +} + +type TelegramConfig struct { + Token string `yaml:"token"` + AllowedIDs []int64 `yaml:"allowed_ids"` // Allowed Telegram user IDs (empty = allow all) +} + +type LLMConfig struct { + Provider string `yaml:"provider"` // "openai", "claw402" + BaseURL string `yaml:"base_url"` // API base URL + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` // e.g. "gpt-4o", "deepseek-chat" +} + +type DatabaseConfig struct { + Path string `yaml:"path"` // SQLite file path +} + +type ExchangeConfig struct { + Name string `yaml:"name"` // "binance", "okx", "bybit", etc. + APIKey string `yaml:"api_key"` + APISecret string `yaml:"api_secret"` + Passphrase string `yaml:"passphrase"` // OKX needs this + Testnet bool `yaml:"testnet"` +} + +// LoadConfig reads config from a YAML file. +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + cfg := &Config{ + Agent: AgentConfig{ + Name: "NOFXi", + Language: "en", + LogLevel: "info", + }, + Database: DatabaseConfig{ + Path: "nofxi.db", + }, + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + // Override with env vars if set + if v := os.Getenv("NOFXI_TELEGRAM_TOKEN"); v != "" { + cfg.Telegram.Token = v + } + if v := os.Getenv("NOFXI_LLM_API_KEY"); v != "" { + cfg.LLM.APIKey = v + } + if v := os.Getenv("NOFXI_LLM_BASE_URL"); v != "" { + cfg.LLM.BaseURL = v + } + + return cfg, nil +} + +// Validate checks required fields. +func (c *Config) Validate() error { + if c.Telegram.Token == "" { + return fmt.Errorf("telegram.token is required") + } + if c.LLM.APIKey == "" { + return fmt.Errorf("llm.api_key is required") + } + if c.LLM.Model == "" { + return fmt.Errorf("llm.model is required") + } + return nil +} diff --git a/nofxi/internal/agent/router.go b/nofxi/internal/agent/router.go new file mode 100644 index 00000000..a4c33a71 --- /dev/null +++ b/nofxi/internal/agent/router.go @@ -0,0 +1,153 @@ +package agent + +import ( + "regexp" + "strings" +) + +// Intent represents the parsed user intent. +type Intent struct { + Type IntentType + Params map[string]string + Raw string +} + +// IntentType identifies what the user wants to do. +type IntentType int + +const ( + IntentChat IntentType = iota // General conversation + IntentTrade // Open/close a trade + IntentQuery // Query positions, balance, P/L + IntentAnalyze // Ask for market analysis + IntentSettings // Change preferences + IntentHelp // Help / command list + IntentStatus // Check agent/system status + IntentWatch // Watch symbols, price alerts +) + +var ( + tradePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(buy|sell|long|short|open|close)\s+(.+)`), + regexp.MustCompile(`(?i)(做多|做空|买入|卖出|开仓|平仓)\s*(.+)`), + } + queryPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(position|balance|pnl|profit|loss|持仓|余额|盈亏)`), + regexp.MustCompile(`(?i)(show|list|查看)\s*(position|trade|order|持仓|订单)`), + } + analyzePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(analyze|analysis|分析|看看)\s+(.+)`), + regexp.MustCompile(`(?i)(what.*think|怎么看|你觉得)\s*(.+)?`), + } + settingsPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(set|config|设置|配置)\s+(.+)`), + } +) + +// Route parses user input and determines intent. +func Route(text string) Intent { + text = strings.TrimSpace(text) + lower := strings.ToLower(text) + + // Commands + if strings.HasPrefix(lower, "/") { + return routeCommand(text) + } + + // Trade intent + for _, p := range tradePatterns { + if m := p.FindStringSubmatch(text); m != nil { + return Intent{ + Type: IntentTrade, + Params: map[string]string{ + "action": strings.ToLower(m[1]), + "detail": m[2], + }, + Raw: text, + } + } + } + + // Query intent + for _, p := range queryPatterns { + if p.MatchString(text) { + return Intent{Type: IntentQuery, Raw: text} + } + } + + // Analyze intent + for _, p := range analyzePatterns { + if m := p.FindStringSubmatch(text); m != nil { + detail := "" + if len(m) > 2 { + detail = m[2] + } + return Intent{ + Type: IntentAnalyze, + Params: map[string]string{ + "detail": detail, + }, + Raw: text, + } + } + } + + // Settings intent + for _, p := range settingsPatterns { + if m := p.FindStringSubmatch(text); m != nil { + return Intent{ + Type: IntentSettings, + Params: map[string]string{ + "detail": m[2], + }, + Raw: text, + } + } + } + + // Default: chat + return Intent{Type: IntentChat, Raw: text} +} + +func routeCommand(text string) Intent { + cmd := strings.ToLower(strings.Fields(text)[0]) + switch cmd { + case "/start", "/help": + return Intent{Type: IntentHelp, Raw: text} + case "/status": + return Intent{Type: IntentStatus, Raw: text} + case "/buy", "/sell", "/long", "/short", "/open", "/close": + parts := strings.SplitN(text, " ", 2) + detail := "" + if len(parts) > 1 { + detail = parts[1] + } + return Intent{ + Type: IntentTrade, + Params: map[string]string{ + "action": strings.TrimPrefix(cmd, "/"), + "detail": detail, + }, + Raw: text, + } + case "/positions", "/balance", "/pnl": + return Intent{Type: IntentQuery, Raw: text} + case "/watch", "/unwatch", "/alert", "/price": + return Intent{Type: IntentWatch, Raw: text} + case "/analyze": + parts := strings.SplitN(text, " ", 2) + detail := "" + if len(parts) > 1 { + detail = parts[1] + } + return Intent{ + Type: IntentAnalyze, + Params: map[string]string{"detail": detail}, + Raw: text, + } + case "/settings", "/config": + return Intent{Type: IntentSettings, Raw: text} + default: + return Intent{Type: IntentChat, Raw: text} + } +} diff --git a/nofxi/internal/agent/scheduler.go b/nofxi/internal/agent/scheduler.go new file mode 100644 index 00000000..70aa806e --- /dev/null +++ b/nofxi/internal/agent/scheduler.go @@ -0,0 +1,187 @@ +package agent + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/NoFxAiOS/nofx/nofxi/internal/memory" +) + +// Scheduler handles periodic tasks: daily reports, portfolio checks, etc. +type Scheduler struct { + agent *Agent + logger *slog.Logger + stopCh chan struct{} + notifyFn func(userID int64, text string) error +} + +// NewScheduler creates a new task scheduler. +func NewScheduler(agent *Agent, logger *slog.Logger) *Scheduler { + return &Scheduler{ + agent: agent, + logger: logger, + stopCh: make(chan struct{}), + } +} + +// SetNotifyFunc sets the notification callback. +func (s *Scheduler) SetNotifyFunc(fn func(userID int64, text string) error) { + s.notifyFn = fn +} + +// Start begins the scheduler loop. +func (s *Scheduler) Start(ctx context.Context) { + go s.loop(ctx) +} + +// Stop stops the scheduler. +func (s *Scheduler) Stop() { + close(s.stopCh) +} + +func (s *Scheduler) loop(ctx context.Context) { + // Check every minute for scheduled tasks + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + lastDailyReport := time.Time{} + lastPortfolioCheck := time.Time{} + + for { + select { + case <-ctx.Done(): + return + case <-s.stopCh: + return + case now := <-ticker.C: + hour := now.Hour() + + // Daily report at 21:00 (9 PM) + if hour == 21 && now.Sub(lastDailyReport) > 12*time.Hour { + s.sendDailyReport(ctx) + lastDailyReport = now + } + + // Portfolio check every 4 hours + if now.Sub(lastPortfolioCheck) > 4*time.Hour { + s.checkPortfolio(ctx) + lastPortfolioCheck = now + } + } + } +} + +func (s *Scheduler) sendDailyReport(ctx context.Context) { + s.logger.Info("generating daily report") + + trades, err := s.agent.memory.GetRecentTrades(50) + if err != nil { + s.logger.Error("get trades for daily report", "error", err) + return + } + + // Filter today's trades + today := time.Now().Truncate(24 * time.Hour) + var todayTrades []memory.TradeRecord + totalPnL := 0.0 + wins := 0 + + for _, t := range trades { + if t.CreatedAt.After(today) { + todayTrades = append(todayTrades, t) + totalPnL += t.PnL + if t.PnL > 0 { + wins++ + } + } + } + + if len(todayTrades) == 0 { + s.logger.Info("no trades today, skipping daily report") + return + } + + winRate := 0.0 + if len(todayTrades) > 0 { + winRate = float64(wins) / float64(len(todayTrades)) * 100 + } + + emoji := "📈" + if totalPnL < 0 { + emoji = "📉" + } + + report := fmt.Sprintf(`%s *NOFXi Daily Report — %s* + +📊 *Summary* +• Trades: %d +• Win Rate: %.1f%% +• Total P/L: $%.2f + +`, emoji, time.Now().Format("2006-01-02"), len(todayTrades), winRate, totalPnL) + + // Add trade details + if len(todayTrades) > 0 { + report += "📋 *Trades*\n" + for _, t := range todayTrades { + e := "🟢" + if t.PnL < 0 { + e = "🔴" + } + report += fmt.Sprintf("%s %s %s — P/L: $%.2f\n", e, t.Side, t.Symbol, t.PnL) + } + } + + report += "\n_Generated by NOFXi 🤖_" + + s.notify(report) +} + +func (s *Scheduler) checkPortfolio(ctx context.Context) { + if s.agent.bridge == nil { + return + } + + s.logger.Info("checking portfolio") + + var alerts []string + + for _, ex := range s.agent.config.Exchanges { + positions, err := s.agent.bridge.GetPositions(ex.Name) + if err != nil { + continue + } + + for _, p := range positions { + // Alert on large unrealized losses (> 5%) + if p.EntryPrice > 0 && p.PnL < 0 { + lossPct := (p.PnL / (p.EntryPrice * p.Size)) * 100 + if lossPct < -5 { + alerts = append(alerts, fmt.Sprintf( + "⚠️ *%s* %s: %.1f%% loss ($%.2f)", + p.Symbol, strings.ToUpper(p.Side), lossPct, p.PnL)) + } + } + } + } + + if len(alerts) > 0 { + msg := "🚨 *Portfolio Alert*\n\n" + strings.Join(alerts, "\n") + s.notify(msg) + } +} + +func (s *Scheduler) notify(text string) { + if s.notifyFn == nil { + s.logger.Warn("no notification function set, cannot send scheduled message") + return + } + for _, uid := range s.agent.config.Telegram.AllowedIDs { + if err := s.notifyFn(uid, text); err != nil { + s.logger.Error("send notification", "user_id", uid, "error", err) + } + } +} diff --git a/nofxi/internal/agent/watchcmd.go b/nofxi/internal/agent/watchcmd.go new file mode 100644 index 00000000..d6a6c453 --- /dev/null +++ b/nofxi/internal/agent/watchcmd.go @@ -0,0 +1,125 @@ +package agent + +import ( + "fmt" + "strconv" + "strings" +) + +// HandleWatchCommand processes /watch and /alert commands. +// Returns response text. Called from HandleMessage when intent is detected. +func (a *Agent) HandleWatchCommand(text string) string { + lower := strings.ToLower(strings.TrimSpace(text)) + + // /watch BTC + if strings.HasPrefix(lower, "/watch") { + parts := strings.Fields(text) + if len(parts) < 2 { + return a.listWatched() + } + symbol := strings.ToUpper(parts[1]) + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + if a.monitor != nil { + a.monitor.Watch(symbol) + return fmt.Sprintf("👁️ Now watching *%s*. I'll track the price.", symbol) + } + return "⚠️ Market monitor not available." + } + + // /unwatch BTC + if strings.HasPrefix(lower, "/unwatch") { + parts := strings.Fields(text) + if len(parts) < 2 { + return "Usage: `/unwatch BTC`" + } + symbol := strings.ToUpper(parts[1]) + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + if a.monitor != nil { + a.monitor.Unwatch(symbol) + return fmt.Sprintf("🚫 Stopped watching *%s*.", symbol) + } + return "⚠️ Market monitor not available." + } + + // /alert BTC above 100000 + if strings.HasPrefix(lower, "/alert") { + parts := strings.Fields(text) + if len(parts) < 4 { + return "Usage: `/alert BTC above 100000` or `/alert ETH below 3000`" + } + symbol := strings.ToUpper(parts[1]) + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + direction := strings.ToLower(parts[2]) + if direction != "above" && direction != "below" { + return "Direction must be `above` or `below`." + } + threshold, err := strconv.ParseFloat(parts[3], 64) + if err != nil { + return fmt.Sprintf("Invalid price: %s", parts[3]) + } + + if a.monitor != nil { + a.monitor.Watch(symbol) // Ensure we're watching it + a.monitor.AddAlert(symbol, direction, threshold) + emoji := "📈" + if direction == "below" { + emoji = "📉" + } + return fmt.Sprintf("%s Alert set: *%s* %s $%.2f\nI'll notify you when it triggers.", + emoji, symbol, direction, threshold) + } + return "⚠️ Market monitor not available." + } + + // /price BTC + if strings.HasPrefix(lower, "/price") { + parts := strings.Fields(text) + if len(parts) < 2 { + return "Usage: `/price BTC`" + } + symbol := strings.ToUpper(parts[1]) + if !strings.HasSuffix(symbol, "USDT") { + symbol += "USDT" + } + if a.monitor != nil { + if snap, ok := a.monitor.GetSnapshot(symbol); ok && snap.LastPrice > 0 { + return fmt.Sprintf("💰 *%s*: $%.4f\n_Updated: %s_", + symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05")) + } + // Not watching yet, watch and return message + a.monitor.Watch(symbol) + return fmt.Sprintf("👁️ Started watching *%s*. Price will be available in ~30s.", symbol) + } + return "⚠️ Market monitor not available." + } + + return "" +} + +func (a *Agent) listWatched() string { + if a.monitor == nil { + return "⚠️ Market monitor not available." + } + snaps := a.monitor.GetAllSnapshots() + if len(snaps) == 0 { + return "📭 Not watching any symbols. Use `/watch BTC` to start." + } + + var sb strings.Builder + sb.WriteString("👁️ *Watching*\n\n") + for symbol, snap := range snaps { + if snap.LastPrice > 0 { + sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", + symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05"))) + } else { + sb.WriteString(fmt.Sprintf("• *%s*: waiting for data...\n", symbol)) + } + } + return sb.String() +} diff --git a/nofxi/internal/execution/bridge.go b/nofxi/internal/execution/bridge.go new file mode 100644 index 00000000..81818e34 --- /dev/null +++ b/nofxi/internal/execution/bridge.go @@ -0,0 +1,192 @@ +package execution + +import ( + "fmt" + "log/slog" + "strings" + "sync" +) + +// TraderFactory creates a NOFX Trader by exchange name. +// Injected from main.go where nofx exchange packages are available. +type TraderFactory func(exchange, apiKey, apiSecret, passphrase string, testnet bool) (NofxTrader, error) + +// NofxTrader mirrors nofx/trader/types.Trader interface. +// We redefine it here to avoid a direct import cycle with the parent module. +type NofxTrader interface { + GetBalance() (map[string]interface{}, error) + GetPositions() ([]map[string]interface{}, error) + OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) + CloseLong(symbol string, quantity float64) (map[string]interface{}, error) + CloseShort(symbol string, quantity float64) (map[string]interface{}, error) + SetLeverage(symbol string, leverage int) error + GetMarketPrice(symbol string) (float64, error) + SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error + SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error + CancelAllOrders(symbol string) error +} + +// Bridge connects NOFXi to NOFX trading engine. +type Bridge struct { + mu sync.RWMutex + traders map[string]NofxTrader // exchange name → trader + factory TraderFactory + logger *slog.Logger +} + +// NewBridge creates a new execution bridge. +func NewBridge(factory TraderFactory, logger *slog.Logger) *Bridge { + return &Bridge{ + traders: make(map[string]NofxTrader), + factory: factory, + logger: logger, + } +} + +// RegisterTrader registers a pre-configured trader for an exchange. +func (b *Bridge) RegisterTrader(exchange string, trader NofxTrader) { + b.mu.Lock() + defer b.mu.Unlock() + b.traders[strings.ToLower(exchange)] = trader + b.logger.Info("trader registered", "exchange", exchange) +} + +// getTrader returns the trader for the given exchange. +func (b *Bridge) getTrader(exchange string) (NofxTrader, error) { + b.mu.RLock() + defer b.mu.RUnlock() + t, ok := b.traders[strings.ToLower(exchange)] + if !ok { + return nil, fmt.Errorf("exchange %q not configured", exchange) + } + return t, nil +} + +// PlaceOrder executes a trade via the NOFX trader. +func (b *Bridge) PlaceOrder(exchange, symbol, side string, quantity float64, leverage int) (map[string]interface{}, error) { + trader, err := b.getTrader(exchange) + if err != nil { + return nil, err + } + + // Set leverage first + if leverage > 0 { + if err := trader.SetLeverage(symbol, leverage); err != nil { + b.logger.Warn("set leverage failed", "error", err) + } + } + + side = strings.ToUpper(side) + switch side { + case "BUY", "LONG", "OPEN_LONG": + b.logger.Info("opening long", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage) + return trader.OpenLong(symbol, quantity, leverage) + case "SELL", "SHORT", "OPEN_SHORT": + b.logger.Info("opening short", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage) + return trader.OpenShort(symbol, quantity, leverage) + case "CLOSE_LONG": + b.logger.Info("closing long", "exchange", exchange, "symbol", symbol, "qty", quantity) + return trader.CloseLong(symbol, quantity) + case "CLOSE_SHORT": + b.logger.Info("closing short", "exchange", exchange, "symbol", symbol, "qty", quantity) + return trader.CloseShort(symbol, quantity) + default: + return nil, fmt.Errorf("unknown side: %s", side) + } +} + +// GetPositions returns all open positions from an exchange. +func (b *Bridge) GetPositions(exchange string) ([]Position, error) { + trader, err := b.getTrader(exchange) + if err != nil { + return nil, err + } + + raw, err := trader.GetPositions() + if err != nil { + return nil, fmt.Errorf("get positions: %w", err) + } + + var positions []Position + for _, p := range raw { + pos := Position{ + Exchange: exchange, + Symbol: fmt.Sprint(p["symbol"]), + Side: fmt.Sprint(p["side"]), + } + if v, ok := p["size"].(float64); ok { + pos.Size = v + } + if v, ok := p["entryPrice"].(float64); ok { + pos.EntryPrice = v + } + if v, ok := p["markPrice"].(float64); ok { + pos.MarkPrice = v + } + if v, ok := p["unrealizedPnl"].(float64); ok { + pos.PnL = v + } + if v, ok := p["leverage"].(float64); ok { + pos.Leverage = v + } + // Skip empty positions + if pos.Size != 0 { + positions = append(positions, pos) + } + } + return positions, nil +} + +// GetBalance returns account balance from an exchange. +func (b *Bridge) GetBalance(exchange string) (*Balance, error) { + trader, err := b.getTrader(exchange) + if err != nil { + return nil, err + } + + raw, err := trader.GetBalance() + if err != nil { + return nil, fmt.Errorf("get balance: %w", err) + } + + bal := &Balance{ + Exchange: exchange, + Currency: "USDT", + } + if v, ok := raw["totalBalance"].(float64); ok { + bal.Total = v + } + if v, ok := raw["availableBalance"].(float64); ok { + bal.Available = v + } + bal.InPosition = bal.Total - bal.Available + return bal, nil +} + +// GetPrice returns the current market price for a symbol. +func (b *Bridge) GetPrice(exchange, symbol string) (float64, error) { + trader, err := b.getTrader(exchange) + if err != nil { + return 0, err + } + return trader.GetMarketPrice(symbol) +} + +// SetStopLoss sets a stop-loss order. +func (b *Bridge) SetStopLoss(exchange, symbol, positionSide string, quantity, price float64) error { + trader, err := b.getTrader(exchange) + if err != nil { + return err + } + return trader.SetStopLoss(symbol, positionSide, quantity, price) +} + +// SetTakeProfit sets a take-profit order. +func (b *Bridge) SetTakeProfit(exchange, symbol, positionSide string, quantity, price float64) error { + trader, err := b.getTrader(exchange) + if err != nil { + return err + } + return trader.SetTakeProfit(symbol, positionSide, quantity, price) +} diff --git a/nofxi/internal/execution/execution.go b/nofxi/internal/execution/execution.go new file mode 100644 index 00000000..192af2ca --- /dev/null +++ b/nofxi/internal/execution/execution.go @@ -0,0 +1,47 @@ +// Package execution implements the Execution Layer. +// +// Phase 2 — stubs for now. +// +// Planned capabilities: +// - Multi-exchange order execution (via NOFX engine) +// - Position management (open/close/adjust) +// - Stop-loss and take-profit management +// - x402 payment integration via Claw402 +// - Safe mode and risk controls +package execution + +// Executor is the trade execution interface. +type Executor interface { + // PlaceOrder places a trade order on the exchange. + PlaceOrder(exchange, symbol, side string, price, quantity float64) (string, error) + + // ClosePosition closes an existing position. + ClosePosition(exchange, symbol string) error + + // GetPositions returns current open positions. + GetPositions(exchange string) ([]Position, error) + + // GetBalance returns account balance. + GetBalance(exchange string) (*Balance, error) +} + +// Position represents an open position. +type Position struct { + Exchange string `json:"exchange"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Size float64 `json:"size"` + EntryPrice float64 `json:"entry_price"` + MarkPrice float64 `json:"mark_price"` + PnL float64 `json:"pnl"` + Leverage float64 `json:"leverage"` +} + +// Balance represents account balance. +type Balance struct { + Exchange string `json:"exchange"` + Total float64 `json:"total"` + Available float64 `json:"available"` + InPosition float64 `json:"in_position"` + Currency string `json:"currency"` +} diff --git a/nofxi/internal/interaction/formatter.go b/nofxi/internal/interaction/formatter.go new file mode 100644 index 00000000..0cab57fc --- /dev/null +++ b/nofxi/internal/interaction/formatter.go @@ -0,0 +1,68 @@ +package interaction + +import ( + "fmt" + "strings" + "time" +) + +// FormatTradeAlert formats a trade event for Telegram notification. +func FormatTradeAlert(action, symbol, exchange string, price, quantity float64) string { + emoji := "🟢" + if action == "sell" || action == "short" || action == "close" { + emoji = "🔴" + } + return fmt.Sprintf(`%s *Trade Executed* + +• Action: %s +• Symbol: %s +• Exchange: %s +• Price: $%.4f +• Quantity: %.6f +• Value: $%.2f +• Time: %s`, + emoji, + strings.ToUpper(action), + symbol, + exchange, + price, + quantity, + price*quantity, + time.Now().Format("15:04:05"), + ) +} + +// FormatDailyReport formats a daily P/L summary. +func FormatDailyReport(totalPnL float64, trades int, winRate float64) string { + emoji := "📈" + if totalPnL < 0 { + emoji = "📉" + } + return fmt.Sprintf(`%s *Daily Report — %s* + +• Trades: %d +• Win Rate: %.1f%% +• P/L: $%.2f + +Keep going! 💪`, + emoji, + time.Now().Format("2006-01-02"), + trades, + winRate, + totalPnL, + ) +} + +// FormatPriceAlert formats a price alert notification. +func FormatPriceAlert(symbol string, price float64, direction string, threshold float64) string { + emoji := "🚨" + if direction == "above" { + emoji = "📈" + } else { + emoji = "📉" + } + return fmt.Sprintf(`%s *Price Alert* + +%s hit $%.4f (%s $%.4f threshold)`, + emoji, symbol, price, direction, threshold) +} diff --git a/nofxi/internal/interaction/interaction.go b/nofxi/internal/interaction/interaction.go new file mode 100644 index 00000000..ae051bd8 --- /dev/null +++ b/nofxi/internal/interaction/interaction.go @@ -0,0 +1,10 @@ +// Package interaction implements the Interaction Layer. +// +// Primary interface: Telegram bot +// - Natural language understanding for trading commands +// - Access control (allowed user IDs) +// - Typing indicators and markdown formatting +// - Proactive notifications +// +// Future: Web UI API endpoints +package interaction diff --git a/nofxi/internal/interaction/telegram.go b/nofxi/internal/interaction/telegram.go new file mode 100644 index 00000000..f3fb727b --- /dev/null +++ b/nofxi/internal/interaction/telegram.go @@ -0,0 +1,128 @@ +package interaction + +import ( + "context" + "fmt" + "log/slog" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" +) + +// MessageHandler is called for each incoming message. +type MessageHandler func(ctx context.Context, userID int64, text string) (string, error) + +// TelegramBot wraps the Telegram Bot API. +type TelegramBot struct { + bot *tgbotapi.BotAPI + handler MessageHandler + allowedIDs map[int64]bool // Empty = allow all + logger *slog.Logger +} + +// NewTelegramBot creates a new Telegram bot. +func NewTelegramBot(token string, allowedIDs []int64, handler MessageHandler, logger *slog.Logger) (*TelegramBot, error) { + bot, err := tgbotapi.NewBotAPI(token) + if err != nil { + return nil, fmt.Errorf("create bot: %w", err) + } + + allowed := make(map[int64]bool) + for _, id := range allowedIDs { + allowed[id] = true + } + + logger.Info("telegram bot authorized", "username", bot.Self.UserName) + + return &TelegramBot{ + bot: bot, + handler: handler, + allowedIDs: allowed, + logger: logger, + }, nil +} + +// Start begins polling for updates. Blocks until context is cancelled. +func (t *TelegramBot) Start(ctx context.Context) error { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := t.bot.GetUpdatesChan(u) + + t.logger.Info("telegram bot started, listening for messages...") + + for { + select { + case <-ctx.Done(): + t.bot.StopReceivingUpdates() + t.logger.Info("telegram bot stopped") + return nil + case update := <-updates: + if update.Message == nil { + continue + } + go t.handleUpdate(ctx, update) + } + } +} + +func (t *TelegramBot) handleUpdate(ctx context.Context, update tgbotapi.Update) { + msg := update.Message + userID := msg.From.ID + + // Access control + if len(t.allowedIDs) > 0 && !t.allowedIDs[userID] { + t.logger.Warn("unauthorized user", "user_id", userID, "username", msg.From.UserName) + t.sendText(msg.Chat.ID, "⛔ Unauthorized. Contact the admin to get access.") + return + } + + text := msg.Text + if text == "" { + return + } + + t.logger.Info("received message", + "user_id", userID, + "username", msg.From.UserName, + "text", text, + ) + + // Send "typing" indicator + typing := tgbotapi.NewChatAction(msg.Chat.ID, tgbotapi.ChatTyping) + t.bot.Send(typing) + + // Process message + response, err := t.handler(ctx, userID, text) + if err != nil { + t.logger.Error("handle message", "error", err) + response = fmt.Sprintf("⚠️ Error: %v", err) + } + + t.sendMarkdown(msg.Chat.ID, response) +} + +func (t *TelegramBot) sendText(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + if _, err := t.bot.Send(msg); err != nil { + t.logger.Error("send message", "error", err) + } +} + +func (t *TelegramBot) sendMarkdown(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = tgbotapi.ModeMarkdown + + if _, err := t.bot.Send(msg); err != nil { + // Retry without markdown if parsing fails + t.logger.Warn("markdown send failed, retrying plain", "error", err) + t.sendText(chatID, text) + } +} + +// SendNotification sends a proactive message to a user. +func (t *TelegramBot) SendNotification(userID int64, text string) error { + msg := tgbotapi.NewMessage(userID, text) + msg.ParseMode = tgbotapi.ModeMarkdown + _, err := t.bot.Send(msg) + return err +} diff --git a/nofxi/internal/interaction/web.go b/nofxi/internal/interaction/web.go new file mode 100644 index 00000000..2a3573cd --- /dev/null +++ b/nofxi/internal/interaction/web.go @@ -0,0 +1,173 @@ +package interaction + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" +) + +// WebServer provides a REST API for NOFXi. +type WebServer struct { + handler MessageHandler + port int + logger *slog.Logger + server *http.Server +} + +// NewWebServer creates a new web API server. +func NewWebServer(port int, handler MessageHandler, logger *slog.Logger) *WebServer { + return &WebServer{ + handler: handler, + port: port, + logger: logger, + } +} + +// chatRequest is the API request body. +type chatRequest struct { + UserID int64 `json:"user_id"` + Message string `json:"message"` +} + +// chatResponse is the API response body. +type chatAPIResponse struct { + Response string `json:"response"` + Error string `json:"error,omitempty"` +} + +// Start begins listening. Blocks until context is cancelled. +func (w *WebServer) Start(ctx context.Context) error { + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) { + json.NewEncoder(rw).Encode(map[string]string{ + "status": "ok", + "agent": "NOFXi", + "time": time.Now().Format(time.RFC3339), + }) + }) + + // Chat endpoint + mux.HandleFunc("/api/chat", func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req chatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "invalid request body"}) + return + } + + if req.Message == "" { + writeJSON(rw, http.StatusBadRequest, chatAPIResponse{Error: "message is required"}) + return + } + if req.UserID == 0 { + req.UserID = 1 // Default user for API access + } + + resp, err := w.handler(r.Context(), req.UserID, req.Message) + if err != nil { + writeJSON(rw, http.StatusInternalServerError, chatAPIResponse{Error: err.Error()}) + return + } + + writeJSON(rw, http.StatusOK, chatAPIResponse{Response: resp}) + }) + + // OpenAI-compatible endpoint (so other tools can talk to NOFXi) + mux.HandleFunc("/v1/chat/completions", func(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(rw, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var body struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + // Get the last user message + userMsg := "" + for i := len(body.Messages) - 1; i >= 0; i-- { + if body.Messages[i].Role == "user" { + userMsg = body.Messages[i].Content + break + } + } + if userMsg == "" { + writeJSON(rw, http.StatusBadRequest, map[string]string{"error": "no user message"}) + return + } + + resp, err := w.handler(r.Context(), 1, userMsg) + if err != nil { + writeJSON(rw, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // Return OpenAI-compatible response + writeJSON(rw, http.StatusOK, map[string]interface{}{ + "id": fmt.Sprintf("nofxi-%d", time.Now().UnixNano()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": "nofxi", + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]string{"role": "assistant", "content": resp}, + "finish_reason": "stop", + }, + }, + }) + }) + + addr := fmt.Sprintf(":%d", w.port) + w.server = &http.Server{ + Addr: addr, + Handler: mux, + } + + w.logger.Info("web server starting", "addr", addr) + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + w.server.Shutdown(shutdownCtx) + }() + + if err := w.server.ListenAndServe(); err != http.ErrServerClosed { + return err + } + return nil +} + +// Port returns the configured port. +func (w *WebServer) Port() int { + return w.port +} + +// PortStr returns port as string. +func (w *WebServer) PortStr() string { + return strconv.Itoa(w.port) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/nofxi/internal/memory/memory.go b/nofxi/internal/memory/memory.go new file mode 100644 index 00000000..6f8460d7 --- /dev/null +++ b/nofxi/internal/memory/memory.go @@ -0,0 +1,11 @@ +// Package memory implements the Memory Layer. +// +// Provides persistent storage for: +// - Trade history and P/L tracking +// - Conversation context (per-user message history) +// - User preferences and risk profiles +// - Strategy performance metrics +// - Market pattern snapshots +// +// Backend: SQLite (pure Go driver, zero CGO) +package memory diff --git a/nofxi/internal/memory/models.go b/nofxi/internal/memory/models.go new file mode 100644 index 00000000..06624c5f --- /dev/null +++ b/nofxi/internal/memory/models.go @@ -0,0 +1,64 @@ +package memory + +import "time" + +// TradeRecord stores a completed or active trade. +type TradeRecord struct { + ID int64 `json:"id"` + Exchange string `json:"exchange"` + Symbol string `json:"symbol"` + Side string `json:"side"` // "buy" or "sell" + Type string `json:"type"` // "market", "limit" + Price float64 `json:"price"` + Quantity float64 `json:"quantity"` + PnL float64 `json:"pnl"` // Realized P/L + Fee float64 `json:"fee"` + Status string `json:"status"` // "open", "closed", "cancelled" + AIModel string `json:"ai_model"` // Which AI model made the decision + AIReason string `json:"ai_reason"` // Why AI made this decision + StrategyID string `json:"strategy_id"` + CreatedAt time.Time `json:"created_at"` + ClosedAt *time.Time `json:"closed_at,omitempty"` +} + +// UserPreference stores user settings and preferences. +type UserPreference struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` // Telegram user ID + Key string `json:"key"` + Value string `json:"value"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Conversation stores chat messages for context. +type Conversation struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Role string `json:"role"` // "user", "assistant", "system" + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// StrategyPerformance tracks how well a strategy has performed. +type StrategyPerformance struct { + ID int64 `json:"id"` + StrategyID string `json:"strategy_id"` + StrategyName string `json:"strategy_name"` + TotalTrades int `json:"total_trades"` + WinRate float64 `json:"win_rate"` + TotalPnL float64 `json:"total_pnl"` + MaxDrawdown float64 `json:"max_drawdown"` + SharpeRatio float64 `json:"sharpe_ratio"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MarketSnapshot stores a point-in-time market state for pattern recognition. +type MarketSnapshot struct { + ID int64 `json:"id"` + Symbol string `json:"symbol"` + Price float64 `json:"price"` + Volume24h float64 `json:"volume_24h"` + Change24h float64 `json:"change_24h"` + Note string `json:"note"` // AI observation about this snapshot + CreatedAt time.Time `json:"created_at"` +} diff --git a/nofxi/internal/memory/store.go b/nofxi/internal/memory/store.go new file mode 100644 index 00000000..80641d8f --- /dev/null +++ b/nofxi/internal/memory/store.go @@ -0,0 +1,216 @@ +package memory + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// Store is the SQLite-backed memory store. +type Store struct { + db *sql.DB +} + +// NewStore opens (or creates) the SQLite database. +func NewStore(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + + // Enable WAL mode for better concurrency + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, fmt.Errorf("set WAL: %w", err) + } + + s := &Store{db: db} + if err := s.migrate(); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + + return s, nil +} + +// Close closes the database. +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate() error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exchange TEXT NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'market', + price REAL NOT NULL, + quantity REAL NOT NULL, + pnl REAL DEFAULT 0, + fee REAL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', + ai_model TEXT, + ai_reason TEXT, + strategy_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + closed_at DATETIME + )`, + `CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, key) + )`, + `CREATE TABLE IF NOT EXISTS strategy_performance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + strategy_id TEXT NOT NULL UNIQUE, + strategy_name TEXT, + total_trades INTEGER DEFAULT 0, + win_rate REAL DEFAULT 0, + total_pnl REAL DEFAULT 0, + max_drawdown REAL DEFAULT 0, + sharpe_ratio REAL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS market_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL, + price REAL NOT NULL, + volume_24h REAL, + change_24h REAL, + note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol)`, + `CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)`, + `CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, created_at)`, + `CREATE INDEX IF NOT EXISTS idx_snapshots_symbol ON market_snapshots(symbol, created_at)`, + } + + for _, q := range queries { + if _, err := s.db.Exec(q); err != nil { + return fmt.Errorf("exec %q: %w", q[:40], err) + } + } + return nil +} + +// --- Trade Operations --- + +// SaveTrade inserts a new trade record. +func (s *Store) SaveTrade(t *TradeRecord) (int64, error) { + res, err := s.db.Exec( + `INSERT INTO trades (exchange, symbol, side, type, price, quantity, pnl, fee, status, ai_model, ai_reason, strategy_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.Exchange, t.Symbol, t.Side, t.Type, t.Price, t.Quantity, + t.PnL, t.Fee, t.Status, t.AIModel, t.AIReason, t.StrategyID, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetRecentTrades returns the last N trades. +func (s *Store) GetRecentTrades(limit int) ([]TradeRecord, error) { + rows, err := s.db.Query( + `SELECT id, exchange, symbol, side, type, price, quantity, pnl, fee, status, + COALESCE(ai_model,''), COALESCE(ai_reason,''), COALESCE(strategy_id,''), created_at, closed_at + FROM trades ORDER BY created_at DESC LIMIT ?`, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var trades []TradeRecord + for rows.Next() { + var t TradeRecord + var closedAt sql.NullTime + if err := rows.Scan(&t.ID, &t.Exchange, &t.Symbol, &t.Side, &t.Type, + &t.Price, &t.Quantity, &t.PnL, &t.Fee, &t.Status, + &t.AIModel, &t.AIReason, &t.StrategyID, &t.CreatedAt, &closedAt); err != nil { + return nil, err + } + if closedAt.Valid { + t.ClosedAt = &closedAt.Time + } + trades = append(trades, t) + } + return trades, nil +} + +// --- Conversation Operations --- + +// SaveMessage stores a conversation message. +func (s *Store) SaveMessage(userID int64, role, content string) error { + _, err := s.db.Exec( + `INSERT INTO conversations (user_id, role, content) VALUES (?, ?, ?)`, + userID, role, content, + ) + return err +} + +// GetRecentMessages returns the last N messages for a user. +func (s *Store) GetRecentMessages(userID int64, limit int) ([]Conversation, error) { + rows, err := s.db.Query( + `SELECT id, user_id, role, content, created_at FROM conversations + WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var msgs []Conversation + for rows.Next() { + var c Conversation + if err := rows.Scan(&c.ID, &c.UserID, &c.Role, &c.Content, &c.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, c) + } + + // Reverse to get chronological order + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + return msgs, nil +} + +// --- Preference Operations --- + +// SetPreference upserts a user preference. +func (s *Store) SetPreference(userID int64, key, value string) error { + _, err := s.db.Exec( + `INSERT INTO user_preferences (user_id, key, value, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, + userID, key, value, time.Now(), + ) + return err +} + +// GetPreference retrieves a user preference. +func (s *Store) GetPreference(userID int64, key string) (string, error) { + var value string + err := s.db.QueryRow( + `SELECT value FROM user_preferences WHERE user_id = ? AND key = ?`, + userID, key, + ).Scan(&value) + if err == sql.ErrNoRows { + return "", nil + } + return value, err +} diff --git a/nofxi/internal/perception/market.go b/nofxi/internal/perception/market.go new file mode 100644 index 00000000..4bf5dc80 --- /dev/null +++ b/nofxi/internal/perception/market.go @@ -0,0 +1,214 @@ +package perception + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" +) + +// PriceAlert triggers when a symbol hits a threshold. +type PriceAlert struct { + Symbol string + Price float64 + Direction string // "above" or "below" + Threshold float64 + Triggered time.Time +} + +// AlertCallback is called when a price alert triggers. +type AlertCallback func(alert PriceAlert) + +// MarketMonitor watches symbols and detects price anomalies. +type MarketMonitor struct { + mu sync.RWMutex + watching map[string]*watchState + alerts []alertRule + onAlert AlertCallback + httpClient *http.Client + logger *slog.Logger + stopCh chan struct{} + pollInterval time.Duration +} + +type watchState struct { + Symbol string + LastPrice float64 + Change1h float64 + Change24h float64 + UpdatedAt time.Time +} + +type alertRule struct { + Symbol string + Direction string // "above" or "below" + Threshold float64 + Fired bool +} + +// NewMarketMonitor creates a new market monitor. +func NewMarketMonitor(onAlert AlertCallback, logger *slog.Logger) *MarketMonitor { + return &MarketMonitor{ + watching: make(map[string]*watchState), + onAlert: onAlert, + httpClient: &http.Client{Timeout: 10 * time.Second}, + logger: logger, + stopCh: make(chan struct{}), + pollInterval: 30 * time.Second, + } +} + +// Watch starts monitoring a symbol. +func (m *MarketMonitor) Watch(symbol string) { + m.mu.Lock() + defer m.mu.Unlock() + m.watching[symbol] = &watchState{Symbol: symbol} + m.logger.Info("watching symbol", "symbol", symbol) +} + +// Unwatch stops monitoring a symbol. +func (m *MarketMonitor) Unwatch(symbol string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.watching, symbol) +} + +// AddAlert adds a price alert rule. +func (m *MarketMonitor) AddAlert(symbol, direction string, threshold float64) { + m.mu.Lock() + defer m.mu.Unlock() + m.alerts = append(m.alerts, alertRule{ + Symbol: symbol, + Direction: direction, + Threshold: threshold, + }) + m.logger.Info("alert added", "symbol", symbol, "direction", direction, "threshold", threshold) +} + +// GetSnapshot returns the latest state for a symbol. +func (m *MarketMonitor) GetSnapshot(symbol string) (*watchState, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.watching[symbol] + return s, ok +} + +// GetAllSnapshots returns all watched symbols. +func (m *MarketMonitor) GetAllSnapshots() map[string]*watchState { + m.mu.RLock() + defer m.mu.RUnlock() + result := make(map[string]*watchState) + for k, v := range m.watching { + result[k] = v + } + return result +} + +// Start begins the monitoring loop. +func (m *MarketMonitor) Start() { + go m.loop() +} + +// Stop stops the monitoring loop. +func (m *MarketMonitor) Stop() { + close(m.stopCh) +} + +func (m *MarketMonitor) loop() { + ticker := time.NewTicker(m.pollInterval) + defer ticker.Stop() + + // Initial fetch + m.updatePrices() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.updatePrices() + } + } +} + +func (m *MarketMonitor) updatePrices() { + m.mu.RLock() + symbols := make([]string, 0, len(m.watching)) + for s := range m.watching { + symbols = append(symbols, s) + } + m.mu.RUnlock() + + for _, symbol := range symbols { + price, err := m.fetchPrice(symbol) + if err != nil { + m.logger.Error("fetch price", "symbol", symbol, "error", err) + continue + } + + m.mu.Lock() + if state, ok := m.watching[symbol]; ok { + state.LastPrice = price + state.UpdatedAt = time.Now() + } + + // Check alerts + for i := range m.alerts { + a := &m.alerts[i] + if a.Symbol != symbol || a.Fired { + continue + } + triggered := false + if a.Direction == "above" && price >= a.Threshold { + triggered = true + } else if a.Direction == "below" && price <= a.Threshold { + triggered = true + } + if triggered { + a.Fired = true + if m.onAlert != nil { + m.onAlert(PriceAlert{ + Symbol: symbol, + Price: price, + Direction: a.Direction, + Threshold: a.Threshold, + Triggered: time.Now(), + }) + } + } + } + m.mu.Unlock() + } +} + +// fetchPrice gets current price from Binance public API (no auth needed). +func (m *MarketMonitor) fetchPrice(symbol string) (float64, error) { + url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/price?symbol=%s", symbol) + resp, err := m.httpClient.Get(url) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + + var result struct { + Price string `json:"price"` + } + if err := json.Unmarshal(body, &result); err != nil { + return 0, err + } + + var price float64 + fmt.Sscanf(result.Price, "%f", &price) + if price == 0 { + return 0, fmt.Errorf("invalid price for %s", symbol) + } + return price, nil +} diff --git a/nofxi/internal/perception/perception.go b/nofxi/internal/perception/perception.go new file mode 100644 index 00000000..0957027b --- /dev/null +++ b/nofxi/internal/perception/perception.go @@ -0,0 +1,20 @@ +// Package perception implements the Perception Layer. +// +// Phase 2 — stubs for now. +// +// Planned capabilities: +// - Real-time market data monitoring +// - Price anomaly detection +// - Position tracking and P/L monitoring +// - News and sentiment analysis +// - Custom alert triggers +package perception + +// Monitor is the market perception interface. +type Monitor interface { + // WatchSymbol starts monitoring a symbol for price changes. + WatchSymbol(symbol string) error + + // Stop stops all monitoring. + Stop() +} diff --git a/nofxi/internal/thinking/engine.go b/nofxi/internal/thinking/engine.go new file mode 100644 index 00000000..4c4e73dc --- /dev/null +++ b/nofxi/internal/thinking/engine.go @@ -0,0 +1,28 @@ +package thinking + +import "context" + +// Engine is the AI decision-making interface. +type Engine interface { + // Chat sends a message to the LLM with conversation context and returns a response. + Chat(ctx context.Context, messages []Message) (string, error) + + // Analyze asks the AI to analyze market data and provide a trading recommendation. + Analyze(ctx context.Context, prompt string) (*Analysis, error) +} + +// Message represents a chat message. +type Message struct { + Role string `json:"role"` // "system", "user", "assistant" + Content string `json:"content"` +} + +// Analysis holds an AI trading recommendation. +type Analysis struct { + Action string `json:"action"` // "buy", "sell", "hold", "wait" + Symbol string `json:"symbol"` + Confidence float64 `json:"confidence"` // 0.0 - 1.0 + Reasoning string `json:"reasoning"` + StopLoss float64 `json:"stop_loss,omitempty"` + TakeProfit float64 `json:"take_profit,omitempty"` +} diff --git a/nofxi/internal/thinking/llm.go b/nofxi/internal/thinking/llm.go new file mode 100644 index 00000000..78f06e7f --- /dev/null +++ b/nofxi/internal/thinking/llm.go @@ -0,0 +1,144 @@ +package thinking + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// LLMEngine implements Engine using an OpenAI-compatible API. +// Works with OpenAI, claw402 (x402), DeepSeek, Qwen, etc. +type LLMEngine struct { + baseURL string + apiKey string + model string + httpClient *http.Client +} + +// NewLLMEngine creates a new LLM-backed thinking engine. +func NewLLMEngine(baseURL, apiKey, model string) *LLMEngine { + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + return &LLMEngine{ + baseURL: baseURL, + apiKey: apiKey, + model: model, + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// chatRequest is the OpenAI chat completions request body. +type chatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` +} + +// chatResponse is the OpenAI chat completions response body. +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Chat sends messages to the LLM and returns the response. +func (e *LLMEngine) Chat(ctx context.Context, messages []Message) (string, error) { + reqBody := chatRequest{ + Model: e.model, + Messages: messages, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", e.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if e.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+e.apiKey) + } + + resp, err := e.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var chatResp chatResponse + if err := json.Unmarshal(respBody, &chatResp); err != nil { + return "", fmt.Errorf("unmarshal response: %w", err) + } + + if chatResp.Error != nil { + return "", fmt.Errorf("LLM error: %s", chatResp.Error.Message) + } + + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("LLM returned no choices") + } + + return chatResp.Choices[0].Message.Content, nil +} + +// Analyze sends an analysis prompt and parses the AI response. +func (e *LLMEngine) Analyze(ctx context.Context, prompt string) (*Analysis, error) { + systemPrompt := `You are NOFXi, an expert AI trading analyst. Analyze the given market data and provide a trading recommendation. + +Respond in JSON format: +{ + "action": "buy|sell|hold|wait", + "symbol": "BTC/USDT", + "confidence": 0.85, + "reasoning": "Brief explanation", + "stop_loss": 0.0, + "take_profit": 0.0 +} + +Be concise. Only recommend high-confidence trades.` + + messages := []Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: prompt}, + } + + resp, err := e.Chat(ctx, messages) + if err != nil { + return nil, err + } + + var analysis Analysis + if err := json.Unmarshal([]byte(resp), &analysis); err != nil { + // If JSON parsing fails, return the raw text as reasoning + return &Analysis{ + Action: "hold", + Reasoning: resp, + }, nil + } + + return &analysis, nil +} diff --git a/nofxi/internal/thinking/thinking.go b/nofxi/internal/thinking/thinking.go new file mode 100644 index 00000000..aabe28ca --- /dev/null +++ b/nofxi/internal/thinking/thinking.go @@ -0,0 +1,9 @@ +// Package thinking implements the Thinking Layer. +// +// Provides AI-powered decision making via OpenAI-compatible APIs. +// Supports multiple providers: OpenAI, claw402 (x402), DeepSeek, Qwen, etc. +// +// Key interfaces: +// - Engine: core AI decision interface (Chat + Analyze) +// - LLMEngine: concrete implementation using HTTP/REST +package thinking