简介:go-stock 是一套基于大语言模型的AI赋能股票分析工具源码,面向有一定Go与前端基础的开发者和量化投资爱好者,用于构建支持A股、港股、美股的多市场本地化分析客户端。项目采用Wails+NaiveUI构建,覆盖行情获取、成本盈亏展示、涨跌报警推送、市场整体/个股情绪分析及K线技术指标等功能,数据全部保留本地,并适配DeepSeek、OpenAI、Ollama、硅基流动、火山方舟、阿里云百炼等主流模型平台,方便二次扩展。资源共167个文件,以Go后端逻辑(63个)、Vue界面(26个)、JSON配置与Markdown说明为主,辅以脚本、图标、样式等文件,压缩包仅5.08MB,结构清晰便于梳理模块。已有107人浏览学习,适合需要快速搭建AI股票分析工具或参考其多端交互与情绪分析实现的中高级开发者。通过源码可掌握Wails桌面应用打包流程、LLM接口统一封装,以及行情数据与AI提示词结合的分析模式,获得一套可直接编译运行的完整项目。
1. 基于大语言模型的 AI 赋能股票分析工具:这不是又一个行情软件
如果只是想看 K 线,同花顺、富途已经做到极致,轮不到我们再造一个桌面客户端。这个标题真正值得做的是另一件事:把大语言模型接入到行情分析链路里,让工具不只展示「股价是多少」,而是回答「市场现在什么情绪、这只股票为什么涨、技术指标在提示什么」。它基于 Wails 和 NaiveUI 构建桌面端,覆盖 A 股、港股、美股,核心功能是市场/个股情绪分析和 K 线技术指标分析。适合两类人:一是想把 LLM 落到真实业务场景的桌面端开发者,二是每天要盯多市场、想用 AI 辅助决策的量化散户。下文会按「选型理由 — 实现路径 — 多市场参数 — 踩坑 — 进阶」展开,代码都可以直接抄。
2. Wails + NaiveUI 搭桌面客户端:为什么选这套组合与最小搭建
2.1 选型理由:Wails 比 Electron 更适合这种工具
做股票分析桌面端,最常见的方案是 Electron,但我最终选了 Wails。原因很直白:占用内存差一个量级。Electron 起一个应用轻松吃掉 200MB 内存,而 Wails 利用的是系统自带的 WebView2(Windows)或 WebKitGTK(Linux),前端用 HTML/JS 渲染,后端是 Go,一个自用行情工具 40MB 左右就能跑起来。对股票这种需要常驻托盘、频繁刷新数据的工具,内存就是体验。
另一个原因是后端语言。行情分析要处理多市场代码映射、技术指标计算、调用大语言模型 API,Go 在并发和部署上非常顺手。用go写接口,用 Vue + NaiveUI 写界面,两者通过 Wails 的 binding 机制直接互调,不用像 Electron 那样在 IPC 上写一堆胶水代码。而且 Wails 支持把 Go 方法直接暴露给前端,前端调用就像调用本地函数,开发心智负担低很多。
2.2 最小工程初始化:wails init 与 NaiveUI 集成
先建工程。常见做法是用 Wails 官方脚手架生成 Vue 模板,再手动装 NaiveUI。
wails init -n stock-ai -t vue cd stock-ai npm install naive-ui # 开发模式,启动 Vite 和 Go 后端 wails dev这段命令背后的逻辑是:wails init生成两个部分——根目录的 Go 入口(main.go)和frontend/里的 Vue3 + Vite 前端。加naive-ui是因为我们需要的是数据密集型界面,NaiveUI 的暗色主题、n-data-table虚拟滚动、n-spin加载态都适合行情页。wails dev会同时启动 Vite 热更新和 Go 编译,前端代码改动即时生效。
提示:如果你的电脑上
wails dev起不来,先跑wails doctor检查环境,常见是 Windows 上没有 WebView2 Runtime,或者 Linux 上缺libwebkit2gtk。
初始化后把wails.json里的frontend:build保持默认,打包时 Wails 会自动调用npm run build生成静态资源并嵌入 Go 二进制。这里有个参数要注意:wails.json里的devServer端口默认 34115,如果和其他项目冲突,改成 34116 再跑。
2.3 Binding 机制:前端怎么直接调 Go 方法
Wails 最核心的机制是 binding。Go 后端把结构体方法暴露给前端,前端用window.go.backend.App.MethodName()调用。下面是最小示例:
// backend/app.go package main import "context" type Stock struct { Code string `json:"code"` Market string `json:"market"` Price float64 `json:"price"` } type App struct{} func (a *App) GetStockBasic(ctx context.Context) []Stock { return []Stock{ {Code: "00700", Market: "HK", Price: 310.2}, {Code: "600519", Market: "A", Price: 1700.5}, } }前端调用时不用写任何网络请求:
// frontend/src/api.ts export async function getStockBasic() { return await window.go.backend.App.GetStockBasic(); }这段逻辑要理解两个点。第一,ctx context.Context是 Wails 注入的,用来传递前端调用的取消信号,长任务(比如后面调大语言模型)必须接收它,用户关闭窗口时能及时中断。第二,Go 结构体字段必须带jsontag,因为 Wails 在底层会把返回数据做 JSON 序列化,不带 tag 的话前端拿到的字段名可能是Code而不是code,导致n-data-table列渲染不出来。
2.4 项目目录与后续扩展点
建议在初始化后按这样的结构组织代码,后面第三章接大模型、第四章接行情数据,都能对号入座:
stock-ai/ ├── main.go ├── backend/ │ ├── app.go # Wails binding 入口 │ ├── llm/ │ │ └── client.go # 大语言模型调用 │ ├── market/ │ │ ├── akshare.go # A股数据 │ │ └── yfinance.go # 港股美股数据 │ └── indicators/ │ └── compute.go # 技术指标计算 └── frontend/ ├── src/ │ ├── views/ │ │ ├── Market.vue # 市场整体情绪 │ │ └── StockDetail.vue │ └── api.ts这样拆的目的很明确:大语言模型和行情数据源都是可能随时换的组件。今天用 OpenAI 兼容接口,明天可能换本地部署的 Ollama,只要llm/client.go里的接口签名不变,前端就不动。数据源同理,AKShare 某天挂了就换一个实现,适配层隔离得越干净越好。
3. 统一情绪分数:提示词设计、结构化输出与大模型调用
3.1 情绪分析的输入是什么
标题里的「市场整体/个股情绪分析」不是让 AI 凭空猜涨跌,而是先输入客观数据,再让大语言模型做语义概括和风险标注。我一般把输入拆成三个来源:
一是市场全景数据:A 股看涨跌家数比、涨停/跌停数量、两市成交额、北向资金净流入;港股看恒指涨跌幅、大市成交额、南向资金;美股看三大指数涨跌幅、VIX 指数、涨跌家数。二是个股行情数据:日 K 线、分时量价、主力资金净流入、换手率。三是新闻或公告摘要:个股新闻标题、公司公告要点。这些数据先被处理成一段文本,再作为用户消息发给大语言模型。
为什么不能直接把原始 JSON 丢给模型?因为 token 有限,且原始数据里的噪声很多。比如 5000 只股票的列表毫无意义,但「跌停 120 家、成交额 8800 亿」这就是高度浓缩的情绪特征。所以这里要做一次「数据压缩」,把结构化数据转成自然语言描述,大模型的工作只负责语义分析和结论生成,不是数据整理。
3.2 提示词模板:要求模型输出稳定 JSON
情绪分析最容易翻车的是输出格式不稳定。一会儿输出中文结论,一会儿输出 JSON,前端根本没法渲染。解决办法是用结构化 JSON 输出,并在系统提示词里给死格式。
package llm const systemPrompt = ` 你是一位冷静的股票市场情绪分析师。用户会给你以下数据: 1. 市场全景数据 2. 个股行情与资金数据 3. 新闻或公告摘要 请结合这些数据输出一个 JSON,格式必须如下: { "sentiment_score": 0, "sentiment_label": "看涨|看跌|中性", "key_factors": ["因素1", "因素2"], "risk_tags": ["风险1"] } 约束: - sentiment_score 是 0 到 100 的整数,50 表示中性,越高越看涨。 - key_factors 最多列 4 条,必须来自输入数据中的事实,禁止编造。 - risk_tags 只在有明确风险时列出,没有就返回空数组。 - 不要输出 JSON 以外的任何文字。 `这里有个容易被忽略的参数:temperature。情绪分析不是创作题,温度太高模型会自己发挥,同一个输入上午打 70 分下午打 30 分,你根本不敢用。我一般把温度设成 0,越接近 0 越好,同时开启response_format: "json_object"来进一步约束输出。
3.3 Go 后端调用大语言模型:用标准库还是 SDK
很多文章喜欢直接贴 OpenAI SDK,但实际项目中我建议先用标准net/http写一层,这样无论接哪个厂商模型,只要兼容 OpenAI 协议就能用。下面是最小可用的调用代码:
package llm import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" ) type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type chatRequest struct { Model string `json:"model"` Messages []ChatMessage `json:"messages"` Temperature float64 `json:"temperature"` ResponseFormat map[string]string `json:"response_format"` } func AnalyzeSentiment(ctx context.Context, baseURL, apiKey, model string, userContent string) (map[string]interface{}, error) { reqBody, _ := json.Marshal(chatRequest{ Model: model, Messages: []ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userContent}, }, Temperature: 0, ResponseFormat: map[string]string{"type": "json_object"}, }) req, _ := http.NewRequestWithContext(ctx, "POST", baseURL+"/chat/completions", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse llm response failed: %w", err) } var sentiment map[string]interface{} json.Unmarshal([]byte(result.Choices[0].Message.Content), &sentiment) return sentiment, nil }这段代码的逻辑有几个关键点。第一,baseURL和model全部做成参数,意味着同一条代码既可以直接连大模型服务商的 API,也可以指向本地 Ollama 的http://127.0.0.1:11434/v1,切换成本只是改配置。第二,temperature写死在 0,这是我在多次测试后确定的值,后面踩坑会细说。第三,接口返回的content本身是字符串,需要二次解析成 map 才能读sentiment_score。
注意:如果模型服务商不支持
response_format参数,这个字段会被忽略或报错。兼容 OpenAI 协议的本地模型大多支持,但个别版本需要升级。
3.4 把情绪分数推给前端展示
拿到结构化结果后,Wails 后端可以把它缓存起来,同时通过事件推给前端,避免前端轮询。
import "github.com/wailsapp/wails/v2/pkg/runtime" func (a *App) AnalyzeMarketSentiment(ctx context.Context) { // 假设 DataBuilder 负责把市场全景数据拼成 userContent userContent := a.DataBuilder.BuildMarketSummary() sentiment, err := llm.AnalyzeSentiment(ctx, cfg.BaseURL, cfg.APIKey, cfg.Model, userContent) if err != nil { runtime.EventsEmit(ctx, "sentiment:error", err.Error()) return } runtime.EventsEmit(ctx, "sentiment:update", sentiment) }前端配合 NaiveUI 渲染:
import { useMessage } from 'naive-ui'; import { onMounted } from 'vue'; onMounted(() => { window.runtime.EventsOn('sentiment:update', (data: any) => { // data.sentiment_score 直接传给 n-progress 或 n-tag sentimentScore.value = data.sentiment_score; }); });这里的取舍是:把大语言模型调用放在事件里异步跑,前端不用等,模型响应慢时界面还能操作。如果直接做成同步 binding,用户点一下按钮卡住十秒,体验很差。情绪分析从触发到返回通常需要 3-8 秒,走事件推送是更稳的做法。
4. K线技术指标分析:数据源、指标计算与AI解读管线
4.1 多市场数据源:AKShare 与 yfinance 怎么选
K 线技术指标分析的第一步是拿到可靠的日 K 数据。如果只是个人工具,我一般这样选:A 股用 AKShare,因为它对 A 股接口覆盖全、免费、字段比较干净;港股和美股用 yfinance,它通过 Yahoo 接口能拿到历史 K 线,不用申请密钥。下面是两个数据源的最小示例:
# data_fetcher.py import akshare as ak import yfinance as yf def fetch_a_daily(code: str, days: int = 120): # A股代码如 600519,AKShare 返回包含 日期,开盘,收盘,最高,最低,成交量 的 DataFrame df = ak.stock_zh_a_hist(symbol=code, period="daily", adjust="qfq") return df.tail(days) def fetch_hk_us_daily(code: str, days: int = 120): # 港股如 0700.HK,美股如 AAPL df = yf.download(code, progress=False) return df.tail(days)注意这里的adjust="qfq"参数。前复权数据能消除分红送股带来的跳空,对计算 MACD、RSI 这种依赖连续价格的指标很重要。如果你用不复权数据,一只股票 10 送 10 后价格直接腰斩,RSI 会被误导成超卖。但也要记住,前复权数据不适合计算目标价和持仓盈亏,指标分析用前复权,组合计算用原始价。
4.2 指标计算:MACD、RSI、BOLL 的实现细节
技术指标我不建议调第三方包,因为 A 股常见的几套指标算法在不同平台间有细微差异,直接自己实现最可控。下面是用 Python + pandas/numpy 计算 RSI 和 MACD 的代码:
import pandas as pd def compute_rsi(df: pd.DataFrame, period: int = 14) -> pd.Series: delta = df["close"].diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(alpha=1 / period, min_periods=period).mean() avg_loss = loss.ewm(alpha=1 / period, min_periods=period).mean() rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) return rsi def compute_macd(df: pd.DataFrame, fast: int = 12, slow: int = 26, signal: int = 9): ema_fast = df["close"].ewm(span=fast, adjust=False).mean() ema_slow = df["close"].ewm(span=slow, adjust=False).mean() dif = ema_fast - ema_slow dea = dif.ewm(span=signal, adjust=False).mean() macd_bar = (dif - dea) * 2 return dif, dea, macd_barEWM 是核心。RSI 的ewm(alpha=1/period)是 Wilder 平滑法,不是普通移动平均;MACD 必须用adjust=False的 EMA,这样昨天算出来的值和今天增量算出来的一致,不会因为窗口变化产生偏差。如果你用df["close"].rolling(14).mean()那种均值,RSI 在数据更新时会跳变,这就是很多人指标计算「今天和昨天不一致」的原因。
4.3 把 K 线指标压缩成「喂给大模型的特征文本」
大语言模型不理解数字表格,但它能理解「RSI 14 从 62 降到 48,MACD 绿柱缩短」。所以我们在把指标送进 LLM 前,要做一次文本化压缩。只保留最近 5-10 根 K 线的关键特征,而不是把所有历史数据塞进去。
def build_indicator_text(df: pd.DataFrame) -> str: df = df.tail(10).copy() df["rsi"] = compute_rsi(df) dif, dea, macd_bar = compute_macd(df) df["dif"] = dif df["dea"] = dea df["macd_bar"] = macd_bar latest = df.iloc[-1] prev = df.iloc[-2] builder = [] builder.append(f"最新收盘:{latest['close']:.2f},涨跌幅:{latest['pct_change']:.2f}%") builder.append(f"RSI14最新值:{latest['rsi']:.1f},前值:{prev['rsi']:.1f}") builder.append(f"MACD柱最新值:{latest['macd_bar']:.3f},前值:{prev['macd_bar']:.3f},DIF:{latest['dif']:.3f},DEA:{latest['dea']:.3f}") builder.append(f"最近10日最高:{df['high'].max():.2f},最低:{df['low'].min():.2f},区间涨幅:{df['pct_change'].sum():.2f}%") return "\n".join(builder)这段代码产出的文本会把「AI 可以自己看 K 线」变成「AI 读取人类整理的指标特征」。这是效率最高的做法,token 消耗极低。比如 10 行数据拼出来只有 300 多字,模型可以快速给出解读,而且要编数据都难,因为所有数字都来自真实计算结果。把这段文本喂给大语言模型,再让模型输出金叉、死叉、量价背离之类的技术判断,才是标题里「K线技术指标分析」的完整链路。
4.4 技术指标的边界:哪些结论不能交给大语言模型
不能指望大语言模型自己算指标,也不能让它凭空预测点位。我见过有人把原始 OHLC 全部塞给模型,问「明天涨还是跌」,模型会给一个看似合理的回答,但实际没有任何概率保证。正确的打开方式是:指标计算交给代码,模型只负责解读指标状态和给出候选策略;同时明确告诉模型「你只能基于指标事实说话,不许预测涨停」。在提示词里加一句「给出该股票当前的风险等级和值得关注的信号,而不是买卖建议」,能显著降低模型胡说八道的概率。
5. 避坑与常见问题:多市场数据、情绪分析飘移与Wails打包排查
5.1 AKShare 接口返回空数据:字段名经常在变
现象:脚本昨天跑得好好的,今天ak.stock_zh_a_hist(symbol="600519")返回空 DataFrame,或者报字段缺失错误,App 直接白屏。原因:AKShare 这类免费接口本质是网页爬虫封装,目标网站改版、加反爬后,字段名或解析逻辑就失效了。解决:不裸调 AKShare,外面套一层适配层,并且加基础异常捕获。比如先请求,如果返回len(df) == 0,自动降级到备选接口,并在日志打印阶段。落地做法是写一个MarketDataProvider接口,有两个实现:AKShare 和本地 CSV 缓存;AKShare 挂了就从缓存读取最后一份数据,保证 App 不闪退。
5.2 同一份新闻情绪分数不稳定:temperature 的坑
现象:同一段新闻摘要,上午分析结果是 65 分看涨,下午变成 40 分看跌;连续调用三次,三个不同结果。原因:大语言模型本身有随机性,我们最初没有固定temperature,用默认值 1.0,模型在语义模糊处会发挥。解决:把情绪分析请求的temperature固定为 0,这是最便宜、最有效的稳定手段。如果还不稳,就做三次调用取情绪分数均值,这个「投票」的工程做法比调任何花哨参数都实用。注意:分数均值不是对 JSON 求平均,而是分别解析sentiment_score后求算术平均,key_factors取出现次数最多的那轮输出。
5.3 港股/美股代码与时区错乱:A股开市时你还在用美债数据
现象:港股用0700.HK返回的数据和软件里的行情对不上,美股开盘时间算错导致技术指标序列错位。原因:多市场数据源在代码命名、交易时区、复权方式上都不统一。Yahoo 用0700.HK,AKShare 用00700,如果不做映射,前端会展示错误代码。解决:建立统一代码表,入库时把外部代码映射成内部market + symbol,比如{ market: "HK", symbol: "0700", yahooSymbol: "0700.HK" }。所有 K 线数据统一存 UTC 时间戳,展示时再转本地时区。技术指标计算必须在同一市场交易时段下进行,A 股和港股不要混成一根时间序列。
5.4 Wails 在 Windows 打包后白屏:WebView2 Runtime 缺失
现象:wails build产物在开发机上能跑,拷到另一台 Windows 上双击后黑屏/白屏,没有报错。原因:Wails 依赖系统 WebView2 Runtime,Windows 10/11 大多数有,但精简版系统或 Windows Server 没有。解决:在安装检查代码里判断runtime.EventsOn("wails:ready")是否成功触发,配合注册表检测 WebView2。更稳妥的做法是在项目发布说明里写明要求安装「Microsoft Edge WebView2 Runtime」,或者在安装包构建脚本里用bootstrapper唤起在线安装。这个坑在 Linux 上对应的是编译时缺libwebkit2gtk-4.1-dev,所以跨平台编译前先跑wails doctor。
5.5 NaiveUI 高频刷新卡顿:行情字段没做节流
现象:自选页每秒收到最新价更新,n-data-table在做排序、渲染时明显掉帧,滚动卡涩。原因:Wails 事件推送频率高,Vue 组件每次收到数据都触发响应式更新,表格大面积重渲染。解决:前端维护一个「数据版本」字段,只有收到新batch_id才更新表格;行情推送做 500ms 节流。实际处理中我用n-data-table的virtual-scroll开启虚拟滚动,同时把价格组件拆成最小子组件,更新只发生在价格文本节点,不触发整行重绘。经过上述处理后,同时追踪 50 只股票的刷新都没问题。
5.6 停牌和退市股数据导致指标 NaN:脏数据喂给大模型
现象:某只港股停牌五天,日 K 缺失,计算 MACD 时出现NaN,文本化后喂给大语言模型,模型给出「数据不足」的含糊结论。原因:没有做数据清洗,直接把缺失值拼进了提示词。解决:在指标计算前检查数据长度,少于 30 根 K 线直接跳过分析;缺失日期的行dropna后如果连续缺失超过 3 根,判断为停牌,用停牌标记替代指标结论;所有指标文本必须带「数据完整性」字段,让大语言模型知道这些结论的置信度。这个字段看似冗余,但在模型拒绝瞎猜时非常有用。
6. 进阶技巧:流式输出、本地部署大语言模型与情绪有效性验证
6.1 让模型分析结果流动起来
情绪分析和 K 线解读都是耗时操作,与其等完成后一次性展示,不如用 Wails 的事件机制做流式输出。后端在拿到大语言模型的分段响应时,通过runtime.EventsEmit(ctx, "llm:token", chunk)推送,前端监听事件后把文本追加到 NaiveUI 的n-input或自定义流式文本块里。这样用户能看到模型一句一句「写」分析过程,体验上比转圈等待好很多。关键参数是一次推理的max_tokens,我习惯设 600,既能完整表达分析逻辑,又不会让流式展示拖太长。
6.2 用 Ollama 本地部署,替换在线大语言模型
很多用户对股票数据敏感,不愿意把持仓数据发到在线 API。常见做法是让后端baseURL可配置,一键切换到本地 Ollama 服务。以 Qwen2.5-7B 为例,启动命令是ollama run qwen2.5:7b,它会监听http://127.0.0.1:11434/v1,完全兼容第三章的调用代码。切换后提示词不用改,但温度控制比在线模型更关键,本地小模型在temperature=0时也会偶尔不稳定,所以情绪分数仍按三次投票取均值。本地部署的代价是分析速度变慢,7B 模型在普通显卡上约每秒 20-30 token,单次分析要 15 秒左右,这是换取数据私密性的必要成本。
6.3 验证情绪指标是否真的有效
情绪分析做得再花哨,如果分数和次日涨跌没有相关性,就是自我安慰。我的验证方法是回测:取历史 60 天数据,每天在收盘后计算市场情绪分数,记录次日指数涨跌,统计分数大于等于 70 的日子中次日上涨比例。
import pandas as pd def evaluate_sentiment(df: pd.DataFrame, threshold: int = 70): # df 包含列:"date", "sentiment_score", "next_day_return" high = df[df["sentiment_score"] >= threshold] hit_rate = (high["next_day_return"] > 0).mean() return hit_rate, len(high)这个回测只适合验证一个趋势:在你的数据源和提示词组合下,情绪分数是否有区分度。如果连续两周回测的命中率都低于 50%,说明提示词里的关键因子取错了,或者数据源时间滞后,这时候不要急着加更多指标,先回头检查数据清洗。这个习惯帮我避免了不少黑匣子式的无效功能。
6.4 一点经验收尾
把大语言模型接进行情分析后,我最大的教训是:永远不要信任模型输出的数字,模型只是「语义翻译器」,它翻译的底层事实必须由代码保证。最早我让模型直接读原始行情 JSON,它把 8 元股价解读成 8 万元,让我差点做出一个离谱的仓位策略。从那以后,所有喂给模型的数据都先在本地完成单位换算和字段清洗,模型输出只做展示和参考。这条路适合真心想把 AI 落到投资决策工具里的开发者,但请记住它分析的是情绪和技术信号,不是确定性收益。希望这篇笔记能帮你在搭建 Wails + NaiveUI + 大语言模型股票分析工具时少踩几个坑,复现出真正敢用、能看效果的产品原型。
本文还有配套的精品资源,点击获取