OpenStock 这个名字,这两年逐渐变成一个自带搜索量的词。很多搞数据、搞量化的朋友都在试着搭自己的开源股票数据平台,但一动手就容易卡在“数据从哪来、存成什么样、前端怎么画K线”这三座大山上。这篇文章我就把搭建 OpenStock 的完整过程掰开揉碎讲一遍,从架构设计、数据采集、接口开发到可视化图表部署,全部用可落地的代码和配置讲清楚。无论你是刚开始接触股票数据分析,还是已经写过不少爬虫想做个正儿八经的可视化项目,这份指南都能让你少走弯路。先说清楚,这是个纯技术工程向的项目,宗旨是数据整理和可视化,不构成任何投资参考。
1. 项目概述与整体设计
1.1 OpenStock 解决的核心问题
市面上其实不缺股票数据服务,但真正用起来总会遇到几个尴尬场景。一是免费接口经常更新一次就挂一片,没人及时维护;二是历史数据不连续,想回测个策略发现缺口一堆;三是数据拿到了,但格式杂乱,换一个接口就要重写一遍清洗逻辑。OpenStock 的思路是做一个“自有的、可二次开发的轻量级数据中台”,把数据采集、清洗、存储、接口、展示这几个环节全部打通,形成一个能长期使用和持续迭代的小系统。
拆开来看,这个项目要解决三个具体问题:
- 数据获取的稳定性:用公开接口加本地缓存机制,即使上游数据源短暂失效,也能通过历史缓存继续提供服务。
- 数据格式的统一:无论上游返回什么格式,落地到数据库后都变成统一的字段结构和命名规范,后续做计算、绘图、导出都不需要再纠结。
- 可视化与接口的解耦:前端只依赖后端定义好的 API 标准,后端只关心数据和指标计算。这样将来你换一个前端框架或者增加移动端,后端完全不用动。
这个设计听起来不复杂,但真做起来每个环节都有坑。特别是数据质量校验和更新任务的调度,是很多 DIY 项目最后烂尾的核心原因。我后面会逐个展开讲。
1.2 整体架构与技术选型
OpenStock 的架构我建议采用“四层一中心”的形态,四层分别是采集层、存储层、服务层、展示层,一中心是任务调度中心。这个结构与企业的数据平台架构思路一致,但针对个人项目和中小团队做了大幅精简。
技术选型这块,我调研过几套组合,最终选定的是比较成熟且社区活跃度高的方案:
| 层级 | 技术选型 | 选择理由 |
|---|---|---|
| 采集层 | Python + akshare + APScheduler | akshare 是开源社区维护的金融数据接口库,覆盖多种公开数据源;APScheduler 轻量可靠,适合处理定时任务 |
| 存储层 | SQLite(起步)/ PostgreSQL(进阶) | 数据量小时 SQLite 开箱即用;数据量大后换 PG 不需要改业务代码,只要改数据库驱动 |
| 服务层 | FastAPI + SQLAlchemy | FastAPI 自带 OpenAPI 文档,开发效率高;SQLAlchemy 对数据模型的迁移管理非常友好 |
| 展示层 | HTML + ECharts + 原生 JavaScript | 不引入重前端框架,减少学习成本;ECharts 的蜡烛图支持非常成熟,图表交互效果好 |
| 调度中心 | APScheduler 内嵌 | 单机部署场景下内嵌调度器足够,不需要额外引入 Celery 之类的队列 |
为什么不用更“重”的方案?比如直接上 ClickHouse、Kafka 或者微服务?因为对于个人搭建的开源项目,维护成本是最大的敌人。你的精力应该花在数据逻辑和可观测性上,而不是伺候一堆分布式组件。如果后续真的要做到分钟级数据更新和多人协作,再平滑演进也不迟。
2. 数据采集层:从拉数据到存起来
2.1 公开数据源的选择与理解
OpenStock 项目第一步是解决数据问题。我建议用 akshare 作为默认数据源,它最大的优势是把网上零散公开的数据接口做了一层统一封装,返回的是 Pandas DataFrame,处理起来非常顺手。当然,你不需要把所有股票数据都拉下来,合理的做法是维护一个核心股票池,比如沪深300 的成分股,或者你重点关注的一篮子股票,把这些股票的日线行情数据入库。
这里要理解一个关键点:上游数据源每天收盘后都会提供当天的行情数据,而历史数据只有第一次初始化时才需要全量拉取。所以系统应该有两种抓取模式:“全量初始化”和“增量更新”。全量初始化适合项目首次上线,把两三年甚至更长的历史数据一次性拉取入库;增量更新则是每个交易日收盘后执行一次,只拉取最新几天的数据,做去重合并。
2.2 获取K线数据的完整实现
下面这段代码是 OpenStock 的采集层核心,我用 akshare 拉取 A 股日线数据,并把字段统一标准化。
import akshare as ak import pandas as pd from datetime import datetime def fetch_daily_bar(symbol: str, start_date: str, end_date: str) -> pd.DataFrame: """ 获取A股日K线数据 symbol: 股票代码,如 "000001" start_date: 开始日期,格式 "20190101" end_date: 结束日期,格式 "20241231" """ df = ak.stock_zh_a_hist( symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq", # 前复权,适合用于趋势计算和回测 ) if df.empty: return df # akshare 返回的中文列名,映射成 OpenStock 的内部标准字段 df = df.rename(columns={ "日期": "trade_date", "开盘": "open", "收盘": "close", "最高": "high", "最低": "low", "成交量": "volume", "成交额": "amount", "涨跌幅": "pct_change", "换手率": "turnover", }) df = df[[ "trade_date", "open", "high", "low", "close", "volume", "amount", "pct_change", "turnover" ]] # 统一日期格式 df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.strftime("%Y-%m-%d") return df代码中adjust="qfq"这个参数需要解释一下。股票会有送股、配股、分红这些操作,导致历史价格看起来不连续,直接看原始价格会形成断崖式的跳空。前复权就是以最新价格为基准,把历史价格按比例调整,这样 K 线图上的走势是平滑连续的,能真实反映资产价格变化趋势,做技术指标计算时也更有意义。如果做的是超额收益分析或者除权除息研究,才需要用未复权数据。
2.3 更新策略与缓存思路
数据拉下来不算完,要保证 OpenStock 每天自动更新,还要防止重复写入。我用的策略是“交易日增量 + 主键去重”:
def daily_update_job(): today = datetime.now().strftime("%Y-%m-%d") # 只拉最近10个自然日的数据,足够覆盖节假日和停牌情况 for symbol in STOCK_POOL: try: start = get_last_trade_date(symbol, days=10) df = fetch_daily_bar(symbol, start, today) upsert_daily_bar(df) # 按 (symbol, trade_date) 主键去重 except Exception as e: log.error(f"{symbol} 更新失败: {e}") continue这里有个容易被忽略的细节:不能只拉“今天”的数据,因为可能存在停牌、涨跌停导致数据缺失,以及收盘后上游数据源延迟更新的情况。多拉最近一段日期,然后靠主键去重,是最稳妥的做法。主键去重比先查后插的效率更高,而且天然具备幂等性,重复执行任务不会产生脏数据。
另一个容易踩的坑是请求频率。公开接口虽然免费,但不代表没有频率限制。建议每次请求之间加一个短暂 sleep,比如 0.5 到 1 秒,避免频繁请求导致 IP 被临时限流。采集整个股票池时可以用线程池控制并发数为 2 到 3,不要一上来开十几个线程猛拉,否则很容易触发风控。
3. 服务层与数据接口设计
3.1 数据库表结构设计
数据库是 OpenStock 的底座,表结构设计得合理,后续所有功能都会顺畅。我设计了两张核心表:股票基础信息表和日线行情表。股票基础信息表存代码、名称、所属行业、上市日期等静态信息;日线行情表存储每天的 OHLCV 数据,其中 OHLCV 是金融数据领域最基础也最核心的五要素,分别代表开盘、最高、最低、收盘和成交量。
-- 股票基础信息表 CREATE TABLE stock_info ( symbol TEXT PRIMARY KEY, -- 股票代码 name TEXT NOT NULL, -- 股票名称 industry TEXT, -- 所属行业 list_date TEXT, -- 上市日期 updated_at TEXT DEFAULT (datetime('now')) ); -- 日线行情表 CREATE TABLE daily_bar ( symbol TEXT NOT NULL, -- 股票代码 trade_date TEXT NOT NULL, -- 交易日期 open REAL NOT NULL, -- 开盘价 high REAL NOT NULL, -- 最高价 low REAL NOT NULL, -- 最低价 close REAL NOT NULL, -- 收盘价 volume INTEGER NOT NULL, -- 成交量(手) amount REAL, -- 成交额(元) pct_change REAL, -- 涨跌幅(%) turnover REAL, -- 换手率(%) PRIMARY KEY (symbol, trade_date) ); CREATE INDEX idx_daily_bar_date ON daily_bar(trade_date);为什么主键用(symbol, trade_date)的组合键?因为同一只股票同一天只有一条行情数据,这个组合天然唯一,直接用来做幂等写入。另外给trade_date建索引,是因为很多查询会按时间范围过滤。如果是查询单只股票的历史序列,组合主键的左侧索引symbol也已经能覆盖,这个设计在大多数场景下不需要额外加索引。
如果后续数据量真的很大,比如存了十年以上 A 股全量分钟数据,再考虑按年份做分区表或者切换列式数据库。对 OpenStock 的第一阶段来说,SQLite 就够了,单文件备份也方便。迁移到 PostgreSQL 时,上面的建表语句稍作调整就能直接用。
3.2 FastAPI 接口实现
服务层用 FastAPI 提供两类核心接口:一类是行情历史数据,给前端画图用;另一类是股票列表和基础信息,给页面搜索和下拉框用。另外我还加了一个指标查询接口,在服务端计算移动均线等指标,减轻前端的计算压力。
from fastapi import FastAPI, Query, HTTPException from fastapi.middleware.cors import CORSMiddleware import sqlite3 import pandas as pd app = FastAPI(title="OpenStock API", version="0.1.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) DB_PATH = "/data/stock.db" @app.get("/api/stocks") def list_stocks(): conn = sqlite3.connect(DB_PATH) df = pd.read_sql("SELECT symbol, name, industry FROM stock_info", conn) conn.close() return df.to_dict(orient="records") @app.get("/api/kline/{symbol}") def get_kline( symbol: str, days: int = Query(200, ge=30, le=1000) ): conn = sqlite3.connect(DB_PATH) df = pd.read_sql( """ SELECT trade_date, open, high, low, close, volume FROM daily_bar WHERE symbol = ? ORDER BY trade_date DESC LIMIT ? """, conn, params=(symbol, days), ) conn.close() if df.empty: raise HTTPException(status_code=404, detail="股票代码不存在或暂无数据") df = df.iloc[::-1].reset_index(drop=True) df["ma5"] = df["close"].rolling(5).mean().round(2) df["ma10"] = df["close"].rolling(10).mean().round(2) df["ma20"] = df["close"].rolling(20).mean().round(2) return { "symbol": symbol, "data": df.fillna("null").to_dict(orient="records") }接口设计上有几个值得注意的地方。第一,days参数设了下限 30 和上限 1000,防止有人恶意拉取超大范围数据把服务拖垮。第二,MySQL 或者 SQLite 的 LIMIT 参数直接拼 SQL 有一定注入风险,这里用params传参是更规范的做法。第三,指标计算放在服务端而不是前端,原因是 pandas 的rolling函数一个命令就能算出均线,而前端用 JavaScript 实现同样的滚动窗口反而要写不少循环逻辑。
说到服务端计算指标,我想强调一下,这里只做了最基础的均线演示。真正的量化分析还需要处理 NaN 值、周期对齐、复权因子连续性等细节。OpenStock 的设计初衷是一个可扩展的框架,指标计算的函数都带参数,后续加 MACD、KDJ 之类只需求函数内部扩展就行,不需要改接口签名。
4. 可视化图表与前端仪表板
4.1 前端页面设计思路
一个数据平台的最终价值要靠展示来体现。OpenStock 的前端我用的是单页面应用,不引入框架,数据请求和图表渲染都用原生 JavaScript 配合 ECharts 实现。页面布局分三块:顶部是股票搜索框和核心指数概览,中间是选中的股票名称和最新价格,下面是大面积的自选股 K 线图区域。
交互逻辑很简单:页面加载时先请求/api/stocks,把股票代码和名称填进一个搜索选择器;用户选中股票后,再请求/api/kline/{symbol},拿到数据后渲染 K 线图和均线;图表底部提供时间范围切换,比如近 60 日、120 日、250 日,前端改一下请求参数即可。
你可能会问,为什么不直接把 ECharts 里的蜡烛图组件说清楚就行?因为前端最容易被忽略的是“数据格式适配”。ECharts 的 candlestick 组件需要的是五元组数组[open, close, low, high],或者三元组数组,顺序是有讲究的,很多第一次用的人在这里栽跟头。
4.2 K线图与均线渲染实现
下面这段代码是 OpenStock 前端的核心部分,把后端返回的数据转弯成 ECharts 需要的格式。
<!-- index.html 核心片段 --> <div id="chart-main" style="height:560px;"></div> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <script> async function loadKline(symbol) { const res = await fetch(`/api/kline/${symbol}?days=200`); const json = await res.json(); const dates = json.data.map(row => row.trade_date); const klines = json.data.map(row => [row.open, row.close, row.low, row.high]); const ma5 = json.data.map(row => row.ma5); const ma10 = json.data.map(row => row.ma10); const ma20 = json.data.map(row => row.ma20); myChart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['K线', 'MA5', 'MA10', 'MA20'] }, xAxis: { type: 'category', data: dates }, yAxis: { scale: true }, dataZoom: [ { type: 'inside', start: 60, end: 100 }, { type: 'slider', start: 60, end: 100 } ], series: [ { name: 'K线', type: 'candlestick', data: klines, itemStyle: { color: '#ef232a', // 阳线填充色 color0: '#14b143', // 阴线填充色 borderColor: '#ef232a', borderColor0: '#14b143' } }, { name: 'MA5', type: 'line', data: ma5, smooth: true, showSymbol: false }, { name: 'MA10', type: 'line', data: ma10, smooth: true, showSymbol: false }, { name: 'MA20', type: 'line', data: ma20, smooth: true, showSymbol: false } ] }); } </script>这里我把蜡烛图数据统一成 A 股市场的颜色约定:阳线红色、阴线绿色。国内行情软件和海外市场的颜色习惯正好相反,如果你做的标的覆盖港股和美股,需要按不同市场的习惯来设置颜色,否则用户很容易看反走势。ECharts 里color是阳线颜色,color0是阴线颜色,borderColor同理,不要搞反。
数据缩放组件dataZoom我几乎每次都加,因为 K 线图动辄 200 根K线,全部挤在一张图里细节根本看不清。内置缩放可以让鼠标滚轮缩放,底部滑块则提供全局预览。如果你做分钟级数据,这个组件的价值会更加明显。
4.3 前端代码的组织架构
很多从后端转到前端的朋友会问,原生 JS 写起来没有框架方便,代码是不是会越写越乱?我的经验是:只要页面功能有限,原生 JS 完全可以控制。OpenStock 前端我分成三个文件:index.html放结构,style.css放样式,app.js放逻辑,逻辑内部再拆成loadStockList、loadKline、renderOverview三个函数,每个函数只做一件事。如果以后要加自选股、策略信号、财务指标等更多功能,再平滑迁移到 Vue 或 React 也不迟。
5. 部署上线、优化与排雷手册
5.1 用 Docker Compose 一键部署
OpenStock 的部署目标很简单:在服务器上一条命令启动全套服务。我用 Docker Compose 编排后端 API 和前端静态资源两个容器,数据目录挂载成本地卷,方便备份。
version: "3.9" services: api: build: context: . dockerfile: Dockerfile.api container_name: openstock-api ports: - "8000:8000" volumes: - ./data:/data environment: - DB_PATH=/data/stock.db - TZ=Asia/Shanghai restart: unless-stopped web: image: nginx:alpine container_name: openstock-web ports: - "8080:80" volumes: - ./frontend:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - api restart: unless-stopped这个编排里有两个细节值得注意。第一,容器内的时区一定要显式设置成中国标准时间TZ=Asia/Shanghai,否则 Python 的datetime.now()默认拿 UTC 时间,定时任务会在凌晨 8 点才执行,错过数据更新的最佳时机。第二,前端容器用 nginx 托管静态文件后,需要在 nginx 配置里加一层反向代理,把/api/开头的请求转发到后端容器的 8000 端口。
location /api/ { proxy_pass http://api:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }如果只把前端文件拷到 nginx 里,而不配反向代理,打开页面后浏览器访问/api/kline/...,会被 nginx 当作静态文件请求直接返回 404。这也是最常见的部署报错之一。
5.2 常见问题排查与避坑指南
OpenStock 看似简单,实际运行一段时间后,问题会集中在数据质量、性能、稳定性三个方向。我把自己踩过的坑整理成一张速查表,方便你快速定位。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 定时任务不执行 | 容器时区错误,datetime.now()与本地时间偏差 | 检查TZ环境变量,改为Asia/Shanghai并重建容器 |
| 某只股票数据一直缺失 | 股票停牌或当天无成交,上游返回空表 | 采集任务里跳过空表,并用日志记录原因,不要覆盖原有数据 |
| 数据库文件越来越大 | 全量历史数据持续累积 | 定期清理过期数据,或者只保留近N年K线,旧数据可归档导出 |
| 接口响应越来越慢 | 表内无索引或索引失效 | 确认daily_bar表存在(symbol, trade_date)主键,查询量大时补充trade_date独立索引 |
| 图表出现断线 | 服务端返回 NaN 被转成字符串 | 指标计算后把 NaN 替换成null,前端忽略空值即可 |
| 拉取数据被限流 | 请求频率过快 | 请求间隔加到 1 秒,并发数控制在 3 以内,必要时加随机抖动 |
除了表格里这些,还有两个经验一定要说。第一,永远不要把上游数据源当作可信数据。akshare 基于爬取公开页面,上游页面改版是常有的事。我在采集层统一封装了一个fetch_daily_bar函数,哪天数据源挂了,只需要改这一个函数的内部实现,上层业务和存储完全不受影响。这种“依赖倒置”的设计思想,就是你搭建 OpenStock 能得到的最重要资产。
第二,日志就是你的眼睛。我在采集任务里写的不是简单的print,而是分info、error两个级别,输出到标准输出和文件。Docker 部署时用docker logs能实时看到抓取进度,半夜运行异常也能快速从日志里定位。加日志的成本极低,排查问题的效率提升却是十倍量级的。
5.3 几条实战建议
如果照着文章内容把 OpenStock 搭起来,你已经拥有了一个完整可用的数据看板。但我建议你在实际使用中做三个很小的增强,它们会带来质的提升。
一是把股票池维护改成配置文件,不要写死在代码里。用 YAML 或 JSON 维护一份pool.json,每次启动时读取,想换一批股票只需要改配置,不用动代码重新部署。
二是增加一个数据导出接口。很多朋友拿到行情数据是为了做回测,那么提供一个/api/export/{symbol}接口,把指定股票的数据直接导出成 CSV 文件,会省掉很多复制粘贴的时间。
三是把图表页面做成响应式。移动端看行情现在已经是很普遍的需求了,最简单的做法是给图表容器的宽度设置成百分比,结合媒体查询调整高度,这样手机浏览器打开也能凑合看。
说实话,OpenStock 最难的不是某个具体功能,而是把几个模块组装成一套能长期运行的系统。数据采集、存储设计、接口分层、前端展示,这些单点知识都能在网上找到,但组合起来后会出现大量“单点没问题、联调就报错”的诡异情况。这时候不要慌,按照我刚才说的日志排查法,一个模块一个模块去定位,你会找到原因。搭建这个开源项目的过程,本质上就是一次完整的数据工程训练。我最后想分享的体会是:别贪多,先把日线数据跑顺,再考虑分钟级;先把单机跑稳,再考虑分布式。OpenStock 这个名字之所以值得做,不是因为它有多高级,而是因为它能让你在真实数据上体会到从零到一构建系统的乐趣,这种经验是任何看教程都替代不了的。