mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 06:46:59 +08:00
feat(agent): surface the AI500 index board in chat, tools, and sidebar
- provider/nofxos: GetAI500ListCached — 5min TTL cache with stale fallback on upstream failure; ResolveClient routes through the claw402 gateway when a wallet key is configured (user's claw402 model key -> CLAW402_WALLET_KEY env -> direct nofxos) - new GET /api/ai500 endpoint serving the score-sorted board - new get_ai500_list agent tool + prompt rule: when the user wants coin picks or creates a strategy without naming coins, consult AI500's high-scoring entries by default - web: AI500 sidebar panel (rank, score badge, gain since entry, 5min auto-refresh); clicking an entry asks the agent to analyze it
This commit is contained in:
@@ -587,6 +587,7 @@ func (a *Agent) buildSystemPromptForStoreUser(lang, storeUserID string) string {
|
||||
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
|
||||
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
|
||||
- **get_watchlist / manage_watchlist** — 查看、添加、移除运行时监控币对,适合“把 BTC 加入监控”“别再监控 SOL”这类请求
|
||||
- **get_ai500_list** — 获取 AI500 指数榜单(AI 评分 0-100 + 入选以来涨幅,按评分排序)。**用户要选币、要推荐标的、或创建策略/交易员没指定币种时,默认先调这个工具,从评分高、表现好的标的里选**;用户问"AI500 里有什么"时也用它
|
||||
|
||||
### 配置、策略与交易员管理规则
|
||||
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
|
||||
@@ -671,6 +672,7 @@ You can call these tools to take action:
|
||||
- **get_balance** — View account balance and equity
|
||||
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
|
||||
- **get_kline** — Get recent candlestick / kline data for a crypto symbol
|
||||
- **get_ai500_list** — AI500 index board (AI score 0-100 + gain since entry, sorted by score). **When the user wants coin picks, recommendations, or creates a strategy/trader without naming coins, call this first and choose from the high-scoring, well-performing entries**; also use it when asked what's in AI500
|
||||
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
|
||||
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
|
||||
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
|
||||
|
||||
122
agent/ai500.go
Normal file
122
agent/ai500.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
const (
|
||||
ai500DefaultLimit = 20
|
||||
ai500MaxLimit = 100
|
||||
)
|
||||
|
||||
// fetchAI500ForTool is swappable in tests. It resolves a nofxos client
|
||||
// (routed through claw402 when a wallet key is available) and returns the
|
||||
// cached AI500 board.
|
||||
var fetchAI500ForTool = func(walletKey string) ([]nofxos.CoinData, error) {
|
||||
return nofxos.GetAI500ListCached(nofxos.ResolveClient(walletKey))
|
||||
}
|
||||
|
||||
// Claw402WalletKeyForStoreUser returns the wallet private key of the user's
|
||||
// enabled claw402 model, if any, so data requests can be routed through the
|
||||
// claw402 payment gateway on the user's own account.
|
||||
func Claw402WalletKeyForStoreUser(st *store.Store, storeUserID string) string {
|
||||
if st == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(storeUserID) == "" {
|
||||
storeUserID = "default"
|
||||
}
|
||||
models, err := st.AIModel().List(storeUserID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, model := range models {
|
||||
if model == nil || !model.Enabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(model.Provider), "claw402") && len(model.APIKey) > 0 {
|
||||
return string(model.APIKey)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AI500BoardEntry is the display shape for one AI500 constituent.
|
||||
type AI500BoardEntry struct {
|
||||
Pair string `json:"pair"`
|
||||
Score float64 `json:"score"`
|
||||
MaxScore float64 `json:"max_score"`
|
||||
IncreasePercent float64 `json:"increase_percent"`
|
||||
StartPrice float64 `json:"start_price"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
}
|
||||
|
||||
// AI500Board returns the AI500 constituents sorted by score (descending),
|
||||
// truncated to limit.
|
||||
func AI500Board(walletKey string, limit int) ([]AI500BoardEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = ai500DefaultLimit
|
||||
}
|
||||
if limit > ai500MaxLimit {
|
||||
limit = ai500MaxLimit
|
||||
}
|
||||
coins, err := fetchAI500ForTool(walletKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sorted := make([]nofxos.CoinData, len(coins))
|
||||
copy(sorted, coins)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].Score > sorted[j].Score
|
||||
})
|
||||
if len(sorted) > limit {
|
||||
sorted = sorted[:limit]
|
||||
}
|
||||
out := make([]AI500BoardEntry, 0, len(sorted))
|
||||
for _, coin := range sorted {
|
||||
out = append(out, AI500BoardEntry{
|
||||
Pair: coin.Pair,
|
||||
Score: coin.Score,
|
||||
MaxScore: coin.MaxScore,
|
||||
IncreasePercent: coin.IncreasePercent,
|
||||
StartPrice: coin.StartPrice,
|
||||
StartTime: coin.StartTime,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toolGetAI500List exposes the AI500 board to the chat agent.
|
||||
func (a *Agent) toolGetAI500List(storeUserID, argsJSON string) string {
|
||||
var args struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if strings.TrimSpace(argsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
walletKey := Claw402WalletKeyForStoreUser(a.store, storeUserID)
|
||||
entries, err := AI500Board(walletKey, args.Limit)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"failed to fetch AI500 list: %s"}`, err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"status": "ok",
|
||||
"count": len(entries),
|
||||
"coins": entries,
|
||||
"note": "AI500 is an AI-scored crypto index; score is 0-100, increase_percent is the gain since the coin entered the index.",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":"failed to serialize AI500 list: %s"}`, err)
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
104
agent/ai500_test.go
Normal file
104
agent/ai500_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/provider/nofxos"
|
||||
)
|
||||
|
||||
func withStubbedAI500(t *testing.T, fn func(walletKey string) ([]nofxos.CoinData, error)) {
|
||||
t.Helper()
|
||||
original := fetchAI500ForTool
|
||||
fetchAI500ForTool = fn
|
||||
t.Cleanup(func() { fetchAI500ForTool = original })
|
||||
}
|
||||
|
||||
func TestToolGetAI500ListSortsByScoreAndLimits(t *testing.T) {
|
||||
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
|
||||
return []nofxos.CoinData{
|
||||
{Pair: "LOWUSDT", Score: 10, IncreasePercent: -3},
|
||||
{Pair: "TOPUSDT", Score: 99, IncreasePercent: 42},
|
||||
{Pair: "MIDUSDT", Score: 55, IncreasePercent: 7},
|
||||
}, nil
|
||||
})
|
||||
|
||||
a := New(nil, nil, DefaultConfig(), slog.Default())
|
||||
raw := a.toolGetAI500List("default", `{"limit": 2}`)
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
Count int `json:"count"`
|
||||
Coins []struct {
|
||||
Pair string `json:"pair"`
|
||||
Score float64 `json:"score"`
|
||||
IncreasePercent float64 `json:"increase_percent"`
|
||||
} `json:"coins"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON %q: %v", raw, err)
|
||||
}
|
||||
if resp.Status != "ok" || resp.Count != 2 || len(resp.Coins) != 2 {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if resp.Coins[0].Pair != "TOPUSDT" || resp.Coins[1].Pair != "MIDUSDT" {
|
||||
t.Fatalf("expected score-descending order, got %+v", resp.Coins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGetAI500ListDefaultLimit(t *testing.T) {
|
||||
coins := make([]nofxos.CoinData, 30)
|
||||
for i := range coins {
|
||||
coins[i] = nofxos.CoinData{Pair: "C", Score: float64(i)}
|
||||
}
|
||||
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
|
||||
return coins, nil
|
||||
})
|
||||
|
||||
a := New(nil, nil, DefaultConfig(), slog.Default())
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(a.toolGetAI500List("default", "")), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if resp.Count != 20 {
|
||||
t.Fatalf("default limit = %d, want 20", resp.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGetAI500ListUpstreamError(t *testing.T) {
|
||||
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
|
||||
return nil, errors.New("upstream down")
|
||||
})
|
||||
|
||||
a := New(nil, nil, DefaultConfig(), slog.Default())
|
||||
raw := a.toolGetAI500List("default", "")
|
||||
if !strings.Contains(raw, `"error"`) {
|
||||
t.Fatalf("expected error payload, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleToolCallDispatchesAI500(t *testing.T) {
|
||||
withStubbedAI500(t, func(walletKey string) ([]nofxos.CoinData, error) {
|
||||
return []nofxos.CoinData{{Pair: "BTCUSDT", Score: 90}}, nil
|
||||
})
|
||||
|
||||
a := New(nil, nil, DefaultConfig(), slog.Default())
|
||||
raw := a.handleToolCall(t.Context(), "default", 1, "zh", toolCall("c1", "get_ai500_list", "{}"))
|
||||
if !strings.Contains(raw, "BTCUSDT") {
|
||||
t.Fatalf("dispatch failed, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolsIncludeAI500(t *testing.T) {
|
||||
for _, tool := range agentTools() {
|
||||
if tool.Function.Name == "get_ai500_list" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("get_ai500_list missing from agent toolset")
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func plannerToolNamesForDomain(domain string) []string {
|
||||
"get_strategies", "manage_strategy",
|
||||
"manage_trader",
|
||||
"get_balance", "get_positions", "get_trade_history",
|
||||
"get_candidate_coins",
|
||||
"get_candidate_coins", "get_ai500_list",
|
||||
"get_watchlist", "manage_watchlist",
|
||||
// Trade execution
|
||||
"execute_trade",
|
||||
@@ -115,7 +115,7 @@ func plannerToolNamesForDomain(domain string) []string {
|
||||
case "__all__", "":
|
||||
return all
|
||||
case "market":
|
||||
return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock"}
|
||||
return []string{"get_market_snapshot", "get_market_price", "get_kline", "search_stock", "get_ai500_list"}
|
||||
case "account":
|
||||
return []string{"get_balance", "get_positions", "get_trade_history", "get_exchange_configs"}
|
||||
case "trader":
|
||||
@@ -858,6 +858,22 @@ func buildAgentTools() []mcp.Tool {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "get_ai500_list",
|
||||
Description: "Get the AI500 index board: crypto symbols scored 0-100 by AI with their gain since entering the index, sorted by score. Use this whenever the user asks what's in AI500, wants coin recommendations, or asks you to pick promising/strong coins and hasn't named specific ones — the top entries are the well-performing candidates.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Max entries to return, default 20, max 100.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
@@ -934,6 +950,8 @@ func (a *Agent) handleToolCall(ctx context.Context, storeUserID string, userID i
|
||||
return a.toolGetTradeHistory(tc.Function.Arguments)
|
||||
case "get_candidate_coins":
|
||||
return a.toolGetCandidateCoins(storeUserID, userID, tc.Function.Arguments)
|
||||
case "get_ai500_list":
|
||||
return a.toolGetAI500List(storeUserID, tc.Function.Arguments)
|
||||
case "get_watchlist":
|
||||
return a.toolGetWatchlist(lang)
|
||||
case "manage_watchlist":
|
||||
|
||||
Reference in New Issue
Block a user