feat(stock):添加美股数据支持

- 新增 StockInfoUS 模型用于存储美股信息
- 实现 IsUSTradingTime 函数判断美股交易时间
- 修改 MonitorStockPrices 函数以支持美股数据
- 更新前端股票组件以适配美股数据
- 优化后端 API 以支持美股实时数据获取和解析
This commit is contained in:
ArvinLovegood 2025-02-28 16:30:48 +08:00
parent 7b3bad4102
commit fdca30ce3a
12 changed files with 349 additions and 12 deletions

45
app.go
View File

@ -11,6 +11,7 @@ import (
"github.com/duke-git/lancet/v2/convertor"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
"github.com/duke-git/lancet/v2/strutil"
"github.com/getlantern/systray"
"github.com/go-resty/resty/v2"
"github.com/wailsapp/wails/v2/pkg/runtime"
@ -104,9 +105,8 @@ func (a *App) domReady(ctx context.Context) {
ticker := time.NewTicker(time.Second * time.Duration(interval))
defer ticker.Stop()
for range ticker.C {
if isTradingTime(time.Now()) || IsHKTradingTime(time.Now()) {
MonitorStockPrices(a)
}
MonitorStockPrices(a)
}
}()
@ -229,6 +229,35 @@ func IsHKTradingTime(date time.Time) bool {
return false
}
// IsUSTradingTime 判断当前时间是否在美股交易时间内
func IsUSTradingTime(date time.Time) bool {
// 获取美国东部时区
est, err := time.LoadLocation("America/New_York")
if err != nil {
logger.SugaredLogger.Errorf("加载时区失败: %s", err.Error())
return false
}
// 将当前时间转换为美国东部时间
estTime := date.In(est)
// 判断是否是周末
weekday := estTime.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
return false
}
// 获取小时和分钟
hour, minute, _ := estTime.Clock()
// 判断是否在9:30到16:00之间
if (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {
return true
}
return false
}
func MonitorStockPrices(a *App) {
dest := &[]data.FollowedStock{}
db.Dao.Model(&data.FollowedStock{}).Find(dest)
@ -244,6 +273,16 @@ func MonitorStockPrices(a *App) {
stockInfos := GetStockInfos(*dest...)
for _, stockInfo := range *stockInfos {
if strutil.HasPrefixAny(stockInfo.Code, []string{"SZ", "SH", "sh", "sz"}) && (!isTradingTime(time.Now())) {
continue
}
if strutil.HasPrefixAny(stockInfo.Code, []string{"hk", "HK"}) && (!IsHKTradingTime(time.Now())) {
continue
}
if strutil.HasPrefixAny(stockInfo.Code, []string{"us", "US", "gb_"}) && (!IsUSTradingTime(time.Now())) {
continue
}
total += stockInfo.ProfitAmountToday
price, _ := convertor.ToFloat(stockInfo.Price)
if stockInfo.PrePrice != price {

View File

@ -13,3 +13,7 @@ func TestIsHKTradingTime(t *testing.T) {
f := IsHKTradingTime(time.Now())
t.Log(f)
}
func TestIsUSTradingTime(t *testing.T) {
t.Log(IsUSTradingTime(time.Now()))
}

View File

@ -2,12 +2,14 @@ package data
import (
"context"
"encoding/json"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/duke-git/lancet/v2/strutil"
"go-stock/backend/db"
"go-stock/backend/logger"
"go-stock/backend/models"
"os"
"strings"
"testing"
"time"
@ -166,3 +168,143 @@ func TestHk(t *testing.T) {
}
}
func TestUpdateUSName(t *testing.T) {
db.Init("../../data/stock.db")
us := &[]models.StockInfoUS{}
db.Dao.Model(&models.StockInfoUS{}).Where("name = ?", "").Order("RANDOM()").Find(us)
for _, us := range *us {
crawlerAPI := CrawlerApi{}
crawlerBaseInfo := CrawlerBaseInfo{
Name: "TestCrawler",
Description: "Test Crawler Description",
BaseUrl: "https://stock.finance.sina.com.cn",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
crawlerAPI = crawlerAPI.NewCrawler(ctx, crawlerBaseInfo)
url := fmt.Sprintf("https://stock.finance.sina.com.cn/usstock/quotes/%s.html", us.Code[:len(us.Code)-3])
logger.SugaredLogger.Infof("url: %s", url)
//waitVisible := "span.quote_title_name"
waitVisible := "div.hq_title > h1"
htmlContent, ok := crawlerAPI.GetHtml(url, waitVisible, true)
if !ok {
continue
}
//logger.SugaredLogger.Infof("htmlContent: %s", htmlContent)
document, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}
name := ""
document.Find(waitVisible).Each(func(i int, selection *goquery.Selection) {
name = strutil.RemoveNonPrintable(selection.Text())
name = strutil.SplitAndTrim(name, " ", "")[0]
logger.SugaredLogger.Infof("股票名称-:%s", name)
})
db.Dao.Model(&models.StockInfoUS{}).Where("code = ?", us.Code).Updates(map[string]interface{}{
"name": name,
"full_name": name,
})
}
}
func TestUS(t *testing.T) {
db.Init("../../data/stock.db")
bytes, err := os.ReadFile("../../build/us.json")
if err != nil {
return
}
crawlerAPI := CrawlerApi{}
crawlerBaseInfo := CrawlerBaseInfo{
Name: "TestCrawler",
Description: "Test Crawler Description",
BaseUrl: "https://quote.eastmoney.com",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
crawlerAPI = crawlerAPI.NewCrawler(ctx, crawlerBaseInfo)
tick := &Tick{}
json.Unmarshal(bytes, &tick)
for i, datum := range tick.Data {
logger.SugaredLogger.Infof("datum: %d, %+v", i, datum)
name := ""
//https://quote.eastmoney.com/us/AAPL.html
//https://stock.finance.sina.com.cn/usstock/quotes/goog.html
//url := fmt.Sprintf("https://stock.finance.sina.com.cn/usstock/quotes/%s.html", strings.ReplaceAll(datum.C, ".US", ""))
////waitVisible := "span.quote_title_name"
//waitVisible := "div.hq_title > h1"
//
//htmlContent, ok := crawlerAPI.GetHtml(url, waitVisible, true)
//
//if !ok {
// continue
//}
////logger.SugaredLogger.Infof("htmlContent: %s", htmlContent)
//document, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
//if err != nil {
// logger.SugaredLogger.Error(err.Error())
//}
//document.Find(waitVisible).Each(func(i int, selection *goquery.Selection) {
// name = strutil.RemoveNonPrintable(selection.Text())
// name = strutil.SplitAndTrim(name, " ", "")[0]
// logger.SugaredLogger.Infof("股票名称-:%s", name)
//})
us := &models.StockInfoUS{
Code: datum.C + ".US",
EName: datum.N,
FullName: datum.N,
Name: name,
Exchange: datum.E,
Type: datum.T,
}
db.Dao.Create(us)
}
}
func TestUSSINA(t *testing.T) {
//https://finance.sina.com.cn/stock/usstock/sector.shtml#cm
crawlerAPI := CrawlerApi{}
crawlerBaseInfo := CrawlerBaseInfo{
Name: "TestCrawler",
Description: "Test Crawler Description",
BaseUrl: "https://quote.eastmoney.com",
Headers: map[string]string{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0"},
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
crawlerAPI = crawlerAPI.NewCrawler(ctx, crawlerBaseInfo)
html, ok := crawlerAPI.GetHtml("https://finance.sina.com.cn/stock/usstock/sector.shtml#cm", "div#data", false)
if !ok {
return
}
document, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}
document.Find("div#data > table >tbody >tr").Each(func(i int, selection *goquery.Selection) {
tr := selection.Text()
logger.SugaredLogger.Infof("tr: %s", tr)
})
}
type Tick struct {
Code int `json:"code"`
Status string `json:"status"`
Data []struct {
C string `json:"c"`
N string `json:"n"`
T string `json:"t"`
E string `json:"e"`
} `json:"data"`
}

View File

@ -449,6 +449,9 @@ func SearchGuShiTongStockInfo(stock string, crawlTimeOut int64) *[]string {
if strutil.HasPrefixAny(stock, []string{"SZ", "SH", "sh", "sz"}) {
url = "https://gushitong.baidu.com/stock/ab-" + RemoveAllNonDigitChar(stock)
}
if strutil.HasPrefixAny(stock, []string{"us", "US", "gb_", "gb"}) {
url = "https://gushitong.baidu.com/stock/us-" + strings.Replace(stock, "gb_", "", 1)
}
logger.SugaredLogger.Infof("SearchGuShiTongStockInfo搜索股票-%s: %s", stock, url)
actions := []chromedp.Action{
@ -483,6 +486,10 @@ func GetFinancialReports(stockCode string, crawlTimeOut int64) *[]string {
stockCode = strings.ReplaceAll(stockCode, "hk", "")
stockCode = strings.ReplaceAll(stockCode, "HK", "")
}
if strutil.HasPrefixAny(stockCode, []string{"us", "gb_"}) {
stockCode = strings.ReplaceAll(stockCode, "us", "")
stockCode = strings.ReplaceAll(stockCode, "gb_", "")
}
// 创建一个 chromedp 上下文
timeoutCtx, timeoutCtxCancel := context.WithTimeout(context.Background(), time.Duration(crawlTimeOut)*time.Second)

View File

@ -25,5 +25,6 @@ func TestGetTopNewsList(t *testing.T) {
func TestSearchGuShiTongStockInfo(t *testing.T) {
SearchGuShiTongStockInfo("hk01810", 60)
SearchGuShiTongStockInfo("sh600745", 60)
SearchGuShiTongStockInfo("gb_goog", 60)
}

View File

@ -283,11 +283,24 @@ func (receiver StockDataApi) GetStockBaseInfo() {
}
func (receiver StockDataApi) GetStockCodeRealTimeData(StockCodes ...string) (*[]StockInfo, error) {
codes := slice.JoinFunc(StockCodes, ",", func(s string) string {
if strings.HasPrefix(s, "us") {
s = strings.Replace(s, "us", "gb_", 1)
}
if strings.HasPrefix(s, "US") {
s = strings.Replace(s, "US", "gb_", 1)
}
return strings.ToLower(s)
})
url := fmt.Sprintf(sinaStockUrl, time.Now().Unix(), codes)
//logger.SugaredLogger.Infof("GetStockCodeRealTimeData %s", url)
resp, err := receiver.client.R().
SetHeader("Host", "hq.sinajs.cn").
SetHeader("Referer", "https://finance.sina.com.cn/").
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0").
Get(fmt.Sprintf(sinaStockUrl, time.Now().Unix(), slice.Join(StockCodes, ",")))
Get(url)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return &[]StockInfo{}, err
@ -391,6 +404,9 @@ func (receiver StockDataApi) GetStockList(key string) []StockBasic {
var result3 []models.StockInfoHK
db.Dao.Model(&models.StockInfoHK{}).Where("name like ? or code like ?", "%"+key+"%", "%"+key+"%").Find(&result3)
var result4 []models.StockInfoUS
db.Dao.Model(&models.StockInfoUS{}).Where("name like ? or code like ?", "%"+key+"%", "%"+key+"%").Find(&result4)
for _, item := range result2 {
result = append(result, StockBasic{
TsCode: item.TsCode,
@ -409,6 +425,14 @@ func (receiver StockDataApi) GetStockList(key string) []StockBasic {
Market: "HK",
})
}
for _, item := range result4 {
result = append(result, StockBasic{
TsCode: item.Code,
Name: item.Name,
Fullname: item.Name,
Market: "US",
})
}
return result
}
@ -436,6 +460,9 @@ func ParseFullSingleStockData(data string) (*StockInfo, error) {
if strutil.ContainsAny(datas[0], []string{"hq_str_hk"}) {
result, err = ParseHKStockData(datas)
}
if strutil.ContainsAny(datas[0], []string{"hq_str_gb"}) {
result, err = ParseUSStockData(datas)
}
//logger.SugaredLogger.Infof("股票数据解析完成: %v", result)
marshal, err := json.Marshal(result)
@ -452,6 +479,65 @@ func ParseFullSingleStockData(data string) (*StockInfo, error) {
return stockInfo, nil
}
func ParseUSStockData(datas []string) (map[string]string, error) {
code := strings.Split(datas[0], "hq_str_")[1]
result := make(map[string]string)
parts := strutil.SplitAndTrim(datas[1], ",", "\"", ";")
//parts := strings.Split(data, ",")
if len(parts) < 30 {
return nil, fmt.Errorf("invalid data format")
}
/*
谷歌, 0
170.2100, 1 现价
-2.57, 2 涨跌幅
2025-02-28 09:38:50, 3 时间
-4.4900, 4 涨跌额
175.9400, 5 今日开盘价
176.5900, 6 区间
169.7520, 7 区间
208.7000, 8 52周区间
130.9500, 9 52周区间
25930485, 10 成交量
17083496, 11 10日均量
2074859900000, 12 市值
8.13, 13 每股收益
20.940000 , 14 市盈率
0.00, 15
0.00, 16
0.20, 17
0.00, 18
12190000000, 19
71, 20
170.2000, 21 盘后
-0.01, 22
-0.01, 23
Feb 27 07:59PM EST, 24
Feb 27 04:00PM EST, 25
174.7000, 26 前收盘
2917444, 27
1, 28
2025, 29
4456143849.0000, 30
176.1200, 31
163.7039, 32
496605933.1411, 33
170.2100, 34 现价
174.7000 35 前收盘
*/
result["股票代码"] = code
result["股票名称"] = parts[0]
result["今日开盘价"] = parts[5]
result["昨日收盘价"] = parts[26]
result["今日最高价"] = parts[6]
result["今日最低价"] = parts[7]
result["当前价格"] = parts[1]
result["日期"] = strutil.SplitAndTrim(parts[3], " ", "")[0]
result["时间"] = strutil.SplitAndTrim(parts[3], " ", "")[1]
logger.SugaredLogger.Infof("美股股票数据解析完成: %v", result)
return result, nil
}
func ParseHKStockData(datas []string) (map[string]string, error) {
code := strings.Split(datas[0], "hq_str_")[1]
result := make(map[string]string)

View File

@ -26,14 +26,15 @@ func TestGetTelegraph(t *testing.T) {
}
func TestGetFinancialReports(t *testing.T) {
GetFinancialReports("sz000802", 30)
//GetFinancialReports("sz000802", 30)
//GetFinancialReports("hk00927", 30)
GetFinancialReports("gb_aapl", 30)
}
func TestGetTelegraphSearch(t *testing.T) {
//url := "https://www.cls.cn/searchPage?keyword=%E9%97%BB%E6%B3%B0%E7%A7%91%E6%8A%80&type=telegram"
messages := SearchStockInfo("新 希 望", "telegram", 30)
messages := SearchStockInfo("谷歌", "telegram", 30)
for _, message := range *messages {
logger.SugaredLogger.Info(message)
}
@ -82,7 +83,7 @@ func TestParseFullSingleStockData(t *testing.T) {
SetHeader("Host", "hq.sinajs.cn").
SetHeader("Referer", "https://finance.sina.com.cn/").
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0").
Get(fmt.Sprintf(sinaStockUrl, time.Now().Unix(), "sh600584,sz000938,hk01810,hk00856"))
Get(fmt.Sprintf(sinaStockUrl, time.Now().Unix(), "sh600584,sz000938,hk01810,hk00856,gb_aapl"))
if err != nil {
logger.SugaredLogger.Error(err.Error())
}

View File

@ -17,7 +17,7 @@ var SensitiveWords = strings.Split("戊边、戍边、戌边、边防、李鹏
func ReplaceSensitiveWords(text string) string {
for _, word := range SensitiveWords {
if strings.Contains(text, word) {
text = strings.ReplaceAll(text, word, "*")
text = strings.ReplaceAll(text, word, "")
}
}
return text

View File

@ -176,6 +176,21 @@ func (receiver StockInfoHK) TableName() string {
return "stock_base_info_hk"
}
type StockInfoUS struct {
gorm.Model
Code string `json:"code"`
Name string `json:"name"`
FullName string `json:"fullName"`
EName string `json:"eName"`
Exchange string `json:"exchange"`
Type string `json:"type"`
IsDel soft_delete.DeletedAt `gorm:"softDelete:flag"`
}
func (receiver StockInfoUS) TableName() string {
return "stock_base_info_us"
}
type Resp struct {
Code int `json:"code"`
Message string `json:"message"`

View File

@ -10,12 +10,12 @@ import {
} from '../wailsjs/runtime'
import {h, ref} from "vue";
import { RouterLink } from 'vue-router'
import {darkTheme, NIcon, NText,} from 'naive-ui'
import {darkTheme, NGradientText, NIcon, NText,} from 'naive-ui'
import {
SettingsOutline,
ReorderTwoOutline,
ExpandOutline,
PowerOutline, LogoGithub, MoveOutline, WalletOutline, StarOutline,
PowerOutline, LogoGithub, MoveOutline, WalletOutline, StarOutline, AlarmOutline, SparklesOutline,
} from '@vicons/ionicons5'
const content = ref('数据来源于网络,仅供参考;投资有风险,入市需谨慎')
const isFullscreen = ref(false)
@ -36,7 +36,7 @@ const menuOptions = ref([
},
}
},
{ default: () => '我的自选',}
{ default: () => '股票自选',}
),
key: 'stock',
icon: renderIcon(StarOutline),
@ -49,6 +49,29 @@ const menuOptions = ref([
},
]
},
{
label: () =>
h(
NGradientText,
{
type: 'warning',
style: {
'text-decoration': 'line-through',
}
},
{ default: () => '基金自选' }
),
key: 'fund',
icon: renderIcon(SparklesOutline),
children:[
{
label: ()=> h(NText, {type:realtimeProfit.value>0?'error':'success'},{ default: () => '敬请期待!'}),
key: 'realtimeProfit',
show: realtimeProfit.value,
icon: renderIcon(AlarmOutline),
},
]
},
{
label: () =>
h(

View File

@ -465,7 +465,7 @@ function search(code,name){
//window.open("https://www.cls.cn/stock?code="+code)
//window.open("https://quote.eastmoney.com/"+code+".html")
//window.open("https://finance.sina.com.cn/realstock/company/"+code+"/nc.shtml")
window.open("https://www.iwencai.com/unifiedwap/result?w="+code)
window.open("https://www.iwencai.com/unifiedwap/result?w="+name)
//window.open("https://www.iwencai.com/chat/?question="+code)
}, 500)
}
@ -486,16 +486,34 @@ function showFenshi(code,name){
data.code=code
data.name=name
data.fenshiURL='http://image.sinajs.cn/newchart/min/n/'+data.code+'.gif'+"?t="+Date.now()
if(code.startsWith('hk')){
data.fenshiURL='http://image.sinajs.cn/newchart/hk_stock/min/'+data.code.replace("hk","")+'.gif'+"?t="+Date.now()
}
if(code.startsWith('gb_')){
data.fenshiURL='http://image.sinajs.cn/newchart/usstock/min/'+data.code.replace("gb_","")+'.gif'+"?t="+Date.now()
}
modalShow2.value=true
}
function showK(code,name){
data.code=code
data.name=name
data.kURL='http://image.sinajs.cn/newchart/daily/n/'+data.code+'.gif'+"?t="+Date.now()
if(code.startsWith('hk')){
data.kURL='http://image.sinajs.cn/newchart/hk_stock/daily/'+data.code.replace("hk","")+'.gif'+"?t="+Date.now()
}
if(code.startsWith('gb_')){
data.kURL='http://image.sinajs.cn/newchart/usstock/daily/'+data.code.replace("gb_","")+'.gif'+"?t="+Date.now()
}
//https://image.sinajs.cn/newchart/usstock/daily/dji.gif
//https://image.sinajs.cn/newchart/hk_stock/daily/06030.gif?1740729404273
modalShow3.value=true
}
function updateCostPriceAndVolumeNew(code,price,volume,alarm,formModel){
if(formModel.sort){

View File

@ -59,6 +59,7 @@ func main() {
db.Dao.AutoMigrate(&data.Settings{})
db.Dao.AutoMigrate(&models.AIResponseResult{})
db.Dao.AutoMigrate(&models.StockInfoHK{})
db.Dao.AutoMigrate(&models.StockInfoUS{})
if stocksBin != nil && len(stocksBin) > 0 {
go initStockData()