mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +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"才会真正执行。
|
- **execute_trade** — 下单交易(做多/做空/平多/平空)。调用后会创建待确认订单,用户需回复"确认 trade_xxx"才会真正执行。
|
||||||
- **get_positions** — 查看当前所有持仓
|
- **get_positions** — 查看当前所有持仓
|
||||||
- **get_balance** — 查看账户余额
|
- **get_balance** — 查看账户余额
|
||||||
@@ -338,6 +339,7 @@ func (a *Agent) buildSystemPrompt(lang string) string {
|
|||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
You can call these tools to take action:
|
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.
|
- **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_positions** — View all current open positions
|
||||||
- **get_balance** — View account balance and equity
|
- **get_balance** — View account balance and equity
|
||||||
@@ -376,8 +378,8 @@ func (a *Agent) gatherContext(text string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A-share / stocks — try Sina Finance
|
// A-share / stocks — try Sina Finance (dynamic search as fallback)
|
||||||
stockCode, stockName := resolveStockCode(text)
|
stockCode, stockName := resolveStockCodeDynamic(text)
|
||||||
if stockCode != "" {
|
if stockCode != "" {
|
||||||
quote, err := fetchStockQuote(stockCode)
|
quote, err := fetchStockQuote(stockCode)
|
||||||
if err == nil && quote.Price > 0 {
|
if err == nil && quote.Price > 0 {
|
||||||
|
|||||||
171
agent/stock.go
171
agent/stock.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -100,6 +101,176 @@ func resolveStockCode(text string) (string, string) {
|
|||||||
return "", ""
|
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) {
|
func fetchStockQuote(code string) (*StockQuote, error) {
|
||||||
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
|
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
|
||||||
req, _ := http.NewRequest("GET", url, nil)
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ import (
|
|||||||
// agentTools defines the tools available to the LLM for autonomous action.
|
// agentTools defines the tools available to the LLM for autonomous action.
|
||||||
func agentTools() []mcp.Tool {
|
func agentTools() []mcp.Tool {
|
||||||
return []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",
|
Type: "function",
|
||||||
Function: mcp.FunctionDef{
|
Function: mcp.FunctionDef{
|
||||||
@@ -82,6 +99,8 @@ func agentTools() []mcp.Tool {
|
|||||||
// handleToolCall processes a single tool call from the LLM and returns the result.
|
// 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 {
|
func (a *Agent) handleToolCall(ctx context.Context, userID int64, lang string, tc mcp.ToolCall) string {
|
||||||
switch tc.Function.Name {
|
switch tc.Function.Name {
|
||||||
|
case "search_stock":
|
||||||
|
return a.toolSearchStock(tc.Function.Arguments)
|
||||||
case "execute_trade":
|
case "execute_trade":
|
||||||
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
|
return a.toolExecuteTrade(ctx, userID, lang, tc.Function.Arguments)
|
||||||
case "get_positions":
|
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 {
|
func (a *Agent) toolExecuteTrade(_ context.Context, userID int64, lang, argsJSON string) string {
|
||||||
var args struct {
|
var args struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
|
|||||||
Reference in New Issue
Block a user