mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
feat(agent): LLM tool-calling for auto trade execution
- New agent/tools.go: defines LLM tools (execute_trade, get_positions,
get_balance, get_market_price)
- thinkAndAct now uses CallWithRequestFull with tool-calling loop
(max 5 rounds) — LLM autonomously decides when to call tools
- Trade safety: execute_trade creates pending orders requiring user
confirmation ('确认 trade_xxx' / 'confirm trade_xxx')
- Pending trades expire after 5 minutes
- System prompt updated with tool usage instructions and safety rules
- Both paths work: regex shortcut ('做多 BTC 0.01') and natural
language ('帮我开一个BTC多单') via LLM tool calling
- Fallback: if tool-calling fails, degrades to plain LLM call
This commit is contained in:
126
agent/agent.go
126
agent/agent.go
@@ -30,6 +30,7 @@ type Agent struct {
|
||||
scheduler *Scheduler
|
||||
logger *slog.Logger
|
||||
history *chatHistory
|
||||
pending *pendingTrades
|
||||
NotifyFunc func(userID int64, text string) error
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ func DefaultConfig() *Config {
|
||||
|
||||
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)}
|
||||
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 }
|
||||
@@ -133,12 +134,25 @@ func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (s
|
||||
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)
|
||||
@@ -170,18 +184,82 @@ func (a *Agent) thinkAndAct(ctx context.Context, userID int64, lang, text string
|
||||
// Record user message in history
|
||||
a.history.Add(userID, "user", text)
|
||||
|
||||
// Call LLM with full conversation context
|
||||
req := &mcp.Request{Messages: messages}
|
||||
resp, err := a.aiClient.CallWithRequest(req)
|
||||
if err != nil {
|
||||
a.logger.Error("LLM call failed", "error", err)
|
||||
return a.noAIFallback(lang, 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
|
||||
}
|
||||
|
||||
// Record assistant response in history
|
||||
a.history.Add(userID, "assistant", resp)
|
||||
|
||||
return resp, nil
|
||||
// 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.
|
||||
@@ -214,6 +292,19 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
||||
- 必须明确告诉用户"以下分析基于历史知识,不含实时数据,请以实际行情为准"
|
||||
- 可以分析趋势、逻辑、策略框架,但具体价位必须让用户自己查看
|
||||
|
||||
## 工具使用
|
||||
你可以调用以下工具来执行操作:
|
||||
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
|
||||
- **get_positions** — 查看当前所有持仓
|
||||
- **get_balance** — 查看账户余额
|
||||
- **get_market_price** — 获取交易所实时价格
|
||||
|
||||
### 交易安全规则
|
||||
- 用户明确要求交易时才调用 execute_trade
|
||||
- 分析和建议不需要调用工具,直接回复即可
|
||||
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
|
||||
- 提醒用户确认命令格式
|
||||
|
||||
## 行为准则
|
||||
- 简洁、专业、有观点。不说废话。
|
||||
- 用户问什么答什么,不要推销配置。
|
||||
@@ -245,6 +336,19 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
||||
- 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
|
||||
|
||||
## Tools
|
||||
You can call these tools to take action:
|
||||
- **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.
|
||||
|
||||
260
agent/tools.go
Normal file
260
agent/tools.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// agentTools defines the tools available to the LLM for autonomous action.
|
||||
func agentTools() []mcp.Tool {
|
||||
return []mcp.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "execute_trade",
|
||||
Description: "Execute a trade order. Use this when the user explicitly asks to open/close a position. This will create a pending trade that requires user confirmation before execution.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"open_long", "open_short", "close_long", "close_short"},
|
||||
"description": "Trade action: open_long (做多/buy), open_short (做空/sell), close_long (平多), close_short (平空)",
|
||||
},
|
||||
"symbol": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Trading symbol, e.g. BTCUSDT, ETHUSDT. Always append USDT if not present.",
|
||||
},
|
||||
"quantity": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Trade quantity/amount. Required for opening positions. Use 0 to close entire position.",
|
||||
},
|
||||
"leverage": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Leverage multiplier (e.g. 5, 10, 20). Optional, defaults to trader's current setting.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "symbol", "quantity"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "get_positions",
|
||||
Description: "Get all current open positions across all traders. Returns symbol, side, size, entry price, mark price, and unrealized PnL.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "get_balance",
|
||||
Description: "Get account balance and equity across all traders.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "get_market_price",
|
||||
Description: "Get the current market price for a symbol from the trader's exchange.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"symbol": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Trading symbol, e.g. BTCUSDT",
|
||||
},
|
||||
},
|
||||
"required": []string{"symbol"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolCall processes a single tool call from the LLM and returns the result.
|
||||
func (a *Agent) handleToolCall(ctx context.Context, userID int64, lang string, tc mcp.ToolCall) string {
|
||||
switch tc.Function.Name {
|
||||
case "execute_trade":
|
||||
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
|
||||
case "get_positions":
|
||||
return a.toolGetPositions()
|
||||
case "get_balance":
|
||||
return a.toolGetBalance()
|
||||
case "get_market_price":
|
||||
return a.toolGetMarketPrice(tc.Function.Arguments)
|
||||
default:
|
||||
return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string {
|
||||
var args struct {
|
||||
Action string `json:"action"`
|
||||
Symbol string `json:"symbol"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
sym := strings.ToUpper(args.Symbol)
|
||||
if !strings.HasSuffix(sym, "USDT") {
|
||||
sym += "USDT"
|
||||
}
|
||||
|
||||
// Validate action
|
||||
validActions := map[string]bool{
|
||||
"open_long": true, "open_short": true,
|
||||
"close_long": true, "close_short": true,
|
||||
}
|
||||
if !validActions[args.Action] {
|
||||
return fmt.Sprintf(`{"error": "invalid action: %s"}`, args.Action)
|
||||
}
|
||||
|
||||
// For open actions, quantity must be > 0
|
||||
if (args.Action == "open_long" || args.Action == "open_short") && args.Quantity <= 0 {
|
||||
return `{"error": "quantity must be > 0 for opening positions"}`
|
||||
}
|
||||
|
||||
// Create pending trade — requires user confirmation
|
||||
trade := &TradeAction{
|
||||
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
|
||||
Action: args.Action,
|
||||
Symbol: sym,
|
||||
Quantity: args.Quantity,
|
||||
Leverage: args.Leverage,
|
||||
Status: "pending_confirmation",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
a.pending.Add(trade)
|
||||
a.pending.CleanExpired()
|
||||
|
||||
// Return confirmation info to LLM so it can present it to the user
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"status": "pending_confirmation",
|
||||
"trade_id": trade.ID,
|
||||
"action": trade.Action,
|
||||
"symbol": trade.Symbol,
|
||||
"quantity": trade.Quantity,
|
||||
"leverage": trade.Leverage,
|
||||
"message": fmt.Sprintf("Trade created. User must confirm with: 确认 %s (or: confirm %s)", trade.ID, trade.ID),
|
||||
"expires": "5 minutes",
|
||||
})
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (a *Agent) toolGetPositions() string {
|
||||
if a.traderManager == nil {
|
||||
return `{"error": "no trader manager configured"}`
|
||||
}
|
||||
|
||||
var positions []map[string]any
|
||||
for id, t := range a.traderManager.GetAllTraders() {
|
||||
pos, err := t.GetPositions()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range pos {
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
tid := id
|
||||
if len(tid) > 8 {
|
||||
tid = tid[:8]
|
||||
}
|
||||
positions = append(positions, map[string]any{
|
||||
"trader": tid,
|
||||
"symbol": p["symbol"],
|
||||
"side": p["side"],
|
||||
"size": size,
|
||||
"entry_price": toFloat(p["entryPrice"]),
|
||||
"mark_price": toFloat(p["markPrice"]),
|
||||
"unrealized_pnl": toFloat(p["unrealizedPnl"]),
|
||||
"leverage": p["leverage"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(positions) == 0 {
|
||||
return `{"positions": [], "message": "no open positions"}`
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{"positions": positions})
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (a *Agent) toolGetBalance() string {
|
||||
if a.traderManager == nil {
|
||||
return `{"error": "no trader manager configured"}`
|
||||
}
|
||||
|
||||
var balances []map[string]any
|
||||
for id, t := range a.traderManager.GetAllTraders() {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tid := id
|
||||
if len(tid) > 8 {
|
||||
tid = tid[:8]
|
||||
}
|
||||
balances = append(balances, map[string]any{
|
||||
"trader": tid,
|
||||
"name": t.GetName(),
|
||||
"exchange": t.GetExchange(),
|
||||
"total_equity": toFloat(info["total_equity"]),
|
||||
"available": toFloat(info["available_balance"]),
|
||||
"used_margin": toFloat(info["used_margin"]),
|
||||
})
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]any{"balances": balances})
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func (a *Agent) toolGetMarketPrice(argsJSON string) string {
|
||||
var args struct {
|
||||
Symbol string `json:"symbol"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
|
||||
}
|
||||
|
||||
sym := strings.ToUpper(args.Symbol)
|
||||
if !strings.HasSuffix(sym, "USDT") {
|
||||
sym += "USDT"
|
||||
}
|
||||
|
||||
if a.traderManager == nil {
|
||||
return `{"error": "no trader manager configured"}`
|
||||
}
|
||||
|
||||
for _, t := range a.traderManager.GetAllTraders() {
|
||||
underlying := t.GetUnderlyingTrader()
|
||||
if underlying == nil {
|
||||
continue
|
||||
}
|
||||
price, err := underlying.GetMarketPrice(sym)
|
||||
if err == nil && price > 0 {
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"symbol": sym,
|
||||
"price": price,
|
||||
})
|
||||
return string(result)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym)
|
||||
}
|
||||
320
agent/trade.go
Normal file
320
agent/trade.go
Normal file
@@ -0,0 +1,320 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TradeAction represents a parsed trade intent from the LLM or user.
|
||||
type TradeAction struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
|
||||
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
|
||||
Quantity float64 `json:"quantity"` // amount
|
||||
Leverage int `json:"leverage"` // leverage multiplier
|
||||
TraderID string `json:"trader_id"` // which trader to use
|
||||
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// pendingTrades stores pending trade confirmations.
|
||||
type pendingTrades struct {
|
||||
mu sync.RWMutex
|
||||
trades map[string]*TradeAction // id -> trade
|
||||
}
|
||||
|
||||
func newPendingTrades() *pendingTrades {
|
||||
return &pendingTrades{trades: make(map[string]*TradeAction)}
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Add(t *TradeAction) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.trades[t.ID] = t
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Get(id string) *TradeAction {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.trades[id]
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Remove(id string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.trades, id)
|
||||
}
|
||||
|
||||
// CleanExpired removes trades older than 5 minutes.
|
||||
func (p *pendingTrades) CleanExpired() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
cutoff := time.Now().Add(-5 * time.Minute).Unix()
|
||||
for id, t := range p.trades {
|
||||
if t.CreatedAt < cutoff {
|
||||
delete(p.trades, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseTradeCommand parses natural language trade commands.
|
||||
// Returns nil if the message is not a trade command.
|
||||
func parseTradeCommand(text string) *TradeAction {
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
|
||||
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
|
||||
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
|
||||
|
||||
var action, symbol string
|
||||
var quantity float64
|
||||
var leverage int
|
||||
|
||||
words := strings.Fields(upper)
|
||||
if len(words) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch words[0] {
|
||||
case "做多", "LONG", "BUY":
|
||||
action = "open_long"
|
||||
case "做空", "SHORT", "SELL":
|
||||
action = "open_short"
|
||||
case "平多":
|
||||
action = "close_long"
|
||||
case "平空":
|
||||
action = "close_short"
|
||||
case "CLOSE":
|
||||
if len(words) >= 3 {
|
||||
switch words[1] {
|
||||
case "LONG":
|
||||
action = "close_long"
|
||||
words = append(words[:1], words[2:]...) // remove "LONG"
|
||||
case "SHORT":
|
||||
action = "close_short"
|
||||
words = append(words[:1], words[2:]...) // remove "SHORT"
|
||||
}
|
||||
}
|
||||
if action == "" {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse symbol
|
||||
if len(words) < 2 {
|
||||
return nil
|
||||
}
|
||||
symbol = words[1]
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
// Parse quantity (optional)
|
||||
if len(words) >= 3 {
|
||||
fmt.Sscanf(words[2], "%f", &quantity)
|
||||
}
|
||||
|
||||
// Parse leverage (optional, "x10" or "10x")
|
||||
if len(words) >= 4 {
|
||||
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
|
||||
fmt.Sscanf(lev, "%d", &leverage)
|
||||
}
|
||||
|
||||
if action == "" || symbol == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &TradeAction{
|
||||
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
|
||||
Action: action,
|
||||
Symbol: symbol,
|
||||
Quantity: quantity,
|
||||
Leverage: leverage,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// executeTrade performs the actual trade execution via TraderManager.
|
||||
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
|
||||
if a.traderManager == nil {
|
||||
return fmt.Errorf("no trader manager available")
|
||||
}
|
||||
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return fmt.Errorf("no traders configured")
|
||||
}
|
||||
|
||||
// Find a running trader's underlying exchange interface
|
||||
var underlyingTrader interface {
|
||||
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
|
||||
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
|
||||
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
|
||||
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
for _, t := range traders {
|
||||
s := t.GetStatus()
|
||||
running, _ := s["is_running"].(bool)
|
||||
if running {
|
||||
ut := t.GetUnderlyingTrader()
|
||||
if ut == nil {
|
||||
continue
|
||||
}
|
||||
underlyingTrader = ut
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if underlyingTrader == nil {
|
||||
return fmt.Errorf("no running trader supports trade execution")
|
||||
}
|
||||
|
||||
switch trade.Action {
|
||||
case "open_long":
|
||||
if trade.Quantity <= 0 {
|
||||
return fmt.Errorf("quantity must be > 0")
|
||||
}
|
||||
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
|
||||
return err
|
||||
case "open_short":
|
||||
if trade.Quantity <= 0 {
|
||||
return fmt.Errorf("quantity must be > 0")
|
||||
}
|
||||
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
|
||||
return err
|
||||
case "close_long":
|
||||
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
|
||||
return err
|
||||
case "close_short":
|
||||
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unknown action: %s", trade.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// formatTradeConfirmation creates a confirmation message for a pending trade.
|
||||
func formatTradeConfirmation(trade *TradeAction, lang string) string {
|
||||
actionNames := map[string]string{
|
||||
"open_long": "做多 (Long)",
|
||||
"open_short": "做空 (Short)",
|
||||
"close_long": "平多 (Close Long)",
|
||||
"close_short": "平空 (Close Short)",
|
||||
}
|
||||
|
||||
symbol := strings.TrimSuffix(trade.Symbol, "USDT")
|
||||
actionName := actionNames[trade.Action]
|
||||
if actionName == "" {
|
||||
actionName = trade.Action
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
|
||||
"操作: %s\n"+
|
||||
"品种: %s\n", actionName, symbol)
|
||||
if trade.Quantity > 0 {
|
||||
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
|
||||
}
|
||||
if trade.Leverage > 0 {
|
||||
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
|
||||
}
|
||||
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
|
||||
return msg
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
|
||||
"Action: %s\n"+
|
||||
"Symbol: %s\n", actionName, symbol)
|
||||
if trade.Quantity > 0 {
|
||||
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
|
||||
}
|
||||
if trade.Leverage > 0 {
|
||||
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
|
||||
}
|
||||
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
|
||||
return msg
|
||||
}
|
||||
|
||||
// handleTradeConfirmation processes a trade confirmation message.
|
||||
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
|
||||
var tradeID string
|
||||
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) >= 2 {
|
||||
tradeID = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if tradeID == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if a.pending == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trade := a.pending.Get(tradeID)
|
||||
if trade == nil {
|
||||
if lang == "zh" {
|
||||
return "❌ 交易已过期或不存在。", true
|
||||
}
|
||||
return "❌ Trade expired or not found.", true
|
||||
}
|
||||
|
||||
a.pending.Remove(tradeID)
|
||||
trade.Status = "confirmed"
|
||||
|
||||
a.logger.Info("executing trade",
|
||||
slog.String("id", trade.ID),
|
||||
slog.String("action", trade.Action),
|
||||
slog.String("symbol", trade.Symbol),
|
||||
slog.Float64("quantity", trade.Quantity),
|
||||
)
|
||||
|
||||
err := a.executeTrade(ctx, trade)
|
||||
if err != nil {
|
||||
trade.Status = "failed"
|
||||
trade.Error = err.Error()
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
|
||||
}
|
||||
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
|
||||
}
|
||||
|
||||
trade.Status = "executed"
|
||||
symbol := strings.TrimSuffix(trade.Symbol, "USDT")
|
||||
actionEmoji := "📈"
|
||||
if strings.Contains(trade.Action, "short") {
|
||||
actionEmoji = "📉"
|
||||
}
|
||||
if strings.Contains(trade.Action, "close") {
|
||||
actionEmoji = "✅"
|
||||
}
|
||||
|
||||
qtyStr := ""
|
||||
if trade.Quantity > 0 {
|
||||
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
|
||||
}
|
||||
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
|
||||
}
|
||||
|
||||
// marshals trade action to JSON for embedding in responses
|
||||
func marshalTradeAction(trade *TradeAction) string {
|
||||
b, _ := json.Marshal(trade)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user