@imbingox/acex 0.4.0-beta.1 → 0.4.0-beta.10

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,181 @@
1
+ import type {
2
+ RateLimiter,
3
+ RateLimitRequestContext,
4
+ RateLimitResponseContext,
5
+ RateLimitScope,
6
+ RateLimitSnapshot,
7
+ RateLimitTransportErrorContext,
8
+ RateLimitUsage,
9
+ } from "../types/index.ts";
10
+
11
+ interface ReactiveRateLimiterOptions {
12
+ readonly now?: () => number;
13
+ readonly sleep?: (ms: number) => Promise<void>;
14
+ readonly defaultRateLimitMs?: number;
15
+ readonly defaultBanMs?: number;
16
+ }
17
+
18
+ interface RateLimitState {
19
+ usage?: RateLimitUsage;
20
+ blockedUntil?: number;
21
+ retryAfterMs?: number;
22
+ state: RateLimitSnapshot["state"];
23
+ updatedAt?: number;
24
+ }
25
+
26
+ const DEFAULT_RATE_LIMIT_MS = 0;
27
+ const DEFAULT_BAN_MS = 60_000;
28
+
29
+ export class ReactiveRateLimiter implements RateLimiter {
30
+ private readonly now: () => number;
31
+ private readonly sleep: (ms: number) => Promise<void>;
32
+ private readonly defaultRateLimitMs: number;
33
+ private readonly defaultBanMs: number;
34
+ private readonly states = new Map<string, RateLimitState>();
35
+
36
+ constructor(options: ReactiveRateLimiterOptions = {}) {
37
+ this.now = options.now ?? Date.now;
38
+ this.sleep = options.sleep ?? defaultSleep;
39
+ this.defaultRateLimitMs =
40
+ options.defaultRateLimitMs ?? DEFAULT_RATE_LIMIT_MS;
41
+ this.defaultBanMs = options.defaultBanMs ?? DEFAULT_BAN_MS;
42
+ }
43
+
44
+ async beforeRequest(ctx: RateLimitRequestContext): Promise<void> {
45
+ const snapshot = this.getSnapshot(ctx.scope);
46
+ if (!snapshot?.blockedUntil || snapshot.blockedUntil <= this.now()) {
47
+ return;
48
+ }
49
+
50
+ await this.sleep(Math.max(0, snapshot.blockedUntil - this.now()));
51
+ }
52
+
53
+ afterResponse(
54
+ ctx: RateLimitRequestContext,
55
+ response: RateLimitResponseContext,
56
+ ): void {
57
+ if (response.usage) {
58
+ const existing = this.getState(ctx.scope);
59
+ const hasActiveBlock =
60
+ existing?.blockedUntil !== undefined &&
61
+ existing.blockedUntil > this.now();
62
+ this.updateState(ctx.scope, {
63
+ usage: cloneUsage(response.usage),
64
+ state: hasActiveBlock ? existing.state : "ok",
65
+ });
66
+ }
67
+ }
68
+
69
+ onTransportError(
70
+ ctx: RateLimitRequestContext,
71
+ error: RateLimitTransportErrorContext,
72
+ ): void {
73
+ if (error.usage) {
74
+ this.updateState(ctx.scope, {
75
+ usage: cloneUsage(error.usage),
76
+ });
77
+ }
78
+
79
+ if (error.status !== 429 && error.status !== 418) {
80
+ return;
81
+ }
82
+
83
+ const now = this.now();
84
+ const isBan = error.status === 418;
85
+ const retryAfterMs =
86
+ error.retryAfterMs ??
87
+ (isBan ? this.defaultBanMs : this.defaultRateLimitMs);
88
+ const blockedUntil =
89
+ retryAfterMs > 0
90
+ ? now + retryAfterMs
91
+ : this.getState(ctx.scope)?.blockedUntil;
92
+
93
+ this.updateState(ctx.scope, {
94
+ blockedUntil,
95
+ retryAfterMs,
96
+ state: isBan ? "banned" : "rate_limited",
97
+ });
98
+ }
99
+
100
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined {
101
+ const state = this.getState(scope);
102
+ if (!state) {
103
+ return undefined;
104
+ }
105
+
106
+ const now = this.now();
107
+ const blockedUntil =
108
+ state.blockedUntil !== undefined && state.blockedUntil > now
109
+ ? state.blockedUntil
110
+ : undefined;
111
+ const runtimeState =
112
+ blockedUntil === undefined && state.state !== "ok" ? "ok" : state.state;
113
+
114
+ return {
115
+ scope: { ...scope },
116
+ usage: state.usage ? cloneUsage(state.usage) : undefined,
117
+ blockedUntil,
118
+ retryAfterMs: blockedUntil ? state.retryAfterMs : undefined,
119
+ state: runtimeState,
120
+ updatedAt: state.updatedAt,
121
+ };
122
+ }
123
+
124
+ private getState(scope: RateLimitScope): RateLimitState | undefined {
125
+ return this.states.get(scopeKey(scope));
126
+ }
127
+
128
+ private updateState(
129
+ scope: RateLimitScope,
130
+ patch: Partial<RateLimitState>,
131
+ ): void {
132
+ const existing = this.getState(scope);
133
+ const nextBlockedUntil = maxOptional(
134
+ existing?.blockedUntil,
135
+ patch.blockedUntil,
136
+ );
137
+ const nextState =
138
+ patch.state ??
139
+ (nextBlockedUntil !== undefined
140
+ ? (existing?.state ?? "ok")
141
+ : existing?.state);
142
+
143
+ this.states.set(scopeKey(scope), {
144
+ usage: patch.usage ?? existing?.usage,
145
+ blockedUntil: nextBlockedUntil,
146
+ retryAfterMs: patch.retryAfterMs ?? existing?.retryAfterMs,
147
+ state: nextState ?? "ok",
148
+ updatedAt: this.now(),
149
+ });
150
+ }
151
+ }
152
+
153
+ function scopeKey(scope: RateLimitScope): string {
154
+ return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
155
+ }
156
+
157
+ function cloneUsage(usage: RateLimitUsage): RateLimitUsage {
158
+ return {
159
+ weight: usage.weight ? { ...usage.weight } : undefined,
160
+ orderCount: usage.orderCount ? { ...usage.orderCount } : undefined,
161
+ };
162
+ }
163
+
164
+ function maxOptional(
165
+ left: number | undefined,
166
+ right: number | undefined,
167
+ ): number | undefined {
168
+ if (left === undefined) {
169
+ return right;
170
+ }
171
+ if (right === undefined) {
172
+ return left;
173
+ }
174
+ return Math.max(left, right);
175
+ }
176
+
177
+ function defaultSleep(ms: number): Promise<void> {
178
+ return new Promise((resolve) => {
179
+ setTimeout(resolve, ms);
180
+ });
181
+ }
@@ -0,0 +1,83 @@
1
+ export const CROSS_CLOCK_WATERMARK_GRACE_MS = 10_000;
2
+
3
+ export interface WatermarkedRecord {
4
+ exchangeTs?: number;
5
+ receivedAt: number;
6
+ }
7
+
8
+ export interface WatermarkApplyOptions {
9
+ requestStartedAt?: number;
10
+ source?: "command" | "rest" | "stream";
11
+ graceMs?: number;
12
+ }
13
+
14
+ export interface SnapshotDeletionGuard {
15
+ requestStartedAt: number;
16
+ snapshotExchangeTs?: number;
17
+ }
18
+
19
+ export function shouldApplyWatermarkedUpdate(
20
+ current: WatermarkedRecord | undefined,
21
+ incoming: WatermarkedRecord,
22
+ options: WatermarkApplyOptions = {},
23
+ ): boolean {
24
+ if (!current) {
25
+ return true;
26
+ }
27
+
28
+ const graceMs = options.graceMs ?? CROSS_CLOCK_WATERMARK_GRACE_MS;
29
+ const requestStartedAt = options.requestStartedAt;
30
+
31
+ if (
32
+ options.source === "rest" &&
33
+ requestStartedAt !== undefined &&
34
+ current.receivedAt > requestStartedAt &&
35
+ (current.exchangeTs === undefined || incoming.exchangeTs === undefined)
36
+ ) {
37
+ return false;
38
+ }
39
+
40
+ if (current.exchangeTs !== undefined && incoming.exchangeTs !== undefined) {
41
+ if (incoming.exchangeTs < current.exchangeTs) {
42
+ return false;
43
+ }
44
+ if (incoming.exchangeTs > current.exchangeTs) {
45
+ return true;
46
+ }
47
+
48
+ return incoming.receivedAt >= current.receivedAt;
49
+ }
50
+
51
+ if (current.exchangeTs !== undefined) {
52
+ if (incoming.receivedAt < current.exchangeTs + graceMs) {
53
+ return false;
54
+ }
55
+
56
+ return incoming.receivedAt >= current.receivedAt;
57
+ }
58
+
59
+ if (incoming.exchangeTs !== undefined) {
60
+ if (incoming.exchangeTs < current.receivedAt - graceMs) {
61
+ return false;
62
+ }
63
+
64
+ return incoming.receivedAt >= current.receivedAt;
65
+ }
66
+
67
+ return incoming.receivedAt >= current.receivedAt;
68
+ }
69
+
70
+ export function canDeleteMissingFromSnapshot(
71
+ current: WatermarkedRecord,
72
+ guard: SnapshotDeletionGuard,
73
+ ): boolean {
74
+ if (current.receivedAt > guard.requestStartedAt) {
75
+ return false;
76
+ }
77
+
78
+ return !(
79
+ current.exchangeTs !== undefined &&
80
+ guard.snapshotExchangeTs !== undefined &&
81
+ current.exchangeTs > guard.snapshotExchangeTs
82
+ );
83
+ }
@@ -17,6 +17,10 @@ import type {
17
17
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
18
18
  import { toCanonical } from "../internal/decimal.ts";
19
19
  import { matchesAccountFilter } from "../internal/filters.ts";
20
+ import {
21
+ canDeleteMissingFromSnapshot,
22
+ shouldApplyWatermarkedUpdate,
23
+ } from "../internal/watermark.ts";
20
24
  import type {
21
25
  AccountDataStatus,
22
26
  AccountEvent,
@@ -61,6 +65,46 @@ function isZeroDecimal(value: string): boolean {
61
65
  return new BigNumber(value).isZero();
62
66
  }
63
67
 
68
+ function isZeroBalance(balance: BalanceSnapshot): boolean {
69
+ return (
70
+ isZeroDecimal(balance.free) &&
71
+ isZeroDecimal(balance.used) &&
72
+ isZeroDecimal(balance.total)
73
+ );
74
+ }
75
+
76
+ function successfulStatus(
77
+ status: AccountDataStatus,
78
+ options: {
79
+ ready?: boolean;
80
+ lastReceivedAt?: number;
81
+ lastReadyAt?: number;
82
+ preserveStatus?: boolean;
83
+ },
84
+ ): AccountDataStatus {
85
+ const preservesStreamState =
86
+ options.preserveStatus &&
87
+ (status.runtimeStatus === "reconnecting" ||
88
+ status.reason === "ws_disconnected" ||
89
+ status.reason === "heartbeat_timeout");
90
+ const ready = options.ready ?? true;
91
+
92
+ return {
93
+ ...status,
94
+ activity: "active",
95
+ ready,
96
+ runtimeStatus: preservesStreamState ? status.runtimeStatus : "healthy",
97
+ reason: preservesStreamState ? status.reason : undefined,
98
+ lastReceivedAt: options.lastReceivedAt ?? status.lastReceivedAt,
99
+ lastReadyAt: ready
100
+ ? (options.lastReadyAt ??
101
+ (options.preserveStatus ? status.lastReadyAt : undefined) ??
102
+ Date.now())
103
+ : status.lastReadyAt,
104
+ inactiveSince: undefined,
105
+ };
106
+ }
107
+
64
108
  export class AccountManagerImpl
65
109
  implements
66
110
  AccountManager,
@@ -282,7 +326,7 @@ export class AccountManagerImpl
282
326
  accountId: string,
283
327
  venue: Venue,
284
328
  update: RawAccountUpdate,
285
- options: { preserveStatus?: boolean } = {},
329
+ options: { preserveStatus?: boolean; requestStartedAt?: number } = {},
286
330
  ): void {
287
331
  const record = this.getOrCreateRecord(accountId, venue);
288
332
  if (!record.subscribed) {
@@ -300,7 +344,17 @@ export class AccountManagerImpl
300
344
  );
301
345
  let risk = previous.risk;
302
346
 
347
+ let latestAppliedAt = 0;
303
348
  for (const balance of update.balances ?? []) {
349
+ if (
350
+ !shouldApplyWatermarkedUpdate(balances[balance.asset], balance, {
351
+ requestStartedAt: options.requestStartedAt,
352
+ source: options.requestStartedAt === undefined ? "stream" : "rest",
353
+ })
354
+ ) {
355
+ continue;
356
+ }
357
+
304
358
  const nextBalance = this.createBalance(
305
359
  accountId,
306
360
  venue,
@@ -308,6 +362,7 @@ export class AccountManagerImpl
308
362
  balances[balance.asset],
309
363
  );
310
364
  balances[balance.asset] = nextBalance;
365
+ latestAppliedAt = Math.max(latestAppliedAt, nextBalance.receivedAt);
311
366
  this.accountBus.publish({
312
367
  type: "balance.updated",
313
368
  accountId,
@@ -320,6 +375,15 @@ export class AccountManagerImpl
320
375
 
321
376
  for (const position of update.positions ?? []) {
322
377
  const key = positionKey(position.symbol, position.side);
378
+ if (
379
+ !shouldApplyWatermarkedUpdate(positions.get(key), position, {
380
+ requestStartedAt: options.requestStartedAt,
381
+ source: options.requestStartedAt === undefined ? "stream" : "rest",
382
+ })
383
+ ) {
384
+ continue;
385
+ }
386
+
323
387
  const nextPosition = this.createPosition(
324
388
  accountId,
325
389
  venue,
@@ -333,6 +397,7 @@ export class AccountManagerImpl
333
397
  positions.set(key, nextPosition);
334
398
  }
335
399
 
400
+ latestAppliedAt = Math.max(latestAppliedAt, nextPosition.receivedAt);
336
401
  this.accountBus.publish({
337
402
  type: "position.updated",
338
403
  accountId,
@@ -343,8 +408,15 @@ export class AccountManagerImpl
343
408
  });
344
409
  }
345
410
 
346
- if (update.risk) {
411
+ if (
412
+ update.risk &&
413
+ shouldApplyWatermarkedUpdate(previous.risk, update.risk, {
414
+ requestStartedAt: options.requestStartedAt,
415
+ source: options.requestStartedAt === undefined ? "stream" : "rest",
416
+ })
417
+ ) {
347
418
  risk = this.createRisk(accountId, venue, update.risk, previous.risk);
419
+ latestAppliedAt = Math.max(latestAppliedAt, risk.receivedAt);
348
420
  this.accountBus.publish({
349
421
  type: "risk.updated",
350
422
  accountId,
@@ -354,34 +426,166 @@ export class AccountManagerImpl
354
426
  });
355
427
  }
356
428
 
429
+ if (latestAppliedAt === 0) {
430
+ return;
431
+ }
432
+
357
433
  record.snapshot = {
358
434
  accountId,
359
435
  venue,
360
436
  balances,
361
437
  positions: [...positions.values()],
362
438
  risk,
363
- exchangeTs: update.exchangeTs ?? previous.exchangeTs,
364
- receivedAt: update.receivedAt,
365
- updatedAt: update.receivedAt,
439
+ exchangeTs:
440
+ update.exchangeTs === undefined
441
+ ? previous.exchangeTs
442
+ : update.exchangeTs,
443
+ receivedAt: latestAppliedAt,
444
+ updatedAt: latestAppliedAt,
366
445
  };
367
- record.status = options.preserveStatus
368
- ? {
369
- ...record.status,
370
- activity: "active",
371
- lastReceivedAt: update.receivedAt,
372
- lastReadyAt: record.status.lastReadyAt ?? update.receivedAt,
373
- inactiveSince: undefined,
374
- }
375
- : {
376
- ...record.status,
377
- activity: "active",
378
- ready: true,
379
- runtimeStatus: "healthy",
380
- reason: undefined,
381
- lastReceivedAt: update.receivedAt,
382
- lastReadyAt: update.receivedAt,
383
- inactiveSince: undefined,
384
- };
446
+ record.status = successfulStatus(record.status, {
447
+ preserveStatus: options.preserveStatus,
448
+ lastReceivedAt: latestAppliedAt,
449
+ lastReadyAt: latestAppliedAt,
450
+ });
451
+ this.publishStatus(record);
452
+ }
453
+
454
+ onPrivateAccountReconcile(
455
+ accountId: string,
456
+ venue: Venue,
457
+ snapshot: RawAccountBootstrap,
458
+ options: { requestStartedAt: number; preserveStatus?: boolean },
459
+ ): void {
460
+ const record = this.getOrCreateRecord(accountId, venue);
461
+ if (!record.subscribed) {
462
+ return;
463
+ }
464
+
465
+ const previous =
466
+ record.snapshot ?? this.createEmptySnapshot(accountId, venue);
467
+ const balances = { ...previous.balances };
468
+ const positions = new Map(
469
+ previous.positions.map((position) => [
470
+ positionKey(position.symbol, position.side),
471
+ position,
472
+ ]),
473
+ );
474
+ let risk = previous.risk;
475
+
476
+ const incomingBalanceAssets = new Set<string>();
477
+ for (const balance of snapshot.balances) {
478
+ incomingBalanceAssets.add(balance.asset);
479
+ if (
480
+ !shouldApplyWatermarkedUpdate(balances[balance.asset], balance, {
481
+ requestStartedAt: options.requestStartedAt,
482
+ source: "rest",
483
+ })
484
+ ) {
485
+ continue;
486
+ }
487
+
488
+ const nextBalance = this.createBalance(
489
+ accountId,
490
+ venue,
491
+ balance,
492
+ balances[balance.asset],
493
+ );
494
+ if (isZeroBalance(nextBalance)) {
495
+ delete balances[balance.asset];
496
+ } else {
497
+ balances[balance.asset] = nextBalance;
498
+ }
499
+ }
500
+
501
+ for (const [asset, balance] of Object.entries(balances)) {
502
+ if (
503
+ (!incomingBalanceAssets.has(asset) || isZeroBalance(balance)) &&
504
+ canDeleteMissingFromSnapshot(balance, {
505
+ requestStartedAt: options.requestStartedAt,
506
+ snapshotExchangeTs: snapshot.exchangeTs,
507
+ })
508
+ ) {
509
+ delete balances[asset];
510
+ }
511
+ }
512
+
513
+ const incomingPositionKeys = new Set<string>();
514
+ for (const position of snapshot.positions) {
515
+ const key = positionKey(position.symbol, position.side);
516
+ incomingPositionKeys.add(key);
517
+ if (
518
+ !shouldApplyWatermarkedUpdate(positions.get(key), position, {
519
+ requestStartedAt: options.requestStartedAt,
520
+ source: "rest",
521
+ })
522
+ ) {
523
+ continue;
524
+ }
525
+
526
+ const nextPosition = this.createPosition(
527
+ accountId,
528
+ venue,
529
+ position,
530
+ positions.get(key),
531
+ );
532
+ if (isZeroDecimal(nextPosition.size)) {
533
+ positions.delete(key);
534
+ } else {
535
+ positions.set(key, nextPosition);
536
+ }
537
+ }
538
+
539
+ for (const [key, position] of positions.entries()) {
540
+ if (
541
+ !incomingPositionKeys.has(key) &&
542
+ canDeleteMissingFromSnapshot(position, {
543
+ requestStartedAt: options.requestStartedAt,
544
+ snapshotExchangeTs: snapshot.exchangeTs,
545
+ })
546
+ ) {
547
+ positions.delete(key);
548
+ }
549
+ }
550
+
551
+ if (
552
+ snapshot.risk &&
553
+ shouldApplyWatermarkedUpdate(previous.risk, snapshot.risk, {
554
+ requestStartedAt: options.requestStartedAt,
555
+ source: "rest",
556
+ })
557
+ ) {
558
+ risk = this.createRisk(accountId, venue, snapshot.risk, previous.risk);
559
+ }
560
+
561
+ record.snapshot = {
562
+ accountId,
563
+ venue,
564
+ balances,
565
+ positions: [...positions.values()],
566
+ risk,
567
+ exchangeTs:
568
+ snapshot.exchangeTs === undefined
569
+ ? previous.exchangeTs
570
+ : snapshot.exchangeTs,
571
+ receivedAt: snapshot.receivedAt,
572
+ updatedAt: snapshot.receivedAt,
573
+ };
574
+ record.status = successfulStatus(record.status, {
575
+ preserveStatus: options.preserveStatus,
576
+ lastReceivedAt: snapshot.receivedAt,
577
+ lastReadyAt: snapshot.receivedAt,
578
+ });
579
+
580
+ const event: AccountSnapshotReplacedEvent = {
581
+ type: "account.snapshot_replaced",
582
+ accountId,
583
+ venue,
584
+ snapshot: record.snapshot,
585
+ ts: this.context.now(),
586
+ };
587
+
588
+ this.accountBus.publish(event);
385
589
  this.publishStatus(record);
386
590
  }
387
591