ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

ETF量化轮动系统Web化实战:本地数据管理与信号展示

ETF量化轮动系统Web化实战:本地数据管理与信号展示 DAY19了这个坑比我想象的深但今天这一个节点扛过去之后整个系统突然就“活”了。前18天里我一直在跟数据、策略、回测较劲命令行下跑出来的结果虽然逻辑是对的但你没法直观看到持仓变动、RS排名、调仓信号这些信息。今天把Web版轮动系统搭起来顺手把本地数据管理模块彻底重写了一遍整个ETF量化交易系统的使用体验终于从“实验室阶段”跨到了“可展示、可操作、可日常使用”的状态。先同步一下当前进度前面几天的开发里ETF轮动策略的数据源、RS排名计算、调仓信号生成这些核心部分已经在Python脚本里跑通了但数据一直是零散存着的回测和实盘信号也完全是两个管道。DAY19我要解决的核心问题有两个一是让所有历史行情数据、信号记录、持仓日志落到本地数据库里统一管理二是把策略结果通过Web页面展示出来不需要每次打开终端跑脚本浏览器里就能看到当前应该持有哪几只ETF、什么时候该调仓。这两个问题解决完21天的工程量其实就收尾了一半后面两天主要做边界情况的打磨和复盘。这篇文章我就把DAY19做的本地数据管理模块和Web轮动系统的设计思路、表结构、接口实现、页面交互逻辑全部摊开讲带有完整的建表SQL和核心Python代码。1. 整体架构设计为什么要在最后阶段补上Web化和数据管理很多人做量化系统都有一个通病策略逻辑跑通了就觉得万事大吉数据文件和脚本散落在各个目录里回测结果存成CSV信号输出打到控制台。这样自己在开发调试时勉强能用但一旦系统要每天运行、要跟踪历史调仓记录、要验证策略稳定性各种文件之间的时间戳、数据口径就会乱成一锅粥。我在前两周深有体会——某一次调仓信号明明已经触发但因为当天没有跑脚本第二天再看时数据已经变了根本无从追溯。所以DAY19的架构升级我做了两个层面的重构第一层数据层统一。之前用Pandas直接读接口拿数据DataFrame用完就丢每次跑脚本都要重新下载。这有两个坏处一是接口调用频繁容易被限流二是历史数据无法做一致性对比。现在我把所有数据落到本地SQLite数据库里行情数据按日增量更新信号和持仓记录每次生成后立刻写入所有历史可追溯。第二层应用层Web化。我选择了FastAPI作为后端框架搭配Vue 3和ECharts做前端图表展示。为什么选FastAPI而不是Flask或DjangoFastAPI天生支持异步Pydantic做数据校验非常顺手最关键的是它自动生成OpenAPI文档前端调接口的时候不用再翻代码对参数。对于这种个人量化系统的Web化改造FastAPI的轻量程度和开发效率是最合适的。整体架构分三块数据采集模块负责从行情源拉数据并做清洗入库策略引擎模块负责读取行情数据、计算RS排名、生成调仓信号Web展示模块负责把信号、持仓、净值曲线以页面形式呈现出来。三层之间解耦干净后面任何一层要替换都不会牵动其他模块。架构图示不方便画出来但你可以理解为数据层在最底部策略层在中间调用数据层Web层在最上面读取策略层的结果方向是单向依赖。数据库选择上本地单机使用SQLite完全足够。ETF行情数据一天全市场全部标的加起来也就几万行记录SQLite承载毫无压力而且备份和迁移只需要复制一个文件就行。考虑到后续系统要长期运行数据文件可能会比较大我加了定时VACUUM和索引优化的处理这部分在第4节会详述。2. 本地数据管理模块核心设计2.1 表结构设计四张表覆盖全链路本地数据管理模块的核心是四张表etf_basic标的基础信息表、etf_daily_price日线行情表、etf_daily_signal信号记录表、portfolio_record持仓记录表。这四张表构成了整个轮动系统的数据闭环先知道有哪些标的可选再取到每个标的的历史行情然后计算信号最后根据信号生成持仓记录。etf_basic表设计如下CREATE TABLE etf_basic ( code TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT, list_date TEXT, tracking_index TEXT, update_time TEXT DEFAULT (datetime(now, localtime)) );code字段用基金代码做主键比如510300就是沪深300ETF159915是创业板ETF。tracking_index用来记录这个ETF跟踪的指数后面做板块分析时可以直接用。list_date一定要存因为在计算RS排名时次新ETF没有足够的历史数据需要把上市不足60日的标的剔除掉否则算出来的动量排名会有明显失真。etf_daily_price表是数据量最大的一张表CREATE TABLE etf_daily_price ( code TEXT NOT NULL, trade_date TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, volume REAL, amount REAL, PRIMARY KEY (code, trade_date) );注意这里用了复合主键code加trade_date这能保证同一个标的在同一天不会出现重复记录后面做增量更新时用INSERT OR REPLACE就不会产生脏数据。涨跌幅没有单独存因为根据收盘价随时可以算出来避免冗余存储导致数据不一致。etf_daily_signal表记录每天的RS排名结果和调仓信号CREATE TABLE etf_daily_signal ( signal_date TEXT NOT NULL, code TEXT NOT NULL, rs_value REAL, rank INTEGER, action TEXT, PRIMARY KEY (signal_date, code) );action字段有BUY、SELL、HOLD三种取值。每次调仓日跑完策略后把所有标的的RS值、排名和信号结果写入这张表既可以用来回看历史信号是否合理也可以用来做信号准确率的统计分析。portfolio_record表存实际持仓CREATE TABLE portfolio_record ( code TEXT NOT NULL, entry_date TEXT NOT NULL, exit_date TEXT, entry_price REAL, current_price REAL, shares INTEGER, weight REAL, PRIMARY KEY (code, entry_date) );每笔建仓生成一条记录平仓时写入exit_date。current_price每天更新用来计算浮动盈亏。这个表的结构设计成保留完整的历史建平仓记录哪怕一笔仓位已经平掉了也能通过entry_date和exit_date精确追踪这笔交易的全过程。2.2 增量更新机制只拉增量不重全量当时设计数据更新逻辑时最需要小心的是效率和流量。如果每天全量下载所有ETF从上市到当天的行情不仅耗时而且大概率会被数据源限制访问频率。所以更新模块采用增量策略每次更新前先查库里每个标的最新一个交易日期然后只拉这个日期之后的数据。核心更新逻辑是这样实现的def daily_update(conn, fetcher): cursor conn.cursor() # 查出每个标的最新日期 cursor.execute( SELECT code, MAX(trade_date) FROM etf_daily_price GROUP BY code ) latest_map dict(cursor.fetchall()) codes [code for code, _ in fetch_all_codes(conn)] for code in codes: start_date latest_map.get(code, 2020-01-01) df fetcher.get_daily(code, startstart_date) if df.empty: continue # 过滤掉数据库中已有的日期 df df[df[trade_date] start_date] if not df.empty: to_sql_batch(conn, etf_daily_price, df) print(f{code} 更新 {len(df)} 条记录) conn.commit()增量更新有一个隐藏的坑如果上次更新时有几天因为数据源故障漏掉了max(trade_date)会停留在故障前的最后日期下一轮增量更新会自动把漏掉的日期全部补齐这是这个思路最舒服的地方。只要保证启动更新时查询的是基础表而非临时文件数据一致性就有保障。2.3 数据完整性校验不清洗就不入库行情数据拿回来之后不能直接入库原因很简单数据源偶尔会出现停牌日没有数据、部分字段为null、甚至临时数据在盘中被修正的情况。如果这些脏数据直接进入数据库后续RS计算就会出现偏差可能让排名噪声大到信号失真。所以数据入库前强制过一遍清洗流程检查trade_date是否为有效的交易日周一至周五且非节假日这种简单校验对非交易日数据问题已足够open、high、low、close四个字段是否都大于0high是否大于等于low。发现异常时我选择把整条记录打印警告并跳过而不是用相邻值填充——因为在行情数据里沉默比乱填更安全。缺失值可以用前向填充处理但异常值往往意味着数据源标记本身有问题强行填充会掩盖数据源的bug。2.4 SQLite运维细节WAL模式和索引优化SQLite看似简单但如果写入频率高同时又有读请求默认的journal mode在高并发下会出现“database is locked”错误。我在初始化数据库时强制开启了WAL模式conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL)WAL模式下读写不互相阻塞这对Web系统后台每天定时触发更新任务、前端同时查询数据的场景非常合适。另外给etf_daily_price表加了一个复合索引CREATE INDEX idx_price_date ON etf_daily_price (trade_date, code);这个索引对于按日期范围取所有标的行情数据的查询很关键RS排名计算就是一次性取某个时间窗口内所有标的的行情没有这个索引会全表扫描数据量大时计算速度慢得让人怀疑人生。3. Web版轮动系统功能拆解与实现3.1 页面整体布局四类页面打通使用闭环Web端一共设计了四个主页面市场概览、轮动信号、持仓管理、数据管理。这四个页面不是简单的信息堆砌而是对应一个完整的使用流程打开系统先在市场概览看整体走势然后去轮动信号看今天的排名和调仓建议确认后有操作去持仓管理执行最后在数据管理里确认数据更新正常。市场概览页展示的是大盘指数的日K线图和主要ETF的当日涨跌幅排行。这里我用ECharts的K线图组件渲染指数走势配合一个表格展示ETF当日表现。轮动信号页是核心展示当前所有备选ETF的RS值排名表同时标记出当前持仓的标的以及今天的调仓信号。持仓管理页展示当前账户里已经买入的ETF包含每只的收益情况、当前市值、最近调仓日期。数据管理页提供数据更新状态查询和手动触发更新的按钮方便在数据源异常时手动介入。前端框架选择了Vue 3 Vite构建组件库用的Element Plus图表用的ECharts这些在个人项目中都算成熟方案。后端接口只负责下发数据前端拿到数据后渲染前后端通过RESTful API通信。3.2 轮动信号的接口实现轮动信号的核心接口是/api/signals/latest返回最新一个交易日的全部信号记录。后端实现不复杂关键是查询逻辑要对app.get(/api/signals/latest) def get_latest_signal(): conn get_db_conn() cursor conn.cursor() cursor.execute( SELECT signal_date FROM etf_daily_signal ORDER BY signal_date DESC LIMIT 1 ) latest_date cursor.fetchone()[0] cursor.execute( SELECT signal_date, code, rs_value, rank, action FROM etf_daily_signal WHERE signal_date ? ORDER BY rank ASC , (latest_date,)) results [{ signal_date: row[0], code: row[1], rs_value: row[2], rank: row[3], action: row[4] } for row in cursor.fetchall()] return {date: latest_date, data: results}逻辑上最需要注意的一点是调仓信号的生成日期要以交易日为准而不是自然日。如果今天是周六数据库里最新信号日期是周五接口返回的latest_date就是周五。这个逻辑背后是策略引擎在每天收盘后定时触发所以信号一定是交易日才生成。3.3 轮动信号计算逻辑RS排名与调仓决策轮动的核心策略并不神秘就是相对强弱排名。我使用的是双均线动量策略变种先计算出每个标的过去N日的收益率动量值再结合趋势确认指标最终得到RS排名。动量计算的核心代码def calculate_momentum(conn, date, window20): cursor conn.cursor() cursor.execute( SELECT code, (SELECT close FROM etf_daily_price WHERE code e.code AND trade_date ? ORDER BY trade_date DESC LIMIT 1) as latest_close, (SELECT close FROM etf_daily_price WHERE code e.code AND trade_date ? ORDER BY trade_date DESC LIMIT 1 OFFSET ?) as past_close FROM ( SELECT DISTINCT code FROM etf_daily_price ) e , (date, date, window)) ...这里用了子查询嵌套来取最新收盘价和N天前的收盘价。虽然写法上不如先取数据再在Python里算直观但SQLite的查询效率对这种规模的数据完全够用省掉了内存循环。最终排名逻辑对所有ETF计算的动量值降序排列取前3名作为备选买入池。如果当前持有的ETF在新一期的排名中跌出前5名就发出SELL信号如果新进入前3名的标的是空仓状态发出BUY信号。这个逻辑的核心思想是只在动量最强的品种中轮转落后者及时撤出。3.4 前端轮动信号表格与状态标记轮动信号页的表格我做了三层标注第一列是排名按RS值降序排列第二列是基金代码和名称第三列是RS数值第四列是状态标记。状态标记分为三种如果这个标的当前在持仓中且今天的信号是HOLD标签显示为持有中绿色如果信号是BUY显示为买入信号橙色信号是SELL则显示为卖出信号红色。这个表格每次在调仓日开盘前看一眼就够了。实际操作中我会根据它来决定是否执行调仓。但这里要强调一个经验信号生成只是参考你自己的资金情况和风险偏好才是最终的决策因素不要盲目机械执行信号。3.5 后端启动与定时任务集成FastAPI启动后轮动系统的数据更新和信号生成不能靠手动跑脚本必须做到自动化。我用APScheduler在FastAPI应用的后台启动了两个定时任务一个在每天收盘后比如16:30执行update_all_data()把当天最新数据入库另一个在更新完成后执行generate_signals()基于最新数据生成信号记录。关键代码from apscheduler.schedulers.background import BackgroundScheduler scheduler BackgroundScheduler() scheduler.add_job(update_all_data, cron, hour16, minute30, day_of_weekmon-fri) scheduler.add_job(generate_signals, cron, hour16, minute45, day_of_weekmon-fri) scheduler.start()两个任务间隔15分钟确保数据更新完成后再生成信号避免出现当天数据还没入库就把旧数据当作最新状态来算信号的时序错误。4. 实操过程中踩过的坑与排查思路4.1 本地数据库突然报错database is locked首次联调时前端多个页面同时打开后端又触发定时数据更新数据库报了“database is locked”。这个问题的根源是SQLite默认的journal模式在多线程环境下读锁和写锁冲突。解决办法在前面提到了开启WAL模式解决读写互斥再加一个连接池配置def get_db_conn(): conn sqlite3.connect(DB_PATH, timeout10) conn.row_factory sqlite3.Row conn.execute(PRAGMA busy_timeout5000) return conn设置busy_timeout后进程之间发生锁冲突时会等待而不是立刻报错。这在低频个人系统里已经足够稳定。如果你数据量大到并发写冲突频繁那就该考虑换PostgreSQL了但对于ETF量化这种数据规模SQLite加WAL模式绰绰有余。4.2 行情数据出现未复权与复权价格的混淆最开始我只存了不复权价格但后来发现用不复权价格计算收益率时遇到ETF分红除息会导致价格出现向下的跳变从而产生虚假的负动量。这个坑非常隐蔽一开始我回测的胜率怎么调都上不去后来对比了同一天两个数据源的close价格才发现了端倪。解决方法是数据入库时统一使用后复权价格。ETF分红相对不那么频繁但除息日的影响依然存在。后复权价格能保证历史收益率序列的连续性和可比性。在数据源的选择上直接使用返回后复权数据的接口入库后无需额外修正。4.3 前端ECharts图表初始化失败Vue 3里使用ECharts时最常遇到的问题是图表容器还没渲染完成就初始化。常规写法的bug是直接在mounted钩子里初始化ECharts实例但此时DOM布局可能还没稳定。我在开发时顺手用了nextTick包裹初始化逻辑才解除问题import * as echarts from echarts; onMounted(() { nextTick(() { const chart echarts.init(document.getElementById(market-chart)); chart.setOption(option); window.addEventListener(resize, () chart.resize()); }); });还有一个需要小心的地方页面用v-if切换显示/隐藏时ECharts实例可能无法正确获取容器大小。改成v-show保持DOM节点常在初始化后就不会再有0宽度问题。4.4 数据源偶发空值导致RS排名分布异常有一阵子每天信号排名里总有几个ETF的RS值明显异常偏大。排查下来发现是部分标的某天成交量字段为空导致后续的收益率计算出现了除零错误。数据清洗中我对volume和amount单独加了非空校验并打印告警日志。这类问题如果不在入库阶段拦截到策略计算阶段就很难定位源头了。实践里我总结了一个经验入库前的数据校验越严格后面策略计算的坑越少。宁可因为某条数据不合法而错过一天的行情记录也不能让脏数据混进数据库导致一整片信号失真。4.5 行情数据重复重复推入手动触发更新时有几次重复调用了update_all_data虽然增量逻辑会过滤掉已有日期但偶尔因为并发线程同时查询max(trade_date)导致两边都拉取了同一段数据。后来那把入库改成INSERT OR REPLACE利用主键(code, trade_date)自动去重问题彻底消失。5. 这套系统的日常使用流程与收益验证模块完成后日常使用的路径非常顺滑。每天收盘后后端定时任务自动把当天行情写入数据库随后计算新的RS排名和信号。第二天开盘前打开Web页面轮动信号页会显示最新日期的排名和状态标记。我在信号页加了“上一交易日信号对次日收益的验证”小模块把历史信号和实际行情对比计算信号胜率。这对评估策略稳定性很有价值。轮动策略的难点其实不在策略算法本身而在于纪律执行。Web化之后信号一目了然不需要自己手动跑脚本也就减少了很多“凭感觉”的干扰。以过去三个月的回测数据为例注意是回测不是实盘收益承诺动量轮动策略相比沪深300ETF等权持有确实展现出了一定的超额收益空间和最大回撤优势。不过ETF轮动量化的收益特征是阶段性的市场风格连续时表现好震荡市中信号会被反复打脸。所以我不建议依靠单一轮动策略重仓实盘更合理的做法是作为观察信号源辅助你做配置决策。Web系统本身也只展示信号不直接对接券商的自动交易这是刻意保留下来的安全边界。想完全自动化交易的话还得接券商接口、做风控熔断工程量不止翻倍这里就不展开建议了。6. 最后两天的计划与一点提醒DAY19完成之后整个ETF量化交易系统的闭环已经通了数据自动更新、策略自动计算、Web自动展示。剩下两天我不会再动核心架构计划做三件事一是把每个页面在极端情况比如无数据、接口异常、数据更新中断下的显示优化好二是把整个项目跑一遍从零开始的全流程测试确保换一台机器也能快速初始化三是把前18天里写过但没有来得及注释清楚的代码模块统一补上文档说明。对于正在参考这套思路自己搭ETF量化系统的朋友我给一个非常务实的提醒不要一上来就追求多因子、机器学习这些复杂模型先把一个简单可靠的动量轮动跑通把数据链路和Web展示做扎实这才是绝大多数人能做到的上限。策略再高级数据管不好、信号看不见也只是一堆废代码。如果你也在做类似的量化系统欢迎交流你的数据管理和Web展示方案。后面两天如果有时间我可能会把整个21天的踩坑记录汇总成一个清单分享出来那个清单比任何一篇单日日志都值钱。
返回列表