Files
nofx/agent/agent.go
shinchan-zhai e2de4e8d0b feat: US stock pre-market/after-hours data support
- Parse extended hours fields from Sina Finance API (field 21-24)
- Display pre-market/after-hours price, change%, and time in stock quotes
- Update system prompt: stocks have real-time data via search_stock tool
- Remove incorrect 'no real-time stock data' disclaimer
2026-03-25 01:05:54 +08:00

527 lines
18 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
history *chatHistory
pending *pendingTrades
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, history: newChatHistory(20), pending: newPendingTrades()}
}
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
}
if text == "/clear" {
a.history.Clear(userID)
if lang == "zh" {
return "🧹 对话记忆已清除。", nil
}
return "🧹 Conversation history cleared.", nil
}
// Check for trade confirmation (e.g. "确认 trade_xxx" or "confirm trade_xxx")
if resp, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
return resp, nil
}
// Check for direct trade commands (e.g. "做多 BTC 0.01")
if trade := parseTradeCommand(text); trade != nil {
a.pending.Add(trade)
a.pending.CleanExpired()
return formatTradeConfirmation(trade, 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.
// Supports a tool-calling loop: LLM can call tools, get results, and continue.
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
}
// Build messages with conversation history
messages := []mcp.Message{mcp.NewSystemMessage(systemPrompt)}
// Add conversation history (up to last N messages)
history := a.history.Get(userID)
for _, msg := range history {
messages = append(messages, mcp.NewMessage(msg.Role, msg.Content))
}
// Add current user message
messages = append(messages, mcp.NewUserMessage(userPrompt))
// Record user message in history
a.history.Add(userID, "user", text)
// Define tools for the LLM
tools := agentTools()
// Tool-calling loop (max 5 iterations to prevent infinite loops)
const maxToolRounds = 5
for round := 0; round < maxToolRounds; round++ {
req := &mcp.Request{
Messages: messages,
Tools: tools,
ToolChoice: "auto",
}
resp, err := a.aiClient.CallWithRequestFull(req)
if err != nil {
a.logger.Error("LLM call failed", "error", err, "round", round)
if round == 0 {
// First round failed — try without tools as fallback
plainReq := &mcp.Request{Messages: messages}
plainResp, plainErr := a.aiClient.CallWithRequest(plainReq)
if plainErr != nil {
return a.noAIFallback(lang, text)
}
a.history.Add(userID, "assistant", plainResp)
return plainResp, nil
}
return a.noAIFallback(lang, text)
}
// If LLM returned a text response (no tool calls), we're done
if len(resp.ToolCalls) == 0 {
a.history.Add(userID, "assistant", resp.Content)
return resp.Content, nil
}
// LLM wants to call tools — process each one
a.logger.Info("LLM tool calls", "count", len(resp.ToolCalls), "round", round)
// Add assistant message with tool calls to conversation
assistantMsg := mcp.Message{
Role: "assistant",
ToolCalls: resp.ToolCalls,
}
if resp.Content != "" {
assistantMsg.Content = resp.Content
}
messages = append(messages, assistantMsg)
// Execute each tool call and add results
for _, tc := range resp.ToolCalls {
a.logger.Info("executing tool",
"name", tc.Function.Name,
"args", tc.Function.Arguments,
"call_id", tc.ID,
)
result := a.handleToolCall(ctx, userID, lang, tc)
// Add tool result message
messages = append(messages, mcp.Message{
Role: "tool",
Content: result,
ToolCallID: tc.ID,
})
}
// Continue the loop — LLM will see tool results and either respond or call more tools
}
// If we exhausted all rounds, ask LLM for a final text response without tools
finalReq := &mcp.Request{Messages: messages}
finalResp, err := a.aiClient.CallWithRequest(finalReq)
if err != nil {
return a.noAIFallback(lang, text)
}
a.history.Add(userID, "assistant", finalResp)
return finalResp, 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股/港股/美股:通过 search_stock 工具获取**实时行情**(新浪财经数据源)
- 美股支持**盘前盘后数据**search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time 为盘前盘后价格
- 当返回数据包含 is_ext_hours=true 时,**必须**向用户展示盘前/盘后价格
- 外汇:没有实时数据,需要说明
- 对于没有实时数据的标的,严禁编造具体价格
- **有数据就给数据,没有才说没有。诚实但不过度声明。**
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
- **get_positions** — 查看当前所有持仓
- **get_balance** — 查看账户余额
- **get_market_price** — 获取交易所实时价格
### 交易安全规则
- 用户明确要求交易时才调用 execute_trade
- 分析和建议不需要调用工具,直接回复即可
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
- 提醒用户确认命令格式
## 行为准则
- 简洁、专业、有观点。不说废话。
- 用户问什么答什么,不要推销配置。
- 有实时数据时给具体价位,没有时给策略框架和思路。
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。
- 用交易相关的 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): Exchange real-time data, marked [Real-time]
- A-shares/HK/US stocks: Real-time quotes via search_stock tool (Sina Finance data)
- US stocks include **pre-market/after-hours data**: ext_price/ext_change_pct/ext_time in quote
- When is_ext_hours=true, you MUST show pre-market/after-hours prices to the user
- Forex: No real-time data — must inform user
- NEVER fabricate prices for assets without data
- Show data when you have it. Only disclaim when you don't.
## Tools
You can call these tools to take action:
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
- **execute_trade** — Place a trade order (open_long/open_short/close_long/close_short). Creates a pending order that requires user confirmation.
- **get_positions** — View all current open positions
- **get_balance** — View account balance and equity
- **get_market_price** — Get real-time price from the exchange
### Trade Safety Rules
- Only call execute_trade when user explicitly requests a trade
- Analysis and advice don't need tools — just reply directly
- Show trade details clearly: symbol, direction, quantity, leverage
- Remind user of the confirmation command format
## 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))
}
}
}
// A-share / stocks — try Sina Finance (dynamic search as fallback)
stockCode, stockName := resolveStockCodeDynamic(text)
if stockCode != "" {
quote, err := fetchStockQuote(stockCode)
if err == nil && quote.Price > 0 {
parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote)))
} else if err != nil {
a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err)
}
}
// 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
}