OpenStock 这个名字这两年逐渐变成一个自带搜索量的词。很多搞数据、搞量化的朋友都在试着搭自己的开源股票数据平台但一动手就容易卡在“数据从哪来、存成什么样、前端怎么画K线”这三座大山上。这篇文章我就把搭建 OpenStock 的完整过程掰开揉碎讲一遍从架构设计、数据采集、接口开发到可视化图表部署全部用可落地的代码和配置讲清楚。无论你是刚开始接触股票数据分析还是已经写过不少爬虫想做个正儿八经的可视化项目这份指南都能让你少走弯路。先说清楚这是个纯技术工程向的项目宗旨是数据整理和可视化不构成任何投资参考。1. 项目概述与整体设计1.1 OpenStock 解决的核心问题市面上其实不缺股票数据服务但真正用起来总会遇到几个尴尬场景。一是免费接口经常更新一次就挂一片没人及时维护二是历史数据不连续想回测个策略发现缺口一堆三是数据拿到了但格式杂乱换一个接口就要重写一遍清洗逻辑。OpenStock 的思路是做一个“自有的、可二次开发的轻量级数据中台”把数据采集、清洗、存储、接口、展示这几个环节全部打通形成一个能长期使用和持续迭代的小系统。拆开来看这个项目要解决三个具体问题数据获取的稳定性用公开接口加本地缓存机制即使上游数据源短暂失效也能通过历史缓存继续提供服务。数据格式的统一无论上游返回什么格式落地到数据库后都变成统一的字段结构和命名规范后续做计算、绘图、导出都不需要再纠结。可视化与接口的解耦前端只依赖后端定义好的 API 标准后端只关心数据和指标计算。这样将来你换一个前端框架或者增加移动端后端完全不用动。这个设计听起来不复杂但真做起来每个环节都有坑。特别是数据质量校验和更新任务的调度是很多 DIY 项目最后烂尾的核心原因。我后面会逐个展开讲。1.2 整体架构与技术选型OpenStock 的架构我建议采用“四层一中心”的形态四层分别是采集层、存储层、服务层、展示层一中心是任务调度中心。这个结构与企业的数据平台架构思路一致但针对个人项目和中小团队做了大幅精简。技术选型这块我调研过几套组合最终选定的是比较成熟且社区活跃度高的方案层级技术选型选择理由采集层Python akshare APSchedulerakshare 是开源社区维护的金融数据接口库覆盖多种公开数据源APScheduler 轻量可靠适合处理定时任务存储层SQLite起步/ PostgreSQL进阶数据量小时 SQLite 开箱即用数据量大后换 PG 不需要改业务代码只要改数据库驱动服务层FastAPI SQLAlchemyFastAPI 自带 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( symbolsymbol, perioddaily, start_datestart_date, end_dateend_date, adjustqfq, # 前复权适合用于趋势计算和回测 ) 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代码中adjustqfq这个参数需要解释一下。股票会有送股、配股、分红这些操作导致历史价格看起来不连续直接看原始价格会形成断崖式的跳空。前复权就是以最新价格为基准把历史价格按比例调整这样 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, days10) 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(titleOpenStock API, version0.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(orientrecords) app.get(/api/kline/{symbol}) def get_kline( symbol: str, days: int Query(200, ge30, le1000) ): 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_code404, detail股票代码不存在或暂无数据) df df.iloc[::-1].reset_index(dropTrue) 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(orientrecords) }接口设计上有几个值得注意的地方。第一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 idchart-main styleheight:560px;/div script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script script async function loadKline(symbol) { const res await fetch(/api/kline/${symbol}?days200); 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 - TZAsia/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这个编排里有两个细节值得注意。第一容器内的时区一定要显式设置成中国标准时间TZAsia/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 这个名字之所以值得做不是因为它有多高级而是因为它能让你在真实数据上体会到从零到一构建系统的乐趣这种经验是任何看教程都替代不了的。
