mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
feat: real-time data for A-shares, HK stocks, and US stocks
Data source: Sina Finance API (free, no key needed) - A股: sh/sz prefix, GBK→UTF-8, 30+ fields - 港股: hk prefix, different field layout - 美股: gb_ prefix, USD prices Coverage: - 20+ A-share stocks (拓维信息/贵州茅台/比亚迪/宁德时代...) - 12 HK stocks (腾讯/阿里/美团/小米/京东...) - 20 US tickers (AAPL/TSLA/NVDA/MSFT/GOOGL/AMZN...) - Auto-detect 6-digit A-share codes, 5-digit HK codes - US ticker symbols recognized (TSLA, AAPL, etc.) All three markets tested: - A股 拓维信息: ¥35.57 (-6.71%) ✅ - 港股 腾讯: HK$510.50 (-0.49%) ✅ - 美股 TSLA: $367.96 (-3.24%) ✅
This commit is contained in:
217
agent/stock.go
217
agent/stock.go
@@ -12,144 +12,209 @@ import (
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// StockQuote holds real-time stock data from Sina Finance API.
|
||||
// StockQuote holds real-time stock data.
|
||||
type StockQuote struct {
|
||||
Name string
|
||||
Code string
|
||||
Market string // "A股", "港股", "美股"
|
||||
Currency string // "CNY", "HKD", "USD"
|
||||
Open float64
|
||||
PrevClose float64
|
||||
Price float64
|
||||
High float64
|
||||
Low float64
|
||||
Volume float64 // shares
|
||||
Turnover float64 // CNY
|
||||
Volume float64
|
||||
Turnover float64
|
||||
Date string
|
||||
Time string
|
||||
|
||||
Change float64 // Price - PrevClose
|
||||
ChangePct float64 // Change / PrevClose * 100
|
||||
Change float64
|
||||
ChangePct float64
|
||||
}
|
||||
|
||||
// knownStocks maps Chinese names / common terms to stock codes.
|
||||
// knownStocks maps Chinese names to stock codes.
|
||||
var knownStocks = map[string]string{
|
||||
// A股
|
||||
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
|
||||
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
|
||||
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
|
||||
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
|
||||
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
|
||||
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
|
||||
"三六零": "sh601360", "中兴通讯": "sz000063",
|
||||
// 港股
|
||||
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
|
||||
"小米": "hk01810", "京东": "hk09618",
|
||||
"苹果": "usr_aapl", "特斯拉": "usr_tsla", "英伟达": "usr_nvda",
|
||||
"微软": "usr_msft", "谷歌": "usr_googl", "亚马逊": "usr_amzn",
|
||||
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
|
||||
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
|
||||
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
|
||||
"华为": "hk00700", // fallback to tencent for now
|
||||
// 美股
|
||||
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
|
||||
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
|
||||
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
|
||||
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
|
||||
}
|
||||
|
||||
// US stock ticker mapping
|
||||
var usTickerMap = map[string]string{
|
||||
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
|
||||
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
|
||||
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
|
||||
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
|
||||
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
|
||||
}
|
||||
|
||||
// resolveStockCode tries to find a stock code from user text.
|
||||
func resolveStockCode(text string) (string, string) {
|
||||
// Direct match known stocks
|
||||
// Known Chinese names
|
||||
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 {
|
||||
// US ticker symbols (uppercase)
|
||||
upper := strings.ToUpper(text)
|
||||
for ticker, code := range usTickerMap {
|
||||
if strings.Contains(upper, ticker) {
|
||||
return code, ticker
|
||||
}
|
||||
}
|
||||
|
||||
// 6-digit A-share code
|
||||
for _, w := range strings.Fields(text) {
|
||||
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"
|
||||
}
|
||||
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
|
||||
return prefix + w, w
|
||||
}
|
||||
}
|
||||
// 5-digit HK code
|
||||
if len(w) == 5 {
|
||||
if _, err := strconv.Atoi(w); err == nil {
|
||||
return "hk" + 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
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
|
||||
|
||||
data := line[start+1 : end]
|
||||
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
|
||||
|
||||
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
|
||||
return parseAShare(code, data)
|
||||
} else if strings.HasPrefix(code, "hk") {
|
||||
return parseHKShare(code, data)
|
||||
} else if strings.HasPrefix(code, "gb_") {
|
||||
return parseUSShare(code, data)
|
||||
}
|
||||
|
||||
fields := strings.Split(line[start+1:end], ",")
|
||||
if len(fields) < 32 {
|
||||
return nil, fmt.Errorf("not enough fields for %s", code)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported market: %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
|
||||
}
|
||||
func parseAShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
|
||||
q.Open, _ = strconv.ParseFloat(f[1], 64)
|
||||
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.Price, _ = strconv.ParseFloat(f[3], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[8], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
|
||||
q.Date = f[30]; q.Time = f[31]
|
||||
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func parseHKShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
|
||||
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
|
||||
q.Open, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.Price, _ = strconv.ParseFloat(f[6], 64)
|
||||
q.Change, _ = strconv.ParseFloat(f[7], 64)
|
||||
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[11], 64)
|
||||
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func parseUSShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
|
||||
q.Price, _ = strconv.ParseFloat(f[1], 64)
|
||||
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.Change, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Open, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[6], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[7], 64)
|
||||
// 52wk high/low
|
||||
high52, _ := strconv.ParseFloat(f[8], 64)
|
||||
low52, _ := strconv.ParseFloat(f[9], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[10], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
|
||||
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
|
||||
q.PrevClose = q.Price - q.Change
|
||||
_ = high52; _ = low52
|
||||
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)
|
||||
if q.ChangePct < 0 { emoji = "🔴" }
|
||||
|
||||
return fmt.Sprintf(`%s *%s* (%s)
|
||||
sym := "¥"
|
||||
if q.Currency == "USD" { sym = "$" }
|
||||
if q.Currency == "HKD" { sym = "HK$" }
|
||||
|
||||
💰 现价: ¥%.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 := fmt.Sprintf("%.0f", q.Volume)
|
||||
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
|
||||
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
|
||||
|
||||
turnStr := fmt.Sprintf("%.0f", q.Turnover)
|
||||
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
|
||||
|
||||
return fmt.Sprintf(`%s *%s* (%s · %s)
|
||||
💰 现价: %s%.2f (%+.2f%%)
|
||||
📊 开盘: %s%.2f | 昨收: %s%.2f
|
||||
📈 最高: %s%.2f | 最低: %s%.2f
|
||||
📦 成交: %s | 额: %s
|
||||
🕐 %s`,
|
||||
emoji, q.Name, q.Code, q.Market,
|
||||
sym, q.Price, q.ChangePct,
|
||||
sym, q.Open, sym, q.PrevClose,
|
||||
sym, q.High, sym, q.Low,
|
||||
volStr, turnStr,
|
||||
q.Date, q.Time)
|
||||
q.Date)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user