T23 · Perp DEX
保证金、资金费和风险引擎怎样组成一个系统?
- 练习的能力
- BuilderFinancial Literacy
- 动手
- 实现仓位与保证金计算,模拟一次连环清算,记录保险基金的消耗。
- AI Lab
- 让 AI 设计资金费率公式,自己用历史数据回测它能不能把价格拉回现货。
一个现实问题
你的永续合约场所上线三周了。你最花力气的是撮合:订单簿、价格优先时间优先、部分成交、撤单、事件推送。它跑得很干净,压测下延迟稳定,成交记录一笔不差。
然后某个凌晨,标的在十二分钟里跌了 18%。
第二天早上你去对账,所有子系统的日志都是正常的:
- 撮合没有卡过,成交全部落库。
- 清算引擎触发了 214 次,全部成功。
- 价格源没有停更,T21 的四个检查一次都没报警。
但账对不上。
永续合约是一个零和账本:多头赚的每一块钱,必须是空头亏的那一块钱。你把当天所有仓位的盈亏加起来,本应等于零,实际却是负的——系统欠着用户一笔钱。
你顺着差额找过去,原因很简单:有 31 个多头仓位在被平掉的时候,亏损已经超过了它押上的保证金。它们的对手方赚的钱是真的,但付钱的人已经没钱了。你写了三周的撮合引擎,在这件事里一行代码都没错,也一点忙都帮不上。
这个洞是谁的?谁来填?
思想实验
把交易所全部拆掉,只留一张纸和一个人。纸上记着两行:A 做多 10 个单位,B 做空 10 个单位,标的现在 100,价格每动 1 就有一方给另一方 10。
这个人的工作不是撮合——A 和 B 已经配上了。他的工作只有一件:保证这张纸上的每一笔账随时都能兑付。 现在给他加一个条件:A 和 B 都只押了 50 保证金,而不是 1000。
价格开始动。他立刻发现自己必须每时每刻回答四个问题,而这四个问题就是这一章的全部内容。
第一,此刻每个人还剩多少? 价格到 103,A 浮盈 30,B 浮亏 30,B 的净值只剩 20 了。这个数字必须随时可算,而不是等到平仓那天才知道。
第二,什么时候必须把人请出去? 如果等 B 的净值归零才动手,那一刻价格正好在 105,他平仓能精确成交在 105 吗?不能——他需要时间,而价格不等他。所以他必须提前,在 B 还剩一点钱的时候就动手。剩多少才动手,是一个必须写下来的数字。
第三,请晚了的缺口怎么办? 价格从 104 跳到 108,B 的净值变成负 30。A 应该拿到 80,但 B 只交得出 50。这三十块钱必须从某个人身上拿走,因为这张纸必须平。挂着不算数——挂着就等于 A 的余额是假的。
第四,如果所有人都想做多呢? 纸上必须一多一空才成立。看多的人有一百个,看空的只有三个,剩下九十七个人和谁配对?
第四个问题的答案就是第 18 章讲过的资金费率:给「站在拥挤那一侧」标一个价格,让付钱这件事把人从拥挤侧赶到稀缺侧。那一章问的是「它为什么能把价格按住」,这一章问的是「它在代码里怎么写」。
四个问题合在一起,就是一台机器:它持续读价格、持续重算每个人的净值、在净值见底之前把人请出去、请晚了就从别人身上拿钱补上。
撮合决定谁和谁成交。这台机器决定这个系统能不能兑付。
你来决定
回到开头那 31 个穿仓仓位。缺口是一个确定的数字,它必须在今天之内落到某个人头上。
你选谁?
观察结果
四个选项摆在一起,共同结构很清楚:它们都在回答同一个问题——零和账本上那个洞,今天之内落在谁头上。
| 承担方 | 用户可预期吗 | 触发时机 | 主要代价 |
|---|---|---|---|
| 挂成坏账 | 否 | 永不结清 | 变成先到先得的挤兑 |
| 专项资金池 | 是 | 第一优先 | 规模有限,计价资产可能同步缩水 |
| 按比例扣盈利 | 否 | 资金池耗尽后 | 损失来源无法提前定价 |
| 强制平掉盈利仓位 | 部分可预期 | 最后一道 | 做对方向也拿不到全部盈利 |
第一行是唯一一个真正错误的选项,其余三个是同一条防线的不同层级。把它们排成序列,就是这一章的骨架:
- 清算按维持保证金线平仓
- 仓位自身的剩余保证金
- 专项资金池
- 强制平掉盈利方
现在把穿仓的发生条件写成一个不等式。它和 T22 那个不等式是同一个形状:
穿仓发生的条件:
标记价格在「触发清算」到「仓位平完」之间的移动幅度
>
维持保证金率 + 预留给清算的那部分
其中:
左边 = 市场的速度 × 你的清算延迟 ← 你控制不了的部分
右边 = 你设的参数 ← 你能设计的部分结构和 T22 完全一致,但有两处差别,这两处差别就是这一章存在的理由:右边小一个数量级(借贷的缓冲是二十几个百分点,永续的维持保证金率常见在百分之几甚至千分之几,因为杠杆高),以及缺口不能挂账(借贷的坏账可以记在协议头上慢慢消化,永续的缺口今天就要从某个用户身上拿走)。
于是这一章的那句话:永续合约的本质是一台持续运行的风险引擎,撮合只是它的外壳。 撮合决定成交质量,风险引擎决定这个系统会不会在某个凌晨欠用户的钱。
建立模型
五个部件。按它们在一次价格变化里被触发的顺序来看。
一、标记价格:所有判断的地基
一个永续市场里同时有三个价格,混用它们是这一章最贵的一个错误:
| 名字 | 它是什么 | 用来做什么 |
|---|---|---|
| 指数价格 | 多个现货场所价格的加权 | 资金费率的基准、标记价格的原料 |
| 标记价格 | 指数价格加上一段受限的偏离 | 算盈亏、算清算 |
| 最新成交价 | 这个合约自己最近一笔成交 | 显示、撮合 |
清算必须用标记价格,不能用最新成交价。 最新成交价由你自己这个市场的盘口决定,而盘口可以被一笔交易推动——用它做清算依据,等于把「谁该被清算」的决定权交给任何一个愿意在薄盘口上砸一笔的人。
最朴素的取法是把外部指数价当基准,只允许成交价在它周围一个固定带宽内浮动:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
uint256 constant BPS = 10_000;
/// 标记价格:以外部指数价为基准,只允许围绕它小幅偏离
function markPrice() public view returns (uint256) {
uint256 index = priceGuard.readPrice(); // T21 的四个检查在这个函数里面
uint256 last = lastTradePrice;
uint256 upper = index * (BPS + markBandBps) / BPS; // markBandBps 可配置,典型量级是几十到几百
uint256 lower = index * (BPS - markBandBps) / BPS;
if (last > upper) return upper;
if (last < lower) return lower;
return last;
}这段代码的全部价值在于它把攻击成本从「推动自己的盘口」抬高到了「推动外部现货市场」,而外部现货市场有多厚,就是 T21 那个安全边际的分子。T21 的整整一章都在这个 readPrice() 里面:它返回一个被操纵的值,下面四个部件全部失效,而且每一个都会正常执行、不报任何错。
二、仓位与保证金:随时可算的净值
int256 constant WAD = 1e18;
struct Position {
int256 size; // 正数是多头,负数是空头,单位是标的数量
uint256 margin; // 这个仓位押上的保证金
uint256 entryPrice; // 开仓时的标记价格
int256 entryFunding; // 开仓时的累计资金费指数,见下一节
}
/// 未实现盈亏:size 带符号,所以多空两种情况用同一行算完
function unrealizedPnl(Position memory p, uint256 mark) pure returns (int256) {
return p.size * (int256(mark) - int256(p.entryPrice)) / WAD;
}
/// 名义价值 = 仓位绝对值 × 标记价格
function notional(Position memory p, uint256 mark) pure returns (uint256) {
int256 s = p.size >= 0 ? p.size : -p.size;
return uint256(s) * mark / uint256(WAD);
}size 用带符号整数是这里唯一值得强调的设计选择:多空的盈亏、资金费、清算判断可以共用同一套公式,不必在每个函数里分叉——分叉写法在多空不对称的边界上几乎必然出错。
三、资金费率:不能遍历所有仓位
第 18 章讲清楚了资金费率为什么能把价格按住。工程上它有一个绕不开的约束:你不能每 8 小时遍历所有仓位收一次钱,仓位可能有几万个,链上没有这个预算。
解法和 T22 的利息完全同构:维护一个全局累计指数,仓位只记开仓时的指数值。 任意时刻某个仓位欠多少,是一次减法:
int256 public cumulativeFunding; // 每一单位多头仓位累计应付的金额,带符号
uint256 public lastFundingAt;
/// 把上次到现在的资金费累进指数里。O(1),和仓位数量无关
function accrueFunding(uint256 mark, uint256 index) internal {
uint256 elapsed = block.timestamp - lastFundingAt;
if (elapsed == 0) return;
// 溢价项:标记价相对指数价偏离多少,放大 WAD 倍
int256 premium = (int256(mark) - int256(index)) * WAD / int256(index);
// 费率 = 溢价项摊到每秒 + 利率项,然后必须夹在上下限之内
int256 rate = premium / int256(fundingPeriod) + interestPerSecond;
if (rate > fundingCapPerSecond) rate = fundingCapPerSecond;
if (rate < -fundingCapPerSecond) rate = -fundingCapPerSecond;
cumulativeFunding += rate * int256(elapsed) * int256(index) / WAD;
lastFundingAt = block.timestamp;
}
/// 欠的资金费:正数表示这个仓位要付出去,负数表示它在收钱
function fundingOwed(Position memory p, int256 cumulative) pure returns (int256) {
return p.size * (cumulative - p.entryFunding) / WAD;
}
/// 账户净值 = 保证金 + 未实现盈亏 − 欠的资金费
function equity(Position memory p, uint256 mark, int256 cumulative) pure returns (int256) {
return int256(p.margin) + unrealizedPnl(p, mark) - fundingOwed(p, cumulative);
}三个必须自己决定的参数,全都是可配置的,没有普遍正确的值:
| 参数 | 它决定什么 | 设错了会怎样 |
|---|---|---|
fundingPeriod | 偏离被摊成多长时间收完 | 太长则锚太松,太短则平静期也在反复收费 |
fundingCapPerSecond | 这个锚的强度上限 | 设窄了,极端行情下机制顶到头、再也施加不了压力 |
| 计费基准 | 按名义价值还是按保证金 | 两者差着一个杠杆倍数,写错就是量级错误 |
第二行最容易被跳过。上下限不是安全措施,它是锚的最大拉力:偏离一旦超过它能提供的压力,费率就变成一个恒定值、不再随偏离增大,这时候把价格拉回来的只剩套利者,而套利者在极端行情里最容易退场。
还有一件必须写进代码的事:资金费要在清算判断之前累计。 一个长期不动的仓位可能仅仅因为资金费累积就跌破维持线,和价格毫无关系。漏掉这一行,你会在某一天发现一批「看起来很健康」的仓位其实早就该被清算了。
四、清算:账必须在这一步平掉
error NotLiquidatable();
/// 可清算的条件:净值跌到维持要求以下
function isLiquidatable(
Position memory p,
uint256 mark,
int256 cumulative,
uint256 maintenanceBps // 维持保证金率,典型量级是几十到几百 bps,通常随仓位规模分档,必须可配置
) pure returns (bool) {
int256 e = equity(p, mark, cumulative);
if (e <= 0) return true;
return uint256(e) <= notional(p, mark) * maintenanceBps / BPS;
}
function liquidate(address trader) external {
Position storage p = positions[trader];
uint256 mark = markPrice();
accrueFunding(mark, priceGuard.readPrice()); // 先结资金费,再做判断
if (!isLiquidatable(p, mark, cumulativeFunding, maintenanceBps)) revert NotLiquidatable();
int256 e = equity(p, mark, cumulativeFunding);
uint256 fee = notional(p, mark) * liquidationFeeBps / BPS; // 清算人的全部动机
if (e > int256(fee)) {
_payout(msg.sender, fee); // 清算费给清算人
_payout(trader, uint256(e) - fee); // 剩下的退还用户
} else if (e > 0) {
_payout(msg.sender, uint256(e)); // 只够付一部分清算费
} else {
// 净值已经是负数:穿仓。缺口必须在这一笔交易里找到承担方
_coverShortfall(uint256(-e));
}
_closePosition(trader);
}
function _coverShortfall(uint256 gap) internal {
emit ShortfallCovered(gap);
if (insuranceFund >= gap) {
insuranceFund -= gap;
return;
}
uint256 remaining = gap - insuranceFund;
insuranceFund = 0;
_autoDeleverage(remaining); // 按盈利与杠杆排序,强制平掉盈利方的一部分
}_coverShortfall 是整个合约里唯一一个不允许 revert 的分支。它可以耗尽资金池、可以动用最后一道防线,但它必须返回,因为账必须在这一笔交易里平掉。
对照 T22 看这一行:借贷的 _recordBadDebt 是「记下来、以后再说」,永续的 _coverShortfall 是「现在就找到钱」。同一个位置,两种完全不同的义务。
五、清算人为什么来
和 T22 一模一样:清算人不领工资,没有义务,只在「名义价值乘以清算费率」大于「Gas 加上平掉这个仓位的滑点」时才出现。 于是两者共享同一组失效模式——小仓位清算不动、拥堵时算不过账、十个人抢一个机会九个白付手续费。区别在后果:借贷是坏账慢慢累积,永续是今天就有人提不出钱。
它叫什么
一个用户在这个市场上未平掉的敞口:方向、规模、开仓价、押上的保证金。
工程上把方向写成带符号的数量而不是一个布尔字段,可以让盈亏、资金费和清算判断共用一套公式。多空分叉的写法在边界上几乎必然出错。
你为一个杠杆仓位押上的本金。
要分清两个数:初始保证金决定能开多大,维持保证金决定什么时候被清算。还要分清模式:逐仓下清算价是一个可以提前算出的常数,全仓下它会随账户里其他仓位的盈亏持续移动,开仓时算出的那个数字不能当常数用。
用来计算盈亏和触发清算的那个价格,以外部指数价为基准,只允许在受限的带宽内偏离。
它存在的全部理由是:把清算的判断依据从一个薄市场搬到一个厚市场上。 用最新成交价代替它,等于允许任何人用一笔交易决定谁该被清算——这是永续合约历史上最经典的一类事故。
按周期在多空两方之间结算的一笔付款,方向和大小由标记价相对指数价的偏离决定。
它不是手续费,钱在用户之间转移。工程实现上不能遍历仓位收取,必须做成累计指数,仓位只记开仓时的指数值。三个必查参数:周期、上下限、计费基准——第二个是这个锚的强度上限。
专门用来填穿仓缺口的一笔钱,来源通常是清算过程中的剩余与手续费留存。
判断它的两个问题:它用什么计价(装着这个场所自己发行的资产,它会在最需要它的时刻同步缩水),以及它的规模相对未平仓量是多少。单看基金有多少没有意义——未平仓量是这个市场此刻挂着的存量杠杆,它才是分母。
保险基金不足以覆盖缺口时,按排序强制平掉盈利方一部分仓位来接住敞口。
它是最后一道防线,也是永续合约独有的一类风险:方向判断正确、仓位在盈利,仍然可能被规则平掉。 排序规则必须提前公开,让用户能知道自己排在第几位。
动手
实现核心结构与四个纯函数。 Position 结构体,加上 unrealizedPnl、notional、fundingOwed、equity,size 用带符号整数。
先只写纯函数,每一个都要能手算验证:
- 多头 10 个单位、开仓价 100、标记价 103 → 浮盈 30;同样规模的空头(size 为负)→ 浮亏 30。
- 累计资金费指数从 0 涨到 2:多头 10 个单位欠 20,同样规模的空头收 20。
- 两边的资金费加起来必须是零。这是零和性质的第一个测试,它在后面所有测试里都应该成立。
实现标记价格与资金费累计。 标记价格用「指数价加带宽」的做法,价格源接 T21 的 PriceGuard,四个检查一个都不要去掉。写一个可控的桩价格源,验证三件事:最新成交价在带宽内时返回成交价;被推到带宽之外时返回被夹住的边界值而不是成交价;价格源过期时,整个清算路径的行为符合你在 T21 里显式选过的那个策略。
实现清算与两道兜底。 liquidate 里那三个分支全部写出来,尤其是 e 为负的那一支。_coverShortfall 必须能在保险基金不足时继续执行,不允许 revert。写一个测试专门验证:把保险基金设成 0,构造一个穿仓仓位,调用清算——它必须成功返回并触发自动减仓。
如果它 revert 了,你的合约里就有了一个永远清算不掉的仓位。 这是 T22 那条教训在永续上的版本,后果还要更严重:它会一直占着对手方的头寸,让别人的浮盈永远兑现不了。
构造一次连环清算,记录保险基金的消耗。这是 Lab 的主体。 建三批多头仓位,杠杆递增,驱动一个下跌序列。关键在于:每一步都必须重新读标记价格、重新累计资金费、重新判断,而不是一次算完。
初始标记价 100,维持保证金率按你自己设的值
三批多头: A 批 5 倍杠杆 1 亿、B 批 10 倍杠杆 3 亿、C 批 20 倍杠杆 2 亿
深度假设: 每平掉 5000 万名义价值,标记价格再跌 1%(全局最关键的假设)
起始扰动: 标记价格跌到 96一轮一轮跑,每轮记一行:
| 轮次 | 标记价 | 本轮被清算的名义价值 | 本轮穿仓缺口 | 保险基金余额 | 是否触发自动减仓 |
|---|---|---|---|---|---|
| 1 | |||||
| 2 | |||||
| 3 |
找出保险基金归零的那一轮。 那一轮就是你这套参数下「规则开始伤害做对方向的人」的起点。
然后只改深度假设,从「每 5000 万跌 1%」改成「每 2 亿跌 1%」,其余全部不变,重跑一遍。两张表放在一起,你会看到这一章最重要的一个结论:同样的杠杆结构、同样的参数、同样的代码,换一个深度假设,一次只是回调,一次是保险基金被打穿。 这也解释了为什么同一套合约配一个流动性很差的标的,风险要高一个数量级——问题不在合约里。
算一次攻击成本。这是整章的量化练习。
前面算的是「行情会不会打穿我」。现在换一个问法:有人主动来打,要花多少钱?
场景:你的市场上有 N 的名义价值堆在某个杠杆水平,
它们的清算触发价距离当前价 d(百分比)
第 1 步:把标记价格推到 d 需要多少钱
标记价以外部指数价为基准,所以这一步要推的是外部现货市场
用 T21 的公式:约等于 现货深度 × (sqrt(1+d) − 1)
第 2 步:被触发的清算规模,以及这些强平单会把价格再推低多少
这是连环效应,用你上一步的深度假设
第 3 步:攻击者能拿到什么
a) 他事先建立的反向仓位在这段下跌里的盈利
b) 他作为清算人收到的清算费
c) 他在被强平单里以折价接到的货
第 4 步:净利 = 第 3 步 − 第 1 步 − 手续费 − Gas代三组数:现货深度取 500 万、5000 万、5 亿,d 取 5%。第一组的净利多半是正的——它说明在一个浅标的上,清算不是「价格下跌的后果」,而是一笔可以被主动制造的生意。
然后做两次反算。一,在你这套参数下外部现货深度至少要多少,这笔生意才不划算? 这个数字就是这个标的能被安全上架的最低流动性要求,和 T21 里那个抵押品准入门槛是同一个东西。二,把标记价格换成最新成交价,第 1 步要花多少钱? 推动自己盘口通常比推动外部现货市场低一到两个数量级,这个倍数就是标记价格这个设计的全部价值。
全程本地网络,不接任何真实资金。 真实资金只出现在毕业项目,并且必须先通过第五阶段的安全验收。
AI Lab
分三步问,第三步是真正的产出:
第一步:
为一个永续合约市场设计资金费率公式。给出完整表达式、每一项的含义、
上下限怎么选,以及一个 O(1) 的链上实现(不允许遍历仓位)。
说明周期长度和上下限这两个参数各自在保护什么。
第二步:
给你一段价格数据:标的现货价格,以及同期永续合约的成交价。
用你上面那个公式逐期算出资金费率,输出每一期的:
偏离幅度、费率、多头的持有成本年化、空头的收益年化。
指出哪些时段费率顶到了上下限。
第三步:
在第二步顶到上限的那些时段,回答:
偏离是继续扩大还是收敛?如果继续扩大,说明什么?
你会怎么改参数,改完之后新的代价是什么?第一步大多数模型能给出形状正确的公式,错误集中在实现上:很多回答会写一个遍历所有仓位的循环,或者干脆把它推给链下。追问它:几万个仓位、每 8 小时一次,这段代码的 Gas 是多少?
第二步要用真实的历史数据,不要让它自己编一段。去公开数据源拉一段现货与永续的价格序列(剧烈波动的时段比平静时段有价值得多)喂给它,然后自己抽查三期,用纸笔复算它给的费率。
第三步是这个 Lab 的全部价值。顶到上限还在扩大,意味着机制已经失效,而账面上一切正常。 让它回答这种情况下还有什么在把价格往回拉——答案是套利者,而套利者恰恰在这时最容易退场。最后把它的公式接进你 Lab 里的合约跑一遍:不要只看公式漂不漂亮,看它在你的连环清算模拟里表现如何。
AI 说完之后,你必须自己验证
- 它的公式里有没有上下限——没有上下限的费率公式在极端行情下会给出荒谬的数值
- 把上下限代进去,算一算在偏离 10% 时这个锚能施加的最大压力是多少,够不够
- 它算的是按名义价值还是按保证金收费,两者差着一个杠杆倍数,它有没有说清楚
- 它的实现是不是遍历所有仓位收费——链上没有这个预算,必须是累计指数
- 它有没有说明费率为负时钱从空头流向多头,以及这时资金池的会计怎么处理
- 回测里它用的价格是指数价还是标记价,混用这两个会让回测结果完全不可信
- 它提到的任何具体参数(周期、上下限、维持保证金率),你是否当成可配置的起点而不是事实
真实案例
早期一些场所直接用合约自己的最新成交价来判断强平。攻击路径完全可以被算出来:估算一批仓位的强平价,估算把自己盘口砸到那里需要多少钱,如果触发的强平规模大于成本,这就是一笔生意。强平单落回同一个薄盘口,把针拉得更长,触发更多强平;针拔掉之后价格回到原位,但被扫掉的仓位回不来了。
行业后来普遍改成外部现货加权的指数价格加上受限的标记价格。这个改动的本质就是本章那段 markPrice() 代码:把清算的判断依据从一个薄市场搬到一个厚市场上。它和 T21 是同一个问题的两个出口。
攻击者选了一个现货流动性很小、却被这个场所上了永续的标的。他先在永续上建立一个很大的多头,然后去现货市场把价格买上去。指数价随之上涨,他的多头浮盈变成一个巨大的数字——而这个浮盈在这个场所的规则里可以立刻用来提走资产。
没有任何一行代码出错。 清算没触发,因为他在赚钱;价格源没撒谎,因为现货真的涨了;保证金计算完全正确。被绕过的是一个假设:「浮盈是真实的」。
两个教训:上架一个标的之前,先算它的现货深度撑不撑得住这个市场的敞口上限;以及未实现盈亏能不能立刻提走,是一个需要单独做决定的参数,很多实现对它做了限制。
价格跳空,大批仓位在维持线以下才被平掉,缺口远超当期清算能覆盖的部分,保险基金迅速见底,自动减仓大规模触发。结果是:大量方向判断正确、正在盈利的用户被强制平掉了部分仓位。 他们没有做错任何事,也没有收到任何风险提示——规则里写着,但他们没读。
这类事件之后的常见改动有三个:公开保险基金余额与未平仓量的比值、公开自动减仓的排序队列让用户看到自己的位置、以及按仓位规模分档提高维持保证金率,让大仓位承担更高的缓冲要求。第三个最有效,也最不受大户欢迎。
在一段持续的单边行情里,资金费率可以连续多期停在上下限上,而偏离还在扩大。
这时候一个很容易被误读的事实是:费率是最大值,不代表压力是最大值。 费率已经不再随偏离增长,机制的拉力被封顶了,把价格拉回去的只剩套利者。而套利者需要在现货一侧建立反向仓位,这在行情最紧张时恰恰最难做。
上下限是这个锚的强度上限。 它是一个必查参数,而绝大多数用户从来没查过——看任何一个永续市场,这个数字应该排在你清单的前三行。
改一个变量
盘口厚的时候看不出任何区别,代码跑得一样快,测试一样绿。区别全部出现在盘口变薄的时候,而且它改变的不是风险大小,是风险的性质:原本「价格波动导致清算」,现在变成「清算可以被主动制造」。
你 Lab 最后一步算出的那个倍数就是这个改动的代价。这是整章最不该改的一行——它是一个把攻击成本从外部现货市场降到自家盘口的开关。
单个仓位的爆仓距离从 4.5% 缩到 0.5%,但真正的变化不在单个仓位:同样一笔保证金现在能撑起五倍的名义价值,未平仓量会上升,而这些新增敞口全部堆在极薄的缓冲上,保险基金的规模却没有跟着变。原本需要 5% 的下跌才能点燃第一排骨牌,现在 0.5% 就够了。
把 Lab 那张表按新杠杆重跑一遍,保险基金归零的轮次会明显提前。提高杠杆上限不是多给用户一个选项,它改变了这个市场从平静到失控所需要的扰动大小。
锚变紧了:偏离一出现就开始计费,不用等到结算时刻。用累计指数实现的话这个改动几乎是免费的——把 accrueFunding 挂在每次状态变更前调用即可。
三个新问题随之而来。抢在结算时刻前后开平仓的套利消失了,这是好事。每笔交易要多做一次价格读取和指数更新,Gas 上升。精度:每个区块的费率是一个很小的数,整数运算下反复截断会系统性地少收或多收,累计几十万个区块之后是一个可观的数字——取整方向要让资金池不亏,这和 T19 里「每一次取整都必须让池子赢」是同一条。
平时账面很好看:基金规模可以做得很大,成本很低。问题在于它引入了一个反馈回路——极端行情里缺口变大的同时,这个资产的价格通常也在下跌,基金的实际覆盖能力在最需要它的时刻缩水。你以为有三道防线,实际上第二道在第一道失效的同一时刻跟着失效了。
更彻底的问法是第 21 章那个结构:你的保险基金和你要保的风险,是不是同一个东西驱动的? 如果是,它就不是保险,它是杠杆。
带走的问题
三笔独立的钱,必须分开看:资金费在多空两方之间转移(不经过协议),清算费从被清算的用户流向清算人,穿仓缺口从保险基金或盈利方流向被穿仓的对手方。三笔钱的付款人都是用户,收款人也大多是用户。
协议在这套机制里主要不是收钱的一方,而是记账和兜底的一方——这决定了它的失败模式是「兑付不了」,而不是「亏钱」。
永续市场的流动性有三层,缺一层就不完整:合约盘口的深度决定开平仓滑点,外部现货市场的深度决定标记价格能不能被操纵,保险基金是缺口出现时唯一不需要找对手方就能动用的资金。很多分析只看第一层——一个盘口看起来很厚、但外部现货很薄的永续市场,是这一章最想让你警惕的东西。
分四层回答,顺序就是本章那条防线链:平时风险在杠杆使用者自己身上;清算时一部分转移给清算人(他承担滑点和竞争失败的成本);穿仓时转移到保险基金;基金不够时转移到盈利的那一方。最后一层是永续独有的——你可以在做对的情况下被规则平掉,所以保险基金规模、它的计价资产、自动减仓的排序规则都是必查项。
这一问在这里有一个很具体的形态:如果这个场所自己发行的资产归零,保险基金还剩多少? 扩展一下更有用——这套机制里有哪些环节依赖一个价格会波动的东西:保险基金的计价资产、清算人的对冲工具、给做市方的激励。每一个这样的依赖,都是一个在极端行情里会和风险同步恶化的部件。
本章自测
因为撮合决定的是「谁和谁成交、成交在什么价位」,而永续合约是一个零和账本:一方的盈利必须由另一方的亏损来支付。保证这件事随时成立的,是一台完全独立于撮合的机器——它持续读标记价格、持续重算每个仓位的净值、在净值见底之前把仓位平掉、平晚了就从保险基金或盈利方身上把缺口补上。
判断标准很直接:撮合写错了,用户的成交价会变差;风险引擎写错了,用户的余额是假的。 前者是产品问题,后者是兑付问题。
因为最新成交价由这个合约自己的盘口决定,而盘口可以被一笔交易推动。用它做清算依据,等于把「谁该被清算」的决定权交给任何一个愿意在薄盘口上砸一笔的人。
标记价格以外部多个现货场所的加权指数为基准,只允许成交价在一个受限的带宽内偏离。它做的事只有一件:把攻击成本从「推动自家盘口」抬高到「推动外部现货市场」。
推论:看一个永续市场安不安全,先看它的指数从哪几个现货场所取价、那些市场有多厚。取价的地方越薄,这层防护越弱——这和 T21 里那个安全边际的分子是同一个数字。
因为仓位数量没有上限,而链上每笔交易的计算预算有上限。几万个仓位的遍历放不进一笔交易,而且它会随业务增长越来越慢,直到某一天彻底执行不了。
正确的做法是维护一个全局累计指数,记录「每一单位多头仓位从开始到现在累计应付的金额」,每次状态变更前按经过的时间把当期费率累进去;仓位只存开仓时的指数值,任意时刻它欠多少是一次减法。这和 T22 里借贷的利息累计是同一个技巧——只要看到「要对很多个账户周期性地做同一件事」,就先想累计指数,而不是循环。
因为债权人不同。借贷里用户欠的是协议,协议可以承认这笔损失、用储备消化、公开披露,账仍然是自洽的。
永续里用户欠的是另一个用户。缺口挂着不结,等于盈利那一方的账面余额是假的。这不会表现为一个公开的数字,而会表现为:前面几个来提款的人提得出来,后面的人提不出来。一个挂账的穿仓缺口,就是一次已经开始但还没被发现的挤兑。
所以 _coverShortfall 是整个合约里唯一一个不允许 revert 的分支:它可以耗尽保险基金、可以动用自动减仓,但必须在这一笔交易里给出答案。
没有标准答案,检查你的顺序里有没有这几项,以及第一项是不是真的排在第一:
- 外部现货市场的深度与场所分布。它决定指数价能不能被操纵,也决定了后面所有参数的上限。这一项不合格,其余全部白搭。
- 这个市场的敞口上限——未平仓量最多允许到多少,必须和第 1 项挂钩,而不是一个拍脑袋的数字。
- 最大杠杆与维持保证金率的分档表。分档是关键:大仓位平仓时造成的价格冲击更大,必须承担更高的缓冲要求。
- 标记价格的带宽,以及价格源不可用时每个入口的行为(T21 的那张表在这里要重填一遍)。
- 资金费率的周期、上下限、计费基准。上下限决定锚的强度上限。
- 保险基金的规模、计价资产、补充机制,以及自动减仓的排序规则是否对用户可见。
顺序的逻辑和 T22 一样:从地基往上,先定价格来源,再定敞口,最后定出事之后怎么收场。大多数人从第 3 项开始想,而决定风险量级的是第 1 项。
一句话带走
永续合约的本质是一台持续运行的风险引擎,撮合只是它的外壳。