@0xmonaco/core 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Local depth book and BBO state for the market-data `depth` / `bbo`
3
+ * WebSocket channels (0XM-3551).
4
+ *
5
+ * Handoff rules (the server enforces them; the client checks defensively):
6
+ * - A Snapshot replaces the local state, including mid-stream.
7
+ * - A frame with `book_seq <=` the current sequence is stale or a duplicate
8
+ * and is dropped.
9
+ * - Every kept frame must have `prev_book_seq <=` the current sequence (the
10
+ * snapshot's for the first frame, else the last applied frame's): a delta
11
+ * restates every level changed since its link, so it may link below the
12
+ * previous frame. A frame that links above it means a lost frame: rebuild
13
+ * from a new snapshot.
14
+ * - A BBO frame carries the whole top of book, so any frame newer than the
15
+ * state applies without a link check.
16
+ * - Frames that arrive before the snapshot are buffered and replayed through
17
+ * the same rules once it lands.
18
+ * - On a side whose snapshot was not complete, levels beyond its deepest
19
+ * returned level are unknown, so the book keeps only levels at or better
20
+ * than that boundary.
21
+ *
22
+ * Sequences are u64 durable-log positions, held as decimal strings and
23
+ * compared as `BigInt`.
24
+ */
25
+ import type { Bbo, BboEvent, BboSnapshot, DepthBook, DepthDelta, DepthSnapshot } from "@0xmonaco/types";
26
+ /**
27
+ * Quote the market-data sequence numbers in a raw frame before `JSON.parse`,
28
+ * so a u64 above `Number.MAX_SAFE_INTEGER` survives exactly. Only the
29
+ * unescaped `"book_seq":` / `"prev_book_seq":` keys followed by an integer are
30
+ * touched; inside a JSON string those quotes would be escaped.
31
+ */
32
+ export declare function quoteSequenceNumbers(raw: string): string;
33
+ /**
34
+ * {@link readSequence} that leaves an absent or malformed value `undefined`
35
+ * instead of throwing. A JSON number counts only while it is still exact (a
36
+ * quoted integer always does), so a rounded u64 never passes for a precise one.
37
+ */
38
+ export declare function tryReadSequence(data: Record<string, unknown>, key: string): string | undefined;
39
+ export declare function parseDepthSnapshot(raw: unknown): DepthSnapshot;
40
+ export declare function parseDepthDelta(raw: unknown): DepthDelta;
41
+ export declare function parseBboSnapshot(raw: unknown): BboSnapshot;
42
+ export declare function parseBboEvent(raw: unknown): BboEvent;
43
+ /** Compare two non-negative decimal strings numerically. */
44
+ export declare function compareDecimal(a: string, b: string): number;
45
+ /** The result of offering a frame to the local state. */
46
+ export type FrameOutcome =
47
+ /** Applied; the state moved forward. */
48
+ "applied"
49
+ /** Held until the snapshot arrives. */
50
+ | "buffered"
51
+ /** Stale or a duplicate. */
52
+ | "dropped"
53
+ /** Did not link: a frame was lost. */
54
+ | "gap"
55
+ /** Applied, but a side whose snapshot was incomplete is now empty: its remainder is unknown. */
56
+ | "truncated";
57
+ /** The result of applying a snapshot (with any buffered frames replayed). */
58
+ export type SnapshotOutcome = "applied" | "gap" | "truncated";
59
+ /**
60
+ * The sequence guard shared by `depth` and `bbo`: snapshot replaces, stale
61
+ * frames drop, a frame linking above the current sequence is a gap, frames
62
+ * before the snapshot buffer.
63
+ */
64
+ declare abstract class SequencedState<Snapshot extends {
65
+ bookSeq: string;
66
+ }, Frame extends {
67
+ bookSeq: string;
68
+ prevBookSeq: string;
69
+ }> {
70
+ private current;
71
+ private buffered;
72
+ /**
73
+ * Whether a frame must link to the current state. Depth deltas carry only
74
+ * changed levels, so a frame past a lost one is a gap; a BBO frame carries
75
+ * the whole top of book, so any newer one is complete on its own.
76
+ */
77
+ protected readonly checksLinks: boolean;
78
+ /** Whether a snapshot has been applied since the last reset. */
79
+ get ready(): boolean;
80
+ protected abstract resetFrom(snapshot: Snapshot): void;
81
+ protected abstract applyFrame(frame: Frame): void;
82
+ /** Whether the state can no longer be trusted past its known levels (see `"truncated"`). */
83
+ protected truncated(): boolean;
84
+ /** Replace the state with `snapshot`, then replay buffered frames. Returns `"gap"` if a buffered frame does not link. */
85
+ applySnapshot(snapshot: Snapshot): SnapshotOutcome;
86
+ /** Offer a live frame. */
87
+ offer(frame: Frame): FrameOutcome;
88
+ /** Discard everything; the next snapshot starts over. */
89
+ reset(): void;
90
+ }
91
+ /** The local depth book for one `depth` channel. */
92
+ export declare class LocalDepthBook extends SequencedState<DepthSnapshot, DepthDelta> {
93
+ private readonly bids;
94
+ private readonly asks;
95
+ private header;
96
+ protected resetFrom(snapshot: DepthSnapshot): void;
97
+ protected applyFrame(frame: DepthDelta): void;
98
+ protected truncated(): boolean;
99
+ reset(): void;
100
+ /** The current book, or `null` before a snapshot. */
101
+ view(): DepthBook | null;
102
+ }
103
+ /** The local top of book for one `bbo` channel. */
104
+ export declare class LocalBbo extends SequencedState<BboSnapshot, BboEvent> {
105
+ protected readonly checksLinks = false;
106
+ private state;
107
+ protected resetFrom(snapshot: BboSnapshot): void;
108
+ protected applyFrame(frame: BboEvent): void;
109
+ reset(): void;
110
+ view(): Bbo | null;
111
+ }
112
+ export {};
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Local depth book and BBO state for the market-data `depth` / `bbo`
3
+ * WebSocket channels (0XM-3551).
4
+ *
5
+ * Handoff rules (the server enforces them; the client checks defensively):
6
+ * - A Snapshot replaces the local state, including mid-stream.
7
+ * - A frame with `book_seq <=` the current sequence is stale or a duplicate
8
+ * and is dropped.
9
+ * - Every kept frame must have `prev_book_seq <=` the current sequence (the
10
+ * snapshot's for the first frame, else the last applied frame's): a delta
11
+ * restates every level changed since its link, so it may link below the
12
+ * previous frame. A frame that links above it means a lost frame: rebuild
13
+ * from a new snapshot.
14
+ * - A BBO frame carries the whole top of book, so any frame newer than the
15
+ * state applies without a link check.
16
+ * - Frames that arrive before the snapshot are buffered and replayed through
17
+ * the same rules once it lands.
18
+ * - On a side whose snapshot was not complete, levels beyond its deepest
19
+ * returned level are unknown, so the book keeps only levels at or better
20
+ * than that boundary.
21
+ *
22
+ * Sequences are u64 durable-log positions, held as decimal strings and
23
+ * compared as `BigInt`.
24
+ */
25
+ /** Frames buffered before a snapshot; past it the oldest are dropped (the link check catches the hole). */
26
+ const MAX_BUFFERED_FRAMES = 10_000;
27
+ const SEQUENCE_KEYS = ["book_seq", "prev_book_seq"];
28
+ // A whole integer only: a fractional or exponent value is left as it is, so
29
+ // `JSON.parse` still reads it and the lenient readers leave it unset.
30
+ const SEQUENCE_KEY_PATTERN = new RegExp(`"(${SEQUENCE_KEYS.join("|")})"\\s*:\\s*(\\d+)(?![\\d.eE])`, "g");
31
+ /**
32
+ * Quote the market-data sequence numbers in a raw frame before `JSON.parse`,
33
+ * so a u64 above `Number.MAX_SAFE_INTEGER` survives exactly. Only the
34
+ * unescaped `"book_seq":` / `"prev_book_seq":` keys followed by an integer are
35
+ * touched; inside a JSON string those quotes would be escaped.
36
+ */
37
+ export function quoteSequenceNumbers(raw) {
38
+ if (!raw.includes("book_seq"))
39
+ return raw;
40
+ return raw.replace(SEQUENCE_KEY_PATTERN, '"$1":"$2"');
41
+ }
42
+ function asRecord(value, what) {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
44
+ throw new Error(`Invalid ${what}: must be an object`);
45
+ }
46
+ return value;
47
+ }
48
+ /** A u64 sequence as a decimal string: a quoted integer, or a JSON number that is still exact. */
49
+ function readSequence(data, key) {
50
+ const sequence = tryReadSequence(data, key);
51
+ if (sequence === undefined)
52
+ throw new Error(`Invalid ${key}: must be a non-negative integer`);
53
+ return sequence;
54
+ }
55
+ /**
56
+ * {@link readSequence} that leaves an absent or malformed value `undefined`
57
+ * instead of throwing. A JSON number counts only while it is still exact (a
58
+ * quoted integer always does), so a rounded u64 never passes for a precise one.
59
+ */
60
+ export function tryReadSequence(data, key) {
61
+ const value = data[key];
62
+ if (typeof value === "string" && /^\d+$/.test(value))
63
+ return value;
64
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
65
+ return String(value);
66
+ return undefined;
67
+ }
68
+ function readString(data, key) {
69
+ const value = data[key];
70
+ if (typeof value !== "string")
71
+ throw new Error(`Invalid ${key}: must be a string`);
72
+ return value;
73
+ }
74
+ /*
75
+ * Value fields parse leniently (packages/core/AGENTS.md: an over-strict value
76
+ * check drops the whole frame, and here a dropped frame costs a rebuild):
77
+ * numbers and numeric strings are interchangeable, an absent flag takes its
78
+ * safe default. Identity and sequence fields stay strict.
79
+ */
80
+ /**
81
+ * A non-negative number as plain decimal digits: `String(1e-7)` is exponent
82
+ * notation, which the decimal compare cannot order, so the exponent is
83
+ * expanded into the digits (the number's shortest round-trip form).
84
+ */
85
+ function plainDecimal(value) {
86
+ const text = String(value);
87
+ const match = /^(\d+)(?:\.(\d+))?e([+-]\d+)$/i.exec(text);
88
+ if (!match)
89
+ return text;
90
+ const [, whole = "0", fraction = "", exponentText = "0"] = match;
91
+ const digits = whole + fraction;
92
+ const point = whole.length + Number(exponentText);
93
+ if (point <= 0)
94
+ return `0.${"0".repeat(-point)}${digits}`.replace(/\.?0+$/, "") || "0";
95
+ if (point >= digits.length)
96
+ return digits + "0".repeat(point - digits.length);
97
+ return `${digits.slice(0, point)}.${digits.slice(point)}`;
98
+ }
99
+ /** A decimal value as a string: a string as sent, a finite number as plain digits. */
100
+ function readDecimal(data, key) {
101
+ const value = data[key];
102
+ if (typeof value === "string")
103
+ return value;
104
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
105
+ return plainDecimal(value);
106
+ throw new Error(`Invalid ${key}: must be a decimal`);
107
+ }
108
+ function readNullableDecimal(data, key) {
109
+ const value = data[key];
110
+ if (value === null || value === undefined)
111
+ return null;
112
+ return readDecimal(data, key);
113
+ }
114
+ /** A boolean, or `fallback` when absent or not a boolean. */
115
+ function readFlag(data, key, fallback) {
116
+ const value = data[key];
117
+ return typeof value === "boolean" ? value : fallback;
118
+ }
119
+ /** A number, or a numeric string; `fallback` otherwise. */
120
+ function readLenientNumber(data, key, fallback) {
121
+ const value = data[key];
122
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
123
+ return Number.isFinite(parsed) ? parsed : fallback;
124
+ }
125
+ /** A level's order count: a number or numeric string, required (it decides removals). */
126
+ function readOrderCount(level) {
127
+ const parsed = readLenientNumber(level, "order_count", Number.NaN);
128
+ if (!Number.isFinite(parsed) || parsed < 0)
129
+ throw new Error("Invalid order_count: must be a non-negative number");
130
+ return Math.trunc(parsed);
131
+ }
132
+ function readLevels(data, key) {
133
+ const value = data[key];
134
+ if (value === undefined || value === null)
135
+ return [];
136
+ if (!Array.isArray(value))
137
+ throw new Error(`Invalid ${key}: must be an array`);
138
+ return value.map((raw) => {
139
+ const level = asRecord(raw, `${key} level`);
140
+ return {
141
+ price: readDecimal(level, "price"),
142
+ quantity: readDecimal(level, "quantity"),
143
+ orderCount: readOrderCount(level),
144
+ };
145
+ });
146
+ }
147
+ export function parseDepthSnapshot(raw) {
148
+ const data = asRecord(raw, "depth snapshot");
149
+ return {
150
+ tradingPairId: readString(data, "trading_pair_id"),
151
+ tradingMode: readString(data, "trading_mode"),
152
+ bookSeq: readSequence(data, "book_seq"),
153
+ bids: readLevels(data, "bids"),
154
+ asks: readLevels(data, "asks"),
155
+ // Absent means unknown: treat the side as incomplete (the safe reading).
156
+ bidsComplete: readFlag(data, "bids_complete", false),
157
+ asksComplete: readFlag(data, "asks_complete", false),
158
+ };
159
+ }
160
+ export function parseDepthDelta(raw) {
161
+ const data = asRecord(raw, "depth frame");
162
+ return {
163
+ tradingPairId: readString(data, "trading_pair_id"),
164
+ tradingMode: readString(data, "trading_mode"),
165
+ bookSeq: readSequence(data, "book_seq"),
166
+ prevBookSeq: readSequence(data, "prev_book_seq"),
167
+ timestampMs: readLenientNumber(data, "timestamp_ms", 0),
168
+ bids: readLevels(data, "bids"),
169
+ asks: readLevels(data, "asks"),
170
+ stale: readFlag(data, "stale", false),
171
+ };
172
+ }
173
+ export function parseBboSnapshot(raw) {
174
+ const data = asRecord(raw, "bbo snapshot");
175
+ return {
176
+ tradingPairId: readString(data, "trading_pair_id"),
177
+ tradingMode: readString(data, "trading_mode"),
178
+ bookSeq: readSequence(data, "book_seq"),
179
+ bestBid: readNullableDecimal(data, "best_bid"),
180
+ bidQty: readNullableDecimal(data, "bid_qty"),
181
+ bestAsk: readNullableDecimal(data, "best_ask"),
182
+ askQty: readNullableDecimal(data, "ask_qty"),
183
+ };
184
+ }
185
+ export function parseBboEvent(raw) {
186
+ const data = asRecord(raw, "bbo frame");
187
+ return {
188
+ ...parseBboSnapshot(data),
189
+ prevBookSeq: readSequence(data, "prev_book_seq"),
190
+ timestampMs: readLenientNumber(data, "timestamp_ms", 0),
191
+ stale: readFlag(data, "stale", false),
192
+ };
193
+ }
194
+ /** Canonical form of a decimal price, so "64250.50" and "64250.5" are one level. */
195
+ function priceKey(price) {
196
+ const trimmed = price.trim();
197
+ const [integer = "0", fraction = ""] = trimmed.split(".");
198
+ const int = integer.replace(/^0+(?=\d)/, "") || "0";
199
+ const frac = fraction.replace(/0+$/, "");
200
+ return frac ? `${int}.${frac}` : int;
201
+ }
202
+ /** Compare two canonical (`priceKey`) non-negative decimals numerically. */
203
+ function compareCanonical(a, b) {
204
+ const [ai = "0", af = ""] = a.split(".");
205
+ const [bi = "0", bf = ""] = b.split(".");
206
+ if (ai.length !== bi.length)
207
+ return ai.length - bi.length;
208
+ if (ai !== bi)
209
+ return ai < bi ? -1 : 1;
210
+ const width = Math.max(af.length, bf.length);
211
+ const ap = af.padEnd(width, "0");
212
+ const bp = bf.padEnd(width, "0");
213
+ if (ap === bp)
214
+ return 0;
215
+ return ap < bp ? -1 : 1;
216
+ }
217
+ /** Compare two non-negative decimal strings numerically. */
218
+ export function compareDecimal(a, b) {
219
+ return compareCanonical(priceKey(a), priceKey(b));
220
+ }
221
+ /** A zero quantity, or zero orders, removes the level (the server sends both together). */
222
+ function isRemoval(level) {
223
+ return level.orderCount === 0 || compareDecimal(level.quantity, "0") === 0;
224
+ }
225
+ /**
226
+ * An incomplete side is refreshed once its known levels fall below this
227
+ * fraction of what its snapshot held: deltas never bring back the levels
228
+ * beyond its boundary, so a thinned side would otherwise understate the book.
229
+ */
230
+ const THIN_SIDE_FRACTION = 0.5;
231
+ /** The deepest a side is kept, matching the server's book mirror and snapshots. */
232
+ const MAX_LEVELS_PER_SIDE = 1000;
233
+ /** One side of the book: levels keyed by canonical price, with an optional boundary. */
234
+ class BookSide {
235
+ bestFirst;
236
+ levels = new Map();
237
+ /** Canonical price of the deepest known level on an incomplete side; levels beyond it are unknown. */
238
+ boundary = null;
239
+ /** Whether the side's snapshot was incomplete. */
240
+ incomplete = false;
241
+ /** Known levels below which an incomplete side needs a fresh snapshot. */
242
+ thinBelow = 0;
243
+ /** Whether every level of the side is known. */
244
+ get complete() {
245
+ return !this.incomplete;
246
+ }
247
+ /** `bestFirst` orders canonical prices best first (negative when `a` is better). */
248
+ constructor(bestFirst) {
249
+ this.bestFirst = bestFirst;
250
+ }
251
+ reset(levels, complete) {
252
+ this.levels.clear();
253
+ for (const level of levels) {
254
+ if (!isRemoval(level))
255
+ this.levels.set(priceKey(level.price), level);
256
+ }
257
+ this.incomplete = !complete;
258
+ const known = [...this.levels.keys()].sort(this.bestFirst);
259
+ this.boundary = complete ? null : (known.at(-1) ?? null);
260
+ this.thinBelow = complete ? 0 : Math.max(1, Math.ceil(known.length * THIN_SIDE_FRACTION));
261
+ }
262
+ apply(levels) {
263
+ for (const level of levels) {
264
+ const key = priceKey(level.price);
265
+ // Beyond the boundary of an incomplete side nothing is known.
266
+ if (this.boundary !== null && this.bestFirst(key, this.boundary) > 0)
267
+ continue;
268
+ if (isRemoval(level)) {
269
+ this.levels.delete(key);
270
+ }
271
+ else {
272
+ this.levels.set(key, level);
273
+ }
274
+ }
275
+ // Like the server's mirror, keep at most MAX_LEVELS_PER_SIDE after each
276
+ // frame: the worst levels go, and the side becomes incomplete at the new
277
+ // deepest level.
278
+ if (this.levels.size > MAX_LEVELS_PER_SIDE) {
279
+ const kept = [...this.levels.keys()].sort(this.bestFirst);
280
+ for (const key of kept.slice(MAX_LEVELS_PER_SIDE))
281
+ this.levels.delete(key);
282
+ this.boundary = kept[MAX_LEVELS_PER_SIDE - 1] ?? null;
283
+ if (!this.incomplete) {
284
+ this.incomplete = true;
285
+ this.thinBelow = Math.ceil(MAX_LEVELS_PER_SIDE * THIN_SIDE_FRACTION);
286
+ }
287
+ }
288
+ }
289
+ /**
290
+ * An incomplete side that has thinned below its refresh threshold (or came
291
+ * back with no level at all): what lies beyond its boundary is unknown and
292
+ * may now be near the top, so it needs a fresh snapshot.
293
+ */
294
+ exhausted() {
295
+ return this.incomplete && this.levels.size < this.thinBelow;
296
+ }
297
+ /** The side, best first, sorted once on the canonical keys. */
298
+ view() {
299
+ return [...this.levels.entries()].sort(([a], [b]) => this.bestFirst(a, b)).map(([, level]) => level);
300
+ }
301
+ }
302
+ /**
303
+ * The sequence guard shared by `depth` and `bbo`: snapshot replaces, stale
304
+ * frames drop, a frame linking above the current sequence is a gap, frames
305
+ * before the snapshot buffer.
306
+ */
307
+ class SequencedState {
308
+ current = null;
309
+ buffered = [];
310
+ /**
311
+ * Whether a frame must link to the current state. Depth deltas carry only
312
+ * changed levels, so a frame past a lost one is a gap; a BBO frame carries
313
+ * the whole top of book, so any newer one is complete on its own.
314
+ */
315
+ checksLinks = true;
316
+ /** Whether a snapshot has been applied since the last reset. */
317
+ get ready() {
318
+ return this.current !== null;
319
+ }
320
+ /** Whether the state can no longer be trusted past its known levels (see `"truncated"`). */
321
+ truncated() {
322
+ return false;
323
+ }
324
+ /** Replace the state with `snapshot`, then replay buffered frames. Returns `"gap"` if a buffered frame does not link. */
325
+ applySnapshot(snapshot) {
326
+ this.resetFrom(snapshot);
327
+ this.current = BigInt(snapshot.bookSeq);
328
+ const pending = this.buffered;
329
+ this.buffered = [];
330
+ for (const frame of pending) {
331
+ const outcome = this.offer(frame);
332
+ if (outcome === "gap" || outcome === "truncated")
333
+ return outcome;
334
+ }
335
+ // An incomplete side that came back with no level is unusable as sent.
336
+ return this.truncated() ? "truncated" : "applied";
337
+ }
338
+ /** Offer a live frame. */
339
+ offer(frame) {
340
+ if (this.current === null) {
341
+ this.buffered.push(frame);
342
+ if (this.buffered.length > MAX_BUFFERED_FRAMES)
343
+ this.buffered.shift();
344
+ return "buffered";
345
+ }
346
+ const seq = BigInt(frame.bookSeq);
347
+ if (seq <= this.current)
348
+ return "dropped";
349
+ if (this.checksLinks && BigInt(frame.prevBookSeq) > this.current)
350
+ return "gap";
351
+ this.applyFrame(frame);
352
+ this.current = seq;
353
+ return this.truncated() ? "truncated" : "applied";
354
+ }
355
+ /** Discard everything; the next snapshot starts over. */
356
+ reset() {
357
+ this.current = null;
358
+ this.buffered = [];
359
+ }
360
+ }
361
+ /** The local depth book for one `depth` channel. */
362
+ export class LocalDepthBook extends SequencedState {
363
+ bids = new BookSide((a, b) => compareCanonical(b, a));
364
+ asks = new BookSide((a, b) => compareCanonical(a, b));
365
+ header = null;
366
+ resetFrom(snapshot) {
367
+ this.bids.reset(snapshot.bids, snapshot.bidsComplete);
368
+ this.asks.reset(snapshot.asks, snapshot.asksComplete);
369
+ this.header = {
370
+ tradingPairId: snapshot.tradingPairId,
371
+ tradingMode: snapshot.tradingMode,
372
+ bookSeq: snapshot.bookSeq,
373
+ bidsComplete: snapshot.bidsComplete,
374
+ asksComplete: snapshot.asksComplete,
375
+ stale: false,
376
+ };
377
+ }
378
+ applyFrame(frame) {
379
+ this.bids.apply(frame.bids);
380
+ this.asks.apply(frame.asks);
381
+ if (this.header) {
382
+ this.header = { ...this.header, bookSeq: frame.bookSeq, stale: frame.stale, timestampMs: frame.timestampMs };
383
+ }
384
+ }
385
+ truncated() {
386
+ return this.bids.exhausted() || this.asks.exhausted();
387
+ }
388
+ reset() {
389
+ super.reset();
390
+ this.header = null;
391
+ }
392
+ /** The current book, or `null` before a snapshot. */
393
+ view() {
394
+ if (!this.ready || !this.header)
395
+ return null;
396
+ return {
397
+ ...this.header,
398
+ bids: this.bids.view(),
399
+ asks: this.asks.view(),
400
+ bidsComplete: this.bids.complete,
401
+ asksComplete: this.asks.complete,
402
+ };
403
+ }
404
+ }
405
+ /** The local top of book for one `bbo` channel. */
406
+ export class LocalBbo extends SequencedState {
407
+ checksLinks = false;
408
+ state = null;
409
+ resetFrom(snapshot) {
410
+ this.state = { ...snapshot, stale: false };
411
+ }
412
+ applyFrame(frame) {
413
+ const { prevBookSeq: _prev, ...rest } = frame;
414
+ this.state = { ...rest };
415
+ }
416
+ reset() {
417
+ super.reset();
418
+ this.state = null;
419
+ }
420
+ view() {
421
+ return this.ready && this.state ? { ...this.state } : null;
422
+ }
423
+ }