拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ETF量化交易系统实战:Web化升级与SQLite数据管理

ETF量化交易系统实战:Web化升级与SQLite数据管理

21天搭建ETF量化交易系统这个系列,到DAY19已经算是进入收官阶段了。前面十多天,我们把数据源、回测引擎、信号计算这些偏底层的模块陆续搭好,大部分时间都对着终端和日志打交道。今天这一步,要把整套系统从“命令行工具”升级成“Web应用”,同时把本地数据管理模块彻底理清楚,让系统真正进入“每天打开浏览器就能看状态、出信号”的状态。

这篇内容我会按实际推进的顺序来写:先解释为什么在DAY19把Web化提上优先级,再详细拆本地数据管理模块的设计思路和表结构,接着是轮动计算接口和页面的实现方式,最后把联调阶段踩过的坑和排查过程整理成速查表。整个项目到这一步,已经可以覆盖“数据入库、轮动排序、信号生成、结果展示”的完整闭环,不再是零散的脚本集合了。

1. 整体设计:Web化为什么放在DAY19,本地数据模块承担什么角色

1.1 从终端输出到Web界面的动机

在DAY19之前,系统已经具备从数据源拉取ETF行情、计算动量排名、输出调仓信号这几个核心能力。但这些能力分散在几个Python脚本里,每次运行都要在终端敲命令,输出是纯文本表格,看历史记录还得翻日志文件。对于每天只用几分钟查看系统状态的普通使用者来说,这个交互方式不够直观。

Web化要解决的不是“有没有功能”的问题,而是“怎么看、怎么用”的问题。轮动系统的核心使用场景是:每天早上打开页面,看一眼当前持仓的ETF是否还在排名前列,是否需要调仓,最近几天的净值曲线是什么样的。这些信息如果变成浏览器里的表格、图表和状态标记,使用门槛会下降一大截。

我参考了一些现成的开源轮动策略项目,在设计上做了两个决策:

  • 后端采用本地服务模式,不依赖外部数据库服务,数据全部落在本机SQLite文件里,启动系统就是启动一个本地Web服务,端口固定,自动化任务和手动查看共用一套数据。
  • 前端不引入重型框架,使用服务端渲染模板加轻量图表库,页面简化到“一屏看全”,避免把精力耗在工程化前端上。

这个取舍背后的逻辑很简单:个人量化的Web系统,核心价值在数据和逻辑展示,不在交互复杂度。把数据层做扎实、把轮动规则算准确,比做一个花哨的可视化界面重要得多。

1.2 本地数据管理模块的定位和选型

本地数据管理模块是整个Web版系统的地基。它要负责三件事:ETF日线数据的存储与增量更新、轮动计算结果的快照保存、调仓记录的落库与查询。

选型上我直接排除了MySQL和PostgreSQL,原因是个人使用场景下完全没有必要引入独立数据库服务。SQLite单文件就能承载ETF日线数据、策略快照和交易日志,不需要账号密码配置,不需要额外进程,备份就是复制文件。

SQLite在这个场景下有几个实打实的优势:

  • 数据文件直接放在项目目录下,路径固定,备份策略简单到拷贝一个文件就完成。
  • Python标准库自带sqlite3模块,不涉及ORM的额外配置和版本兼容问题。
  • 事务能力足够支撑“更新一批数据、写入一条调仓记录”这种轻量级写操作,WAL模式的并发表现也够用。

对数据库文件的管理,我用一个独立的data_manager模块统一操作,不散落在各个脚本里。模块内部只做数据的存取,不做业务计算,业务层通过接口调用数据层,避免后续增加策略时改动数据库逻辑。

2. 本地数据模块:建表、增量更新、防重与校验的完整做法

2.1 三张核心表的设计思路

本地数据库我命名为rotation.db,放在data目录下。整个数据模块围绕三张表展开,实际建表语句如下:

-- ETF日线行情表 CREATE TABLE IF NOT EXISTS etf_daily ( ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, close REAL NOT NULL, pct_chg REAL, volume REAL, amount REAL, PRIMARY KEY (ts_code, trade_date) ); -- 每日轮动快照表 CREATE TABLE IF NOT EXISTS rotation_snapshot ( id INTEGER PRIMARY KEY AUTOINCREMENT, calc_date TEXT NOT NULL, rank INTEGER NOT NULL, ts_code TEXT NOT NULL, name TEXT, momentum REAL, is_hold INTEGER DEFAULT 0, action TEXT DEFAULT 'hold' ); -- 调仓记录表 CREATE TABLE IF NOT EXISTS trade_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, signal_date TEXT NOT NULL, ts_code TEXT NOT NULL, direction TEXT NOT NULL, reason TEXT );

etf_daily表的主键是ts_code加trade_date的组合,这个设置非常关键,它从数据库层面杜绝了同一只ETF在同一个交易日出现两条记录的问题。很多新手踩过的坑是用id做自增主键,然后发现重复数据越积越多,去重还得额外写语句。

rotation_snapshot表保存每一天全市场ETF的动量排名全量结果,字段里的is_hold标记当前是否持仓,action记录操作动作(buy、sell、hold)。快照的意义在于事后复盘:过了三个月想查某天的排名是怎么样的,直接select这张表就能还原,不用重新计算。

trade_log表的结构偏向记录行为,不在里面做过多冗余字段。调仓动作是buy还是sell,信号日期是哪天,触发原因是什么,三个信息够用了。细节的成交价格和滑点估算,留给后续的模拟交易模块去扩展。

2.2 增量更新的实现逻辑

数据源上面,我用的是免费的数据接口方式,从akshare拉取ETF历史行情。增量更新的思路是:每次更新前先查etf_daily表里每只ETF的最新日期,然后只拉取这个日期之后的数据,避免全量重复下载。

def get_latest_date(ts_code): cur = conn.execute( "SELECT MAX(trade_date) FROM etf_daily WHERE ts_code = ?", (ts_code,) ) row = cur.fetchone() return row[0] if row and row[0] else "20000101"

拿到最新日期后拼接开始日期参数,调用数据源接口拉取增量数据。如果表里没有该ETF的记录,就从默认的起始日期拉全量。这个逻辑保证了首次同步和日常增量走同一条代码路径,不搞特殊分支。

更新完成之后要做一次“校验”环节,这是我在项目早期不多做、后来发现非常必要的步骤。简单来说就是检查拉取到的数据量和数据库里的记录数是否对得上,检查最新日期是否等于数据源能提供的最新交易日。如果数据源返回为空,大概率是接口返回格式变化或者网络问题,这时候要主动报警,而不是默默写一条空记录进库。

2.3 数据异常与停牌处理

ETF行情数据有个容易忽略的细节:停牌期间数据源可能不返回记录,也可能返回与前一交易日相同的收盘价。两种情况的处理方式不同。

我采用的处理规则比较简单明确:凡是数据源没有返回的交易日,默认该ETF当日无成交,不补录数据;遇到持续多日数据缺失的情况,在轮动计算时将其排除出候选池,避免因价格长期不变导致动量指标失真。

价格校验方面,我会对单日涨跌幅绝对值超过15%的记录做人工复核标记。ETF的涨跌幅限制通常为10%,超过这个范围基本可以判定是数据源错误,需要排查复权因子或者字段映射问题。校验规则虽然粗糙,但能把绝大多数脏数据挡在轮动计算之前。

3. Web轮动系统:FastAPI服务、动量计算接口与页面渲染

3.1 服务端架构和项目目录

Web服务我选用FastAPI,纯粹是个人技术栈偏好。如果你更熟悉Flask,替换成本也很低,核心接口逻辑是一致的。我最终的项目目录结构如下:

etf_rotation/ ├── app.py # FastAPI入口 ├── data_manager.py # SQLite数据层 ├── momentum.py # 动量计算模块 ├── templates/ │ └── dashboard.html # 主页面模板 ├── static/ │ ├── echarts.min.js # 图表库 │ └── style.css └── data/ └── rotation.db # 本地数据库

app.py里只做三件事:提供查询接口、调用动量计算模块、把计算后的结果渲染到模板上。不写死任何业务规则,所有参数从配置读取。

3.2 动量轮动的计算逻辑

动量轮动策略的核心思想是“强者恒强”:过去一段时间涨幅靠前的资产,未来一段时间继续跑赢的概率较大。实现上就是计算每只ETF过去N个交易日(默认20日)的区间涨跌幅,按数值从大到小排序,取前几名持仓。

这里有一个参数细节需要说明:计算区间涨跌幅用的不是“首日到末日简单相减”,而是用累计每日收益率的方式,公式如下:

def calc_momentum(close_prices, window=20): if len(close_prices) < window + 1: return None segment = close_prices[-max(window, 5):] # 在前复权数据基础上,用区间内每日收益率累乘 ret = 1.0 for i in range(1, len(segment)): if segment[i-1] == 0: return None ret *= (segment[i] / segment[i-1]) return ret - 1

累乘法能更真实地反映区间内的复合收益。ETF的费率低、跟踪误差小,这个方法对ETF的适用性比股票更好。实际运行时,在数据源拉完数据后调用我实现的接口,输出如下:

def run_rotation(calc_date): codes = get_all_etf_codes() results = [] for code in codes: closes = fetch_close_series(code, calc_date, window=20) if closes is None or len(closes) < 21: continue mom = calc_momentum(closes, window=20) results.append((code, mom)) results.sort(key=lambda x: x[1], reverse=True) return results[:5] # 返回前5,实际持仓取前3

参数“前5”和“持有前3”是有意分开的。显示前5是为了让用户看到候补名单,如果排第三的ETF近期风险指标恶化,可以直接从候补里选顶上。这个设计比起只显示持仓名单,可操作性好很多。

3.3 调仓信号的生成规则

调仓信号不是简单地“排名前3就买入,排名掉出前3就卖出”,真实场景中会出现排名频繁震荡的情况,如果每天调仓,交易成本会拖垮收益。

我设置的规则参考了主流量化社区的做法,加了一点自己的调整:

  • 每5个交易日检查一次持仓排名,周频调仓。
  • 持仓ETF的排名只要还在前5,就不卖出;只有跌出前5才触发卖信号。
  • 卖出后,从剩余ETF中按排名补足持仓到3只。

这个规则我回测过,比“严格持有前3、掉出即卖出”的换手率低了约40%,收益差别在可接受范围内。规则的具体代码写在momentum.py里,与数据模块完全解耦,后续想改成“连续N次掉出前5才卖”的版本,只需要改判定函数。

3.4 Dashboard页面的核心布局

页面模板方面,我保留了最简单的设计:顶部是当前持仓状态卡,中部是动量排名前10的表格,底部是净值走势图。

持仓状态卡展示的数据从rotation_snapshot表取最新日期记录,字段包含持仓ETF名称、数量、最新收盘价、持仓浮盈比例。排名表格直接展示当天计算的排名全量数据,每行标记“持仓”或“观察”状态,让用户一眼看出当前信号。

图表部分用了ECharts。考虑到动态数据的更新频率,我直接在前端fetch一个接口返回的JSON数据来渲染折线图:

fetch('/api/nav') .then(r => r.json()) .then(data => { const chart = echarts.init(document.getElementById('navChart')); chart.setOption({ xAxis: { data: data.dates }, series: [{ name: '策略净值', type: 'line', data: data.values }] }); });

图表的净值数据由后端读取trade_log和etf_daily两张表,按照“买入卖出价格、持仓数量变化”的模型计算逐日净值。这个过程不复杂,但数据精度依赖成交价格的准确性,所以我在trade_log表里特意加了reason字段,记录每次调仓是基于什么排名变化触发的,便于后续核对。

4. 联调阶段与常见问题:SQLite并发、数据重算、路径与编码问题

4.1 SQLite在多线程写入场景下的表现

FastAPI默认以多线程方式处理请求,这意味着数据层可能被多个请求同时读取或写入。SQLite本身支持多读单写,但在默认的rollback journal模式下,并发写会出现"database is locked"错误。

我的解决方案是在初始化数据库连接时启用WAL模式并设置忙等待超时:

conn = sqlite3.connect(DB_PATH, timeout=10) conn.execute("PRAGMA journal_mode=WAL")

WAL模式让读操作和写操作可以并发执行,写操作之间仍然串行,但通过timeout参数,当多个写请求竞争时,不会立刻报错,而是等待锁释放。实测在单机个人场景下,这个配置完全够用,没有出现锁冲突影响业务的情况。

更细一层的优化是在数据管理模块内部使用连接池,每个线程池各自持有独立连接,避免多个线程共享同一个sqlite3 connection对象。sqlite3默认check_same_thread=True,如果多线程共享连接会直接抛异常,这个坑在Web服务里特别容易踩到。

4.2 轮动结果的历史重算问题

开发过程中经常要修改动量计算规则,例如把窗口从20日改成10日,或者调整调仓阈值。修改规则后,之前保存的历史快照就“过期”了,需要重新计算。

这里我采用了简单有效的做法:rotation_snapshot表里不保存计算时使用的参数版本,而是在重算后删除指定日期范围的数据,再重新插入。删除条件用calc_date字段控制,不会影响行情数据表。

def recalc_snapshot(start_date): cur = conn.execute( "DELETE FROM rotation_snapshot WHERE calc_date >= ?", (start_date,) ) for day in trading_days: snapshot = compute_daily_snapshot(day) save_snapshot(snapshot)

开发期重算是高频操作,这种全部删除再重建的方式虽然简单粗暴,但胜在逻辑清晰、不容易残留脏数据。等系统稳定运行后,重算频率会大幅降低,性能完全不是瓶颈。

4.3 路径、编码与缓存三个容易忽略的细节

先讲路径问题。项目里所有数据文件用相对路径会导致一个很隐蔽的错误:通过命令行启动服务时,当前目录在etf_rotation/下运行正常;但如果用定时任务脚本调起,当前目录或工作目录就变成用户目录了,数据库路径就找不到了。

我的解决方案是在模块顶部统一获取项目根目录的绝对路径:

BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DB_PATH = os.path.join(BASE_DIR, "data", "rotation.db")

这样无论从哪个工作目录启动,数据库路径都是确定的,不会出现“昨天还能启动,今天怎么找不到数据文件”的诡异问题。

编码问题通常出现在从接口返回的中文ETF名称写入数据库再读取展示的环节。连接SQLite时指定:

conn.execute("PRAGMA encoding = 'UTF-8'")

更稳妥的方式是在读写接口层面做好统一的UTF-8编解码,同时确保Web模板声明了正确的字符集。

缓存问题则隐藏在前端。浏览器访问页面时,JS文件和图表库可能被缓存,导致更新了脚本但页面上看到的还是旧版本。开发阶段调试时常被这个问题困扰,后来在静态文件请求后加上了版本参数,例如style.css?v=20250119,就彻底避开了缓存干扰。这个技巧在正式部署到服务器做同局域网访问时也一样实用。

4.4 常见问题排查速查表

问题现象可能原因排查步骤与解法
数据库报locked错误WAL模式未开启,多线程写冲突执行PRAGMA journal_mode=WAL;连接设置timeout
页面数据为空白快照表无当天记录,计算未运行调用run_rotation接口生成当日快照
ETF行情更新量与预期不符增量更新起始日期计算错误打印get_latest_date返回值,比对数据源最新日期
轮动排名异常,收益过大未做前复权或除权导致价格跳变检查数据源的复权参数,增量更新用前复权行情
调仓触发过于频繁排名阈值设置过窄修改调仓规则为“跌出前5才卖”,降低换手率
前端页面中文乱码数据库编码或HTTP响应头不对统一UTF-8,检查Content-Type和meta声明

另外补充一个真实踩过的坑:启动Web服务后,首次访问行情数据加载很慢。原因是数据源接口对单只ETF拉取20日行情需要约0.3秒,几十只ETF串行拉取就是十几秒。后来加了内存缓存,把当日已计算过的动量结果存到内存字典里,第二次请求直接命中缓存,响应时间降到毫秒级。这个优化对日常使用体验提升很明显,因为用户往往会在短时间内刷新页面多次。

5. 一些开发过程中积累的实操体会

做到DAY19这个节点,我最大的感受是:本地数据模块的质量,直接决定整个Web系统的可靠性。如果数据层乱,页面做得再好看,展示出来的信号也没有参考价值。所以我在这两天花了大量篇幅来打磨数据校验、增量更新和快照保存这三个环节,而不是急着堆功能。

个人建议是在进入Web化阶段之前,先回头检查一遍之前的脚本,把“手动运行时能跑”和“被服务调用时稳定跑”这几个差异点找出来。比如路径是否绝对化、数据库连接是否管理好、异常日志是否完整,这些问题在命令行模式下不太显眼,一旦变成Web服务,就会被放大成明显的故障点。

DAY19完成后,这套系统的日常操作已经可以控制在三分钟以内:启动服务、打开页面、查看调仓信号。DAY20和DAY21我计划把自动更新数据、每日定时计算信号的任务调度加进来,再补一个交易记录的手动确认功能,把整个流程推到“自动化运行、人工确认执行”的状态。

返回列表