chore: remove legacy nofxi/ subdirectory

All functionality has been migrated to the top-level agent/ package.
The old nofxi/ code was fully self-contained with no external references.
Integration plan doc preserved in docs/nofx-integration-plan.md.

Removed 35 files, ~4400 lines of dead code.
This commit is contained in:
shinchan-zhai
2026-03-23 03:08:07 +08:00
parent 5676ba2e5c
commit afa73790c7
35 changed files with 0 additions and 4427 deletions

13
nofxi/.gitignore vendored
View File

@@ -1,13 +0,0 @@
# Binary
nofxi
# Config (contains secrets)
config.yaml
# Database
*.db
*.db-shm
*.db-wal
# Logs
*.log

View File

@@ -1,31 +0,0 @@
# Build stage
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nofxi ./nofxi/cmd/nofxi/
# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /nofxi .
COPY nofxi/config.example.yaml ./config.example.yaml
# Create data directory for SQLite
RUN mkdir -p /app/data
ENV TZ=Asia/Shanghai
EXPOSE 8900
ENTRYPOINT ["./nofxi"]
CMD ["--config", "/app/config.yaml"]

View File

@@ -1,13 +0,0 @@
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

View File

@@ -1,143 +0,0 @@
<h1 align="center">NOFXi</h1>
<p align="center">
<strong>Your AI Trading Agent.</strong><br/>
<strong>Not a tool. A partner that trades with you.</strong>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
</p>
<p align="center">
<a href="#english">English</a> ·
<a href="#中文">中文</a>
</p>
---
<a id="english"></a>
## What is NOFXi?
NOFXi is an **AI Trading Agent** built on top of [NOFX](https://github.com/NoFxAiOS/nofx). While NOFX is an open-source AI trading engine, NOFXi elevates it into a fully autonomous agent that perceives, thinks, remembers, and acts.
**NOFX** = Engine. **NOFXi** = Intelligence.
### The Difference
| | NOFX (Engine) | NOFXi (Agent) |
|:--|:--|:--|
| **Interaction** | Configure → Start → Watch Dashboard | "Open a BTC long, 2x leverage, 3% stop loss" |
| **Intelligence** | AI makes trade decisions | AI understands context, learns from history |
| **Memory** | Stateless per session | Remembers your preferences, past trades, lessons |
| **Proactivity** | Executes when triggered | Monitors markets, alerts you, acts autonomously |
| **Communication** | Dashboard & logs | Natural language via Telegram, proactive notifications |
### Core Capabilities
- 🧠 **Agent Core** — Context-aware conversation, intent recognition, memory management
- 👁️ **Perception** — Market monitoring, anomaly detection, position tracking, sentiment analysis
- 💭 **Thinking** — Multi-model AI decisions, strategy matching, risk assessment
- 📝 **Memory** — Trade history, strategy performance, user preferences, market patterns
-**Execution** — Multi-exchange trading, position management, x402 payments
- 💬 **Interaction** — Natural language trading, proactive alerts, report generation
### Architecture
```
Interaction Layer (Telegram / Web UI / API)
Agent Core (Conversation + Intent + Context)
┌────┴───────────┬────────────┐
│ │ │
Perception Thinking Memory
(Market Monitor) (AI Engine) (Experience DB)
│ │ │
└────┬───────────┴────────────┘
Execution Layer (Multi-Exchange + x402 Payment)
```
### Built On
- **[NOFX](https://github.com/NoFxAiOS/nofx)** — Open-source AI trading engine (9 exchanges, 7+ AI models)
- **[Claw402](https://claw402.ai)** — x402 payment gateway (USDC micropayments)
- **[x402](https://x402.org)** — Pay-per-request with USDC, no API keys
---
<a id="中文"></a>
## NOFXi 是什么?
NOFXi 是基于 [NOFX](https://github.com/NoFxAiOS/nofx) 构建的 **AI 交易 Agent**。NOFX 是开源的 AI 交易引擎,而 NOFXi 将其升级为一个能感知、思考、记忆、行动的自主 Agent。
**NOFX** = 引擎。**NOFXi** = 智能。
### 核心区别
| | NOFX引擎 | NOFXiAgent |
|:--|:--|:--|
| **交互方式** | 配置 → 启动 → 看仪表盘 | "帮我开 BTC 多单2x 杠杆3% 止损" |
| **智能程度** | AI 做交易决策 | AI 理解上下文,从历史中学习 |
| **记忆** | 每次无状态 | 记住你的偏好、交易历史、教训 |
| **主动性** | 被触发时执行 | 主动监控市场、通知你、自主行动 |
| **沟通方式** | 仪表盘和日志 | Telegram 自然语言对话、主动推送 |
### 核心能力
- 🧠 **Agent 核心** — 上下文对话、意图识别、记忆管理
- 👁️ **感知层** — 市场监控、异动检测、持仓监控、舆情分析
- 💭 **思考层** — 多模型 AI 决策、策略匹配、风险评估
- 📝 **记忆层** — 交易历史、策略效果、用户偏好、市场规律
-**执行层** — 多交易所下单、仓位管理、x402 支付
- 💬 **交互层** — 自然语言交易、主动通知、报告生成
### 技术架构
```
交互层Telegram / Web UI / API
Agent Core对话理解 + 意图识别 + 上下文管理)
┌────┴───────────┬────────────┐
│ │ │
感知层 思考层 记忆层
(市场监控) AI 决策引擎) (经验库)
│ │ │
└────┬───────────┴────────────┘
执行层(多交易所 + x402 支付)
```
### 构建基础
- **[NOFX](https://github.com/NoFxAiOS/nofx)** — 开源 AI 交易引擎9 家交易所7+ AI 模型)
- **[Claw402](https://claw402.ai)** — x402 支付网关USDC 微支付)
- **[x402](https://x402.org)** — USDC 按量付费,无需 API Key
---
## Development
```bash
# Clone
git clone https://github.com/NoFxAiOS/nofxi.git
cd nofxi
# Build
go build ./...
# Run
go run cmd/nofxi/main.go
```
## License
Proprietary. All rights reserved.
© 2026 NoFxAiOS

View File

@@ -1,35 +0,0 @@
# 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: ""

View File

@@ -1,23 +0,0 @@
version: '3.8'
services:
nofxi:
build:
context: ..
dockerfile: nofxi/Dockerfile
container_name: nofxi
restart: unless-stopped
ports:
- "8900:8900"
volumes:
- ./config.yaml:/app/config.yaml:ro
- nofxi-data:/app/data
environment:
- TZ=Asia/Shanghai
# Override config via env vars:
# - NOFXI_TELEGRAM_TOKEN=xxx
# - NOFXI_LLM_API_KEY=xxx
# - NOFXI_LLM_BASE_URL=xxx
volumes:
nofxi-data:

View File

@@ -1,396 +0,0 @@
// Package agent implements the NOFXi Agent Core.
package agent
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"nofx/nofxi/internal/execution"
"nofx/nofxi/internal/memory"
"nofx/nofxi/internal/perception"
"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 func(userID int64, text string) error
strategyRunner *StrategyRunner
}
func New(cfg *Config, mem *memory.Store, thinker thinking.Engine, logger *slog.Logger) *Agent {
a := &Agent{config: cfg, memory: mem, thinker: thinker, logger: logger}
a.strategyRunner = NewStrategyRunner(a, logger)
return a
}
func (a *Agent) SetBridge(b *execution.Bridge) { a.bridge = b }
func (a *Agent) SetMonitor(m *perception.MarketMonitor) { a.monitor = m }
func (a *Agent) getLang(userID int64) string {
l, _ := a.memory.GetPreference(userID, "lang")
if l == "" {
l = a.config.Agent.Language
}
if l != "zh" && l != "en" {
l = "en"
}
return l
}
// HandleMessage processes a user message and returns a response.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
// Extract lang prefix from web UI
if strings.HasPrefix(text, "[lang:") {
if end := strings.Index(text, "] "); end > 0 {
lang := text[6:end]
if lang == "zh" || lang == "en" {
a.memory.SetPreference(userID, "lang", lang)
}
text = text[end+2:]
}
}
a.logger.Info("incoming message", "user_id", userID, "text", text)
a.memory.SaveMessage(userID, "user", text)
intent := Route(text)
a.logger.Info("routed intent", "type", intent.Type, "params", intent.Params)
L := a.getLang(userID)
var resp string
var err error
switch intent.Type {
case IntentHelp:
resp = msg(L, "help")
case IntentStatus:
resp = a.handleStatus(L)
case IntentQuery:
resp, err = a.handleQuery(ctx, L, intent)
case IntentAnalyze:
resp, err = a.handleAnalyze(ctx, L, intent)
case IntentTrade:
resp, err = a.handleTrade(ctx, userID, L, intent)
case IntentWatch:
resp = a.HandleWatchCommand(intent.Raw)
case IntentStrategy:
resp = a.handleStrategyCommand(intent.Raw)
case IntentSettings:
resp = fmt.Sprintf(msg(L, "settings"), L, a.config.LLM.Model, a.config.LLM.Provider, len(a.config.Exchanges))
default:
resp, err = a.handleChat(ctx, userID, L, text)
}
if err != nil {
a.logger.Error("handle message", "intent", intent.Type, "error", err)
resp = fmt.Sprintf("⚠️ Error: %v", err)
}
a.memory.SaveMessage(userID, "assistant", resp)
return resp, nil
}
func (a *Agent) handleStatus(L string) string {
wc := 0
if a.monitor != nil {
wc = len(a.monitor.GetAllSnapshots())
}
bs := msg(L, "bridge_disconnected")
if a.bridge != nil {
bs = msg(L, "bridge_connected")
}
return fmt.Sprintf(msg(L, "status_title"),
a.config.Agent.Name, a.config.LLM.Model, a.config.LLM.Provider,
bs, wc, time.Now().Format("2006-01-02 15:04:05"))
}
func (a *Agent) handleQuery(ctx context.Context, L string, intent Intent) (string, error) {
raw := strings.ToLower(intent.Raw)
if a.bridge != nil && (strings.Contains(raw, "position") || strings.Contains(raw, "持仓")) {
return a.queryPositions(L)
}
if a.bridge != nil && (strings.Contains(raw, "balance") || strings.Contains(raw, "余额")) {
return a.queryBalance(L)
}
trades, err := a.memory.GetRecentTrades(10)
if err != nil {
return "", fmt.Errorf("get trades: %w", err)
}
if len(trades) == 0 {
return msg(L, "no_trades"), nil
}
var sb strings.Builder
sb.WriteString(msg(L, "recent_trades"))
total := 0.0
for _, t := range trades {
e := "🟢"
if t.PnL < 0 {
e = "🔴"
}
sb.WriteString(fmt.Sprintf("%s %s %s %s — $%.2f (P/L: $%.2f)\n",
e, t.Side, t.Symbol, t.Exchange, t.Price*t.Quantity, t.PnL))
total += t.PnL
}
sb.WriteString(fmt.Sprintf(msg(L, "total_pnl"), total))
return sb.String(), nil
}
func (a *Agent) queryPositions(L string) (string, error) {
var all []execution.Position
for _, ex := range a.config.Exchanges {
pos, err := a.bridge.GetPositions(ex.Name)
if err != nil {
continue
}
all = append(all, pos...)
}
if len(all) == 0 {
return msg(L, "no_positions"), nil
}
var sb strings.Builder
sb.WriteString(msg(L, "open_positions"))
total := 0.0
for _, p := range all {
e := "🟢"
if p.PnL < 0 {
e = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* %s\n Size: %.4f | Entry: $%.2f\n Mark: $%.2f | P/L: $%.2f\n",
e, p.Symbol, strings.ToUpper(p.Side), p.Size, p.EntryPrice, p.MarkPrice, p.PnL))
if p.Leverage > 0 {
sb.WriteString(fmt.Sprintf(" Leverage: %.0fx | Exchange: %s\n", p.Leverage, p.Exchange))
}
sb.WriteString("\n")
total += p.PnL
}
sb.WriteString(fmt.Sprintf(msg(L, "total_unrealized"), total))
return sb.String(), nil
}
func (a *Agent) queryBalance(L string) (string, error) {
var sb strings.Builder
sb.WriteString(msg(L, "account_balance"))
for _, ex := range a.config.Exchanges {
bal, err := a.bridge.GetBalance(ex.Name)
if err != nil {
sb.WriteString(fmt.Sprintf("• %s: ⚠️ Error\n", ex.Name))
continue
}
sb.WriteString(fmt.Sprintf("*%s*\n", ex.Name))
sb.WriteString(fmt.Sprintf(msg(L, "balance_total"), bal.Total))
sb.WriteString(fmt.Sprintf(msg(L, "balance_available"), bal.Available))
sb.WriteString(fmt.Sprintf(msg(L, "balance_in_position"), bal.InPosition))
}
return sb.String(), nil
}
func (a *Agent) handleAnalyze(ctx context.Context, L string, intent Intent) (string, error) {
symbol := "BTC"
if d, ok := intent.Params["detail"]; ok && d != "" {
symbol = strings.ToUpper(strings.TrimSpace(d))
}
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)
}
}
prompt := fmt.Sprintf("Analyze %s/USDT for trading. %s\nConsider: trend, support/resistance, momentum, volume, sentiment.\nGive specific entry/exit levels and stop loss. Be concise. Respond in %s.",
symbol, priceInfo, map[string]string{"zh": "Chinese", "en": "English"}[L])
analysis, err := a.thinker.Analyze(ctx, prompt)
if err != nil {
return "", fmt.Errorf("AI analyze: %w", err)
}
emojiMap := map[string]string{"buy": "🟢 BUY", "sell": "🔴 SELL", "hold": "🟡 HOLD", "wait": "⏳ WAIT"}
action := emojiMap[analysis.Action]
if action == "" {
action = "🤔 " + analysis.Action
}
result := fmt.Sprintf(msg(L, "analysis_signal"), symbol, action, analysis.Confidence*100, analysis.Reasoning)
if analysis.StopLoss > 0 {
result += fmt.Sprintf(msg(L, "stop_loss"), analysis.StopLoss)
}
if analysis.TakeProfit > 0 {
result += fmt.Sprintf(msg(L, "take_profit"), analysis.TakeProfit)
}
return result, nil
}
func (a *Agent) handleTrade(ctx context.Context, userID int64, L string, intent Intent) (string, error) {
action := strings.ToLower(intent.Params["action"])
detail := intent.Params["detail"]
if a.bridge == nil || len(a.config.Exchanges) == 0 {
return msg(L, "no_exchange"), nil
}
parts := strings.Fields(detail)
if len(parts) < 1 {
return msg(L, "trade_usage"), 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(msg(L, "invalid_quantity"), parts[1]), nil
}
quantity = q
}
if len(parts) >= 3 {
if l, err := strconv.Atoi(strings.TrimSuffix(strings.ToLower(parts[2]), "x")); err == nil {
leverage = l
}
}
if quantity <= 0 {
return msg(L, "specify_quantity"), nil
}
var side string
switch action {
case "buy", "long", "open_long", "做多":
side = "LONG"
case "sell", "short", "open_short", "做空":
side = "SHORT"
case "close", "平仓":
side = "CLOSE_LONG"
default:
side = strings.ToUpper(action)
}
exchange := a.config.Exchanges[0].Name
a.memory.SetPreference(userID, "pending_trade",
fmt.Sprintf("%s|%s|%f|%d|%s", side, symbol, quantity, leverage, exchange))
return fmt.Sprintf(msg(L, "confirm_trade"), side, symbol, quantity, leverage, exchange), nil
}
// ExecutePendingTrade executes a confirmed trade.
func (a *Agent) ExecutePendingTrade(ctx context.Context, userID int64, L string) (string, error) {
pending, err := a.memory.GetPreference(userID, "pending_trade")
if err != nil || pending == "" {
return "", fmt.Errorf(msg(L, "no_pending"))
}
a.memory.SetPreference(userID, "pending_trade", "")
parts := strings.Split(pending, "|")
if len(parts) != 5 {
return "", fmt.Errorf("invalid pending trade")
}
side, symbol := parts[0], 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)
}
tr := &memory.TradeRecord{Exchange: exchange, Symbol: symbol, Side: strings.ToLower(side), Type: "market", Quantity: quantity, Status: "open"}
if p, ok := result["avgPrice"].(float64); ok {
tr.Price = p
}
a.memory.SaveTrade(tr)
return fmt.Sprintf(msg(L, "trade_executed"), side, symbol, quantity, leverage, exchange, result), nil
}
func (a *Agent) handleStrategyCommand(text string) string {
parts := strings.Fields(text)
if len(parts) < 2 {
return a.strategyRunner.FormatStrategyList()
}
switch strings.ToLower(parts[1]) {
case "list":
return a.strategyRunner.FormatStrategyList()
case "start":
if len(parts) < 3 {
return "Usage: `/strategy start BTC 1h`"
}
sym := strings.ToUpper(parts[2])
if !strings.HasSuffix(sym, "USDT") {
sym += "USDT"
}
iv := 1 * time.Hour
if len(parts) >= 4 {
switch parts[3] {
case "15m":
iv = 15 * time.Minute
case "30m":
iv = 30 * time.Minute
case "4h":
iv = 4 * time.Hour
}
}
ex := "binance"
if len(parts) >= 5 {
ex = parts[4]
}
id, err := a.strategyRunner.StartStrategy("AI-"+sym, sym, ex, iv)
if err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return fmt.Sprintf("🚀 Strategy started!\n\n• ID: `%s`\n• Symbol: %s\n• Interval: %s\n• Exchange: %s", id, sym, iv, ex)
case "stop":
if len(parts) < 3 {
return "Usage: `/strategy stop <id>`"
}
if err := a.strategyRunner.StopStrategy(parts[2]); err != nil {
return fmt.Sprintf("⚠️ %v", err)
}
return "✅ Strategy stopped."
case "stopall":
a.strategyRunner.StopAll()
return "✅ All strategies stopped."
default:
return "Use: `/strategy list|start|stop|stopall`"
}
}
func (a *Agent) handleChat(ctx context.Context, userID int64, L string, text string) (string, error) {
lower := strings.ToLower(text)
if lower == "yes" || lower == "y" || lower == "确认" || lower == "是" {
if p, _ := a.memory.GetPreference(userID, "pending_trade"); p != "" {
return a.ExecutePendingTrade(ctx, userID, L)
}
}
history, _ := a.memory.GetRecentMessages(userID, 20)
sysPrompt := fmt.Sprintf(msg(L, "system_prompt"), time.Now().Format("2006-01-02 15:04:05"))
msgs := []thinking.Message{{Role: "system", Content: sysPrompt}}
for _, m := range history {
msgs = append(msgs, thinking.Message{Role: m.Role, Content: m.Content})
}
msgs = append(msgs, thinking.Message{Role: "user", Content: text})
return a.thinker.Chat(ctx, msgs)
}

View File

@@ -1,189 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"nofx/nofxi/internal/perception"
"nofx/nofxi/internal/thinking"
)
// Brain is the proactive intelligence layer.
// It receives signals from Sentinel, processes news, and decides
// when to proactively notify the user.
type Brain struct {
agent *Agent
news *perception.NewsMonitor
logger *slog.Logger
stopCh chan struct{}
// Debounce: don't spam the same signal
recentSignals map[string]time.Time
}
// NewBrain creates the proactive brain.
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
news: perception.NewNewsMonitor(logger),
logger: logger,
stopCh: make(chan struct{}),
recentSignals: make(map[string]time.Time),
}
}
// HandleSignal processes a market signal from the Sentinel.
func (b *Brain) HandleSignal(signal perception.Signal) {
// Debounce: same signal type + symbol within 10 minutes
key := fmt.Sprintf("%s:%s", signal.Type, signal.Symbol)
if last, ok := b.recentSignals[key]; ok && time.Since(last) < 10*time.Minute {
return
}
b.recentSignals[key] = time.Now()
// Format alert message
emoji := map[string]string{
"info": "",
"warning": "⚠️",
"critical": "🚨",
}
e := emoji[signal.Severity]
if e == "" {
e = "📊"
}
msg := fmt.Sprintf("%s *%s*\n\n%s\n\n_%s_",
e, signal.Title, signal.Detail, signal.Timestamp.Format("15:04:05"))
b.notifyAll(msg)
}
// StartNewsScan begins periodic news scanning.
func (b *Brain) StartNewsScan(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-b.stopCh:
return
case <-ticker.C:
b.scanNews()
}
}
}()
}
// StartMarketBrief sends a morning/evening market brief.
func (b *Brain) StartMarketBrief() {
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-b.stopCh:
return
case now := <-ticker.C:
hour := now.Hour()
minute := now.Minute()
// Morning brief at 08:30
if hour == 8 && minute == 30 {
b.sendMarketBrief("morning")
}
// Evening brief at 20:30
if hour == 20 && minute == 30 {
b.sendMarketBrief("evening")
}
}
}
}()
}
func (b *Brain) scanNews() {
items, err := b.news.FetchNews()
if err != nil {
b.logger.Error("fetch news", "error", err)
return
}
// Filter for high-impact news related to watched symbols
for _, item := range items {
if item.Sentiment == "neutral" {
continue
}
if len(item.Symbols) == 0 {
continue
}
// Only alert on recent news (last 10 minutes)
if time.Since(item.Timestamp) > 10*time.Minute {
continue
}
emoji := "📰"
if item.Sentiment == "bullish" {
emoji = "🟢"
} else if item.Sentiment == "bearish" {
emoji = "🔴"
}
msg := fmt.Sprintf("%s *%s*\n\n%s\n\n• Source: %s\n• Symbols: %s\n• Sentiment: %s",
emoji, "News Alert",
item.Title,
item.Source,
strings.Join(item.Symbols, ", "),
strings.ToUpper(item.Sentiment),
)
b.notifyAll(msg)
}
}
func (b *Brain) sendMarketBrief(timeOfDay string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prompt := fmt.Sprintf(`Generate a brief %s market summary for crypto trading.
Include: BTC/ETH price direction, key levels, market sentiment, any notable events.
Be concise (under 200 words). Use trading emojis. Respond in Chinese.
Current time: %s`, timeOfDay, time.Now().Format("2006-01-02 15:04:05"))
resp, err := b.agent.thinker.Chat(ctx, []thinking.Message{
{Role: "system", Content: "You are NOFXi, a professional crypto trading AI. Respond in Chinese."},
{Role: "user", Content: prompt},
})
if err != nil {
b.logger.Error("generate market brief", "error", err)
return
}
title := "☀️ *早间市场简报*"
if timeOfDay == "evening" {
title = "🌙 *晚间市场简报*"
}
msg := fmt.Sprintf("%s\n\n%s\n\n_Generated by NOFXi 🤖_", title, resp)
b.notifyAll(msg)
}
func (b *Brain) notifyAll(text string) {
if b.agent.NotifyFunc == nil {
return
}
for _, uid := range b.agent.config.Telegram.AllowedIDs {
if err := b.agent.NotifyFunc(uid, text); err != nil {
b.logger.Error("notify", "user_id", uid, "error", err)
}
}
}
// Stop stops the brain.
func (b *Brain) Stop() {
close(b.stopCh)
}

View File

@@ -1,98 +0,0 @@
package agent
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config holds the complete NOFXi configuration.
type Config struct {
Agent AgentConfig `yaml:"agent"`
Telegram TelegramConfig `yaml:"telegram"`
LLM LLMConfig `yaml:"llm"`
Database DatabaseConfig `yaml:"database"`
Exchanges []ExchangeConfig `yaml:"exchanges"`
}
type AgentConfig struct {
Name string `yaml:"name"` // Agent display name
Language string `yaml:"language"` // "en" or "zh"
LogLevel string `yaml:"log_level"` // "debug", "info", "warn", "error"
WebPort int `yaml:"web_port"` // REST API port (0 = disabled)
}
type TelegramConfig struct {
Token string `yaml:"token"`
AllowedIDs []int64 `yaml:"allowed_ids"` // Allowed Telegram user IDs (empty = allow all)
}
type LLMConfig struct {
Provider string `yaml:"provider"` // "openai", "claw402"
BaseURL string `yaml:"base_url"` // API base URL
APIKey string `yaml:"api_key"`
Model string `yaml:"model"` // e.g. "gpt-4o", "deepseek-chat"
}
type DatabaseConfig struct {
Path string `yaml:"path"` // SQLite file path
}
type ExchangeConfig struct {
Name string `yaml:"name"` // "binance", "okx", "bybit", etc.
APIKey string `yaml:"api_key"`
APISecret string `yaml:"api_secret"`
Passphrase string `yaml:"passphrase"` // OKX needs this
Testnet bool `yaml:"testnet"`
}
// LoadConfig reads config from a YAML file.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
cfg := &Config{
Agent: AgentConfig{
Name: "NOFXi",
Language: "en",
LogLevel: "info",
},
Database: DatabaseConfig{
Path: "nofxi.db",
},
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
// Override with env vars if set
if v := os.Getenv("NOFXI_TELEGRAM_TOKEN"); v != "" {
cfg.Telegram.Token = v
}
if v := os.Getenv("NOFXI_LLM_API_KEY"); v != "" {
cfg.LLM.APIKey = v
}
if v := os.Getenv("NOFXI_LLM_BASE_URL"); v != "" {
cfg.LLM.BaseURL = v
}
return cfg, nil
}
// Validate checks required fields.
func (c *Config) Validate() error {
if c.Telegram.Token == "" {
return fmt.Errorf("telegram.token is required")
}
if c.LLM.APIKey == "" {
return fmt.Errorf("llm.api_key is required")
}
if c.LLM.Model == "" {
return fmt.Errorf("llm.model is required")
}
return nil
}

View File

@@ -1,204 +0,0 @@
package agent
// i18n message templates
var messages = map[string]map[string]string{
"help": {
"zh": `🤖 *NOFXi — 你的 AI 交易 Agent*
*交易:*
/buy BTC 0.01 — 做多(市价单)
/sell BTC 0.01 — 做空
/close BTC — 平仓
/positions — 查看持仓
/balance — 查看余额
/pnl — 盈亏记录
*分析:*
/analyze BTC — AI 市场分析
/watch BTC — 监控价格
/alert BTC above 100000 — 价格提醒
/price BTC — 实时价格
*策略:*
/strategy start BTC 1h — 启动 AI 自动策略
/strategy list — 查看运行中的策略
/strategy stop <id> — 停止策略
*系统:*
/status — Agent 状态
/help — 帮助菜单
直接跟我说话就行,中英文都可以 💬`,
"en": `🤖 *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 — P/L history
*Analysis:*
/analyze BTC — AI market analysis
/watch BTC — Monitor price
/alert BTC above 100000 — Price alert
/price BTC — Real-time price
*Strategy:*
/strategy start BTC 1h — Start AI auto strategy
/strategy list — View active strategies
/strategy stop <id> — Stop strategy
*System:*
/status — Agent status
/help — This menu
Just talk to me in any language 💬`,
},
"no_trades": {
"zh": "📭 暂无交易记录。试试 `/buy BTC 0.01` 或 `/analyze BTC` 开始吧。",
"en": "📭 No trades yet. Start with `/buy BTC 0.01` or `/analyze BTC`.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"recent_trades": {
"zh": "📋 *最近交易*\n\n",
"en": "📋 *Recent Trades*\n\n",
},
"total_pnl": {
"zh": "\n💰 总盈亏: $%.2f",
"en": "\n💰 Total P/L: $%.2f",
},
"open_positions": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_unrealized": {
"zh": "💰 *未实现总盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"account_balance": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balance*\n\n",
},
"balance_total": {
"zh": " 总额: $%.2f\n",
"en": " Total: $%.2f\n",
},
"balance_available": {
"zh": " 可用: $%.2f\n",
"en": " Available: $%.2f\n",
},
"balance_in_position": {
"zh": " 持仓占用: $%.2f\n\n",
"en": " In Position: $%.2f\n\n",
},
"no_exchange": {
"zh": "⚠️ 还没有配置交易所。请在 config.yaml 的 exchanges 中添加交易所 API Key。",
"en": "⚠️ No exchange configured. Add exchange API keys in config.yaml.",
},
"trade_usage": {
"zh": "❓ 用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
"en": "❓ Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
},
"invalid_quantity": {
"zh": "❓ 无效数量: %s",
"en": "❓ Invalid quantity: %s",
},
"specify_quantity": {
"zh": "❓ 请指定数量: `/buy BTC 0.01`",
"en": "❓ Please specify quantity: `/buy BTC 0.01`",
},
"confirm_trade": {
"zh": "⚡ *确认交易*\n\n• 操作: %s\n• 交易对: %s\n• 数量: %.6f\n• 杠杆: %dx\n• 交易所: %s\n\n回复 '确认' 执行交易。",
"en": "⚡ *Confirm Trade*\n\n• Action: %s\n• Symbol: %s\n• Quantity: %.6f\n• Leverage: %dx\n• Exchange: %s\n\nReply 'yes' to execute.",
},
"trade_executed": {
"zh": "✅ *交易已执行!*\n\n• %s %s\n• 数量: %.6f\n• 杠杆: %dx\n• 交易所: %s\n• 结果: %v",
"en": "✅ *Trade Executed!*\n\n• %s %s\n• Qty: %.6f\n• Leverage: %dx\n• Exchange: %s\n• Result: %v",
},
"no_pending": {
"zh": "没有待确认的交易",
"en": "no pending trade",
},
"analysis_signal": {
"zh": "🔍 *%s/USDT 分析*\n\n信号: %s\n置信度: %.0f%%\n\n%s",
"en": "🔍 *%s/USDT Analysis*\n\nSignal: %s\nConfidence: %.0f%%\n\n%s",
},
"stop_loss": {
"zh": "\n\n🛑 止损: $%.2f",
"en": "\n\n🛑 Stop Loss: $%.2f",
},
"take_profit": {
"zh": "\n🎯 止盈: $%.2f",
"en": "\n🎯 Take Profit: $%.2f",
},
"settings": {
"zh": "⚙️ *设置*\n\n• 语言: %s\n• 模型: %s\n• 提供商: %s\n• 交易所: %d 个已配置",
"en": "⚙️ *Settings*\n\n• Language: %s\n• Model: %s\n• Provider: %s\n• Exchanges: %d configured",
},
"status_title": {
"zh": "📊 *NOFXi 状态*\n\n• Agent: %s\n• 模型: %s\n• 提供商: %s\n• 记忆: ✅ 在线\n• 执行: %s\n• 监控: %d 个交易对\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Agent: %s\n• Model: %s\n• Provider: %s\n• Memory: ✅ Online\n• Execution: %s\n• Watching: %d symbols\n• Time: %s",
},
"bridge_connected": {
"zh": "✅ 已连接",
"en": "✅ Connected",
},
"bridge_disconnected": {
"zh": "❌ 未连接",
"en": "❌ Not connected",
},
"ai_timeout": {
"zh": "⏱️ AI 响应超时,请稍后再试。",
"en": "⏱️ AI response timed out, please try again.",
},
"system_prompt": {
"zh": `你是 NOFXi一个基于 NOFX 构建的 AI 交易 Agent。
你的能力:
- 市场分析和交易建议
- 实时持仓和余额监控
- 交易执行(开仓/平仓)
- 价格提醒和行情监控
- 风险管理建议
支持多家交易所Binance、OKX、Bybit、Bitget、KuCoin、Gate、Hyperliquid 等。
简洁、自信、专注交易。使用交易相关的 emoji。
用中文回复。
当前时间: %s`,
"en": `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)
- Price alerts and market monitoring
- Risk management advice
Supports multiple exchanges: Binance, OKX, Bybit, Bitget, KuCoin, Gate, Hyperliquid, etc.
Be concise, confident, and action-oriented. Use trading emojis.
Respond in English.
Current time: %s`,
},
}
// msg returns the localized message for the given key and language.
func msg(lang, key string) string {
if m, ok := messages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}

View File

@@ -1,156 +0,0 @@
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
IntentStrategy // Start/stop/list strategies
)
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 "/strategy":
return Intent{Type: IntentStrategy, Raw: text}
case "/analyze":
parts := strings.SplitN(text, " ", 2)
detail := ""
if len(parts) > 1 {
detail = parts[1]
}
return Intent{
Type: IntentAnalyze,
Params: map[string]string{"detail": detail},
Raw: text,
}
case "/settings", "/config":
return Intent{Type: IntentSettings, Raw: text}
default:
return Intent{Type: IntentChat, Raw: text}
}
}

View File

@@ -1,187 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"nofx/nofxi/internal/memory"
)
// Scheduler handles periodic tasks: daily reports, portfolio checks, etc.
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
notifyFn func(userID int64, text string) error
}
// NewScheduler creates a new task scheduler.
func NewScheduler(agent *Agent, logger *slog.Logger) *Scheduler {
return &Scheduler{
agent: agent,
logger: logger,
stopCh: make(chan struct{}),
}
}
// SetNotifyFunc sets the notification callback.
func (s *Scheduler) SetNotifyFunc(fn func(userID int64, text string) error) {
s.notifyFn = fn
}
// Start begins the scheduler loop.
func (s *Scheduler) Start(ctx context.Context) {
go s.loop(ctx)
}
// Stop stops the scheduler.
func (s *Scheduler) Stop() {
close(s.stopCh)
}
func (s *Scheduler) loop(ctx context.Context) {
// Check every minute for scheduled tasks
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastDailyReport := time.Time{}
lastPortfolioCheck := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case now := <-ticker.C:
hour := now.Hour()
// Daily report at 21:00 (9 PM)
if hour == 21 && now.Sub(lastDailyReport) > 12*time.Hour {
s.sendDailyReport(ctx)
lastDailyReport = now
}
// Portfolio check every 4 hours
if now.Sub(lastPortfolioCheck) > 4*time.Hour {
s.checkPortfolio(ctx)
lastPortfolioCheck = now
}
}
}
}
func (s *Scheduler) sendDailyReport(ctx context.Context) {
s.logger.Info("generating daily report")
trades, err := s.agent.memory.GetRecentTrades(50)
if err != nil {
s.logger.Error("get trades for daily report", "error", err)
return
}
// Filter today's trades
today := time.Now().Truncate(24 * time.Hour)
var todayTrades []memory.TradeRecord
totalPnL := 0.0
wins := 0
for _, t := range trades {
if t.CreatedAt.After(today) {
todayTrades = append(todayTrades, t)
totalPnL += t.PnL
if t.PnL > 0 {
wins++
}
}
}
if len(todayTrades) == 0 {
s.logger.Info("no trades today, skipping daily report")
return
}
winRate := 0.0
if len(todayTrades) > 0 {
winRate = float64(wins) / float64(len(todayTrades)) * 100
}
emoji := "📈"
if totalPnL < 0 {
emoji = "📉"
}
report := fmt.Sprintf(`%s *NOFXi Daily Report — %s*
📊 *Summary*
• Trades: %d
• Win Rate: %.1f%%
• Total P/L: $%.2f
`, emoji, time.Now().Format("2006-01-02"), len(todayTrades), winRate, totalPnL)
// Add trade details
if len(todayTrades) > 0 {
report += "📋 *Trades*\n"
for _, t := range todayTrades {
e := "🟢"
if t.PnL < 0 {
e = "🔴"
}
report += fmt.Sprintf("%s %s %s — P/L: $%.2f\n", e, t.Side, t.Symbol, t.PnL)
}
}
report += "\n_Generated by NOFXi 🤖_"
s.notify(report)
}
func (s *Scheduler) checkPortfolio(ctx context.Context) {
if s.agent.bridge == nil {
return
}
s.logger.Info("checking portfolio")
var alerts []string
for _, ex := range s.agent.config.Exchanges {
positions, err := s.agent.bridge.GetPositions(ex.Name)
if err != nil {
continue
}
for _, p := range positions {
// Alert on large unrealized losses (> 5%)
if p.EntryPrice > 0 && p.PnL < 0 {
lossPct := (p.PnL / (p.EntryPrice * p.Size)) * 100
if lossPct < -5 {
alerts = append(alerts, fmt.Sprintf(
"⚠️ *%s* %s: %.1f%% loss ($%.2f)",
p.Symbol, strings.ToUpper(p.Side), lossPct, p.PnL))
}
}
}
}
if len(alerts) > 0 {
msg := "🚨 *Portfolio Alert*\n\n" + strings.Join(alerts, "\n")
s.notify(msg)
}
}
func (s *Scheduler) notify(text string) {
if s.notifyFn == nil {
s.logger.Warn("no notification function set, cannot send scheduled message")
return
}
for _, uid := range s.agent.config.Telegram.AllowedIDs {
if err := s.notifyFn(uid, text); err != nil {
s.logger.Error("send notification", "user_id", uid, "error", err)
}
}
}

View File

@@ -1,199 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
)
// StrategyRunner manages automated trading strategies.
type StrategyRunner struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
// Active strategies
activeStrategies map[string]*RunningStrategy
}
// RunningStrategy represents an active automated strategy.
type RunningStrategy struct {
ID string
Name string
Symbol string
Interval time.Duration
Exchange string
StopCh chan struct{}
Running bool
}
// NewStrategyRunner creates a new strategy runner.
func NewStrategyRunner(agent *Agent, logger *slog.Logger) *StrategyRunner {
return &StrategyRunner{
agent: agent,
logger: logger,
stopCh: make(chan struct{}),
activeStrategies: make(map[string]*RunningStrategy),
}
}
// StartStrategy begins an AI-driven trading strategy.
// The AI will periodically analyze the market and suggest/execute trades.
func (r *StrategyRunner) StartStrategy(name, symbol, exchange string, interval time.Duration) (string, error) {
id := fmt.Sprintf("%s-%s-%d", strings.ToLower(symbol), strings.ToLower(exchange), time.Now().Unix())
if _, exists := r.activeStrategies[id]; exists {
return "", fmt.Errorf("strategy already running: %s", id)
}
strategy := &RunningStrategy{
ID: id,
Name: name,
Symbol: symbol,
Interval: interval,
Exchange: exchange,
StopCh: make(chan struct{}),
Running: true,
}
r.activeStrategies[id] = strategy
go r.runStrategy(strategy)
r.logger.Info("strategy started",
"id", id,
"symbol", symbol,
"exchange", exchange,
"interval", interval,
)
return id, nil
}
// StopStrategy stops a running strategy.
func (r *StrategyRunner) StopStrategy(id string) error {
s, ok := r.activeStrategies[id]
if !ok {
return fmt.Errorf("strategy not found: %s", id)
}
close(s.StopCh)
s.Running = false
delete(r.activeStrategies, id)
r.logger.Info("strategy stopped", "id", id)
return nil
}
// StopAll stops all running strategies.
func (r *StrategyRunner) StopAll() {
for id, s := range r.activeStrategies {
close(s.StopCh)
s.Running = false
r.logger.Info("strategy stopped", "id", id)
}
r.activeStrategies = make(map[string]*RunningStrategy)
}
// ListStrategies returns all active strategies.
func (r *StrategyRunner) ListStrategies() []*RunningStrategy {
result := make([]*RunningStrategy, 0, len(r.activeStrategies))
for _, s := range r.activeStrategies {
result = append(result, s)
}
return result
}
func (r *StrategyRunner) runStrategy(s *RunningStrategy) {
ticker := time.NewTicker(s.Interval)
defer ticker.Stop()
// Initial analysis
r.executeStrategyTick(s)
for {
select {
case <-s.StopCh:
return
case <-r.stopCh:
return
case <-ticker.C:
r.executeStrategyTick(s)
}
}
}
func (r *StrategyRunner) executeStrategyTick(s *RunningStrategy) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
r.logger.Info("strategy tick", "id", s.ID, "symbol", s.Symbol)
// Get AI analysis
prompt := fmt.Sprintf(
"You are running an automated trading strategy for %s on %s.\n"+
"Analyze the current market and decide: should we BUY, SELL, or HOLD?\n"+
"Consider risk management. Only trade on high confidence signals.\n"+
"Respond with a brief analysis and your recommendation.",
s.Symbol, s.Exchange,
)
analysis, err := r.agent.thinker.Analyze(ctx, prompt)
if err != nil {
r.logger.Error("strategy analysis failed", "id", s.ID, "error", err)
return
}
r.logger.Info("strategy analysis",
"id", s.ID,
"action", analysis.Action,
"confidence", analysis.Confidence,
)
// Only execute on high confidence
if analysis.Confidence < 0.75 {
r.logger.Info("strategy: confidence too low, holding", "id", s.ID)
return
}
// Notify user about the signal
if r.agent.NotifyFunc != nil {
msg := fmt.Sprintf("🤖 *Strategy Signal: %s*\n\n"+
"Symbol: %s\n"+
"Action: %s\n"+
"Confidence: %.0f%%\n\n"+
"%s",
s.Name, s.Symbol,
strings.ToUpper(analysis.Action),
analysis.Confidence*100,
analysis.Reasoning,
)
for _, uid := range r.agent.config.Telegram.AllowedIDs {
r.agent.NotifyFunc(uid, msg)
}
}
// TODO: Auto-execute trades based on strategy config
// For now, just notify. Users can enable auto-execution in Phase 4.
}
// FormatStrategyList formats active strategies for display.
func (r *StrategyRunner) FormatStrategyList() string {
strategies := r.ListStrategies()
if len(strategies) == 0 {
return "📭 No active strategies.\n\nUse `/strategy start BTC 1h` to start one."
}
var sb strings.Builder
sb.WriteString("🤖 *Active Strategies*\n\n")
for _, s := range strategies {
status := "🟢"
if !s.Running {
status = "🔴"
}
sb.WriteString(fmt.Sprintf("%s *%s* — %s on %s (every %s)\n ID: `%s`\n\n",
status, s.Name, s.Symbol, s.Exchange, s.Interval, s.ID))
}
sb.WriteString("Stop with: `/strategy stop <id>`")
return sb.String()
}

View File

@@ -1,125 +0,0 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
// HandleWatchCommand processes /watch and /alert commands.
// Returns response text. Called from HandleMessage when intent is detected.
func (a *Agent) HandleWatchCommand(text string) string {
lower := strings.ToLower(strings.TrimSpace(text))
// /watch BTC
if strings.HasPrefix(lower, "/watch") {
parts := strings.Fields(text)
if len(parts) < 2 {
return a.listWatched()
}
symbol := strings.ToUpper(parts[1])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
if a.monitor != nil {
a.monitor.Watch(symbol)
return fmt.Sprintf("👁️ Now watching *%s*. I'll track the price.", symbol)
}
return "⚠️ Market monitor not available."
}
// /unwatch BTC
if strings.HasPrefix(lower, "/unwatch") {
parts := strings.Fields(text)
if len(parts) < 2 {
return "Usage: `/unwatch BTC`"
}
symbol := strings.ToUpper(parts[1])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
if a.monitor != nil {
a.monitor.Unwatch(symbol)
return fmt.Sprintf("🚫 Stopped watching *%s*.", symbol)
}
return "⚠️ Market monitor not available."
}
// /alert BTC above 100000
if strings.HasPrefix(lower, "/alert") {
parts := strings.Fields(text)
if len(parts) < 4 {
return "Usage: `/alert BTC above 100000` or `/alert ETH below 3000`"
}
symbol := strings.ToUpper(parts[1])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
direction := strings.ToLower(parts[2])
if direction != "above" && direction != "below" {
return "Direction must be `above` or `below`."
}
threshold, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
return fmt.Sprintf("Invalid price: %s", parts[3])
}
if a.monitor != nil {
a.monitor.Watch(symbol) // Ensure we're watching it
a.monitor.AddAlert(symbol, direction, threshold)
emoji := "📈"
if direction == "below" {
emoji = "📉"
}
return fmt.Sprintf("%s Alert set: *%s* %s $%.2f\nI'll notify you when it triggers.",
emoji, symbol, direction, threshold)
}
return "⚠️ Market monitor not available."
}
// /price BTC
if strings.HasPrefix(lower, "/price") {
parts := strings.Fields(text)
if len(parts) < 2 {
return "Usage: `/price BTC`"
}
symbol := strings.ToUpper(parts[1])
if !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
if a.monitor != nil {
if snap, ok := a.monitor.GetSnapshot(symbol); ok && snap.LastPrice > 0 {
return fmt.Sprintf("💰 *%s*: $%.4f\n_Updated: %s_",
symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05"))
}
// Not watching yet, watch and return message
a.monitor.Watch(symbol)
return fmt.Sprintf("👁️ Started watching *%s*. Price will be available in ~30s.", symbol)
}
return "⚠️ Market monitor not available."
}
return ""
}
func (a *Agent) listWatched() string {
if a.monitor == nil {
return "⚠️ Market monitor not available."
}
snaps := a.monitor.GetAllSnapshots()
if len(snaps) == 0 {
return "📭 Not watching any symbols. Use `/watch BTC` to start."
}
var sb strings.Builder
sb.WriteString("👁️ *Watching*\n\n")
for symbol, snap := range snaps {
if snap.LastPrice > 0 {
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n",
symbol, snap.LastPrice, snap.UpdatedAt.Format("15:04:05")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting for data...\n", symbol))
}
}
return sb.String()
}

View File

@@ -1,192 +0,0 @@
package execution
import (
"fmt"
"log/slog"
"strings"
"sync"
)
// TraderFactory creates a NOFX Trader by exchange name.
// Injected from main.go where nofx exchange packages are available.
type TraderFactory func(exchange, apiKey, apiSecret, passphrase string, testnet bool) (NofxTrader, error)
// NofxTrader mirrors nofx/trader/types.Trader interface.
// We redefine it here to avoid a direct import cycle with the parent module.
type NofxTrader interface {
GetBalance() (map[string]interface{}, error)
GetPositions() ([]map[string]interface{}, error)
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
SetLeverage(symbol string, leverage int) error
GetMarketPrice(symbol string) (float64, error)
SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error
SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error
CancelAllOrders(symbol string) error
}
// Bridge connects NOFXi to NOFX trading engine.
type Bridge struct {
mu sync.RWMutex
traders map[string]NofxTrader // exchange name → trader
factory TraderFactory
logger *slog.Logger
}
// NewBridge creates a new execution bridge.
func NewBridge(factory TraderFactory, logger *slog.Logger) *Bridge {
return &Bridge{
traders: make(map[string]NofxTrader),
factory: factory,
logger: logger,
}
}
// RegisterTrader registers a pre-configured trader for an exchange.
func (b *Bridge) RegisterTrader(exchange string, trader NofxTrader) {
b.mu.Lock()
defer b.mu.Unlock()
b.traders[strings.ToLower(exchange)] = trader
b.logger.Info("trader registered", "exchange", exchange)
}
// getTrader returns the trader for the given exchange.
func (b *Bridge) getTrader(exchange string) (NofxTrader, error) {
b.mu.RLock()
defer b.mu.RUnlock()
t, ok := b.traders[strings.ToLower(exchange)]
if !ok {
return nil, fmt.Errorf("exchange %q not configured", exchange)
}
return t, nil
}
// PlaceOrder executes a trade via the NOFX trader.
func (b *Bridge) PlaceOrder(exchange, symbol, side string, quantity float64, leverage int) (map[string]interface{}, error) {
trader, err := b.getTrader(exchange)
if err != nil {
return nil, err
}
// Set leverage first
if leverage > 0 {
if err := trader.SetLeverage(symbol, leverage); err != nil {
b.logger.Warn("set leverage failed", "error", err)
}
}
side = strings.ToUpper(side)
switch side {
case "BUY", "LONG", "OPEN_LONG":
b.logger.Info("opening long", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage)
return trader.OpenLong(symbol, quantity, leverage)
case "SELL", "SHORT", "OPEN_SHORT":
b.logger.Info("opening short", "exchange", exchange, "symbol", symbol, "qty", quantity, "leverage", leverage)
return trader.OpenShort(symbol, quantity, leverage)
case "CLOSE_LONG":
b.logger.Info("closing long", "exchange", exchange, "symbol", symbol, "qty", quantity)
return trader.CloseLong(symbol, quantity)
case "CLOSE_SHORT":
b.logger.Info("closing short", "exchange", exchange, "symbol", symbol, "qty", quantity)
return trader.CloseShort(symbol, quantity)
default:
return nil, fmt.Errorf("unknown side: %s", side)
}
}
// GetPositions returns all open positions from an exchange.
func (b *Bridge) GetPositions(exchange string) ([]Position, error) {
trader, err := b.getTrader(exchange)
if err != nil {
return nil, err
}
raw, err := trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("get positions: %w", err)
}
var positions []Position
for _, p := range raw {
pos := Position{
Exchange: exchange,
Symbol: fmt.Sprint(p["symbol"]),
Side: fmt.Sprint(p["side"]),
}
if v, ok := p["size"].(float64); ok {
pos.Size = v
}
if v, ok := p["entryPrice"].(float64); ok {
pos.EntryPrice = v
}
if v, ok := p["markPrice"].(float64); ok {
pos.MarkPrice = v
}
if v, ok := p["unrealizedPnl"].(float64); ok {
pos.PnL = v
}
if v, ok := p["leverage"].(float64); ok {
pos.Leverage = v
}
// Skip empty positions
if pos.Size != 0 {
positions = append(positions, pos)
}
}
return positions, nil
}
// GetBalance returns account balance from an exchange.
func (b *Bridge) GetBalance(exchange string) (*Balance, error) {
trader, err := b.getTrader(exchange)
if err != nil {
return nil, err
}
raw, err := trader.GetBalance()
if err != nil {
return nil, fmt.Errorf("get balance: %w", err)
}
bal := &Balance{
Exchange: exchange,
Currency: "USDT",
}
if v, ok := raw["totalBalance"].(float64); ok {
bal.Total = v
}
if v, ok := raw["availableBalance"].(float64); ok {
bal.Available = v
}
bal.InPosition = bal.Total - bal.Available
return bal, nil
}
// GetPrice returns the current market price for a symbol.
func (b *Bridge) GetPrice(exchange, symbol string) (float64, error) {
trader, err := b.getTrader(exchange)
if err != nil {
return 0, err
}
return trader.GetMarketPrice(symbol)
}
// SetStopLoss sets a stop-loss order.
func (b *Bridge) SetStopLoss(exchange, symbol, positionSide string, quantity, price float64) error {
trader, err := b.getTrader(exchange)
if err != nil {
return err
}
return trader.SetStopLoss(symbol, positionSide, quantity, price)
}
// SetTakeProfit sets a take-profit order.
func (b *Bridge) SetTakeProfit(exchange, symbol, positionSide string, quantity, price float64) error {
trader, err := b.getTrader(exchange)
if err != nil {
return err
}
return trader.SetTakeProfit(symbol, positionSide, quantity, price)
}

View File

@@ -1,47 +0,0 @@
// Package execution implements the Execution Layer.
//
// Phase 2 — stubs for now.
//
// Planned capabilities:
// - Multi-exchange order execution (via NOFX engine)
// - Position management (open/close/adjust)
// - Stop-loss and take-profit management
// - x402 payment integration via Claw402
// - Safe mode and risk controls
package execution
// Executor is the trade execution interface.
type Executor interface {
// PlaceOrder places a trade order on the exchange.
PlaceOrder(exchange, symbol, side string, price, quantity float64) (string, error)
// ClosePosition closes an existing position.
ClosePosition(exchange, symbol string) error
// GetPositions returns current open positions.
GetPositions(exchange string) ([]Position, error)
// GetBalance returns account balance.
GetBalance(exchange string) (*Balance, error)
}
// Position represents an open position.
type Position struct {
Exchange string `json:"exchange"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Size float64 `json:"size"`
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
PnL float64 `json:"pnl"`
Leverage float64 `json:"leverage"`
}
// Balance represents account balance.
type Balance struct {
Exchange string `json:"exchange"`
Total float64 `json:"total"`
Available float64 `json:"available"`
InPosition float64 `json:"in_position"`
Currency string `json:"currency"`
}

View File

@@ -1,52 +0,0 @@
package execution
import (
"fmt"
"strings"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/kucoin"
"nofx/trader/okx"
)
// ExchangeConfig holds credentials for creating a trader.
type ExchangeConfig struct {
Name string
APIKey string
APISecret string
Passphrase string
Testnet bool
}
// CreateTrader creates a NofxTrader for the given exchange.
func CreateTrader(cfg ExchangeConfig) (NofxTrader, error) {
switch strings.ToLower(cfg.Name) {
case "binance":
return binance.NewFuturesTrader(cfg.APIKey, cfg.APISecret, "nofxi"), nil
case "okx":
return okx.NewOKXTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "bybit":
return bybit.NewBybitTrader(cfg.APIKey, cfg.APISecret), nil
case "bitget":
return bitget.NewBitgetTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "kucoin":
return kucoin.NewKuCoinTrader(cfg.APIKey, cfg.APISecret, cfg.Passphrase), nil
case "gate":
return gate.NewGateTrader(cfg.APIKey, cfg.APISecret), nil
// Hyperliquid needs private key, not API key/secret
// case "hyperliquid":
// return hyperliquid.NewHyperliquidTrader(cfg.APIKey, "", cfg.Testnet)
default:
return nil, fmt.Errorf("unsupported exchange: %s", cfg.Name)
}
}

View File

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

View File

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

View File

@@ -1,126 +0,0 @@
package interaction
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
// KlineBar represents a single candlestick.
type KlineBar struct {
Time int64 `json:"time"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
}
// fetchKlines gets kline data from Binance public API (no auth needed).
func fetchKlines(symbol, interval string, limit int) ([]KlineBar, error) {
if limit <= 0 {
limit = 200
}
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=%d",
symbol, interval, limit)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch klines: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var raw [][]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse klines: %w", err)
}
bars := make([]KlineBar, 0, len(raw))
for _, k := range raw {
if len(k) < 6 {
continue
}
bar := KlineBar{
Time: int64(k[0].(float64)) / 1000, // ms → seconds for lightweight-charts
}
bar.Open, _ = strconv.ParseFloat(k[1].(string), 64)
bar.High, _ = strconv.ParseFloat(k[2].(string), 64)
bar.Low, _ = strconv.ParseFloat(k[3].(string), 64)
bar.Close, _ = strconv.ParseFloat(k[4].(string), 64)
bar.Volume, _ = strconv.ParseFloat(k[5].(string), 64)
bars = append(bars, bar)
}
return bars, nil
}
// RegisterKlineRoutes adds kline API endpoints to the mux.
func RegisterKlineRoutes(mux *http.ServeMux) {
// GET /api/klines?symbol=BTCUSDT&interval=1h&limit=200
mux.HandleFunc("/api/klines", func(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
limitStr := r.URL.Query().Get("limit")
limit := 200
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
if limit > 1000 {
limit = 1000
}
bars, err := fetchKlines(symbol, interval, limit)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(bars)
})
// GET /api/ticker?symbol=BTCUSDT — current price + 24h change
mux.HandleFunc("/api/ticker", func(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Write(body)
})
}

View File

@@ -1,137 +0,0 @@
package interaction
import (
"context"
"fmt"
"log/slog"
"time"
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(parentCtx 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 with timeout
ctx, cancel := context.WithTimeout(parentCtx, 55*time.Second)
defer cancel()
response, err := t.handler(ctx, userID, text)
if err != nil {
t.logger.Error("handle message", "error", err)
if ctx.Err() != nil {
response = "⏱️ AI 响应超时,请稍后再试。"
} else {
response = fmt.Sprintf("⚠️ Error: %v", err)
}
}
t.logger.Info("sending response", "user_id", userID, "len", len(response))
t.sendMarkdown(msg.Chat.ID, response)
}
func (t *TelegramBot) sendText(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
if _, err := t.bot.Send(msg); err != nil {
t.logger.Error("send message", "error", err)
}
}
func (t *TelegramBot) sendMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
if _, err := t.bot.Send(msg); err != nil {
// Retry without markdown if parsing fails
t.logger.Warn("markdown send failed, retrying plain", "error", err)
t.sendText(chatID, text)
}
}
// SendNotification sends a proactive message to a user.
func (t *TelegramBot) SendNotification(userID int64, text string) error {
msg := tgbotapi.NewMessage(userID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
_, err := t.bot.Send(msg)
return err
}

View File

@@ -1,195 +0,0 @@
package interaction
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// WebServer provides a REST API and Web UI for NOFXi.
type WebServer struct {
handler MessageHandler
port int
webDir string // Path to web/ directory for static files
logger *slog.Logger
server *http.Server
}
// NewWebServer creates a new web API server.
// webDir is the path to the web/ directory containing index.html.
func NewWebServer(port int, handler MessageHandler, webDir string, logger *slog.Logger) *WebServer {
return &WebServer{
handler: handler,
port: port,
webDir: webDir,
logger: logger,
}
}
// chatRequest is the API request body.
type chatRequest struct {
UserID int64 `json:"user_id"`
Message string `json:"message"`
Lang string `json:"lang"` // "zh" or "en"
}
// 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
}
// Pass language preference via message prefix (agent will extract it)
message := req.Message
if req.Lang != "" {
message = "[lang:" + req.Lang + "] " + message
}
resp, err := w.handler(r.Context(), req.UserID, 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",
},
},
})
})
// Register kline/market data API
RegisterKlineRoutes(mux)
// Serve web UI static files
if w.webDir != "" {
if _, err := os.Stat(filepath.Join(w.webDir, "index.html")); err == nil {
mux.Handle("/", http.FileServer(http.Dir(w.webDir)))
w.logger.Info("serving web UI", "dir", w.webDir)
}
}
addr := fmt.Sprintf(":%d", w.port)
w.server = &http.Server{
Addr: addr,
Handler: mux,
}
w.logger.Info("web server starting", "addr", addr)
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
w.server.Shutdown(shutdownCtx)
}()
if err := w.server.ListenAndServe(); err != http.ErrServerClosed {
return err
}
return nil
}
// Port returns the configured port.
func (w *WebServer) Port() int {
return w.port
}
// PortStr returns port as string.
func (w *WebServer) PortStr() string {
return strconv.Itoa(w.port)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -1,229 +0,0 @@
package memory
import (
"database/sql"
"fmt"
"time"
)
// UserProfile captures what the AI has learned about a user's trading behavior.
type UserProfile struct {
UserID int64 `json:"user_id"`
TotalTrades int `json:"total_trades"`
WinRate float64 `json:"win_rate"`
AvgHoldTime float64 `json:"avg_hold_time_hours"`
PreferredSide string `json:"preferred_side"` // "long", "short", "balanced"
RiskTolerance string `json:"risk_tolerance"` // "conservative", "moderate", "aggressive"
FavoriteSymbols []string `json:"favorite_symbols"`
AvgLeverage float64 `json:"avg_leverage"`
BestStrategy string `json:"best_strategy"`
WorstStrategy string `json:"worst_strategy"`
TotalPnL float64 `json:"total_pnl"`
BiggestWin float64 `json:"biggest_win"`
BiggestLoss float64 `json:"biggest_loss"`
LastAnalyzed time.Time `json:"last_analyzed"`
}
// Lesson is an insight learned from past trading.
type Lesson struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Type string `json:"type"` // "win_pattern", "loss_pattern", "risk_insight", "strategy_note"
Content string `json:"content"` // Natural language description
Symbol string `json:"symbol,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// InitLearnerTables creates the learner-specific tables.
func (s *Store) InitLearnerTables() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS user_profiles (
user_id INTEGER PRIMARY KEY,
total_trades INTEGER DEFAULT 0,
win_rate REAL DEFAULT 0,
avg_hold_time REAL DEFAULT 0,
preferred_side TEXT DEFAULT 'balanced',
risk_tolerance TEXT DEFAULT 'moderate',
favorite_symbols TEXT DEFAULT '',
avg_leverage REAL DEFAULT 1,
best_strategy TEXT DEFAULT '',
worst_strategy TEXT DEFAULT '',
total_pnl REAL DEFAULT 0,
biggest_win REAL DEFAULT 0,
biggest_loss REAL DEFAULT 0,
last_analyzed DATETIME
)`,
`CREATE TABLE IF NOT EXISTS lessons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
symbol TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS ai_predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
predicted_action TEXT NOT NULL,
predicted_confidence REAL,
actual_result TEXT,
actual_pnl REAL,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME
)`,
`CREATE INDEX IF NOT EXISTS idx_lessons_user ON lessons(user_id, type)`,
`CREATE INDEX IF NOT EXISTS idx_predictions_symbol ON ai_predictions(symbol, created_at)`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("learner migration: %w", err)
}
}
return nil
}
// SaveLesson stores a trading lesson.
func (s *Store) SaveLesson(userID int64, lessonType, content, symbol string) error {
_, err := s.db.Exec(
`INSERT INTO lessons (user_id, type, content, symbol) VALUES (?, ?, ?, ?)`,
userID, lessonType, content, symbol,
)
return err
}
// GetLessons retrieves lessons for a user.
func (s *Store) GetLessons(userID int64, limit int) ([]Lesson, error) {
rows, err := s.db.Query(
`SELECT id, user_id, type, content, COALESCE(symbol,''), created_at
FROM lessons WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`,
userID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var lessons []Lesson
for rows.Next() {
var l Lesson
if err := rows.Scan(&l.ID, &l.UserID, &l.Type, &l.Content, &l.Symbol, &l.CreatedAt); err != nil {
return nil, err
}
lessons = append(lessons, l)
}
return lessons, nil
}
// SavePrediction logs an AI prediction for later evaluation.
func (s *Store) SavePrediction(symbol, action string, confidence float64, model string) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO ai_predictions (symbol, predicted_action, predicted_confidence, model) VALUES (?, ?, ?, ?)`,
symbol, action, confidence, model,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// ResolvePrediction updates a prediction with the actual result.
func (s *Store) ResolvePrediction(id int64, result string, pnl float64) error {
_, err := s.db.Exec(
`UPDATE ai_predictions SET actual_result = ?, actual_pnl = ?, resolved_at = ? WHERE id = ?`,
result, pnl, time.Now(), id,
)
return err
}
// GetPredictionAccuracy returns the accuracy of AI predictions.
func (s *Store) GetPredictionAccuracy(model string) (total int, correct int, avgPnL float64, err error) {
var pnl sql.NullFloat64
err = s.db.QueryRow(
`SELECT COUNT(*), SUM(CASE WHEN actual_pnl > 0 THEN 1 ELSE 0 END), AVG(actual_pnl)
FROM ai_predictions WHERE resolved_at IS NOT NULL AND (? = '' OR model = ?)`,
model, model,
).Scan(&total, &correct, &pnl)
if pnl.Valid {
avgPnL = pnl.Float64
}
return
}
// AnalyzeUserProfile builds a profile from trading history.
func (s *Store) AnalyzeUserProfile(userID int64) (*UserProfile, error) {
trades, err := s.GetRecentTrades(1000)
if err != nil {
return nil, err
}
if len(trades) == 0 {
return &UserProfile{UserID: userID}, nil
}
profile := &UserProfile{
UserID: userID,
TotalTrades: len(trades),
}
wins := 0
symbolCount := make(map[string]int)
var totalPnL, bigWin, bigLoss, totalLev float64
longCount, shortCount := 0, 0
for _, t := range trades {
totalPnL += t.PnL
if t.PnL > 0 {
wins++
}
if t.PnL > bigWin {
bigWin = t.PnL
}
if t.PnL < bigLoss {
bigLoss = t.PnL
}
symbolCount[t.Symbol]++
if t.Side == "long" || t.Side == "buy" {
longCount++
} else {
shortCount++
}
}
profile.WinRate = float64(wins) / float64(len(trades)) * 100
profile.TotalPnL = totalPnL
profile.BiggestWin = bigWin
profile.BiggestLoss = bigLoss
profile.AvgLeverage = totalLev / float64(len(trades))
if longCount > shortCount*2 {
profile.PreferredSide = "long"
} else if shortCount > longCount*2 {
profile.PreferredSide = "short"
} else {
profile.PreferredSide = "balanced"
}
// Top symbols
var favs []string
for sym := range symbolCount {
favs = append(favs, sym)
}
if len(favs) > 5 {
favs = favs[:5]
}
profile.FavoriteSymbols = favs
// Risk tolerance based on leverage and loss patterns
if profile.BiggestLoss < -500 || profile.AvgLeverage > 10 {
profile.RiskTolerance = "aggressive"
} else if profile.BiggestLoss < -100 || profile.AvgLeverage > 3 {
profile.RiskTolerance = "moderate"
} else {
profile.RiskTolerance = "conservative"
}
profile.LastAnalyzed = time.Now()
return profile, nil
}

View File

@@ -1,11 +0,0 @@
// Package memory implements the Memory Layer.
//
// Provides persistent storage for:
// - Trade history and P/L tracking
// - Conversation context (per-user message history)
// - User preferences and risk profiles
// - Strategy performance metrics
// - Market pattern snapshots
//
// Backend: SQLite (pure Go driver, zero CGO)
package memory

View File

@@ -1,64 +0,0 @@
package memory
import "time"
// TradeRecord stores a completed or active trade.
type TradeRecord struct {
ID int64 `json:"id"`
Exchange string `json:"exchange"`
Symbol string `json:"symbol"`
Side string `json:"side"` // "buy" or "sell"
Type string `json:"type"` // "market", "limit"
Price float64 `json:"price"`
Quantity float64 `json:"quantity"`
PnL float64 `json:"pnl"` // Realized P/L
Fee float64 `json:"fee"`
Status string `json:"status"` // "open", "closed", "cancelled"
AIModel string `json:"ai_model"` // Which AI model made the decision
AIReason string `json:"ai_reason"` // Why AI made this decision
StrategyID string `json:"strategy_id"`
CreatedAt time.Time `json:"created_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// UserPreference stores user settings and preferences.
type UserPreference struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"` // Telegram user ID
Key string `json:"key"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updated_at"`
}
// Conversation stores chat messages for context.
type Conversation struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Role string `json:"role"` // "user", "assistant", "system"
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// StrategyPerformance tracks how well a strategy has performed.
type StrategyPerformance struct {
ID int64 `json:"id"`
StrategyID string `json:"strategy_id"`
StrategyName string `json:"strategy_name"`
TotalTrades int `json:"total_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
MaxDrawdown float64 `json:"max_drawdown"`
SharpeRatio float64 `json:"sharpe_ratio"`
UpdatedAt time.Time `json:"updated_at"`
}
// MarketSnapshot stores a point-in-time market state for pattern recognition.
type MarketSnapshot struct {
ID int64 `json:"id"`
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Volume24h float64 `json:"volume_24h"`
Change24h float64 `json:"change_24h"`
Note string `json:"note"` // AI observation about this snapshot
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -1,216 +0,0 @@
package memory
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Store is the SQLite-backed memory store.
type Store struct {
db *sql.DB
}
// NewStore opens (or creates) the SQLite database.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// Enable WAL mode for better concurrency
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("set WAL: %w", err)
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the database.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exchange TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'market',
price REAL NOT NULL,
quantity REAL NOT NULL,
pnl REAL DEFAULT 0,
fee REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
ai_model TEXT,
ai_reason TEXT,
strategy_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, key)
)`,
`CREATE TABLE IF NOT EXISTS strategy_performance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_id TEXT NOT NULL UNIQUE,
strategy_name TEXT,
total_trades INTEGER DEFAULT 0,
win_rate REAL DEFAULT 0,
total_pnl REAL DEFAULT 0,
max_drawdown REAL DEFAULT 0,
sharpe_ratio REAL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS market_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
price REAL NOT NULL,
volume_24h REAL,
change_24h REAL,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol)`,
`CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status)`,
`CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_snapshots_symbol ON market_snapshots(symbol, created_at)`,
}
for _, q := range queries {
if _, err := s.db.Exec(q); err != nil {
return fmt.Errorf("exec %q: %w", q[:40], err)
}
}
return nil
}
// --- Trade Operations ---
// SaveTrade inserts a new trade record.
func (s *Store) SaveTrade(t *TradeRecord) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO trades (exchange, symbol, side, type, price, quantity, pnl, fee, status, ai_model, ai_reason, strategy_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.Exchange, t.Symbol, t.Side, t.Type, t.Price, t.Quantity,
t.PnL, t.Fee, t.Status, t.AIModel, t.AIReason, t.StrategyID,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// GetRecentTrades returns the last N trades.
func (s *Store) GetRecentTrades(limit int) ([]TradeRecord, error) {
rows, err := s.db.Query(
`SELECT id, exchange, symbol, side, type, price, quantity, pnl, fee, status,
COALESCE(ai_model,''), COALESCE(ai_reason,''), COALESCE(strategy_id,''), created_at, closed_at
FROM trades ORDER BY created_at DESC LIMIT ?`, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var trades []TradeRecord
for rows.Next() {
var t TradeRecord
var closedAt sql.NullTime
if err := rows.Scan(&t.ID, &t.Exchange, &t.Symbol, &t.Side, &t.Type,
&t.Price, &t.Quantity, &t.PnL, &t.Fee, &t.Status,
&t.AIModel, &t.AIReason, &t.StrategyID, &t.CreatedAt, &closedAt); err != nil {
return nil, err
}
if closedAt.Valid {
t.ClosedAt = &closedAt.Time
}
trades = append(trades, t)
}
return trades, nil
}
// --- Conversation Operations ---
// SaveMessage stores a conversation message.
func (s *Store) SaveMessage(userID int64, role, content string) error {
_, err := s.db.Exec(
`INSERT INTO conversations (user_id, role, content) VALUES (?, ?, ?)`,
userID, role, content,
)
return err
}
// GetRecentMessages returns the last N messages for a user.
func (s *Store) GetRecentMessages(userID int64, limit int) ([]Conversation, error) {
rows, err := s.db.Query(
`SELECT id, user_id, role, content, created_at FROM conversations
WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []Conversation
for rows.Next() {
var c Conversation
if err := rows.Scan(&c.ID, &c.UserID, &c.Role, &c.Content, &c.CreatedAt); err != nil {
return nil, err
}
msgs = append(msgs, c)
}
// Reverse to get chronological order
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// --- Preference Operations ---
// SetPreference upserts a user preference.
func (s *Store) SetPreference(userID int64, key, value string) error {
_, err := s.db.Exec(
`INSERT INTO user_preferences (user_id, key, value, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
userID, key, value, time.Now(),
)
return err
}
// GetPreference retrieves a user preference.
func (s *Store) GetPreference(userID int64, key string) (string, error) {
var value string
err := s.db.QueryRow(
`SELECT value FROM user_preferences WHERE user_id = ? AND key = ?`,
userID, key,
).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}

View File

@@ -1,214 +0,0 @@
package perception
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
// PriceAlert triggers when a symbol hits a threshold.
type PriceAlert struct {
Symbol string
Price float64
Direction string // "above" or "below"
Threshold float64
Triggered time.Time
}
// AlertCallback is called when a price alert triggers.
type AlertCallback func(alert PriceAlert)
// MarketMonitor watches symbols and detects price anomalies.
type MarketMonitor struct {
mu sync.RWMutex
watching map[string]*watchState
alerts []alertRule
onAlert AlertCallback
httpClient *http.Client
logger *slog.Logger
stopCh chan struct{}
pollInterval time.Duration
}
type watchState struct {
Symbol string
LastPrice float64
Change1h float64
Change24h float64
UpdatedAt time.Time
}
type alertRule struct {
Symbol string
Direction string // "above" or "below"
Threshold float64
Fired bool
}
// NewMarketMonitor creates a new market monitor.
func NewMarketMonitor(onAlert AlertCallback, logger *slog.Logger) *MarketMonitor {
return &MarketMonitor{
watching: make(map[string]*watchState),
onAlert: onAlert,
httpClient: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
pollInterval: 30 * time.Second,
}
}
// Watch starts monitoring a symbol.
func (m *MarketMonitor) Watch(symbol string) {
m.mu.Lock()
defer m.mu.Unlock()
m.watching[symbol] = &watchState{Symbol: symbol}
m.logger.Info("watching symbol", "symbol", symbol)
}
// Unwatch stops monitoring a symbol.
func (m *MarketMonitor) Unwatch(symbol string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.watching, symbol)
}
// AddAlert adds a price alert rule.
func (m *MarketMonitor) AddAlert(symbol, direction string, threshold float64) {
m.mu.Lock()
defer m.mu.Unlock()
m.alerts = append(m.alerts, alertRule{
Symbol: symbol,
Direction: direction,
Threshold: threshold,
})
m.logger.Info("alert added", "symbol", symbol, "direction", direction, "threshold", threshold)
}
// GetSnapshot returns the latest state for a symbol.
func (m *MarketMonitor) GetSnapshot(symbol string) (*watchState, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.watching[symbol]
return s, ok
}
// GetAllSnapshots returns all watched symbols.
func (m *MarketMonitor) GetAllSnapshots() map[string]*watchState {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]*watchState)
for k, v := range m.watching {
result[k] = v
}
return result
}
// Start begins the monitoring loop.
func (m *MarketMonitor) Start() {
go m.loop()
}
// Stop stops the monitoring loop.
func (m *MarketMonitor) Stop() {
close(m.stopCh)
}
func (m *MarketMonitor) loop() {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
// Initial fetch
m.updatePrices()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
m.updatePrices()
}
}
}
func (m *MarketMonitor) updatePrices() {
m.mu.RLock()
symbols := make([]string, 0, len(m.watching))
for s := range m.watching {
symbols = append(symbols, s)
}
m.mu.RUnlock()
for _, symbol := range symbols {
price, err := m.fetchPrice(symbol)
if err != nil {
m.logger.Error("fetch price", "symbol", symbol, "error", err)
continue
}
m.mu.Lock()
if state, ok := m.watching[symbol]; ok {
state.LastPrice = price
state.UpdatedAt = time.Now()
}
// Check alerts
for i := range m.alerts {
a := &m.alerts[i]
if a.Symbol != symbol || a.Fired {
continue
}
triggered := false
if a.Direction == "above" && price >= a.Threshold {
triggered = true
} else if a.Direction == "below" && price <= a.Threshold {
triggered = true
}
if triggered {
a.Fired = true
if m.onAlert != nil {
m.onAlert(PriceAlert{
Symbol: symbol,
Price: price,
Direction: a.Direction,
Threshold: a.Threshold,
Triggered: time.Now(),
})
}
}
}
m.mu.Unlock()
}
}
// fetchPrice gets current price from Binance public API (no auth needed).
func (m *MarketMonitor) fetchPrice(symbol string) (float64, error) {
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/price?symbol=%s", symbol)
resp, err := m.httpClient.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, err
}
var result struct {
Price string `json:"price"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, err
}
var price float64
fmt.Sscanf(result.Price, "%f", &price)
if price == 0 {
return 0, fmt.Errorf("invalid price for %s", symbol)
}
return price, nil
}

View File

@@ -1,136 +0,0 @@
package perception
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// NewsItem represents a crypto news headline.
type NewsItem struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Sentiment string `json:"sentiment"` // "bullish", "bearish", "neutral"
Symbols []string `json:"symbols"` // Related symbols
Timestamp time.Time `json:"timestamp"`
}
// NewsMonitor fetches crypto news and detects sentiment shifts.
type NewsMonitor struct {
httpClient *http.Client
logger *slog.Logger
lastCheck time.Time
seenURLs map[string]bool
}
// NewNewsMonitor creates a new news monitor.
func NewNewsMonitor(logger *slog.Logger) *NewsMonitor {
return &NewsMonitor{
httpClient: &http.Client{Timeout: 15 * time.Second},
logger: logger,
seenURLs: make(map[string]bool),
}
}
// FetchNews gets recent crypto news from CryptoCompare (free, no auth needed).
func (n *NewsMonitor) FetchNews() ([]NewsItem, error) {
url := "https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest"
resp, err := n.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("fetch news: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse news: %w", err)
}
var items []NewsItem
for _, d := range result.Data {
if n.seenURLs[d.URL] {
continue
}
n.seenURLs[d.URL] = true
item := NewsItem{
Title: d.Title,
Source: d.Source,
URL: d.URL,
Sentiment: classifySentiment(d.Title + " " + d.Body),
Symbols: extractSymbols(d.Title + " " + d.Categories),
Timestamp: time.Unix(d.PublishedOn, 0),
}
items = append(items, item)
}
// Keep seen URLs map from growing forever
if len(n.seenURLs) > 1000 {
n.seenURLs = make(map[string]bool)
}
n.lastCheck = time.Now()
return items, nil
}
// classifySentiment does basic keyword-based sentiment analysis.
func classifySentiment(text string) string {
lower := strings.ToLower(text)
bullish := []string{"surge", "rally", "soar", "bullish", "breakout", "all-time high", "ath",
"pump", "moon", "gain", "rise", "uptrend", "buy signal", "accumulate", "adoption"}
bearish := []string{"crash", "dump", "plunge", "bearish", "sell-off", "selloff", "decline",
"drop", "fall", "liquidat", "hack", "exploit", "ban", "fraud", "scam", "risk"}
bullCount, bearCount := 0, 0
for _, w := range bullish {
if strings.Contains(lower, w) {
bullCount++
}
}
for _, w := range bearish {
if strings.Contains(lower, w) {
bearCount++
}
}
if bullCount > bearCount {
return "bullish"
}
if bearCount > bullCount {
return "bearish"
}
return "neutral"
}
// extractSymbols finds crypto symbols mentioned in text.
func extractSymbols(text string) []string {
upper := strings.ToUpper(text)
known := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK", "MATIC", "UNI", "AAVE"}
var found []string
for _, s := range known {
if strings.Contains(upper, s) {
found = append(found, s)
}
}
return found
}

View File

@@ -1,20 +0,0 @@
// Package perception implements the Perception Layer.
//
// Phase 2 — stubs for now.
//
// Planned capabilities:
// - Real-time market data monitoring
// - Price anomaly detection
// - Position tracking and P/L monitoring
// - News and sentiment analysis
// - Custom alert triggers
package perception
// Monitor is the market perception interface.
type Monitor interface {
// WatchSymbol starts monitoring a symbol for price changes.
WatchSymbol(symbol string) error
// Stop stops all monitoring.
Stop()
}

View File

@@ -1,282 +0,0 @@
package perception
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"strconv"
"sync"
"time"
)
// Signal types for proactive notifications.
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout" // Sudden price move
SignalVolumeSpike SignalType = "volume_spike" // Abnormal volume
SignalFundingRate SignalType = "funding_rate" // Extreme funding rate
SignalLiquidation SignalType = "liquidation_wave" // Mass liquidations
SignalTrendReversal SignalType = "trend_reversal" // Potential reversal
SignalPositionRisk SignalType = "position_risk" // User's position at risk
)
// Signal is a proactive market event detected by the sentinel.
type Signal struct {
Type SignalType `json:"type"`
Symbol string `json:"symbol"`
Severity string `json:"severity"` // "info", "warning", "critical"
Title string `json:"title"`
Detail string `json:"detail"`
Price float64 `json:"price"`
Change float64 `json:"change"` // Percentage
Timestamp time.Time `json:"timestamp"`
}
// SignalCallback is called when the sentinel detects something.
type SignalCallback func(signal Signal)
// Sentinel continuously monitors markets and detects anomalies.
// This is the "eyes" of NOFXi — always watching, always analyzing.
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePoint // symbol → recent prices
onSignal SignalCallback
httpClient *http.Client
logger *slog.Logger
stopCh chan struct{}
// Thresholds
priceBreakoutPct float64 // Price move % to trigger alert (default 3%)
volumeSpikeMult float64 // Volume multiplier vs average (default 3x)
fundingThreshold float64 // Extreme funding rate threshold (default 0.1%)
}
type pricePoint struct {
Price float64
Volume float64
Timestamp time.Time
}
// NewSentinel creates a new market sentinel.
func NewSentinel(symbols []string, onSignal SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePoint),
onSignal: onSignal,
httpClient: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
priceBreakoutPct: 3.0,
volumeSpikeMult: 3.0,
fundingThreshold: 0.1,
}
}
// Start begins the sentinel loop. Checks every 60 seconds.
func (s *Sentinel) Start() {
go s.loop()
s.logger.Info("sentinel started", "symbols", s.symbols)
}
// Stop stops the sentinel.
func (s *Sentinel) Stop() {
close(s.stopCh)
}
// AddSymbol adds a symbol to watch.
func (s *Sentinel) AddSymbol(symbol string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, sym := range s.symbols {
if sym == symbol {
return
}
}
s.symbols = append(s.symbols, symbol)
}
func (s *Sentinel) loop() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
// Initial scan
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
}
func (s *Sentinel) scan() {
s.mu.RLock()
symbols := make([]string, len(s.symbols))
copy(symbols, s.symbols)
s.mu.RUnlock()
for _, sym := range symbols {
s.checkSymbol(sym)
}
s.checkFundingRates()
}
func (s *Sentinel) checkSymbol(symbol string) {
// Fetch current ticker
ticker, err := s.fetchTicker(symbol)
if err != nil {
return
}
price, _ := strconv.ParseFloat(ticker["lastPrice"].(string), 64)
volume, _ := strconv.ParseFloat(ticker["quoteVolume"].(string), 64)
changePct, _ := strconv.ParseFloat(ticker["priceChangePercent"].(string), 64)
now := time.Now()
point := pricePoint{Price: price, Volume: volume, Timestamp: now}
s.mu.Lock()
hist := s.history[symbol]
hist = append(hist, point)
// Keep last 60 points (1 hour at 1min intervals)
if len(hist) > 60 {
hist = hist[len(hist)-60:]
}
s.history[symbol] = hist
s.mu.Unlock()
// Need at least 5 data points to detect anomalies
if len(hist) < 5 {
return
}
// === Detect Price Breakout ===
// Compare current price to 5-minute-ago price
fiveAgo := hist[len(hist)-5]
pctMove := ((price - fiveAgo.Price) / fiveAgo.Price) * 100
if math.Abs(pctMove) >= s.priceBreakoutPct {
direction := "📈 上涨"
severity := "warning"
if pctMove < 0 {
direction = "📉 下跌"
}
if math.Abs(pctMove) >= s.priceBreakoutPct*2 {
severity = "critical"
}
s.emit(Signal{
Type: SignalPriceBreakout,
Symbol: symbol,
Severity: severity,
Title: fmt.Sprintf("%s %s 急速%s %.1f%%", symbol, direction, map[bool]string{true: "拉升", false: "下跌"}[pctMove > 0], math.Abs(pctMove)),
Detail: fmt.Sprintf("5分钟内从 $%.2f → $%.2f,变动 %.1f%%\n24h 涨跌: %.1f%%", fiveAgo.Price, price, pctMove, changePct),
Price: price,
Change: pctMove,
})
}
// === Detect Volume Spike ===
if len(hist) >= 10 {
var avgVol float64
for i := 0; i < len(hist)-1; i++ {
avgVol += hist[i].Volume
}
avgVol /= float64(len(hist) - 1)
if avgVol > 0 && volume > avgVol*s.volumeSpikeMult {
mult := volume / avgVol
s.emit(Signal{
Type: SignalVolumeSpike,
Symbol: symbol,
Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常放大 %.1fx", symbol, mult),
Detail: fmt.Sprintf("当前成交量是平均值的 %.1f 倍\n价格: $%.2f (24h: %.1f%%)", mult, price, changePct),
Price: price,
Change: changePct,
})
}
}
}
func (s *Sentinel) checkFundingRates() {
url := "https://fapi.binance.com/fapi/v1/premiumIndex"
resp, err := s.httpClient.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
var indexes []map[string]interface{}
if err := json.Unmarshal(body, &indexes); err != nil {
return
}
s.mu.RLock()
watchSet := make(map[string]bool)
for _, sym := range s.symbols {
watchSet[sym] = true
}
s.mu.RUnlock()
for _, idx := range indexes {
symbol, _ := idx["symbol"].(string)
if !watchSet[symbol] {
continue
}
rateStr, _ := idx["lastFundingRate"].(string)
rate, _ := strconv.ParseFloat(rateStr, 64)
ratePct := rate * 100
if math.Abs(ratePct) >= s.fundingThreshold {
direction := "多头主导"
if ratePct < 0 {
direction = "空头主导"
}
s.emit(Signal{
Type: SignalFundingRate,
Symbol: symbol,
Severity: "info",
Title: fmt.Sprintf("%s 资金费率异常: %.4f%%", symbol, ratePct),
Detail: fmt.Sprintf("当前资金费率 %.4f%% (%s)\n极端费率可能预示反转", ratePct, direction),
Change: ratePct,
})
}
}
}
func (s *Sentinel) fetchTicker(symbol string) (map[string]interface{}, error) {
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol)
resp, err := s.httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
return result, nil
}
func (s *Sentinel) emit(sig Signal) {
sig.Timestamp = time.Now()
s.logger.Info("signal detected",
"type", sig.Type,
"symbol", sig.Symbol,
"severity", sig.Severity,
"title", sig.Title,
)
if s.onSignal != nil {
s.onSignal(sig)
}
}

View File

@@ -1,28 +0,0 @@
package thinking
import "context"
// Engine is the AI decision-making interface.
type Engine interface {
// Chat sends a message to the LLM with conversation context and returns a response.
Chat(ctx context.Context, messages []Message) (string, error)
// Analyze asks the AI to analyze market data and provide a trading recommendation.
Analyze(ctx context.Context, prompt string) (*Analysis, error)
}
// Message represents a chat message.
type Message struct {
Role string `json:"role"` // "system", "user", "assistant"
Content string `json:"content"`
}
// Analysis holds an AI trading recommendation.
type Analysis struct {
Action string `json:"action"` // "buy", "sell", "hold", "wait"
Symbol string `json:"symbol"`
Confidence float64 `json:"confidence"` // 0.0 - 1.0
Reasoning string `json:"reasoning"`
StopLoss float64 `json:"stop_loss,omitempty"`
TakeProfit float64 `json:"take_profit,omitempty"`
}

View File

@@ -1,161 +0,0 @@
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, Dashscope (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: 60 * time.Second,
},
}
}
// chatRequest is the OpenAI chat completions request body.
type chatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
}
// chatResponse handles both standard and thinking-mode responses.
type chatResponse struct {
Choices []struct {
Message struct {
Content *string `json:"content"` // Can be null in thinking mode
ReasoningContent string `json:"reasoning_content"` // Qwen3 thinking mode
} `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")
}
// Extract content — handle thinking mode where content can be null
choice := chatResp.Choices[0]
content := ""
if choice.Message.Content != nil {
content = *choice.Message.Content
}
// If content is empty but reasoning_content exists, use that
if content == "" && choice.Message.ReasoningContent != "" {
content = choice.Message.ReasoningContent
}
if content == "" {
return "🤔 (AI returned empty response)", nil
}
return content, nil
}
// Analyze sends an analysis prompt and parses the AI response.
func (e *LLMEngine) Analyze(ctx context.Context, prompt string) (*Analysis, error) {
systemPrompt := `You are NOFXi, an expert AI trading analyst. Analyze the given market data and provide a trading recommendation.
Respond in JSON format:
{
"action": "buy|sell|hold|wait",
"symbol": "BTC/USDT",
"confidence": 0.85,
"reasoning": "Brief explanation",
"stop_loss": 0.0,
"take_profit": 0.0
}
Be concise. Only recommend high-confidence trades.`
messages := []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: prompt},
}
resp, err := e.Chat(ctx, messages)
if err != nil {
return nil, err
}
var analysis Analysis
if err := json.Unmarshal([]byte(resp), &analysis); err != nil {
// If JSON parsing fails, return the raw text as reasoning
return &Analysis{
Action: "hold",
Reasoning: resp,
}, nil
}
return &analysis, nil
}

View File

@@ -1,9 +0,0 @@
// 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

View File

@@ -1,418 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NOFXi — AI Trading Agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lightweight-charts@4.1.3/dist/lightweight-charts.standalone.production.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#09090b;--bg2:#0c0c12;--surface:#13131b;--surface2:#1a1a24;
--border:#1f1f2c;--border2:#2a2a38;
--text:#eaeaf0;--text2:#9898ab;--text3:#5c5c72;
--accent:#00e5a0;--accent-dim:rgba(0,229,160,.08);
--purple:#8b5cf6;--purple-dim:rgba(139,92,246,.08);
--blue:#3b82f6;--red:#ef4444;--green:#22c55e;--orange:#f59e0b;
--radius:10px;--radius-sm:6px;
}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden}
button{font-family:inherit;cursor:pointer}
select{font-family:inherit}
/* Header */
.hd{display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:48px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0}
.hd-left{display:flex;align-items:center;gap:14px}
.logo{font-size:17px;font-weight:800;display:flex;align-items:center;gap:6px}
.logo-mark{width:26px;height:26px;background:linear-gradient(135deg,var(--accent),var(--purple));border-radius:6px;display:grid;place-items:center;font-size:13px;color:#000;font-weight:800}
.logo-text{background:linear-gradient(135deg,var(--accent),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.tabs{display:flex;gap:1px;background:var(--surface);border-radius:var(--radius-sm);padding:2px;height:30px}
.tab{padding:0 14px;border:none;background:none;color:var(--text3);font-size:12px;font-weight:500;border-radius:calc(var(--radius-sm) - 1px);transition:all .15s;white-space:nowrap}
.tab:hover{color:var(--text2)}
.tab.on{background:var(--surface2);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.3)}
.hd-right{display:flex;align-items:center;gap:8px}
.badge{display:flex;align-items:center;gap:5px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:500}
.badge.ok{color:var(--green);background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.15)}
.badge.err{color:var(--red);background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.15)}
.badge-dot{width:5px;height:5px;border-radius:50%;background:currentColor}
.lang-btn{padding:3px 8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text2);font-size:11px;font-weight:500;transition:all .15s}
.lang-btn:hover{border-color:var(--border2);color:var(--text)}
/* Layout */
.main{flex:1;display:flex;overflow:hidden}
/* Sidebar */
.sb{width:240px;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;flex-shrink:0}
.sb::-webkit-scrollbar{width:3px}
.sb::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.sb-sec{padding:12px}
.sb-sec+.sb-sec{border-top:1px solid var(--border)}
.sb-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin-bottom:8px}
.sb-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.sb-card{display:flex;flex-direction:column;align-items:center;gap:3px;padding:10px 6px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text2);font-size:10px;font-weight:500;transition:all .12s}
.sb-card:hover{background:var(--surface2);border-color:var(--border2);color:var(--text);transform:translateY(-1px)}
.sb-card .e{font-size:18px}
.sb-list{display:flex;flex-direction:column;gap:1px}
.sb-item{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-sm);color:var(--text2);font-size:12px;border:none;background:none;width:100%;text-align:left;transition:all .12s}
.sb-item:hover{background:var(--surface);color:var(--text)}
.sb-ico{width:24px;height:24px;border-radius:5px;display:grid;place-items:center;font-size:12px;flex-shrink:0}
.sb-ico.g{background:rgba(34,197,94,.08)}.sb-ico.b{background:rgba(59,130,246,.08)}.sb-ico.p{background:rgba(139,92,246,.08)}.sb-ico.o{background:rgba(245,158,11,.08)}
/* Chat */
.chat{flex:1;display:flex;flex-direction:column;background:var(--bg)}
.chat.hide{display:none}
.msgs{flex:1;overflow-y:auto;padding:20px 16px;scroll-behavior:smooth}
.msgs::-webkit-scrollbar{width:3px}
.msgs::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.m{display:flex;gap:10px;margin-bottom:16px;animation:fi .25s ease}
.m.u{flex-direction:row-reverse}
@keyframes fi{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.m-av{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;font-size:15px;flex-shrink:0}
.m.b .m-av{background:linear-gradient(135deg,var(--accent-dim),var(--purple-dim));border:1px solid var(--border)}
.m.u .m-av{background:var(--purple-dim);border:1px solid rgba(139,92,246,.15)}
.m-body{max-width:640px}
.m-name{font-size:11px;font-weight:600;color:var(--text3);margin-bottom:2px}
.m.u .m-name{text-align:right}
.m-bub{padding:10px 14px;border-radius:14px;font-size:13px;line-height:1.65;white-space:pre-wrap;word-break:break-word}
.m.b .m-bub{background:var(--surface);border:1px solid var(--border);border-top-left-radius:3px}
.m.u .m-bub{background:linear-gradient(135deg,var(--purple),#6d28d9);color:#fff;border-top-right-radius:3px}
.m-t{font-size:10px;color:var(--text3);margin-top:2px}
.m.u .m-t{text-align:right}
.typing{display:none;padding:0 20px 8px;color:var(--text3);font-size:12px;align-items:center;gap:6px}
.typing.on{display:flex}
.dots{display:flex;gap:2px}
.dots span{width:4px;height:4px;background:var(--accent);border-radius:50%;animation:bou 1.4s infinite}
.dots span:nth-child(2){animation-delay:.2s}.dots span:nth-child(3){animation-delay:.4s}
@keyframes bou{0%,60%,100%{transform:translateY(0);opacity:.3}30%{transform:translateY(-5px);opacity:1}}
.ibar{padding:12px 16px 16px;background:var(--bg2);border-top:1px solid var(--border)}
.iwrap{display:flex;align-items:center;gap:8px;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:3px 3px 3px 14px;transition:all .2s;max-width:800px;margin:0 auto}
.iwrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}
.iwrap input{flex:1;background:none;border:none;color:var(--text);font-size:13px;outline:none;padding:8px 0}
.iwrap input::placeholder{color:var(--text3)}
.sbtn{width:36px;height:36px;border-radius:10px;border:none;background:linear-gradient(135deg,var(--accent),#00c896);color:#000;font-size:16px;display:grid;place-items:center;transition:all .12s;flex-shrink:0}
.sbtn:hover{transform:scale(1.05);box-shadow:0 2px 10px rgba(0,229,160,.25)}
.sbtn:disabled{opacity:.35;cursor:not-allowed;transform:none}
.ihint{font-size:10px;color:var(--text3);text-align:center;margin-top:6px}
.ihint kbd{background:var(--surface);padding:0 4px;border-radius:3px;border:1px solid var(--border);font-family:'JetBrains Mono',monospace;font-size:9px}
/* Chart */
.chart-p{flex:1;display:none;flex-direction:column;background:var(--bg)}
.chart-p.on{display:flex}
.ch-hd{display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--bg2);flex-wrap:wrap;gap:8px}
.ch-left{display:flex;align-items:center;gap:12px}
.ch-sym select{padding:4px 8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font-size:12px;font-family:'JetBrains Mono',monospace;outline:none}
.ch-price{font-size:22px;font-weight:700;font-family:'JetBrains Mono',monospace}
.ch-price.up{color:var(--green)}.ch-price.dn{color:var(--red)}
.ch-chg{font-size:12px;font-weight:500;padding:2px 6px;border-radius:4px}
.ch-chg.up{color:var(--green);background:rgba(34,197,94,.08)}.ch-chg.dn{color:var(--red);background:rgba(239,68,68,.08)}
.ch-tfs{display:flex;gap:2px}
.tf{padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:none;color:var(--text3);font-size:11px;font-weight:500;font-family:'JetBrains Mono',monospace;transition:all .12s}
.tf:hover{color:var(--text);border-color:var(--border2)}
.tf.on{background:var(--accent);color:#000;border-color:var(--accent)}
.ch-box{flex:1;position:relative}
/* Info bar */
.info-bar{display:flex;gap:16px;padding:6px 16px;border-bottom:1px solid var(--border);background:var(--bg2);font-size:11px;color:var(--text3);font-family:'JetBrains Mono',monospace;overflow-x:auto;flex-shrink:0}
.info-item{display:flex;gap:4px;white-space:nowrap}
.info-item .val{color:var(--text2)}
.info-item .val.up{color:var(--green)}.info-item .val.dn{color:var(--red)}
@media(max-width:768px){
.sb{display:none}.tabs{display:none}.hd{padding:0 12px}
.msgs{padding:12px 8px}.ibar{padding:8px 8px 12px}
.m-body{max-width:85%}
.ch-hd{padding:6px 10px}.info-bar{padding:4px 10px}
}
</style>
</head>
<body>
<div class="hd">
<div class="hd-left">
<div class="logo"><div class="logo-mark">N</div><span class="logo-text">NOFXi</span></div>
<div class="tabs">
<button class="tab on" onclick="go('chat')" data-i18n="tab_chat">Chat</button>
<button class="tab" onclick="go('chart')" data-i18n="tab_chart">Chart</button>
<button class="tab" onclick="send('/positions');go('chat')" data-i18n="tab_positions">Positions</button>
</div>
</div>
<div class="hd-right">
<div class="badge ok" id="stBadge"><div class="badge-dot"></div><span id="stText">--</span></div>
<button class="lang-btn" id="langBtn" onclick="toggleLang()">EN</button>
</div>
</div>
<!-- Info ticker bar -->
<div class="info-bar" id="infoBar"></div>
<div class="main">
<div class="sb">
<div class="sb-sec">
<div class="sb-title" data-i18n="sb_analysis">Analysis</div>
<div class="sb-grid">
<button class="sb-card" onclick="send('/analyze BTC')"><span class="e"></span><span data-i18n="sb_btc_analysis">BTC</span></button>
<button class="sb-card" onclick="send('/analyze ETH')"><span class="e">Ξ</span><span data-i18n="sb_eth_analysis">ETH</span></button>
<button class="sb-card" onclick="send('/analyze SOL')"><span class="e"></span><span>SOL</span></button>
<button class="sb-card" onclick="send('/price BTCUSDT')"><span class="e">💲</span><span data-i18n="sb_price">Price</span></button>
</div>
</div>
<div class="sb-sec">
<div class="sb-title" data-i18n="sb_portfolio">Portfolio</div>
<div class="sb-list">
<button class="sb-item" onclick="send('/positions')"><div class="sb-ico g">💼</div><span data-i18n="sb_positions">Positions</span></button>
<button class="sb-item" onclick="send('/balance')"><div class="sb-ico b">💰</div><span data-i18n="sb_balance">Balance</span></button>
<button class="sb-item" onclick="send('/pnl')"><div class="sb-ico p">📊</div><span data-i18n="sb_pnl">P/L</span></button>
</div>
</div>
<div class="sb-sec">
<div class="sb-title" data-i18n="sb_monitor">Monitor</div>
<div class="sb-list">
<button class="sb-item" onclick="send('/watch BTCUSDT')"><div class="sb-ico o">👁</div><span data-i18n="sb_watch_btc">Watch BTC</span></button>
<button class="sb-item" onclick="send('/watch')"><div class="sb-ico b">📋</div><span data-i18n="sb_watchlist">Watchlist</span></button>
</div>
</div>
<div class="sb-sec">
<div class="sb-title" data-i18n="sb_strategy">Strategy</div>
<div class="sb-list">
<button class="sb-item" onclick="send('/strategy list')"><div class="sb-ico p">🤖</div><span data-i18n="sb_active_strategies">Strategies</span></button>
<button class="sb-item" onclick="send('/strategy start BTC 1h')"><div class="sb-ico g">🚀</div><span data-i18n="sb_start_btc">Start BTC AI</span></button>
</div>
</div>
</div>
<!-- Chat Panel -->
<div class="chat" id="chatP">
<div class="msgs" id="msgs"></div>
<div class="typing" id="typ"><div class="dots"><span></span><span></span><span></span></div><span data-i18n="thinking">Thinking...</span></div>
<div class="ibar">
<div class="iwrap">
<input id="inp" data-i18n-placeholder="input_placeholder" placeholder="Ask NOFXi anything..." autocomplete="off">
<button class="sbtn" id="sBtn" onclick="send()"></button>
</div>
<div class="ihint"><kbd>Enter</kbd> <span data-i18n="input_hint">to send</span> · /help, /analyze BTC, /buy ETH 0.1</div>
</div>
</div>
<!-- Chart Panel -->
<div class="chart-p" id="chartP">
<div class="ch-hd">
<div class="ch-left">
<div class="ch-sym">
<select id="symSel" onchange="loadChart()">
<option value="BTCUSDT">BTC/USDT</option>
<option value="ETHUSDT">ETH/USDT</option>
<option value="SOLUSDT">SOL/USDT</option>
<option value="BNBUSDT">BNB/USDT</option>
<option value="XRPUSDT">XRP/USDT</option>
<option value="DOGEUSDT">DOGE/USDT</option>
</select>
</div>
<span class="ch-price" id="cPr">--</span>
<span class="ch-chg" id="cChg">--</span>
</div>
<div class="ch-tfs">
<button class="tf" onclick="setTF('5m')">5m</button>
<button class="tf" onclick="setTF('15m')">15m</button>
<button class="tf on" onclick="setTF('1h')">1H</button>
<button class="tf" onclick="setTF('4h')">4H</button>
<button class="tf" onclick="setTF('1d')">1D</button>
<button class="tf" onclick="setTF('1w')">1W</button>
</div>
</div>
<div class="ch-box" id="chBox"></div>
</div>
</div>
<script>
// === i18n ===
const I18N = {
en: {
tab_chat:'Chat', tab_chart:'Chart', tab_positions:'Positions',
sb_analysis:'Analysis', sb_btc_analysis:'BTC', sb_eth_analysis:'ETH', sb_price:'Price',
sb_portfolio:'Portfolio', sb_positions:'Positions', sb_balance:'Balance', sb_pnl:'P/L',
sb_monitor:'Monitor', sb_watch_btc:'Watch BTC', sb_watchlist:'Watchlist',
sb_strategy:'Strategy', sb_active_strategies:'Strategies', sb_start_btc:'Start BTC AI',
thinking:'Thinking...', input_placeholder:'Ask NOFXi anything...', input_hint:'to send',
welcome:"Hey! 👋 I'm NOFXi, your AI trading agent.\n\nI can analyze markets, track prices, manage trades, and run automated strategies.\n\nTry: /help or click the sidebar.",
online:'Online', offline:'Offline', connecting:'...',
info_24h_vol:'24h Vol', info_24h_high:'High', info_24h_low:'Low', info_trades:'Trades', info_funding:'Funding',
},
zh: {
tab_chat:'对话', tab_chart:'行情', tab_positions:'持仓',
sb_analysis:'快速分析', sb_btc_analysis:'BTC', sb_eth_analysis:'ETH', sb_price:'价格',
sb_portfolio:'投资组合', sb_positions:'持仓', sb_balance:'余额', sb_pnl:'盈亏',
sb_monitor:'行情监控', sb_watch_btc:'关注 BTC', sb_watchlist:'关注列表',
sb_strategy:'策略', sb_active_strategies:'运行中策略', sb_start_btc:'启动 BTC AI',
thinking:'思考中...', input_placeholder:'跟 NOFXi 聊点什么...', input_hint:'发送',
welcome:"你好!👋 我是 NOFXi你的 AI 交易 Agent。\n\n我可以分析市场、监控行情、管理交易、运行自动策略。\n\n试试: /help 或点击左侧按钮。",
online:'在线', offline:'离线', connecting:'...',
info_24h_vol:'24h 成交量', info_24h_high:'最高', info_24h_low:'最低', info_trades:'成交笔数', info_funding:'资金费率',
}
};
let lang = localStorage.getItem('nofxi_lang') || 'zh';
function t(key){ return I18N[lang][key] || I18N.en[key] || key; }
function applyLang(){
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
if(I18N[lang][k]) el.textContent = I18N[lang][k];
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const k = el.getAttribute('data-i18n-placeholder');
if(I18N[lang][k]) el.placeholder = I18N[lang][k];
});
document.getElementById('langBtn').textContent = lang === 'zh' ? 'EN' : '中文';
localStorage.setItem('nofxi_lang', lang);
}
function toggleLang(){
lang = lang === 'zh' ? 'en' : 'zh';
applyLang();
}
// === Status ===
const API = location.origin;
async function checkSt(){
try{
const r=await fetch(API+'/health');
await r.json();
document.getElementById('stBadge').className='badge ok';
document.getElementById('stText').textContent=t('online');
}catch{
document.getElementById('stBadge').className='badge err';
document.getElementById('stText').textContent=t('offline');
}
}
checkSt(); setInterval(checkSt,30000);
// === Ticker Info Bar ===
async function loadInfoBar(){
try{
const r=await fetch(API+'/api/ticker?symbol=BTCUSDT');
const d=await r.json();
if(!d.lastPrice) return;
const bar=document.getElementById('infoBar');
const vol=parseFloat(d.quoteVolume||0);
const hi=parseFloat(d.highPrice||0);
const lo=parseFloat(d.lowPrice||0);
const cnt=parseInt(d.count||0);
const chg=parseFloat(d.priceChangePercent||0);
bar.innerHTML=`
<div class="info-item">BTC/USDT <span class="val ${chg>=0?'up':'dn'}">$${parseFloat(d.lastPrice).toLocaleString('en',{minimumFractionDigits:2})}</span></div>
<div class="info-item">${t('info_24h_vol')} <span class="val">$${(vol/1e9).toFixed(2)}B</span></div>
<div class="info-item">${t('info_24h_high')} <span class="val">$${hi.toLocaleString('en',{minimumFractionDigits:2})}</span></div>
<div class="info-item">${t('info_24h_low')} <span class="val">$${lo.toLocaleString('en',{minimumFractionDigits:2})}</span></div>
<div class="info-item">${t('info_trades')} <span class="val">${(cnt/1e6).toFixed(1)}M</span></div>
`;
}catch{}
}
loadInfoBar(); setInterval(loadInfoBar,15000);
// === Chat ===
const msgsEl=document.getElementById('msgs');
const inpEl=document.getElementById('inp');
const typEl=document.getElementById('typ');
const sBtnEl=document.getElementById('sBtn');
function now(){return new Date().toLocaleTimeString(lang==='zh'?'zh-CN':'en-US',{hour:'2-digit',minute:'2-digit'})}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function addM(role,text){
const isU=role==='user';
const d=document.createElement('div');
d.className='m '+(isU?'u':'b');
d.innerHTML=`<div class="m-av">${isU?'👤':'⚡'}</div><div class="m-body"><div class="m-name">${isU?'You':'NOFXi'}</div><div class="m-bub">${esc(text)}</div><div class="m-t">${now()}</div></div>`;
msgsEl.appendChild(d);
msgsEl.scrollTop=msgsEl.scrollHeight;
}
// Welcome message
addM('bot', t('welcome'));
async function send(text){
if(!text){text=inpEl.value.trim();if(!text)return;inpEl.value=''}
addM('user',text);
typEl.classList.add('on');sBtnEl.disabled=true;inpEl.focus();
try{
const r=await fetch(API+'/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,user_id:1,lang:lang})});
const d=await r.json();
addM('bot',d.response||d.error||'No response');
}catch(e){addM('bot','⚠️ Error: '+e.message)}
typEl.classList.remove('on');sBtnEl.disabled=false;inpEl.focus();
}
// === Tabs ===
function go(tab){
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('on',(tab==='chat'&&i===0)||(tab==='chart'&&i===1)));
const cp=document.getElementById('chatP'), chp=document.getElementById('chartP');
if(tab==='chart'){cp.classList.add('hide');chp.classList.add('on');if(!chart)initChart();loadChart()}
else{cp.classList.remove('hide');chp.classList.remove('on');if(chTimer)clearInterval(chTimer)}
}
// === Chart ===
let chart=null,cSeries=null,vSeries=null,curTF='1h',chTimer=null;
function initChart(){
const box=document.getElementById('chBox');
chart=LightweightCharts.createChart(box,{
width:box.clientWidth,height:box.clientHeight,
layout:{background:{color:'#09090b'},textColor:'#5c5c72',fontFamily:"'JetBrains Mono',monospace",fontSize:10},
grid:{vertLines:{color:'#15151f'},horzLines:{color:'#15151f'}},
crosshair:{mode:LightweightCharts.CrosshairMode.Normal,vertLine:{color:'#2a2a3a',labelBackgroundColor:'#1a1a24'},horzLine:{color:'#2a2a3a',labelBackgroundColor:'#1a1a24'}},
rightPriceScale:{borderColor:'#1f1f2c',scaleMargins:{top:.08,bottom:.22}},
timeScale:{borderColor:'#1f1f2c',timeVisible:true,secondsVisible:false},
});
cSeries=chart.addCandlestickSeries({upColor:'#22c55e',downColor:'#ef4444',borderUpColor:'#22c55e',borderDownColor:'#ef4444',wickUpColor:'#22c55e',wickDownColor:'#ef4444'});
vSeries=chart.addHistogramSeries({priceFormat:{type:'volume'},priceScaleId:'vol'});
chart.priceScale('vol').applyOptions({scaleMargins:{top:.82,bottom:0}});
new ResizeObserver(()=>chart.applyOptions({width:box.clientWidth,height:box.clientHeight})).observe(box);
}
function setTF(tf){
curTF=tf;
document.querySelectorAll('.tf').forEach(b=>b.classList.toggle('on',b.textContent.toLowerCase()===tf||b.textContent===tf.toUpperCase()));
loadChart();
}
async function loadChart(){
const sym=document.getElementById('symSel').value;
try{
const[kr,tr]=await Promise.all([fetch(`${API}/api/klines?symbol=${sym}&interval=${curTF}&limit=300`),fetch(`${API}/api/ticker?symbol=${sym}`)]);
const klines=await kr.json(), ticker=await tr.json();
if(klines?.length){
cSeries.setData(klines.map(k=>({time:k.time,open:k.open,high:k.high,low:k.low,close:k.close})));
vSeries.setData(klines.map(k=>({time:k.time,value:k.volume,color:k.close>=k.open?'rgba(34,197,94,.25)':'rgba(239,68,68,.25)'})));
}
if(ticker?.lastPrice){
const p=parseFloat(ticker.lastPrice),c=parseFloat(ticker.priceChangePercent);
const pe=document.getElementById('cPr'),ce=document.getElementById('cChg');
pe.textContent='$'+p.toLocaleString('en',{minimumFractionDigits:2,maximumFractionDigits:p>100?2:4});
pe.className='ch-price '+(c>=0?'up':'dn');
ce.textContent=(c>=0?'+':'')+c.toFixed(2)+'%';
ce.className='ch-chg '+(c>=0?'up':'dn');
}
}catch(e){console.error(e)}
if(chTimer)clearInterval(chTimer);
chTimer=setInterval(loadChart,30000);
}
// IME composition guard
let composing = false;
inpEl.addEventListener('compositionstart', () => { composing = true; });
inpEl.addEventListener('compositionend', () => { composing = false; });
inpEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault();
send();
}
});
// Init
applyLang();
inpEl.focus();
</script>
</body>
</html>