mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 16:26:57 +08:00
feat: dynamic crypto detection, trade history tool, fix 9 ticker leaks
- Agent gatherContext: expand from 10 hardcoded symbols to 38+ known symbols plus dynamic XXXUSDT pattern extraction (caps at 5 to avoid slow context) - Add get_trade_history tool: LLM can now query closed trades with PnL summary (win rate, total PnL, recent N trades across all traders) - Fix ticker.Stop() leak in ALL 9 exchange order_sync goroutines: OKX, Hyperliquid, Aster, Bybit, KuCoin, Gate, Bitget, Lighter, Binance — tickers were never stopped when traders were stopped, leaking goroutines
This commit is contained in:
@@ -399,17 +399,42 @@ func (a *Agent) gatherContext(text string) string {
|
|||||||
var parts []string
|
var parts []string
|
||||||
upper := strings.ToUpper(text)
|
upper := strings.ToUpper(text)
|
||||||
|
|
||||||
// Crypto — try to get real-time data
|
// Crypto — detect symbols dynamically
|
||||||
cryptoSymbols := []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK"}
|
// 1. Check known popular symbols (fast path)
|
||||||
for _, sym := range cryptoSymbols {
|
// 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) {
|
if strings.Contains(upper, sym) {
|
||||||
md, err := market.Get(sym + "USDT")
|
matched[sym] = true
|
||||||
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))
|
// 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)
|
// A-share / stocks — try Sina Finance (dynamic search as fallback)
|
||||||
stockCode, stockName := resolveStockCodeDynamic(text)
|
stockCode, stockName := resolveStockCodeDynamic(text)
|
||||||
|
|||||||
115
agent/tools.go
115
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()
|
return a.toolGetBalance()
|
||||||
case "get_market_price":
|
case "get_market_price":
|
||||||
return a.toolGetMarketPrice(tc.Function.Arguments)
|
return a.toolGetMarketPrice(tc.Function.Arguments)
|
||||||
|
case "get_trade_history":
|
||||||
|
return a.toolGetTradeHistory(tc.Function.Arguments)
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf(`{"error": "unknown tool: %s"}`, tc.Function.Name)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ func deriveAsterOrderAction(side, positionSide string, realizedPnL float64) stri
|
|||||||
// StartOrderSync starts background order sync task for Aster
|
// StartOrderSync starts background order sync task for Aster
|
||||||
func (t *AsterTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *AsterTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("aster-order-sync", func() {
|
safe.GoNamed("aster-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, excha
|
|||||||
|
|
||||||
// Then run periodically
|
// Then run periodically
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("binance-order-sync", func() {
|
safe.GoNamed("binance-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
// StartOrderSync starts background order sync task for Bitget
|
// StartOrderSync starts background order sync task for Bitget
|
||||||
func (t *BitgetTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *BitgetTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("bitget-order-sync", func() {
|
safe.GoNamed("bitget-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
// StartOrderSync starts background order sync task for Bybit
|
// StartOrderSync starts background order sync task for Bybit
|
||||||
func (t *BybitTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *BybitTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("bybit-order-sync", func() {
|
safe.GoNamed("bybit-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch
|
|||||||
// StartOrderSync starts background order sync task for Gate
|
// StartOrderSync starts background order sync task for Gate
|
||||||
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("gate-order-sync", func() {
|
safe.GoNamed("gate-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
// StartOrderSync starts background order sync task
|
// StartOrderSync starts background order sync task
|
||||||
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("hyperliquid-order-sync", func() {
|
safe.GoNamed("hyperliquid-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ func (t *KuCoinTrader) SyncOrdersFromKuCoin(traderID string, exchangeID string,
|
|||||||
// StartOrderSync starts background order sync task for KuCoin
|
// StartOrderSync starts background order sync task for KuCoin
|
||||||
func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("kucoin-order-sync", func() {
|
safe.GoNamed("kucoin-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
// StartOrderSync starts background order sync task
|
// StartOrderSync starts background order sync task
|
||||||
func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("lighter-order-sync", func() {
|
safe.GoNamed("lighter-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
// StartOrderSync starts background order sync task for OKX
|
// StartOrderSync starts background order sync task for OKX
|
||||||
func (t *OKXTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *OKXTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
safe.GoNamed("okx-order-sync", func() {
|
safe.GoNamed("okx-order-sync", func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if err := t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, st); err != nil {
|
if err := t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user