一张订单的一生
订单提交以后不是"成功"或"失败"这么简单。它会经历好几个阶段,每个阶段叫一个订单状态。交易系统里一大半的 bug,都出在状态没处理对。
订单状态 order status
OPEN 或 NEWPARTIALLY_FILLEDFILLEDCANCELEDREJECTEDenum OrderStatus(Order 就是订单,代码里都用这个词)加一个状态机:只允许某些状态跳到某些状态,比如"全部成交"之后不能再"撤单"。跟电商订单、支付单的状态机是一回事。 submit ──► OPEN ──fill──► PARTIALLY_FILLED ──fill──► FILLED
│ │
cancel cancel
▼ ▼
CANCELED CANCELED (filledQty > 0) ← 最容易漏的情况
reject ──► REJECTED (never reached the book)
"部分成交后撤单"最容易出 bug:订单状态是已撤销,看上去像"什么都没发生",但其实已经买到了 0.3 个,仓位变了。如果程序只看订单状态,就会漏掉这 0.3 个。
图里的英文:submit = 提交,fill = 成交,cancel = 撤单,reject = 拒绝,filledQty = 已成交数量,never reached the book = 从没进过订单簿。
滚到这里会自动按"提交订单 → 成交 0.3 个 → 撤单"演示一遍,每一步被按下的按钮会圈出来;点任何按钮就停下,换你自己点。按顺序点"提交订单 → 成交 0.3 个 → 撤单",看最上面的状态条和下面的红色警告:状态是已撤销,但仓位是 0.3。再点"重来",试"剩下的全部成交":之后"撤单"按钮会变灰,因为全部成交的订单已经结束了。
订单和成交是两回事
一张订单可以分好几次成交,每成交一次就产生一条成交记录。钱和仓位要按成交记录来算,不能按订单来算。
成交记录 fill / trade
一共花了 18003 + 30010 + 12010 = 60023 U,买到 1.0 个,成交均价 60023。
Order 和 Fill 是一对多。仓位、资金、手续费都从 List<Fill> 累加出来,跟"支付单"和"支付流水"的关系一样。Order A1 BUY 1.0 BTC limit 60,050 ├─ Fill f1 0.3 @ 60,010 = 18,003 U ├─ Fill f2 0.5 @ 60,020 = 30,010 U └─ Fill f3 0.2 @ 60,050 = 12,010 U filledQty = 1.0 cost = 60,023 U avg = 60,023 → position +1.0 BTC
图里的英文:Order = 订单,Fill = 成交记录,limit = 限价(最高愿意出的价),filledQty = 已成交数量,cost = 一共花的钱,avg = 成交均价,position = 仓位。
下单时的附加要求
下限价单时,可以额外加一句"要求",告诉交易所:碰到某种情况该怎么处理。这四个是最常见的,名字是英文缩写,意思都很简单。
post-only 只挂单
IOC Immediate Or Cancel,立即成交,剩下的撤掉
FOK Fill Or Kill,要么全成交,要么全撤
reduce-only 只减仓
· 不加 reduce-only:先卖 0.5 个,把原来做多的仓位了结掉(把仓位了结、归零,行话叫平仓),多出来的 0.3 个变成做空 0.3 个(D1 第 10 节讲过做空)。你本来只想平仓,结果反过来开了一个做空的仓位(行话叫空单)。
· 加 reduce-only:只会卖掉 0.5 个,多出来的 0.3 个自动作废。
先保持"普通限价单",拖动限价,看三栏里"马上成交""挂上去""被撤掉"怎么分配;再依次点 post-only、IOC、FOK,同一个价格下对比三栏的变化。默认限价 60020、买 1.0 个:普通单成交 0.8 挂 0.2,IOC 成交 0.8 撤 0.2,FOK 整张作废,post-only 整张被拒。
下单超时:不知道到底成功没有
程序发出下单请求,等了几秒没收到回复。这时候订单可能已经下成功了,也可能根本没到交易所,你不知道。这是交易系统里最常见、也最危险的情况。
超时 timeout
B:请求根本没到交易所。
从你这边看,A 和 B 一模一样,都是"没回复"。
clientOid 客户端订单号
网格编号-第几格-买卖-第几轮,比如 G7-3-B-12。程序重启以后,靠同样的规则能把编号重新算出来,再拿去查。幂等 idempotent
四种组合都点一遍。重点看两处:A + 换新编号为什么多出一张单;B + 换新编号为什么只是"碰巧没事"(因为你事先分不清是 A 还是 B)。
精度:价格和数量为什么要取整
交易所规定了价格和数量能精确到哪一位。程序算出来的数字往往位数更多,下单前必须按规定取整,而且往哪个方向取整,关系到钱。
tickSize 价格最小单位
lotSize 数量最小单位
最小下单金额 min notional
往哪个方向取整
浮点数 double
0.1 + 0.2 得到 0.30000000000000004。0.3 / 0.1 得到 2.9999999999999996,向下取整变成 2,于是 0.3 被取整成了 0.2。价格凭空少了一格。BigDecimal,而且必须用字符串构造:new BigDecimal("0.1")。写成 new BigDecimal(0.1) 会把那个不准的 double 原样带进来。你做支付时应该已经踩过这个坑了。BigDecimal raw = new BigDecimal("27123.456"); // 程序算出来的价格
BigDecimal tick = new BigDecimal("0.1");
BigDecimal buy = raw.divide(tick, 0, RoundingMode.DOWN).multiply(tick); // 27123.4
BigDecimal sell = raw.divide(tick, 0, RoundingMode.UP).multiply(tick); // 27123.5
把下面五个预设挨个点一遍,对比左右两栏:左边是用 double 算的结果,右边是正确结果;最下面的结论说明 double 错在哪(错一格,或者多出一长串小数尾巴)。
new BigDecimal(0.1) 得到的是 0.1000000000000000055511…,要写 new BigDecimal("0.1") 或 BigDecimal.valueOf(0.1)。② new BigDecimal("1.0").equals(new BigDecimal("1.00")) 是 false,因为 equals 连小数位数也比。比大小用 compareTo;要当 HashMap 的 key,先 stripTrailingZeros()。从期货到永续合约
D1 讲过:合约不是真的买币,而是按价格涨跌算盈亏。合约分两种:有到期日的叫期货,没有到期日的叫永续合约。你们交易所的合约产品,主要是永续合约。
期货 futures
永续合约 perpetual,常简称"永续"
三个价格和插针
在合约里,同一时刻的行情里有三个不同的"价格"。你只需要记住一句话:算盈亏和判断强平,用的是标记价格,不是最新成交价。
最新成交价 last price
指数价格 index price
溢价 和 标记价格 premium / mark price
插针 wick / spike
exchange A spot 60,010 ─┐
exchange B spot 59,990 ─┼─► index price 60,000 (weighted average)
exchange C spot 60,005 ─┘ │
│ + smoothed premium
last trade here 53,500 ▼
(one-second spike) mark price 59,940 → 算盈亏、判断强平都用它
图里的英文:exchange A spot = A 交易所的现货价格,weighted average = 加权平均,smoothed premium = 平滑后的溢价,last trade here = 本交易所的最新成交价,one-second spike = 一秒钟的插针,mark price = 标记价格。
拖动"插针插到"再点"制造插针":细线是最新成交价,粗的橙色线是标记价格,红色虚线是你的强平价。细线戳穿红线时,"如果按最新成交价判断"会显示被强平(误杀),而橙色线几乎不动,按标记价格一直是安全的。这里用"指数 + 溢价的移动平均"来模拟标记价格,真实算法各家不同,但思路一样。
资金费率:多空之间定期转账
每隔一段时间,做多的人和做空的人之间要互相转一笔钱。转多少、谁给谁,由资金费率决定。它的作用是把永续合约的价格拉回到指数价格附近。
资金费率 和 资金费 funding rate / funding fee
费率为负:做空的人付钱给做多的人。
每次付多少:
资金费 = 持仓价值 × 资金费率。多久一次:常见是每 8 小时一次(一天 3 次),各家、各交易对都可能不同。
每次付 10000 × 0.01% = 1 U;一天 3 次 = 3 U;30 天约 90 U。
如果你是做空,那就是每次收到 1 U。
先看默认:做多、10000 U、+0.01%,每次付 1 U。把费率改成 −0.01,看"付"变成"收";再把方向改成做空对比。绿色是你收钱,红色是你付钱。
逐仓和全仓
开合约时要选:这笔仓位亏钱时,最多亏掉哪些钱?只亏这一笔押金,叫逐仓;整个账户的钱都拿来兜底,叫全仓。
逐仓 isolated margin
全仓 cross margin
维持保证金和强平价
D1 说过"押金快亏光时会被强平"。今天把"快亏光"说精确:押金亏到只剩维持保证金那么多时,就强平。这个时刻对应的价格,就是强平价。
初始保证金 initial margin
初始保证金 = 仓位价值 ÷ 杠杆。维持保证金 和 维持保证金率 maintenance margin / MMR
维持保证金 = 当前仓位价值 × 维持保证金率。维持保证金率常见是零点几个百分点,仓位越大通常越高,各家不同。强平价 liquidation price
押金 + 浮动盈亏 = 维持保证金左边是"你账户里还剩的钱",右边是"必须留下的钱"。两边相等的那个价格,就是强平价。
① 押金 = 60000 ÷ 10 = 6000
② 浮动盈亏 = (P − 60000) × 1
③ 维持保证金 = 0.5% × P × 1 = 0.005P
④ 6000 + P − 60000 = 0.005P → 0.995P = 54000 → P ≈ 54271.36
也就是跌了约 9.55% 就强平,而不是 D1 简化版说的 10%。
6000 + 60000 − P = 0.005P → 1.005P = 66000 → P ≈ 65671.64
P = 开仓价 × (1 − 1/杠杆) ÷ (1 − 维持保证金率)做空:
P = 开仓价 × (1 + 1/杠杆) ÷ (1 + 维持保证金率)这是逐仓、不算手续费的简化版,真实交易所还会考虑手续费、分档的维持保证金率等。
先别看结果,按上面第 ④ 步自己手算 60000、10 倍、0.5%,再对照下面每一步。然后把杠杆改成 20、50、100,看"距开仓价"缩到多小:杠杆越高,价格动一点点就强平。
强平之后:穿仓、保险基金和自动减仓
强平不是"到了强平价就瞬间按强平价卖掉"。交易所要在市场上把仓位卖掉,实际卖出的价格可能比强平价好,也可能更差。
穿仓 bankruptcy / negative equity
· 行情平稳,实际在 54100 卖掉:亏 5900,押金还剩 100 U。这 100 U 常见做法是划进保险基金(各家规则不同),你拿不回来。
· 行情急跌,实际在 53800 才卖掉:亏 6200,比押金多了 200 U,这就是穿仓。
保险基金 insurance fund
自动减仓 ADL,Auto-Deleveraging
position hits liquidation price (by MARK price)
│
▼
exchange closes it in the market
├─ closed with equity left ──► leftover goes to insurance fund
└─ closed below zero (loss > margin)
│
▼
insurance fund pays the gap
│ fund not enough
▼
ADL: reduce profitable opposite positions → 被减仓的人也要能正确对账
图里的英文:position hits liquidation price = 仓位碰到强平价;closes it in the market = 在市场上把它卖掉;equity left = 押金还有剩;loss > margin = 亏损超过押金;insurance fund pays the gap = 保险基金补窟窿;fund not enough = 保险基金不够;reduce profitable opposite positions = 减掉方向相反、正在赚钱的仓位。
词典和自测
今天和 D1 用到的词,这里都能查到。