feat: dynamic stock search via Sina suggest API

- Add searchStock() using Sina's suggest API (type=11,31,41)
- Supports A-share, HK, and US markets dynamically
- No longer limited to hardcoded knownStocks map
- resolveStockCodeDynamic: tries static map first, then API fallback
- extractStockKeyword: smart keyword extraction from natural language
- New LLM tool 'search_stock': agent can proactively search stocks
- Top 3 search results auto-enriched with real-time quotes
- System prompts updated for both zh/en
This commit is contained in:
shinchan-zhai
2026-03-23 02:54:20 +08:00
parent 203d5b4d14
commit d8d136ced6
3 changed files with 248 additions and 2 deletions

View File

@@ -294,6 +294,7 @@ func (a *Agent) buildSystemPrompt(lang string) string {
## 工具使用
你可以调用以下工具来执行操作:
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
- **get_positions** — 查看当前所有持仓
- **get_balance** — 查看账户余额
@@ -338,6 +339,7 @@ func (a *Agent) buildSystemPrompt(lang string) string {
## 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
@@ -376,8 +378,8 @@ func (a *Agent) gatherContext(text string) string {
}
}
// A-share / stocks — try Sina Finance
stockCode, stockName := resolveStockCode(text)
// 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 {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -100,6 +101,176 @@ func resolveStockCode(text string) (string, string) {
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())
body, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)

View File

@@ -13,6 +13,23 @@ import (
// agentTools defines the tools available to the LLM for autonomous action.
func agentTools() []mcp.Tool {
return []mcp.Tool{
{
Type: "function",
Function: mcp.FunctionDef{
Name: "search_stock",
Description: "Search for a stock by name, ticker symbol, or keyword. Searches across A-share (沪深), Hong Kong, and US markets. Returns a list of matching stocks with their codes. Use this when the user asks about a stock not in your known list, or when you need to find the exact code for a stock.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"keyword": map[string]any{
"type": "string",
"description": "Search keyword: stock name (e.g. '宁德时代', '腾讯'), ticker (e.g. 'TSLA', 'AAPL'), or stock code (e.g. '300750')",
},
},
"required": []string{"keyword"},
},
},
},
{
Type: "function",
Function: mcp.FunctionDef{
@@ -82,6 +99,8 @@ func agentTools() []mcp.Tool {
// 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 "search_stock":
return a.toolSearchStock(tc.Function.Arguments)
case "execute_trade":
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
case "get_positions":
@@ -95,6 +114,60 @@ func (a *Agent) handleToolCall(ctx context.Context, userID int64, lang string, t
}
}
func (a *Agent) toolSearchStock(argsJSON string) string {
var args struct {
Keyword string `json:"keyword"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return fmt.Sprintf(`{"error": "invalid arguments: %s"}`, err)
}
if args.Keyword == "" {
return `{"error": "keyword is required"}`
}
results, err := searchStock(args.Keyword)
if err != nil {
return fmt.Sprintf(`{"error": "search failed: %s"}`, err)
}
if len(results) == 0 {
return fmt.Sprintf(`{"results": [], "message": "no stocks found for '%s'"}`, args.Keyword)
}
// Limit to top 10 results
if len(results) > 10 {
results = results[:10]
}
// Also fetch real-time quotes for the top results (up to 3)
type enrichedResult struct {
Name string `json:"name"`
Code string `json:"code"`
Market string `json:"market"`
Quote *StockQuote `json:"quote,omitempty"`
}
var enriched []enrichedResult
for i, r := range results {
er := enrichedResult{Name: r.Name, Code: r.Code, Market: r.Market}
if i < 3 {
q, qErr := fetchStockQuote(r.Code)
if qErr == nil && q.Price > 0 {
er.Quote = q
}
}
enriched = append(enriched, er)
}
result, _ := json.Marshal(map[string]any{
"keyword": args.Keyword,
"count": len(enriched),
"results": enriched,
})
return string(result)
}
func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string {
var args struct {
Action string `json:"action"`