mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
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:
13
nofxi/.gitignore
vendored
13
nofxi/.gitignore
vendored
@@ -1,13 +0,0 @@
|
||||
# Binary
|
||||
nofxi
|
||||
|
||||
# Config (contains secrets)
|
||||
config.yaml
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
143
nofxi/README.md
143
nofxi/README.md
@@ -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(引擎) | NOFXi(Agent) |
|
||||
|:--|:--|:--|
|
||||
| **交互方式** | 配置 → 启动 → 看仪表盘 | "帮我开 BTC 多单,2x 杠杆,3% 止损" |
|
||||
| **智能程度** | AI 做交易决策 | AI 理解上下文,从历史中学习 |
|
||||
| **记忆** | 每次无状态 | 记住你的偏好、交易历史、教训 |
|
||||
| **主动性** | 被触发时执行 | 主动监控市场、通知你、自主行动 |
|
||||
| **沟通方式** | 仪表盘和日志 | Telegram 自然语言对话、主动推送 |
|
||||
|
||||
### 核心能力
|
||||
|
||||
- 🧠 **Agent 核心** — 上下文对话、意图识别、记忆管理
|
||||
- 👁️ **感知层** — 市场监控、异动检测、持仓监控、舆情分析
|
||||
- 💭 **思考层** — 多模型 AI 决策、策略匹配、风险评估
|
||||
- 📝 **记忆层** — 交易历史、策略效果、用户偏好、市场规律
|
||||
- ⚡ **执行层** — 多交易所下单、仓位管理、x402 支付
|
||||
- 💬 **交互层** — 自然语言交易、主动通知、报告生成
|
||||
|
||||
### 技术架构
|
||||
|
||||
```
|
||||
交互层(Telegram / Web UI / API)
|
||||
│
|
||||
Agent Core(对话理解 + 意图识别 + 上下文管理)
|
||||
│
|
||||
┌────┴───────────┬────────────┐
|
||||
│ │ │
|
||||
感知层 思考层 记忆层
|
||||
(市场监控) (AI 决策引擎) (经验库)
|
||||
│ │ │
|
||||
└────┬───────────┴────────────┘
|
||||
│
|
||||
执行层(多交易所 + x402 支付)
|
||||
```
|
||||
|
||||
### 构建基础
|
||||
|
||||
- **[NOFX](https://github.com/NoFxAiOS/nofx)** — 开源 AI 交易引擎(9 家交易所,7+ AI 模型)
|
||||
- **[Claw402](https://claw402.ai)** — x402 支付网关(USDC 微支付)
|
||||
- **[x402](https://x402.org)** — USDC 按量付费,无需 API Key
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/NoFxAiOS/nofxi.git
|
||||
cd nofxi
|
||||
|
||||
# Build
|
||||
go build ./...
|
||||
|
||||
# Run
|
||||
go run cmd/nofxi/main.go
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Proprietary. All rights reserved.
|
||||
|
||||
© 2026 NoFxAiOS
|
||||
@@ -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: ""
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user