go-stock/app.go
ArvinLovegood b1a9a8d4d8 refactor(update):优化更新检查逻辑
- 修改 CheckUpdate 函数签名,添加 flag 参数
- 根据 flag 参数控制是否显示"当前版本无更新"的通知
- 调整前端按钮点击事件,传递参数 1 给 CheckUpdate 函数
- 优化后端更新检查流程,减少不必要的通知推送
2025-07-17 17:39:59 +08:00

1353 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/duke-git/lancet/v2/cryptor"
"github.com/inconshreveable/go-update"
"go-stock/backend/data"
"go-stock/backend/db"
"go-stock/backend/logger"
"go-stock/backend/models"
"os"
"path/filepath"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/coocood/freecache"
"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/go-resty/resty/v2"
"github.com/robfig/cron/v3"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App struct
type App struct {
ctx context.Context
cache *freecache.Cache
cron *cron.Cron
cronEntrys map[string]cron.EntryID
AiTools []data.Tool
SponsorInfo map[string]any
}
// NewApp creates a new App application struct
func NewApp() *App {
cacheSize := 512 * 1024
cache := freecache.NewCache(cacheSize)
c := cron.New(cron.WithSeconds())
c.Start()
var tools []data.Tool
tools = AddTools(tools)
return &App{
cache: cache,
cron: c,
cronEntrys: make(map[string]cron.EntryID),
AiTools: tools,
}
}
func AddTools(tools []data.Tool) []data.Tool {
tools = append(tools, data.Tool{
Type: "function",
Function: data.ToolFunction{
Name: "SearchStockByIndicators",
Description: "根据自然语言筛选股票,返回自然语言选股条件要求的股票所有相关数据。输入股票名称可以获取当前股票最新的股价交易数据和基础财务指标信息,多个股票名称使用,分隔。",
Parameters: data.FunctionParameters{
Type: "object",
Properties: map[string]any{
"words": map[string]any{
"type": "string",
"description": "选股自然语言。" +
"例1创新药,半导体;PE<30;净利润增长率>50%。 " +
"例2上证指数,科创50。 " +
"例3长电科技,上海贝岭。" +
"例4长电科技,上海贝岭;KDJ,MACD,RSI,BOLL,主力净流入/流出" +
"例5换手率大于3%小于25%.量比1以上. 10日内有过涨停.股价处于峰值的二分之一以下.流通股本<100亿.当日和连续四日净流入;股价在20日均线以上.分时图股价在均线之上.热门板块下涨幅领先的A股. 当日量能20000手以上.沪深个股.近一年市盈率波动小于150%.MACD金叉;不要ST股及不要退市股非北交所每股收益>0。" +
"例6沪深主板.流通市值小于100亿.市值大于10亿.60分钟dif大于dea.60分钟skdj指标k值大于d值.skdj指标k值小于90.换手率大于3%.成交额大于1亿元.量比大于2.涨幅大于2%小于7%.股价大于5小于50.创业板.10日均线大于20日均线;不要ST股及不要退市股;不要北交所;不要科创板;不要创业板。" +
"例7股价在20日线上一月之内涨停次数>=1量比大于1换手率大于3%,流通市值大于 50亿小于200亿。" +
"例8基本条件前期有爆量回调到 10 日线,当日是缩量阴线,均线趋势向上。;优选条件:一月之内涨停次数>=1",
},
},
Required: []string{"words"},
},
},
})
tools = append(tools, data.Tool{
Type: "function",
Function: data.ToolFunction{
Name: "GetStockKLine",
Description: "获取股票日K线数据。",
Parameters: data.FunctionParameters{
Type: "object",
Properties: map[string]any{
"days": map[string]any{
"type": "string",
"description": "日K数据条数",
},
"stockCode": map[string]any{
"type": "string",
"description": "股票代码A股sh,sz开头;港股hk开头,美股us开头",
},
},
Required: []string{"days", "stockCode"},
},
},
})
return tools
}
func (a *App) GetSponsorInfo() map[string]any {
return a.SponsorInfo
}
func (a *App) CheckSponsorCode(sponsorCode string) map[string]any {
sponsorCode = strutil.Trim(sponsorCode)
if sponsorCode != "" {
encrypted, err := hex.DecodeString(sponsorCode)
if err != nil {
return map[string]any{
"code": 0,
"msg": "赞助码格式错误,请输入正确的赞助码!",
}
}
key, err := hex.DecodeString(BuildKey)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return map[string]any{
"code": 0,
"msg": "版本错误,不支持赞助码!",
}
}
decrypt := cryptor.AesEcbDecrypt(encrypted, key)
if decrypt == nil || len(decrypt) == 0 {
return map[string]any{
"code": 0,
"msg": "赞助码错误,请输入正确的赞助码!",
}
}
return map[string]any{
"code": 1,
"msg": "赞助码校验成功,感谢您的支持!",
}
} else {
return map[string]any{"code": 0, "message": "赞助码不能为空,请输入正确的赞助码!"}
}
}
func (a *App) CheckUpdate(flag int) {
sponsorCode := strutil.Trim(a.GetConfig().SponsorCode)
if sponsorCode != "" {
encrypted, err := hex.DecodeString(sponsorCode)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
key, err := hex.DecodeString(BuildKey)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
decrypt := string(cryptor.AesEcbDecrypt(encrypted, key))
err = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
}
releaseVersion := &models.GitHubReleaseVersion{}
_, err := resty.New().R().
SetResult(releaseVersion).
Get("https://api.github.com/repos/ArvinLovegood/go-stock/releases/latest")
if err != nil {
logger.SugaredLogger.Errorf("get github release version error:%s", err.Error())
return
}
logger.SugaredLogger.Infof("releaseVersion:%+v", releaseVersion.TagName)
if releaseVersion.TagName != Version {
tag := &models.Tag{}
_, err = resty.New().R().
SetResult(tag).
Get("https://api.github.com/repos/ArvinLovegood/go-stock/git/ref/tags/" + releaseVersion.TagName)
if err == nil {
releaseVersion.Tag = *tag
}
commit := &models.Commit{}
_, err = resty.New().R().
SetResult(commit).
Get(tag.Object.Url)
if err == nil {
releaseVersion.Commit = *commit
}
if !(IsWindows() || IsMacOS()) {
go runtime.EventsEmit(a.ctx, "updateVersion", releaseVersion)
return
}
downloadUrl := fmt.Sprintf("https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe", releaseVersion.TagName)
if IsMacOS() {
downloadUrl = fmt.Sprintf("https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal", releaseVersion.TagName)
}
sponsorCode = strutil.Trim(a.GetConfig().SponsorCode)
if sponsorCode != "" {
encrypted, err := hex.DecodeString(sponsorCode)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
key, err := hex.DecodeString(BuildKey)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
decrypt := string(cryptor.AesEcbDecrypt(encrypted, key))
err = json.Unmarshal([]byte(decrypt), &a.SponsorInfo)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
vipStartTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.SponsorInfo["vipStartTime"].(string), time.Local)
vipEndTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.SponsorInfo["vipEndTime"].(string), time.Local)
vipAuthTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.SponsorInfo["vipAuthTime"].(string), time.Local)
if err != nil {
logger.SugaredLogger.Error(err.Error())
return
}
isVip := false
if time.Now().After(vipAuthTime) && time.Now().After(vipStartTime) && time.Now().Before(vipEndTime) {
isVip = true
}
if IsWindows() {
if isVip {
if a.SponsorInfo["winDownUrl"] == nil {
downloadUrl = fmt.Sprintf("https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe", releaseVersion.TagName)
} else {
downloadUrl = a.SponsorInfo["winDownUrl"].(string)
}
} else {
downloadUrl = fmt.Sprintf("https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-windows-amd64.exe", releaseVersion.TagName)
}
}
if IsMacOS() {
if isVip {
if a.SponsorInfo["macDownUrl"] == nil {
downloadUrl = fmt.Sprintf("https://gitproxy.click/https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal", releaseVersion.TagName)
} else {
downloadUrl = a.SponsorInfo["macDownUrl"].(string)
}
} else {
downloadUrl = fmt.Sprintf("https://github.com/ArvinLovegood/go-stock/releases/download/%s/go-stock-darwin-universal", releaseVersion.TagName)
}
}
}
go runtime.EventsEmit(a.ctx, "newsPush", map[string]any{
"time": "发现新版本:" + releaseVersion.TagName,
"isRed": false,
"source": "go-stock",
"content": fmt.Sprintf("%s", commit.Message),
})
resp, err := resty.New().R().Get(downloadUrl)
if err != nil {
go runtime.EventsEmit(a.ctx, "newsPush", map[string]any{
"time": "新版本:" + releaseVersion.TagName,
"isRed": true,
"source": "go-stock",
"content": commit.Message + "\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。",
})
return
}
body := resp.Body()
if len(body) < 1024 {
go runtime.EventsEmit(a.ctx, "newsPush", map[string]any{
"time": "新版本:" + releaseVersion.TagName,
"isRed": true,
"source": "go-stock",
"content": commit.Message + "\n新版本下载失败,请稍后重试或请前往 https://github.com/ArvinLovegood/go-stock/releases 手动下载替换文件。",
})
return
}
err = update.Apply(bytes.NewReader(body), update.Options{})
if err != nil {
logger.SugaredLogger.Error("更新失败: ", err.Error())
go runtime.EventsEmit(a.ctx, "updateVersion", releaseVersion)
return
} else {
go runtime.EventsEmit(a.ctx, "newsPush", map[string]any{
"time": "新版本:" + releaseVersion.TagName,
"isRed": true,
"source": "go-stock",
"content": "版本更新完成,下次重启软件生效.",
})
}
} else {
if flag == 1 {
go runtime.EventsEmit(a.ctx, "newsPush", map[string]any{
"time": "当前版本:" + Version,
"isRed": false,
"source": "go-stock",
"content": "当前版本无更新",
})
}
}
}
// domReady is called after front-end resources have been loaded
func (a *App) domReady(ctx context.Context) {
defer PanicHandler()
defer func() {
go runtime.EventsEmit(ctx, "loadingMsg", "done")
}()
//if stocksBin != nil && len(stocksBin) > 0 {
// go runtime.EventsEmit(a.ctx, "loadingMsg", "检查A股基础信息...")
// go initStockData(a.ctx)
//}
//
//if stocksBinHK != nil && len(stocksBinHK) > 0 {
// go runtime.EventsEmit(a.ctx, "loadingMsg", "检查港股基础信息...")
// go initStockDataHK(a.ctx)
//}
//
//if stocksBinUS != nil && len(stocksBinUS) > 0 {
// go runtime.EventsEmit(a.ctx, "loadingMsg", "检查美股基础信息...")
// go initStockDataUS(a.ctx)
//}
updateBasicInfo()
// Add your action here
//定时更新数据
config := data.NewSettingsApi(&data.Settings{}).GetConfig()
go func() {
interval := config.RefreshInterval
if interval <= 0 {
interval = 1
}
//ticker := time.NewTicker(time.Second * time.Duration(interval))
//defer ticker.Stop()
//for range ticker.C {
// MonitorStockPrices(a)
//}
id, err := a.cron.AddFunc(fmt.Sprintf("@every %ds", interval), func() {
MonitorStockPrices(a)
})
if err != nil {
logger.SugaredLogger.Errorf("AddFunc error:%s", err.Error())
} else {
a.cronEntrys["MonitorStockPrices"] = id
}
entryID, err := a.cron.AddFunc(fmt.Sprintf("@every %ds", interval+10), func() {
news := data.NewMarketNewsApi().GetNewTelegraph(30)
if config.EnablePushNews {
go a.NewsPush(news)
}
go runtime.EventsEmit(a.ctx, "newTelegraph", news)
})
if err != nil {
logger.SugaredLogger.Errorf("AddFunc error:%s", err.Error())
} else {
a.cronEntrys["GetNewTelegraph"] = entryID
}
entryIDSina, err := a.cron.AddFunc(fmt.Sprintf("@every %ds", interval+10), func() {
news := data.NewMarketNewsApi().GetSinaNews(30)
if config.EnablePushNews {
go a.NewsPush(news)
}
go runtime.EventsEmit(a.ctx, "newSinaNews", news)
})
if err != nil {
logger.SugaredLogger.Errorf("AddFunc error:%s", err.Error())
} else {
a.cronEntrys["newSinaNews"] = entryIDSina
}
}()
//刷新基金净值信息
go func() {
//ticker := time.NewTicker(time.Second * time.Duration(60))
//defer ticker.Stop()
//for range ticker.C {
// MonitorFundPrices(a)
//}
if config.EnableFund {
id, err := a.cron.AddFunc(fmt.Sprintf("@every %ds", 60), func() {
MonitorFundPrices(a)
})
if err != nil {
logger.SugaredLogger.Errorf("AddFunc error:%s", err.Error())
} else {
a.cronEntrys["MonitorFundPrices"] = id
}
}
}()
if config.EnableNews {
//go func() {
// ticker := time.NewTicker(time.Second * time.Duration(60))
// defer ticker.Stop()
// for range ticker.C {
// telegraph := refreshTelegraphList()
// if telegraph != nil {
// go runtime.EventsEmit(a.ctx, "telegraph", telegraph)
// }
// }
//
//}()
id, err := a.cron.AddFunc(fmt.Sprintf("@every %ds", 60), func() {
telegraph := refreshTelegraphList()
if telegraph != nil {
go runtime.EventsEmit(a.ctx, "telegraph", telegraph)
}
})
if err != nil {
logger.SugaredLogger.Errorf("AddFunc error:%s", err.Error())
} else {
a.cronEntrys["refreshTelegraphList"] = id
}
go runtime.EventsEmit(a.ctx, "telegraph", refreshTelegraphList())
}
go MonitorStockPrices(a)
if config.EnableFund {
go MonitorFundPrices(a)
go data.NewFundApi().AllFund()
}
//检查新版本
go func() {
a.CheckUpdate(0)
a.CheckStockBaseInfo(a.ctx)
a.cron.AddFunc("30 05 8,12,20 * * *", func() {
logger.SugaredLogger.Errorf("Checking for updates...")
a.CheckUpdate(0)
})
}()
//检查谷歌浏览器
//go func() {
// f := checkChromeOnWindows()
// if !f {
// go runtime.EventsEmit(a.ctx, "warnMsg", "谷歌浏览器未安装,ai分析功能可能无法使用")
// }
//}()
//检查Edge浏览器
//go func() {
// path, e := checkEdgeOnWindows()
// if !e {
// go runtime.EventsEmit(a.ctx, "warnMsg", "Edge浏览器未安装,ai分析功能可能无法使用")
// } else {
// logger.SugaredLogger.Infof("Edge浏览器已安装路径为: %s", path)
// }
//}()
followList := data.NewStockDataApi().GetFollowList(0)
for _, follow := range *followList {
if follow.Cron == nil || *follow.Cron == "" {
continue
}
entryID, err := a.cron.AddFunc(*follow.Cron, a.AddCronTask(follow))
if err != nil {
logger.SugaredLogger.Errorf("添加自动分析任务失败:%s cron=%s entryID:%v", follow.Name, *follow.Cron, entryID)
continue
}
a.cronEntrys[follow.StockCode] = entryID
}
logger.SugaredLogger.Infof("domReady-cronEntrys:%+v", a.cronEntrys)
}
func (a *App) CheckStockBaseInfo(ctx context.Context) {
defer PanicHandler()
defer func() {
go runtime.EventsEmit(ctx, "loadingMsg", "done")
}()
stockBasics := &[]data.StockBasic{}
resty.New().R().
SetHeader("user", "go-stock").
SetResult(stockBasics).
Get("http://8.134.249.145:18080/go-stock/stock_basic.json")
for _, stock := range *stockBasics {
stockInfo := &data.StockBasic{
TsCode: stock.TsCode,
Name: stock.Name,
Symbol: stock.Symbol,
BKCode: stock.BKCode,
BKName: stock.BKName,
}
db.Dao.Model(&data.StockBasic{}).Where("ts_code = ?", stock.TsCode).First(stockInfo)
if stockInfo.ID == 0 {
db.Dao.Model(&data.StockBasic{}).Create(stockInfo)
} else {
db.Dao.Model(&data.StockBasic{}).Where("ts_code = ?", stock.TsCode).Updates(stockInfo)
}
}
stockHKBasics := &[]models.StockInfoHK{}
resty.New().R().
SetHeader("user", "go-stock").
SetResult(stockHKBasics).
Get("http://8.134.249.145:18080/go-stock/stock_base_info_hk.json")
for _, stock := range *stockHKBasics {
stockInfo := &models.StockInfoHK{
Code: stock.Code,
Name: stock.Name,
BKName: stock.BKName,
BKCode: stock.BKCode,
}
db.Dao.Model(&models.StockInfoHK{}).Where("code = ?", stock.Code).First(stockInfo)
if stockInfo.ID == 0 {
db.Dao.Model(&models.StockInfoHK{}).Create(stockInfo)
} else {
db.Dao.Model(&models.StockInfoHK{}).Where("code = ?", stock.Code).Updates(stockInfo)
}
}
stockUSBasics := &[]models.StockInfoUS{}
resty.New().R().
SetHeader("user", "go-stock").
SetResult(stockUSBasics).
Get("http://8.134.249.145:18080/go-stock/stock_base_info_us.json")
for _, stock := range *stockUSBasics {
stockInfo := &models.StockInfoUS{
Code: stock.Code,
Name: stock.Name,
BKName: stock.BKName,
BKCode: stock.BKCode,
}
db.Dao.Model(&models.StockInfoUS{}).Where("code = ?", stock.Code).First(stockInfo)
if stockInfo.ID == 0 {
db.Dao.Model(&models.StockInfoUS{}).Create(stockInfo)
} else {
db.Dao.Model(&models.StockInfoUS{}).Where("code = ?", stock.Code).Updates(stockInfo)
}
}
}
func (a *App) NewsPush(news *[]models.Telegraph) {
for _, telegraph := range *news {
//if telegraph.IsRed {
go runtime.EventsEmit(a.ctx, "newsPush", telegraph)
go data.NewAlertWindowsApi("go-stock", telegraph.Source+" "+telegraph.Time, telegraph.Content, string(icon)).SendNotification()
//}
}
}
func (a *App) AddCronTask(follow data.FollowedStock) func() {
return func() {
go runtime.EventsEmit(a.ctx, "warnMsg", "开始自动分析"+follow.Name+"_"+follow.StockCode)
ai := data.NewDeepSeekOpenAi(a.ctx)
msgs := ai.NewChatStream(follow.Name, follow.StockCode, "", nil, a.AiTools)
var res strings.Builder
chatId := ""
question := ""
for msg := range msgs {
if msg["extraContent"] != nil {
res.WriteString(msg["extraContent"].(string) + "\n")
}
if msg["content"] != nil {
res.WriteString(msg["content"].(string))
}
if msg["chatId"] != nil {
chatId = msg["chatId"].(string)
}
if msg["question"] != nil {
question = msg["question"].(string)
}
}
data.NewDeepSeekOpenAi(a.ctx).SaveAIResponseResult(follow.StockCode, follow.Name, res.String(), chatId, question)
go runtime.EventsEmit(a.ctx, "warnMsg", "AI分析完成"+follow.Name+"_"+follow.StockCode)
}
}
func refreshTelegraphList() *[]string {
url := "https://www.cls.cn/telegraph"
response, err := resty.New().R().
SetHeader("Referer", "https://www.cls.cn/").
SetHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.60").
Get(fmt.Sprintf(url))
if err != nil {
return &[]string{}
}
//logger.SugaredLogger.Info(string(response.Body()))
document, err := goquery.NewDocumentFromReader(strings.NewReader(string(response.Body())))
if err != nil {
return &[]string{}
}
var telegraph []string
document.Find("div.telegraph-content-box").Each(func(i int, selection *goquery.Selection) {
//logger.SugaredLogger.Info(selection.Text())
telegraph = append(telegraph, selection.Text())
})
return &telegraph
}
// isTradingDay 判断是否是交易日
func isTradingDay(date time.Time) bool {
weekday := date.Weekday()
// 判断是否是周末
if weekday == time.Saturday || weekday == time.Sunday {
return false
}
// 这里可以添加具体的节假日判断逻辑
// 例如:判断是否是春节、国庆节等
return true
}
// isTradingTime 判断是否是交易时间
func isTradingTime(date time.Time) bool {
if !isTradingDay(date) {
return false
}
hour, minute, _ := date.Clock()
// 判断是否在9:15到11:30之间
if (hour == 9 && minute >= 15) || (hour == 10) || (hour == 11 && minute <= 30) {
return true
}
// 判断是否在13:00到15:00之间
if (hour == 13) || (hour == 14) || (hour == 15 && minute <= 0) {
return true
}
return false
}
// IsHKTradingTime 判断当前时间是否在港股交易时间内
func IsHKTradingTime(date time.Time) bool {
hour, minute, _ := date.Clock()
// 开市前竞价时段09:00 - 09:30
if (hour == 9 && minute >= 0) || (hour == 9 && minute <= 30) {
return true
}
// 上午持续交易时段09:30 - 12:00
if (hour == 9 && minute > 30) || (hour >= 10 && hour < 12) || (hour == 12 && minute == 0) {
return true
}
// 下午持续交易时段13:00 - 16:00
if (hour == 13 && minute >= 0) || (hour >= 14 && hour < 16) || (hour == 16 && minute == 0) {
return true
}
// 收市竞价交易时段16:00 - 16:10
if (hour == 16 && minute >= 0) || (hour == 16 && minute <= 10) {
return true
}
return false
}
// IsUSTradingTime 判断当前时间是否在美股交易时间内
func IsUSTradingTime(date time.Time) bool {
// 获取美国东部时区
est, err := time.LoadLocation("America/New_York")
var estTime time.Time
if err != nil {
estTime = date.Add(time.Hour * -12)
} else {
// 将当前时间转换为美国东部时间
estTime = date.In(est)
}
// 判断是否是周末
weekday := estTime.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
return false
}
// 获取小时和分钟
hour, minute, _ := estTime.Clock()
// 判断是否在4:00 AM到9:30 AM之间盘前
if (hour == 4) || (hour == 5) || (hour == 6) || (hour == 7) || (hour == 8) || (hour == 9 && minute < 30) {
return true
}
// 判断是否在9:30 AM到4:00 PM之间盘中
if (hour == 9 && minute >= 30) || (hour >= 10 && hour < 16) || (hour == 16 && minute == 0) {
return true
}
// 判断是否在4:00 PM到8:00 PM之间盘后
if (hour == 16 && minute > 0) || (hour >= 17 && hour < 20) || (hour == 20 && minute == 0) {
return true
}
return false
}
func MonitorFundPrices(a *App) {
dest := &[]data.FollowedFund{}
db.Dao.Model(&data.FollowedFund{}).Find(dest)
for _, follow := range *dest {
_, err := data.NewFundApi().CrawlFundBasic(follow.Code)
if err != nil {
logger.SugaredLogger.Errorf("获取基金基本信息失败,基金代码:%s错误信息%s", follow.Code, err.Error())
continue
}
data.NewFundApi().CrawlFundNetEstimatedUnit(follow.Code)
data.NewFundApi().CrawlFundNetUnitValue(follow.Code)
}
}
func GetStockInfos(follows ...data.FollowedStock) *[]data.StockInfo {
stockInfos := make([]data.StockInfo, 0)
stockCodes := make([]string, 0)
for _, follow := range follows {
if strutil.HasPrefixAny(follow.StockCode, []string{"SZ", "SH", "sh", "sz"}) && (!isTradingTime(time.Now())) {
continue
}
if strutil.HasPrefixAny(follow.StockCode, []string{"hk", "HK"}) && (!IsHKTradingTime(time.Now())) {
continue
}
if strutil.HasPrefixAny(follow.StockCode, []string{"us", "US", "gb_"}) && (!IsUSTradingTime(time.Now())) {
continue
}
stockCodes = append(stockCodes, follow.StockCode)
}
stockData, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCodes...)
for _, info := range *stockData {
v, ok := slice.FindBy(follows, func(idx int, follow data.FollowedStock) bool {
if strutil.HasPrefixAny(follow.StockCode, []string{"US", "us"}) {
return strings.ToLower(strings.Replace(follow.StockCode, "us", "gb_", 1)) == info.Code
}
return follow.StockCode == info.Code
})
if ok {
addStockFollowData(v, &info)
stockInfos = append(stockInfos, info)
}
}
return &stockInfos
}
func getStockInfo(follow data.FollowedStock) *data.StockInfo {
stockCode := follow.StockCode
stockDatas, err := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)
if err != nil || len(*stockDatas) == 0 {
return &data.StockInfo{}
}
stockData := (*stockDatas)[0]
addStockFollowData(follow, &stockData)
return &stockData
}
func addStockFollowData(follow data.FollowedStock, stockData *data.StockInfo) {
stockData.PrePrice = follow.Price //上次当前价格
stockData.Sort = follow.Sort
stockData.CostPrice = follow.CostPrice //成本价
stockData.CostVolume = follow.Volume //成本量
stockData.AlarmChangePercent = follow.AlarmChangePercent
stockData.AlarmPrice = follow.AlarmPrice
stockData.Groups = follow.Groups
//当前价格
price, _ := convertor.ToFloat(stockData.Price)
//当前价格为0 时 使用卖一价格作为当前价格
if price == 0 {
price, _ = convertor.ToFloat(stockData.A1P)
}
//当前价格依然为0 时 使用买一报价作为当前价格
if price == 0 {
price, _ = convertor.ToFloat(stockData.B1P)
}
//昨日收盘价
preClosePrice, _ := convertor.ToFloat(stockData.PreClose)
//当前价格依然为0 时 使用昨日收盘价为当前价格
if price == 0 {
price = preClosePrice
}
//今日最高价
highPrice, _ := convertor.ToFloat(stockData.High)
if highPrice == 0 {
highPrice, _ = convertor.ToFloat(stockData.Open)
}
//今日最低价
lowPrice, _ := convertor.ToFloat(stockData.Low)
if lowPrice == 0 {
lowPrice, _ = convertor.ToFloat(stockData.Open)
}
//开盘价
//openPrice, _ := convertor.ToFloat(stockData.Open)
if price > 0 && preClosePrice > 0 {
stockData.ChangePrice = mathutil.RoundToFloat(price-preClosePrice, 2)
stockData.ChangePercent = mathutil.RoundToFloat(mathutil.Div(price-preClosePrice, preClosePrice)*100, 3)
}
if highPrice > 0 && preClosePrice > 0 {
stockData.HighRate = mathutil.RoundToFloat(mathutil.Div(highPrice-preClosePrice, preClosePrice)*100, 3)
}
if lowPrice > 0 && preClosePrice > 0 {
stockData.LowRate = mathutil.RoundToFloat(mathutil.Div(lowPrice-preClosePrice, preClosePrice)*100, 3)
}
if follow.CostPrice > 0 && follow.Volume > 0 {
if price > 0 {
stockData.Profit = mathutil.RoundToFloat(mathutil.Div(price-follow.CostPrice, follow.CostPrice)*100, 3)
stockData.ProfitAmount = mathutil.RoundToFloat((price-follow.CostPrice)*float64(follow.Volume), 2)
stockData.ProfitAmountToday = mathutil.RoundToFloat((price-preClosePrice)*float64(follow.Volume), 2)
} else {
//未开盘时当前价格为昨日收盘价
stockData.Profit = mathutil.RoundToFloat(mathutil.Div(preClosePrice-follow.CostPrice, follow.CostPrice)*100, 3)
stockData.ProfitAmount = mathutil.RoundToFloat((preClosePrice-follow.CostPrice)*float64(follow.Volume), 2)
// 未开盘时,今日盈亏为 0
stockData.ProfitAmountToday = 0
}
}
//logger.SugaredLogger.Debugf("stockData:%+v", stockData)
if follow.Price != price && price > 0 {
go db.Dao.Model(follow).Where("stock_code = ?", follow.StockCode).Updates(map[string]interface{}{
"price": price,
})
}
}
// shutdown is called at application termination
func (a *App) shutdown(ctx context.Context) {
defer PanicHandler()
// Perform your teardown here
//os.Exit(0)
logger.SugaredLogger.Infof("application shutdown Version:%s", Version)
}
// Greet returns a greeting for the given name
func (a *App) Greet(stockCode string) *data.StockInfo {
//stockInfo, _ := data.NewStockDataApi().GetStockCodeRealTimeData(stockCode)
follow := &data.FollowedStock{
StockCode: stockCode,
}
db.Dao.Model(follow).Where("stock_code = ?", stockCode).Preload("Groups").Preload("Groups.GroupInfo").First(follow)
stockInfo := getStockInfo(*follow)
return stockInfo
}
func (a *App) Follow(stockCode string) string {
return data.NewStockDataApi().Follow(stockCode)
}
func (a *App) UnFollow(stockCode string) string {
return data.NewStockDataApi().UnFollow(stockCode)
}
func (a *App) GetFollowList(groupId int) *[]data.FollowedStock {
return data.NewStockDataApi().GetFollowList(groupId)
}
func (a *App) GetStockList(key string) []data.StockBasic {
return data.NewStockDataApi().GetStockList(key)
}
func (a *App) SetCostPriceAndVolume(stockCode string, price float64, volume int64) string {
return data.NewStockDataApi().SetCostPriceAndVolume(price, volume, stockCode)
}
func (a *App) SetAlarmChangePercent(val, alarmPrice float64, stockCode string) string {
return data.NewStockDataApi().SetAlarmChangePercent(val, alarmPrice, stockCode)
}
func (a *App) SetStockSort(sort int64, stockCode string) {
data.NewStockDataApi().SetStockSort(sort, stockCode)
}
func (a *App) SendDingDingMessage(message string, stockCode string) string {
ttl, _ := a.cache.TTL([]byte(stockCode))
logger.SugaredLogger.Infof("stockCode %s ttl:%d", stockCode, ttl)
if ttl > 0 {
return ""
}
err := a.cache.Set([]byte(stockCode), []byte("1"), 60*5)
if err != nil {
logger.SugaredLogger.Errorf("set cache error:%s", err.Error())
return ""
}
return data.NewDingDingAPI().SendDingDingMessage(message)
}
// SendDingDingMessageByType msgType 报警类型: 1 涨跌报警;2 股价报警 3 成本价报警
func (a *App) SendDingDingMessageByType(message string, stockCode string, msgType int) string {
if strutil.HasPrefixAny(stockCode, []string{"SZ", "SH", "sh", "sz"}) && (!isTradingTime(time.Now())) {
return "非A股交易时间"
}
if strutil.HasPrefixAny(stockCode, []string{"hk", "HK"}) && (!IsHKTradingTime(time.Now())) {
return "非港股交易时间"
}
if strutil.HasPrefixAny(stockCode, []string{"us", "US", "gb_"}) && (!IsUSTradingTime(time.Now())) {
return "非美股交易时间"
}
ttl, _ := a.cache.TTL([]byte(stockCode))
//logger.SugaredLogger.Infof("stockCode %s ttl:%d", stockCode, ttl)
if ttl > 0 {
return ""
}
err := a.cache.Set([]byte(stockCode), []byte("1"), getMsgTypeTTL(msgType))
if err != nil {
logger.SugaredLogger.Errorf("set cache error:%s", err.Error())
return ""
}
stockInfo := &data.StockInfo{}
db.Dao.Model(stockInfo).Where("code = ?", stockCode).First(stockInfo)
go data.NewAlertWindowsApi("go-stock消息通知", getMsgTypeName(msgType), GenNotificationMsg(stockInfo), "").SendNotification()
return data.NewDingDingAPI().SendDingDingMessage(message)
}
func (a *App) NewChatStream(stock, stockCode, question string, sysPromptId *int, enableTools bool) {
var msgs <-chan map[string]any
if enableTools {
msgs = data.NewDeepSeekOpenAi(a.ctx).NewChatStream(stock, stockCode, question, sysPromptId, a.AiTools)
} else {
msgs = data.NewDeepSeekOpenAi(a.ctx).NewChatStream(stock, stockCode, question, sysPromptId, []data.Tool{})
}
for msg := range msgs {
runtime.EventsEmit(a.ctx, "newChatStream", msg)
}
runtime.EventsEmit(a.ctx, "newChatStream", "DONE")
}
func (a *App) SaveAIResponseResult(stockCode, stockName, result, chatId, question string) {
data.NewDeepSeekOpenAi(a.ctx).SaveAIResponseResult(stockCode, stockName, result, chatId, question)
}
func (a *App) GetAIResponseResult(stock string) *models.AIResponseResult {
return data.NewDeepSeekOpenAi(a.ctx).GetAIResponseResult(stock)
}
func (a *App) GetVersionInfo() *models.VersionInfo {
return &models.VersionInfo{
Version: Version,
Icon: GetImageBase(icon),
Alipay: GetImageBase(alipay),
Wxpay: GetImageBase(wxpay),
Wxgzh: GetImageBase(wxgzh),
Content: VersionCommit,
OfficialStatement: OFFICIAL_STATEMENT,
}
}
//// checkChromeOnWindows 在 Windows 系统上检查谷歌浏览器是否安装
//func checkChromeOnWindows() bool {
// key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe`, registry.QUERY_VALUE)
// if err != nil {
// // 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)
// key, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe`, registry.QUERY_VALUE)
// if err != nil {
// return false
// }
// defer key.Close()
// }
// defer key.Close()
// _, _, err = key.GetValue("Path", nil)
// return err == nil
//}
//
//// checkEdgeOnWindows 在 Windows 系统上检查Edge浏览器是否安装并返回安装路径
//func checkEdgeOnWindows() (string, bool) {
// key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe`, registry.QUERY_VALUE)
// if err != nil {
// // 尝试在 WOW6432Node 中查找(适用于 64 位系统上的 32 位程序)
// key, err = registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe`, registry.QUERY_VALUE)
// if err != nil {
// return "", false
// }
// defer key.Close()
// }
// defer key.Close()
// path, _, err := key.GetStringValue("Path")
// if err != nil {
// return "", false
// }
// return path, true
//}
func GetImageBase(bytes []byte) string {
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(bytes)
}
func GenNotificationMsg(stockInfo *data.StockInfo) string {
Price, err := convertor.ToFloat(stockInfo.Price)
if err != nil {
Price = 0
}
PreClose, err := convertor.ToFloat(stockInfo.PreClose)
if err != nil {
PreClose = 0
}
var RF float64
if PreClose > 0 {
RF = mathutil.RoundToFloat(((Price-PreClose)/PreClose)*100, 2)
}
return "[" + stockInfo.Name + "] " + stockInfo.Price + " " + convertor.ToString(RF) + "% " + stockInfo.Date + " " + stockInfo.Time
}
// msgType : 1 涨跌报警(5分钟);2 股价报警(30分钟) 3 成本价报警(30分钟)
func getMsgTypeTTL(msgType int) int {
switch msgType {
case 1:
return 60 * 5
case 2:
return 60 * 30
case 3:
return 60 * 30
default:
return 60 * 5
}
}
func getMsgTypeName(msgType int) string {
switch msgType {
case 1:
return "涨跌报警"
case 2:
return "股价报警"
case 3:
return "成本价报警"
default:
return "未知类型"
}
}
func onExit(a *App) {
// 清理操作
logger.SugaredLogger.Infof("systray onExit")
//systray.Quit()
//runtime.Quit(a.ctx)
}
func (a *App) UpdateConfig(settings *data.Settings) string {
//logger.SugaredLogger.Infof("UpdateConfig:%+v", settings)
if settings.RefreshInterval > 0 {
if entryID, exists := a.cronEntrys["MonitorStockPrices"]; exists {
a.cron.Remove(entryID)
}
id, _ := a.cron.AddFunc(fmt.Sprintf("@every %ds", settings.RefreshInterval), func() {
//logger.SugaredLogger.Infof("MonitorStockPrices:%s", time.Now())
MonitorStockPrices(a)
})
a.cronEntrys["MonitorStockPrices"] = id
}
return data.NewSettingsApi(settings).UpdateConfig()
}
func (a *App) GetConfig() *data.Settings {
return data.NewSettingsApi(&data.Settings{}).GetConfig()
}
func (a *App) ExportConfig() string {
config := data.NewSettingsApi(&data.Settings{}).Export()
file, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "导出配置文件",
CanCreateDirectories: true,
DefaultFilename: "config.json",
})
if err != nil {
logger.SugaredLogger.Errorf("导出配置文件失败:%s", err.Error())
return err.Error()
}
err = os.WriteFile(file, []byte(config), 0644)
if err != nil {
logger.SugaredLogger.Errorf("导出配置文件失败:%s", err.Error())
return err.Error()
}
return "导出成功:" + file
}
func (a *App) ShareAnalysis(stockCode, stockName string) string {
//http://go-stock.sparkmemory.top:16688/upload
res := data.NewDeepSeekOpenAi(a.ctx).GetAIResponseResult(stockCode)
if res != nil && len(res.Content) > 100 {
analysisTime := res.CreatedAt.Format("2006/01/02")
logger.SugaredLogger.Infof("%s analysisTime:%s", res.CreatedAt, analysisTime)
response, err := resty.New().SetHeader("ua-x", "go-stock").R().SetFormData(map[string]string{
"text": res.Content,
"stockCode": stockCode,
"stockName": stockName,
"analysisTime": analysisTime,
}).Post("http://go-stock.sparkmemory.top:16688/upload")
if err != nil {
return err.Error()
}
return response.String()
} else {
return "分析结果异常"
}
}
func (a *App) GetfundList(key string) []data.FundBasic {
return data.NewFundApi().GetFundList(key)
}
func (a *App) GetFollowedFund() []data.FollowedFund {
return data.NewFundApi().GetFollowedFund()
}
func (a *App) FollowFund(fundCode string) string {
return data.NewFundApi().FollowFund(fundCode)
}
func (a *App) UnFollowFund(fundCode string) string {
return data.NewFundApi().UnFollowFund(fundCode)
}
func (a *App) SaveAsMarkdown(stockCode, stockName string) string {
res := data.NewDeepSeekOpenAi(a.ctx).GetAIResponseResult(stockCode)
if res != nil && len(res.Content) > 100 {
analysisTime := res.CreatedAt.Format("2006-01-02_15_04_05")
file, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存为Markdown",
DefaultFilename: fmt.Sprintf("%s[%s]AI分析结果_%s.md", stockName, stockCode, analysisTime),
Filters: []runtime.FileFilter{
{
DisplayName: "Markdown",
Pattern: "*.md;*.markdown",
},
},
})
if err != nil {
return err.Error()
}
err = os.WriteFile(file, []byte(res.Content), 0644)
return "已保存至:" + file
}
return "分析结果异常,无法保存。"
}
func (a *App) GetPromptTemplates(name, promptType string) *[]models.PromptTemplate {
return data.NewPromptTemplateApi().GetPromptTemplates(name, promptType)
}
func (a *App) AddPrompt(prompt models.Prompt) string {
promptTemplate := models.PromptTemplate{
ID: prompt.ID,
Content: prompt.Content,
Name: prompt.Name,
Type: prompt.Type,
}
return data.NewPromptTemplateApi().AddPrompt(promptTemplate)
}
func (a *App) DelPrompt(id uint) string {
return data.NewPromptTemplateApi().DelPrompt(id)
}
func (a *App) SetStockAICron(cronText, stockCode string) {
data.NewStockDataApi().SetStockAICron(cronText, stockCode)
if strutil.HasPrefixAny(stockCode, []string{"gb_"}) {
stockCode = strings.ToUpper(stockCode)
stockCode = strings.Replace(stockCode, "gb_", "us", 1)
stockCode = strings.Replace(stockCode, "GB_", "us", 1)
}
if entryID, exists := a.cronEntrys[stockCode]; exists {
a.cron.Remove(entryID)
}
follow := data.NewStockDataApi().GetFollowedStockByStockCode(stockCode)
id, _ := a.cron.AddFunc(cronText, a.AddCronTask(follow))
a.cronEntrys[stockCode] = id
}
func (a *App) AddGroup(group data.Group) string {
ok := data.NewStockGroupApi(db.Dao).AddGroup(group)
if ok {
return "添加成功"
} else {
return "添加失败"
}
}
func (a *App) GetGroupList() []data.Group {
return data.NewStockGroupApi(db.Dao).GetGroupList()
}
func (a *App) GetGroupStockList(groupId int) []data.GroupStock {
return data.NewStockGroupApi(db.Dao).GetGroupStockByGroupId(groupId)
}
func (a *App) AddStockGroup(groupId int, stockCode string) string {
ok := data.NewStockGroupApi(db.Dao).AddStockGroup(groupId, stockCode)
if ok {
return "添加成功"
} else {
return "添加失败"
}
}
func (a *App) RemoveStockGroup(code, name string, groupId int) string {
ok := data.NewStockGroupApi(db.Dao).RemoveStockGroup(code, name, groupId)
if ok {
return "移除成功"
} else {
return "移除失败"
}
}
func (a *App) RemoveGroup(groupId int) string {
ok := data.NewStockGroupApi(db.Dao).RemoveGroup(groupId)
if ok {
return "移除成功"
} else {
return "移除失败"
}
}
func (a *App) GetStockKLine(stockCode, stockName string, days int64) *[]data.KLineData {
return data.NewStockDataApi().GetHK_KLineData(stockCode, "day", days)
}
func (a *App) GetStockMinutePriceLineData(stockCode, stockName string) map[string]any {
res := make(map[string]any, 4)
priceData, date := data.NewStockDataApi().GetStockMinutePriceData(stockCode)
res["priceData"] = priceData
res["date"] = date
res["stockName"] = stockName
res["stockCode"] = stockCode
return res
}
func (a *App) GetStockCommonKLine(stockCode, stockName string, days int64) *[]data.KLineData {
return data.NewStockDataApi().GetCommonKLineData(stockCode, "day", days)
}
func (a *App) GetTelegraphList(source string) *[]*models.Telegraph {
telegraphs := data.NewMarketNewsApi().GetTelegraphList(source)
return telegraphs
}
func (a *App) ReFleshTelegraphList(source string) *[]*models.Telegraph {
data.NewMarketNewsApi().GetNewTelegraph(30)
data.NewMarketNewsApi().GetSinaNews(30)
telegraphs := data.NewMarketNewsApi().GetTelegraphList(source)
return telegraphs
}
func (a *App) GlobalStockIndexes() map[string]any {
return data.NewMarketNewsApi().GlobalStockIndexes(30)
}
func (a *App) SummaryStockNews(question string, sysPromptId *int, enableTools bool) {
var msgs <-chan map[string]any
if enableTools {
msgs = data.NewDeepSeekOpenAi(a.ctx).NewSummaryStockNewsStreamWithTools(question, sysPromptId, a.AiTools)
} else {
msgs = data.NewDeepSeekOpenAi(a.ctx).NewSummaryStockNewsStream(question, sysPromptId)
}
for msg := range msgs {
runtime.EventsEmit(a.ctx, "summaryStockNews", msg)
}
runtime.EventsEmit(a.ctx, "summaryStockNews", "DONE")
}
func (a *App) GetIndustryRank(sort string, cnt int) []any {
res := data.NewMarketNewsApi().GetIndustryRank(sort, cnt)
return res["data"].([]any)
}
func (a *App) GetIndustryMoneyRankSina(fenlei, sort string) []map[string]any {
res := data.NewMarketNewsApi().GetIndustryMoneyRankSina(fenlei, sort)
return res
}
func (a *App) GetMoneyRankSina(sort string) []map[string]any {
res := data.NewMarketNewsApi().GetMoneyRankSina(sort)
return res
}
func (a *App) GetStockMoneyTrendByDay(stockCode string, days int) []map[string]any {
res := data.NewMarketNewsApi().GetStockMoneyTrendByDay(stockCode, days)
slice.Reverse(res)
return res
}
// OpenURL
//
// @Description: 跨平台打开默认浏览器
// @receiver a
// @param url
func (a *App) OpenURL(url string) {
runtime.BrowserOpenURL(a.ctx, url)
}
// SaveImage
//
// @Description: 跨平台保存图片
// @receiver a
// @param name
// @param base64Data
// @return error
func (a *App) SaveImage(name, base64Data string) string {
// 打开保存文件对话框
filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存图片",
DefaultFilename: name + "AI分析.png",
Filters: []runtime.FileFilter{
{
DisplayName: "PNG 图片",
Pattern: "*.png",
},
},
})
if err != nil || filePath == "" {
return "文件路径,无法保存。"
}
// 解码并保存
decodeString, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return "文件内容异常,无法保存。"
}
err = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)
if err != nil {
return "保存结果异常,无法保存。"
}
return filePath
}
// SaveWordFile
//
// @Description: // 跨平台保存word
// @receiver a
// @param filename
// @param base64Data
// @return error
func (a *App) SaveWordFile(filename string, base64Data string) string {
// 弹出保存文件对话框
filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "保存 Word 文件",
DefaultFilename: filename,
Filters: []runtime.FileFilter{
{DisplayName: "Word 文件", Pattern: "*.docx"},
},
})
if err != nil || filePath == "" {
return "文件路径,无法保存。"
}
// 解码 base64 内容
decodeString, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return "文件内容异常,无法保存。"
}
// 保存为文件
err = os.WriteFile(filepath.Clean(filePath), decodeString, 0777)
if err != nil {
return "保存结果异常,无法保存。"
}
return filePath
}