PolyMarket 预测市场是如何运转的 02

PolyMarket 链下撮合模块解析


从前面一篇文章,我们从用户视角和整个项目工程设计的视角把项目拆了一遍

这篇文章接上文,这篇文章更深入的研究中间的撮合系统,链上合约结算的模块

对后面我们跑一些bot,研究Bot 执行切入的方式,是非常重要的


1. 订单簿

先从订单簿开始看,一个事件中的独立的Market, 有成功也有失败。

YESNO ,两者概率加起来等于1, 就相当于,1美元代表100% 的总概率,对应的不同的yes/no share 的价格,即用户参与判断的概率。

ASK 代表卖方报价, Bid 代表买方报价。

Maker 挂单代表主动提交限价单(limit order),订单会放下面的订单簿中,增加这个市场的流动性, Taker 会吃掉对应的订单,订单成交,消耗订单簿中的流动性。


当前的价格, YES的最高买价BIDS 0.33, 最低卖价 ASKS 0.36

NO 侧的最高买价格BIDS 0.64, 最低卖价格ASKS 0.67

价差Spread =0.03 , 说明流动性还不错。


限价单

此时用户进来之后,想要使用更低的价格买到YES,比如报价0.3,单子签名排序后,进入到订单簿,便会叠加在0.3 的深度上面。

如果想要使用更高的价格卖出NO,比如报价0.68,对应的单子也会在排序到NO 的对应0.68 的深度上。

市价单

如果想直接买入对应的份额,不考虑价格,则使用Market Order, 买入YES 的价格,匹配最高卖出yes的价格 0.36, 这个价格肯定不是最优的,但是是可以在订单簿中撮合成交的。

08-01

部分撮合成交

有了上面市价单和限价单的概念, 接下来来看部分成交

这个订单簿中,我想以当前市场的价格去卖出150个YES的shares,但是当前Bids 0.12 概率的深度太低,当前盘口这个价格的全部吃掉,还有剩余,剩余得从0.06价格里面去进行市价购买撮合。

最终150的shares, 卖出得到的钱为 16.92, 和页面保持一致。

132 * 0.12 + ( 150-132 ) * 0.06 

= 16.92
08-02

2. CLOB 构建

这个撮合系统是在链下进行撮合的,速度快,不需要GasFee,只有撮合成功的结果才会被写入到链上进行记录。

想要构建一个这样的撮合引擎,我们肯定要先熟悉其中的执行原理。

2.1 订单匹配规则Price–Time Priority

订单匹配的规则,首先是价格,价格大于时间(price > Time)。

其实我们想象,整个引擎,类似于一个机器,这个机器本身是在运行中的,这个机器需要输入,需要输出。

这里的输入就是用户不断发进来的订单请求,现价单和市价单,以及撤单取消订单的请求,输出就是最终用户订单匹配成功,Bids订单收到对应的凭证shares,asks订单会把对应的shares,卖出得USDC。

这个进来的队列Queue,不管是在CEX,还是perp,都满足下面的规则

  • 价格更优的订单,永远更优先成交
  • 价格相同的前提下,满足先进先出(FIFO - first in first out)的策略)

这个规则,即能够保证流动性,用户更快交易,就必须拿出更好的报价,也可以保证公平性,防止插队,如果不按照对应的时间进行排序,后面的订单就可能通过其他的策略,被优先撮合,就会有潜在的价格成交的风险。

2.2 撮合状态机

有了上面的规则,其实也可以把整个引擎,抽象成一条区块链,区块链就像一个状态机,每一笔交易(订单),进来之后就会改变这个状态机的状态。

这个撮合系统本质上是死的,外部订单进来之后不断触发状态机的更新。

08-03

输入

输入就是用户传进来的一个个的订单请求,等下再看这些订单的具体类型,有市价,限价,取消,到期某个时间点执行的订单。

这些订单是按照一个单线程的队列进行往和引擎里面放的。

这里有一个容易混淆的关键点就是,在外面的订单,如果放一个价格更优的,是不是直接可以插队去进行执行。

当然结果是按照前面的 Price-Time Priority 是肯定会把价格更优的去先执行的,但是在外面的FIFO,这个队列是不会变的,引擎每秒能够处理几万笔订单的输入,但外面的订单顺序,还是按照用户先进先出的策略。

type Order struct {
	ID        string         
	UserID    string         
	Symbol    string         
	Side      enum.OrderSide 
	Type      enum.OrderType 
	Price     float64        
	Amount    float64        
	Timestamp int64          
}

撮合执行

真正使用价格时间优先级别的策略是在中间的这层引擎上。

这里涉及一个新订单进来之后遍历的问题, 按照正常思考,遍历肯定是需要把我们当前订单簿里面的订单全部扫一遍,然后看看哪个能被匹配执行,进行执行。

但是这样是错的,这样的时间优先级是O(n)

这里是整个引擎的核心,要实现很高的性能

  • 订单进来之后就不能在一个个遍历,所以我们需要Map,来对每一个tick 点的订单进行快速的索引。
  • 为了让订单匹配插入(挂单)删除(吃单),都能获得一个最优的时间复杂度,那么每个tick 点订单列表,实现了一个Olog(n ) 红黑树的数据结构

下面是我写的 一个简单的撮合引擎结构,里面包括订单簿,交易以及后面的event。

当然真正把这套系统做得最强的,还是 Citadel,Jump Trading,Jane Street, Nasdaq,Binance ,Coinbase 这些,延迟级别纳秒到微秒的区间,吞吐量在数百万的空间,性能做到极致。

type OrderQueue struct {
	Price  float64
	Orders *list.List // List of *Order
}
type OrderBook struct {
	Symbol string
	Bids   []*OrderQueue // Sorted descending (highest price first)
	Asks   []*OrderQueue // Sorted ascending (lowest price first)
	mu     sync.RWMutex
}

type Engine struct {
	OrderBook        *OrderBook
	OrderChan        chan *OrderRequest
	StopChan         chan struct{}
	Trades           []*Trade
	IsRunning        bool
	DB               *pgxpool.Pool
	EventChan        chan *EngineEvent
	CandleAggregator *CandleAggregator
	mu               sync.RWMutex
}

Event 订单输出

订单成交,订单簿更新之后,代表最新一次撮合之后,在价格上会有更新,同样成交的细节和执行的结果也需要记录。

在一笔订单成交之后,都会触发我们我们的一笔matchOrders()对应的链上交易。

交易撮合的成功的结果会上链,记录用户的takerOrder以及,被吃掉的订单makerOrder[],具体关于合约的详情,我们在项目章节也会展开阐述。

  function matchOrders(
        Order memory takerOrder,
        Order[] memory makerOrders,
        uint256 takerFillAmount,
        uint256[] memory makerFillAmounts
    ) external nonReentrant onlyOperator notPaused {
        _matchOrders(takerOrder, makerOrders, takerFillAmount, makerFillAmounts);
    }

下面这里是一笔交易的详情查看

08-06

2.3 订单类型

为了满足不同用户不同的挂单需求,对应的订单类型,也不只是市价单和限价单,还有止盈(收益到达某个成交点卖出止盈),止损,收入到达某个成交点进行本金的 止损。

  • GTC ( Good-Till-Canceled)

    订单是一直有效的,只要用户手动取消订单,我们平时挂的都是这些订单,已经展示

  • GTD ( Good-Til-Date )

    时间有效性订单,如果在达到某个时间点的时候,没有被成交撮合,订单则会自动失效。

  • IOC(Immediate-Or-Cancel) /FAk( Fill-And-Kill)

    订单挂出去之后,能成交多少就成交多少,没有成交的部分,立即取消掉

  • FOK(Fill-Or-Kill )

    满足条件的话,订单全部成交,否则就全部取消

  • Post-Only

    类似于交易的预执行,在真实吃流动性之前,向引擎中抛出这样的订单,模拟订单订单执行的结果,如果不会被匹配,则会直接挂到订单簿

  • Market Order

    市价单,要求与市场当前的最优的价格,尽快成交这些订单。

下面是一些Polymarket 订单的类型。

export declare enum Side {
    BUY = "BUY",
    SELL = "SELL"
}
export declare enum OrderType {
    GTC = "GTC",
    FOK = "FOK",
    GTD = "GTD",
    FAK = "FAK"
}

2.4 事件Event与 RTDS

到目前为止,我们对,整个订单处理前和撮合后的结果,已经展示了,接下来是我们如何访问这里的数据。

当然,为了最高效的访问,查看里面状态中的,订单详情,肯定是优先使用Websocket 请求对接口。

Polymarket 提供了一个官方的websocket URL wss://ws-live-data.polymarket.com

要使用这个接口,是非常简单的,连上这个API,然后根据我们想要的一些数据去建立subscribe订阅的信息

RTDS( Real Time Data Stream ) 本质上就是一个基于Websocket 的事件流系统。

当用户的订单进入订单簿,订单被成交,订单确认,盘口更新,都会对外部吐出这些Event, 这些event,也会像生产者一样不断的放入,pub 的接口中。

我们订阅的这些RTDS,就告诉了这些真实数据的改变,比如价格更新,订单更新。

08-07

2.5 数据库持久化

其实我们在前面讲到,在整个撮合引擎执行的时候,是不和数据库打交道的,因为这个打交道的IO,(Input/output). 放进去再取出来,太慢了。

那数据库在这里的意义是什么 ---- 备份和存储。

其实我们一直担心的一个问题就是,如果一旦有问题,这个引擎挂掉,我们一定要想办法去,再次启动的时候,把当前Orderbook的状态恢复回来,

这个时候,我们用的是, WAL(write ahead log), 每一笔用户发送的订单请求,在进入引擎中,必须先落盘,如果真的有问题,我们还可以通过重放的方式,把这些数据都在恢复回来。

3. 结尾

从订单簿这些买单和买单,到这个订单簿的执行流程,引擎的操作,我们都有理解了。

这个这篇文章主要聊的还是,offchain撮合的这一部分,为了更高的性能,我们首先会选用链下+链上混合的方式来进行架构设计。

当然,这篇文章只说了最核心的设计,还有一些 用户签名EIP712,用户订单成交后,钱包状态更新,成交前的一些状态检查,这些对于整个系统安全性来说也是非常重要的。

下一篇主要就是关于这些合约的模块, 包括 单位代币进来之后如何进行分割,链上如何记录这些成交的结果(这篇文章就写了一个 matchOrder的方法),还有UMA Oracle 进来之后, 如何进行正确的给这些Event 进行结果的设置。