From bd6fe6bd6d66e622d8454465721ad4f9287e51ff Mon Sep 17 00:00:00 2001 From: shinchan-zhai Date: Mon, 23 Mar 2026 01:41:40 +0800 Subject: [PATCH] feat: real-time A-share stock data via Sina Finance API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOFXi can now fetch REAL stock prices for A-shares. Data source: Sina Finance hq.sinajs.cn (free, no API key) - Name, price, open, prev close, high, low, volume, turnover - Change % calculated from prev close - GBK → UTF-8 encoding handled Stock resolution: - Known stocks: 拓维信息→sz002261, 贵州茅台→sh600519, etc. - Auto-detect 6-digit codes in user text - Prefix detection: 6xx→sh, 0xx/3xx→sz Context injection: - Stock data auto-appended to LLM context when user mentions a stock - AI sees REAL price, volume, change % — cannot fabricate Tested: '拓维信息这只股,10万元,1个月交易计划' → Returns ¥35.57 (real price), -6.71% (real change), 53.94亿成交额 → Strategy based on actual price levels, not hallucinated --- agent/agent.go | 11 ++++ agent/stock.go | 155 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 agent/stock.go diff --git a/agent/agent.go b/agent/agent.go index 0a8ae858..53cacfe8 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -245,6 +245,17 @@ func (a *Agent) gatherContext(text string) string { } } + // A-share / stocks — try Sina Finance + stockCode, stockName := resolveStockCode(text) + if stockCode != "" { + quote, err := fetchStockQuote(stockCode) + if err == nil && quote.Price > 0 { + parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote))) + } else if err != nil { + a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err) + } + } + // Trader positions if a.traderManager != nil { for _, t := range a.traderManager.GetAllTraders() { diff --git a/agent/stock.go b/agent/stock.go new file mode 100644 index 00000000..b0ff727f --- /dev/null +++ b/agent/stock.go @@ -0,0 +1,155 @@ +package agent + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +// StockQuote holds real-time stock data from Sina Finance API. +type StockQuote struct { + Name string + Code string + Open float64 + PrevClose float64 + Price float64 + High float64 + Low float64 + Volume float64 // shares + Turnover float64 // CNY + Date string + Time string + + Change float64 // Price - PrevClose + ChangePct float64 // Change / PrevClose * 100 +} + +// knownStocks maps Chinese names / common terms to stock codes. +var knownStocks = map[string]string{ + "拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750", + "贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036", + "腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690", + "小米": "hk01810", "京东": "hk09618", + "苹果": "usr_aapl", "特斯拉": "usr_tsla", "英伟达": "usr_nvda", + "微软": "usr_msft", "谷歌": "usr_googl", "亚马逊": "usr_amzn", +} + +// resolveStockCode tries to find a stock code from user text. +func resolveStockCode(text string) (string, string) { + // Direct match known stocks + for name, code := range knownStocks { + if strings.Contains(text, name) { + return code, name + } + } + + // Try to extract a stock code like 002261, 600519, etc. + words := strings.Fields(text) + for _, w := range words { + w = strings.TrimSpace(w) + if len(w) == 6 { + if _, err := strconv.Atoi(w); err == nil { + prefix := "sz" + if w[0] == '6' { + prefix = "sh" + } else if w[0] == '3' { + prefix = "sz" + } else if w[0] == '0' { + prefix = "sz" + } + return prefix + w, w + } + } + } + + return "", "" +} + +// fetchStockQuote gets real-time A-share data from Sina Finance. +func fetchStockQuote(code string) (*StockQuote, error) { + if !strings.HasPrefix(code, "sh") && !strings.HasPrefix(code, "sz") { + return nil, fmt.Errorf("unsupported stock code: %s", code) + } + + url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code) + req, _ := http.NewRequest("GET", url, 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() + + // Sina returns GBK encoding + reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder()) + body, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + line := string(body) + // Parse: var hq_str_sz002261="name,open,prev_close,price,high,low,..." + start := strings.Index(line, "\"") + end := strings.LastIndex(line, "\"") + if start == -1 || end <= start { + return nil, fmt.Errorf("invalid response for %s", code) + } + + fields := strings.Split(line[start+1:end], ",") + if len(fields) < 32 { + return nil, fmt.Errorf("not enough fields for %s", code) + } + + q := &StockQuote{ + Name: fields[0], + Code: code, + Date: fields[30], + Time: fields[31], + } + q.Open, _ = strconv.ParseFloat(fields[1], 64) + q.PrevClose, _ = strconv.ParseFloat(fields[2], 64) + q.Price, _ = strconv.ParseFloat(fields[3], 64) + q.High, _ = strconv.ParseFloat(fields[4], 64) + q.Low, _ = strconv.ParseFloat(fields[5], 64) + q.Volume, _ = strconv.ParseFloat(fields[8], 64) + q.Turnover, _ = strconv.ParseFloat(fields[9], 64) + + if q.PrevClose > 0 { + q.Change = q.Price - q.PrevClose + q.ChangePct = (q.Change / q.PrevClose) * 100 + } + + return q, nil +} + +// formatStockQuote formats a quote for display. +func formatStockQuote(q *StockQuote) string { + emoji := "🟢" + if q.ChangePct < 0 { + emoji = "🔴" + } + volStr := fmt.Sprintf("%.0f万手", q.Volume/1000000) + turnStr := fmt.Sprintf("%.2f亿", q.Turnover/100000000) + + return fmt.Sprintf(`%s *%s* (%s) + +💰 现价: ¥%.2f (%+.2f%%) +📊 今开: ¥%.2f | 昨收: ¥%.2f +📈 最高: ¥%.2f | 最低: ¥%.2f +📦 成交量: %s | 成交额: %s +🕐 数据时间: %s %s`, + emoji, q.Name, q.Code, + q.Price, q.ChangePct, + q.Open, q.PrevClose, + q.High, q.Low, + volStr, turnStr, + q.Date, q.Time) +}