@nevuamarkets/poly-websockets
Version:
Plug-and-play Polymarket WebSocket price alerts
208 lines (177 loc) • 6.54 kB
text/typescript
import _ from 'lodash';
import {
BookEvent,
PriceChangeEvent,
PriceLevel,
} from '../types/PolymarketWebSocket';
/*
* Shared book cache store – exported so legacy code paths can keep using it
* until the refactor is complete.
*/
export interface BookEntry {
bids: PriceLevel[];
asks: PriceLevel[];
price: string | null;
midpoint: string | null;
spread: string | null;
}
export
function sortDescendingInPlace(bookSide: PriceLevel[]): void {
bookSide.sort((a, b) => parseFloat(b.price) - parseFloat(a.price));
}
function sortAscendingInPlace(bookSide: PriceLevel[]): void {
bookSide.sort((a, b) => parseFloat(a.price) - parseFloat(b.price));
}
export class OrderBookCache {
private bookCache: {
[assetId: string]: BookEntry
} = {};
constructor() {}
/**
* Replace full book (after a `book` event)
*/
public replaceBook(event: BookEvent): void {
let lastPrice = null;
let lastMidpoint = null;
let lastSpread = null;
if (this.bookCache[event.asset_id]) {
lastPrice = this.bookCache[event.asset_id].price;
lastMidpoint = this.bookCache[event.asset_id].midpoint;
lastSpread = this.bookCache[event.asset_id].spread;
}
this.bookCache[event.asset_id] = {
bids: [...event.bids],
asks: [...event.asks],
price: lastPrice,
midpoint: lastMidpoint,
spread: lastSpread,
};
/* Polymarket book events are currently sorted as such:
* - bids (buys) ascending
* - asks (sells) descending
*
* So we maintain this order in the cache.
*/
sortAscendingInPlace(this.bookCache[event.asset_id].bids);
sortDescendingInPlace(this.bookCache[event.asset_id].asks);
}
/**
* Update a cached book from a `price_change` event.
*
* Returns true if the book was updated.
* Throws if the book is not found.
*/
public upsertPriceChange(event: PriceChangeEvent): void {
const book = this.bookCache[event.asset_id];
if (!book) {
throw new Error(`Book not found for asset ${event.asset_id}`);
}
for (const change of event.changes) {
const { price, size, side } = change;
if (side === 'BUY') {
const i = book.bids.findIndex(bid => bid.price === price);
if (i !== -1) {
book.bids[i].size = size;
} else {
book.bids.push({ price, size });
// Ensure the bids are sorted ascending
sortAscendingInPlace(book.bids);
}
} else {
const i = book.asks.findIndex(ask => ask.price === price);
if (i !== -1) {
book.asks[i].size = size;
} else {
book.asks.push({ price, size });
// Ensure the asks are sorted descending
sortDescendingInPlace(book.asks);
}
}
}
}
/**
* Return `true` if best-bid/best-ask spread exceeds `cents`.
*
* Side effect: updates the book's spread
*
* Throws if either side of the book is empty.
*/
public spreadOver(assetId: string, cents = 0.1): boolean {
const book = this.bookCache[assetId];
if (!book) throw new Error(`Book for ${assetId} not cached`);
if (book.asks.length === 0) throw new Error(`No asks in book for ${assetId}`);
if (book.bids.length === 0) throw new Error(`No bids in book for ${assetId}`);
/*
* Polymarket book events are currently sorted as such:
* - bids ascending
* - asks descending
*/
const highestBid = book.bids[book.bids.length - 1].price;
const lowestAsk = book.asks[book.asks.length - 1].price;
const highestBidNum = parseFloat(highestBid);
const lowestAskNum = parseFloat(lowestAsk);
const spread = lowestAskNum - highestBidNum;
if (isNaN(spread)) {
throw new Error(`Spread is NaN: lowestAsk '${lowestAsk}' highestBid '${highestBid}'`);
}
/*
* Update spead, 3 precision decimal places, trim trailing zeros
*/
book.spread = parseFloat(spread.toFixed(3)).toString();
// Should be safe for 0.### - precision values
return spread > cents;
}
/**
* Calculate the midpoint of the book, rounded to 3dp, no trailing zeros
*
* Side effect: updates the book's midpoint
*
* Throws if
* - the book is not found or missing either bid or ask
* - the midpoint is NaN.
*/
public midpoint(assetId: string): string {
const book = this.bookCache[assetId];
if (!book) throw new Error(`Book for ${assetId} not cached`);
if (book.asks.length === 0) throw new Error(`No asks in book for ${assetId}`);
if (book.bids.length === 0) throw new Error(`No bids in book for ${assetId}`);
/*
* Polymarket book events are currently sorted as such:
* - bids ascending
* - asks descending
*/
const highestBid = book.bids[book.bids.length - 1].price;
const lowestAsk = book.asks[book.asks.length - 1].price;
const highestBidNum = parseFloat(highestBid);
const lowestAskNum = parseFloat(lowestAsk);
const midpoint = (highestBidNum + lowestAskNum) / 2;
if (isNaN(midpoint)) {
throw new Error(`Midpoint is NaN: lowestAsk '${lowestAsk}' highestBid '${highestBid}'`);
}
/*
* Update midpoint, 3 precision decimal places, trim trailing zeros
*/
book.midpoint = parseFloat(midpoint.toFixed(3)).toString();
return parseFloat(midpoint.toFixed(3)).toString();
}
public clear(assetId?: string): void {
if (assetId) {
delete this.bookCache[assetId];
} else {
for (const k of Object.keys(this.bookCache)) {
delete this.bookCache[k];
}
}
}
/**
* Get a book entry by asset id.
*
* Return null if the book is not found.
*/
public getBookEntry(assetId: string): BookEntry | null {
if (!this.bookCache[assetId]) {
return null;
}
return this.bookCache[assetId];
}
}