diff --git a/agent/agent.go b/agent/agent.go index 97cea1a0..77e82b3e 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -399,17 +399,42 @@ 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 { + // Crypto — detect symbols dynamically + // 1. Check known popular symbols (fast path) + // 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs) + knownSymbols := []string{ + "BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK", + "PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF", + "NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD", + "AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP", + } + matched := make(map[string]bool) + for _, sym := range knownSymbols { 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)) + matched[sym] = true + } + } + // Also extract "XXXUSDT" patterns for coins not in the known list + for _, word := range strings.Fields(upper) { + word = strings.Trim(word, ".,!?;:()[]{}\"'") + if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 { + sym := strings.TrimSuffix(word, "USDT") + if len(sym) >= 2 && len(sym) <= 10 { + matched[sym] = true } } } + // Cap at 5 symbols to avoid slow context gathering + count := 0 + for sym := range matched { + if count >= 5 { break } + md, err := market.Get(sym + "USDT") + if err == nil && md.CurrentPrice > 0 { + 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)) + count++ + } + } // A-share / stocks — try Sina Finance (dynamic search as fallback) stockCode, stockName := resolveStockCodeDynamic(text) diff --git a/agent/tools.go b/agent/tools.go index 57e490a9..d934d7fd 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -93,6 +93,22 @@ func agentTools() []mcp.Tool { }, }, }, + { + Type: "function", + Function: mcp.FunctionDef{ + Name: "get_trade_history", + Description: "Get recent closed trade history with PnL. Use when user asks about past trades, performance, or trade results. Returns the most recent closed positions.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{ + "type": "number", + "description": "Number of recent trades to return (default 10, max 50)", + }, + }, + }, + }, + }, } } @@ -109,6 +125,8 @@ func (a *Agent) handleToolCall(ctx context.Context, userID int64, lang string, t return a.toolGetBalance() case "get_market_price": return a.toolGetMarketPrice(tc.Function.Arguments) + case "get_trade_history": + return a.toolGetTradeHistory(tc.Function.Arguments) default: return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name) } @@ -331,3 +349,100 @@ func (a *Agent) toolGetMarketPrice(argsJSON string) string { return fmt.Sprintf(`{"error": "could not get price for %s"}`, sym) } + +func (a *Agent) toolGetTradeHistory(argsJSON string) string { + if a.store == nil { + return `{"error": "store not available"}` + } + + var args struct { + Limit int `json:"limit"` + } + if argsJSON != "" { + _ = json.Unmarshal([]byte(argsJSON), &args) + } + if args.Limit <= 0 { + args.Limit = 10 + } + if args.Limit > 50 { + args.Limit = 50 + } + + if a.traderManager == nil { + return `{"error": "no trader manager configured"}` + } + + var trades []map[string]any + var totalPnL float64 + var wins, losses int + + for id, t := range a.traderManager.GetAllTraders() { + positions, err := a.store.Position().GetClosedPositions(id, args.Limit) + if err != nil { + continue + } + tid := id + if len(tid) > 8 { + tid = tid[:8] + } + for _, pos := range positions { + pnl := pos.RealizedPnL + totalPnL += pnl + if pnl >= 0 { + wins++ + } else { + losses++ + } + + entryTime := "" + if pos.EntryTime > 0 { + entryTime = time.Unix(pos.EntryTime/1000, 0).Format("2006-01-02 15:04") + } + exitTime := "" + if pos.ExitTime > 0 { + exitTime = time.Unix(pos.ExitTime/1000, 0).Format("2006-01-02 15:04") + } + + trades = append(trades, map[string]any{ + "trader": t.GetName(), + "trader_id": tid, + "symbol": pos.Symbol, + "side": pos.Side, + "entry_price": pos.EntryPrice, + "exit_price": pos.ExitPrice, + "quantity": pos.Quantity, + "leverage": pos.Leverage, + "pnl": pnl, + "entry_time": entryTime, + "exit_time": exitTime, + }) + } + } + + if len(trades) == 0 { + return `{"trades": [], "message": "no closed trades found"}` + } + + // Only return up to the limit + if len(trades) > args.Limit { + trades = trades[:args.Limit] + } + + winRate := 0.0 + total := wins + losses + if total > 0 { + winRate = float64(wins) / float64(total) * 100 + } + + result, _ := json.Marshal(map[string]any{ + "trades": trades, + "summary": map[string]any{ + "total_trades": total, + "wins": wins, + "losses": losses, + "win_rate": fmt.Sprintf("%.1f%%", winRate), + "total_pnl": totalPnL, + }, + }) + return string(result) +} diff --git a/trader/aster/trader_sync.go b/trader/aster/trader_sync.go index 205a58e2..17db4185 100644 --- a/trader/aster/trader_sync.go +++ b/trader/aster/trader_sync.go @@ -183,6 +183,7 @@ func deriveAsterOrderAction(side, positionSide string, realizedPnL float64) stri // StartOrderSync starts background order sync task for Aster func (t *AsterTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("aster-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/binance/order_sync.go b/trader/binance/order_sync.go index c762d068..0b6157ca 100644 --- a/trader/binance/order_sync.go +++ b/trader/binance/order_sync.go @@ -361,6 +361,7 @@ func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, excha // Then run periodically ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("binance-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/bitget/order_sync.go b/trader/bitget/order_sync.go index 65d9f0d8..53b6e54a 100644 --- a/trader/bitget/order_sync.go +++ b/trader/bitget/order_sync.go @@ -282,6 +282,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string, // StartOrderSync starts background order sync task for Bitget func (t *BitgetTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("bitget-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/bybit/order_sync.go b/trader/bybit/order_sync.go index d9539019..3c4535f8 100644 --- a/trader/bybit/order_sync.go +++ b/trader/bybit/order_sync.go @@ -299,6 +299,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex // StartOrderSync starts background order sync task for Bybit func (t *BybitTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("bybit-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/gate/order_sync.go b/trader/gate/order_sync.go index 59a9bae9..24261030 100644 --- a/trader/gate/order_sync.go +++ b/trader/gate/order_sync.go @@ -294,6 +294,7 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch // StartOrderSync starts background order sync task for Gate func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("gate-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/hyperliquid/order_sync.go b/trader/hyperliquid/order_sync.go index 65b3225e..cfdf15a6 100644 --- a/trader/hyperliquid/order_sync.go +++ b/trader/hyperliquid/order_sync.go @@ -139,6 +139,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI // StartOrderSync starts background order sync task func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("hyperliquid-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/kucoin/order_sync.go b/trader/kucoin/order_sync.go index 1038a97b..f5297c40 100644 --- a/trader/kucoin/order_sync.go +++ b/trader/kucoin/order_sync.go @@ -402,6 +402,7 @@ func (t *KuCoinTrader) SyncOrdersFromKuCoin(traderID string, exchangeID string, // StartOrderSync starts background order sync task for KuCoin func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("kucoin-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/lighter/order_sync.go b/trader/lighter/order_sync.go index f0bccd84..3103e0c5 100644 --- a/trader/lighter/order_sync.go +++ b/trader/lighter/order_sync.go @@ -148,6 +148,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri // StartOrderSync starts background order sync task func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("lighter-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, st); err != nil { diff --git a/trader/okx/order_sync.go b/trader/okx/order_sync.go index 4028e637..209db347 100644 --- a/trader/okx/order_sync.go +++ b/trader/okx/order_sync.go @@ -275,6 +275,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan // StartOrderSync starts background order sync task for OKX func (t *OKXTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) + defer ticker.Stop() safe.GoNamed("okx-order-sync", func() { for range ticker.C { if err := t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, st); err != nil {