Files
nofx/agent/agent.go
shinchan-zhai 3ad71b025d fix: AI no longer fabricates prices for assets without real-time data
System prompt now explicitly:
- Lists which assets have real-time data (crypto) and which don't (stocks/forex)
- FORBIDS fabricating specific prices
- Requires disclosure: 'based on historical knowledge, not live data'
- Allows strategy frameworks but not fake price levels

Tested: 拓维信息 → now says '我没有A股实时数据' upfront, gives
strategy framework without fake prices. Honesty > confidence.
2026-03-25 01:05:54 +08:00

379 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package agent implements the NOFXi Agent Core.
//
// Architecture: ALL user messages go to the LLM. The LLM understands intent
// and calls tools to execute actions. No regex routing, no pattern matching.
// The LLM IS the brain — just like how OpenClaw works.
package agent
import (
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"nofx/manager"
"nofx/market"
"nofx/mcp"
"nofx/store"
)
type Agent struct {
traderManager *manager.TraderManager
store *store.Store
aiClient mcp.AIClient
config *Config
sentinel *Sentinel
brain *Brain
scheduler *Scheduler
logger *slog.Logger
NotifyFunc func(userID int64, text string) error
}
type Config struct {
Language string `json:"language"`
WatchSymbols []string `json:"watch_symbols"`
EnableBriefs bool `json:"enable_briefs"`
EnableNews bool `json:"enable_news"`
EnableSentinel bool `json:"enable_sentinel"`
BriefTimes []int `json:"brief_times"`
}
func DefaultConfig() *Config {
return &Config{
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
}
}
func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent {
if cfg == nil { cfg = DefaultConfig() }
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger}
}
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
func (a *Agent) EnsureAIClient() {
if a.aiClient != nil { return }
if a.store != nil {
models, err := a.store.AIModel().List("default")
if err == nil {
for _, m := range models {
apiKey := string(m.APIKey)
if apiKey != "" && m.CustomAPIURL != "" {
// Use standard HTTP client (no SSRF protection) since we control the URLs
httpClient := &http.Client{Timeout: 60 * time.Second}
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
name := m.CustomModelName
if name == "" { name = m.ID }
client.SetAPIKey(apiKey, m.CustomAPIURL, name)
a.aiClient = client
a.logger.Info("agent AI client ready", "model", name)
return
}
}
}
}
a.logger.Warn("no AI client — agent will have limited capabilities")
}
func (a *Agent) Start() {
a.logger.Info("starting NOFXi agent...")
a.EnsureAIClient()
if a.config.EnableSentinel {
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
a.sentinel.Start()
}
a.brain = NewBrain(a, a.logger)
if a.config.EnableNews { a.brain.StartNewsScan(5 * time.Minute) }
if a.config.EnableBriefs { a.brain.StartMarketBriefs(a.config.BriefTimes) }
a.scheduler = NewScheduler(a, a.logger)
a.scheduler.Start(context.Background())
a.logger.Info("NOFXi agent is online 🚀")
}
func (a *Agent) Stop() {
if a.sentinel != nil { a.sentinel.Stop() }
if a.brain != nil { a.brain.Stop() }
if a.scheduler != nil { a.scheduler.Stop() }
}
// HandleMessage — the core. Everything goes through the LLM.
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
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("message", "user_id", userID, "text", text)
// Setup flow — only when user explicitly asks
if resp, handled := a.handleSetupFlow(userID, text, lang); handled {
return resp, nil
}
// Only handle bare slash commands directly (instant, no AI needed)
if text == "/help" || text == "/start" {
return a.msg(lang, "help"), nil
}
if text == "/status" {
return a.handleStatus(lang), nil
}
// EVERYTHING else → LLM with tools
return a.thinkAndAct(ctx, userID, lang, text)
}
// thinkAndAct sends the user message to LLM with full context and tools.
// The LLM decides what to do — analyze, query, trade, or just chat.
func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string) (string, error) {
if a.aiClient == nil {
return a.noAIFallback(lang, text)
}
// Build rich context for the LLM
systemPrompt := a.buildSystemPrompt(lang)
// Enrich with real-time data if any asset is mentioned
enrichment := a.gatherContext(text)
userPrompt := text
if enrichment != "" {
userPrompt = text + "\n\n---\n[NOFXi System Context - real-time data for reference]\n" + enrichment
}
// Call LLM
resp, err := a.aiClient.CallWithMessages(systemPrompt, userPrompt)
if err != nil {
a.logger.Error("LLM call failed", "error", err)
return a.noAIFallback(lang, text)
}
return resp, nil
}
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
func (a *Agent) buildSystemPrompt(lang string) string {
// Gather live system state
traderInfo := a.getTradersSummary()
watchlist := ""
if a.sentinel != nil {
watchlist = a.sentinel.FormatWatchlist(lang)
}
if lang == "zh" {
return fmt.Sprintf(`你是 NOFXi一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
## 你的核心能力
1. **市场分析** — 加密货币BTC/ETH/SOL等有实时数据A股/港股/美股/外汇你可以基于知识分析
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
3. **策略建议** — 根据用户需求制定交易策略
4. **风险管理** — 评估风险、建议止损止盈
5. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
## 当前系统状态
%s
%s
## 数据说明
- 加密货币BTC/ETH等我有实时数据会在上下文中标注 [Real-time]
- A股/港股/美股/外汇:我**没有**实时价格数据
- 对于没有实时数据的标的,**严禁编造具体价格**
- 必须明确告诉用户"以下分析基于历史知识,不含实时数据,请以实际行情为准"
- 可以分析趋势、逻辑、策略框架,但具体价位必须让用户自己查看
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。
- 用交易相关的 emoji 让回复更直观。
- 用中文回复。
当前时间: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05"))
}
return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner.
## Capabilities
1. Market analysis — crypto with real-time data, stocks/forex with knowledge
2. Trade management — positions, balance, history, trader status
3. Strategy — build trading strategies based on user needs
4. Risk management — assess risk, suggest stop-loss/take-profit
5. Setup — guide exchange/AI configuration when user asks
## Current System State
%s
%s
## Data Notice
- Crypto (BTC/ETH): I have real-time data, marked [Real-time] in context
- Stocks/Forex: I do NOT have real-time prices
- For assets without real-time data, NEVER fabricate specific prices
- Must tell user: "Analysis based on historical knowledge, not live data. Verify with actual quotes."
- Can analyze trends, logic, strategy frameworks — but specific prices must be verified by user
## Behavior
- Concise, professional, opinionated. No fluff.
- Answer what's asked. Don't push setup.
- With real-time data: give specific levels. Without: give strategy frameworks.
- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data.
- Use trading emojis.
Current time: %s`, traderInfo, watchlist, time.Now().Format("2006-01-02 15:04:05"))
}
// gatherContext collects real-time market data relevant to the user's message.
func (a *Agent) gatherContext(text string) string {
var parts []string
upper := strings.ToUpper(text)
// Crypto — try to get real-time data
cryptoSymbols := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK"}
for _, sym := range cryptoSymbols {
if strings.Contains(upper, sym) {
md, err := market.Get(sym + "USDT")
if err == nil {
parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%",
sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100))
}
}
}
// Trader positions
if a.traderManager != nil {
for _, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 { continue }
parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f",
p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"])))
}
}
}
return strings.Join(parts, "\n")
}
func (a *Agent) getTradersSummary() string {
if a.traderManager == nil { return "Traders: none configured" }
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 { return "Traders: none configured" }
var lines []string
for id, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
status := "stopped"
if running { status = "running" }
tid := id
if len(tid) > 8 { tid = tid[:8] }
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
}
return "Traders:\n" + strings.Join(lines, "\n")
}
func (a *Agent) handleStatus(L string) string {
tc, rc := 0, 0
if a.traderManager != nil {
all := a.traderManager.GetAllTraders()
tc = len(all)
for _, t := range all {
if s := t.GetStatus(); s["is_running"] == true { rc++ }
}
}
wc := 0
if a.sentinel != nil { wc = a.sentinel.SymbolCount() }
ai := "❌"
if a.aiClient != nil { ai = "✅" }
return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05"))
}
// noAIFallback — when no AI is available, still try to be useful.
func (a *Agent) noAIFallback(lang, text string) (string, error) {
upper := strings.ToUpper(text)
// Try to provide market data directly
for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} {
if strings.Contains(upper, sym) {
md, err := market.Get(sym + "USDT")
if err == nil {
return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil
}
}
}
// Check if asking about positions/balance
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
return a.queryPositionsDirect(lang)
}
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
return a.queryBalancesDirect(lang)
}
if lang == "zh" {
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用\n• 加密货币实时行情试试「BTC」\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
}
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
}
func (a *Agent) queryPositionsDirect(L string) (string, error) {
if a.traderManager == nil { return a.msg(L, "no_traders"), nil }
var sb strings.Builder
sb.WriteString("📊 *Positions*\n\n")
hasAny := false
for id, t := range a.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil { continue }
for _, p := range positions {
size := toFloat(p["size"])
if size == 0 { continue }
hasAny = true
pnl := toFloat(p["unrealizedPnl"])
e := "🟢"; if pnl < 0 { e = "🔴" }
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, id[:8]))
}
}
if !hasAny { return a.msg(L, "no_positions"), nil }
return sb.String(), nil
}
func (a *Agent) queryBalancesDirect(L string) (string, error) {
if a.traderManager == nil { return a.msg(L, "no_traders"), nil }
var sb strings.Builder
sb.WriteString("💰 *Balance*\n\n")
for id, t := range a.traderManager.GetAllTraders() {
info, err := t.GetAccountInfo()
if err != nil { continue }
tid := id; if len(tid) > 8 { tid = tid[:8] }
sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"])))
}
return sb.String(), nil
}
func (a *Agent) handleSignal(sig Signal) {
if a.brain != nil { a.brain.HandleSignal(sig) }
}
func (a *Agent) notifyAll(text string) {
if a.NotifyFunc != nil { a.NotifyFunc(0, text) }
}
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
}