@imbingox/acex 0.1.0-beta.0 → 0.1.0-beta.1

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.
Files changed (64) hide show
  1. package/README.md +12 -54
  2. package/index.ts +1 -0
  3. package/package.json +15 -24
  4. package/src/adapters/binance/adapter.ts +53 -0
  5. package/src/adapters/binance/book-ticker.ts +123 -0
  6. package/src/adapters/binance/market-catalog.ts +251 -0
  7. package/src/adapters/types.ts +43 -0
  8. package/src/client/context.ts +60 -0
  9. package/src/client/create-client.ts +6 -0
  10. package/src/client/runtime.ts +283 -0
  11. package/src/errors.ts +20 -0
  12. package/src/index.ts +4 -0
  13. package/src/internal/async-event-bus.ts +100 -0
  14. package/src/internal/filters.ts +119 -0
  15. package/src/internal/managed-websocket.ts +258 -0
  16. package/src/managers/account-manager.ts +315 -0
  17. package/src/managers/market-manager.ts +642 -0
  18. package/src/managers/order-manager.ts +304 -0
  19. package/src/types/account.ts +160 -0
  20. package/src/types/client.ts +79 -0
  21. package/src/types/index.ts +5 -0
  22. package/src/types/market.ts +136 -0
  23. package/src/types/order.ts +142 -0
  24. package/src/types/shared.ts +78 -0
  25. package/dist/adapters/ccxt/aster-ccxt-adapter.d.ts +0 -157
  26. package/dist/adapters/ccxt/aster-ccxt-adapter.js +0 -272
  27. package/dist/adapters/ccxt/binance-usdm-ccxt-adapter.d.ts +0 -179
  28. package/dist/adapters/ccxt/binance-usdm-ccxt-adapter.js +0 -537
  29. package/dist/adapters/fake/fake-aster-adapter.d.ts +0 -130
  30. package/dist/adapters/fake/fake-aster-adapter.js +0 -283
  31. package/dist/adapters/types.d.ts +0 -210
  32. package/dist/adapters/types.js +0 -1
  33. package/dist/core/client.d.ts +0 -37
  34. package/dist/core/client.js +0 -45
  35. package/dist/core/recovery.d.ts +0 -22
  36. package/dist/core/recovery.js +0 -18
  37. package/dist/core/runtime.d.ts +0 -26
  38. package/dist/core/runtime.js +0 -150
  39. package/dist/errors/acex-error.d.ts +0 -25
  40. package/dist/errors/acex-error.js +0 -54
  41. package/dist/index.d.ts +0 -5
  42. package/dist/index.js +0 -3
  43. package/dist/managers/account-manager.d.ts +0 -41
  44. package/dist/managers/account-manager.js +0 -80
  45. package/dist/managers/market-manager.d.ts +0 -16
  46. package/dist/managers/market-manager.js +0 -28
  47. package/dist/managers/order-manager.d.ts +0 -87
  48. package/dist/managers/order-manager.js +0 -122
  49. package/dist/runtime/async-queue.d.ts +0 -8
  50. package/dist/runtime/async-queue.js +0 -88
  51. package/dist/runtime/request-id.d.ts +0 -1
  52. package/dist/runtime/request-id.js +0 -5
  53. package/dist/store/account-store.d.ts +0 -52
  54. package/dist/store/account-store.js +0 -18
  55. package/dist/store/health-store.d.ts +0 -16
  56. package/dist/store/health-store.js +0 -29
  57. package/dist/store/market-store.d.ts +0 -42
  58. package/dist/store/market-store.js +0 -51
  59. package/dist/store/order-store.d.ts +0 -38
  60. package/dist/store/order-store.js +0 -49
  61. package/dist/testing/create-fake-runtime.d.ts +0 -5
  62. package/dist/testing/create-fake-runtime.js +0 -7
  63. package/dist/types/public.d.ts +0 -11
  64. package/dist/types/public.js +0 -1
@@ -0,0 +1,258 @@
1
+ type TimerHandle = ReturnType<typeof setTimeout>;
2
+
3
+ export type WebSocketFactory = (url: string) => WebSocket;
4
+
5
+ export interface ManagedWebSocketWatchdogOptions {
6
+ staleAfterMs: number;
7
+ onStale(staleAt: number): void;
8
+ }
9
+
10
+ export interface ManagedWebSocketReconnectOptions {
11
+ initialDelayMs: number;
12
+ maxDelayMs: number;
13
+ backoffMultiplier?: number;
14
+ }
15
+
16
+ export interface ManagedWebSocketOptions<TMessage> {
17
+ url: string;
18
+ initialMessageTimeoutMs: number;
19
+ parseMessage(data: string): TMessage | undefined;
20
+ onMessage(message: TMessage, receivedAt: number): void;
21
+ onUnexpectedClose(event: CloseEvent): void;
22
+ onError?(event: Event): void;
23
+ messageWatchdog?: ManagedWebSocketWatchdogOptions;
24
+ reconnect?: ManagedWebSocketReconnectOptions;
25
+ now?: () => number;
26
+ createWebSocket?: WebSocketFactory;
27
+ setTimer?: typeof setTimeout;
28
+ clearTimer?: typeof clearTimeout;
29
+ }
30
+
31
+ export interface ManagedWebSocketSession {
32
+ readonly ready: Promise<void>;
33
+ close(): void;
34
+ }
35
+
36
+ function toError(value: unknown, fallback: string): Error {
37
+ if (value instanceof Error) {
38
+ return value;
39
+ }
40
+
41
+ return new Error(fallback);
42
+ }
43
+
44
+ export function createManagedWebSocket<TMessage>(
45
+ options: ManagedWebSocketOptions<TMessage>,
46
+ ): ManagedWebSocketSession {
47
+ const now = options.now ?? Date.now;
48
+ const setTimer = options.setTimer ?? setTimeout;
49
+ const clearTimer = options.clearTimer ?? clearTimeout;
50
+ const createWebSocket =
51
+ options.createWebSocket ?? ((url: string) => new WebSocket(url));
52
+ const messageWatchdog = options.messageWatchdog;
53
+ const reconnect = options.reconnect;
54
+ const reconnectMultiplier = reconnect?.backoffMultiplier ?? 2;
55
+
56
+ let closed = false;
57
+ let staleNotified = false;
58
+ let hasMessage = false;
59
+ let lastMessageAt = now();
60
+ let initialTimeout: TimerHandle | undefined;
61
+ let staleTimeout: TimerHandle | undefined;
62
+ let reconnectTimeout: TimerHandle | undefined;
63
+ let reconnectAttempts = 0;
64
+ let resolveReady: (() => void) | undefined;
65
+ let rejectReady: ((error: Error) => void) | undefined;
66
+ let activeSocket: WebSocket | undefined;
67
+
68
+ const ready = new Promise<void>((resolve, reject) => {
69
+ resolveReady = resolve;
70
+ rejectReady = reject;
71
+ });
72
+
73
+ const clearTimers = () => {
74
+ if (initialTimeout) {
75
+ clearTimer(initialTimeout);
76
+ initialTimeout = undefined;
77
+ }
78
+
79
+ if (staleTimeout) {
80
+ clearTimer(staleTimeout);
81
+ staleTimeout = undefined;
82
+ }
83
+ };
84
+
85
+ const clearReconnectTimer = () => {
86
+ if (reconnectTimeout) {
87
+ clearTimer(reconnectTimeout);
88
+ reconnectTimeout = undefined;
89
+ }
90
+ };
91
+
92
+ const rejectIfPending = (error: Error) => {
93
+ if (!resolveReady || !rejectReady) {
94
+ return;
95
+ }
96
+
97
+ const reject = rejectReady;
98
+ resolveReady = undefined;
99
+ rejectReady = undefined;
100
+ reject(error);
101
+ };
102
+
103
+ const resolveIfPending = () => {
104
+ if (!resolveReady || !rejectReady) {
105
+ return;
106
+ }
107
+
108
+ const resolve = resolveReady;
109
+ resolveReady = undefined;
110
+ rejectReady = undefined;
111
+ resolve();
112
+ };
113
+
114
+ const scheduleStaleTimeout = () => {
115
+ if (closed || !messageWatchdog) {
116
+ return;
117
+ }
118
+
119
+ if (staleTimeout) {
120
+ clearTimer(staleTimeout);
121
+ }
122
+
123
+ staleTimeout = setTimer(() => {
124
+ if (closed || staleNotified) {
125
+ return;
126
+ }
127
+
128
+ staleNotified = true;
129
+ messageWatchdog.onStale(now());
130
+ }, messageWatchdog.staleAfterMs);
131
+ };
132
+
133
+ const scheduleReconnect = () => {
134
+ if (closed || !reconnect || !hasMessage || reconnectTimeout) {
135
+ return;
136
+ }
137
+
138
+ const delay = Math.min(
139
+ reconnect.initialDelayMs * reconnectMultiplier ** reconnectAttempts,
140
+ reconnect.maxDelayMs,
141
+ );
142
+ reconnectAttempts += 1;
143
+ reconnectTimeout = setTimer(() => {
144
+ reconnectTimeout = undefined;
145
+ connect();
146
+ }, delay);
147
+ };
148
+
149
+ const connect = () => {
150
+ if (closed) {
151
+ return;
152
+ }
153
+
154
+ const socket = createWebSocket(options.url);
155
+ activeSocket = socket;
156
+
157
+ if (!hasMessage) {
158
+ initialTimeout = setTimer(() => {
159
+ if (closed || hasMessage || activeSocket !== socket) {
160
+ return;
161
+ }
162
+
163
+ rejectIfPending(
164
+ new Error("Timed out waiting for the first websocket message"),
165
+ );
166
+ socket.close(1000, "initial message timeout");
167
+ }, options.initialMessageTimeoutMs);
168
+ }
169
+
170
+ if (messageWatchdog) {
171
+ scheduleStaleTimeout();
172
+ }
173
+
174
+ socket.addEventListener("message", (event) => {
175
+ if (closed || activeSocket !== socket || typeof event.data !== "string") {
176
+ return;
177
+ }
178
+
179
+ let parsed: TMessage | undefined;
180
+ try {
181
+ parsed = options.parseMessage(event.data);
182
+ } catch (error) {
183
+ options.onError?.(
184
+ new ErrorEvent("error", {
185
+ error: toError(error, "Failed to parse websocket message"),
186
+ }),
187
+ );
188
+ return;
189
+ }
190
+
191
+ if (!parsed) {
192
+ return;
193
+ }
194
+
195
+ hasMessage = true;
196
+ staleNotified = false;
197
+ lastMessageAt = now();
198
+ reconnectAttempts = 0;
199
+
200
+ if (initialTimeout) {
201
+ clearTimer(initialTimeout);
202
+ initialTimeout = undefined;
203
+ }
204
+
205
+ if (messageWatchdog) {
206
+ scheduleStaleTimeout();
207
+ }
208
+ options.onMessage(parsed, lastMessageAt);
209
+ resolveIfPending();
210
+ });
211
+
212
+ socket.addEventListener("error", (event) => {
213
+ if (closed || activeSocket !== socket) {
214
+ return;
215
+ }
216
+
217
+ options.onError?.(event);
218
+ });
219
+
220
+ socket.addEventListener("close", (event) => {
221
+ if (closed || activeSocket !== socket) {
222
+ return;
223
+ }
224
+
225
+ activeSocket = undefined;
226
+ clearTimers();
227
+
228
+ if (!hasMessage) {
229
+ rejectIfPending(
230
+ toError(
231
+ event.reason || undefined,
232
+ "WebSocket closed before the first market update arrived",
233
+ ),
234
+ );
235
+ }
236
+
237
+ options.onUnexpectedClose(event);
238
+ scheduleReconnect();
239
+ });
240
+ };
241
+
242
+ connect();
243
+
244
+ return {
245
+ ready,
246
+ close() {
247
+ if (closed) {
248
+ return;
249
+ }
250
+
251
+ closed = true;
252
+ clearTimers();
253
+ clearReconnectTimer();
254
+ activeSocket?.close(1000, "manual close");
255
+ activeSocket = undefined;
256
+ },
257
+ };
258
+ }
@@ -0,0 +1,315 @@
1
+ import type {
2
+ AccountAwareManager,
3
+ ClientContext,
4
+ HealthReporter,
5
+ ManagerLifecycle,
6
+ } from "../client/context.ts";
7
+ import { AsyncEventBus } from "../internal/async-event-bus.ts";
8
+ import { matchesAccountFilter } from "../internal/filters.ts";
9
+ import type {
10
+ AccountDataStatus,
11
+ AccountEvent,
12
+ AccountEventStreams,
13
+ AccountManager,
14
+ AccountSnapshot,
15
+ AccountSnapshotReplacedEvent,
16
+ AccountStatusChangedEvent,
17
+ BalanceSnapshot,
18
+ Exchange,
19
+ PositionKeyInput,
20
+ PositionSnapshot,
21
+ RiskSnapshot,
22
+ SubscribeAccountInput,
23
+ UnsubscribeAccountInput,
24
+ } from "../types/index.ts";
25
+
26
+ interface AccountRecord {
27
+ accountId: string;
28
+ exchange: Exchange;
29
+ subscribed: boolean;
30
+ snapshot?: AccountSnapshot;
31
+ status: AccountDataStatus;
32
+ }
33
+
34
+ function cloneAccountStatus(status: AccountDataStatus): AccountDataStatus {
35
+ return { ...status };
36
+ }
37
+
38
+ export class AccountManagerImpl
39
+ implements
40
+ AccountManager,
41
+ ManagerLifecycle,
42
+ AccountAwareManager,
43
+ HealthReporter<AccountDataStatus>
44
+ {
45
+ readonly events: AccountEventStreams;
46
+
47
+ private readonly context: ClientContext;
48
+ private readonly accountBus = new AsyncEventBus<AccountEvent>();
49
+ private readonly accountStatusBus =
50
+ new AsyncEventBus<AccountStatusChangedEvent>();
51
+ private readonly records = new Map<string, AccountRecord>();
52
+
53
+ constructor(context: ClientContext) {
54
+ this.context = context;
55
+
56
+ this.events = {
57
+ status: (filter) =>
58
+ this.accountStatusBus.stream((event) =>
59
+ matchesAccountFilter(
60
+ { accountId: event.accountId, exchange: event.exchange },
61
+ filter,
62
+ ),
63
+ ),
64
+ updates: (filter) =>
65
+ this.accountBus.stream((event) =>
66
+ matchesAccountFilter(
67
+ {
68
+ accountId: event.accountId,
69
+ exchange: event.exchange,
70
+ symbol: "symbol" in event ? event.symbol : undefined,
71
+ },
72
+ filter,
73
+ ),
74
+ ),
75
+ };
76
+ }
77
+
78
+ // --- AccountManager public API ---
79
+
80
+ async subscribeAccount(input: SubscribeAccountInput): Promise<void> {
81
+ this.context.assertStarted();
82
+ const account = this.context.getRegisteredAccount(input.accountId);
83
+ this.context.ensurePrivateCredentials(input.accountId);
84
+
85
+ const record = this.getOrCreateRecord(input.accountId, account.exchange);
86
+ record.subscribed = true;
87
+ record.snapshot ??= this.createEmptySnapshot(
88
+ input.accountId,
89
+ account.exchange,
90
+ );
91
+ record.status = {
92
+ ...this.createStatus(input.accountId, account.exchange, "active"),
93
+ ready: true,
94
+ runtimeStatus: "healthy",
95
+ lastReceivedAt: record.snapshot.updatedAt,
96
+ lastReadyAt: record.snapshot.updatedAt,
97
+ };
98
+
99
+ const event: AccountSnapshotReplacedEvent = {
100
+ type: "account.snapshot_replaced",
101
+ accountId: record.accountId,
102
+ exchange: record.exchange,
103
+ snapshot: record.snapshot,
104
+ ts: this.context.now(),
105
+ };
106
+
107
+ this.accountBus.publish(event);
108
+ this.publishStatus(record);
109
+ }
110
+
111
+ async unsubscribeAccount(input: UnsubscribeAccountInput): Promise<void> {
112
+ const record = this.records.get(input.accountId);
113
+ if (!record?.subscribed) {
114
+ return;
115
+ }
116
+
117
+ record.subscribed = false;
118
+ record.status = {
119
+ ...record.status,
120
+ activity: "inactive",
121
+ runtimeStatus: "stopped",
122
+ inactiveSince: this.context.now(),
123
+ };
124
+ this.publishStatus(record);
125
+ }
126
+
127
+ getAccountSnapshot(accountId: string): AccountSnapshot | undefined {
128
+ return this.records.get(accountId)?.snapshot;
129
+ }
130
+
131
+ getBalance(accountId: string, asset: string): BalanceSnapshot | undefined {
132
+ return this.records.get(accountId)?.snapshot?.balances[asset];
133
+ }
134
+
135
+ getBalances(accountId: string): BalanceSnapshot[] {
136
+ const balances = this.records.get(accountId)?.snapshot?.balances;
137
+ return balances ? Object.values(balances) : [];
138
+ }
139
+
140
+ getPosition(input: PositionKeyInput): PositionSnapshot | undefined {
141
+ return this.getPositions(input.accountId, input.symbol).find(
142
+ (position) => input.side === undefined || position.side === input.side,
143
+ );
144
+ }
145
+
146
+ getPositions(accountId: string, symbol?: string): PositionSnapshot[] {
147
+ const positions = this.records.get(accountId)?.snapshot?.positions ?? [];
148
+ if (!symbol) {
149
+ return [...positions];
150
+ }
151
+ return positions.filter((position) => position.symbol === symbol);
152
+ }
153
+
154
+ getRiskSnapshot(accountId: string): RiskSnapshot | undefined {
155
+ return this.records.get(accountId)?.snapshot?.risk;
156
+ }
157
+
158
+ getAccountStatus(accountId: string): AccountDataStatus | undefined {
159
+ const status = this.records.get(accountId)?.status;
160
+ return status ? cloneAccountStatus(status) : undefined;
161
+ }
162
+
163
+ // --- ManagerLifecycle ---
164
+
165
+ onClientStarted(): void {
166
+ const now = this.context.now();
167
+
168
+ for (const [accountId, record] of this.records) {
169
+ if (!record.subscribed) {
170
+ continue;
171
+ }
172
+
173
+ const account = this.context.getRegisteredAccount(accountId);
174
+ const creds = account.credentials;
175
+ if (!creds?.apiKey || !creds.secret) {
176
+ continue;
177
+ }
178
+
179
+ record.snapshot ??= this.createEmptySnapshot(accountId, account.exchange);
180
+ record.status = {
181
+ ...this.createStatus(accountId, account.exchange, "active"),
182
+ ready: true,
183
+ runtimeStatus: "healthy",
184
+ lastReceivedAt: now,
185
+ lastReadyAt: record.snapshot.updatedAt,
186
+ };
187
+ this.publishStatus(record);
188
+ }
189
+ }
190
+
191
+ onClientStopping(now: number): void {
192
+ for (const record of this.records.values()) {
193
+ if (!record.subscribed) {
194
+ continue;
195
+ }
196
+
197
+ record.status = {
198
+ ...record.status,
199
+ activity: "inactive",
200
+ runtimeStatus: "stopped",
201
+ inactiveSince: now,
202
+ };
203
+ this.publishStatus(record);
204
+ }
205
+ }
206
+
207
+ // --- AccountAwareManager ---
208
+
209
+ onAccountRemoved(accountId: string, now: number): void {
210
+ const record = this.records.get(accountId);
211
+ if (!record) {
212
+ return;
213
+ }
214
+
215
+ record.subscribed = false;
216
+ record.status = {
217
+ ...record.status,
218
+ activity: "inactive",
219
+ runtimeStatus: "stopped",
220
+ inactiveSince: now,
221
+ };
222
+ this.publishStatus(record);
223
+ this.records.delete(accountId);
224
+ }
225
+
226
+ onCredentialsUpdated(accountId: string, exchange: Exchange): void {
227
+ const record = this.records.get(accountId);
228
+ if (!record?.subscribed) {
229
+ return;
230
+ }
231
+
232
+ record.status = this.createStatus(accountId, exchange, "active");
233
+ record.status.ready = Boolean(record.snapshot);
234
+ record.status.runtimeStatus = "healthy";
235
+ record.status.lastReadyAt =
236
+ record.snapshot?.updatedAt ?? this.context.now();
237
+ this.publishStatus(record);
238
+ }
239
+
240
+ // --- HealthReporter ---
241
+
242
+ getStatuses(): AccountDataStatus[] {
243
+ return [...this.records.values()]
244
+ .map((record) => cloneAccountStatus(record.status))
245
+ .sort((left, right) =>
246
+ `${left.exchange}:${left.accountId}`.localeCompare(
247
+ `${right.exchange}:${right.accountId}`,
248
+ ),
249
+ );
250
+ }
251
+
252
+ // --- Internal helpers ---
253
+
254
+ private getOrCreateRecord(
255
+ accountId: string,
256
+ exchange: Exchange,
257
+ ): AccountRecord {
258
+ const existing = this.records.get(accountId);
259
+ if (existing) {
260
+ return existing;
261
+ }
262
+
263
+ const record: AccountRecord = {
264
+ accountId,
265
+ exchange,
266
+ subscribed: false,
267
+ status: this.createStatus(accountId, exchange, "inactive"),
268
+ };
269
+
270
+ this.records.set(accountId, record);
271
+ return record;
272
+ }
273
+
274
+ private createStatus(
275
+ accountId: string,
276
+ exchange: Exchange,
277
+ activity: "active" | "inactive",
278
+ ): AccountDataStatus {
279
+ return {
280
+ accountId,
281
+ exchange,
282
+ activity,
283
+ ready: false,
284
+ runtimeStatus: activity === "active" ? "bootstrap_pending" : "stopped",
285
+ };
286
+ }
287
+
288
+ private createEmptySnapshot(
289
+ accountId: string,
290
+ exchange: Exchange,
291
+ ): AccountSnapshot {
292
+ const now = this.context.now();
293
+ return {
294
+ accountId,
295
+ exchange,
296
+ balances: {},
297
+ positions: [],
298
+ receivedAt: now,
299
+ updatedAt: now,
300
+ };
301
+ }
302
+
303
+ private publishStatus(record: AccountRecord): void {
304
+ const event: AccountStatusChangedEvent = {
305
+ type: "account.status_changed",
306
+ accountId: record.accountId,
307
+ exchange: record.exchange,
308
+ status: cloneAccountStatus(record.status),
309
+ ts: this.context.now(),
310
+ };
311
+
312
+ this.accountStatusBus.publish(event);
313
+ this.context.publishHealthEvent(event);
314
+ }
315
+ }