MATLAB量化入门:从CSV清洗到timetable回测全流程
2026/9/14 3:56:46 网站建设 项目流程

简介:这份随书MATLAB代码包面向量化金融入门与进阶读者,源自马萌编著的《MATLAB量化金融分析基础与实战》,按章节组织代码,覆盖MATLAB基础操作、金融数据导入与可视化、统计指标计算、时间序列建模、机器学习算法、期权定价(含Black-Scholes与波动率曲面)、投资组合优化、风险管理(VaR/CVaR)及程序化交易策略开发等核心主题,适合金融工程学生、量化分析师和自学者对照教材或独立研读。包内共90个文件,以71个.m源码文件为主,包含各章示例与综合案例,如Granger因果检验、SDE随机微分方程工具箱、海龟交易策略等,并辅以7个txt说明文档、csv/xlsx数据表、zip压缩包和少量Python辅助脚本,便于查看数据格式与跨语言调用;代码按Chapter 1至Chapter 13分目录存放,结构清晰,可直接运行调试。整体压缩包仅3.57MB,轻量便携,已有932人学习使用。通过运行和改造这些源码,可快速掌握MATLAB金融工具箱与统计/机器学习工具箱的实际调用方法,理解从数据清洗到策略回测、风险监控的完整量化流程,为独立开展量化研究打下扎实基础。

1. 随书代码能跑通,才算真正入门MATLAB量化分析

先说一个反直觉的结论:量化金融分析真正卡人的地方,不是策略本身,而是数据从CSV变成能被策略消费的那一层结构。市面上《MATLAB量化金融分析基础与实战》这类带随书代码的书,代码本身大多是对的,但多数读者下载完跑不起来——版本差异改了readtable和datetime的默认行为,示例数据格式固定,换了自己导出的CSV,字段名和日期格式立刻对不上,停牌、缺失、复权这些真实数据特征更是书里没展开的盲区。下面按我带团队时的内部路径展开:先用timetable把行情数据理顺,再写信号生成、回测循环和绩效指标,最后讲随书代码迁到新版MATLAB时的参数与坑。新手看完能半小时跑通第一版,老手也能在边界处理上查漏补缺。

2. 金融数据读取与清洗:把CSV导入MATLAB并整理成timetable

2.1 为什么量化分析首选timetable而不是table

R2016b之后MATLAB引入timetable,随书代码里的行情数据几乎全部用它组织。timetable的核心概念是行时间(RowTimes):所有运算按时间轴自动对齐,不需要像老代码那样为日期单独维护索引数组。金融序列最常用的lag、diff、按日期切片、跨表合并,在timetable上都有一行命令的实现。

选型理由有两点。一是调试效率:head(priceTT)直接展示日期与各变量,断点处一眼能看出数据是否对齐,比在矩阵里用行号猜日期直观太多。二是接口兼容:retimesynctick2retmovavg这些金融计算函数都围绕timetable设计,随书代码后续的指标计算、回测、画图都依赖这层结构。如果手上还有老脚本用datenum加矩阵存日期,建议统一迁移到timetable,后面无论是接Wind接口还是换数据源,都不用再改主逻辑。

2.2 用readtable把CSV导入并解析日期字段

随书配套数据或者自己导出的日线CSV,列一般是日期、开盘、最高、最低、收盘、成交量(或含成交额)。网上问得最多的"如何将csv导入到matlab"通常卡在日期列:readtable默认把日期读成文本或数值,需要手工转换。下面这段代码把CSV读入并整理成可直接计算的timetable:

% 读取CSV,PreserveVariableNames保留原始列名 rawData = readtable('stock_daily.csv', 'PreserveVariableNames', true); % 第一列是日期文本,按指定格式解析为datetime rawData.DateTime = datetime(rawData.DateTime, ... 'InputFormat', 'yyyy-MM-dd'); % 按日期升序排,并去掉重复日期(保留最后一个) rawData = sortrows(rawData, 'DateTime'); [~, uniqueIdx] = unique(rawData.DateTime, 'last'); rawData = rawData(uniqueIdx, :); % 转为timetable:行时间由DateTime列决定 priceTT = table2timetable(rawData, 'RowTimes', 'DateTime'); % 统一列名,后续代码不用记CSV原始列名 priceTT.Properties.VariableNames = {'open','high','low','close','volume'};

逐行说明:PreserveVariableNames为true时保留原始列名,中文名或带空格的列名不会被改写成合法标识符,代价是引用时要写字符串;datetimeInputFormat必须和CSV里实际格式逐字符一致,'yyyy-MM-dd'、'yyyy/MM/dd'、'yyyyMMdd'是三种常见写法,带时分秒的追加HH:mm:ss。排序和去重放在转换之前,因为table2timetable虽然会自动按行时间排序,但重复行时间不会自动剔除。

两个容易踩的边界:如果日期在Excel里被存成序列号(例如41821),datetime要加'ConvertFrom','excel'参数;如果日期字符串带时区后缀如'2024-01-05T15:00:00+08:00',先解析再统一转成本地时间,避免后面retime按UTC重采样把行情错位到"昨天夜里"。反过来,导出文件名或打印日志时把datetime转字符串,用char(priceTT.DateTime(end), 'yyyyMMdd')比老旧的datestr稳,后者在高版本里已标记为legacy。

2.3 停牌缺失与异常值的填充规则

真实日线数据几乎都缺行:停牌、数据源漏抓、除权除息前后价格跳变。直接拿缺行序列算收益,diff会把停牌前后两天的收益当成连续值,波动率被严重低估。先确认缺口性质,再选填充方式,不同字段用不同策略。

字段类型推荐方式理由
收盘价、开盘价fillmissing 'previous'停牌期按上一交易日定价延续
成交量缺失补0停牌确实没有成交,插值违反事实
首日缺失'next' 或直接删行新股没有前收价时反推误差大
连续型中间缺失'linear' 仅限有依据的场景金融价格不宜假定线性,慎用

按表格逻辑实现:

% 价格类字段用前一有效值填充,成交量空缺补0 filledTT = fillmissing(priceTT, 'previous', ... 'DataVariables', {'open','high','low','close'}); filledTT.volume(isnan(filledTT.volume)) = 0; % 剔除周末:weekday默认周日=1,周六=7 dow = weekday(filledTT.DateTime); filledTT(dow == 1 | dow == 7, :) = []; % 如果数据源本身按交易日输出,上面的剔除步骤可以跳过

fillmissingDataVariables指定只处理哪些字段,避免把成交量也previous填充成上一天的数字。weekday默认把周日当一周第一天,与国内习惯相反,很多人在这一步把周六的7误判成周一,调试时务必先head(filledTT)看日期列确认。节假日无法用通用函数一次性剔除,我更推荐直接用交易所以及数据商按交易日口径导出的现成数据,而不是自己维护节假日表。

注意:停牌数据的正确处理直接影响后续所有指标。凡是回测夏普比率高得离谱的情况,先查数据里有没有停牌后连续数日价格不变的假信号。

3. 收益计算与均线信号:从价格序列生成可回测的交易信号

3.1 简单收益与对数收益的选择

收益是量化策略的第一层抽象。MATLAB里tick2ret专门做这件事,随书代码里既能看见手写diff的版本,也能看见直接用工具箱的版本,两种写法在边界处理上有实际差别。

% 手写简单收益:注意分母是前一收盘价,第一行必为NaN simpleRet = diff(filledTT.close) ./ filledTT.close(1:end-1); % 用tick2ret计算连续复利收益,输出保持行时间 retTT = tick2ret(filledTT(:, 'close')); retTT.Properties.VariableNames = {'ret'}; % 多列同时计算,并指定简单收益 multiRet = tick2ret(filledTT(:, {'close','volume'}), ... 'Method', 'simple', 'Dimension', 1);

tick2retMethod参数选'continuous'得到对数收益,选'simple'得到简单收益;Dimension指定计算方向,金融时间序列按时间方向计算取1。对单标的多因子研究,我一般直接用'continuous',因为对数收益跨期可加,分布更接近正态,后面算相关性、做回归时不会因偏度被个别大阳线带偏。组合层面涉及复利折算才切'simple'。手写diff版本第一行是NaN,进std、回归前必须删掉,tick2ret则没有这个问题,这也是能直接用工具箱函数就尽量不手写的原因。

3.2 双均线交叉信号的向量化生成

均线交叉是随书代码出现频率最高的策略逻辑。老代码常用for循环逐日比较,向量化写法更符合MATLAB习惯,而且不容易把索引抄错。用Financial Toolbox的movavg,注意它的双输出形式:

% movavg双输出:第一个是短周期均线,第二个是长周期均线 [shortMA, longMA] = movavg(filledTT.close, 30, 60); % 持仓状态:短均线在长均线上方记1,否则记0 rawPos = double(shortMA > longMA); % 前段窗口期均线为NaN,比较结果自动为0,这里再兜底一次 rawPos(isnan(rawPos)) = 0; % 关键一步:信号滞后一天,避免用当天收盘价“预测”当天行情 position = [0; rawPos(1:end-1)];

movavg(filledTT.close, 30, 60)中30是短窗口Lead,60是长窗口Lag,返回两个长度与输入一致的序列。想用指数均线就加第四个参数Alpha,如movavg(filledTT.close, 12, 26, 0.2)。滞后处理是全段代码最容易抄漏的一行:position(t)代表第t天开盘时实际执行的仓位,它只能由第t-1天收盘后的信息决定。不滞后,回测年化收益会虚高几个点,实盘完全复现不了,这是回测里最常见的未来函数。

注意:不要用movmean替代movavg做交易信号。movmean默认窗口居中,天然引入未来数据,用它算出的金叉会“提前”出现。

3.3 用趋势与波动率过滤器减少无效交易

双均线在震荡市里会被反复打脸。随书策略一般会配一个过滤器,逻辑上分两类:趋势过滤和波动率过滤。趋势过滤的核心是“只在大趋势向上时做多”,波动率过滤则是“市场太狂躁时不开新仓”。过滤器参数对结果影响很大,先看一组常见取值:

参数常见取值作用调大后的影响
短均线窗口5/10/20/30捕捉短期趋势信号变钝,交易次数减少
长均线窗口60/120/200界定长期方向过滤更严格,启动更慢
波动率窗口10/20/60衡量近期波动幅度对突发波动反应变慢
波动率阈值中位数/75分位开仓门槛阈值越高开仓越少

对应实现:

% 趋势过滤:收盘价站上200日均线才允许做多 [~, trendMA] = movavg(filledTT.close, 200, 200); trendFilter = double(filledTT.close > trendMA); trendFilter(isnan(trendFilter)) = 0; % 20日滚动标准差默认是居中窗口,后移10个位置消除未来函数 volCenter = movstd(filledTT.close, 20); vol20 = [NaN(10,1); volCenter(1:end-10)]; % 阈值取中位数,omitnan忽略前段NaN volFilter = double(vol20 < median(vol20, 'omitnan')); % 过滤器各自滞后一天,与原始信号相乘 position = position .* [0; trendFilter(1:end-1)] ... .* [0; volFilter(1:end-1)];

movstd的居中窗口处理是这里唯一的难点:k=20的窗口实际覆盖前9天到后10天,直接使用等于偷看未来,所以把结果整体后移10位,让第t天的波动率只看t-19到t天的数据,再滞后一天进场,信息在t-1收盘时全部可用。过滤器叠加后的position对应实盘里的“信号来了但条件不满足就不动”,交易次数明显下降,代价是趋势启动初期会慢半拍。

4. 回测循环与绩效评估:把信号跑成资金曲线

4.1 最小回测循环与成交假设

回测回答的问题只有一个:按这套信号交易,账户每个交易日结束值多少。随书代码的回测结构通常是逐日循环,我给出的最小版本包含三个显式假设:信号来自前一日收盘、当天以收盘价成交、全仓进出且暂不计手续费。三个假设写清楚,后面加滑点和手续费时才有依据。

% 回测初始化 initCapital = 100000; cash = initCapital; % 可用资金 positionQty = 0; % 当前持股数 equityCurve = zeros(height(filledTT), 1); equityCurve(1) = initCapital; % 逐日循环,t从2开始,第1天无历史信号 for t = 2:height(filledTT) % 空仓且信号为1:全仓买入,股数向下取整 if position(t-1) == 1 && positionQty == 0 positionQty = floor(cash / filledTT.close(t)); cash = cash - positionQty * filledTT.close(t); % 持仓且信号为0:全部卖出 elseif position(t-1) == 0 && positionQty > 0 cash = cash + positionQty * filledTT.close(t); positionQty = 0; end % 日末净值 = 现金 + 持仓市值 equityCurve(t) = cash + positionQty * filledTT.close(t); end

这里用position(t-1)而不是position(t),因为position本身已经滞后过一天,position(t-1)实际按t-2收盘信息决策、t-1收盘成交、t结算,比直接引用position(t)保守,也更接近实盘。floor向下取整是为了避免产生小数股,实盘A股还要对100取整加一手手续费判断。想改成次日开盘价成交,把成交价从filledTT.close(t)换成filledTT.open(t),同时循环上界改成height(filledTT)-1,少跑最后一天。

4.2 最大回撤计算与资金曲线画图

净值序列出来后,第一个要看的不是收益而是回撤。回撤的定义是当前净值相对历史最高净值的跌幅,MATLAB里cummax一行就能算历史峰值:

% 净值历史峰值与回撤序列 runningMax = cummax(equityCurve); drawdown = (equityCurve - runningMax) ./ runningMax; maxDrawdown = min(drawdown); % 最大回撤是负数 % 资金曲线与回撤子图 figure; subplot(2,1,1); plot(filledTT.DateTime, equityCurve, 'LineWidth', 1.2); ylabel('账户净值'); grid on; title('回测资金曲线'); subplot(2,1,2); plot(filledTT.DateTime, drawdown * 100, 'r'); ylabel('回撤(%)'); xlabel('日期'); grid on;

画图时filledTT.DateTimeequityCurve长度必须一致,前面做过填充、删行、剔除周末的,回测前后用height对一下,差一行都会导致plot报错或错位。导出图片用exportgraphics(gcf, 'equity_curve.png', 'Resolution', 300),高版本MATLAB里saveas出的位图放进论文会发虚,exportgraphics是矢量精度导出。

4.3 绩效指标口径:夏普、卡玛与换手率

绩效指标不是越多越好,口径统一才可比较。随书代码常用的指标和容易踩的口径问题如下:

指标计算口径常见坑
年化收益(期末净值/期初)^(244/交易日数)-1用365还是244,结果差3%以上
年化波动率std(日收益) * sqrt(244)与年化收益的天数必须一致
夏普比率(年化收益 - 无风险利率)/年化波动率无风险利率取值必须注明
最大回撤min(净值/历史峰值 - 1)是负数,取绝对值口径要统一
卡玛比率年化收益 / abs(最大回撤)回撤为0时分母无定义

实现如下:

% 日收益从净值序列差分得到 dailyRet = diff(equityCurve) ./ equityCurve(1:end-1); % 年化:A股按约244个交易日 years = height(filledTT) / 244; annualRet = (equityCurve(end) / initCapital) ^ (1/years) - 1; annualVol = std(dailyRet) * sqrt(244); % 夏普与卡玛,无风险利率按2%估算 sharpe = (annualRet - 0.02) / annualVol; calmar = annualRet / abs(maxDrawdown); % 年均换手次数:position变化一次算一次买卖 turnover = sum(abs(diff(position))) / years;

最容易翻车的三处:std(dailyRet)不加omitnan,只要净值序列开头有一个NaN,整个夏普变NaN;年化用的天数与波动率sqrt放大的天数不一致,A股用244,美股用252,混用会导致夏普被系统性高估或低估;换手率只统计position的1与0跳变,忽略加仓减仓时持仓数量的连续变化,只能作为相对比较的近似值。

5. 随书代码迁移与参数优化:从跑通到敢用

随书代码到手第一件事不是run,而是检查环境。用license('test','Financial_Toolbox')确认工具箱在不在,返回0就先把财务工具箱装好。然后addpath(genpath('bookcode'))把随书目录加进搜索路径,所有“找不到文件或函数”的报错,八成是当前工作目录或搜索路径没设对,跟代码本身没关系。跨版本运行时,优先看readtabledatetimemovavg三类调用的报错,它们是版本改动最频繁的函数。

5.1 把回测封装成函数,参数扫描才有意义

随书代码是脚本,参数散落各处,想试不同均线周期就得反复复制粘贴。封装成函数后,参数扫描就成了一个双层循环的事:

function metrics = runMAStrategy(priceTT, shortWin, longWin) % 输入:timetable行情、短均线窗口、长均线窗口 % 输出:绩效结构体,含净值序列 [shortMA, longMA] = movavg(priceTT.close, shortWin, longWin); rawPos = double(shortMA > longMA); rawPos(isnan(rawPos)) = 0; position = [0; rawPos(1:end-1)]; % ... 回测循环与绩效计算 ... metrics.equityCurve = equityCurve; metrics.sharpe = sharpe; end

函数化之后,外层用网格扫描找参数就非常直接。装了Global Optimization Toolbox可以用patternsearch做连续参数搜索,但金融策略参数通常是整数、目标函数非光滑,网格扫描在绝大多数情况下够用,结果还可复现、好解释。

5.2 样本外验证:网格搜索出的参数算不算数

参数扫描只能说明历史拟合好,真正决定能不能用的是样本外表现。最简做法是按时间切分:前70%训练,后30%测试,在训练段挑最优参数,再到测试段验证:

% 按时间切分,前70%为训练段 splitIdx = floor(height(filledTT) * 0.7); trainTT = filledTT(1:splitIdx, :); testTT = filledTT(splitIdx+1:end, :); % 训练段网格扫描,记录最优参数 bestSharpe = -Inf; bestParams = []; for s = [10 20 30] for l = [40 60 90] m = runMAStrategy(trainTT, s, l); if m.sharpe > bestSharpe bestSharpe = m.sharpe; bestParams = [s, l]; end end end % 最优参数在测试段上验证 result = runMAStrategy(testTT, bestParams(1), bestParams(2));

判断标准不是测试段收益更高,而是三个事实:训练段与测试段夏普值符号一致、最大回撤没有扩大到三倍以上、交易次数没有骤降。任何一条不满足,说明最优参数只是拟合了训练段的噪声。滚动窗口验证(walk-forward)在随书代码阶段不必急着实现,先跑通分段验证,确认策略逻辑本身合理,再学walk-forward才有意义。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询