做市交易这个东西,这几年在国内量化圈的热度一直没降过。很多人一开始以为做市就是“挂个买价挂个卖价,等着两边成交赚差价”,真下场写代码才发现,问题根本不是“挂不挂单”,而是“挂多少、挂在哪、挂了之后库存怎么处理”。尤其是在加密市场、商品期货、期权这类高频交易场景里,订单稍微不对称,几个tick的波动就能把库存打成单边,然后眼睁睁看着浮亏吃掉之前积累的微薄利润。Avellaneda-Stoikov模型之所以被反复拿出来讲,就是因为它把“报价怎么定”和“库存怎么管”这两个核心问题,从拍脑袋变成了可计算的数学公式。这篇文章我就结合自己的实践经验,把这个模型从公式到工程落地完整拆一遍,适合已经会写策略但想上手做市方向、或者在做市策略里挣扎于参数和框架的开发者参考。
1. 先搞懂Avellaneda-Stoikov在解决什么问题
1.1 做市策略的本质:赚的不是方向,是流动性
做市商的生意模式本质上赚的是“流动性溢价”的钱。市场上总有人想立即成交,他们愿意为自己的急切付一点价差,做市商的角色就是站在中间,买入一个略低于市场公允价值的卖单风险,同时卖出一个略高于公允价值的买单风险,把两边的价差收下。
听起来很稳,但风险出在库存上。如果你只挂出了买单而卖单没成交,你的库存就会净多,这时价格一旦下跌,账面就会出现亏损。做市策略的核心难点就在这里:既要通过报价在市场上占据一个好的价差位置,又不能让库存暴露承担太大方向性风险。
传统思路是人工设定一个安全库存范围,比如“库存超过100手就停止买”,这种做法简单粗暴但把很多利润空间浪费了。Avellaneda-Stoikov的高明之处在于,它把库存的风险和报价的竞争力放到了同一个优化框架里,模型自动告诉你:当前库存偏多的时候,你的买价应该压低一点、卖价应该抬高一点,用牺牲单边成交率的方式来把库存恢复到零附近。
1.2 模型怎么算价位:残差价格与最优半价差
Avellaneda-Stoikov模型的核心输出是两条曲线:一条是残差价格(reservation price),另一条是最优半价差(optimal half-spread)。残差价格的定义是把库存风险折算进去之后,你心里那个“真正应该成交的基准价”。
用公式表示就是:
r = s - q * γ * σ² * (T - t)其中:
- s 是当前市场中间价;
- q 是当前净库存,正数表示净多,负数是净空;
- γ 是风险厌恶系数,越大代表越怕库存风险;
- σ 是标的资产的瞬时波动率;
- T - t 是当前时间到做市结束时间之间的剩余时长。
这里面的直觉很直接:当你库存净多的时候,q为正,残差价格就会比市场中间价低,相当于模型在告诉你“现在手上存货多了,想继续买就得买得更便宜,想卖就要卖得更积极”。如果库存是负的,逻辑反过来,残差价格会上移。
最优半价差的公式是:
δ = γ * σ² * (T - t) + (1/γ) * ln(1 + γ/κ)其中 κ 是订单到达强度参数,可以理解为市场参与度。κ 越大,说明你的报单越容易被敲中,那么你需要的价差补偿就可以稍微薄一点。
最终报价就是残差价格加减半价差:
bid = r - δ ask = r + δ这套公式最舒服的一点是,它把“想赚多少价差”“怕不怕库存”“市场成交活跃度”三个维度拆成了独立参数,任何一个维度变化,都能在报价上得到量化反馈。
1.3 每个参数到底是什么意思
参数这关不过,公式跑起来就是灾难。我见过不少直接照抄原始论文参数跑模拟盘的对数收益看起来不错,一上实盘就穿仓的案例,原因基本都是对参数含义理解不到位。
先看 γ,风险厌恶系数。它的实际作用是对库存做“惩罚”,γ 越大,模型越倾向于把库存压向零,报价会偏离中间价很多,做市成交率变低,但库存风险也小。γ 太小,模型会贪婪地追求成交,库存越积越大,一个波动就能带走所有利润。经验上,γ 一般不需要设很大,尤其在波动率较低的标的上,0.01到0.5之间就够用了,关键是和σ相乘后的量级要和盘口价格粒度匹配上。
再看 σ,波动率。这里要注意,模型里的σ指的是“成交带来的库存波动”,而不是简单的历史波动率。很多实现直接用固定窗口的历史收益率标准差,也可以,但窗口长度很敏感。窗口太长,波动率反应迟钝,真出大行情时报价还停留在原来的宽度,容易被打穿;窗口太短,噪声太多,报价一直跳来跳去,撤单率居高不下。
最后是 κ,订单到达强度。严格意义上它应该由历史成交数据估计出来,比如统计每秒你的报价被成交的次数。实际工程中更常见的做法是把它弱化,直接用固定值,或者干脆设成无限大,这样半价差公式里对数项退化为0,模型就变成了简单库存惩罚版的Bias报价,用起来也不会有太大问题。
2. 工程化第一件事:数据清洗与波动率信号
2.1 本地数据:逐笔、切片、盘口快照怎么选
工程化做市策略,数据粒度决定策略上限。市价驱动盘中数据至少要保持百毫秒级切片,如果用分钟级数据回测和生成信号,做市策略几乎不可能盈利,因为做市的利润窗口就是秒级甚至毫秒级的。
我的建议是至少准备三套数据:盘口快照(order book snapshot)、逐笔成交(trade tick)、以及自己合成的周期切片。盘口快照用于计算中间价、盘口深度、买卖压力;逐笔成交用于校准成交率参数和滑点估计;周期切片用于滚动计算波动率和更新信号。
在数据落地上有个比较常见的坑:不同交易所的快照推送时间戳不一样,有的带本地时间,有的带交易所时间,有的两个都有。做市信号对时间同步很敏感,建议统一转换成一个单调递增的时间轴,优先使用交易所侧时间,同时记录本地接收时间的偏差,后续做撮合延迟分析时用得上。
2.2 波动率估计:滚动窗口与EWMA
波动率这个参数在A-S模型里起着乘法器的作用,直接决定价差宽度和库存惩罚强度。我早期实现时直接用过去60分钟的收益率滚动标准差,后来发现几类问题:市场刚从一个平静期进入活跃期时,滚动窗口反应太慢,价差还维持低位,结果被连续大单反复打穿;反过来,大行情刚结束进入整理期,波动率还悬在高位,价差收不回来,做市利润变薄。
后来切成了EWMA(指数加权移动平均)估计,效果明显改善。核心是给近期数据更高权重,计算公式:
σ²(t) = λ * σ²(t-1) + (1-λ) * r²(t)其中 λ 是衰减因子,取值一般在0.9到0.99之间。加密市场短线交易我更偏向 λ 设成0.94左右,相当于大约15到20个周期的有效回看窗口,既能跟上突发波动,又不容易被单根异常K线带偏。
做市方向还有个细节:波动率要区分“已实现波动率”和“跨周期预测波动率”。如果策略在每个交易时段结束时清零库存,那么时间项 (T-t) 里的σ应该用“剩余周期内的预期波动”而不是历史全量波动,否则快到收盘时会发现模型给的价差依然很大,但实际上你根本没有足够时间把库存清掉。
2.3 参数计算:从tick信号到报价参数
数据准备好之后,报价参数的生成就是纯计算问题了。假设我们每500毫秒重算一次信号,伪代码大致这样:
def calc_quote_params(mid_price, inventory, risk_gamma, sigma, kappa, dt_remaining): reservation = mid_price - inventory * risk_gamma * sigma * sigma * dt_remaining half_spread = risk_gamma * sigma * sigma * dt_remaining if kappa is not None and kappa > 0: half_spread += (1.0 / risk_gamma) * math.log(1.0 + risk_gamma / kappa) bid = reservation - half_spread ask = reservation + half_spread return bid, ask, reservation这里有一个工程点容易被忽略:pricing tick size。交易所的订单价格不是连续的浮点数,都有最小价格精度。如果你计算出来的 bid 是 100.532114,但tick size是0.1,那实际挂单价只能是100.5或者100.6。取整方向做错了会严重影响成交率。我的习惯是:买单价向下取整到tick,卖出价向上取整到tick,这样保证计算出来的理论价差不会因为取整被压缩掉,属于做市商之间常见的最小保护。
计算出的 bid 和 ask 还要和盘口当前最优价位做对比。如果最新动态盘口上已经有比你报得更优的价格,你的单子大概率排在后面,等于白挂,这种时候通常直接撤掉重新报价,或者干脆跳过本轮更新,等待下一轮信号。
3. 做市引擎:订单状态机与风控熔断
3.1 订单生命周期管理
做市策略的订单管理比方向策略复杂得多,因为做市单的特点是“高挂单频率、高撤单率”。一个典型的做市订单会经历:生成、发送、确认、部分成交、完全成交、主动撤单、超时失败这么几个状态,任何一个环节处理不当都会造成重复挂单或者幽灵订单。
我在生产环境里用一个简单的状态机来管理订单,状态定义成四个:IDLE(空闲)、PENDING(待成交跟踪)、FILLED(完全成交)、CANCELLING(撤单中)。每次信号更新时,引擎会扫描所有在途订单,对应该撤的下撤单指令,对应该重报的立刻重新发送。
这里特别要强调的是撤单的确认机制。很多散户在交易所把单子撤掉,以为撤单请求发出去就OK了。但实际成交和撤单存在竞态,可能你发撤单的同时,一笔对手单刚好吃掉了你这个单子。如果订单管理系统没有处理这个竞态,本地状态以为已经撤掉了,但交易所侧已经成交,就会出现“幽灵持仓”,这对做市商来说很致命。我见过不少运行几十天的实盘程序在出现一次竞态后,库存方向没被正确记录,整个策略开摆。
标准做法是:撤单请求发出后,必须等待交易所回执确认状态为 CANCELLED 或者 FILLED,把账算清楚,再进入下一轮下单逻辑。如果交易所回执长时间不返回,宁可停机报警,也不要盲目对账。
3.2 引擎整体架构设计
做市引擎的架构和常规CTA策略不太一样,CTA只要保证信号计算和下单通道通畅即可,做市引擎则要在一个循环周期内完成“行情采集、信号计算、订单管理、风控检查、日志落盘”五个环节,任何一个环节拖慢都会影响下一轮报价。
我常用的分层设计是三层。
数据层负责订阅和处理行情数据,输出统一格式的Tick对象,包含时间戳、中间价、盘口双侧深度、最新成交价。信号层拿到Tick后计算参数,生成目标报价。执行层包含订单管理器和风控模块,负责把目标报价变成实际订单发送到交易所,并对在途订单进行生命周期管理。
这三层之间通过消息队列传递事件,避免上游延迟阻塞下游。比如行情数据吞吐量大,如果直接同步调用信号计算,遇到消息高峰会把整个引擎卡死;用异步队列做削峰,在执行层保证每秒最多重算多少次报价,超出的行情更新自动丢弃。做市场景下你不需要处理每一毫秒的行情,关键是保持报价稳定,减少无意义撤单。
3.3 风控参数与熔断机制
做市策略的风控,第一优先级不是防亏损,而是防失控。做市业务的利润每天就那么多,一旦失控,往往一小时亏掉一个月的利润。所以我做风控时会在策略层面和账户层面同时布防。
策略层面的风控主要限制报价行为和库存行为,常见配置大概是:
| 风控项 | 参数 | 说明 |
|---|---|---|
| 最大库存 | 比如±50张 | 超过后单边报价自动停止,只允许回补方向挂单 |
| 单轮最大撤单次数 | 比如每秒不超过20次 | 超出后暂停报价,防止被交易所限流 |
| 最小价差保护 | 比如不小于2个tick | 防止模型在特定参数下报出极端窄价差 |
| 单笔最大订单量 | 比如不超过20张 | 限制单次成交冲击 |
| 每日最大回撤 | 比如策略净值的2% | 触发后当天停止做市,人工确认后恢复 |
账户层面还要有一套独立于策略逻辑的大风控,不依赖策略代码,直接读取账户持仓和盈亏接口,一旦触限立刻发指令撤销所有挂单并锁死下单权限。这个大风控要单独部署,进程独立,只有手工解锁才能恢复。
熔断条件里有一个很容易被忽略的:交易所行情异常。所谓“瀑布行情”就是中间价瞬间跳空,报价还没重新计算,旧的限价单已经变成市价单被大量成交。应对方式是监测中间价单秒变化幅度,比如超过过去30秒平均波动的5倍,立刻撤掉全部订单、停止做市,等待行情稳定后再重启。这个触发器必须放在数据层,而不是信号层,因为它不依赖模型参数,纯粹是行情保护。
4. 回测系统:别让拟合报表骗了你
4.1 回测框架怎么选
做市的回测比方向型策略更挑剔,如果用传统的 K 线级别回测框架,基本没法用,因为它模拟不了“限价单排队、被反向吃掉、撤单重挂”这些做市的核心交互。
我觉得做市内回测的最低要求是事件驱动撮合,也就是按 tick 顺序逐笔模拟,每笔成交都判断是否和自己的订单价格有交叉,交叉则成交。事件驱动框架可以自己写,也可以基于现有的开源框架改。比如用 Python 的话,可以基于 backtrader 做二次开发,但性能比较差;追求性能可以用 Rust 或 C++ 写撮合核心,只把策略逻辑留在 Python 里。
自己写撮合听起来麻烦,但好处是很清楚自己的模拟假设是什么。撮合规则不透明,才是回测结果的真正风险。我见过有人在撮合器里默认“成交价永远是己方限价单的价格”,这等于假设没有滑点和排队惩罚,结果回测结果显著好于实盘。在写撮合器时,至少要考虑盘口排队概率、成交量限制、手续费率和最小价格精度。
4.2 撮合细节与费用模型
做市策略对成本极其敏感,因为做市本身的单笔利润就是1-2个tick,手续费稍微高一点,策略直接亏损。回测时必须精确模拟手续费结构,包括maker的手续费返佣、taker的手续费,以及可能的成交后结算费用。
我这里会把撮合器里的“成交判断”用泊松到达模型近似简化,同时叠加一个排队惩罚:报价与内盘最优价持平的单子,有50%的概率在同一个 tick 中被对手单成交;报得更远的单子概率更低。虽然这是一个简化的方式,但在资产流动性较好的大盘标的上,回测结果和实盘差距已经可以控制在10%以内。
还有个细节:做市策略在回测里会频繁的撤单和重挂,如果回测撮合器没有模拟“撤单可能在成交tick之后到达”这种竞态,同样会低估成交风险,高估收益。我在撮合器里加了一个随机延迟因子,模拟撤单请求在网络传输中与对手单成交先后顺序的不确定性,这样回测结果会更保守,也更接近实盘。
4.3 Walk-forward参数优化
很多团队在做市参数优化的时候,用一个历史区间跑网格搜索,选取收益最高的那组参数上实盘,结果回测都是“神”,一实盘就萎。原因很简单:过拟合。做市策略对参数敏感度极高,特别是 γ 和 σ 的配合,稍微偏移就可能是大亏和微赚的区别。
我倾向于用 Walk-forward 优化的方式,把历史数据切成多个连续区间,每次用前段数据选参,后段数据验证,最终选择在多个验证区间上表现最稳定的参数组,而不是单一回测区间里收益最高的那组。稳定性指标一般看:夏普比率、最大回撤、样本外胜率、总交易次数。
回测评估时还要注意几个坑:总交易次数太少的结果没有统计意义;最大回撤太小有时候是因为参数太保守,成交和利润都不够,只能算“死扛不动”的稳定;同一个参数在不同年份的收益方差如果非常巨大,说明策略对市场状态极度敏感,上实盘之前要谨慎。
5. 生产部署:延迟、监控与灰度
5.1 延迟优化与稳定性
做市策略的生产部署,最大的敌人是延迟波动,不是延迟平均值。平均值100毫秒可能还能跑,但如果延迟在10毫秒到500毫秒之间剧烈抖动,策略会频繁判断价格已经变化而撤单重挂,导致撤单率暴增。
我做过的一个加密市场做市项目,最开始运行在普通云服务器上,行情推送是WebSocket + 按秒重连,结果发现撤单率一直异常高。后来排查发现云服务器的网络环境在高峰期会出现明显的抖动,有时候WebSocket消息延迟超过400毫秒。最后换了同数据中心的物理机,行情接入改为带时间戳的TCP私有协议推送,平均延迟稳定在20毫秒以内,撤单率直接降了一半。
部署上我强烈建议“就近部署”原则:交易所API在哪里的延迟最低,你的执行机就放在哪里,行情网关和执行机在同一个内网。策略进程和行情进程若能在同一台机器上,通过网络共享内存或Unix Socket通信,延迟和抖动都会大幅下降。这在K线策略里无所谓,但在做市策略里是生死线。
5.2 监控指标与告警体系
做市策略的监控不能只看盈亏,更需要监控“行为质量”类指标:撤单率、挂单成交率、平均挂单时长、库存绝对值、报价跳变次数等。这些指标比总收益更能提前暴露策略的异常。比如挂单成交率突然从5%跌到0.3%,大概率不是市场行情问题,而是代码bug导致报价离盘口太远,需要立刻人工介入。
我做的监控体系分成三层:
第一层是实时数值监控,用 Prometheus 采集指标,Grafana 出面板。核心指标包括:当前库存、目标价差、实际撤单率、累计成交次数、累计手续费。第二层是日志追踪,所有订单生命周期事件都落结构化日志,包含订单ID、方向、价格、数量、状态变更时间、触发原因。第三层是定时检查,每隔30秒把当前订单状态与交易所账户持仓做对账,一旦发现不一致立刻报警。
告警级别要区分。库存超过风控阈值、日亏损超过阈值属于P0级,需要告警CALL和强制熔断;撤单率短期异常、行情数据长时间不更新属于P1级,只需要告警通知,通过自动重启或切换备用行情源恢复即可。
5.3 灰度切换与人工干预
做市系统上线不能一把梭。我的习惯是先做一整套 shadow mode,也就是影子盘模式。策略照常计算报价,但不下真实订单,而是把这些目标订单通过撮合器做本地仿真,并记录成交结果。跑一周影子盘,对比实盘行情下,但如果模拟成交结果明显好于同类真实做市商的表现,就先怀疑仿真器里的乐观假设,再考虑是否上量。
真正切实盘时,也从最小下单量开始,比如目标挂单量的1/10。跑通一天的完整交易周期,确认没有出现异常状态、对账正常,再逐步把下单量调整到目标值。整个灰度时间不要少于三天,中间必须经历至少一次高波动行情和一次低流动性行情,才能真正检验风控逻辑。
人工干预的角色也不能全交给代码。我们团队会安排值班人员在策略熔断后手动复盘,把熔断时的订单状态、盈亏变化和当时的行情快照一起拉出来看,确认是参数问题还是流动性问题,然后再决定是否重启策略。完全自动化的做市系统并不是越智能越好,关键场景下人的判断仍然是必须的防线。
6. 踩坑记录与扩展方向
6.1 实盘中最容易翻车的几个地方
第一批坑集中在参数上。一个是 γ 和 σ 的量纲不匹配。比如你算出的 σ² 是0.0001,γ 是10,乘起来是0.001,但盘口价格精度只有0.01,那这个库存惩罚就太小了,模型基本不修正库存,做过市效果和裸奔差不多。要把惩罚换算成 tick 数来做校验,确保“库存每变化一个单位,报价偏移至少0.5个tick”。否则模型的数学表达式再完美,在真实盘口上也发挥不出来。
第二批坑在时间项 (T-t) 上。如果把 T-t 设成一整天,每个时段的报价差异会被时间项稀释,库存惩罚在午盘时段几乎不起作用,只有快到收盘时才剧烈变化。更好的做法是把每个交易日切分成多个独立做市周期,比如每15分钟一个周期,每个周期结束前库存清零,这样 (T-t) 的衰减效应才会在每个周期内持续发挥作用。
第三批坑是订单生命周期里没有处理“部分成交”。做市单很容易出现只成交一部分的情况,比如挂了20张,只成交了5张,剩下15张还在订单簿上。如果系统只记录“这笔单子被成功发送”而忽略具体成交量,下一轮信号更新时会把剩余的15张也撤掉,或者在某些竞态下重复追加挂单额度,导致库存名义值和实际值不一致。正确做法是每次收到成交回执,立即更新本地库存和订单剩余量,更新完再计算下一轮报价。
6.2 进阶扩展方向
A-S模型的经典版本虽然好用,但放到复杂实盘中还是有不少改进空间。最简单的扩展是引入动态风险厌恶系数。比如 gamma 不再是一个固定常量,而是与当前未实现盈亏挂钩:盈利越多,风险容忍度可以稍微放宽;亏损接近阈值,gamma 自动调高,让系统加速清库存。
另一个方向是把订单簿形状引入价差估计。原始模型假设市场到达强度是均匀的,但实际不同价位上的深度差异很大。我们可以统计订单簿上 bid 侧和 ask 侧在±2%、±5%价位上的挂单量,作为实时流动性因子,动态修正 κ 参数,让报价策略更贴近当前市场状态。
对做市策略更进一步的改造,还可以叠加方向性模型。经典做市是纯被动策略,如果想在趋势行情中降低做市成本,可以在信号层引入一个趋势衰减因子,比如检测到持续同向大单流入时,把做市报价朝反方向偏移一点,避免持续被流动性吃单。这个方向水很深,但一旦做好,策略容量会成倍上升。
我自己在实盘里最大的体会是:做市策略的工程实现,看起来围绕数学模型,实际上拼的是系统细节。A-S模型给了你一张相对合理的地图,但从地图到真实行走之间还有无数的坑。参数标定、订单竞态、行情异常、风控断点,任何一个地方掉以轻心,模型再好也白搭。如果你刚开始上手,我建议先把回测做到足够保守,再把影子盘跑够时间,最后才上小量实盘验证,每一步走得慢一点,做市这条路反而会走得远一点。