@icgio/icg-exchanges-wrapper 1.17.20 → 1.17.22

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,366 @@
1
+ # Performance cleanup — 2026-04-09
2
+
3
+ Session of performance and dead-code cleanup work spanning three repos:
4
+
5
+ - `icg-utils` — `combine_orderbooks` rewrite + new tests
6
+ - `icg-exchanges-wrapper` — `orders.js` hot-path optimization + dead-code removal
7
+ - `icg-market-maker` — small follow-up to drop `o.trace` references
8
+
9
+ Nothing in this document has been published or deployed. Versions, dependents,
10
+ and MM client servers still need updating per the deployment notes at the
11
+ bottom.
12
+
13
+ ## Why
14
+
15
+ The maker hot path (rate update → `process_rates` → `notify()` → maker
16
+ callback → `handle_maker_*`) was the bottleneck under analysis. Several
17
+ contributing costs were identified and addressed. Expected aggregate effect:
18
+ the per-rate-tick cost on the maker hot path should drop from the
19
+ **multi-millisecond range to sub-millisecond** for typical configurations
20
+ (5 exchanges × 3 pairs × ~100 orders).
21
+
22
+ ## Changes
23
+
24
+ ### icg-utils
25
+
26
+ #### Rewrote `combine_orderbooks` (2026-04-09)
27
+
28
+ `lib/utils.js`
29
+
30
+ The original implementation:
31
+ - Used `_.get`, `_.sum`, `_.values`, `_.keys` inside the inner loop
32
+ - Mutated input tuples in place by pushing the exchange name (`tuple[2]`)
33
+ onto each level — forcing callers to `cloneDeep` upstream
34
+ - O(R × K) with high lodash constant factors
35
+
36
+ The rewrite:
37
+ - Strips all lodash from the inner loop
38
+ - Uses parallel arrays (`lists`, `indexes`, `exchanges`) keyed by an integer
39
+ index, hoists the asks/bids direction comparator out of the loop
40
+ - **Non-mutating**: `orderbooks_dict` and its inner arrays/tuples are never
41
+ touched. Callers do NOT need to deep-clone before calling.
42
+ - **Pushes input tuple references directly into the result** (Flavor 2 — see
43
+ Reference Sharing Contract below). Zero allocations in the inner loop.
44
+ - Drops the `tuple[2]` augmentation entirely. The previous algorithm needed
45
+ the exchange name on each tuple to know which pointer to advance; the
46
+ rewrite tracks this via `bestIdx` (an integer index into the parallel
47
+ arrays).
48
+
49
+ Expected speedup vs the original: ~10× per call. Combined with the upstream
50
+ `cloneDeep` removal, `process_rates` per call drops from ~3-5 ms to ~0.2-0.4
51
+ ms for a typical 5-exchange / 3-pair MM.
52
+
53
+ ##### Reference Sharing Contract
54
+
55
+ The returned `{asks, bids}` arrays contain references to the SAME tuple
56
+ objects that live inside the caller's input. **Callers MUST treat both the
57
+ input and the result as read-only.** Mutating either will corrupt the other.
58
+
59
+ This is safe in `icg-exchanges-wrapper`'s `process_rates` flow because:
60
+
61
+ 1. WS rate updates REPLACE the per-pair object via
62
+ `_.set(this.rates, [exchange, pair], { asks, bids, ... })` rather than
63
+ mutating in place — old tuple references simply become garbage when no
64
+ one holds them.
65
+ 2. The combined-rates consumer (the MM `rates_observable` subscriber) runs
66
+ synchronously inside `notify()` in the same call stack, so no other WS
67
+ handler can interleave and rebuild rates underneath us.
68
+
69
+ If you ever need result tuples that are independent of the input (e.g. to
70
+ pass to a mutating consumer like `vol_position_combined`, which expects a
71
+ `[price, amount, exchange]` 3-tuple), wrap them at the call site — do not
72
+ put that contract back into `combine_orderbooks`.
73
+
74
+ #### Added `test/combine-orderbooks.test.js` (2026-04-09)
75
+
76
+ `test/combine-orderbooks.test.js`
77
+
78
+ 13 tests covering:
79
+
80
+ - Basic merge ordering (asks ascend, bids descend, length limit honoured)
81
+ - Result tuples remain `[price, amount]` (no `[2]` augmentation)
82
+ - Input is byte-equal to its pre-call snapshot (non-mutation contract)
83
+ - Idempotent on repeated calls
84
+ - Result tuples `===` input tuples (Flavor 2 reference aliasing)
85
+ - Empty dict, empty side per exchange, single exchange, length larger than
86
+ available, no length limit
87
+ - 3-way interleaving correctness
88
+ - Tied prices are deterministic
89
+ - Fractional prices
90
+
91
+ Wired into `npm test` via `package.json` (now runs both `rate-limiter.test.js`
92
+ and `combine-orderbooks.test.js`).
93
+
94
+ ### icg-exchanges-wrapper
95
+
96
+ All changes in `middleware/orders.js` unless noted otherwise.
97
+
98
+ #### Dropped `_.cloneDeep` in `process_rates` (2026-04-09)
99
+
100
+ The deep clone existed only because the old `combine_orderbooks` mutated its
101
+ input. With the rewrite the input is never touched, so `process_rates` now
102
+ passes the live `this.rates[exchange][pair]` reference directly. Saves
103
+ ~600 µs per `process_rates` call for a typical 5-exchange / 3-pair / 20-level
104
+ MM.
105
+
106
+ #### Removed dead DEX code (2026-04-09)
107
+
108
+ `rates_with_routes` and the entire DEX trading path were referenced from six
109
+ call sites in `orders.js` and one in
110
+ `functions/get-exchanges-rates-market-history-ohlcv.js`, but no exchange in
111
+ `icg-exchanges` ever set `exchange_type === 'dex'` and no exchange implemented
112
+ `rate_with_routes` / `rate_with_routes_ws` / `limit_trade_with_routes`. The
113
+ branches were unreachable in production.
114
+
115
+ Removed:
116
+
117
+ - `this.rates_with_routes = {}` initialization
118
+ - `limit_trade`'s DEX branch (~30 lines including closest-level / route lookup)
119
+ - `if (rates_with_routes)` block in `rates_handler`, plus the
120
+ `rates_with_routes = false` parameter
121
+ - Both DEX branches in `get_rates` (with and without `ws_market`)
122
+ - DEX startup branch that pre-set `previous_orders_canceled_exchange_pair`
123
+ - The `else if (Exchange.rate_with_routes)` REST fallback in
124
+ `functions/get-exchanges-rates-market-history-ohlcv.js`
125
+
126
+ Net: ~74 lines of dead code removed.
127
+
128
+ #### Removed `update_order_info` trace block (2026-04-09)
129
+
130
+ `update_order_info` previously appended to a per-order `trace` array on every
131
+ update via `[...existing_trace, trace_entry]`. This was:
132
+
133
+ - O(N) quadratic per update — the spread copies the entire existing trace
134
+ - Unbounded memory growth per long-lived order
135
+ - Sent over WebSocket on every update, growing the WS payload over time
136
+
137
+ The trace had two consumers:
138
+
139
+ 1. **`maker.js`** read `_.last(o.trace).ts` to filter stale CANCELING orders
140
+ in `maker_2` and `maker_3`. Replaced with a new `last_update_ts` field set
141
+ via native assignment in `update_order_info`.
142
+ 2. **The dashboard "Trace Timeline" UI** showed the full status history for
143
+ any clicked order. This is real value, but the wrapper is the wrong place
144
+ to materialize it. Plan: rebuild it on the dashboard backend from the
145
+ `ws_client.send_order` events that already arrive there. See
146
+ "Future work" below.
147
+
148
+ The wrapper now does zero trace work. The dashboard "Trace Timeline" will
149
+ temporarily show "No trace available for this order" until the backend
150
+ update ships.
151
+
152
+ #### Added status / order_type indexes (2026-04-09)
153
+
154
+ Two new indexes maintained incrementally by `update_order_info` and
155
+ `delete_order_info`:
156
+
157
+ - `this.order_status_index[exchange][pair][type][status] -> Set<order_ref>`
158
+ Used by `get_order_info_status_all`.
159
+ - `this.order_info_by_type[order_type][exchange][pair][type][order_id] -> ref`
160
+ Used by `get_order_info_by_order_type`.
161
+
162
+ Maintenance happens at the end of `update_order_info` after the dict fields
163
+ are written. The function snapshots the old `status` / `order_type` BEFORE
164
+ the for-loop, then compares to the new values after, and moves the order
165
+ between buckets only if the relevant field actually changed. The common
166
+ case (a non-status update like `f_amount` or `last_update_ts`) is a no-op.
167
+
168
+ `delete_order_info` removes the order from both indexes using the order's
169
+ current `status` / `order_type` BEFORE deleting it from the canonical
170
+ `this.order_info`.
171
+
172
+ `get_order_info_status_all` was rewritten to look up the precomputed Set
173
+ and return `Array.from(...)`. Handles both single-status and array-status
174
+ queries, plus the `'both'` recursive case.
175
+
176
+ `get_order_info_by_order_type` walks only the matching `order_type`
177
+ subtree(s) and returns a freshly built top-level dict (so callers can safely
178
+ mutate the returned structure) with leaves that are still refs into
179
+ `this.order_info`.
180
+
181
+ ##### Indexing invariants
182
+
183
+ - Every order in `this.order_info[exchange][pair][type][order_id]` has at
184
+ most one entry in `order_status_index[exchange][pair][type][status]` and
185
+ at most one entry in
186
+ `order_info_by_type[order_type][exchange][pair][type][order_id]`.
187
+ - The only mutators of `this.order_info` are `update_order_info` and
188
+ `delete_order_info`. Both maintain the indexes. Anyone who writes directly
189
+ to `this.order_info` outside these two methods will desynchronize the
190
+ indexes.
191
+ - The previous `this.order_info_status` cache (used by the old
192
+ `get_order_info_status_all`) is removed.
193
+ - `Account.order_type === 'range-order'` mutations at `:1006` and `:1011`
194
+ occur on `res.body` open-orders entries from the exchange WS, NOT on
195
+ `this.order_info` orders, so they don't affect the indexes.
196
+
197
+ ##### Functional verification
198
+
199
+ A standalone in-process test exercises the indexed code paths against a
200
+ stub instance. 11 assertions cover:
201
+
202
+ 1. Insert maker_1 sell INIT → in INIT bucket, in maker_1 subtree
203
+ 2. Transition INIT → N-FILLED → moves buckets, stays in maker_1
204
+ 3. `'both'` query → unions sell + buy
205
+ 4. Array status query → unions multiple status buckets
206
+ 5. by_order_type with array → merges multiple subtrees
207
+ 6. `delete_order_info` → removed from BOTH indexes + canonical store
208
+ 7. Multiple status transitions → bucket moves are atomic
209
+ 8. `last_update_ts` is stamped (numeric, > 0)
210
+ 9. Empty queries return `[]` / `{}` (no crash)
211
+ 10. Result arrays from `get_order_info_status_all` are independent (fresh
212
+ array per call) but inner orders are the same refs
213
+ 11. Mutating the dict returned by `get_order_info_by_order_type` does not
214
+ affect the canonical store
215
+
216
+ #### Fixed `check_balances` shadow bug (2026-04-09)
217
+
218
+ The original inner loop used `for (let exchange in this.exchange_cur_quote_cur)`
219
+ which **shadowed** the outer `exchange` parameter. Every balance update
220
+ rebuilt `buy_sell_limits_against_reserve_balance` for ALL exchanges, doing
221
+ K× the necessary work where K = exchange count. The fix scopes the inner
222
+ loop to the single exchange whose balance just arrived. Also collapsed the
223
+ empty `if (max > 0) { } else { max = 0 }` blocks to `if (max < 0) max = 0`.
224
+
225
+ #### Early-exit on `previous_orders_canceled` recompute (2026-04-09)
226
+
227
+ The recompute walked every `(exchange, pair)` cell every time. Now uses a
228
+ labeled `break outer` on the first unmarked cell. Steady-state behavior
229
+ unchanged (the outer guard `if (!this.previous_orders_canceled)` still
230
+ prevents re-runs once the flag is true), but startup is faster.
231
+
232
+ #### `_.omit(obj, [key])` → `delete obj[key]` (2026-04-09)
233
+
234
+ 16 sites. `_.omit` allocates a new object copying every other key — O(N)
235
+ plus GC pressure. `delete` is O(1) and free.
236
+
237
+ Sites:
238
+
239
+ - `delete_order_info` (`this.order_info[exchange][pair][type]`)
240
+ - `check_balances` expiry (`this.balances`,
241
+ `this.buy_sell_limits_against_reserve_balance`)
242
+ - `check_positions` expiry (`this.positions`)
243
+ - `open_orders_handler` and `all_open_orders_handler` expiry
244
+ (`this.open_orders[exchange]`)
245
+ - `trades_handler` and `all_trades_handler` expiry (`this.trades[exchange]`)
246
+ - `query_order_handler` expiry (`this.query_order[exchange]`)
247
+ - `process_rates` local-variable cleanups (`orderbook_major`,
248
+ `orderbook_major_temp`) and `this.rates_combined_major`
249
+ - `rates_handler` expiry (`this.rates[exchange]`)
250
+ - `market_history_handler` expiry (`this.market_history[exchange]`)
251
+ - `ohlcv_handler` short-data and expiry (`this.ohlcv[exchange]`)
252
+
253
+ #### `new Date().getTime()` → `Date.now()` (2026-04-09)
254
+
255
+ 13 sites. `new Date().getTime()` allocates a Date object just to read its
256
+ timestamp; `Date.now()` is an intrinsic with no allocation. Pure micro-win.
257
+
258
+ ### icg-market-maker
259
+
260
+ #### Replaced `_.last(o.trace).ts` filters with `o.last_update_ts` (2026-04-09)
261
+
262
+ `scripts/maker.js`
263
+
264
+ Two filters in `maker_2` and `maker_3` (for stale CANCELING orders) replaced
265
+ with the new `o.last_update_ts` field added to `orders.js` in this session.
266
+ No semantic change.
267
+
268
+ ## Files touched
269
+
270
+ | Repo | File | Note |
271
+ |---|---|---|
272
+ | `icg-utils` | `lib/utils.js` | `combine_orderbooks` rewrite |
273
+ | `icg-utils` | `test/combine-orderbooks.test.js` | new file, 13 tests |
274
+ | `icg-utils` | `package.json` | `test` script runs both files |
275
+ | `icg-exchanges-wrapper` | `middleware/orders.js` | (a) drop `_.cloneDeep`, (b) remove DEX dead code, (c) remove trace block + add `last_update_ts`, (d) status / order_type indexes, (e) rewrite `get_order_info_status_all` and `get_order_info_by_order_type`, (f) `check_balances` shadow + scope, (g) early-exit `previous_orders_canceled` recompute, (h) `_.omit` → `delete` (16 sites), (i) `new Date().getTime()` → `Date.now()` (13 sites) |
276
+ | `icg-exchanges-wrapper` | `functions/get-exchanges-rates-market-history-ohlcv.js` | remove DEX REST fallback |
277
+ | `icg-market-maker` | `scripts/maker.js` | two `last_trace` filters → `o.last_update_ts` |
278
+
279
+ ## Net effect
280
+
281
+ Hot path per rate tick (5 exchanges × 3 pairs × ~100 orders):
282
+
283
+ | Operation | Before | After |
284
+ |---|---|---|
285
+ | `process_rates` per pair | ~1-2 ms | ~0.2-0.4 ms |
286
+ | `combine_orderbooks` per call | ~500-1000 µs | ~50-100 µs |
287
+ | `get_order_info_status_all` × ~270 calls/tick | walks all matching orders for that (exchange, pair, type), lodash-heavy | Set lookup + `Array.from`, O(matching) |
288
+ | `get_order_info_by_order_type` × ~15 calls/tick | 4-deep scan of all `order_info`, lodash-heavy | walks only matching subtree |
289
+ | `update_order_info` trace cost | O(N) spread per update | 0 |
290
+ | `check_balances` per balance event | K× redundant rebuild | 1× rebuild scoped to the right exchange |
291
+ | `previous_orders_canceled` recompute | always full walk while flag is false | early-exit on first miss |
292
+ | `_.omit` × 16 sites | O(N) new-object allocation | O(1) `delete`, no allocation |
293
+ | `Date` instances per check | 13 per tick | 0 |
294
+
295
+ ## Future work
296
+
297
+ ### Backend trace materialization
298
+
299
+ Rebuild the "Trace Timeline" data on
300
+ `clients-dashboard-backend/src/services/ws-data-service.js` from the
301
+ `ws_client.send_order` events it already receives. About 12 lines added in
302
+ `handle_order_event`. The wrapper does no trace work; the backend caps the
303
+ trace at last 20 entries; the dashboard UI is unchanged. Spans
304
+ `clients-dashboard-backend` only — neither the wrapper nor the maker need to
305
+ ship for this.
306
+
307
+ The required pattern in `handle_order_event`:
308
+
309
+ ```js
310
+ if (action === 'update') {
311
+ const existing = _.get(this.order_info[client_id], [exchange, pair, side, order_id])
312
+ const trace = (existing && existing.trace) || []
313
+ trace.push({
314
+ ts: Date.now(),
315
+ status: data.status,
316
+ note,
317
+ f_amount: data.f_amount,
318
+ })
319
+ if (trace.length > TRACE_MAX_ENTRIES) {
320
+ trace.splice(0, trace.length - TRACE_MAX_ENTRIES)
321
+ }
322
+ _.setWith(
323
+ this.order_info[client_id],
324
+ `${exchange}.${pair}.${side}.${order_id}`,
325
+ { ...data, trace, ts: Date.now() },
326
+ Object,
327
+ )
328
+ // ... rest unchanged
329
+ }
330
+ ```
331
+
332
+ Plus a small change to `handle_order_sync` to preserve existing traces
333
+ across full re-sync (otherwise reconnects wipe history).
334
+
335
+ ### Other items not done in this session
336
+
337
+ - **`Observable.notify()` try/catch** in `icg-utils/lib/observable.js`. Today
338
+ there's only one subscriber so a throw is just a crash, but the moment a
339
+ second subscriber is added, a throw in #1 silently kills #2. Two-line fix.
340
+ - **Remaining lodash hot-path cleanup**: `_.set` / `_.setWith` / `_.get` in
341
+ `update_order_info` and `rates_handler` could be replaced with native
342
+ indexing for ~5-20× speedup on those calls.
343
+ - **Empty `cancel_order_by_order` callbacks** at `orders.js:1038`, `:1045`:
344
+ failures silently ignored. Robustness, not perf.
345
+ - **Hot-path `console.log('UPDATE %s: ...')` formatting** at `orders.js:862`:
346
+ runs on every `update_order_info` call. Should be gated with a verbosity
347
+ flag or routed through `log_limiter` (like maker.js does).
348
+ - **`process_open_orders` nested scans** at `orders.js:618-766`. Same
349
+ indexing approach as the status / order_type indexes would help here too,
350
+ but it's off the hot path so lower priority.
351
+
352
+ ## Deployment notes
353
+
354
+ When this work ships:
355
+
356
+ 1. `icg-utils` → bump version → publish to npm (new `combine_orderbooks`).
357
+ 2. `icg-exchanges-wrapper` → bump `@icgio/icg-utils` dep range → `npm install`
358
+ → bump pkg version → publish.
359
+ 3. **MM client servers** → manual deploy across all 12. Per
360
+ `feedback_mm_servers_dirty_lockfile.md`, pre-stash any dirty
361
+ `package.json` / `package-lock.json` on each server before
362
+ `git pull && npm ci`. The deploy carries both the wrapper republish and
363
+ the `maker.js` change (they both touch the maker process).
364
+ 4. The dashboard "Trace Timeline" will show "No trace available" until the
365
+ `clients-dashboard-backend` update ships separately. Plan that as a
366
+ follow-up.
@@ -73,6 +73,7 @@ const MARKET_HISTORY_DEFAULT_MS = 10 * 60 * 1000
73
73
  const TRADES_DEFAULT_MS = 7 * 24 * 60 * 60 * 1000
74
74
  const NO_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000
75
75
  const PROCESS_RATES_THROTTLE_MS = 75
76
+ const MAX_ORDER_TRACE_ENTRIES = 20
76
77
  const ORDER_TYPE_PRIORITY = {
77
78
  self: 2,
78
79
  maker_1: 3,
@@ -89,6 +90,13 @@ const getOrderPriority = (order_type, is_cancel = false) => {
89
90
  return is_cancel ? priority - 0.5 : priority
90
91
  }
91
92
 
93
+ const hrtime_diff = (start, end) => {
94
+ if (!Array.isArray(start) || !Array.isArray(end)) {
95
+ return undefined
96
+ }
97
+ return (end[0] - start[0]) * 1e9 + (end[1] - start[1])
98
+ }
99
+
92
100
  module.exports = class Orders {
93
101
  constructor(exchange_cur_quote_cur_dict, cd, settings) {
94
102
  let { exchange_cur_quote_cur, exchange_cur_quote_cur_rates_only, pair_map_rates_only } = exchange_cur_quote_cur_dict
@@ -272,72 +280,135 @@ module.exports = class Orders {
272
280
  ws_client.set_order_info_ref(this.order_info)
273
281
  }
274
282
  }
283
+ build_trace_entry(order, note) {
284
+ return {
285
+ ts: Date.now(),
286
+ status: order.status,
287
+ note,
288
+ f_amount: order.f_amount,
289
+ }
290
+ }
291
+ append_trace(order, note) {
292
+ if (!note || !order) return
293
+ if (!Array.isArray(order.trace)) {
294
+ order.trace = []
295
+ }
296
+ order.trace.push(this.build_trace_entry(order, note))
297
+ if (order.trace.length > MAX_ORDER_TRACE_ENTRIES) {
298
+ order.trace.splice(0, order.trace.length - MAX_ORDER_TRACE_ENTRIES)
299
+ }
300
+ }
301
+ move_order_info_key(exchange, pair, type, from_order_id, to_order_id) {
302
+ if (!from_order_id || !to_order_id || from_order_id === to_order_id) {
303
+ return
304
+ }
305
+ const from_bucket = this.order_info?.[exchange]?.[pair]?.[type]
306
+ if (!from_bucket || !from_bucket[from_order_id]) {
307
+ return
308
+ }
309
+ const order = from_bucket[from_order_id]
310
+ from_bucket[to_order_id] = order
311
+ delete from_bucket[from_order_id]
312
+ if (order.order_type) {
313
+ const by_type = this.order_info_by_type?.[order.order_type]?.[exchange]?.[pair]?.[type]
314
+ if (by_type) {
315
+ by_type[to_order_id] = order
316
+ delete by_type[from_order_id]
317
+ }
318
+ }
319
+ }
275
320
  init_account(balances, benchmarks) {
276
321
  this.account = new Account(balances, benchmarks)
277
322
  }
278
323
  update_non_initial_from_balances(balances) {
279
324
  this.account?.update_non_initial_from_balances(balances)
280
325
  }
281
- limit_trade(exchange, pair, method, price, amount, id = uuid(), order_type = '', limiter_on = true, api_key_index, order_options = {}, decision_context) {
326
+ limit_trade(exchange, pair, method, price, amount, id = uuid(), order_type = '', limiter_on = true, api_key_index, order_options = {}, decision_context = null) {
282
327
  price = round_price(exchange, pair, price)
283
328
  amount = round_amount(exchange, pair, amount)
284
- // console.log('%s %s %s %s %s %s at %s', limiter_on, order_type, exchange, pair, method, amount, price)
285
- this.update_order_info(
286
- exchange,
287
- pair,
288
- method,
289
- id,
290
- {
291
- order_type,
292
- status: 'INIT',
293
- id,
294
- order_id: id,
295
- type: method,
296
- price,
297
- amount,
298
- f_amount: 0,
299
- f_percentage: 0,
300
- t_amount: amount,
301
- open_time: Date.now(),
302
- close_time: null,
303
- decision_context,
304
- },
305
- order_type + ' init',
306
- )
307
- let no_callback_timeout = setTimeout(() => {
308
- if (_.get(this.order_info, [exchange, pair, method, id, 'status']) === 'INIT') {
309
- console.log('no callback: %s %s %s %s %s', exchange, pair, method, id, order_type)
310
- this.update_order_info(
311
- exchange,
312
- pair,
313
- method,
314
- id,
315
- {
316
- status: 'N-OPENED-TIMEOUT',
317
- },
318
- order_type + ' n-opened-timeout-init',
319
- )
329
+ const internal_id = id
330
+ let no_callback_timeout = null
331
+ let init_emitted = false
332
+ const start_no_callback_timeout = () => {
333
+ if (no_callback_timeout) {
334
+ clearTimeout(no_callback_timeout)
335
+ }
336
+ no_callback_timeout = setTimeout(() => {
337
+ if (_.get(this.order_info, [exchange, pair, method, internal_id, 'status']) === 'INIT') {
338
+ console.log('no callback: %s %s %s %s %s', exchange, pair, method, internal_id, order_type)
339
+ this.update_order_info(
340
+ exchange,
341
+ pair,
342
+ method,
343
+ internal_id,
344
+ {
345
+ status: 'N-OPENED-TIMEOUT',
346
+ },
347
+ order_type + ' n-opened-timeout-init',
348
+ )
349
+ }
350
+ }, NO_CALLBACK_TIMEOUT_MS)
351
+ }
352
+ const emit_init = ({ t_order_sent } = {}) => {
353
+ if (init_emitted) {
354
+ return
320
355
  }
321
- }, NO_CALLBACK_TIMEOUT_MS)
356
+ init_emitted = true
357
+ const init_decision_context = decision_context ? { ...decision_context } : null
358
+ const t2t_ns = hrtime_diff(init_decision_context?.t_decision_hr, t_order_sent)
359
+ if (init_decision_context) {
360
+ delete init_decision_context.t_decision_hr
361
+ }
362
+ this.update_order_info(
363
+ exchange,
364
+ pair,
365
+ method,
366
+ internal_id,
367
+ {
368
+ order_type,
369
+ status: 'INIT',
370
+ id: internal_id,
371
+ internal_id,
372
+ order_id: null,
373
+ type: method,
374
+ price,
375
+ amount,
376
+ f_amount: 0,
377
+ f_percentage: 0,
378
+ t_amount: amount,
379
+ open_time: Date.now(),
380
+ close_time: null,
381
+ decision_context: init_decision_context,
382
+ t2t_ns,
383
+ event_hrtime: t_order_sent,
384
+ },
385
+ order_type + ' init',
386
+ )
387
+ start_no_callback_timeout()
388
+ }
322
389
  let limit_trade = (cb) => {
390
+ const order_options_with_meta = Object.assign({}, order_options, {
391
+ on_sent: ({ t_order_sent }) => {
392
+ emit_init({ t_order_sent })
393
+ },
394
+ })
323
395
  if (limiter_on) {
324
396
  if (this.Exchanges[exchange].private_limiter) {
325
397
  this.Exchanges[exchange].private_limiter.priority = getOrderPriority(order_type)
326
398
  }
327
- this.Exchanges[exchange].limit_trade(pair, method, price, amount, order_options, cb)
399
+ this.Exchanges[exchange].limit_trade(pair, method, price, amount, order_options_with_meta, cb)
328
400
  } else {
329
- this.Exchanges[exchange].limit_trade_base(pair, method, price, amount, order_options, cb)
401
+ this.Exchanges[exchange].limit_trade_base(pair, method, price, amount, order_options_with_meta, cb)
330
402
  }
331
403
  }
332
404
  if (api_key_index) {
333
405
  this.Exchanges[exchange].key_index = api_key_index
334
406
  }
335
- const t_order_sent = process.hrtime()
336
407
  limit_trade((res) => {
337
- const t_ack_recv = process.hrtime()
338
408
  if (res.success) {
339
409
  let order_id = res.body
340
- this.delete_order_info(exchange, pair, method, id, order_type + ' n-filled')
410
+ emit_init(res.meta || {})
411
+ this.move_order_info_key(exchange, pair, method, internal_id, order_id)
341
412
  this.update_order_info(
342
413
  exchange,
343
414
  pair,
@@ -346,7 +417,8 @@ module.exports = class Orders {
346
417
  {
347
418
  order_type,
348
419
  status: 'N-FILLED',
349
- id,
420
+ id: internal_id,
421
+ internal_id,
350
422
  order_id,
351
423
  type: method,
352
424
  price,
@@ -354,31 +426,32 @@ module.exports = class Orders {
354
426
  f_amount: 0,
355
427
  f_percentage: 0,
356
428
  t_amount: amount,
357
- open_time: Date.now(),
358
429
  close_time: null,
359
- t_order_sent,
360
- t_ack_recv,
430
+ order_rtt_ns: hrtime_diff(_.get(res, ['meta', 't_order_sent']), _.get(res, ['meta', 't_ack_recv'])),
431
+ event_hrtime: _.get(res, ['meta', 't_ack_recv']),
361
432
  },
362
433
  order_type + ' n-filled',
363
434
  )
364
435
  } else if (res.error === 'amount-too-small' || res.error === 'insufficient-funds') {
365
- this.delete_order_info(exchange, pair, method, id, order_type + ' limit-trade-' + res.error)
436
+ this.delete_order_info(exchange, pair, method, internal_id, order_type + ' limit-trade-' + res.error)
366
437
  } else if (res.error === 'connection-error') {
367
438
  setTimeout(() => {
368
- this.delete_order_info(exchange, pair, method, id, order_type + ' limit-trade-' + res.error)
439
+ this.delete_order_info(exchange, pair, method, internal_id, order_type + ' limit-trade-' + res.error)
369
440
  }, this.delete_connection_error_orders_timeout)
370
441
  } else {
371
442
  if (order_type.startsWith('self') || order_type.startsWith('maker_2')) {
372
- this.delete_order_info(exchange, pair, method, id, order_type + ' limit-trade-timeout')
443
+ this.delete_order_info(exchange, pair, method, internal_id, order_type + ' limit-trade-timeout')
373
444
  }
374
445
  // else if (_.get(this.order_info, [exchange, pair, method, id, 'status']) === 'INIT') { // make sure no_callback_timeout is NOT running zero
375
446
  // this.update_order_info(exchange, pair, method, id, {
376
447
  // status: 'N-OPENED-TIMEOUT'
377
448
  // }, order_type + ' n-opened-timeout-callback')
378
449
  // }
379
- console.error('limit trade error: %s %s %s %s %s %s %s %j', order_type, id, exchange, pair, method, price, amount, res)
450
+ console.error('limit trade error: %s %s %s %s %s %s %s %j', order_type, internal_id, exchange, pair, method, price, amount, res)
451
+ }
452
+ if (no_callback_timeout) {
453
+ clearTimeout(no_callback_timeout)
380
454
  }
381
- clearTimeout(no_callback_timeout)
382
455
  })
383
456
  }
384
457
  cancel_order(exchange, pair, order) {
@@ -407,6 +480,8 @@ module.exports = class Orders {
407
480
  {
408
481
  status: 'CANCELED',
409
482
  close_time: Date.now(),
483
+ cancel_latency_ns: hrtime_diff(_.get(res, ['meta', 't_cancel_sent']), _.get(res, ['meta', 't_cancel_ack'])),
484
+ event_hrtime: _.get(res, ['meta', 't_cancel_ack']),
410
485
  },
411
486
  order.order_type + ' canceled',
412
487
  )
@@ -557,8 +632,9 @@ module.exports = class Orders {
557
632
  amount: 0,
558
633
  f_amount: trade_amount,
559
634
  f_percentage: trade_amount / order_info.t_amount,
560
- close_time: trade.close_time || Date.now(),
635
+ close_time: trade.close_time ? new Date(trade.close_time).getTime() : Date.now(),
561
636
  fees: trade.fees,
637
+ event_hrtime: process.hrtime(),
562
638
  },
563
639
  order_info.order_type + ' canceled-order-filled(-more)',
564
640
  )
@@ -597,8 +673,9 @@ module.exports = class Orders {
597
673
  amount: order_info.t_amount - trade_amount,
598
674
  f_amount: trade.amount,
599
675
  f_percentage: trade_amount / order_info.t_amount,
600
- close_time: trade.close_time || Date.now(),
676
+ close_time: trade.close_time ? new Date(trade.close_time).getTime() : Date.now(),
601
677
  fees: trade.fees,
678
+ event_hrtime: process.hrtime(),
602
679
  },
603
680
  order_info.order_type + ' order-f-filled-1',
604
681
  )
@@ -684,6 +761,7 @@ module.exports = class Orders {
684
761
  f_amount: order_info_order.t_amount,
685
762
  f_percentage: 1,
686
763
  close_time: Date.now(),
764
+ event_hrtime: process.hrtime(),
687
765
  },
688
766
  order_info_order.order_type + ' order-f-filled-2',
689
767
  )
@@ -718,7 +796,8 @@ module.exports = class Orders {
718
796
  amount: order.amount,
719
797
  f_amount: order_f_amount,
720
798
  f_percentage: order_f_amount / order_info.t_amount,
721
- close_time: order.close_time || Date.now(),
799
+ close_time: order.close_time ? new Date(order.close_time).getTime() : Date.now(),
800
+ event_hrtime: process.hrtime(),
722
801
  },
723
802
  order_info.order_type + ' order-f-filled-3',
724
803
  )
@@ -749,8 +828,8 @@ module.exports = class Orders {
749
828
  this.get_order_info_status_all(exchange, pair, 'both', 'INIT').map((init_order) => {
750
829
  let init_order_price = round_price(exchange, pair, init_order.price)
751
830
  let order_price = round_price(exchange, pair, order.price)
752
- let init_order_time = init_order.open_time.getTime()
753
- let order_time = order.open_time.getTime()
831
+ let init_order_time = typeof init_order.open_time === 'number' ? init_order.open_time : new Date(init_order.open_time).getTime()
832
+ let order_time = typeof order.open_time === 'number' ? order.open_time : new Date(order.open_time).getTime()
754
833
  if (
755
834
  init_order.type === order.type &&
756
835
  init_order_price === order_price &&
@@ -758,7 +837,7 @@ module.exports = class Orders {
758
837
  order_time - init_order_time < this.check_init_order_opened_timeout
759
838
  ) {
760
839
  // console.log('ORDERS: %s %s %s %s INIT ORDER OPENED', init_order.order_id, order.order_id, exchange, pair)
761
- this.delete_order_info(exchange, pair, init_order.type, init_order.order_id, init_order.order_type + ' init-order-opened')
840
+ this.move_order_info_key(exchange, pair, init_order.type, init_order.internal_id || init_order.id, order.order_id)
762
841
  this.update_order_info(
763
842
  exchange,
764
843
  pair,
@@ -772,8 +851,9 @@ module.exports = class Orders {
772
851
  amount: order.amount,
773
852
  f_amount: 0,
774
853
  t_amount: order.amount,
775
- open_time: order.open_time || Date.now(),
854
+ open_time: order.open_time ? new Date(order.open_time).getTime() : Date.now(),
776
855
  close_time: null,
856
+ event_hrtime: process.hrtime(),
777
857
  },
778
858
  init_order.order_type + ' init-order-opened',
779
859
  )
@@ -794,6 +874,7 @@ module.exports = class Orders {
794
874
  {
795
875
  status: 'CANCELED',
796
876
  close_time: Date.now(),
877
+ event_hrtime: process.hrtime(),
797
878
  },
798
879
  (order_info.order_type || '') + ' canceling-canceled-from-open-orders',
799
880
  )
@@ -814,6 +895,8 @@ module.exports = class Orders {
814
895
  const existing = this.order_info[exchange]?.[pair]?.[type]?.[order_id]
815
896
  const old_status = existing && existing.status
816
897
  const old_order_type = existing && existing.order_type
898
+ const event_hrtime = dict.event_hrtime
899
+ delete dict.event_hrtime
817
900
 
818
901
  for (let key in dict) {
819
902
  if (key === 'f_percentage') {
@@ -827,7 +910,15 @@ module.exports = class Orders {
827
910
  }
828
911
 
829
912
  const order = this.order_info[exchange][pair][type][order_id]
913
+ if (order.internal_id == null && order.id != null) {
914
+ order.internal_id = order.id
915
+ }
916
+ if (order.trace == null) {
917
+ order.trace = []
918
+ }
919
+ order.lifecycle_seq = existing && Number.isFinite(existing.lifecycle_seq) ? existing.lifecycle_seq + 1 : dict.lifecycle_seq || 1
830
920
  order.last_update_ts = Date.now()
921
+ this.append_trace(order, note)
831
922
 
832
923
  const new_status = order.status
833
924
  if (old_status !== new_status) {
@@ -884,14 +975,16 @@ module.exports = class Orders {
884
975
 
885
976
  // Send order update via WebSocket to dashboard backend
886
977
  const order_data = _.get(this.order_info, [exchange, pair, type, order_id], {})
887
- // Only send N-FILLED and later statuses (skip INIT which has temporary order_id)
888
- if (dict.status !== 'INIT' && order_data.status !== 'INIT' && ws_client) {
978
+ if (ws_client) {
889
979
  ws_client.send_order({
890
980
  action: 'update',
891
981
  exchange,
892
982
  pair,
893
983
  side: type,
894
984
  order_id,
985
+ internal_id: order_data.internal_id,
986
+ lifecycle_seq: order_data.lifecycle_seq,
987
+ event_hrtime,
895
988
  data: order_data,
896
989
  note,
897
990
  })
@@ -914,14 +1007,16 @@ module.exports = class Orders {
914
1007
  }
915
1008
  console.log('DELETE %s: %s %s %s %s', note, exchange, pair, type, order_id)
916
1009
 
917
- // Send delete event via WebSocket (only for non-INIT orders)
918
- if (order_data.status !== 'INIT' && ws_client) {
1010
+ if (ws_client && order_data.internal_id) {
919
1011
  ws_client.send_order({
920
1012
  action: 'delete',
921
1013
  exchange,
922
1014
  pair,
923
1015
  side: type,
924
1016
  order_id,
1017
+ internal_id: order_data.internal_id,
1018
+ lifecycle_seq: (order_data.lifecycle_seq || 0) + 1,
1019
+ event_hrtime: process.hrtime(),
925
1020
  data: order_data,
926
1021
  note,
927
1022
  })
@@ -1411,16 +1506,15 @@ module.exports = class Orders {
1411
1506
  const throttled_process_rates = _.throttle(process_rates, PROCESS_RATES_THROTTLE_MS, { leading: true, trailing: true })
1412
1507
  let rates_handler = (exchange, pair, res) => {
1413
1508
  if (res.success) {
1414
- let { asks, bids } = res.body
1509
+ let { asks, bids, _ts } = res.body
1415
1510
  asks = asks.slice(0, RATES_LENGTH_MAX)
1416
1511
  bids = bids.slice(0, RATES_LENGTH_MAX)
1417
- const _ts = _.get(this.Exchanges, [exchange, 'ws', 'market', 'ts_dict', pair]) || _.get(this.ExchangesRatesOnly, [exchange, 'ws', 'market', 'ts_dict', pair])
1418
- _.set(this.rates, [exchange, pair], { asks, bids, _ts })
1512
+ _.set(this.rates, [exchange, pair], { asks, bids, _ts, update_time: new Date() })
1419
1513
  /*
1420
1514
  * throttle to 20ms
1421
1515
  */
1422
1516
  throttled_process_rates()
1423
- } else if (_.get(this.rates, [exchange, pair, '_ts', 'received_ts'], 0) < Date.now() - this.interval_dict[exchange].rates) {
1517
+ } else if (_.get(this.rates, [exchange, pair, 'update_time']) < Date.now() - this.interval_dict[exchange].rates) {
1424
1518
  console.log('%s %s rates expired: %j', exchange, pair, res)
1425
1519
  if (this.rates[exchange]) delete this.rates[exchange][pair]
1426
1520
  this.rates_observable.notify()
@@ -1529,13 +1623,13 @@ module.exports = class Orders {
1529
1623
  for (let exchange in exchange_pairs) {
1530
1624
  for (let pair of exchange_pairs[exchange]) {
1531
1625
  if (Exchanges[exchange].ohlcv) {
1532
- const ohlcv_from_timetamp = new Date().floorHours(1).getTime() - OHLCV_DEFAULT_PAST_HOURS * 3_600_000
1626
+ const ohlcv_from_timetamp = new Date().floorHours(1).addHours(-OHLCV_DEFAULT_PAST_HOURS).getTime()
1533
1627
  const ohlcv_to_timestamp = new Date().floorHours(1).getTime()
1534
1628
  Exchanges[exchange].ohlcv(pair, ohlcv_from_timetamp, ohlcv_to_timestamp, (res) => {
1535
1629
  ohlcv_handler(exchange, pair, res)
1536
1630
  })
1537
1631
  setInterval(() => {
1538
- const ohlcv_from_timetamp = new Date().floorHours(1).getTime() - OHLCV_DEFAULT_PAST_HOURS * 3_600_000
1632
+ const ohlcv_from_timetamp = new Date().floorHours(1).addHours(-OHLCV_DEFAULT_PAST_HOURS).getTime()
1539
1633
  const ohlcv_to_timestamp = new Date().floorHours(1).getTime()
1540
1634
  Exchanges[exchange].ohlcv(pair, ohlcv_from_timetamp, ohlcv_to_timestamp, (res) => {
1541
1635
  ohlcv_handler(exchange, pair, res)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icgio/icg-exchanges-wrapper",
3
- "version": "1.17.20",
3
+ "version": "1.17.22",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -17,8 +17,8 @@
17
17
  },
18
18
  "homepage": "https://github.com/icgio/icg-exchanges-wrapper#readme",
19
19
  "dependencies": {
20
- "@icgio/icg-exchanges": "^1.40.43",
21
- "@icgio/icg-utils": "^1.9.60",
20
+ "@icgio/icg-exchanges": "^1.40.45",
21
+ "@icgio/icg-utils": "^1.9.61",
22
22
  "lodash": "^4.17.23",
23
23
  "pg": "^8.17.2"
24
24
  }