tradelab 0.4.0 → 1.0.0

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 (54) hide show
  1. package/README.md +121 -52
  2. package/bin/tradelab.js +340 -49
  3. package/dist/cjs/data.cjs +210 -155
  4. package/dist/cjs/index.cjs +1782 -274
  5. package/dist/cjs/live.cjs +3350 -0
  6. package/docs/README.md +26 -9
  7. package/docs/api-reference.md +89 -26
  8. package/docs/backtest-engine.md +74 -60
  9. package/docs/data-reporting-cli.md +66 -36
  10. package/docs/examples.md +275 -0
  11. package/docs/live-trading.md +186 -0
  12. package/examples/yahooEmaCross.js +1 -6
  13. package/package.json +18 -3
  14. package/src/data/csv.js +24 -14
  15. package/src/data/index.js +1 -5
  16. package/src/data/yahoo.js +6 -19
  17. package/src/engine/backtest.js +137 -144
  18. package/src/engine/backtestTicks.js +481 -0
  19. package/src/engine/barSystemRunner.js +1027 -0
  20. package/src/engine/execution.js +11 -39
  21. package/src/engine/portfolio.js +237 -66
  22. package/src/engine/walkForward.js +132 -13
  23. package/src/index.js +3 -11
  24. package/src/live/broker/alpaca.js +254 -0
  25. package/src/live/broker/binance.js +351 -0
  26. package/src/live/broker/coinbase.js +339 -0
  27. package/src/live/broker/interactiveBrokers.js +123 -0
  28. package/src/live/broker/interface.js +74 -0
  29. package/src/live/clock.js +56 -0
  30. package/src/live/engine/candleAggregator.js +154 -0
  31. package/src/live/engine/liveEngine.js +694 -0
  32. package/src/live/engine/paperEngine.js +453 -0
  33. package/src/live/engine/riskManager.js +185 -0
  34. package/src/live/engine/stateManager.js +112 -0
  35. package/src/live/events.js +48 -0
  36. package/src/live/feed/brokerFeed.js +35 -0
  37. package/src/live/feed/interface.js +28 -0
  38. package/src/live/feed/pollingFeed.js +105 -0
  39. package/src/live/index.js +27 -0
  40. package/src/live/logger.js +82 -0
  41. package/src/live/orchestrator.js +133 -0
  42. package/src/live/storage/interface.js +36 -0
  43. package/src/live/storage/jsonFileStorage.js +112 -0
  44. package/src/metrics/buildMetrics.js +103 -100
  45. package/src/reporting/exportBacktestArtifacts.js +1 -4
  46. package/src/reporting/exportTradesCsv.js +2 -7
  47. package/src/reporting/renderHtmlReport.js +8 -13
  48. package/src/utils/indicators.js +1 -2
  49. package/src/utils/positionSizing.js +16 -2
  50. package/src/utils/time.js +4 -12
  51. package/templates/report.html +23 -9
  52. package/templates/report.js +83 -69
  53. package/types/index.d.ts +98 -4
  54. package/types/live.d.ts +382 -0
@@ -0,0 +1,351 @@
1
+ import crypto from "node:crypto";
2
+ import { URL } from "node:url";
3
+
4
+ import { normalizeCandles } from "../../data/csv.js";
5
+ import { BrokerAdapter } from "./interface.js";
6
+
7
+ function queryString(params = {}) {
8
+ const parts = [];
9
+ for (const [key, value] of Object.entries(params)) {
10
+ if (value === undefined || value === null) continue;
11
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
12
+ }
13
+ return parts.join("&");
14
+ }
15
+
16
+ function mapOrderStatus(status) {
17
+ const normalized = String(status || "").toUpperCase();
18
+ if (normalized === "PARTIALLY_FILLED") return "partially_filled";
19
+ if (normalized === "FILLED") return "filled";
20
+ if (normalized === "CANCELED" || normalized === "CANCELLED") return "canceled";
21
+ if (normalized === "REJECTED") return "rejected";
22
+ if (normalized === "EXPIRED" || normalized === "EXPIRED_IN_MATCH") return "expired";
23
+ return "new";
24
+ }
25
+
26
+ /**
27
+ * Binance spot/futures adapter.
28
+ */
29
+ export class BinanceBroker extends BrokerAdapter {
30
+ constructor({ fetchImpl = globalThis.fetch } = {}) {
31
+ super();
32
+ this.fetch = fetchImpl;
33
+ this.connected = false;
34
+ this.config = {};
35
+ this.subscriptions = { bars: new Map(), trades: new Map(), quotes: new Map() };
36
+ }
37
+
38
+ async connect(config = {}) {
39
+ this.config = { ...config };
40
+ const useFutures = Boolean(config.futures);
41
+ if (config.baseUrl) {
42
+ this.baseUrl = config.baseUrl;
43
+ } else if (config.paper && useFutures) {
44
+ this.baseUrl = "https://testnet.binancefuture.com";
45
+ } else if (config.paper) {
46
+ this.baseUrl = "https://testnet.binance.vision";
47
+ } else if (useFutures) {
48
+ this.baseUrl = "https://fapi.binance.com";
49
+ } else {
50
+ this.baseUrl = "https://api.binance.com";
51
+ }
52
+ this.connected = true;
53
+ }
54
+
55
+ async disconnect() {
56
+ this.connected = false;
57
+ this.subscriptions.bars.clear();
58
+ this.subscriptions.trades.clear();
59
+ this.subscriptions.quotes.clear();
60
+ }
61
+
62
+ isConnected() {
63
+ return this.connected;
64
+ }
65
+
66
+ supportsPaperNative() {
67
+ return true;
68
+ }
69
+
70
+ _signedParams(params = {}) {
71
+ const base = {
72
+ ...params,
73
+ timestamp: Date.now(),
74
+ };
75
+ const payload = queryString(base);
76
+ const signature = crypto
77
+ .createHmac("sha256", this.config.apiSecret || "")
78
+ .update(payload)
79
+ .digest("hex");
80
+ return { ...base, signature };
81
+ }
82
+
83
+ async _request(method, path, { signed = false, params = {}, body = null } = {}) {
84
+ if (!this.fetch) throw new Error("global fetch is unavailable");
85
+ const finalParams = signed ? this._signedParams(params) : params;
86
+ const qs = queryString(finalParams);
87
+ const url = new URL(`${this.baseUrl}${path}${qs ? `?${qs}` : ""}`);
88
+ const headers = {
89
+ "content-type": "application/json",
90
+ };
91
+ if (this.config.apiKey) headers["X-MBX-APIKEY"] = this.config.apiKey;
92
+ const response = await this.fetch(url, {
93
+ method,
94
+ headers,
95
+ body: body ? JSON.stringify(body) : undefined,
96
+ });
97
+ const text = await response.text();
98
+ const payload = text ? JSON.parse(text) : {};
99
+ if (!response.ok) {
100
+ const message =
101
+ payload?.msg || payload?.message || `binance request failed (${response.status})`;
102
+ throw new Error(message);
103
+ }
104
+ return payload;
105
+ }
106
+
107
+ async getServerTime() {
108
+ const path = this.config.futures ? "/fapi/v1/time" : "/api/v3/time";
109
+ const data = await this._request("GET", path);
110
+ return Number(data.serverTime || Date.now());
111
+ }
112
+
113
+ async getAccount() {
114
+ if (this.config.futures) {
115
+ const account = await this._request("GET", "/fapi/v2/account", { signed: true });
116
+ return {
117
+ equity: Number(account.totalWalletBalance || 0),
118
+ buyingPower: Number(account.availableBalance || 0),
119
+ cash: Number(account.availableBalance || 0),
120
+ currency: "USDT",
121
+ marginUsed: Number(account.totalPositionInitialMargin || 0),
122
+ };
123
+ }
124
+
125
+ const account = await this._request("GET", "/api/v3/account", { signed: true });
126
+ const free = Number(
127
+ (account.balances || []).reduce((sum, item) => sum + Number(item.free || 0), 0)
128
+ );
129
+ return {
130
+ equity: free,
131
+ buyingPower: free,
132
+ cash: free,
133
+ currency: "USDT",
134
+ marginUsed: 0,
135
+ };
136
+ }
137
+
138
+ async getPositions() {
139
+ if (this.config.futures) {
140
+ const rows = await this._request("GET", "/fapi/v2/positionRisk", { signed: true });
141
+ return rows
142
+ .map((row) => ({
143
+ symbol: row.symbol,
144
+ qty: Math.abs(Number(row.positionAmt || 0)),
145
+ side: Number(row.positionAmt || 0) >= 0 ? "long" : "short",
146
+ avgEntry: Number(row.entryPrice || 0),
147
+ marketValue: Math.abs(Number(row.positionAmt || 0) * Number(row.markPrice || 0)),
148
+ unrealizedPnl: Number(row.unRealizedProfit || 0),
149
+ }))
150
+ .filter((row) => row.qty > 0);
151
+ }
152
+
153
+ const account = await this._request("GET", "/api/v3/account", { signed: true });
154
+ return (account.balances || [])
155
+ .map((asset) => ({
156
+ symbol: `${asset.asset}USDT`,
157
+ side: "long",
158
+ qty: Number(asset.free || 0),
159
+ avgEntry: 0,
160
+ marketValue: Number(asset.free || 0),
161
+ unrealizedPnl: 0,
162
+ }))
163
+ .filter((position) => position.qty > 0);
164
+ }
165
+
166
+ _orderPayload(order) {
167
+ const payload = {
168
+ symbol: order.symbol,
169
+ side: String(order.side || "").toUpperCase(),
170
+ quantity: String(order.qty),
171
+ type:
172
+ order.type === "stop_limit"
173
+ ? "STOP_LOSS_LIMIT"
174
+ : String(order.type || "market").toUpperCase(),
175
+ timeInForce: String(order.timeInForce || "GTC").toUpperCase(),
176
+ newClientOrderId: order.clientOrderId,
177
+ };
178
+ if (order.limitPrice !== undefined) payload.price = String(order.limitPrice);
179
+ if (order.stopPrice !== undefined) payload.stopPrice = String(order.stopPrice);
180
+ if (payload.type === "MARKET") delete payload.timeInForce;
181
+ return payload;
182
+ }
183
+
184
+ async submitOrder(order) {
185
+ const path = this.config.futures ? "/fapi/v1/order" : "/api/v3/order";
186
+ const response = await this._request("POST", path, {
187
+ signed: true,
188
+ params: this._orderPayload(order),
189
+ });
190
+ const receipt = {
191
+ orderId: String(response.orderId),
192
+ clientOrderId: response.clientOrderId,
193
+ status: mapOrderStatus(response.status),
194
+ filledQty: Number(response.executedQty || 0),
195
+ avgFillPrice: Number.isFinite(Number(response.avgPrice))
196
+ ? Number(response.avgPrice)
197
+ : undefined,
198
+ filledAt: response.transactTime ? Number(response.transactTime) : undefined,
199
+ symbol: response.symbol,
200
+ side: String(response.side || "").toLowerCase(),
201
+ type: String(response.type || "").toLowerCase(),
202
+ qty: Number(response.origQty || 0),
203
+ rejectReason: response.rejectReason,
204
+ };
205
+ this.emit("order:submitted", receipt);
206
+ return receipt;
207
+ }
208
+
209
+ async cancelOrder(orderId) {
210
+ const path = this.config.futures ? "/fapi/v1/order" : "/api/v3/order";
211
+ await this._request("DELETE", path, {
212
+ signed: true,
213
+ params: {
214
+ orderId,
215
+ },
216
+ });
217
+ this.emit("order:canceled", { orderId: String(orderId) });
218
+ }
219
+
220
+ async modifyOrder(orderId, changes = {}) {
221
+ const path = this.config.futures ? "/fapi/v1/order" : "/api/v3/order";
222
+ const response = await this._request("PUT", path, {
223
+ signed: true,
224
+ params: {
225
+ orderId,
226
+ quantity: changes.qty,
227
+ price: changes.limitPrice,
228
+ stopPrice: changes.stopPrice,
229
+ },
230
+ });
231
+ const receipt = {
232
+ orderId: String(response.orderId),
233
+ clientOrderId: response.clientOrderId,
234
+ status: mapOrderStatus(response.status),
235
+ filledQty: Number(response.executedQty || 0),
236
+ avgFillPrice: Number(response.avgPrice || 0) || undefined,
237
+ filledAt: response.updateTime ? Number(response.updateTime) : undefined,
238
+ symbol: response.symbol,
239
+ side: String(response.side || "").toLowerCase(),
240
+ type: String(response.type || "").toLowerCase(),
241
+ qty: Number(response.origQty || 0),
242
+ };
243
+ this.emit("order:modified", receipt);
244
+ return receipt;
245
+ }
246
+
247
+ async getOpenOrders() {
248
+ const path = this.config.futures ? "/fapi/v1/openOrders" : "/api/v3/openOrders";
249
+ const rows = await this._request("GET", path, { signed: true });
250
+ return rows.map((row) => ({
251
+ orderId: String(row.orderId),
252
+ clientOrderId: row.clientOrderId,
253
+ status: mapOrderStatus(row.status),
254
+ filledQty: Number(row.executedQty || 0),
255
+ avgFillPrice: Number(row.avgPrice || 0) || undefined,
256
+ filledAt: row.updateTime ? Number(row.updateTime) : undefined,
257
+ symbol: row.symbol,
258
+ side: String(row.side || "").toLowerCase(),
259
+ type: String(row.type || "").toLowerCase(),
260
+ qty: Number(row.origQty || 0),
261
+ rejectReason: row.rejectReason,
262
+ }));
263
+ }
264
+
265
+ async getOrderStatus(orderId) {
266
+ const path = this.config.futures ? "/fapi/v1/order" : "/api/v3/order";
267
+ const row = await this._request("GET", path, {
268
+ signed: true,
269
+ params: { orderId },
270
+ });
271
+ return {
272
+ orderId: String(row.orderId),
273
+ clientOrderId: row.clientOrderId,
274
+ status: mapOrderStatus(row.status),
275
+ filledQty: Number(row.executedQty || 0),
276
+ avgFillPrice: Number(row.avgPrice || 0) || undefined,
277
+ filledAt: row.updateTime ? Number(row.updateTime) : undefined,
278
+ symbol: row.symbol,
279
+ side: String(row.side || "").toLowerCase(),
280
+ type: String(row.type || "").toLowerCase(),
281
+ qty: Number(row.origQty || 0),
282
+ rejectReason: row.rejectReason,
283
+ };
284
+ }
285
+
286
+ async subscribeQuotes(symbol, handler) {
287
+ const list = this.subscriptions.quotes.get(symbol) || [];
288
+ list.push(handler);
289
+ this.subscriptions.quotes.set(symbol, list);
290
+ return {
291
+ unsubscribe: () => {
292
+ const current = this.subscriptions.quotes.get(symbol) || [];
293
+ this.subscriptions.quotes.set(
294
+ symbol,
295
+ current.filter((candidate) => candidate !== handler)
296
+ );
297
+ },
298
+ };
299
+ }
300
+
301
+ async subscribeTrades(symbol, handler) {
302
+ const list = this.subscriptions.trades.get(symbol) || [];
303
+ list.push(handler);
304
+ this.subscriptions.trades.set(symbol, list);
305
+ return {
306
+ unsubscribe: () => {
307
+ const current = this.subscriptions.trades.get(symbol) || [];
308
+ this.subscriptions.trades.set(
309
+ symbol,
310
+ current.filter((candidate) => candidate !== handler)
311
+ );
312
+ },
313
+ };
314
+ }
315
+
316
+ async subscribeBars(symbol, interval, handler) {
317
+ const key = `${symbol}::${interval}`;
318
+ const list = this.subscriptions.bars.get(key) || [];
319
+ list.push(handler);
320
+ this.subscriptions.bars.set(key, list);
321
+ return {
322
+ unsubscribe: () => {
323
+ const current = this.subscriptions.bars.get(key) || [];
324
+ this.subscriptions.bars.set(
325
+ key,
326
+ current.filter((candidate) => candidate !== handler)
327
+ );
328
+ },
329
+ };
330
+ }
331
+
332
+ async getHistoricalBars(symbol, interval, limit = 200) {
333
+ const path = this.config.futures ? "/fapi/v1/klines" : "/api/v3/klines";
334
+ const rows = await this._request("GET", path, {
335
+ params: { symbol, interval, limit },
336
+ });
337
+ const bars = rows.map((row) => ({
338
+ time: Number(row[0]),
339
+ open: Number(row[1]),
340
+ high: Number(row[2]),
341
+ low: Number(row[3]),
342
+ close: Number(row[4]),
343
+ volume: Number(row[5] || 0),
344
+ }));
345
+ return normalizeCandles(bars);
346
+ }
347
+ }
348
+
349
+ export function createBinanceBroker(options) {
350
+ return new BinanceBroker(options);
351
+ }
@@ -0,0 +1,339 @@
1
+ import crypto from "node:crypto";
2
+ import { URL } from "node:url";
3
+
4
+ import { normalizeCandles } from "../../data/csv.js";
5
+ import { BrokerAdapter } from "./interface.js";
6
+
7
+ function base64url(input) {
8
+ return Buffer.from(input).toString("base64url");
9
+ }
10
+
11
+ function buildJwt({ key, secret, method, host, path }) {
12
+ const now = Math.floor(Date.now() / 1000);
13
+ const header = { alg: "HS256", typ: "JWT", kid: key };
14
+ const payload = {
15
+ iss: "cdp",
16
+ sub: key,
17
+ nbf: now - 5,
18
+ exp: now + 120,
19
+ uri: `${method.toUpperCase()} ${host}${path}`,
20
+ };
21
+ const encodedHeader = base64url(JSON.stringify(header));
22
+ const encodedPayload = base64url(JSON.stringify(payload));
23
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
24
+ const signature = crypto.createHmac("sha256", secret).update(signingInput).digest("base64url");
25
+ return `${signingInput}.${signature}`;
26
+ }
27
+
28
+ function mapOrderStatus(status) {
29
+ const normalized = String(status || "").toUpperCase();
30
+ if (normalized.includes("PARTIALLY")) return "partially_filled";
31
+ if (normalized.includes("FILLED")) return "filled";
32
+ if (normalized.includes("CANCEL")) return "canceled";
33
+ if (normalized.includes("REJECT")) return "rejected";
34
+ if (normalized.includes("EXPIRE")) return "expired";
35
+ return "new";
36
+ }
37
+
38
+ function productToSymbol(productId) {
39
+ return String(productId || "").replace("-", "");
40
+ }
41
+
42
+ /**
43
+ * Coinbase Advanced Trade adapter.
44
+ */
45
+ export class CoinbaseBroker extends BrokerAdapter {
46
+ constructor({ fetchImpl = globalThis.fetch } = {}) {
47
+ super();
48
+ this.fetch = fetchImpl;
49
+ this.connected = false;
50
+ this.config = {};
51
+ this.baseUrl = "https://api.coinbase.com/api/v3/brokerage";
52
+ this.subscriptions = { bars: new Map(), trades: new Map(), quotes: new Map() };
53
+ }
54
+
55
+ async connect(config = {}) {
56
+ this.config = { ...config };
57
+ if (config.baseUrl) this.baseUrl = config.baseUrl;
58
+ this.connected = true;
59
+ }
60
+
61
+ async disconnect() {
62
+ this.connected = false;
63
+ this.subscriptions.bars.clear();
64
+ this.subscriptions.trades.clear();
65
+ this.subscriptions.quotes.clear();
66
+ }
67
+
68
+ isConnected() {
69
+ return this.connected;
70
+ }
71
+
72
+ supportsPaperNative() {
73
+ return false;
74
+ }
75
+
76
+ async getServerTime() {
77
+ return Date.now();
78
+ }
79
+
80
+ _authHeader(method, url) {
81
+ const target = new URL(url);
82
+ return buildJwt({
83
+ key: this.config.apiKey || "",
84
+ secret: this.config.apiSecret || "",
85
+ method,
86
+ host: target.host,
87
+ path: target.pathname,
88
+ });
89
+ }
90
+
91
+ async _request(method, path, { query = {}, body = null } = {}) {
92
+ if (!this.fetch) throw new Error("global fetch is unavailable");
93
+ const url = new URL(`${this.baseUrl}${path}`);
94
+ for (const [key, value] of Object.entries(query || {})) {
95
+ if (value === undefined || value === null) continue;
96
+ url.searchParams.set(key, String(value));
97
+ }
98
+ const response = await this.fetch(url, {
99
+ method,
100
+ headers: {
101
+ "content-type": "application/json",
102
+ Authorization: `Bearer ${this._authHeader(method, url)}`,
103
+ },
104
+ body: body ? JSON.stringify(body) : undefined,
105
+ });
106
+ const text = await response.text();
107
+ const payload = text ? JSON.parse(text) : {};
108
+ if (!response.ok) {
109
+ const message =
110
+ payload?.error_response?.message ||
111
+ payload?.message ||
112
+ `coinbase request failed (${response.status})`;
113
+ throw new Error(message);
114
+ }
115
+ return payload;
116
+ }
117
+
118
+ async getAccount() {
119
+ const payload = await this._request("GET", "/accounts");
120
+ const accounts = payload.accounts || [];
121
+ const balances = accounts.map((entry) => Number(entry.available_balance?.value || 0));
122
+ const equity = balances.reduce((sum, value) => sum + value, 0);
123
+ return {
124
+ equity,
125
+ buyingPower: equity,
126
+ cash: equity,
127
+ currency: "USD",
128
+ marginUsed: 0,
129
+ };
130
+ }
131
+
132
+ async getPositions() {
133
+ const payload = await this._request("GET", "/accounts");
134
+ const accounts = payload.accounts || [];
135
+ return accounts
136
+ .map((entry) => {
137
+ const qty = Number(entry.available_balance?.value || 0);
138
+ return {
139
+ symbol: productToSymbol(entry.currency),
140
+ side: "long",
141
+ qty,
142
+ avgEntry: 0,
143
+ marketValue: qty,
144
+ unrealizedPnl: 0,
145
+ };
146
+ })
147
+ .filter((position) => position.qty > 0);
148
+ }
149
+
150
+ async submitOrder(order) {
151
+ const orderType = String(order.type || "market").toLowerCase();
152
+ const payload = {
153
+ client_order_id: order.clientOrderId || crypto.randomUUID(),
154
+ product_id: order.symbol,
155
+ side: String(order.side || "buy").toUpperCase(),
156
+ order_configuration: {},
157
+ };
158
+ if (orderType === "market") {
159
+ payload.order_configuration.market_market_ioc = {
160
+ base_size: String(order.qty),
161
+ };
162
+ } else if (orderType === "limit") {
163
+ payload.order_configuration.limit_limit_gtc = {
164
+ base_size: String(order.qty),
165
+ limit_price: String(order.limitPrice),
166
+ };
167
+ } else {
168
+ payload.order_configuration.stop_limit_stop_limit_gtc = {
169
+ base_size: String(order.qty),
170
+ stop_price: String(order.stopPrice),
171
+ limit_price: String(order.limitPrice ?? order.stopPrice),
172
+ };
173
+ }
174
+
175
+ const response = await this._request("POST", "/orders", { body: payload });
176
+ const result = response.success_response || response.order || {};
177
+ const receipt = {
178
+ orderId: String(result.order_id || response.order_id || payload.client_order_id),
179
+ clientOrderId: payload.client_order_id,
180
+ status: mapOrderStatus(result.status || "PENDING"),
181
+ filledQty: Number(result.filled_size || 0),
182
+ avgFillPrice: Number(result.average_filled_price || 0) || undefined,
183
+ filledAt: result.last_fill_time ? Date.parse(result.last_fill_time) : undefined,
184
+ symbol: order.symbol,
185
+ side: String(order.side || "buy").toLowerCase(),
186
+ type: orderType,
187
+ qty: Number(order.qty || 0),
188
+ rejectReason: result.reject_reason,
189
+ };
190
+ this.emit("order:submitted", receipt);
191
+ return receipt;
192
+ }
193
+
194
+ async cancelOrder(orderId) {
195
+ await this._request("POST", "/orders/batch_cancel", { body: { order_ids: [String(orderId)] } });
196
+ this.emit("order:canceled", { orderId: String(orderId) });
197
+ }
198
+
199
+ async modifyOrder(orderId, changes = {}) {
200
+ const response = await this._request("POST", "/orders/edit", {
201
+ body: {
202
+ order_id: String(orderId),
203
+ size: changes.qty ? String(changes.qty) : undefined,
204
+ limit_price: changes.limitPrice ? String(changes.limitPrice) : undefined,
205
+ stop_price: changes.stopPrice ? String(changes.stopPrice) : undefined,
206
+ },
207
+ });
208
+ const result = response.success_response || {};
209
+ const receipt = {
210
+ orderId: String(result.order_id || orderId),
211
+ clientOrderId: result.client_order_id,
212
+ status: mapOrderStatus(result.status || "PENDING"),
213
+ filledQty: Number(result.filled_size || 0),
214
+ avgFillPrice: Number(result.average_filled_price || 0) || undefined,
215
+ filledAt: result.last_fill_time ? Date.parse(result.last_fill_time) : undefined,
216
+ symbol: result.product_id || "",
217
+ side: String(result.side || "").toLowerCase(),
218
+ type: String(result.order_type || "").toLowerCase(),
219
+ qty: Number(result.base_size || 0),
220
+ rejectReason: result.reject_reason,
221
+ };
222
+ this.emit("order:modified", receipt);
223
+ return receipt;
224
+ }
225
+
226
+ async getOpenOrders() {
227
+ const response = await this._request("GET", "/orders/historical/batch", {
228
+ query: { order_status: "OPEN" },
229
+ });
230
+ const orders = response.orders || [];
231
+ return orders.map((order) => ({
232
+ orderId: String(order.order_id),
233
+ clientOrderId: order.client_order_id,
234
+ status: mapOrderStatus(order.status),
235
+ filledQty: Number(order.filled_size || 0),
236
+ avgFillPrice: Number(order.average_filled_price || 0) || undefined,
237
+ filledAt: order.last_fill_time ? Date.parse(order.last_fill_time) : undefined,
238
+ symbol: order.product_id,
239
+ side: String(order.side || "").toLowerCase(),
240
+ type: String(order.order_type || "").toLowerCase(),
241
+ qty: Number(order.base_size || 0),
242
+ rejectReason: order.reject_reason,
243
+ }));
244
+ }
245
+
246
+ async getOrderStatus(orderId) {
247
+ const response = await this._request("GET", `/orders/historical/${orderId}`);
248
+ const order = response.order || {};
249
+ return {
250
+ orderId: String(order.order_id || orderId),
251
+ clientOrderId: order.client_order_id,
252
+ status: mapOrderStatus(order.status),
253
+ filledQty: Number(order.filled_size || 0),
254
+ avgFillPrice: Number(order.average_filled_price || 0) || undefined,
255
+ filledAt: order.last_fill_time ? Date.parse(order.last_fill_time) : undefined,
256
+ symbol: order.product_id || "",
257
+ side: String(order.side || "").toLowerCase(),
258
+ type: String(order.order_type || "").toLowerCase(),
259
+ qty: Number(order.base_size || 0),
260
+ rejectReason: order.reject_reason,
261
+ };
262
+ }
263
+
264
+ async subscribeQuotes(symbol, handler) {
265
+ const list = this.subscriptions.quotes.get(symbol) || [];
266
+ list.push(handler);
267
+ this.subscriptions.quotes.set(symbol, list);
268
+ return {
269
+ unsubscribe: () => {
270
+ const current = this.subscriptions.quotes.get(symbol) || [];
271
+ this.subscriptions.quotes.set(
272
+ symbol,
273
+ current.filter((candidate) => candidate !== handler)
274
+ );
275
+ },
276
+ };
277
+ }
278
+
279
+ async subscribeTrades(symbol, handler) {
280
+ const list = this.subscriptions.trades.get(symbol) || [];
281
+ list.push(handler);
282
+ this.subscriptions.trades.set(symbol, list);
283
+ return {
284
+ unsubscribe: () => {
285
+ const current = this.subscriptions.trades.get(symbol) || [];
286
+ this.subscriptions.trades.set(
287
+ symbol,
288
+ current.filter((candidate) => candidate !== handler)
289
+ );
290
+ },
291
+ };
292
+ }
293
+
294
+ async subscribeBars(symbol, interval, handler) {
295
+ const key = `${symbol}::${interval}`;
296
+ const list = this.subscriptions.bars.get(key) || [];
297
+ list.push(handler);
298
+ this.subscriptions.bars.set(key, list);
299
+ return {
300
+ unsubscribe: () => {
301
+ const current = this.subscriptions.bars.get(key) || [];
302
+ this.subscriptions.bars.set(
303
+ key,
304
+ current.filter((candidate) => candidate !== handler)
305
+ );
306
+ },
307
+ };
308
+ }
309
+
310
+ async getHistoricalBars(symbol, interval, limit = 200) {
311
+ const granularity = (() => {
312
+ const raw = String(interval || "1m").toLowerCase();
313
+ if (raw.endsWith("m")) return Number(raw.slice(0, -1)) * 60;
314
+ if (raw.endsWith("h")) return Number(raw.slice(0, -1)) * 3600;
315
+ if (raw.endsWith("d")) return Number(raw.slice(0, -1)) * 86400;
316
+ return 60;
317
+ })();
318
+ const response = await this._request("GET", `/products/${symbol}/candles`, {
319
+ query: {
320
+ granularity,
321
+ limit,
322
+ },
323
+ });
324
+ const rows = response.candles || response || [];
325
+ const bars = rows.map((row) => ({
326
+ time: Number(row.start || row.time || row[0]) * 1000,
327
+ low: Number(row.low ?? row[1]),
328
+ high: Number(row.high ?? row[2]),
329
+ open: Number(row.open ?? row[3]),
330
+ close: Number(row.close ?? row[4]),
331
+ volume: Number(row.volume ?? row[5] ?? 0),
332
+ }));
333
+ return normalizeCandles(bars);
334
+ }
335
+ }
336
+
337
+ export function createCoinbaseBroker(options) {
338
+ return new CoinbaseBroker(options);
339
+ }