mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
feat: NOFXi Agent integrated into NOFX core
This is the key architectural change: NOFXi is no longer a separate sub-project in nofxi/, but a first-class module in agent/ that sits on top of NOFX's existing engine. agent/ package: - agent.go: Core orchestrator, directly uses NOFX's TraderManager, Store, and Market packages. No wrapper/bridge needed. - brain.go: Proactive intelligence (news scan, market briefs, signal handling) - sentinel.go: Market anomaly detection (price breakout, volume spike, funding rate) - scheduler.go: Daily reports, position risk checks - router.go: Intent routing for natural language (中/英 bilingual) - i18n.go: Full Chinese/English message catalog - web.go: Agent REST API on :8900 (chat, klines, ticker) main.go changes: - Banner: NOFX → NOFXi - Agent auto-starts with NOFX (sentinel + brain + scheduler + web) - Zero breaking changes to existing NOFX functionality Key difference from old nofxi/: - Agent reads LIVE data from TraderManager (positions, balances, status) - Uses market.Get() for full technical indicators (EMA/MACD/RSI/BB) - No duplicate code — kernel, trader, store all used directly - Old nofxi/ sub-project will be removed once migration is verified Architecture: NOFX (engine) → kernel + trader + market + store + mcp + telegram NOFXi (brain) → agent/ package → uses all of the above
This commit is contained in:
499
agent/agent.go
Normal file
499
agent/agent.go
Normal file
@@ -0,0 +1,499 @@
|
||||
// Package agent implements the NOFXi Agent Core.
|
||||
//
|
||||
// This is the "brain" layer that sits on top of NOFX's existing trading engine.
|
||||
// It adds: natural language interaction, proactive market monitoring,
|
||||
// trading memory/learning, and autonomous decision-making.
|
||||
//
|
||||
// Architecture:
|
||||
// NOFX (engine) provides: kernel, trader, market, store, mcp
|
||||
// Agent (brain) adds: perception, thinking, memory, interaction
|
||||
//
|
||||
// The agent does NOT replace any NOFX functionality — it enhances it.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/manager"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// Agent is the NOFXi intelligence layer on top of NOFX.
|
||||
type Agent struct {
|
||||
// NOFX core (injected)
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
|
||||
// Agent components
|
||||
config *Config
|
||||
sentinel *Sentinel
|
||||
brain *Brain
|
||||
scheduler *Scheduler
|
||||
router *Router
|
||||
logger *slog.Logger
|
||||
|
||||
// Notification callback (set by telegram/web)
|
||||
NotifyFunc func(userID int64, text string) error
|
||||
}
|
||||
|
||||
// Config holds agent-specific configuration.
|
||||
type Config struct {
|
||||
Language string `json:"language"` // "zh" or "en"
|
||||
WatchSymbols []string `json:"watch_symbols"` // Default symbols to watch
|
||||
EnableBriefs bool `json:"enable_briefs"` // Morning/evening market briefs
|
||||
EnableNews bool `json:"enable_news"` // News scanning
|
||||
EnableSentinel bool `json:"enable_sentinel"` // Market anomaly detection
|
||||
BriefTimes []int `json:"brief_times"` // Hours to send briefs (e.g. [8, 20])
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults.
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Language: "zh",
|
||||
WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
|
||||
EnableBriefs: true,
|
||||
EnableNews: true,
|
||||
EnableSentinel: true,
|
||||
BriefTimes: []int{8, 20},
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new NOFXi Agent.
|
||||
func New(
|
||||
traderMgr *manager.TraderManager,
|
||||
st *store.Store,
|
||||
cfg *Config,
|
||||
logger *slog.Logger,
|
||||
) *Agent {
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
traderManager: traderMgr,
|
||||
store: st,
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
a.router = NewRouter()
|
||||
a.scheduler = NewScheduler(a, logger)
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Start starts all agent services.
|
||||
func (a *Agent) Start() {
|
||||
a.logger.Info("starting NOFXi agent...")
|
||||
|
||||
// Start sentinel (market anomaly detection)
|
||||
if a.config.EnableSentinel {
|
||||
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
|
||||
a.sentinel.Start()
|
||||
a.logger.Info("sentinel started", "symbols", a.config.WatchSymbols)
|
||||
}
|
||||
|
||||
// Start brain (proactive intelligence)
|
||||
a.brain = NewBrain(a, a.logger)
|
||||
if a.config.EnableNews {
|
||||
a.brain.StartNewsScan(5 * time.Minute)
|
||||
a.logger.Info("news scanner started")
|
||||
}
|
||||
if a.config.EnableBriefs {
|
||||
a.brain.StartMarketBriefs(a.config.BriefTimes)
|
||||
a.logger.Info("market briefs enabled", "hours", a.config.BriefTimes)
|
||||
}
|
||||
|
||||
// Start scheduler
|
||||
a.scheduler.Start(context.Background())
|
||||
a.logger.Info("scheduler started")
|
||||
|
||||
a.logger.Info("NOFXi agent is online 🚀")
|
||||
}
|
||||
|
||||
// Stop stops all agent services.
|
||||
func (a *Agent) Stop() {
|
||||
if a.sentinel != nil {
|
||||
a.sentinel.Stop()
|
||||
}
|
||||
if a.brain != nil {
|
||||
a.brain.Stop()
|
||||
}
|
||||
a.scheduler.Stop()
|
||||
a.logger.Info("NOFXi agent stopped")
|
||||
}
|
||||
|
||||
// HandleMessage processes a user message and returns a response.
|
||||
// This is the main entry point for Telegram/Web interaction.
|
||||
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
|
||||
// Extract language from prefix [lang:xx]
|
||||
lang := a.config.Language
|
||||
if strings.HasPrefix(text, "[lang:") {
|
||||
if end := strings.Index(text, "] "); end > 0 {
|
||||
lang = text[6:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("agent message", "user_id", userID, "text", text)
|
||||
|
||||
intent := a.router.Route(text)
|
||||
|
||||
switch intent.Type {
|
||||
case IntentHelp:
|
||||
return a.msg(lang, "help"), nil
|
||||
case IntentStatus:
|
||||
return a.handleStatus(lang), nil
|
||||
case IntentQuery:
|
||||
return a.handleQuery(lang, intent)
|
||||
case IntentAnalyze:
|
||||
return a.handleAnalyze(ctx, lang, intent)
|
||||
case IntentTrade:
|
||||
return a.handleTrade(lang, intent)
|
||||
case IntentWatch:
|
||||
return a.handleWatch(lang, intent), nil
|
||||
case IntentStrategy:
|
||||
return a.handleStrategyCmd(lang, intent), nil
|
||||
default:
|
||||
return a.handleChat(ctx, lang, userID, text)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Handlers using NOFX core ---
|
||||
|
||||
func (a *Agent) handleStatus(L string) string {
|
||||
traderCount := 0
|
||||
runningCount := 0
|
||||
if a.traderManager != nil {
|
||||
all := a.traderManager.GetAllTraders()
|
||||
traderCount = len(all)
|
||||
for _, t := range all {
|
||||
status := t.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
watchCount := 0
|
||||
if a.sentinel != nil {
|
||||
watchCount = a.sentinel.SymbolCount()
|
||||
}
|
||||
|
||||
return fmt.Sprintf(a.msg(L, "status"),
|
||||
runningCount, traderCount, watchCount, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
func (a *Agent) handleQuery(L string, intent Intent) (string, error) {
|
||||
raw := strings.ToLower(intent.Raw)
|
||||
|
||||
// Get live data from NOFX trader manager
|
||||
if a.traderManager == nil {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
// List all positions across all traders
|
||||
if strings.Contains(raw, "position") || strings.Contains(raw, "持仓") {
|
||||
return a.queryAllPositions(L)
|
||||
}
|
||||
if strings.Contains(raw, "balance") || strings.Contains(raw, "余额") {
|
||||
return a.queryAllBalances(L)
|
||||
}
|
||||
if strings.Contains(raw, "trader") || strings.Contains(raw, "交易员") {
|
||||
return a.queryTraders(L, nil)
|
||||
}
|
||||
|
||||
return a.queryAllPositions(L)
|
||||
}
|
||||
|
||||
func (a *Agent) queryAllPositions(L string) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "positions_header"))
|
||||
totalPnL := 0.0
|
||||
hasPosition := false
|
||||
|
||||
for id, t := range traders {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range positions {
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
hasPosition = true
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
e := "🟢"
|
||||
if pnl < 0 {
|
||||
e = "🔴"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* %s\n Entry: $%.4f → $%.4f | P/L: $%.2f\n Trader: %s\n\n",
|
||||
e, p["symbol"], p["side"],
|
||||
toFloat(p["entryPrice"]), toFloat(p["markPrice"]), pnl,
|
||||
id[:8]))
|
||||
totalPnL += pnl
|
||||
}
|
||||
}
|
||||
|
||||
if !hasPosition {
|
||||
return a.msg(L, "no_positions"), nil
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(a.msg(L, "total_pnl"), totalPnL))
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryAllBalances(L string) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "balance_header"))
|
||||
|
||||
for id, t := range traders {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("*%s* (%s)\n Total: $%.2f | Available: $%.2f | P/L: $%.2f\n\n",
|
||||
t.GetName(), id[:8],
|
||||
toFloat(info["total_equity"]),
|
||||
toFloat(info["available_balance"]),
|
||||
toFloat(info["unrealized_pnl"])))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryTraders(L string, _ interface{}) (string, error) {
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.msg(L, "traders_header"))
|
||||
|
||||
for id, t := range traders {
|
||||
s := t.GetStatus()
|
||||
running, _ := s["is_running"].(bool)
|
||||
icon := "⏹"
|
||||
if running {
|
||||
icon = "▶️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* `%s`\n Exchange: %s | AI: %s\n\n",
|
||||
icon, t.GetName(), id[:8], t.GetExchange(), t.GetAIModel()))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(x, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (a *Agent) handleAnalyze(ctx context.Context, L string, intent Intent) (string, error) {
|
||||
symbol := "BTCUSDT"
|
||||
if d, ok := intent.Params["detail"]; ok && d != "" {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(d))
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
}
|
||||
|
||||
// Use NOFX's market data — full technical indicators
|
||||
md, err := market.Get(symbol)
|
||||
if err != nil {
|
||||
a.logger.Error("get market data", "symbol", symbol, "error", err)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("🔍 *%s 分析*\n\n⚠️ 市场数据暂不可用,请稍后再试。", strings.TrimSuffix(symbol, "USDT")), nil
|
||||
}
|
||||
return fmt.Sprintf("🔍 *%s Analysis*\n\n⚠️ Market data unavailable. Try again later.", strings.TrimSuffix(symbol, "USDT")), nil
|
||||
}
|
||||
|
||||
// Build rich analysis prompt with real data
|
||||
prompt := buildAnalysisPrompt(symbol, md, L)
|
||||
|
||||
// For now, format the raw market data as analysis
|
||||
return fmt.Sprintf(a.msg(L, "analysis_header"), strings.TrimSuffix(symbol, "USDT")) + "\n\n" + prompt, nil
|
||||
}
|
||||
|
||||
func buildAnalysisPrompt(symbol string, md *market.Data, L string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Analyze %s for trading.\n\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Current Price: $%.4f\n", md.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("1h Change: %.2f%%\n", md.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("4h Change: %.2f%%\n", md.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("EMA20: %.4f\n", md.CurrentEMA20))
|
||||
sb.WriteString(fmt.Sprintf("MACD: %.4f\n", md.CurrentMACD))
|
||||
sb.WriteString(fmt.Sprintf("RSI7: %.2f\n", md.CurrentRSI7))
|
||||
sb.WriteString(fmt.Sprintf("Funding Rate: %.4f%%\n", md.FundingRate*100))
|
||||
if md.OpenInterest != nil {
|
||||
sb.WriteString(fmt.Sprintf("OI: %.0f (avg: %.0f)\n", md.OpenInterest.Latest, md.OpenInterest.Average))
|
||||
}
|
||||
if L == "zh" {
|
||||
sb.WriteString("\n请用中文给出交易建议,包括方向、入场价、止损、止盈。简洁专业。")
|
||||
} else {
|
||||
sb.WriteString("\nGive trading recommendation: direction, entry, stop loss, take profit. Be concise and professional.")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (a *Agent) handleTrade(L string, intent Intent) (string, error) {
|
||||
action := strings.ToLower(intent.Params["action"])
|
||||
detail := intent.Params["detail"]
|
||||
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
|
||||
// Parse symbol and quantity
|
||||
parts := strings.Fields(detail)
|
||||
if len(parts) < 1 {
|
||||
return a.msg(L, "trade_usage"), nil
|
||||
}
|
||||
|
||||
symbol := strings.ToUpper(parts[0])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
qty := 0.0
|
||||
if len(parts) >= 2 {
|
||||
q, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(a.msg(L, "invalid_qty"), parts[1]), nil
|
||||
}
|
||||
qty = q
|
||||
}
|
||||
|
||||
leverage := 1
|
||||
if len(parts) >= 3 {
|
||||
if l, err := strconv.Atoi(strings.TrimSuffix(strings.ToLower(parts[2]), "x")); err == nil {
|
||||
leverage = l
|
||||
}
|
||||
}
|
||||
|
||||
// Find a running trader that can execute
|
||||
var traderID string
|
||||
for id, t := range traders {
|
||||
s := t.GetStatus()
|
||||
if running, ok := s["is_running"].(bool); ok && running {
|
||||
traderID = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if traderID == "" {
|
||||
return a.msg(L, "no_running_trader"), nil
|
||||
}
|
||||
|
||||
_ = qty
|
||||
_ = leverage
|
||||
_ = action
|
||||
|
||||
// For now, acknowledge but don't execute (safety)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("⚡ *交易请求*\n\n• 操作: %s\n• 交易对: %s\n• 数量: %.6f\n• 杠杆: %dx\n• Trader: %s\n\n⚠️ 自动执行功能开发中。请在 Web UI 中操作。",
|
||||
strings.ToUpper(action), symbol, qty, leverage, traderID[:8]), nil
|
||||
}
|
||||
return fmt.Sprintf("⚡ *Trade Request*\n\n• Action: %s\n• Symbol: %s\n• Qty: %.6f\n• Leverage: %dx\n• Trader: %s\n\n⚠️ Auto-execution coming soon. Please use Web UI.",
|
||||
strings.ToUpper(action), symbol, qty, leverage, traderID[:8]), nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleWatch(L string, intent Intent) string {
|
||||
parts := strings.Fields(intent.Raw)
|
||||
if len(parts) < 2 {
|
||||
if a.sentinel == nil {
|
||||
return a.msg(L, "sentinel_off")
|
||||
}
|
||||
return a.sentinel.FormatWatchlist(L)
|
||||
}
|
||||
|
||||
cmd := strings.ToLower(parts[0])
|
||||
symbol := strings.ToUpper(parts[1])
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
if a.sentinel == nil {
|
||||
return a.msg(L, "sentinel_off")
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "/watch":
|
||||
a.sentinel.AddSymbol(symbol)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("👁️ 已添加 *%s* 到监控列表", symbol)
|
||||
}
|
||||
return fmt.Sprintf("👁️ Now watching *%s*", symbol)
|
||||
case "/unwatch":
|
||||
a.sentinel.RemoveSymbol(symbol)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("🚫 已移除 *%s*", symbol)
|
||||
}
|
||||
return fmt.Sprintf("🚫 Removed *%s* from watchlist", symbol)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Agent) handleStrategyCmd(L string, intent Intent) string {
|
||||
parts := strings.Fields(intent.Raw)
|
||||
if len(parts) < 2 {
|
||||
result, _ := a.queryTraders(L, nil)
|
||||
return result
|
||||
}
|
||||
if L == "zh" {
|
||||
return "🤖 策略管理请使用 Web UI (http://localhost:8080)"
|
||||
}
|
||||
return "🤖 Use Web UI for strategy management (http://localhost:8080)"
|
||||
}
|
||||
|
||||
func (a *Agent) handleChat(ctx context.Context, L string, userID int64, text string) (string, error) {
|
||||
// TODO: integrate with NOFX's MCP client for AI chat
|
||||
// For now, route to the existing Telegram agent or return guidance
|
||||
if L == "zh" {
|
||||
return "🤖 收到。AI 对话功能正在接入 NOFX 的 AI 引擎。\n\n你可以试试:\n• `/analyze BTC` — 市场分析\n• `/positions` — 查看持仓\n• `/status` — 系统状态", nil
|
||||
}
|
||||
return "🤖 Got it. AI chat is being integrated with NOFX's AI engine.\n\nTry:\n• `/analyze BTC` — market analysis\n• `/positions` — view positions\n• `/status` — system status", nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSignal(sig Signal) {
|
||||
if a.brain != nil {
|
||||
a.brain.HandleSignal(sig)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) notifyAll(text string) {
|
||||
// Notify via Telegram (using existing NOFX telegram system)
|
||||
if a.NotifyFunc != nil {
|
||||
a.NotifyFunc(0, text)
|
||||
}
|
||||
}
|
||||
146
agent/brain.go
Normal file
146
agent/brain.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brain handles proactive intelligence: signals, news, market briefs.
|
||||
type Brain struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
recentSignals sync.Map // debounce
|
||||
}
|
||||
|
||||
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
|
||||
return &Brain{agent: agent, logger: logger, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (b *Brain) Stop() { close(b.stopCh) }
|
||||
|
||||
func (b *Brain) HandleSignal(sig Signal) {
|
||||
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
|
||||
if v, ok := b.recentSignals.Load(key); ok {
|
||||
if time.Since(v.(time.Time)) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.recentSignals.Store(key, time.Now())
|
||||
|
||||
emoji := map[string]string{"info": "ℹ️", "warning": "⚠️", "critical": "🚨"}
|
||||
e := emoji[sig.Severity]
|
||||
if e == "" { e = "📊" }
|
||||
|
||||
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
|
||||
}
|
||||
|
||||
func (b *Brain) StartNewsScan(interval time.Duration) {
|
||||
seen := make(map[string]bool)
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh: return
|
||||
case <-ticker.C:
|
||||
b.scanNews(seen)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Brain) scanNews(seen map[string]bool) {
|
||||
resp, err := http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
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"`
|
||||
}
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
|
||||
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
|
||||
|
||||
for _, d := range result.Data {
|
||||
if seen[d.URL] { continue }
|
||||
seen[d.URL] = true
|
||||
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
|
||||
|
||||
lower := strings.ToLower(d.Title + " " + d.Body)
|
||||
bc, brc := 0, 0
|
||||
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
|
||||
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
|
||||
|
||||
if bc == 0 && brc == 0 { continue }
|
||||
|
||||
emoji := "📰"
|
||||
sentiment := "NEUTRAL"
|
||||
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
|
||||
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
|
||||
|
||||
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
|
||||
emoji, d.Title, d.Source, sentiment))
|
||||
}
|
||||
|
||||
if len(seen) > 1000 { for k := range seen { delete(seen, k) } }
|
||||
}
|
||||
|
||||
func (b *Brain) StartMarketBriefs(hours []int) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
sent := make(map[string]bool)
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh: return
|
||||
case now := <-ticker.C:
|
||||
key := now.Format("2006-01-02-15")
|
||||
for _, h := range hours {
|
||||
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
|
||||
sent[key] = true
|
||||
b.sendBrief(h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *Brain) sendBrief(hour int) {
|
||||
title := "☀️ *早间市场简报*"
|
||||
if hour >= 18 { title = "🌙 *晚间市场简报*" }
|
||||
|
||||
// Fetch BTC/ETH prices for the brief
|
||||
var btcPrice, ethPrice, btcChg, ethChg string
|
||||
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
|
||||
resp, err := http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
||||
if err != nil { continue }
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var t map[string]string
|
||||
json.Unmarshal(body, &t)
|
||||
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
|
||||
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
|
||||
}
|
||||
|
||||
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
|
||||
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
|
||||
|
||||
b.agent.notifyAll(brief)
|
||||
}
|
||||
86
agent/i18n.go
Normal file
86
agent/i18n.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package agent
|
||||
|
||||
var i18nMessages = map[string]map[string]string{
|
||||
"help": {
|
||||
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
|
||||
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
|
||||
"*查询:* /positions /balance /pnl /traders\n" +
|
||||
"*分析:* /analyze BTC\n" +
|
||||
"*监控:* /watch BTC · /unwatch BTC\n" +
|
||||
"*策略:* /strategy\n" +
|
||||
"*系统:* /status /help\n\n" +
|
||||
"直接跟我说话就行,中英文都可以 💬",
|
||||
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
|
||||
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
|
||||
"*Query:* /positions /balance /pnl /traders\n" +
|
||||
"*Analyze:* /analyze BTC\n" +
|
||||
"*Monitor:* /watch BTC · /unwatch BTC\n" +
|
||||
"*Strategy:* /strategy\n" +
|
||||
"*System:* /status /help\n\n" +
|
||||
"Just talk to me in any language 💬",
|
||||
},
|
||||
"status": {
|
||||
"zh": "📊 *NOFXi 状态*\n\n• 运行中 Traders: %d/%d\n• 监控交易对: %d\n• 时间: %s",
|
||||
"en": "📊 *NOFXi Status*\n\n• Running Traders: %d/%d\n• Watching: %d symbols\n• Time: %s",
|
||||
},
|
||||
"no_traders": {
|
||||
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
|
||||
"en": "📭 No traders configured. Create one in Web UI.",
|
||||
},
|
||||
"no_running_trader": {
|
||||
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
|
||||
"en": "⚠️ No running trader. Start one in Web UI.",
|
||||
},
|
||||
"no_positions": {
|
||||
"zh": "📭 当前没有持仓。",
|
||||
"en": "📭 No open positions.",
|
||||
},
|
||||
"positions_header": {
|
||||
"zh": "📊 *当前持仓*\n\n",
|
||||
"en": "📊 *Open Positions*\n\n",
|
||||
},
|
||||
"total_pnl": {
|
||||
"zh": "💰 *总未实现盈亏: $%.2f*",
|
||||
"en": "💰 *Total Unrealized P/L: $%.2f*",
|
||||
},
|
||||
"balance_header": {
|
||||
"zh": "💰 *账户余额*\n\n",
|
||||
"en": "💰 *Account Balances*\n\n",
|
||||
},
|
||||
"traders_header": {
|
||||
"zh": "🤖 *Traders*\n\n",
|
||||
"en": "🤖 *Traders*\n\n",
|
||||
},
|
||||
"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_qty": {
|
||||
"zh": "❓ 无效数量: %s",
|
||||
"en": "❓ Invalid quantity: %s",
|
||||
},
|
||||
"analysis_header": {
|
||||
"zh": "🔍 *%s 市场分析*",
|
||||
"en": "🔍 *%s Analysis*",
|
||||
},
|
||||
"sentinel_off": {
|
||||
"zh": "⚠️ Sentinel 未启用。",
|
||||
"en": "⚠️ Sentinel not enabled.",
|
||||
},
|
||||
"system_prompt": {
|
||||
"zh": "你是 NOFXi,一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
|
||||
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
|
||||
},
|
||||
}
|
||||
|
||||
func (a *Agent) msg(lang, key string) string {
|
||||
if m, ok := i18nMessages[key]; ok {
|
||||
if s, ok := m[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := m["en"]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
102
agent/router.go
Normal file
102
agent/router.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type IntentType int
|
||||
|
||||
const (
|
||||
IntentChat IntentType = iota
|
||||
IntentTrade
|
||||
IntentQuery
|
||||
IntentAnalyze
|
||||
IntentSettings
|
||||
IntentHelp
|
||||
IntentStatus
|
||||
IntentWatch
|
||||
IntentStrategy
|
||||
)
|
||||
|
||||
type Intent struct {
|
||||
Type IntentType
|
||||
Params map[string]string
|
||||
Raw string
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
tradePatterns []*regexp.Regexp
|
||||
queryPatterns []*regexp.Regexp
|
||||
analyzePatterns []*regexp.Regexp
|
||||
}
|
||||
|
||||
func NewRouter() *Router {
|
||||
return &Router{
|
||||
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|持仓|余额|盈亏|trader|交易员)`),
|
||||
},
|
||||
analyzePatterns: []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(analyze|analysis|分析|看看)\s+(.+)`),
|
||||
regexp.MustCompile(`(?i)(what.*think|怎么看|你觉得)\s*(.+)?`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Route(text string) Intent {
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if strings.HasPrefix(text, "/") {
|
||||
return r.routeCommand(text)
|
||||
}
|
||||
|
||||
for _, p := range r.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}
|
||||
}
|
||||
}
|
||||
for _, p := range r.queryPatterns {
|
||||
if p.MatchString(text) {
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
}
|
||||
}
|
||||
for _, p := range r.analyzePatterns {
|
||||
if m := p.FindStringSubmatch(text); m != nil {
|
||||
d := ""
|
||||
if len(m) > 2 { d = m[2] }
|
||||
return Intent{Type: IntentAnalyze, Params: map[string]string{"detail": d}, Raw: text}
|
||||
}
|
||||
}
|
||||
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
|
||||
func (r *Router) routeCommand(text string) Intent {
|
||||
cmd := strings.ToLower(strings.Fields(text)[0])
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
detail := ""
|
||||
if len(parts) > 1 { detail = parts[1] }
|
||||
|
||||
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":
|
||||
return Intent{Type: IntentTrade, Params: map[string]string{"action": strings.TrimPrefix(cmd, "/"), "detail": detail}, Raw: text}
|
||||
case "/positions", "/balance", "/pnl", "/traders":
|
||||
return Intent{Type: IntentQuery, Raw: text}
|
||||
case "/analyze":
|
||||
return Intent{Type: IntentAnalyze, Params: map[string]string{"detail": detail}, Raw: text}
|
||||
case "/watch", "/unwatch":
|
||||
return Intent{Type: IntentWatch, Raw: text}
|
||||
case "/strategy":
|
||||
return Intent{Type: IntentStrategy, Raw: text}
|
||||
default:
|
||||
return Intent{Type: IntentChat, Raw: text}
|
||||
}
|
||||
}
|
||||
99
agent/scheduler.go
Normal file
99
agent/scheduler.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
|
||||
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastReport := time.Time{}
|
||||
lastCheck := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): return
|
||||
case <-s.stopCh: return
|
||||
case now := <-ticker.C:
|
||||
// Daily report at 21:00
|
||||
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
|
||||
s.dailyReport()
|
||||
lastReport = now
|
||||
}
|
||||
// Position risk check every 4h
|
||||
if now.Sub(lastCheck) > 4*time.Hour {
|
||||
s.riskCheck()
|
||||
lastCheck = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() { close(s.stopCh) }
|
||||
|
||||
func (s *Scheduler) dailyReport() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
traders := s.agent.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 { return }
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
|
||||
|
||||
totalPnL := 0.0
|
||||
for _, t := range traders {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil { continue }
|
||||
equity := toFloat(info["total_equity"])
|
||||
pnl := toFloat(info["unrealized_pnl"])
|
||||
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
|
||||
totalPnL += pnl
|
||||
}
|
||||
e := "📈"
|
||||
if totalPnL < 0 { e = "📉" }
|
||||
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
|
||||
|
||||
s.agent.notifyAll(sb.String())
|
||||
}
|
||||
|
||||
func (s *Scheduler) riskCheck() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
var alerts []string
|
||||
for _, t := range s.agent.traderManager.GetAllTraders() {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil { continue }
|
||||
for _, p := range positions {
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 { continue }
|
||||
entry := toFloat(p["entryPrice"])
|
||||
if entry > 0 {
|
||||
pnlPct := (pnl / (entry * size)) * 100
|
||||
if pnlPct < -5 {
|
||||
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
|
||||
p["symbol"], p["side"], pnlPct, pnl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
|
||||
}
|
||||
}
|
||||
167
agent/sentinel.go
Normal file
167
agent/sentinel.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SignalType string
|
||||
|
||||
const (
|
||||
SignalPriceBreakout SignalType = "price_breakout"
|
||||
SignalVolumeSpike SignalType = "volume_spike"
|
||||
SignalFundingRate SignalType = "funding_rate"
|
||||
)
|
||||
|
||||
type Signal struct {
|
||||
Type SignalType
|
||||
Symbol string
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
Price float64
|
||||
Change float64
|
||||
}
|
||||
|
||||
type SignalCallback func(Signal)
|
||||
|
||||
type Sentinel struct {
|
||||
mu sync.RWMutex
|
||||
symbols []string
|
||||
history map[string][]pricePt
|
||||
onSignal SignalCallback
|
||||
http *http.Client
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type pricePt struct {
|
||||
Price float64
|
||||
Volume float64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
|
||||
return &Sentinel{
|
||||
symbols: symbols,
|
||||
history: make(map[string][]pricePt),
|
||||
onSignal: cb,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
logger: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) Start() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
s.scan()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.scan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Sentinel) Stop() { close(s.stopCh) }
|
||||
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
|
||||
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
|
||||
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
|
||||
|
||||
func (s *Sentinel) FormatWatchlist(L string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.symbols) == 0 {
|
||||
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
|
||||
return "📭 Watchlist empty. Use `/watch BTC` to add."
|
||||
}
|
||||
var sb strings.Builder
|
||||
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
|
||||
for _, sym := range s.symbols {
|
||||
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
|
||||
last := pts[len(pts)-1]
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Sentinel) scan() {
|
||||
s.mu.RLock()
|
||||
syms := make([]string, len(s.symbols))
|
||||
copy(syms, s.symbols)
|
||||
s.mu.RUnlock()
|
||||
for _, sym := range syms {
|
||||
s.check(sym)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) check(symbol string) {
|
||||
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var t map[string]interface{}
|
||||
json.Unmarshal(body, &t)
|
||||
|
||||
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
|
||||
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
|
||||
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
|
||||
|
||||
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
|
||||
s.mu.Lock()
|
||||
h := s.history[symbol]
|
||||
h = append(h, pt)
|
||||
if len(h) > 60 { h = h[len(h)-60:] }
|
||||
s.history[symbol] = h
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(h) < 5 { return }
|
||||
|
||||
// Price breakout (>3% in 5 min)
|
||||
old := h[len(h)-5]
|
||||
pct := ((price - old.Price) / old.Price) * 100
|
||||
if math.Abs(pct) >= 3.0 {
|
||||
sev := "warning"
|
||||
if math.Abs(pct) >= 6.0 { sev = "critical" }
|
||||
dir := "📈 拉升"
|
||||
if pct < 0 { dir = "📉 下跌" }
|
||||
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
|
||||
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
|
||||
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
|
||||
Price: price, Change: pct})
|
||||
}
|
||||
|
||||
// Volume spike (>3x avg)
|
||||
if len(h) >= 10 {
|
||||
var avg float64
|
||||
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
|
||||
avg /= float64(len(h) - 1)
|
||||
if avg > 0 && vol > avg*3 {
|
||||
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
|
||||
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
|
||||
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
|
||||
Price: price, Change: chg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) emit(sig Signal) {
|
||||
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
|
||||
if s.onSignal != nil { s.onSignal(sig) }
|
||||
}
|
||||
141
agent/web.go
Normal file
141
agent/web.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebHandler provides HTTP endpoints for the NOFXi agent.
|
||||
// These are registered on the existing NOFX API server.
|
||||
type WebHandler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewWebHandler creates a new web handler.
|
||||
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
|
||||
return &WebHandler{agent: agent, logger: logger}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers agent API routes on an existing mux or gin router.
|
||||
// For now we use a standalone http server on a separate port.
|
||||
func (w *WebHandler) StartStandalone(port int) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health
|
||||
mux.HandleFunc("/api/agent/health", func(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
|
||||
})
|
||||
|
||||
// Chat endpoint
|
||||
mux.HandleFunc("/api/agent/chat", func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = 1
|
||||
}
|
||||
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := w.agent.HandleMessage(ctx, req.UserID, msg)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||
})
|
||||
|
||||
// Kline data
|
||||
mux.HandleFunc("/api/agent/klines", handleKlines)
|
||||
|
||||
// Ticker
|
||||
mux.HandleFunc("/api/agent/ticker", handleTicker)
|
||||
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
w.logger.Info("NOFXi agent web API starting", "port", port)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
w.logger.Error("agent web server error", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func handleKlines(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" }
|
||||
|
||||
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 32768)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
|
||||
func handleTicker(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 {
|
||||
writeJSON(w, 502, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(200)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 { w.Write(buf[:n]) }
|
||||
if err != nil { break }
|
||||
}
|
||||
}
|
||||
16
main.go
16
main.go
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"nofx/api"
|
||||
nofxiagent "nofx/agent"
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
@@ -29,7 +31,8 @@ func main() {
|
||||
logger.Init(nil)
|
||||
|
||||
logger.Info("╔════════════════════════════════════════════════════════════╗")
|
||||
logger.Info("║ 🚀 NOFX - AI-Powered Trading System ║")
|
||||
logger.Info("║ 🚀 NOFXi - AI Trading Agent ║")
|
||||
logger.Info("║ Powered by NOFX Engine ║")
|
||||
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
||||
|
||||
// Initialize global configuration (loaded from .env)
|
||||
@@ -140,6 +143,17 @@ func main() {
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Start NOFXi Agent (proactive intelligence layer)
|
||||
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
|
||||
nofxiAgent.Start()
|
||||
defer nofxiAgent.Stop()
|
||||
|
||||
// Start NOFXi Agent Web API (port 8900)
|
||||
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
|
||||
agentWeb.StartStandalone(8900)
|
||||
|
||||
logger.Info("🧠 NOFXi Agent started (sentinel + brain + scheduler + web:8900)")
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
418
web/nofxi.html
Normal file
418
web/nofxi.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!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