@0xmonaco/react 1.0.50 → 1.0.53
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.
- package/dist/hooks/useInstruments/useInstruments.d.ts +12 -10
- package/dist/hooks/useInstruments/useInstruments.js +16 -11
- package/dist/hooks/useUserBalances/useUserBalances.d.ts +78 -1
- package/dist/hooks/useUserBalances/useUserBalances.js +399 -44
- package/dist/hooks/useUserOrders/useUserOrders.d.ts +133 -6
- package/dist/hooks/useUserOrders/useUserOrders.js +848 -42
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useMonacoSDK } from "../useMonaco";
|
|
3
3
|
/**
|
|
4
4
|
* Hook for subscribing to real-time user order events via WebSocket (authenticated)
|
|
@@ -9,6 +9,22 @@ import { useMonacoSDK } from "../useMonaco";
|
|
|
9
9
|
*
|
|
10
10
|
* @param maxOrders - Maximum number of orders to keep in state (default: 50)
|
|
11
11
|
*/
|
|
12
|
+
/** Debounce for the REST re-read after an undescribable event, in milliseconds. */
|
|
13
|
+
const RESYNC_DEBOUNCE_MS = 1000;
|
|
14
|
+
/** Avoid a reconnect burst issuing one point read per resting order at once. */
|
|
15
|
+
const ORDER_RESOLUTION_CONCURRENCY = 4;
|
|
16
|
+
/** Release a queue slot even when the underlying client request never settles. */
|
|
17
|
+
const ORDER_RESOLUTION_TIMEOUT_MS = 10_000;
|
|
18
|
+
function withTimeout(request, timeoutMs) {
|
|
19
|
+
let timeoutId;
|
|
20
|
+
const timeout = new Promise((_, reject) => {
|
|
21
|
+
timeoutId = setTimeout(() => reject(new Error("Snapshot order resolution timed out")), timeoutMs);
|
|
22
|
+
});
|
|
23
|
+
return Promise.race([request, timeout]).finally(() => {
|
|
24
|
+
if (timeoutId !== undefined)
|
|
25
|
+
clearTimeout(timeoutId);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
12
28
|
export function useUserOrders(maxOrders = 50) {
|
|
13
29
|
const { sdk } = useMonacoSDK();
|
|
14
30
|
const [orders, setOrders] = useState([]);
|
|
@@ -16,57 +32,285 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
16
32
|
const [error, setError] = useState(null);
|
|
17
33
|
const [subscribed, setSubscribed] = useState(false);
|
|
18
34
|
const clearError = useCallback(() => setError(null), []);
|
|
19
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Drop unresolved rows from a REST list read that spans a snapshot.
|
|
37
|
+
*
|
|
38
|
+
* Snapshot absence says a held resting row needs a targeted `getOrder`; it
|
|
39
|
+
* does not say what state the order reached. A list read that started before
|
|
40
|
+
* that snapshot cannot resolve the question, so its omitted resting rows are
|
|
41
|
+
* filtered while terminal rows and snapshot-present rows pass through.
|
|
42
|
+
*/
|
|
43
|
+
const withoutSupersededRows = useCallback((rows, readEpoch) => {
|
|
44
|
+
if (snapshotEpoch.current === readEpoch)
|
|
45
|
+
return rows;
|
|
46
|
+
const resting = snapshotRestingIds.current;
|
|
47
|
+
return rows.filter((row) => !isRestingStatus(row.status) || resting.has(row.id));
|
|
48
|
+
}, []);
|
|
49
|
+
/** Apply a new list, reducing against the ref rather than through an updater. */
|
|
50
|
+
const applyOrders = useCallback((next) => {
|
|
51
|
+
const applied = typeof next === "function" ? next(ordersRef.current) : next;
|
|
52
|
+
ordersRef.current = applied;
|
|
53
|
+
setOrders(applied);
|
|
54
|
+
}, []);
|
|
55
|
+
/**
|
|
56
|
+
* The latest applied orders, readable synchronously from a WebSocket callback
|
|
57
|
+
* — which closes over the `orders` of the render that installed it.
|
|
58
|
+
*
|
|
59
|
+
* `applyOrders` reduces against this and `setOrders` only mirrors it for
|
|
60
|
+
* rendering, so no decision and no side effect depends on when React chooses
|
|
61
|
+
* to invoke a functional updater. Same arrangement as `useUserBalances`.
|
|
62
|
+
*/
|
|
63
|
+
const ordersRef = useRef([]);
|
|
64
|
+
/**
|
|
65
|
+
* Bumped every time a snapshot is applied, with the resting ids that snapshot
|
|
66
|
+
* carried.
|
|
67
|
+
*
|
|
68
|
+
* A REST read that STARTED before a snapshot landed can still be in flight
|
|
69
|
+
* when it does, and its rows are older than the snapshot for the resting set.
|
|
70
|
+
* Applying them unfiltered can undo a targeted resolution: `mergeOrders`
|
|
71
|
+
* treats a row absent from the current list as REST-only and re-inserts it,
|
|
72
|
+
* even though the spanning read is older than the snapshot that prompted the
|
|
73
|
+
* single-order lookup.
|
|
74
|
+
*
|
|
75
|
+
* `readGeneration` does not cover this. It tracks the SDK/effect lifetime, and
|
|
76
|
+
* a reconnect resubscribes without tearing the effect down.
|
|
77
|
+
*/
|
|
78
|
+
const snapshotEpoch = useRef(0);
|
|
79
|
+
const snapshotRestingIds = useRef(new Set());
|
|
80
|
+
const resyncTimer = useRef(null);
|
|
81
|
+
/**
|
|
82
|
+
* Bumped by effect cleanup. Every REST read — the initial load and the resync
|
|
83
|
+
* alike — captures this when it starts and drops its result if it no longer
|
|
84
|
+
* matches, so a response still in flight when the SDK changed or the
|
|
85
|
+
* component unmounted cannot write rows belonging to the previous client.
|
|
86
|
+
*/
|
|
87
|
+
const readGeneration = useRef(0);
|
|
88
|
+
/** Read the newest page of orders. Shared by the initial load and the resync. */
|
|
89
|
+
const readOrders = useCallback(async () => {
|
|
90
|
+
if (!sdk?.trading)
|
|
91
|
+
return null;
|
|
92
|
+
// Cursor mode (pageToken "") reads the newest rows first, which is all
|
|
93
|
+
// this hook needs; legacy page-number mode is deprecated.
|
|
94
|
+
const response = await sdk.trading.getPaginatedOrders({
|
|
95
|
+
pageSize: maxOrders,
|
|
96
|
+
pageToken: "",
|
|
97
|
+
});
|
|
98
|
+
// Deduplicate the paginated orders by id, keeping the first occurrence.
|
|
99
|
+
const seenIds = new Set();
|
|
100
|
+
const dedupedOrders = [];
|
|
101
|
+
for (const order of response.orders) {
|
|
102
|
+
if (!seenIds.has(order.id)) {
|
|
103
|
+
seenIds.add(order.id);
|
|
104
|
+
dedupedOrders.push(order);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return dedupedOrders.slice(0, maxOrders);
|
|
108
|
+
}, [sdk?.trading, maxOrders]);
|
|
109
|
+
/**
|
|
110
|
+
* Read the list and apply it, either owning it outright or reconciling.
|
|
111
|
+
*
|
|
112
|
+
* `replace` is only correct BEFORE the subscription opens: the initial load
|
|
113
|
+
* runs first by design, so nothing newer can exist to lose. Every other
|
|
114
|
+
* caller runs with the socket live and must `reconcile`, or an event arriving
|
|
115
|
+
* while the request is in flight is overwritten by an older snapshot.
|
|
116
|
+
*/
|
|
117
|
+
const load = useCallback(async (mode) => {
|
|
20
118
|
if (!sdk?.trading)
|
|
21
119
|
return;
|
|
120
|
+
const generation = readGeneration.current;
|
|
121
|
+
const readEpoch = snapshotEpoch.current;
|
|
122
|
+
// A read belonging to a previous client must not write ANY of this state:
|
|
123
|
+
// not the rows, not the error, and not the loading flag. Clearing
|
|
124
|
+
// `loading` from a stale request would mark the hook loaded while the new
|
|
125
|
+
// client's read is still running — showing an empty list as though it
|
|
126
|
+
// were the answer — and a stale rejection would leave `error` set after
|
|
127
|
+
// the new load had already succeeded.
|
|
128
|
+
const current = () => generation === readGeneration.current;
|
|
22
129
|
setLoading(true);
|
|
23
130
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
pageToken: "",
|
|
29
|
-
});
|
|
30
|
-
// Deduplicate the paginated orders by id, keeping the first occurrence.
|
|
31
|
-
const seenIds = new Set();
|
|
32
|
-
const dedupedOrders = [];
|
|
33
|
-
for (const order of response.orders) {
|
|
34
|
-
if (!seenIds.has(order.id)) {
|
|
35
|
-
seenIds.add(order.id);
|
|
36
|
-
dedupedOrders.push(order);
|
|
37
|
-
}
|
|
131
|
+
const rows = await readOrders();
|
|
132
|
+
if (rows && current()) {
|
|
133
|
+
const fresh = withoutSupersededRows(rows, readEpoch);
|
|
134
|
+
applyOrders((prev) => (mode === "replace" ? fresh : mergeOrders(prev, fresh, maxOrders)));
|
|
38
135
|
}
|
|
39
|
-
setOrders(dedupedOrders.slice(0, maxOrders));
|
|
40
136
|
}
|
|
41
137
|
catch (err) {
|
|
42
|
-
|
|
138
|
+
if (current())
|
|
139
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
43
140
|
}
|
|
44
141
|
finally {
|
|
45
|
-
|
|
142
|
+
if (current())
|
|
143
|
+
setLoading(false);
|
|
46
144
|
}
|
|
47
|
-
}, [sdk?.trading, maxOrders]);
|
|
48
|
-
|
|
145
|
+
}, [sdk?.trading, readOrders, maxOrders, applyOrders, withoutSupersededRows]);
|
|
146
|
+
const fetchOrders = useCallback(() => load("replace"), [load]);
|
|
147
|
+
/**
|
|
148
|
+
* Manual refresh. Reconciles rather than replaces: unlike the initial load
|
|
149
|
+
* this is public and can be called at any time, including with the
|
|
150
|
+
* subscription open, so a fill arriving mid-request must not be rolled back
|
|
151
|
+
* by the older snapshot the request returns.
|
|
152
|
+
*/
|
|
49
153
|
const refresh = useCallback(async () => {
|
|
50
|
-
await
|
|
51
|
-
}, [
|
|
154
|
+
await load("reconcile");
|
|
155
|
+
}, [load]);
|
|
156
|
+
/**
|
|
157
|
+
* Re-read the list from REST after an event this SDK version cannot describe.
|
|
158
|
+
*
|
|
159
|
+
* `orderFromEvent` declines an `OrderPlaced` whose `orderType`, `side` or
|
|
160
|
+
* `tradingMode` is a value this version does not know, rather than
|
|
161
|
+
* relabelling it. Without this the order would then be missing from the list
|
|
162
|
+
* indefinitely — it exists on the server, and no later event adds it, because
|
|
163
|
+
* every subsequent event for it takes the "update an existing order" path and
|
|
164
|
+
* finds nothing to update.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately NOT `fetchOrders`. This runs while the subscription is live,
|
|
167
|
+
* so unlike the initial load it cannot own the list: it must not raise
|
|
168
|
+
* `loading` (consumers read that as the initial load and would flash a
|
|
169
|
+
* spinner), and it must not replace the list wholesale (an event landing
|
|
170
|
+
* while the request is in flight would be overwritten by an older snapshot —
|
|
171
|
+
* the very race the initial load avoids by subscribing only after it
|
|
172
|
+
* completes). It merges instead, ranking on `version` when either side has
|
|
173
|
+
* one and falling back to `updatedAt` only when neither does.
|
|
174
|
+
*
|
|
175
|
+
* Debounced and coalesced: a deploy that adds an order type produces a burst
|
|
176
|
+
* of declines, and one REST read repairs the whole burst. A failure is
|
|
177
|
+
* swallowed rather than surfaced through `error`, which describes the load.
|
|
178
|
+
*/
|
|
179
|
+
const scheduleResync = useCallback(() => {
|
|
180
|
+
if (resyncTimer.current)
|
|
181
|
+
return;
|
|
182
|
+
const generation = readGeneration.current;
|
|
183
|
+
resyncTimer.current = setTimeout(() => {
|
|
184
|
+
resyncTimer.current = null;
|
|
185
|
+
// Captured where the READ starts, not where it was scheduled: a snapshot
|
|
186
|
+
// landing during the debounce is already reflected by the time this runs.
|
|
187
|
+
const readEpoch = snapshotEpoch.current;
|
|
188
|
+
void readOrders()
|
|
189
|
+
.then((rows) => {
|
|
190
|
+
// Clearing the timer cannot cancel a read that already started, so
|
|
191
|
+
// the generation is what invalidates it.
|
|
192
|
+
if (rows && generation === readGeneration.current) {
|
|
193
|
+
applyOrders((prev) => mergeOrders(prev, withoutSupersededRows(rows, readEpoch), maxOrders));
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
.catch(() => {
|
|
197
|
+
// A repair that fails leaves the list exactly as it was.
|
|
198
|
+
});
|
|
199
|
+
}, RESYNC_DEBOUNCE_MS);
|
|
200
|
+
}, [readOrders, maxOrders, applyOrders, withoutSupersededRows]);
|
|
52
201
|
useEffect(() => {
|
|
53
202
|
if (!sdk?.ws || !sdk?.trading) {
|
|
54
203
|
setSubscribed(false);
|
|
204
|
+
// The previous effect's cleanup has already bumped `readGeneration`, so
|
|
205
|
+
// any read still in flight will now decline to write — including its
|
|
206
|
+
// `setLoading(false)`. With no client left to start a replacement read,
|
|
207
|
+
// nothing would ever clear the flag, and the hook would report loading
|
|
208
|
+
// forever. Clear it here instead.
|
|
209
|
+
setLoading(false);
|
|
55
210
|
return;
|
|
56
211
|
}
|
|
57
|
-
|
|
212
|
+
applyOrders([]);
|
|
58
213
|
setError(null);
|
|
59
214
|
setLoading(true);
|
|
60
215
|
const limit = Number.isFinite(maxOrders) ? maxOrders : 50;
|
|
61
216
|
// Fetch initial orders via REST API, then subscribe to WebSocket updates
|
|
62
217
|
let unsubscribe;
|
|
218
|
+
// Cleanup cannot cancel a fetch already in flight. Without this, a slow
|
|
219
|
+
// initial read could resolve after the SDK changed or the component
|
|
220
|
+
// unmounted and then install a subscription on the OLD client — after
|
|
221
|
+
// cleanup had already run with `unsubscribe` still unset, so that handler
|
|
222
|
+
// would never be removed.
|
|
223
|
+
let cancelled = false;
|
|
224
|
+
const generation = readGeneration.current;
|
|
225
|
+
// Generation-scoped queue for snapshot omissions. The set covers QUEUED
|
|
226
|
+
// and ACTIVE work, so another reconnect snapshot reuses the same request
|
|
227
|
+
// rather than launching a duplicate for that order.
|
|
228
|
+
const queuedOrActiveOrderReads = new Set();
|
|
229
|
+
const orderReadQueue = [];
|
|
230
|
+
let activeOrderReads = 0;
|
|
231
|
+
const enqueueOrderReads = (orderIds) => {
|
|
232
|
+
for (const orderId of orderIds) {
|
|
233
|
+
if (queuedOrActiveOrderReads.has(orderId))
|
|
234
|
+
continue;
|
|
235
|
+
const started = ordersRef.current.find((row) => row.id === orderId);
|
|
236
|
+
if (!started)
|
|
237
|
+
continue;
|
|
238
|
+
queuedOrActiveOrderReads.add(orderId);
|
|
239
|
+
orderReadQueue.push({ orderId, started });
|
|
240
|
+
}
|
|
241
|
+
drainOrderReads();
|
|
242
|
+
};
|
|
243
|
+
const runOrderRead = async (task) => {
|
|
244
|
+
let retry = false;
|
|
245
|
+
try {
|
|
246
|
+
const { order: resolved } = await withTimeout(sdk.trading.getOrder(task.orderId), ORDER_RESOLUTION_TIMEOUT_MS);
|
|
247
|
+
if (cancelled || generation !== readGeneration.current)
|
|
248
|
+
return;
|
|
249
|
+
const current = ordersRef.current;
|
|
250
|
+
const held = current.find((row) => row.id === task.orderId);
|
|
251
|
+
if (held && held.version === undefined && resolved.version === undefined) {
|
|
252
|
+
if (held === task.started) {
|
|
253
|
+
// Neither side can be ranked, and the two REST surfaces use
|
|
254
|
+
// different clocks. With no intervening local change, the point
|
|
255
|
+
// read is the answer this omission explicitly requested, so take
|
|
256
|
+
// it without consulting `updatedAt`.
|
|
257
|
+
const withoutHeld = current.filter((row) => row.id !== task.orderId);
|
|
258
|
+
applyOrders(mergeOrders(withoutHeld, [keepingPostOnly(resolved, held)], limit));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// An unversioned event or later snapshot changed this row while the
|
|
262
|
+
// request was in flight. Preserve that change; if the latest
|
|
263
|
+
// snapshot still omits a resting row, retry from the new baseline
|
|
264
|
+
// once this in-flight slot is released.
|
|
265
|
+
retry = isRestingStatus(held.status) && !snapshotRestingIds.current.has(task.orderId);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Versioned results are safe to merge even when state changed while
|
|
270
|
+
// the request was in flight: the shared comparator picks the newer
|
|
271
|
+
// state and gives a point-read final state the equal-version tie.
|
|
272
|
+
applyOrders(mergeOrders(current, [resolved], limit));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Absence or a failed point read is not proof of terminal state. Keep
|
|
277
|
+
// the local row; a later snapshot can enqueue another attempt.
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
activeOrderReads -= 1;
|
|
281
|
+
queuedOrActiveOrderReads.delete(task.orderId);
|
|
282
|
+
if (!cancelled && generation === readGeneration.current && retry) {
|
|
283
|
+
enqueueOrderReads([task.orderId]);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
drainOrderReads();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
function drainOrderReads() {
|
|
291
|
+
while (!cancelled && generation === readGeneration.current && activeOrderReads < ORDER_RESOLUTION_CONCURRENCY) {
|
|
292
|
+
const task = orderReadQueue.shift();
|
|
293
|
+
if (!task)
|
|
294
|
+
return;
|
|
295
|
+
activeOrderReads += 1;
|
|
296
|
+
void runOrderRead(task);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
63
299
|
fetchOrders()
|
|
64
300
|
.then(() => {
|
|
301
|
+
if (cancelled || generation !== readGeneration.current)
|
|
302
|
+
return;
|
|
65
303
|
// Subscribe to WebSocket order updates after initial data is loaded
|
|
66
304
|
// This prevents race conditions where WS events could be overwritten by REST response
|
|
67
305
|
try {
|
|
68
306
|
unsubscribe = sdk.ws.userOrders((event) => {
|
|
69
|
-
|
|
307
|
+
// Built OUTSIDE the state updater: an updater must stay pure, and
|
|
308
|
+
// React may invoke it more than once. `null` here means the event
|
|
309
|
+
// is an `OrderPlaced` this version cannot describe.
|
|
310
|
+
const placed = event.eventType === "OrderPlaced" ? orderFromEvent(event) : null;
|
|
311
|
+
if (event.eventType === "OrderPlaced" && !placed)
|
|
312
|
+
scheduleResync();
|
|
313
|
+
applyOrders((prev) => {
|
|
70
314
|
const orderId = event.orderId;
|
|
71
315
|
// Check if this order already exists
|
|
72
316
|
const existingIndex = prev.findIndex((o) => o.id === orderId);
|
|
@@ -80,14 +324,48 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
80
324
|
return newOrders;
|
|
81
325
|
}
|
|
82
326
|
// New order - add to the beginning if it's an OrderPlaced event
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
if (newOrder) {
|
|
86
|
-
return [newOrder, ...prev].slice(0, limit);
|
|
87
|
-
}
|
|
327
|
+
if (placed) {
|
|
328
|
+
return [placed, ...prev].slice(0, limit);
|
|
88
329
|
}
|
|
89
330
|
return prev;
|
|
90
331
|
});
|
|
332
|
+
},
|
|
333
|
+
// The subscribe-time snapshot repairs rows it carries without a
|
|
334
|
+
// REST round trip. It matters most after a reconnect: the
|
|
335
|
+
// resubscribe brings a fresh one, while a targeted read resolves
|
|
336
|
+
// each formerly-resting order it omits.
|
|
337
|
+
(items) => {
|
|
338
|
+
// Read BEFORE the updater: whether a row was declined depends only
|
|
339
|
+
// on the rows themselves, and an updater must stay free of side
|
|
340
|
+
// effects because React may invoke it more than once. A row this
|
|
341
|
+
// version cannot describe would otherwise be missing indefinitely
|
|
342
|
+
// — the same repair a declined OrderPlaced gets.
|
|
343
|
+
// Both decisions are made against the ref BEFORE anything is
|
|
344
|
+
// applied, so neither depends on when React invokes an updater.
|
|
345
|
+
const applied = applyOrderSnapshot(ordersRef.current, items, limit);
|
|
346
|
+
// Recorded BEFORE the rows are applied, so a REST read that
|
|
347
|
+
// resolves after this point can tell that it spans a snapshot and
|
|
348
|
+
// filter resting rows whose absence needs targeted resolution.
|
|
349
|
+
snapshotRestingIds.current = new Set(snapshotOrderRows(items).rows.map((row) => row.id));
|
|
350
|
+
snapshotEpoch.current += 1;
|
|
351
|
+
// A row this version cannot describe would otherwise be missing
|
|
352
|
+
// indefinitely — the same repair a declined OrderPlaced gets.
|
|
353
|
+
if (applied.hasUndescribableRow)
|
|
354
|
+
scheduleResync();
|
|
355
|
+
// A fill this connection never saw moves the fee aggregates the
|
|
356
|
+
// snapshot cannot carry, and REST is the only source for them.
|
|
357
|
+
if (applied.hasOfflineFill)
|
|
358
|
+
scheduleResync();
|
|
359
|
+
applyOrders(applied.orders);
|
|
360
|
+
// Absence from the persisted resting snapshot is ambiguous: the
|
|
361
|
+
// order may have gone terminal during the outage, or persistence
|
|
362
|
+
// may lag a newer row retained from the previous connection.
|
|
363
|
+
// Keep the row visible while a cache-first point read resolves
|
|
364
|
+
// it. `mergeOrders` ranks the answer against any event or newer
|
|
365
|
+
// snapshot that lands while these requests are in flight.
|
|
366
|
+
if (applied.missingRestingOrderIds.length > 0) {
|
|
367
|
+
enqueueOrderReads(applied.missingRestingOrderIds);
|
|
368
|
+
}
|
|
91
369
|
});
|
|
92
370
|
setSubscribed(true);
|
|
93
371
|
}
|
|
@@ -102,34 +380,492 @@ export function useUserOrders(maxOrders = 50) {
|
|
|
102
380
|
setLoading(false);
|
|
103
381
|
});
|
|
104
382
|
return () => {
|
|
383
|
+
cancelled = true;
|
|
105
384
|
unsubscribe?.();
|
|
385
|
+
if (resyncTimer.current) {
|
|
386
|
+
clearTimeout(resyncTimer.current);
|
|
387
|
+
resyncTimer.current = null;
|
|
388
|
+
}
|
|
389
|
+
orderReadQueue.length = 0;
|
|
390
|
+
queuedOrActiveOrderReads.clear();
|
|
391
|
+
// Invalidates any read already in flight; see `readGeneration`.
|
|
392
|
+
readGeneration.current += 1;
|
|
106
393
|
setSubscribed(false);
|
|
107
394
|
};
|
|
108
|
-
}, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders]);
|
|
395
|
+
}, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders, scheduleResync, applyOrders]);
|
|
109
396
|
return { orders, loading, subscribed, error, clearError, refresh };
|
|
110
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Runtime membership test for the closed `OrderStatus` union.
|
|
400
|
+
*
|
|
401
|
+
* `@0xmonaco/core` never validates the order-event `status` against a value
|
|
402
|
+
* list — it hands the wire string through, so an unfamiliar value cannot drop
|
|
403
|
+
* the whole frame (0XM-2627/0XM-2628). The REST `Order` model this hook builds
|
|
404
|
+
* has a closed `OrderStatus`, so an unrecognized value has to be recognized as
|
|
405
|
+
* such here rather than cast in, where it would corrupt the model for every
|
|
406
|
+
* consumer that branches on `order.status`.
|
|
407
|
+
*
|
|
408
|
+
* A `Record<OrderStatus, true>` rather than an array: adding a variant to
|
|
409
|
+
* `OrderStatus` is a compile error here until it is listed, so this cannot
|
|
410
|
+
* fall behind the union it mirrors.
|
|
411
|
+
*/
|
|
412
|
+
const ORDER_STATUSES = {
|
|
413
|
+
SUBMITTED: true,
|
|
414
|
+
PARTIALLY_FILLED: true,
|
|
415
|
+
FILLED: true,
|
|
416
|
+
SETTLED_ON_CHAIN: true,
|
|
417
|
+
SETTLED: true,
|
|
418
|
+
CANCELLED: true,
|
|
419
|
+
REJECTED: true,
|
|
420
|
+
EXPIRED: true,
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* The other closed unions this hook converts into the REST `Order`. Same
|
|
424
|
+
* contract as `ORDER_STATUSES`: every one of these arrives on the same
|
|
425
|
+
* unvalidated WebSocket payload, so an unrecognized value in any of them would
|
|
426
|
+
* corrupt the model just as an unknown status would. `Record<T, true>` again,
|
|
427
|
+
* so adding a variant to any union is a compile error here until it is listed.
|
|
428
|
+
*/
|
|
429
|
+
const ORDER_TYPES = { LIMIT: true, MARKET: true };
|
|
430
|
+
const ORDER_SIDES = { BUY: true, SELL: true };
|
|
431
|
+
const TRADING_MODES = { SPOT: true, MARGIN: true };
|
|
432
|
+
const TIMES_IN_FORCE = { GTC: true, IOC: true, FOK: true, GTD: true };
|
|
433
|
+
/**
|
|
434
|
+
* Narrow a widened WebSocket value back to its closed union, or `undefined`.
|
|
435
|
+
*
|
|
436
|
+
* `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a wire value of
|
|
437
|
+
* "toString" / "constructor" / "valueOf" would test true and be cast into the
|
|
438
|
+
* closed union — the corruption these helpers exist to prevent.
|
|
439
|
+
*/
|
|
440
|
+
function asKnown(known, value) {
|
|
441
|
+
return typeof value === "string" && Object.hasOwn(known, value) ? value : undefined;
|
|
442
|
+
}
|
|
443
|
+
function asOrderStatus(value) {
|
|
444
|
+
return asKnown(ORDER_STATUSES, value);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* `live`, with every REST-only field taken from the fetched row.
|
|
448
|
+
*
|
|
449
|
+
* "REST-only" means no WebSocket frame carries it — neither a snapshot row nor
|
|
450
|
+
* an order event — so the live row's copy is whatever an older read left
|
|
451
|
+
* behind, and a snapshot-derived row has none at all. Each is assigned even
|
|
452
|
+
* when the response omits it: REST is the authority on these, so "not set
|
|
453
|
+
* there" is an answer rather than a gap.
|
|
454
|
+
*
|
|
455
|
+
* `postOnly` is deliberately NOT among them. The `OrderPlaced` acknowledgement
|
|
456
|
+
* carries it and is the authority on it, and REST omitting the key is the
|
|
457
|
+
* ordinary shape of a non-post-only order — so taking it from the response
|
|
458
|
+
* would clear a flag learned from the socket.
|
|
459
|
+
*
|
|
460
|
+
* Written out rather than looped over a key list so it type-checks without a
|
|
461
|
+
* cast: indexing `Order` by a union of keys loses the per-field value type.
|
|
462
|
+
*/
|
|
463
|
+
function withRestOnlyFields(live, fetched) {
|
|
464
|
+
return {
|
|
465
|
+
...live,
|
|
466
|
+
expirationDate: fetched.expirationDate,
|
|
467
|
+
applicationTakerFee: fetched.applicationTakerFee,
|
|
468
|
+
monacoTakerFee: fetched.monacoTakerFee,
|
|
469
|
+
monacoMakerRebate: fetched.monacoMakerRebate,
|
|
470
|
+
totalTakerFees: fetched.totalTakerFees,
|
|
471
|
+
takerTotalPayment: fetched.takerTotalPayment,
|
|
472
|
+
makerTotalReceipt: fetched.makerTotalReceipt,
|
|
473
|
+
positionSide: fetched.positionSide,
|
|
474
|
+
terminalReason: fetched.terminalReason,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Whether an incoming row (a REST response or a snapshot) supersedes the one
|
|
479
|
+
* held locally.
|
|
480
|
+
*
|
|
481
|
+
* Ranked on `version` — the sequencer step every endpoint reports identically
|
|
482
|
+
* for the same order state (0XM-2440). `updatedAt` cannot do this job: order
|
|
483
|
+
* detail carries the matching-engine event clock and order lists the database
|
|
484
|
+
* commit clock, so a stale response can hold the LATER timestamp, and the
|
|
485
|
+
* column can move without the order changing at all.
|
|
486
|
+
*
|
|
487
|
+
* `>=`, not `>`. Equal means the same sequencer step, not identical state — one
|
|
488
|
+
* step emits an `OrderPlaced` and then its fills, and a batch shares one step
|
|
489
|
+
* across every item — and the incoming row is that step's FINAL state, so on a
|
|
490
|
+
* tie it is the authority over an intermediate event.
|
|
491
|
+
*
|
|
492
|
+
* Absent is unknown, never zero. When exactly one side has a version, that is
|
|
493
|
+
* the only rankable side: incoming-known wins and live-known stays. Falling
|
|
494
|
+
* back to timestamps in either asymmetric case would reintroduce the two-clock
|
|
495
|
+
* bug this counter exists to remove. Only when BOTH sides lack a version does
|
|
496
|
+
* the caller use its pre-0XM-2440 fallback.
|
|
497
|
+
*/
|
|
498
|
+
function supersedes(incoming, live) {
|
|
499
|
+
if (incoming.version === undefined)
|
|
500
|
+
return live.version === undefined ? "unranked" : false;
|
|
501
|
+
if (live.version === undefined)
|
|
502
|
+
return true;
|
|
503
|
+
return incoming.version >= live.version;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* `incoming`, keeping a `postOnly` the incoming row cannot report.
|
|
507
|
+
*
|
|
508
|
+
* REST omitting the key is the ordinary shape of a non-post-only order, and a
|
|
509
|
+
* snapshot row carries no such field at all, so neither can distinguish "not
|
|
510
|
+
* post-only" from "unknown". Only a `true` learned from the `OrderPlaced`
|
|
511
|
+
* acknowledgement is carried forward, and only over an absence.
|
|
512
|
+
*/
|
|
513
|
+
function keepingPostOnly(incoming, live) {
|
|
514
|
+
return live.postOnly === true && incoming.postOnly === undefined ? { ...incoming, postOnly: true } : incoming;
|
|
515
|
+
}
|
|
516
|
+
/** Milliseconds since epoch for an order's `updatedAt`, or `NaN` if unparseable. */
|
|
517
|
+
function updatedAtMs(order) {
|
|
518
|
+
return Date.parse(order.updatedAt ?? "");
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Sort key for the list's newest-first order. An unparseable or absent
|
|
522
|
+
* `createdAt` sorts oldest rather than jumping to the top.
|
|
523
|
+
*/
|
|
524
|
+
function createdAtMs(order) {
|
|
525
|
+
const parsed = Date.parse(order.createdAt ?? "");
|
|
526
|
+
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Map one `orders` snapshot row onto the REST `Order` model, or `null` when
|
|
530
|
+
* this SDK version cannot describe it.
|
|
531
|
+
*
|
|
532
|
+
* Same decline contract as {@link orderFromEvent}, for the same reason: the
|
|
533
|
+
* wire values are handed through unvalidated, and relabelling a future order
|
|
534
|
+
* type or side renders an order as something it is not. `status` joins the
|
|
535
|
+
* declining set here, unlike on an `OrderPlaced` — that event IS a submitted
|
|
536
|
+
* order whatever string it carries, whereas a snapshot row's status is the
|
|
537
|
+
* order's actual resting state and has no honest substitute.
|
|
538
|
+
*
|
|
539
|
+
* The row's optionals arrive as explicit `null` (Rust `Option`s serialized
|
|
540
|
+
* without `skip_serializing_if`), so they are normalized to absent: the REST
|
|
541
|
+
* model declares them `?:`, and leaking `null` past it would have consumers
|
|
542
|
+
* branching on a value the model says cannot occur.
|
|
543
|
+
*/
|
|
544
|
+
export function orderFromSnapshotItem(item) {
|
|
545
|
+
const orderType = asKnown(ORDER_TYPES, item.orderType);
|
|
546
|
+
const side = asKnown(ORDER_SIDES, item.side);
|
|
547
|
+
const tradingMode = asKnown(TRADING_MODES, item.tradingMode);
|
|
548
|
+
const status = asOrderStatus(item.status);
|
|
549
|
+
if (!orderType || !side || !tradingMode || !status)
|
|
550
|
+
return null;
|
|
551
|
+
return {
|
|
552
|
+
id: item.orderId,
|
|
553
|
+
tradingPairId: item.tradingPairId,
|
|
554
|
+
orderType,
|
|
555
|
+
side,
|
|
556
|
+
status,
|
|
557
|
+
price: item.price ?? undefined,
|
|
558
|
+
quantity: item.quantity,
|
|
559
|
+
filledQuantity: item.filledQuantity,
|
|
560
|
+
averageFillPrice: item.averageFillPrice ?? undefined,
|
|
561
|
+
tradingMode,
|
|
562
|
+
// Optional on the model, so an unrecognized value is simply absent — there
|
|
563
|
+
// is no honest default to invent, and no reason to lose the order over it.
|
|
564
|
+
timeInForce: asKnown(TIMES_IN_FORCE, item.timeInForce),
|
|
565
|
+
createdAt: item.createdAt,
|
|
566
|
+
updatedAt: item.updatedAt ?? undefined,
|
|
567
|
+
marginAccountId: item.marginAccountId ?? undefined,
|
|
568
|
+
positionId: item.positionId ?? undefined,
|
|
569
|
+
leverage: item.leverage ?? undefined,
|
|
570
|
+
reduceOnly: item.reduceOnly,
|
|
571
|
+
...(item.clientOrderId === undefined ? {} : { clientOrderId: item.clientOrderId }),
|
|
572
|
+
// Carried, not dropped: it is what the merge ranks this row by, and a row
|
|
573
|
+
// whose state came from the snapshot while its version stayed at the held
|
|
574
|
+
// value would be internally inconsistent — reporting a step it does not
|
|
575
|
+
// describe. Absent stays absent; absent means unknown, never zero.
|
|
576
|
+
...(item.version === undefined ? {} : { version: item.version }),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Apply a subscribe-time `orders` snapshot to the current list.
|
|
581
|
+
*
|
|
582
|
+
* The snapshot carries RESTING orders only, so — unlike the balances one — it
|
|
583
|
+
* is not the whole list this hook shows. Replacing wholesale would erase every
|
|
584
|
+
* filled or cancelled order the user just placed, so it reconciles through
|
|
585
|
+
* the same version-first ordering as {@link mergeOrders}, with its cap applied
|
|
586
|
+
* by age.
|
|
587
|
+
*
|
|
588
|
+
* It IS complete for the persisted resting set, so absence identifies rows that
|
|
589
|
+
* need resolution — but it is not itself a terminal-state tombstone. On a fast
|
|
590
|
+
* reconnect persistence may lag a newer resting event retained from the prior
|
|
591
|
+
* connection, while an order that really filled or was cancelled during the
|
|
592
|
+
* outage is absent for the same reason: it no longer rests. The pure merge
|
|
593
|
+
* therefore keeps omitted resting rows and reports their ids so the caller can
|
|
594
|
+
* issue the targeted `getOrder` required by the shared order contract. That
|
|
595
|
+
* point read is cache-first and returns the state/version that can be ranked.
|
|
596
|
+
* Terminal rows are kept exactly as before: the snapshot never carried them,
|
|
597
|
+
* so their absence says nothing.
|
|
598
|
+
*
|
|
599
|
+
* A snapshot row is ranked against the order already held on `version`. The
|
|
600
|
+
* snapshot wins when its version is equal or higher, because it is the final
|
|
601
|
+
* state for that sequencer step; a lower version loses. A versioned local row
|
|
602
|
+
* also wins over an unversioned snapshot: the latter cannot be ranked and may
|
|
603
|
+
* be persisted state lagging the previous connection's live stream. When both
|
|
604
|
+
* sides are unversioned, the pre-counter fallback keeps the snapshot's original
|
|
605
|
+
* precedence instead of comparing incompatible timestamps; that also permits a
|
|
606
|
+
* validly-null `updatedAt`.
|
|
607
|
+
*
|
|
608
|
+
* A snapshot row is LAYERED over the order it already knows rather than
|
|
609
|
+
* replacing it. `OrderSnapshotItem` is not a whole `Order`: it carries no
|
|
610
|
+
* `postOnly`, `expirationDate`, `positionSide`, `terminalReason` or fee
|
|
611
|
+
* aggregates. Substituting a winning snapshot row would otherwise erase every
|
|
612
|
+
* one of those — including `postOnly`, where absence is never evidence of
|
|
613
|
+
* `false`. Spreading the row over the existing order lets the snapshot own
|
|
614
|
+
* exactly the fields it describes and leaves the rest standing.
|
|
615
|
+
*
|
|
616
|
+
* The fee aggregates are the one part that can then go stale rather than
|
|
617
|
+
* missing: they accumulate with fills, and a fill this connection never saw
|
|
618
|
+
* moves them. So a snapshot row that reports MORE filled quantity than the row
|
|
619
|
+
* it supersedes schedules the debounced REST re-read, which is the only source
|
|
620
|
+
* for them. A snapshot that changes nothing about the fill state schedules
|
|
621
|
+
* nothing, so a quiet reconnect still costs no request.
|
|
622
|
+
*
|
|
623
|
+
* `hasUndescribableRow` reports rows this version declined, `hasOfflineFill`
|
|
624
|
+
* rows whose fills this connection never saw, and `missingRestingOrderIds`
|
|
625
|
+
* names snapshot omissions that need point reads. The first two let the caller
|
|
626
|
+
* schedule the debounced list re-read; the last must not use a list page because
|
|
627
|
+
* an old order can fall outside it.
|
|
628
|
+
*
|
|
629
|
+
* Pure, and called with the current list read from a ref rather than from
|
|
630
|
+
* inside a state updater — an updater must stay free of side effects, and its
|
|
631
|
+
* signals exist precisely to drive those reads.
|
|
632
|
+
*/
|
|
633
|
+
export function applyOrderSnapshot(current, items, limit) {
|
|
634
|
+
const { rows, hasUndescribableRow } = snapshotOrderRows(items);
|
|
635
|
+
const known = new Map(current.map((row) => [row.id, row]));
|
|
636
|
+
// Layered, not substituted: the row's own keys win, and every field it does
|
|
637
|
+
// not carry survives from the order already held.
|
|
638
|
+
//
|
|
639
|
+
// Ranked first, though. A reconnect does NOT recreate this hook's effect, so
|
|
640
|
+
// the list still holds state applied from the previous connection's live
|
|
641
|
+
// stream — while the snapshot is read from persisted state, which can lag
|
|
642
|
+
// that stream. An unconditional win would walk such a row backwards, which is
|
|
643
|
+
// exactly what the shared `version` counter exists to prevent.
|
|
644
|
+
const carried = rows.flatMap((row) => {
|
|
645
|
+
const existing = known.get(row.id);
|
|
646
|
+
if (!existing)
|
|
647
|
+
return [row];
|
|
648
|
+
const ranked = supersedes(row, existing);
|
|
649
|
+
// Unranked means neither side carries a version, which is the pre-0XM-2440
|
|
650
|
+
// world: the snapshot is still the authority on resting state there, so it
|
|
651
|
+
// keeps its original precedence.
|
|
652
|
+
if (ranked === false)
|
|
653
|
+
return [];
|
|
654
|
+
return [{ ...existing, ...row }];
|
|
655
|
+
});
|
|
656
|
+
// Core validates the frame and every order id before delivery. A row this
|
|
657
|
+
// hook cannot describe because it carries a future enum value is still known
|
|
658
|
+
// to be PRESENT, but it does not make the other absences ambiguous. Build the
|
|
659
|
+
// presence set from raw items so the declined row itself is not misclassified
|
|
660
|
+
// as missing, then resolve every distinct held resting row the frame omitted.
|
|
661
|
+
const snapshotIds = new Set(items.map((row) => row.orderId));
|
|
662
|
+
const missingRestingOrderIds = current.filter((row) => isRestingStatus(row.status) && !snapshotIds.has(row.id)).map((row) => row.id);
|
|
663
|
+
// Upsert, then apply the same recency cap `mergeOrders` uses, so a snapshot
|
|
664
|
+
// row and a live-only row compete for the cap on age rather than on origin.
|
|
665
|
+
const byId = new Map(current.map((row) => [row.id, row]));
|
|
666
|
+
for (const row of carried)
|
|
667
|
+
byId.set(row.id, row);
|
|
668
|
+
const orders = [...byId.values()].sort((a, b) => createdAtMs(b) - createdAtMs(a)).slice(0, limit);
|
|
669
|
+
return {
|
|
670
|
+
orders,
|
|
671
|
+
hasUndescribableRow,
|
|
672
|
+
hasOfflineFill: carried.some((row) => filledOffline(known.get(row.id), row)),
|
|
673
|
+
missingRestingOrderIds,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Whether a snapshot row reports fills the held order has not seen.
|
|
678
|
+
*
|
|
679
|
+
* The fee aggregates the snapshot cannot carry move with fills, so a changed
|
|
680
|
+
* fill total is the signal that they are now stale and need the REST read. A
|
|
681
|
+
* row whose fill state is unchanged leaves them accurate, and a new order the
|
|
682
|
+
* list did not hold has no aggregates to have gone stale.
|
|
683
|
+
*
|
|
684
|
+
* Called only for rows that won the version comparison. Compared as normalized
|
|
685
|
+
* decimal STRINGS, not as numbers. `filledQuantity` only ever grows for an
|
|
686
|
+
* accepted incoming row, so "differs" and "grew" are the same question, and
|
|
687
|
+
* answering it without `parseFloat` keeps it exact — a float comparison would
|
|
688
|
+
* be inexact past ~15 significant digits, and this repo does not do financial
|
|
689
|
+
* comparisons in floating point. Normalizing is what makes the string compare
|
|
690
|
+
* sound: the held row can come from REST and the incoming one from the wire,
|
|
691
|
+
* and the same value may be rendered `1` by one and `1.000` by the other.
|
|
692
|
+
*
|
|
693
|
+
* An unparseable quantity on either side is treated as no evidence rather than
|
|
694
|
+
* as a difference, so a malformed decimal cannot drive a read on every frame.
|
|
695
|
+
*/
|
|
696
|
+
function filledOffline(existing, incoming) {
|
|
697
|
+
if (!existing)
|
|
698
|
+
return false;
|
|
699
|
+
const before = normalizedDecimal(existing.filledQuantity);
|
|
700
|
+
const after = normalizedDecimal(incoming.filledQuantity);
|
|
701
|
+
return before !== null && after !== null && before !== after;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* A decimal string in a single canonical form, or `null` if it is not one.
|
|
705
|
+
*
|
|
706
|
+
* Strips a leading `+`, redundant leading zeros and trailing fractional zeros,
|
|
707
|
+
* so two renderings of one value compare equal.
|
|
708
|
+
*/
|
|
709
|
+
function normalizedDecimal(value) {
|
|
710
|
+
const match = value.trim().match(/^([+-]?)(\d+)(?:\.(\d*))?$/);
|
|
711
|
+
if (!match)
|
|
712
|
+
return null;
|
|
713
|
+
// Read by index with a fallback rather than destructured: under
|
|
714
|
+
// `noUncheckedIndexedAccess` a capture group is `string | undefined`, even
|
|
715
|
+
// one the pattern makes mandatory.
|
|
716
|
+
const sign = match[1] ?? "";
|
|
717
|
+
const integer = (match[2] ?? "").replace(/^0+(?=\d)/, "");
|
|
718
|
+
const decimals = (match[3] ?? "").replace(/0+$/, "");
|
|
719
|
+
const magnitude = decimals ? `${integer}.${decimals}` : integer;
|
|
720
|
+
// `-0` and `0` are the same quantity.
|
|
721
|
+
return magnitude === "0" ? "0" : `${sign === "-" ? "-" : ""}${magnitude}`;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The statuses the subscribe-time snapshot treats as resting.
|
|
725
|
+
*
|
|
726
|
+
* Mirrors the server's own `SNAPSHOT_RESTING_ORDER_STATUSES` — an order in one
|
|
727
|
+
* of these is still working on the book, so the snapshot carries it and its
|
|
728
|
+
* absence therefore means it stopped resting. Every other status is terminal
|
|
729
|
+
* or post-trade, never carried, so absence says nothing about it.
|
|
730
|
+
*
|
|
731
|
+
* The two sets must not drift: a status the server started treating as resting
|
|
732
|
+
* but this one does not would put its orders back in the stale-forever case
|
|
733
|
+
* this drop exists to fix, and one this set claims but the server does not
|
|
734
|
+
* would delete rows on an absence that means nothing. Exported so
|
|
735
|
+
* `useUserOrders.test.ts` can pin it against the Rust constant directly.
|
|
736
|
+
*
|
|
737
|
+
* A `Record<..., true>` over a subset of `OrderStatus`, read through
|
|
738
|
+
* `Object.hasOwn`, for the same reasons as {@link asKnown}: no prototype walk,
|
|
739
|
+
* and the union's own membership is checked by the compiler.
|
|
740
|
+
*/
|
|
741
|
+
export const SNAPSHOT_RESTING_STATUSES = { SUBMITTED: true, PARTIALLY_FILLED: true };
|
|
742
|
+
function isRestingStatus(status) {
|
|
743
|
+
return Object.hasOwn(SNAPSHOT_RESTING_STATUSES, status);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Map snapshot rows onto the REST model, reporting whether any was declined.
|
|
747
|
+
*
|
|
748
|
+
* Split out of {@link applyOrderSnapshot} because it needs no view of the
|
|
749
|
+
* current list, which makes the mapping — and the decline rule that governs it
|
|
750
|
+
* — testable on its own.
|
|
751
|
+
*/
|
|
752
|
+
export function snapshotOrderRows(items) {
|
|
753
|
+
const mapped = items.map(orderFromSnapshotItem);
|
|
754
|
+
return { rows: mapped.filter((row) => row !== null), hasUndescribableRow: mapped.some((row) => row === null) };
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Reconcile a REST read against live state, ranking on `version` first.
|
|
758
|
+
*
|
|
759
|
+
* The resync runs with the subscription open, so `fetched` may already be stale
|
|
760
|
+
* by the time it lands. Three cases:
|
|
761
|
+
*
|
|
762
|
+
* - In both: when either row has `version`, the versioned side wins; when both
|
|
763
|
+
* do, the incoming REST row wins on `>=` because it is that step's final
|
|
764
|
+
* state. Only when neither has a version does `updatedAt` arbitrate.
|
|
765
|
+
* - Live only: keep it. Either it was placed over the socket after the read
|
|
766
|
+
* started, or it is an older row the page no longer reaches — the two are
|
|
767
|
+
* indistinguishable here, which is why the cap is applied by age below
|
|
768
|
+
* rather than by which side a row came from.
|
|
769
|
+
* - REST only: take it. This is the order the resync exists to recover.
|
|
770
|
+
*
|
|
771
|
+
* The union is then sorted newest-first by `createdAt` and truncated to
|
|
772
|
+
* `limit`, so the row dropped at the cap is the genuinely oldest one. A missing
|
|
773
|
+
* or unparseable `createdAt` sorts last rather than to the top.
|
|
774
|
+
*/
|
|
775
|
+
export function mergeOrders(current, fetched, limit) {
|
|
776
|
+
const fetchedIds = new Set(fetched.map((order) => order.id));
|
|
777
|
+
const currentById = new Map(current.map((order) => [order.id, order]));
|
|
778
|
+
const reconciled = fetched.map((incoming) => {
|
|
779
|
+
const live = currentById.get(incoming.id);
|
|
780
|
+
if (!live)
|
|
781
|
+
return incoming;
|
|
782
|
+
// Version first: it is the only field that orders these two soundly.
|
|
783
|
+
const ranked = supersedes(incoming, live);
|
|
784
|
+
if (ranked !== "unranked")
|
|
785
|
+
return ranked ? keepingPostOnly(incoming, live) : live;
|
|
786
|
+
const liveAt = updatedAtMs(live);
|
|
787
|
+
const incomingAt = updatedAtMs(incoming);
|
|
788
|
+
// An unparseable timestamp on either side is not evidence of staleness, so
|
|
789
|
+
// prefer the live row rather than silently rolling it back.
|
|
790
|
+
if (Number.isNaN(liveAt) || Number.isNaN(incomingAt))
|
|
791
|
+
return live;
|
|
792
|
+
if (liveAt > incomingAt)
|
|
793
|
+
return live;
|
|
794
|
+
if (liveAt < incomingAt)
|
|
795
|
+
return incoming;
|
|
796
|
+
// A TIE keeps the live row's own state — it cannot be older than the
|
|
797
|
+
// response, and at second resolution two genuinely different states can
|
|
798
|
+
// share a timestamp — but takes the REST-ONLY fields from the response.
|
|
799
|
+
// No WebSocket frame carries those, so the live row's copies are whatever
|
|
800
|
+
// an older read left behind, and a snapshot-derived row has none at all.
|
|
801
|
+
// Without this the post-snapshot repair could never land: a snapshot and a
|
|
802
|
+
// REST read of one order report the SAME `updated_at`, both projecting
|
|
803
|
+
// `orders.updated_at`, so the read returned the fee aggregates and the tie
|
|
804
|
+
// then discarded them.
|
|
805
|
+
return withRestOnlyFields(live, incoming);
|
|
806
|
+
});
|
|
807
|
+
const liveOnly = current.filter((order) => !fetchedIds.has(order.id));
|
|
808
|
+
// Cap by RECENCY, not by side. Prepending every live-only row and slicing
|
|
809
|
+
// evicted the tail of the REST page, and a live-only row is not always the
|
|
810
|
+
// newer one: once the list is at `limit`, a row REST no longer returns is the
|
|
811
|
+
// OLDEST held, so the recovered order this resync exists to insert was
|
|
812
|
+
// precisely the row being dropped — at `limit` 1 it never landed at all.
|
|
813
|
+
// Sorting newest-first keeps a genuinely new socket row ahead of the page and
|
|
814
|
+
// lets a genuinely old one fall off, which is what the cap means.
|
|
815
|
+
return [...liveOnly, ...reconciled].sort((a, b) => createdAtMs(b) - createdAtMs(a)).slice(0, limit);
|
|
816
|
+
}
|
|
111
817
|
/**
|
|
112
818
|
* Create an Order object from an OrderPlaced event
|
|
113
819
|
*/
|
|
114
|
-
function orderFromEvent(event) {
|
|
820
|
+
export function orderFromEvent(event) {
|
|
115
821
|
if (event.eventType !== "OrderPlaced")
|
|
116
822
|
return null;
|
|
117
823
|
const { data } = event;
|
|
118
824
|
const tradingPairId = data.tradingPairId || data.tradingPair;
|
|
119
825
|
if (!tradingPairId)
|
|
120
826
|
return null;
|
|
827
|
+
// The closed REST unions this event must supply for the order to be
|
|
828
|
+
// describable at all. `@0xmonaco/core` passes an unrecognized value straight
|
|
829
|
+
// through during version skew, and there is no honest default for any of
|
|
830
|
+
// these: defaulting a future `STOP` to `LIMIT`, or an unknown side to `BUY`,
|
|
831
|
+
// renders an order as something it is not — the worst outcome in a trading
|
|
832
|
+
// UI, and worse than not rendering it. So the order is declined here the same
|
|
833
|
+
// way one with no trading pair is. Declining alone would leave the order
|
|
834
|
+
// missing indefinitely, so `useUserOrders` schedules a debounced REST re-read
|
|
835
|
+
// when this happens and picks the order up from there.
|
|
836
|
+
const orderType = asKnown(ORDER_TYPES, data.orderType);
|
|
837
|
+
const side = asKnown(ORDER_SIDES, data.side);
|
|
838
|
+
const tradingMode = asKnown(TRADING_MODES, data.tradingMode);
|
|
839
|
+
if (!orderType || !side || !tradingMode)
|
|
840
|
+
return null;
|
|
121
841
|
return {
|
|
122
842
|
id: event.orderId,
|
|
123
843
|
tradingPairId: tradingPairId,
|
|
124
|
-
orderType
|
|
125
|
-
side
|
|
844
|
+
orderType,
|
|
845
|
+
side,
|
|
126
846
|
price: data.price,
|
|
127
847
|
quantity: data.quantity || "0",
|
|
128
848
|
filledQuantity: "0",
|
|
129
849
|
averageFillPrice: undefined,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
850
|
+
// Not an invented value: an `OrderPlaced` event IS a submitted order by
|
|
851
|
+
// construction, whatever status string it carries.
|
|
852
|
+
status: asOrderStatus(data.status) ?? "SUBMITTED",
|
|
853
|
+
tradingMode,
|
|
854
|
+
// Optional on the model, so an unrecognized value is simply absent —
|
|
855
|
+
// there is no honest default to invent for it.
|
|
856
|
+
timeInForce: asKnown(TIMES_IN_FORCE, data.timeInForce),
|
|
857
|
+
// Read from the ACKNOWLEDGEMENT only, and then left alone:
|
|
858
|
+
// `updateOrderFromEvent` spreads the existing order and never rewrites this
|
|
859
|
+
// field, so the value learned here survives every later event. That is
|
|
860
|
+
// load-bearing rather than incidental — the maker fill does not carry
|
|
861
|
+
// `postOnly`, and a post-only order rests and therefore fills as a maker,
|
|
862
|
+
// so re-deriving the field per event would clear it on the one event a
|
|
863
|
+
// post-only order most often produces and disagree with REST. Only a real
|
|
864
|
+
// boolean is taken: anything else is not evidence the order was post-only.
|
|
865
|
+
postOnly: typeof data.postOnly === "boolean" ? data.postOnly : undefined,
|
|
866
|
+
// The local revision the merge ranks against. Without it every REST
|
|
867
|
+
// response outranks live state by default and the stream can be undone.
|
|
868
|
+
version: typeof data.version === "number" ? data.version : undefined,
|
|
133
869
|
createdAt: event.timestamp,
|
|
134
870
|
updatedAt: event.timestamp,
|
|
135
871
|
};
|
|
@@ -137,12 +873,16 @@ function orderFromEvent(event) {
|
|
|
137
873
|
/**
|
|
138
874
|
* Update an existing Order with data from a WebSocket event
|
|
139
875
|
*/
|
|
140
|
-
function updateOrderFromEvent(order, event) {
|
|
876
|
+
export function updateOrderFromEvent(order, event) {
|
|
141
877
|
const data = event.data;
|
|
142
|
-
// Prefer status from event data
|
|
878
|
+
// Prefer a RECOGNIZED status from the event data, else fall back to the
|
|
879
|
+
// event-type mapping. An unrecognized status falls through rather than being
|
|
880
|
+
// cast in: the event type still says what happened, which is a better answer
|
|
881
|
+
// for the UI than a string no consumer can branch on.
|
|
143
882
|
let newStatus = order.status;
|
|
144
|
-
|
|
145
|
-
|
|
883
|
+
const reportedStatus = "status" in data ? asOrderStatus(data.status) : undefined;
|
|
884
|
+
if (reportedStatus) {
|
|
885
|
+
newStatus = reportedStatus;
|
|
146
886
|
}
|
|
147
887
|
else {
|
|
148
888
|
switch (event.eventType) {
|
|
@@ -187,12 +927,78 @@ function updateOrderFromEvent(order, event) {
|
|
|
187
927
|
if ("quantity" in data && data.quantity) {
|
|
188
928
|
quantity = data.quantity;
|
|
189
929
|
}
|
|
190
|
-
|
|
930
|
+
const updated = {
|
|
191
931
|
...order,
|
|
192
932
|
status: newStatus,
|
|
193
933
|
quantity,
|
|
194
934
|
filledQuantity: filledQuantity,
|
|
195
935
|
averageFillPrice: avgFillPrice,
|
|
196
936
|
updatedAt: event.timestamp,
|
|
937
|
+
// Advanced only when the event reports one. Absent means unknown, never
|
|
938
|
+
// zero, so it must not erase a revision already known — spreading `order`
|
|
939
|
+
// above is what keeps that value when this event carries none.
|
|
940
|
+
...(typeof data.version === "number" ? { version: data.version } : {}),
|
|
197
941
|
};
|
|
942
|
+
return liveEventSupersedes(order, updated, typeof data.version === "number" ? data.version : undefined) ? updated : order;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Whether a live event can advance the row currently held.
|
|
946
|
+
*
|
|
947
|
+
* Snapshots and REST point reads can land before an older buffered event. A
|
|
948
|
+
* lower event version — or an absent one against known-version state — cannot
|
|
949
|
+
* overwrite that authoritative row. Equal versions need finer arbitration:
|
|
950
|
+
* one sequencer step can emit `OrderPlaced` and then one or more cumulative
|
|
951
|
+
* fill events, so a later same-step event is accepted only while the order is
|
|
952
|
+
* still resting and its cumulative fill/status does not move backwards.
|
|
953
|
+
*/
|
|
954
|
+
function liveEventSupersedes(current, incoming, incomingVersion) {
|
|
955
|
+
if (incomingVersion === undefined)
|
|
956
|
+
return current.version === undefined;
|
|
957
|
+
if (current.version === undefined)
|
|
958
|
+
return true;
|
|
959
|
+
if (incomingVersion !== current.version)
|
|
960
|
+
return incomingVersion > current.version;
|
|
961
|
+
// A REST/snapshot terminal row is the step's final state. No event from the
|
|
962
|
+
// same step can advance it, but an intermediate event could roll it back.
|
|
963
|
+
if (!isRestingStatus(current.status))
|
|
964
|
+
return false;
|
|
965
|
+
const fillOrder = compareDecimalValues(incoming.filledQuantity, current.filledQuantity);
|
|
966
|
+
if (fillOrder === null) {
|
|
967
|
+
// Different unreadable values provide no evidence of forward progress.
|
|
968
|
+
if (incoming.filledQuantity !== current.filledQuantity)
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
else if (fillOrder !== 0) {
|
|
972
|
+
return fillOrder > 0;
|
|
973
|
+
}
|
|
974
|
+
// With an unchanged cumulative fill, SUBMITTED is the only resting state
|
|
975
|
+
// behind PARTIALLY_FILLED. Terminal events remain valid forward progress.
|
|
976
|
+
return current.status !== "PARTIALLY_FILLED" || incoming.status !== "SUBMITTED";
|
|
977
|
+
}
|
|
978
|
+
/** Exact decimal ordering without floating-point loss. */
|
|
979
|
+
function compareDecimalValues(left, right) {
|
|
980
|
+
const normalizedLeft = normalizedDecimal(left);
|
|
981
|
+
const normalizedRight = normalizedDecimal(right);
|
|
982
|
+
if (normalizedLeft === null || normalizedRight === null)
|
|
983
|
+
return null;
|
|
984
|
+
if (normalizedLeft === normalizedRight)
|
|
985
|
+
return 0;
|
|
986
|
+
const leftNegative = normalizedLeft.startsWith("-");
|
|
987
|
+
const rightNegative = normalizedRight.startsWith("-");
|
|
988
|
+
if (leftNegative !== rightNegative)
|
|
989
|
+
return leftNegative ? -1 : 1;
|
|
990
|
+
const [leftInteger = "", leftFraction = ""] = (leftNegative ? normalizedLeft.slice(1) : normalizedLeft).split(".");
|
|
991
|
+
const [rightInteger = "", rightFraction = ""] = (rightNegative ? normalizedRight.slice(1) : normalizedRight).split(".");
|
|
992
|
+
let magnitudeOrder;
|
|
993
|
+
if (leftInteger.length !== rightInteger.length) {
|
|
994
|
+
magnitudeOrder = leftInteger.length < rightInteger.length ? -1 : 1;
|
|
995
|
+
}
|
|
996
|
+
else if (leftInteger !== rightInteger) {
|
|
997
|
+
magnitudeOrder = leftInteger < rightInteger ? -1 : 1;
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
const scale = Math.max(leftFraction.length, rightFraction.length);
|
|
1001
|
+
magnitudeOrder = leftFraction.padEnd(scale, "0") < rightFraction.padEnd(scale, "0") ? -1 : 1;
|
|
1002
|
+
}
|
|
1003
|
+
return leftNegative ? (magnitudeOrder === -1 ? 1 : -1) : magnitudeOrder;
|
|
198
1004
|
}
|