feat: real-time A-share stock data via Sina Finance API

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
This commit is contained in:
shinchan-zhai
2026-03-23 01:41:40 +08:00
parent c04579108f
commit bd6fe6bd6d
2 changed files with 166 additions and 0 deletions

View File

@@ -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() {

155
agent/stock.go Normal file
View File

@@ -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)
}