mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
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:
@@ -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 {
|
||||
|
||||
171
agent/stock.go
171
agent/stock.go
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user