@huskly/ibkr-client 0.6.0 → 0.8.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 (48) hide show
  1. package/README.md +79 -55
  2. package/dist/ibkr/derivativeContract.d.ts +6 -0
  3. package/dist/ibkr/derivativeContract.d.ts.map +1 -0
  4. package/dist/ibkr/derivativeContract.js +69 -0
  5. package/dist/ibkr/derivativeContract.js.map +1 -0
  6. package/dist/ibkr/ibkrApiTypes.d.ts +83 -1
  7. package/dist/ibkr/ibkrApiTypes.d.ts.map +1 -1
  8. package/dist/ibkr/ibkrClient.d.ts +39 -3
  9. package/dist/ibkr/ibkrClient.d.ts.map +1 -1
  10. package/dist/ibkr/ibkrClient.js +542 -2
  11. package/dist/ibkr/ibkrClient.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/types.d.ts +133 -9
  17. package/dist/types.d.ts.map +1 -1
  18. package/dist/types.js +4 -6
  19. package/dist/types.js.map +1 -1
  20. package/package.json +4 -10
  21. package/dist/cli/account.d.ts +0 -7
  22. package/dist/cli/account.d.ts.map +0 -1
  23. package/dist/cli/account.js +0 -27
  24. package/dist/cli/account.js.map +0 -1
  25. package/dist/cli/index.d.ts +0 -3
  26. package/dist/cli/index.d.ts.map +0 -1
  27. package/dist/cli/index.js +0 -50
  28. package/dist/cli/index.js.map +0 -1
  29. package/dist/cli/orders.d.ts +0 -4
  30. package/dist/cli/orders.d.ts.map +0 -1
  31. package/dist/cli/orders.js +0 -5
  32. package/dist/cli/orders.js.map +0 -1
  33. package/dist/cli/positions.d.ts +0 -12
  34. package/dist/cli/positions.d.ts.map +0 -1
  35. package/dist/cli/positions.js +0 -106
  36. package/dist/cli/positions.js.map +0 -1
  37. package/dist/cli/quote.d.ts +0 -4
  38. package/dist/cli/quote.d.ts.map +0 -1
  39. package/dist/cli/quote.js +0 -5
  40. package/dist/cli/quote.js.map +0 -1
  41. package/dist/cli/shared.d.ts +0 -12
  42. package/dist/cli/shared.d.ts.map +0 -1
  43. package/dist/cli/shared.js +0 -31
  44. package/dist/cli/shared.js.map +0 -1
  45. package/dist/format.d.ts +0 -13
  46. package/dist/format.d.ts.map +0 -1
  47. package/dist/format.js +0 -32
  48. package/dist/format.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { ASSET_CLASS_LABELS, toNumber } from "../helpers.js";
3
3
  import { normalizeOptionContract, parseOsiOptionSymbol } from "./optionContract.js";
4
+ import { normalizeDerivativeContract, normalizeDerivativeDataAvailability, } from "./derivativeContract.js";
4
5
  // `ibkr-client`'s published ESM build is broken: its `import` condition points
5
6
  // at files that use extensionless relative imports, which Node's strict ESM
6
7
  // resolver rejects. Its CJS build is fine, so we deliberately load that via
@@ -18,6 +19,17 @@ const OPTION_QUOTE_FIELDS = [
18
19
  "7638", // Option open interest
19
20
  "7762", // Unformatted volume
20
21
  ].join(",");
22
+ const DERIVATIVE_QUOTE_FIELDS = [
23
+ "31", // Last
24
+ "84", // Bid
25
+ "86", // Ask
26
+ "6509", // Market data availability
27
+ "7308", // Delta
28
+ "7633", // Implied volatility
29
+ "7635", // Mark price
30
+ "7638", // Option open interest
31
+ "7762", // Unformatted volume
32
+ ].join(",");
21
33
  const QUOTE_FIELDS = [
22
34
  "31", // Last
23
35
  "55", // Symbol
@@ -39,6 +51,27 @@ const OPTION_MARKETDATA_BATCH_SIZE = 100;
39
51
  const READ_ONLY_REQUEST_MAX_RETRIES = 3;
40
52
  const REQUEST_RETRY_BASE_DELAY_MS = 250;
41
53
  const REQUEST_RETRY_MAX_DELAY_MS = 5_000;
54
+ const DAY_MS = 24 * 60 * 60 * 1000;
55
+ const IBKR_STATUS_FILTERS = {
56
+ CANCELED: "cancelled",
57
+ CANCELLED: "cancelled",
58
+ FILLED: "filled",
59
+ PENDING_CANCEL: "pending_cancel",
60
+ PENDING_SUBMIT: "pending_submit",
61
+ PRE_SUBMITTED: "pre_submitted",
62
+ SUBMITTED: "submitted",
63
+ };
64
+ const IBKR_WORKING_STATUSES = new Set([
65
+ "API_PENDING",
66
+ "PENDING_SUBMIT",
67
+ "PRE_SUBMITTED",
68
+ "SUBMITTED",
69
+ "PENDING_CANCEL",
70
+ ]);
71
+ /** Extract the canonical OSI symbol embedded in an IBKR option description. */
72
+ function extractOsiPositionSymbol(contractDescription) {
73
+ return /\[([A-Z]+\s*\d{6}[CP]\d{8})\s+\d+\]\s*$/.exec(contractDescription)?.[1];
74
+ }
42
75
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
76
  function parseRetryAfter(raw) {
44
77
  const asString = typeof raw === "string" ? raw.trim() : undefined;
@@ -124,6 +157,7 @@ export class IbkrClient {
124
157
  initPromise;
125
158
  accountIdPromise;
126
159
  optionDiscovery = new Map();
160
+ derivativeDiscovery = new Map();
127
161
  constructor(config) {
128
162
  this.raw = new RawIbkrClientCtor(config);
129
163
  }
@@ -221,8 +255,12 @@ export class IbkrClient {
221
255
  normalizePosition(p, dayPnl) {
222
256
  const qty = p.position ?? 0;
223
257
  const assetClass = p.assetClass ?? "";
258
+ const contractDescription = p.contractDesc ?? String(p.conid ?? "-");
259
+ const symbol = assetClass === "OPT"
260
+ ? (extractOsiPositionSymbol(contractDescription) ?? contractDescription)
261
+ : contractDescription;
224
262
  return {
225
- symbol: p.contractDesc ?? String(p.conid ?? "-"),
263
+ symbol,
226
264
  assetType: ASSET_CLASS_LABELS[assetClass] ?? (assetClass || "-"),
227
265
  longQuantity: qty > 0 ? qty : 0,
228
266
  shortQuantity: qty < 0 ? Math.abs(qty) : 0,
@@ -264,7 +302,10 @@ export class IbkrClient {
264
302
  return quotes;
265
303
  }
266
304
  /** Resolve equity/ETF symbols to IBKR contracts via `trsrv/stocks`. */
267
- async searchInstruments(symbol) {
305
+ async searchInstruments(symbol, projection = "symbol-search") {
306
+ if (projection !== "symbol-search" && projection !== "search") {
307
+ throw new Error(`IBKR search currently supports only symbol-search/search projections (got '${projection}').`);
308
+ }
268
309
  const query = symbol.trim().toUpperCase();
269
310
  if (!query)
270
311
  return [];
@@ -274,6 +315,71 @@ export class IbkrClient {
274
315
  });
275
316
  return (response[query] ?? []).flatMap((listing) => this.normalizeStockListing(query, listing));
276
317
  }
318
+ async fetchTransactionHistory(startDate, endDate) {
319
+ const accountId = await this.getAccountId();
320
+ const rows = await this.fetchAllPositions(accountId);
321
+ const positionsByConid = new Map(rows
322
+ .filter((position) => position.conid !== undefined)
323
+ .map((position) => [position.conid, position]));
324
+ const transactionsByKey = new Map();
325
+ const days = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / DAY_MS) + 1);
326
+ for (const conid of positionsByConid.keys()) {
327
+ const response = await this.req({
328
+ path: "pa/transactions",
329
+ method: "POST",
330
+ data: {
331
+ acctIds: [accountId],
332
+ conids: [conid],
333
+ currency: process.env["IBKR_TRANSACTION_CURRENCY"] ?? "USD",
334
+ days,
335
+ },
336
+ });
337
+ for (const transaction of response.transactions ?? []) {
338
+ const normalized = this.normalizeTransaction(transaction, positionsByConid);
339
+ const time = new Date(normalized.time).getTime();
340
+ if (time < startDate.getTime() || time > endDate.getTime())
341
+ continue;
342
+ transactionsByKey.set(this.transactionKey(normalized), normalized);
343
+ }
344
+ }
345
+ return [{ accountNumber: accountId, transactions: [...transactionsByKey.values()] }];
346
+ }
347
+ async fetchOrders(options) {
348
+ const accountId = await this.getAccountId();
349
+ await this.prepareBrokerageAccount(accountId);
350
+ const params = {};
351
+ if (options.status && options.status.toUpperCase() !== "WORKING") {
352
+ params["filters"] = this.ibkrStatusFilter(options.status);
353
+ }
354
+ const response = await this.req({
355
+ path: "iserver/account/orders",
356
+ params,
357
+ });
358
+ let orders = (response.orders ?? [])
359
+ .filter((order) => this.orderBelongsToAccount(order, accountId))
360
+ .map((order) => this.normalizeOrder(order))
361
+ .filter((order) => this.orderMatchesStatus(order, options.status))
362
+ .filter((order) => this.orderInDateRange(order, options.fromEnteredTime, options.toEnteredTime))
363
+ .sort((left, right) => this.orderTimeMs(right) - this.orderTimeMs(left));
364
+ if (options.maxResults !== undefined)
365
+ orders = orders.slice(0, options.maxResults);
366
+ return [{ accountNumber: accountId, orders }];
367
+ }
368
+ async prepareBrokerageAccount(accountId) {
369
+ const brokerageAccounts = await this.req({
370
+ path: "iserver/accounts",
371
+ });
372
+ if (brokerageAccounts.selectedAccount === accountId)
373
+ return;
374
+ if (brokerageAccounts.accounts && !brokerageAccounts.accounts.includes(accountId)) {
375
+ throw new Error(`IBKR account ${accountId} is not available for trading/order queries.`);
376
+ }
377
+ await this.req({
378
+ path: "iserver/account",
379
+ method: "POST",
380
+ data: { acctId: accountId },
381
+ });
382
+ }
277
383
  normalizeStockListing(symbol, listing) {
278
384
  const assetType = listing.assetClass === "STK" ? "EQUITY" : listing.assetClass;
279
385
  const contracts = listing.contracts ?? [];
@@ -357,6 +463,71 @@ export class IbkrClient {
357
463
  };
358
464
  });
359
465
  }
466
+ /** Discover listed derivative series over an inclusive calendar range. */
467
+ async getDerivativeExpiries(query) {
468
+ const contracts = (await Promise.all(monthCodes(query.from, query.to).map((month) => this.discoverDerivativeMonth(query.underlying, query.assetClass, month, query.exchange, query.right)))).flat();
469
+ const filtered = contracts.filter((contract) => contract.expiration >= query.from &&
470
+ contract.expiration <= query.to &&
471
+ (query.right === undefined || contract.right === query.right) &&
472
+ (query.tradingClass === undefined ||
473
+ contract.tradingClass === query.tradingClass.trim().toUpperCase()));
474
+ const expiries = filtered.map(({ assetClass, underlying, expiration, tradingClass, exchange, multiplier }) => ({
475
+ assetClass,
476
+ underlying,
477
+ expiration,
478
+ tradingClass,
479
+ exchange,
480
+ multiplier,
481
+ }));
482
+ return [
483
+ ...new Map(expiries.map((expiry) => [
484
+ [
485
+ expiry.assetClass,
486
+ expiry.underlying,
487
+ expiry.expiration,
488
+ expiry.tradingClass,
489
+ expiry.exchange,
490
+ String(expiry.multiplier),
491
+ ].join(":"),
492
+ expiry,
493
+ ])).values(),
494
+ ].sort((left, right) => left.expiration.localeCompare(right.expiration));
495
+ }
496
+ /** Discover contracts for one exact expiration, preserving class and venue identity. */
497
+ async getDerivativeContracts(query) {
498
+ const tradingClass = query.tradingClass?.trim().toUpperCase();
499
+ return (await this.discoverDerivativeMonth(query.underlying, query.assetClass, monthCode(query.expiration), query.exchange, query.right, query.strike)).filter((contract) => contract.expiration === query.expiration &&
500
+ (query.right === undefined || contract.right === query.right) &&
501
+ (query.strike === undefined || contract.strike === query.strike) &&
502
+ (tradingClass === undefined || contract.tradingClass === tradingClass));
503
+ }
504
+ /** Resolve exactly one contract and reject missing or ambiguous semantic identity. */
505
+ async resolveDerivativeContract(query) {
506
+ const contracts = await this.getDerivativeContracts(query);
507
+ if (!contracts.length) {
508
+ throw new Error(`IBKR returned no exact ${query.assetClass} contract for ${query.underlying} ${query.expiration} ${query.right}${String(query.strike)}`);
509
+ }
510
+ if (contracts.length !== 1) {
511
+ const classes = [...new Set(contracts.map((contract) => contract.tradingClass))].join(", ");
512
+ throw new Error(`Ambiguous ${query.assetClass} contract for ${query.underlying} ${query.expiration} ${query.right}${String(query.strike)}; specify tradingClass (${classes})`);
513
+ }
514
+ const contract = contracts[0];
515
+ if (!contract)
516
+ throw new Error("IBKR exact derivative resolution lost its selected contract");
517
+ return contract;
518
+ }
519
+ /** Return an exact-expiration derivative chain with explicit data availability. */
520
+ async getDerivativeChain(query) {
521
+ const contracts = await this.getDerivativeContracts(query);
522
+ if (!contracts.length) {
523
+ throw new Error(`IBKR returned no ${query.assetClass} contracts for ${query.underlying} ${query.expiration}`);
524
+ }
525
+ const quotes = await this.fetchDerivativeQuotes(contracts);
526
+ if (!quotes.some((quote) => quote.bid !== null && quote.ask !== null)) {
527
+ throw new Error(`IBKR returned no usable derivative quotes for ${query.underlying} ${query.expiration}`);
528
+ }
529
+ return quotes;
530
+ }
360
531
  /** Discover every listed weekly/monthly expiry in the requested calendar range. */
361
532
  async getOptionExpiries(symbol, right, fromDate, toDate) {
362
533
  const normalized = symbol.trim().toUpperCase();
@@ -415,6 +586,141 @@ export class IbkrClient {
415
586
  contract.right === input.right &&
416
587
  contract.strike === input.strike) ?? null);
417
588
  }
589
+ discoverDerivativeMonth(underlying, assetClass, month, exchange, right, strike) {
590
+ const normalizedUnderlying = underlying.trim().toUpperCase();
591
+ if (!normalizedUnderlying)
592
+ throw new Error("Derivative underlying is required");
593
+ const normalizedExchange = exchange?.trim().toUpperCase();
594
+ const key = [
595
+ normalizedUnderlying,
596
+ assetClass,
597
+ month,
598
+ normalizedExchange ?? "*",
599
+ right ?? "*",
600
+ strike === undefined ? "*" : String(strike),
601
+ ].join(":");
602
+ let pending = this.derivativeDiscovery.get(key);
603
+ if (!pending) {
604
+ pending = this.loadDerivativeContracts(normalizedUnderlying, assetClass, month, normalizedExchange, right, strike);
605
+ this.derivativeDiscovery.set(key, pending);
606
+ }
607
+ return pending;
608
+ }
609
+ async loadDerivativeContracts(underlying, assetClass, month, requestedExchange, requestedRight, requestedStrike) {
610
+ // IBKR keeps this priming state in the authenticated session. Strikes may be
611
+ // empty when search has not run first, even with otherwise identical params.
612
+ const search = await this.req({
613
+ path: "iserver/secdef/search",
614
+ params: { symbol: underlying, ...(assetClass === "FOP" ? { secType: "FUT" } : {}) },
615
+ });
616
+ const candidates = search.filter((candidate) => candidate.conid !== undefined &&
617
+ candidate.symbol?.trim().toUpperCase() === underlying &&
618
+ candidate.sections?.some((section) => section.secType?.toUpperCase() === assetClass));
619
+ if (candidates.length !== 1) {
620
+ throw new Error(`IBKR ${assetClass} underlying identity is ${candidates.length ? "ambiguous" : "missing"} for ${underlying}`);
621
+ }
622
+ const candidate = candidates[0];
623
+ if (!candidate)
624
+ throw new Error(`IBKR lost the selected underlying for ${underlying}`);
625
+ const conid = Number(candidate.conid);
626
+ if (!Number.isSafeInteger(conid) || conid <= 0) {
627
+ throw new Error(`IBKR returned an invalid underlying contract id for ${underlying}`);
628
+ }
629
+ const section = candidate.sections?.find((item) => item.secType?.toUpperCase() === assetClass);
630
+ const exchanges = (section?.exchange ?? "")
631
+ .split(";")
632
+ .map((value) => value.trim().toUpperCase())
633
+ .filter(Boolean);
634
+ if (requestedExchange && !exchanges.includes(requestedExchange)) {
635
+ throw new Error(`IBKR does not list ${underlying} ${assetClass} discovery on ${requestedExchange}`);
636
+ }
637
+ const exchange = requestedExchange ?? (exchanges.length === 1 ? exchanges[0] : undefined);
638
+ const strikes = await this.req({
639
+ path: "iserver/secdef/strikes",
640
+ params: {
641
+ conid: String(conid),
642
+ sectype: assetClass,
643
+ month,
644
+ ...(exchange ? { exchange } : {}),
645
+ },
646
+ });
647
+ const availableRequests = [
648
+ ...(strikes.call ?? []).map((strike) => ({ strike, right: "C" })),
649
+ ...(strikes.put ?? []).map((strike) => ({ strike, right: "P" })),
650
+ ];
651
+ if (!availableRequests.length) {
652
+ throw new Error(`IBKR returned empty ${assetClass} strikes for ${underlying} ${month} after secdef/search priming`);
653
+ }
654
+ const requests = availableRequests.filter((request) => (requestedRight === undefined || request.right === requestedRight) &&
655
+ (requestedStrike === undefined || request.strike === requestedStrike));
656
+ if (!requests.length) {
657
+ return [];
658
+ }
659
+ const contracts = [];
660
+ for (const batch of chunks(requests, OPTION_SECDEF_INFO_BATCH_SIZE)) {
661
+ const responses = await Promise.all(batch.map(({ strike, right }) => this.req({
662
+ path: "iserver/secdef/info",
663
+ params: {
664
+ conid: String(conid),
665
+ sectype: assetClass,
666
+ month,
667
+ strike,
668
+ right,
669
+ ...(exchange ? { exchange } : {}),
670
+ },
671
+ })));
672
+ for (const raw of responses.flat()) {
673
+ const contract = normalizeDerivativeContract(raw, assetClass, underlying);
674
+ if (contract && (!requestedExchange || contract.exchange === requestedExchange)) {
675
+ contracts.push(contract);
676
+ }
677
+ }
678
+ }
679
+ const unique = [...new Map(contracts.map((contract) => [contract.conid, contract])).values()];
680
+ if (!unique.length) {
681
+ throw new Error(`IBKR returned no usable ${assetClass} definitions for ${underlying} ${month}`);
682
+ }
683
+ return unique;
684
+ }
685
+ async fetchDerivativeQuotes(contracts) {
686
+ const result = [];
687
+ for (const batch of chunks(contracts, OPTION_MARKETDATA_BATCH_SIZE)) {
688
+ const params = {
689
+ conids: batch.map((contract) => contract.conid).join(","),
690
+ fields: DERIVATIVE_QUOTE_FIELDS,
691
+ };
692
+ await this.req({ path: "iserver/marketdata/snapshot", params });
693
+ await this.wait(2000);
694
+ const snapshots = await this.req({
695
+ path: "iserver/marketdata/snapshot",
696
+ params,
697
+ });
698
+ const byConid = new Map(snapshots
699
+ .filter((snapshot) => snapshot.conid !== undefined)
700
+ .map((snapshot) => [snapshot.conid, snapshot]));
701
+ for (const contract of batch) {
702
+ const snapshot = byConid.get(contract.conid);
703
+ const updated = snapshot ? this.snapshotNumber(snapshot, "_updated") : undefined;
704
+ const updatedMs = updated === undefined ? undefined : updated < 100_000_000_000 ? updated * 1000 : updated;
705
+ const updatedDate = updatedMs === undefined ? undefined : new Date(updatedMs);
706
+ const timestamp = updatedDate && !Number.isNaN(updatedDate.getTime()) ? updatedDate.toISOString() : null;
707
+ result.push({
708
+ contract,
709
+ availability: normalizeDerivativeDataAvailability(snapshot?.["6509"]),
710
+ timestamp,
711
+ bid: snapshot ? (this.snapshotNumber(snapshot, "84") ?? null) : null,
712
+ ask: snapshot ? (this.snapshotNumber(snapshot, "86") ?? null) : null,
713
+ last: snapshot ? (this.snapshotNumber(snapshot, "31") ?? null) : null,
714
+ mark: snapshot ? (this.snapshotNumber(snapshot, "7635") ?? null) : null,
715
+ delta: snapshot ? (this.snapshotNumber(snapshot, "7308") ?? null) : null,
716
+ impliedVolatility: snapshot ? (this.snapshotNumber(snapshot, "7633") ?? null) : null,
717
+ volume: snapshot ? (this.snapshotVolume(snapshot) ?? null) : null,
718
+ openInterest: snapshot ? (this.snapshotNumber(snapshot, "7638") ?? null) : null,
719
+ });
720
+ }
721
+ }
722
+ return result;
723
+ }
418
724
  discoverOptions(symbol, month) {
419
725
  const normalized = symbol.trim().toUpperCase();
420
726
  const key = `${normalized}:${month}`;
@@ -561,6 +867,240 @@ export class IbkrClient {
561
867
  return undefined;
562
868
  }
563
869
  }
870
+ normalizeTransaction(transaction, positionsByConid) {
871
+ const conid = transaction.conid;
872
+ const position = conid === undefined ? undefined : positionsByConid.get(conid);
873
+ const assetType = position?.assetClass === undefined
874
+ ? undefined
875
+ : (ASSET_CLASS_LABELS[position.assetClass] ?? position.assetClass);
876
+ const symbol = position?.contractDesc ?? (conid === undefined ? undefined : String(conid));
877
+ const description = transaction.desc ?? symbol;
878
+ const time = this.parseTransactionTime(transaction)?.toISOString() ?? "";
879
+ const type = (transaction.type ?? "TRANSACTION").toUpperCase();
880
+ const transferItem = {
881
+ instrument: {
882
+ ...(assetType === undefined ? {} : { assetType }),
883
+ ...(symbol === undefined ? {} : { symbol }),
884
+ ...(description === undefined ? {} : { description }),
885
+ },
886
+ ...(transaction.qty === undefined ? {} : { amount: transaction.qty }),
887
+ ...(transaction.pr === undefined ? {} : { cost: transaction.pr }),
888
+ transferItemType: type,
889
+ };
890
+ const activityId = [
891
+ conid === undefined ? "unknown" : String(conid),
892
+ time,
893
+ transaction.qty === undefined ? "" : String(transaction.qty),
894
+ transaction.amt === undefined ? "" : String(transaction.amt),
895
+ ].join(":");
896
+ return {
897
+ activityId,
898
+ time,
899
+ type,
900
+ status: "VALID",
901
+ ...(transaction.acctid === undefined ? {} : { subAccount: transaction.acctid }),
902
+ ...(description === undefined ? {} : { description }),
903
+ netAmount: toNumber(transaction.amt),
904
+ transferItems: [transferItem],
905
+ };
906
+ }
907
+ normalizeOrder(order) {
908
+ const description = order.orderDescriptionWithContract ??
909
+ order.order_description_with_contract ??
910
+ order.orderDesc ??
911
+ order.orderDescription ??
912
+ order.order_description;
913
+ const symbol = order.description1 ??
914
+ order.contract_description_1 ??
915
+ order.contractDescription1 ??
916
+ order.symbol ??
917
+ order.ticker;
918
+ const quantity = this.firstPositiveNumber(order.total_size, order.totalSize, order.size) ??
919
+ this.quantityFromDescription(description);
920
+ const filledQuantity = this.firstNumber(order.cum_fill, order.cumFill, order.filledQuantity) ??
921
+ this.filledQuantityFromSizeAndFills(order.size_and_fills ?? order.sizeAndFills);
922
+ const remainingQuantity = order.remainingQuantity !== undefined
923
+ ? toNumber(order.remainingQuantity)
924
+ : quantity !== undefined && filledQuantity !== undefined
925
+ ? Math.max(0, quantity - filledQuantity)
926
+ : undefined;
927
+ const status = this.normalizeOrderStatus(order.order_status ?? order.orderStatus ?? order.status);
928
+ const price = this.firstPositiveNumber(order.limitPrice, order.price, order.avgPrice, order.average_price, order.averagePrice);
929
+ const stopPrice = this.firstPositiveNumber(order.stopPrice);
930
+ const orderId = order.order_id ?? order.orderId;
931
+ const enteredTime = this.parseOrderTime(order)?.toISOString();
932
+ const orderType = this.normalizeOrderType(order.order_type ?? order.orderType);
933
+ return {
934
+ ...(orderId === undefined ? {} : { orderId }),
935
+ ...(enteredTime === undefined ? {} : { enteredTime }),
936
+ ...(status === undefined ? {} : { status }),
937
+ ...(orderType === undefined ? {} : { orderType }),
938
+ ...(quantity === undefined ? {} : { quantity }),
939
+ ...(filledQuantity === undefined ? {} : { filledQuantity }),
940
+ ...(remainingQuantity === undefined ? {} : { remainingQuantity }),
941
+ ...(price === undefined ? {} : { price }),
942
+ ...(stopPrice === undefined ? {} : { stopPrice }),
943
+ orderLegCollection: [this.normalizeOrderLeg(order, symbol)],
944
+ };
945
+ }
946
+ normalizeOrderLeg(order, symbol) {
947
+ const fallbackSymbol = symbol ?? (order.conid === undefined ? undefined : String(order.conid));
948
+ const instruction = this.normalizeOrderSide(order.side);
949
+ return {
950
+ ...(instruction === undefined ? {} : { instruction }),
951
+ instrument: { ...(fallbackSymbol === undefined ? {} : { symbol: fallbackSymbol }) },
952
+ };
953
+ }
954
+ normalizeOrderStatus(status) {
955
+ if (!status)
956
+ return undefined;
957
+ const normalized = status
958
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
959
+ .replace(/\s+/g, "_")
960
+ .toUpperCase();
961
+ return normalized === "CANCELLED" ? "CANCELED" : normalized;
962
+ }
963
+ normalizeOrderType(type) {
964
+ if (!type)
965
+ return undefined;
966
+ if (type === "MKT")
967
+ return "MARKET";
968
+ if (type === "LMT")
969
+ return "LIMIT";
970
+ if (type === "STP")
971
+ return "STOP";
972
+ return type.replace(/\s+/g, "_").toUpperCase();
973
+ }
974
+ normalizeOrderSide(side) {
975
+ if (!side)
976
+ return undefined;
977
+ const upper = side.toUpperCase();
978
+ if (upper === "B" || upper === "BUY")
979
+ return "BUY";
980
+ if (upper === "S" || upper === "SELL")
981
+ return "SELL";
982
+ return upper;
983
+ }
984
+ ibkrStatusFilter(status) {
985
+ const normalized = status.toUpperCase();
986
+ return IBKR_STATUS_FILTERS[normalized] ?? normalized.toLowerCase();
987
+ }
988
+ orderMatchesStatus(order, requestedStatus) {
989
+ if (!requestedStatus)
990
+ return true;
991
+ const normalizedStatus = this.normalizeOrderStatus(requestedStatus);
992
+ if (normalizedStatus === "WORKING") {
993
+ return order.status !== undefined && IBKR_WORKING_STATUSES.has(order.status);
994
+ }
995
+ return order.status === normalizedStatus;
996
+ }
997
+ orderBelongsToAccount(order, accountId) {
998
+ const account = order.account ?? order.acct;
999
+ return account === undefined || account === accountId;
1000
+ }
1001
+ orderInDateRange(order, fromDate, toDate) {
1002
+ const timeMs = this.orderTimeMs(order);
1003
+ return !Number.isFinite(timeMs) || (timeMs >= fromDate.getTime() && timeMs <= toDate.getTime());
1004
+ }
1005
+ orderTimeMs(order) {
1006
+ const parsed = order.enteredTime ? new Date(order.enteredTime).getTime() : Number.NaN;
1007
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
1008
+ }
1009
+ parseOrderTime(order) {
1010
+ if (order.lastExecutionTime_r !== undefined) {
1011
+ const parsed = new Date(order.lastExecutionTime_r);
1012
+ if (!Number.isNaN(parsed.getTime()))
1013
+ return parsed;
1014
+ }
1015
+ const value = order.order_time ?? order.orderTime ?? order.lastExecutionTime;
1016
+ if (!value)
1017
+ return undefined;
1018
+ const compact = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(value);
1019
+ if (compact) {
1020
+ const [, year, month, day, hour, minute, second] = compact;
1021
+ return new Date(Date.UTC(2000 + Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)));
1022
+ }
1023
+ const parsed = new Date(value);
1024
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
1025
+ }
1026
+ firstNumber(...values) {
1027
+ for (const value of values) {
1028
+ if (value === undefined)
1029
+ continue;
1030
+ const numeric = Number(value);
1031
+ if (Number.isFinite(numeric))
1032
+ return numeric;
1033
+ }
1034
+ return undefined;
1035
+ }
1036
+ firstPositiveNumber(...values) {
1037
+ for (const value of values) {
1038
+ const numeric = this.firstNumber(value);
1039
+ if (numeric !== undefined && numeric > 0)
1040
+ return numeric;
1041
+ }
1042
+ return undefined;
1043
+ }
1044
+ quantityFromDescription(description) {
1045
+ const quantity = description
1046
+ ? /\b(?:Bought|Sold|Buy|Sell)\s+(?<quantity>[\d.]+)/i.exec(description)?.groups?.["quantity"]
1047
+ : undefined;
1048
+ if (!quantity)
1049
+ return undefined;
1050
+ const parsed = Number(quantity);
1051
+ return Number.isFinite(parsed) ? parsed : undefined;
1052
+ }
1053
+ filledQuantityFromSizeAndFills(value) {
1054
+ const quantity = value ? /(?<quantity>[\d.]+)/.exec(value)?.groups?.["quantity"] : undefined;
1055
+ if (!quantity)
1056
+ return undefined;
1057
+ const parsed = Number(quantity);
1058
+ return Number.isFinite(parsed) ? parsed : undefined;
1059
+ }
1060
+ parseTransactionTime(transaction) {
1061
+ if (transaction.rawDate && /^\d{8}$/.test(transaction.rawDate)) {
1062
+ const year = transaction.rawDate.slice(0, 4);
1063
+ const month = transaction.rawDate.slice(4, 6);
1064
+ const day = transaction.rawDate.slice(6, 8);
1065
+ return new Date(`${year}-${month}-${day}T00:00:00`);
1066
+ }
1067
+ const value = transaction.date;
1068
+ if (!value)
1069
+ return undefined;
1070
+ const parsed = new Date(value);
1071
+ if (!Number.isNaN(parsed.getTime()))
1072
+ return parsed;
1073
+ const match = /^(?:\w{3}) (?<month>\w{3}) (?<day>\d{1,2}) (?<time>\d{2}:\d{2}:\d{2}) (?<zone>\w{3}) (?<year>\d{4})$/.exec(value);
1074
+ if (!match?.groups)
1075
+ return undefined;
1076
+ const zoneOffsets = {
1077
+ EST: "-05:00",
1078
+ EDT: "-04:00",
1079
+ CST: "-06:00",
1080
+ CDT: "-05:00",
1081
+ MST: "-07:00",
1082
+ MDT: "-06:00",
1083
+ PST: "-08:00",
1084
+ PDT: "-07:00",
1085
+ UTC: "Z",
1086
+ GMT: "Z",
1087
+ };
1088
+ const { month, day, time, zone, year } = match.groups;
1089
+ if (!month || !day || !time || !zone || !year)
1090
+ return undefined;
1091
+ const normalized = `${day.padStart(2, "0")} ${month} ${year} ${time} ${zoneOffsets[zone] ?? "Z"}`;
1092
+ const fallback = new Date(normalized);
1093
+ return Number.isNaN(fallback.getTime()) ? undefined : fallback;
1094
+ }
1095
+ transactionKey(transaction) {
1096
+ return [
1097
+ transaction.activityId,
1098
+ transaction.time,
1099
+ transaction.type,
1100
+ transaction.netAmount,
1101
+ transaction.transferItems?.[0]?.amount ?? "",
1102
+ ].join(":");
1103
+ }
564
1104
  /** Overridable in request-level tests so snapshot warm-up does not sleep. */
565
1105
  wait(ms) {
566
1106
  return sleep(ms);