gloomberb 0.2.0 → 0.3.2
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.
- package/README.md +20 -19
- package/bin/gloomberb +3 -0
- package/package.json +5 -4
- package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
- package/src/app.tsx +1365 -0
- package/src/brokers/sync-broker-instance.test.ts +100 -0
- package/src/brokers/sync-broker-instance.ts +341 -0
- package/src/cli/commands/plugins.ts +173 -0
- package/src/cli/commands/portfolio.ts +210 -0
- package/src/cli/commands/search.ts +120 -0
- package/src/cli/commands/ticker.ts +375 -0
- package/src/cli/commands/watchlist.ts +210 -0
- package/src/cli/context.ts +62 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/helpers.ts +85 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/ticker-resolution.ts +39 -0
- package/src/cli/types.ts +15 -0
- package/src/cli.test.ts +361 -0
- package/src/components/chart/chart-data.ts +239 -0
- package/src/components/chart/chart-renderer.ts +1199 -0
- package/src/components/chart/chart-types.ts +82 -0
- package/src/components/chart/chart-viewport.ts +21 -0
- package/src/components/chart/chart.test.ts +439 -0
- package/src/components/chart/comparison-chart-data.test.ts +109 -0
- package/src/components/chart/comparison-chart-data.ts +316 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
- package/src/components/chart/comparison-chart-renderer.ts +354 -0
- package/src/components/chart/comparison-stock-chart.tsx +1486 -0
- package/src/components/chart/cursor-motion.ts +48 -0
- package/src/components/chart/native/chart-rasterizer.ts +732 -0
- package/src/components/chart/native/kitty-adapter.ts +12 -0
- package/src/components/chart/native/kitty-manager.ts +142 -0
- package/src/components/chart/native/kitty-protocol.ts +110 -0
- package/src/components/chart/native/kitty-support.ts +71 -0
- package/src/components/chart/native/kitty.test.ts +620 -0
- package/src/components/chart/native/renderer-selection.ts +24 -0
- package/src/components/chart/native/surface-manager.ts +225 -0
- package/src/components/chart/native/surface-sync.ts +34 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
- package/src/components/chart/stock-chart.test.ts +107 -0
- package/src/components/chart/stock-chart.tsx +1556 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
- package/src/components/command-bar/command-bar.test.tsx +1546 -0
- package/src/components/command-bar/command-bar.tsx +4841 -0
- package/src/components/command-bar/command-registry.ts +269 -0
- package/src/components/command-bar/helpers.test.ts +224 -0
- package/src/components/command-bar/helpers.ts +371 -0
- package/src/components/command-bar/root-shortcuts.ts +158 -0
- package/src/components/command-bar/view-model.test.ts +152 -0
- package/src/components/command-bar/view-model.ts +185 -0
- package/src/components/command-bar/workflow-ops.test.ts +70 -0
- package/src/components/command-bar/workflow-ops.ts +433 -0
- package/src/components/command-bar/workflow-types.ts +127 -0
- package/src/components/detail-feed-view.test.tsx +105 -0
- package/src/components/detail-feed-view.tsx +295 -0
- package/src/components/index.ts +32 -0
- package/src/components/layout/floating-pane.tsx +49 -0
- package/src/components/layout/header.tsx +151 -0
- package/src/components/layout/pane-header.tsx +44 -0
- package/src/components/layout/pane.tsx +49 -0
- package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
- package/src/components/layout/pixel-mouse.test.ts +40 -0
- package/src/components/layout/shell.test.tsx +651 -0
- package/src/components/layout/shell.tsx +1200 -0
- package/src/components/layout/status-bar.test.tsx +173 -0
- package/src/components/layout/status-bar.tsx +138 -0
- package/src/components/markdown-editor.tsx +134 -0
- package/src/components/markdown-text.tsx +218 -0
- package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
- package/src/components/pane-settings-dialog.tsx +468 -0
- package/src/components/pane-template-wizard.test.tsx +52 -0
- package/src/components/pane-template-wizard.tsx +199 -0
- package/src/components/price-selector-dialog.tsx +195 -0
- package/src/components/spinner.tsx +2 -0
- package/src/components/tab-bar.tsx +17 -0
- package/src/components/ticker-badge-text.test.tsx +160 -0
- package/src/components/ticker-badge-text.tsx +58 -0
- package/src/components/ticker-badge.tsx +88 -0
- package/src/components/ticker-list-table.tsx +188 -0
- package/src/components/toggle-list.tsx +74 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/data-table.tsx +210 -0
- package/src/components/ui/external-link.tsx +22 -0
- package/src/components/ui/fields.tsx +187 -0
- package/src/components/ui/frame.tsx +73 -0
- package/src/components/ui/index.ts +53 -0
- package/src/components/ui/list-view.tsx +166 -0
- package/src/components/ui/loading.tsx +70 -0
- package/src/components/ui/status.tsx +83 -0
- package/src/components/ui/tabs.tsx +47 -0
- package/src/components/ui/toggle.tsx +164 -0
- package/src/components/ui/ui.test.tsx +134 -0
- package/src/data/app-persistence.ts +25 -0
- package/src/data/config-store.test.ts +347 -0
- package/src/data/config-store.ts +507 -0
- package/src/data/plugin-state-store.ts +102 -0
- package/src/data/resource-store.ts +323 -0
- package/src/data/session-store.ts +52 -0
- package/src/data/sqlite-cache.test.ts +103 -0
- package/src/data/sqlite-database.ts +62 -0
- package/src/data/sqlite-json.ts +19 -0
- package/src/data/ticker-repository.ts +45 -0
- package/src/data/ticker-store.ts +46 -0
- package/src/index.tsx +67 -0
- package/src/market-data/coordinator.test.ts +369 -0
- package/src/market-data/coordinator.ts +580 -0
- package/src/market-data/hooks.tsx +234 -0
- package/src/market-data/query-store.ts +28 -0
- package/src/market-data/request-types.test.ts +64 -0
- package/src/market-data/request-types.ts +70 -0
- package/src/market-data/result-types.ts +56 -0
- package/src/market-data/selectors.ts +166 -0
- package/src/market-data/trace.ts +7 -0
- package/src/pane-settings.test.ts +55 -0
- package/src/pane-settings.ts +73 -0
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +350 -0
- package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
- package/src/plugins/builtin/ai/index.test.tsx +139 -0
- package/src/plugins/builtin/ai/index.tsx +86 -0
- package/src/plugins/builtin/ai/providers.ts +99 -0
- package/src/plugins/builtin/ai/runner.ts +82 -0
- package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
- package/src/plugins/builtin/ai/screener-contract.ts +174 -0
- package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
- package/src/plugins/builtin/ai/settings.ts +79 -0
- package/src/plugins/builtin/ai/ticker-context.ts +64 -0
- package/src/plugins/builtin/ai/utils.ts +71 -0
- package/src/plugins/builtin/ask-ai.test.tsx +258 -0
- package/src/plugins/builtin/ask-ai.tsx +9 -0
- package/src/plugins/builtin/chat-controller.test.ts +217 -0
- package/src/plugins/builtin/chat-controller.ts +340 -0
- package/src/plugins/builtin/chat.test.tsx +451 -0
- package/src/plugins/builtin/chat.tsx +620 -0
- package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
- package/src/plugins/builtin/comparison-chart.tsx +169 -0
- package/src/plugins/builtin/data-providers.ts +16 -0
- package/src/plugins/builtin/debug.tsx +351 -0
- package/src/plugins/builtin/help.test.tsx +110 -0
- package/src/plugins/builtin/help.tsx +281 -0
- package/src/plugins/builtin/layout-manager.tsx +264 -0
- package/src/plugins/builtin/manual-entry.tsx +25 -0
- package/src/plugins/builtin/news.tsx +143 -0
- package/src/plugins/builtin/notes-files.ts +58 -0
- package/src/plugins/builtin/notes.tsx +396 -0
- package/src/plugins/builtin/options-availability.test.tsx +179 -0
- package/src/plugins/builtin/options-availability.ts +158 -0
- package/src/plugins/builtin/options.tsx +364 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +460 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +270 -0
- package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
- package/src/plugins/builtin/portfolio-list.test.ts +74 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
- package/src/plugins/builtin/portfolio-metrics.test.ts +224 -0
- package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
- package/src/plugins/builtin/sec.tsx +250 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
- package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
- package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
- package/src/plugins/builtin/yahoo.ts +18 -0
- package/src/plugins/event-bus.ts +37 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
- package/src/plugins/ibkr/account-cache.test.ts +101 -0
- package/src/plugins/ibkr/account-cache.ts +137 -0
- package/src/plugins/ibkr/broker-adapter.ts +168 -0
- package/src/plugins/ibkr/config.test.ts +68 -0
- package/src/plugins/ibkr/config.ts +262 -0
- package/src/plugins/ibkr/dialogs.tsx +83 -0
- package/src/plugins/ibkr/flex.test.ts +107 -0
- package/src/plugins/ibkr/flex.ts +267 -0
- package/src/plugins/ibkr/fundamental-parser.ts +149 -0
- package/src/plugins/ibkr/gateway-helpers.ts +28 -0
- package/src/plugins/ibkr/gateway-selection.ts +51 -0
- package/src/plugins/ibkr/gateway-service.test.ts +450 -0
- package/src/plugins/ibkr/gateway-service.ts +2228 -0
- package/src/plugins/ibkr/index.test.tsx +183 -0
- package/src/plugins/ibkr/index.tsx +153 -0
- package/src/plugins/ibkr/instance-selection.test.ts +121 -0
- package/src/plugins/ibkr/instance-selection.ts +36 -0
- package/src/plugins/ibkr/price-normalization.test.ts +38 -0
- package/src/plugins/ibkr/price-normalization.ts +59 -0
- package/src/plugins/ibkr/trade-badge.tsx +27 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
- package/src/plugins/ibkr/trade-tab.tsx +1050 -0
- package/src/plugins/ibkr/trade-utils.test.ts +175 -0
- package/src/plugins/ibkr/trade-utils.ts +149 -0
- package/src/plugins/ibkr/trading-pane.tsx +438 -0
- package/src/plugins/ibkr/trading-state.test.ts +109 -0
- package/src/plugins/ibkr/trading-state.ts +339 -0
- package/src/plugins/loader.ts +71 -0
- package/src/plugins/pane-manager.test.ts +103 -0
- package/src/plugins/pane-manager.ts +974 -0
- package/src/plugins/plugin-persistence.ts +85 -0
- package/src/plugins/plugin-runtime.test.tsx +185 -0
- package/src/plugins/plugin-runtime.tsx +175 -0
- package/src/plugins/prediction-markets/cache.ts +114 -0
- package/src/plugins/prediction-markets/categories.ts +223 -0
- package/src/plugins/prediction-markets/chart.tsx +512 -0
- package/src/plugins/prediction-markets/columns.ts +133 -0
- package/src/plugins/prediction-markets/controller-data.ts +614 -0
- package/src/plugins/prediction-markets/controller.ts +725 -0
- package/src/plugins/prediction-markets/detail/book.tsx +118 -0
- package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
- package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
- package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
- package/src/plugins/prediction-markets/detail.test.tsx +47 -0
- package/src/plugins/prediction-markets/index.tsx +102 -0
- package/src/plugins/prediction-markets/keyboard.ts +64 -0
- package/src/plugins/prediction-markets/launch.test.ts +126 -0
- package/src/plugins/prediction-markets/launch.ts +240 -0
- package/src/plugins/prediction-markets/metrics.ts +311 -0
- package/src/plugins/prediction-markets/navigation.ts +90 -0
- package/src/plugins/prediction-markets/outcome-order.ts +19 -0
- package/src/plugins/prediction-markets/pane.test.tsx +425 -0
- package/src/plugins/prediction-markets/pane.tsx +294 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
- package/src/plugins/prediction-markets/rows.ts +229 -0
- package/src/plugins/prediction-markets/services/fetch.ts +97 -0
- package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
- package/src/plugins/prediction-markets/settings.ts +164 -0
- package/src/plugins/prediction-markets/status.ts +24 -0
- package/src/plugins/prediction-markets/table.tsx +84 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
- package/src/plugins/prediction-markets/types.ts +186 -0
- package/src/plugins/registry.ts +633 -0
- package/src/sources/exchange-rates.ts +18 -0
- package/src/sources/gloomberb-cloud.test.ts +199 -0
- package/src/sources/gloomberb-cloud.ts +326 -0
- package/src/sources/provider-errors.ts +19 -0
- package/src/sources/provider-router.test.ts +1476 -0
- package/src/sources/provider-router.ts +1405 -0
- package/src/sources/sec-edgar.test.ts +286 -0
- package/src/sources/sec-edgar.ts +526 -0
- package/src/sources/yahoo-finance.test.ts +14 -0
- package/src/sources/yahoo-finance.ts +1017 -0
- package/src/state/app-activity.test.ts +41 -0
- package/src/state/app-activity.ts +106 -0
- package/src/state/app-bootstrap.test.ts +471 -0
- package/src/state/app-bootstrap.ts +308 -0
- package/src/state/app-context.test.ts +406 -0
- package/src/state/app-context.test.tsx +162 -0
- package/src/state/app-context.tsx +981 -0
- package/src/state/selectors.ts +67 -0
- package/src/state/session-persistence.test.ts +128 -0
- package/src/state/session-persistence.ts +161 -0
- package/src/state/ticker-refresh-queue.test.ts +46 -0
- package/src/state/ticker-refresh-queue.ts +40 -0
- package/src/state/use-inline-tickers.ts +174 -0
- package/src/state/use-quote-streaming.test.ts +37 -0
- package/src/state/use-quote-streaming.test.tsx +70 -0
- package/src/state/use-quote-streaming.ts +80 -0
- package/src/state/use-session-snapshot.ts +84 -0
- package/src/test-support/data-provider.ts +22 -0
- package/src/theme/color-utils.ts +67 -0
- package/src/theme/colors.ts +128 -0
- package/src/theme/themes.test.ts +45 -0
- package/src/theme/themes.ts +491 -0
- package/src/types/broker.ts +105 -0
- package/src/types/config.ts +444 -0
- package/src/types/data-provider.ts +81 -0
- package/src/types/financials.ts +142 -0
- package/src/types/instrument.ts +29 -0
- package/src/types/persistence.ts +21 -0
- package/src/types/plugin.ts +354 -0
- package/src/types/ticker.ts +58 -0
- package/src/types/trading.ts +85 -0
- package/src/updater.test.ts +282 -0
- package/src/updater.ts +303 -0
- package/src/utils/api-client.ts +666 -0
- package/src/utils/ascii-chart.ts +172 -0
- package/src/utils/broker-instances.ts +47 -0
- package/src/utils/cli-output.ts +83 -0
- package/src/utils/currency-units.test.ts +49 -0
- package/src/utils/currency-units.ts +82 -0
- package/src/utils/debug-log.ts +133 -0
- package/src/utils/exchange-rate-map.ts +8 -0
- package/src/utils/format.ts +158 -0
- package/src/utils/fuzzy-search.ts +34 -0
- package/src/utils/market-status.ts +95 -0
- package/src/utils/options.ts +86 -0
- package/src/utils/price-history.ts +39 -0
- package/src/utils/quote-time.test.ts +22 -0
- package/src/utils/quote-time.ts +50 -0
- package/src/utils/sec.test.ts +55 -0
- package/src/utils/sec.ts +45 -0
- package/src/utils/ticker-list.test.ts +22 -0
- package/src/utils/ticker-list.ts +35 -0
- package/src/utils/ticker-metadata.ts +51 -0
- package/src/utils/ticker-search.test.ts +227 -0
- package/src/utils/ticker-search.ts +764 -0
- package/src/utils/ticker-tokenizer.test.ts +48 -0
- package/src/utils/ticker-tokenizer.ts +72 -0
- package/src/version.ts +1 -0
- package/install.mjs +0 -68
|
@@ -0,0 +1,2228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarSizeSetting,
|
|
3
|
+
ConnectionState,
|
|
4
|
+
EventName,
|
|
5
|
+
IBApiTickType,
|
|
6
|
+
IBApiNext,
|
|
7
|
+
MarketDataType,
|
|
8
|
+
OptionType,
|
|
9
|
+
OrderAction,
|
|
10
|
+
OrderType,
|
|
11
|
+
SecType,
|
|
12
|
+
TimeInForce,
|
|
13
|
+
WhatToShow,
|
|
14
|
+
type Contract,
|
|
15
|
+
type ContractDescription,
|
|
16
|
+
type ContractDetails,
|
|
17
|
+
type MarketDataUpdate,
|
|
18
|
+
type Order,
|
|
19
|
+
type OrderState,
|
|
20
|
+
type TickByTickAllLast,
|
|
21
|
+
} from "@stoqey/ib";
|
|
22
|
+
import { mkdir, open, readFile, rm } from "fs/promises";
|
|
23
|
+
import { createConnection } from "net";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
import { firstValueFrom, filter, take, timeout } from "rxjs";
|
|
27
|
+
import type { TimeRange } from "../../components/chart/chart-types";
|
|
28
|
+
import type { BrokerConnectionStatus, BrokerPosition } from "../../types/broker";
|
|
29
|
+
import type { QuoteSubscriptionTarget } from "../../types/data-provider";
|
|
30
|
+
import type { Fundamentals, FinancialStatement, Quote, PricePoint, TickerFinancials } from "../../types/financials";
|
|
31
|
+
import type { BrokerContractRef, InstrumentSearchResult } from "../../types/instrument";
|
|
32
|
+
import type {
|
|
33
|
+
BrokerAccount,
|
|
34
|
+
BrokerCashBalance,
|
|
35
|
+
BrokerExecution,
|
|
36
|
+
BrokerOrder,
|
|
37
|
+
BrokerOrderPreview,
|
|
38
|
+
BrokerOrderRequest,
|
|
39
|
+
} from "../../types/trading";
|
|
40
|
+
import { debugLog } from "../../utils/debug-log";
|
|
41
|
+
import { parseReportSnapshot, parseFinStatements } from "./fundamental-parser";
|
|
42
|
+
import { getIbkrPriceDivisor, normalizeIbkrPriceValue } from "./price-normalization";
|
|
43
|
+
|
|
44
|
+
export interface IbkrGatewayConfig {
|
|
45
|
+
host: string;
|
|
46
|
+
port?: number;
|
|
47
|
+
clientId?: number;
|
|
48
|
+
lastSuccessfulPort?: number;
|
|
49
|
+
lastSuccessfulClientId?: number;
|
|
50
|
+
marketDataType?: "auto" | "live" | "frozen" | "delayed" | "delayed-frozen";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolvedIbkrGatewayConnection {
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
clientId: number;
|
|
57
|
+
requestedPort?: number;
|
|
58
|
+
requestedClientId: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface IbkrSnapshot {
|
|
62
|
+
status: BrokerConnectionStatus;
|
|
63
|
+
accounts: BrokerAccount[];
|
|
64
|
+
openOrders: BrokerOrder[];
|
|
65
|
+
executions: BrokerExecution[];
|
|
66
|
+
lastError?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ClientLockMetadata {
|
|
70
|
+
pid: number;
|
|
71
|
+
ownerToken: string;
|
|
72
|
+
requestedClientId: number;
|
|
73
|
+
clientId: number;
|
|
74
|
+
host: string;
|
|
75
|
+
port: number;
|
|
76
|
+
instanceId?: string;
|
|
77
|
+
cwd: string;
|
|
78
|
+
timestamp: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ClaimedClientLock {
|
|
82
|
+
clientId: number;
|
|
83
|
+
requestedClientId: number;
|
|
84
|
+
path: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type QuoteStreamListener = (target: QuoteSubscriptionTarget, quote: Quote) => void;
|
|
88
|
+
|
|
89
|
+
interface ActiveQuoteStream {
|
|
90
|
+
target: QuoteSubscriptionTarget;
|
|
91
|
+
listeners: Map<QuoteStreamListener, QuoteSubscriptionTarget>;
|
|
92
|
+
stop: () => void;
|
|
93
|
+
lastQuote?: Quote;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface AccountPortfolioSnapshotPosition {
|
|
97
|
+
contract: Contract;
|
|
98
|
+
avgCost?: number;
|
|
99
|
+
marketPrice?: number;
|
|
100
|
+
marketValue?: number;
|
|
101
|
+
unrealizedPNL?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_SNAPSHOT: IbkrSnapshot = {
|
|
105
|
+
status: { state: "disconnected", updatedAt: Date.now() },
|
|
106
|
+
accounts: [],
|
|
107
|
+
openOrders: [],
|
|
108
|
+
executions: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const gatewayLog = debugLog.createLogger("ibkr-gateway");
|
|
112
|
+
const IBKR_CLIENT_LOCK_DIR = join(tmpdir(), "gloomberb-ibkr-client-locks");
|
|
113
|
+
const IBKR_CLIENT_ID_SEARCH_SPAN = 64;
|
|
114
|
+
const LOCAL_IBKR_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
115
|
+
const COMMON_LOCAL_IBKR_PORTS = [4001, 4002, 7496, 7497] as const;
|
|
116
|
+
const LOCAL_TCP_PROBE_TIMEOUT_MS = 250;
|
|
117
|
+
const DEFAULT_AUTO_CLIENT_ID = 1;
|
|
118
|
+
|
|
119
|
+
type ResolvedGatewayListener = (
|
|
120
|
+
instanceId: string | undefined,
|
|
121
|
+
connection: ResolvedIbkrGatewayConnection,
|
|
122
|
+
) => void | Promise<void>;
|
|
123
|
+
|
|
124
|
+
let resolvedGatewayListener: ResolvedGatewayListener | null = null;
|
|
125
|
+
|
|
126
|
+
export function setResolvedIbkrGatewayListener(listener: ResolvedGatewayListener | null): void {
|
|
127
|
+
resolvedGatewayListener = listener;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type AccountSummaryValueMap = ReadonlyMap<string, { value: string }>;
|
|
131
|
+
type AccountSummaryTags = ReadonlyMap<string, AccountSummaryValueMap>;
|
|
132
|
+
|
|
133
|
+
function getAccountSummaryEntry(
|
|
134
|
+
values: AccountSummaryValueMap | undefined,
|
|
135
|
+
preferredCurrency?: string,
|
|
136
|
+
): { currency: string; value: number } | null {
|
|
137
|
+
if (!values) return null;
|
|
138
|
+
|
|
139
|
+
if (preferredCurrency) {
|
|
140
|
+
const preferred = values.get(preferredCurrency);
|
|
141
|
+
if (preferred?.value) {
|
|
142
|
+
const numeric = parseFloat(preferred.value);
|
|
143
|
+
if (Number.isFinite(numeric)) {
|
|
144
|
+
return { currency: preferredCurrency, value: numeric };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const [currency, entry] of values.entries()) {
|
|
150
|
+
const numeric = parseFloat(entry?.value ?? "");
|
|
151
|
+
if (Number.isFinite(numeric)) {
|
|
152
|
+
return { currency, value: numeric };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getAccountSummaryNumber(
|
|
159
|
+
tags: AccountSummaryTags | undefined,
|
|
160
|
+
tagName: string,
|
|
161
|
+
preferredCurrency?: string,
|
|
162
|
+
): number | undefined {
|
|
163
|
+
return getAccountSummaryEntry(tags?.get(tagName), preferredCurrency)?.value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getAccountSummaryNumberWithAggregateFallback(
|
|
167
|
+
tags: AccountSummaryTags | undefined,
|
|
168
|
+
aggregateTags: AccountSummaryTags | undefined,
|
|
169
|
+
tagName: string,
|
|
170
|
+
preferredCurrency: string | undefined,
|
|
171
|
+
allowAggregateFallback: boolean,
|
|
172
|
+
): number | undefined {
|
|
173
|
+
const direct = getAccountSummaryNumber(tags, tagName, preferredCurrency);
|
|
174
|
+
if (direct != null) return direct;
|
|
175
|
+
if (!allowAggregateFallback) return undefined;
|
|
176
|
+
return getAccountSummaryNumber(aggregateTags, tagName, preferredCurrency);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function inferAccountCurrency(tags: AccountSummaryTags | undefined): string | undefined {
|
|
180
|
+
if (!tags) return undefined;
|
|
181
|
+
for (const tagName of ["NetLiquidation", "TotalCashValue", "SettledCash", "AvailableFunds"]) {
|
|
182
|
+
const entry = getAccountSummaryEntry(tags.get(tagName));
|
|
183
|
+
if (entry?.currency) return entry.currency;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getSummaryMap(
|
|
189
|
+
tags: AccountSummaryTags | undefined,
|
|
190
|
+
tagName: string,
|
|
191
|
+
): AccountSummaryValueMap | undefined {
|
|
192
|
+
const values = tags?.get(tagName);
|
|
193
|
+
return values && values.size > 0 ? values : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildCashBalancesFromSummary(
|
|
197
|
+
cashBalanceMap: AccountSummaryValueMap | undefined,
|
|
198
|
+
exchangeRateMap: AccountSummaryValueMap | undefined,
|
|
199
|
+
baseCurrency?: string,
|
|
200
|
+
): BrokerCashBalance[] | undefined {
|
|
201
|
+
const balances: BrokerCashBalance[] = [];
|
|
202
|
+
for (const [currency, entry] of cashBalanceMap?.entries() ?? []) {
|
|
203
|
+
if (!currency || currency === "BASE") continue;
|
|
204
|
+
const numeric = parseFloat(entry?.value ?? "");
|
|
205
|
+
if (!Number.isFinite(numeric)) continue;
|
|
206
|
+
|
|
207
|
+
const exchangeRate = currency === baseCurrency
|
|
208
|
+
? 1
|
|
209
|
+
: parseFloat(exchangeRateMap?.get(currency)?.value ?? "");
|
|
210
|
+
const baseValue = Number.isFinite(exchangeRate) ? numeric * exchangeRate : undefined;
|
|
211
|
+
|
|
212
|
+
balances.push({
|
|
213
|
+
currency,
|
|
214
|
+
quantity: numeric,
|
|
215
|
+
baseValue,
|
|
216
|
+
baseCurrency,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return balances.length > 0 ? balances : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildCashBalances(
|
|
224
|
+
tags: AccountSummaryTags | undefined,
|
|
225
|
+
aggregateTags: AccountSummaryTags | undefined,
|
|
226
|
+
baseCurrency: string | undefined,
|
|
227
|
+
allowAggregateFallback: boolean,
|
|
228
|
+
): BrokerCashBalance[] | undefined {
|
|
229
|
+
const directLedger = buildCashBalancesFromSummary(
|
|
230
|
+
getSummaryMap(tags, "$LEDGER:ALL"),
|
|
231
|
+
undefined,
|
|
232
|
+
baseCurrency,
|
|
233
|
+
);
|
|
234
|
+
if (directLedger) return directLedger;
|
|
235
|
+
|
|
236
|
+
const directSummary = buildCashBalancesFromSummary(
|
|
237
|
+
getSummaryMap(tags, "CashBalance") ?? getSummaryMap(tags, "TotalCashBalance"),
|
|
238
|
+
getSummaryMap(tags, "ExchangeRate"),
|
|
239
|
+
baseCurrency,
|
|
240
|
+
);
|
|
241
|
+
if (directSummary) return directSummary;
|
|
242
|
+
|
|
243
|
+
if (!allowAggregateFallback) return undefined;
|
|
244
|
+
|
|
245
|
+
return buildCashBalancesFromSummary(
|
|
246
|
+
getSummaryMap(aggregateTags, "CashBalance") ?? getSummaryMap(aggregateTags, "TotalCashBalance"),
|
|
247
|
+
getSummaryMap(aggregateTags, "ExchangeRate"),
|
|
248
|
+
baseCurrency,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function summarizeBrokerAccount(
|
|
253
|
+
accountId: string,
|
|
254
|
+
tags: AccountSummaryTags | undefined,
|
|
255
|
+
updatedAt: number,
|
|
256
|
+
aggregateTags?: AccountSummaryTags,
|
|
257
|
+
allowAggregateCashBalances = false,
|
|
258
|
+
): BrokerAccount {
|
|
259
|
+
const currency = inferAccountCurrency(tags) ?? (allowAggregateCashBalances ? inferAccountCurrency(aggregateTags) : undefined);
|
|
260
|
+
return {
|
|
261
|
+
accountId,
|
|
262
|
+
name: accountId,
|
|
263
|
+
currency,
|
|
264
|
+
source: "gateway",
|
|
265
|
+
updatedAt,
|
|
266
|
+
netLiquidation: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "NetLiquidation", currency, allowAggregateCashBalances),
|
|
267
|
+
totalCashValue: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "TotalCashValue", currency, allowAggregateCashBalances),
|
|
268
|
+
settledCash: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "SettledCash", currency, allowAggregateCashBalances),
|
|
269
|
+
availableFunds: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "AvailableFunds", currency, allowAggregateCashBalances),
|
|
270
|
+
buyingPower: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "BuyingPower", currency, allowAggregateCashBalances),
|
|
271
|
+
excessLiquidity: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "ExcessLiquidity", currency, allowAggregateCashBalances),
|
|
272
|
+
initMarginReq: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "InitMarginReq", currency, allowAggregateCashBalances),
|
|
273
|
+
maintMarginReq: getAccountSummaryNumberWithAggregateFallback(tags, aggregateTags, "MaintMarginReq", currency, allowAggregateCashBalances),
|
|
274
|
+
cashBalances: buildCashBalances(tags, aggregateTags, currency, allowAggregateCashBalances),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Wrap a promise with a timeout so that IBKR calls don't hang indefinitely. */
|
|
279
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
280
|
+
return new Promise<T>((resolve, reject) => {
|
|
281
|
+
const timer = setTimeout(() => reject(new Error(`IBKR ${label} timed out after ${ms}ms`)), ms);
|
|
282
|
+
promise.then(
|
|
283
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
284
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const IBKR_DATA_TIMEOUT = 8_000;
|
|
290
|
+
const IBKR_QUOTE_STREAM_TICKS = "165,221,233";
|
|
291
|
+
|
|
292
|
+
const HISTORY_PARAMS: Record<TimeRange, { duration: string; size: BarSizeSetting }> = {
|
|
293
|
+
"1W": { duration: "7 D", size: BarSizeSetting.HOURS_ONE },
|
|
294
|
+
"1M": { duration: "1 M", size: BarSizeSetting.HOURS_ONE },
|
|
295
|
+
"3M": { duration: "3 M", size: BarSizeSetting.DAYS_ONE },
|
|
296
|
+
"6M": { duration: "6 M", size: BarSizeSetting.DAYS_ONE },
|
|
297
|
+
"1Y": { duration: "1 Y", size: BarSizeSetting.DAYS_ONE },
|
|
298
|
+
"5Y": { duration: "5 Y", size: BarSizeSetting.WEEKS_ONE },
|
|
299
|
+
"ALL": { duration: "10 Y", size: BarSizeSetting.MONTHS_ONE },
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Map generic bar size labels to IBKR BarSizeSetting values. */
|
|
303
|
+
const GENERIC_BAR_SIZE_MAP: Record<string, BarSizeSetting> = {
|
|
304
|
+
"1m": BarSizeSetting.MINUTES_ONE,
|
|
305
|
+
"5m": BarSizeSetting.MINUTES_FIVE,
|
|
306
|
+
"15m": BarSizeSetting.MINUTES_FIFTEEN,
|
|
307
|
+
"30m": BarSizeSetting.MINUTES_THIRTY,
|
|
308
|
+
"1h": BarSizeSetting.HOURS_ONE,
|
|
309
|
+
"1d": BarSizeSetting.DAYS_ONE,
|
|
310
|
+
"1w": BarSizeSetting.WEEKS_ONE,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
function normalizeQuoteStreamTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
|
|
314
|
+
const symbol = target.symbol.trim().toUpperCase();
|
|
315
|
+
if (!symbol) return null;
|
|
316
|
+
return {
|
|
317
|
+
...target,
|
|
318
|
+
symbol,
|
|
319
|
+
exchange: (target.exchange ?? "").trim().toUpperCase(),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildQuoteStreamKey(target: QuoteSubscriptionTarget): string {
|
|
324
|
+
const contractKey = target.context?.instrument?.conId
|
|
325
|
+
?? target.context?.instrument?.localSymbol
|
|
326
|
+
?? target.context?.instrument?.symbol
|
|
327
|
+
?? "";
|
|
328
|
+
return [target.symbol, target.exchange ?? "", contractKey].join("|");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasDelayedMarketData(
|
|
332
|
+
marketData: ReadonlyMap<number, { value?: number; ingressTm: number }>,
|
|
333
|
+
): boolean {
|
|
334
|
+
return [
|
|
335
|
+
IBApiTickType.DELAYED_BID,
|
|
336
|
+
IBApiTickType.DELAYED_ASK,
|
|
337
|
+
IBApiTickType.DELAYED_LAST,
|
|
338
|
+
IBApiTickType.DELAYED_BID_SIZE,
|
|
339
|
+
IBApiTickType.DELAYED_ASK_SIZE,
|
|
340
|
+
IBApiTickType.DELAYED_LAST_SIZE,
|
|
341
|
+
IBApiTickType.DELAYED_HIGH,
|
|
342
|
+
IBApiTickType.DELAYED_LOW,
|
|
343
|
+
IBApiTickType.DELAYED_VOLUME,
|
|
344
|
+
IBApiTickType.DELAYED_CLOSE,
|
|
345
|
+
IBApiTickType.DELAYED_OPEN,
|
|
346
|
+
IBApiTickType.DELAYED_LAST_TIMESTAMP,
|
|
347
|
+
].some((tickType) => marketData.has(tickType));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface TickByTickBidAskUpdate {
|
|
351
|
+
time: number;
|
|
352
|
+
bidPrice: number;
|
|
353
|
+
askPrice: number;
|
|
354
|
+
bidSize: number;
|
|
355
|
+
askSize: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function applyTickByTickAllLastToQuote(
|
|
359
|
+
current: Quote | undefined,
|
|
360
|
+
contract: Contract,
|
|
361
|
+
details: ContractDetails,
|
|
362
|
+
tick: TickByTickAllLast,
|
|
363
|
+
priceDivisor: number,
|
|
364
|
+
dataSource: Quote["dataSource"],
|
|
365
|
+
): Quote | null {
|
|
366
|
+
const nextPrice = normalizeIbkrPriceValue(tick.price, priceDivisor);
|
|
367
|
+
if (nextPrice == null || nextPrice <= 0) return null;
|
|
368
|
+
|
|
369
|
+
const previousClose = current?.previousClose ?? current?.price ?? nextPrice;
|
|
370
|
+
const change = nextPrice - previousClose;
|
|
371
|
+
return {
|
|
372
|
+
symbol: current?.symbol ?? contract.localSymbol ?? contract.symbol ?? "",
|
|
373
|
+
providerId: "ibkr",
|
|
374
|
+
price: nextPrice,
|
|
375
|
+
currency: current?.currency ?? contract.currency ?? "USD",
|
|
376
|
+
change,
|
|
377
|
+
changePercent: previousClose ? (change / previousClose) * 100 : 0,
|
|
378
|
+
previousClose,
|
|
379
|
+
high52w: current?.high52w,
|
|
380
|
+
low52w: current?.low52w,
|
|
381
|
+
volume: current?.volume,
|
|
382
|
+
name: current?.name ?? details.longName ?? details.marketName ?? contract.symbol,
|
|
383
|
+
lastUpdated: tick.time ? tick.time * 1000 : Date.now(),
|
|
384
|
+
exchangeName: current?.exchangeName ?? details.validExchanges?.split(",")[0],
|
|
385
|
+
fullExchangeName: current?.fullExchangeName ?? details.validExchanges?.split(",")[0],
|
|
386
|
+
marketState: current?.marketState ?? "REGULAR",
|
|
387
|
+
preMarketPrice: current?.preMarketPrice,
|
|
388
|
+
preMarketChange: current?.preMarketChange,
|
|
389
|
+
preMarketChangePercent: current?.preMarketChangePercent,
|
|
390
|
+
postMarketPrice: current?.postMarketPrice,
|
|
391
|
+
postMarketChange: current?.postMarketChange,
|
|
392
|
+
postMarketChangePercent: current?.postMarketChangePercent,
|
|
393
|
+
bid: current?.bid,
|
|
394
|
+
ask: current?.ask,
|
|
395
|
+
bidSize: current?.bidSize,
|
|
396
|
+
askSize: current?.askSize,
|
|
397
|
+
open: current?.open,
|
|
398
|
+
high: current?.high,
|
|
399
|
+
low: current?.low,
|
|
400
|
+
mark: current?.mark,
|
|
401
|
+
dataSource,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function applyTickByTickBidAskToQuote(
|
|
406
|
+
current: Quote | undefined,
|
|
407
|
+
update: TickByTickBidAskUpdate,
|
|
408
|
+
priceDivisor: number,
|
|
409
|
+
): Quote | null {
|
|
410
|
+
if (!current) return null;
|
|
411
|
+
const bid = normalizeIbkrPriceValue(update.bidPrice, priceDivisor);
|
|
412
|
+
const ask = normalizeIbkrPriceValue(update.askPrice, priceDivisor);
|
|
413
|
+
return {
|
|
414
|
+
...current,
|
|
415
|
+
bid: bid ?? current.bid,
|
|
416
|
+
ask: ask ?? current.ask,
|
|
417
|
+
bidSize: Number.isFinite(update.bidSize) && update.bidSize >= 0 ? update.bidSize : current.bidSize,
|
|
418
|
+
askSize: Number.isFinite(update.askSize) && update.askSize >= 0 ? update.askSize : current.askSize,
|
|
419
|
+
lastUpdated: update.time ? update.time * 1000 : current.lastUpdated,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function marketDataTypeFromConfig(config?: IbkrGatewayConfig): MarketDataType {
|
|
424
|
+
switch (config?.marketDataType) {
|
|
425
|
+
case "frozen":
|
|
426
|
+
return MarketDataType.FROZEN;
|
|
427
|
+
case "delayed":
|
|
428
|
+
return MarketDataType.DELAYED;
|
|
429
|
+
case "delayed-frozen":
|
|
430
|
+
return MarketDataType.DELAYED_FROZEN;
|
|
431
|
+
case "live":
|
|
432
|
+
return MarketDataType.REALTIME;
|
|
433
|
+
case "auto":
|
|
434
|
+
default:
|
|
435
|
+
// Start with realtime; withMarketDataFallback will downgrade to delayed
|
|
436
|
+
// if the account lacks live market data subscriptions.
|
|
437
|
+
return MarketDataType.REALTIME;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isMarketDataPermissionError(code: number | undefined, message: string | undefined): boolean {
|
|
442
|
+
if (code === 354 || code === 10167) return true;
|
|
443
|
+
const text = (message || "").toLowerCase();
|
|
444
|
+
return text.includes("displaying delayed market data")
|
|
445
|
+
|| text.includes("delayed market data is available")
|
|
446
|
+
|| text.includes("market data connections")
|
|
447
|
+
|| text.includes("requested market data is not subscribed")
|
|
448
|
+
|| text.includes("requested market data requires additional subscription")
|
|
449
|
+
|| text.includes("market data subscription");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function isIbInformationalWarning(code: number | undefined, message: string | undefined): boolean {
|
|
453
|
+
if (code != null && [1102, 2104, 2106, 2107, 2108, 2158].includes(code)) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const text = (message || "").toLowerCase();
|
|
458
|
+
return text.includes("connection is ok")
|
|
459
|
+
|| text.includes("connectivity between ib and trader workstation has been restored")
|
|
460
|
+
|| text.includes("inactive but should be available upon demand")
|
|
461
|
+
|| text.includes("sec-def data farm connection is ok")
|
|
462
|
+
|| text.includes("market data farm connection is ok")
|
|
463
|
+
|| text.includes("hmds data farm connection is ok");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function getIbErrorCode(error: any): number | undefined {
|
|
467
|
+
const candidates = [
|
|
468
|
+
error?.errorCode,
|
|
469
|
+
error?.code,
|
|
470
|
+
error?.error?.errorCode,
|
|
471
|
+
error?.error?.code,
|
|
472
|
+
error?.id,
|
|
473
|
+
];
|
|
474
|
+
for (const candidate of candidates) {
|
|
475
|
+
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
476
|
+
return candidate;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getIbErrorMessage(error: any): string | undefined {
|
|
483
|
+
const candidates = [
|
|
484
|
+
error?.error?.message,
|
|
485
|
+
error?.message,
|
|
486
|
+
error?.error?.toString?.(),
|
|
487
|
+
];
|
|
488
|
+
for (const candidate of candidates) {
|
|
489
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
490
|
+
return candidate;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function isClientIdInUseError(code: number | undefined, message: string | undefined): boolean {
|
|
497
|
+
if (code === 326) return true;
|
|
498
|
+
const text = (message || "").toLowerCase();
|
|
499
|
+
return text.includes("client id is already in use")
|
|
500
|
+
|| text.includes("clientid is already in use")
|
|
501
|
+
|| text.includes("client id already in use");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function buildClientLockPath(config: IbkrGatewayConfig, clientId: number): string {
|
|
505
|
+
const host = (config.host || "localhost").replace(/[^a-z0-9_.-]+/gi, "_");
|
|
506
|
+
return join(IBKR_CLIENT_LOCK_DIR, `${host}-${config.port}-${clientId}.lock`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function buildPortfolioPositionKey(accountId: string, contract: Contract): string {
|
|
510
|
+
return [
|
|
511
|
+
accountId.trim(),
|
|
512
|
+
contract.conId ?? "",
|
|
513
|
+
contract.localSymbol ?? "",
|
|
514
|
+
contract.symbol ?? "",
|
|
515
|
+
contract.secType ?? "",
|
|
516
|
+
].join("|");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isProcessAlive(pid: number | undefined): boolean {
|
|
520
|
+
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
|
521
|
+
try {
|
|
522
|
+
process.kill(pid, 0);
|
|
523
|
+
return true;
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readClientLock(path: string): Promise<ClientLockMetadata | null> {
|
|
530
|
+
try {
|
|
531
|
+
const raw = await readFile(path, "utf-8");
|
|
532
|
+
const parsed = JSON.parse(raw) as Partial<ClientLockMetadata>;
|
|
533
|
+
if (
|
|
534
|
+
typeof parsed.pid !== "number"
|
|
535
|
+
|| typeof parsed.ownerToken !== "string"
|
|
536
|
+
|| typeof parsed.clientId !== "number"
|
|
537
|
+
|| typeof parsed.requestedClientId !== "number"
|
|
538
|
+
) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
pid: parsed.pid,
|
|
543
|
+
ownerToken: parsed.ownerToken,
|
|
544
|
+
clientId: parsed.clientId,
|
|
545
|
+
requestedClientId: parsed.requestedClientId,
|
|
546
|
+
host: typeof parsed.host === "string" ? parsed.host : "",
|
|
547
|
+
port: typeof parsed.port === "number" ? parsed.port : 0,
|
|
548
|
+
instanceId: typeof parsed.instanceId === "string" ? parsed.instanceId : undefined,
|
|
549
|
+
cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
|
|
550
|
+
timestamp: typeof parsed.timestamp === "number" ? parsed.timestamp : 0,
|
|
551
|
+
};
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildConnectionNote(
|
|
558
|
+
requestedClientId: number,
|
|
559
|
+
actualClientId: number,
|
|
560
|
+
requestedPort: number | undefined,
|
|
561
|
+
actualPort: number,
|
|
562
|
+
): string | undefined {
|
|
563
|
+
const notes: string[] = [];
|
|
564
|
+
if (requestedPort == null || requestedPort !== actualPort) {
|
|
565
|
+
notes.push(`Detected IBKR API on port ${actualPort}.`);
|
|
566
|
+
}
|
|
567
|
+
if (requestedClientId !== actualClientId) {
|
|
568
|
+
notes.push(`Using client ID ${actualClientId} because ${requestedClientId} is already in use.`);
|
|
569
|
+
}
|
|
570
|
+
return notes.length > 0 ? notes.join(" ") : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function isRetryableErrorCode(code: number): boolean {
|
|
574
|
+
return ![200, 201, 202, 321, 354].includes(code);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function isLoopbackHost(host: string): boolean {
|
|
578
|
+
return LOCAL_IBKR_HOSTS.has(host.trim().toLowerCase());
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function probeTcpPort(host: string, port: number, timeoutMs = LOCAL_TCP_PROBE_TIMEOUT_MS): Promise<boolean> {
|
|
582
|
+
return new Promise<boolean>((resolve) => {
|
|
583
|
+
let settled = false;
|
|
584
|
+
const finish = (result: boolean) => {
|
|
585
|
+
if (settled) return;
|
|
586
|
+
settled = true;
|
|
587
|
+
socket.removeAllListeners();
|
|
588
|
+
socket.destroy();
|
|
589
|
+
resolve(result);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const socket = createConnection({ host, port });
|
|
593
|
+
socket.once("connect", () => finish(true));
|
|
594
|
+
socket.once("error", () => finish(false));
|
|
595
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function uniquePorts(...values: Array<number | undefined | readonly number[]>): number[] {
|
|
600
|
+
const unique = new Set<number>();
|
|
601
|
+
for (const value of values) {
|
|
602
|
+
if (Array.isArray(value)) {
|
|
603
|
+
for (const candidate of value) {
|
|
604
|
+
if (Number.isFinite(candidate)) unique.add(candidate);
|
|
605
|
+
}
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
609
|
+
unique.add(value);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return [...unique];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
interface IbkrPortDiagnosticOptions {
|
|
616
|
+
candidatePorts?: readonly number[];
|
|
617
|
+
probePort?: (host: string, port: number) => Promise<boolean>;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export async function diagnoseLocalIbkrPortIssue(
|
|
621
|
+
config: IbkrGatewayConfig,
|
|
622
|
+
options: IbkrPortDiagnosticOptions = {},
|
|
623
|
+
): Promise<string | null> {
|
|
624
|
+
if (typeof config.port !== "number" || !Number.isFinite(config.port)) return null;
|
|
625
|
+
if (!isLoopbackHost(config.host)) return null;
|
|
626
|
+
|
|
627
|
+
const candidatePorts = options.candidatePorts ?? COMMON_LOCAL_IBKR_PORTS;
|
|
628
|
+
const probePort = options.probePort ?? ((host: string, port: number) => probeTcpPort(host, port));
|
|
629
|
+
if (await probePort(config.host, config.port)) return null;
|
|
630
|
+
|
|
631
|
+
const openAlternatives: number[] = [];
|
|
632
|
+
for (const port of candidatePorts) {
|
|
633
|
+
if (port === config.port) continue;
|
|
634
|
+
if (await probePort(config.host, port)) openAlternatives.push(port);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (openAlternatives.length === 0) {
|
|
638
|
+
return `IBKR is not listening on ${config.host}:${config.port}. Start Gateway/TWS and confirm the API socket port in the IBKR API settings.`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const detectedTargets = openAlternatives.map((port) => `${config.host}:${port}`).join(", ");
|
|
642
|
+
return `IBKR is not listening on ${config.host}:${config.port}. Detected a local IBKR API listener on ${detectedTargets} instead. Update this profile's port to match Gateway/TWS.`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function resolveGatewayConnection(
|
|
646
|
+
config: IbkrGatewayConfig,
|
|
647
|
+
options: IbkrPortDiagnosticOptions = {},
|
|
648
|
+
): Promise<ResolvedIbkrGatewayConnection> {
|
|
649
|
+
const host = (config.host || "127.0.0.1").trim() || "127.0.0.1";
|
|
650
|
+
const requestedPort = typeof config.port === "number" && Number.isFinite(config.port) ? config.port : undefined;
|
|
651
|
+
const requestedClientId = typeof config.clientId === "number" && Number.isFinite(config.clientId)
|
|
652
|
+
? config.clientId
|
|
653
|
+
: typeof config.lastSuccessfulClientId === "number" && Number.isFinite(config.lastSuccessfulClientId)
|
|
654
|
+
? config.lastSuccessfulClientId
|
|
655
|
+
: DEFAULT_AUTO_CLIENT_ID;
|
|
656
|
+
const probePort = options.probePort ?? ((candidateHost: string, candidatePort: number) => probeTcpPort(candidateHost, candidatePort));
|
|
657
|
+
|
|
658
|
+
if (requestedPort != null) {
|
|
659
|
+
const localPortIssue = await diagnoseLocalIbkrPortIssue({
|
|
660
|
+
host,
|
|
661
|
+
port: requestedPort,
|
|
662
|
+
clientId: requestedClientId,
|
|
663
|
+
marketDataType: config.marketDataType,
|
|
664
|
+
}, options);
|
|
665
|
+
if (localPortIssue) throw new Error(localPortIssue);
|
|
666
|
+
return {
|
|
667
|
+
host,
|
|
668
|
+
port: requestedPort,
|
|
669
|
+
clientId: requestedClientId,
|
|
670
|
+
requestedPort,
|
|
671
|
+
requestedClientId,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (isLoopbackHost(host)) {
|
|
676
|
+
const candidatePorts = uniquePorts(config.lastSuccessfulPort, options.candidatePorts ?? COMMON_LOCAL_IBKR_PORTS);
|
|
677
|
+
for (const port of candidatePorts) {
|
|
678
|
+
if (await probePort(host, port)) {
|
|
679
|
+
return {
|
|
680
|
+
host,
|
|
681
|
+
port,
|
|
682
|
+
clientId: requestedClientId,
|
|
683
|
+
requestedPort: typeof config.lastSuccessfulPort === "number" ? config.lastSuccessfulPort : undefined,
|
|
684
|
+
requestedClientId,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
throw new Error(
|
|
690
|
+
`No local IBKR API listeners were detected on ${host}. Checked ports ${candidatePorts.join(", ")}. Start Gateway/TWS and enable socket clients in the IBKR API settings.`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const fallbackPort = typeof config.lastSuccessfulPort === "number" && Number.isFinite(config.lastSuccessfulPort)
|
|
695
|
+
? config.lastSuccessfulPort
|
|
696
|
+
: COMMON_LOCAL_IBKR_PORTS[0];
|
|
697
|
+
return {
|
|
698
|
+
host,
|
|
699
|
+
port: fallbackPort,
|
|
700
|
+
clientId: requestedClientId,
|
|
701
|
+
requestedPort: typeof config.lastSuccessfulPort === "number" ? config.lastSuccessfulPort : undefined,
|
|
702
|
+
requestedClientId,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export class IbkrGatewayService {
|
|
707
|
+
private api: IBApiNext | null = null;
|
|
708
|
+
private configKey: string | null = null;
|
|
709
|
+
private connecting: Promise<void> | null = null;
|
|
710
|
+
/** Account IDs captured from the managedAccounts event on initial connection. */
|
|
711
|
+
private cachedAccountIds: string[] = [];
|
|
712
|
+
private snapshot: IbkrSnapshot = DEFAULT_SNAPSHOT;
|
|
713
|
+
private listeners = new Set<() => void>();
|
|
714
|
+
private activeMarketDataType: MarketDataType = MarketDataType.REALTIME;
|
|
715
|
+
private autoMarketData = true;
|
|
716
|
+
/** Dedup in-flight requests: reuse pending promises for the same key. */
|
|
717
|
+
private pendingRequests = new Map<string, Promise<any>>();
|
|
718
|
+
private readonly clientLockOwner = `${process.pid}:${Math.random().toString(36).slice(2, 10)}`;
|
|
719
|
+
private claimedClientLock: ClaimedClientLock | null = null;
|
|
720
|
+
private connectionNote?: string;
|
|
721
|
+
private resolvedConnection: ResolvedIbkrGatewayConnection | null = null;
|
|
722
|
+
private readonly quoteStreams = new Map<string, ActiveQuoteStream>();
|
|
723
|
+
private readonly quoteStreamStarts = new Map<string, Promise<void>>();
|
|
724
|
+
|
|
725
|
+
constructor(private readonly instanceId?: string) {}
|
|
726
|
+
|
|
727
|
+
/** Dedup concurrent calls with the same key — returns cached promise if one is in flight. */
|
|
728
|
+
private dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
729
|
+
const existing = this.pendingRequests.get(key);
|
|
730
|
+
if (existing) return existing as Promise<T>;
|
|
731
|
+
const promise = fn().finally(() => this.pendingRequests.delete(key));
|
|
732
|
+
this.pendingRequests.set(key, promise);
|
|
733
|
+
return promise;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
subscribe(listener: () => void): () => void {
|
|
737
|
+
this.listeners.add(listener);
|
|
738
|
+
return () => this.listeners.delete(listener);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
getSnapshot(): IbkrSnapshot {
|
|
742
|
+
return this.snapshot;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
getStatus(): BrokerConnectionStatus {
|
|
746
|
+
return this.snapshot.status;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
getResolvedConnection(): ResolvedIbkrGatewayConnection | null {
|
|
750
|
+
return this.resolvedConnection;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async connect(config: IbkrGatewayConfig): Promise<void> {
|
|
754
|
+
const nextKey = JSON.stringify({
|
|
755
|
+
host: config.host,
|
|
756
|
+
port: config.port ?? null,
|
|
757
|
+
clientId: config.clientId ?? null,
|
|
758
|
+
marketDataType: config.marketDataType ?? "auto",
|
|
759
|
+
});
|
|
760
|
+
if (this.api?.isConnected && this.configKey === nextKey) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (this.connecting) {
|
|
764
|
+
return this.connecting;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
this.connecting = this.connectWithClientFallback(config, nextKey);
|
|
768
|
+
try {
|
|
769
|
+
await this.connecting;
|
|
770
|
+
} finally {
|
|
771
|
+
this.connecting = null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async disconnect(): Promise<void> {
|
|
776
|
+
this.teardownQuoteStreams();
|
|
777
|
+
this.api?.disconnect();
|
|
778
|
+
this.api = null;
|
|
779
|
+
this.configKey = null;
|
|
780
|
+
this.autoMarketData = true;
|
|
781
|
+
this.activeMarketDataType = MarketDataType.REALTIME;
|
|
782
|
+
this.connectionNote = undefined;
|
|
783
|
+
this.resolvedConnection = null;
|
|
784
|
+
await this.releaseClientLock();
|
|
785
|
+
this.updateSnapshot({
|
|
786
|
+
...this.snapshot,
|
|
787
|
+
status: { state: "disconnected", updatedAt: Date.now() },
|
|
788
|
+
accounts: [],
|
|
789
|
+
openOrders: [],
|
|
790
|
+
executions: [],
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async searchInstruments(query: string, config: IbkrGatewayConfig): Promise<InstrumentSearchResult[]> {
|
|
795
|
+
await this.connect(config);
|
|
796
|
+
const [descriptions, directMatches] = await Promise.all([
|
|
797
|
+
withTimeout(this.api!.getMatchingSymbols(query), IBKR_DATA_TIMEOUT, "getMatchingSymbols").catch(() => [] as ContractDescription[]),
|
|
798
|
+
withTimeout(this.getDirectContractMatches(query), IBKR_DATA_TIMEOUT, "getDirectContractMatches").catch(() => [] as InstrumentSearchResult[]),
|
|
799
|
+
]);
|
|
800
|
+
const merged = new Map<string, InstrumentSearchResult>();
|
|
801
|
+
|
|
802
|
+
for (const result of descriptions
|
|
803
|
+
.map((description) => this.contractDescriptionToSearchResult(description))
|
|
804
|
+
.filter((value): value is InstrumentSearchResult => value != null)) {
|
|
805
|
+
const key = `${result.symbol}|${result.exchange}|${result.type}`;
|
|
806
|
+
merged.set(key, result);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
for (const result of directMatches) {
|
|
810
|
+
const key = `${result.symbol}|${result.exchange}|${result.type}`;
|
|
811
|
+
merged.set(key, result);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return [...merged.values()];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async getAccounts(config: IbkrGatewayConfig): Promise<BrokerAccount[]> {
|
|
818
|
+
await this.connect(config);
|
|
819
|
+
return this.loadAccountsAndUpdateSnapshot();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async getPositions(config: IbkrGatewayConfig): Promise<BrokerPosition[]> {
|
|
823
|
+
await this.connect(config);
|
|
824
|
+
const update = await firstValueFrom(this.api!.getPositions().pipe(take(1), timeout(10_000)));
|
|
825
|
+
const portfolioSnapshots = await this.loadPortfolioSnapshotsForAccounts([...update.all.keys()]);
|
|
826
|
+
const positions: BrokerPosition[] = [];
|
|
827
|
+
for (const [accountId, accountPositions] of update.all) {
|
|
828
|
+
for (const position of accountPositions) {
|
|
829
|
+
if (!position.contract.symbol) continue;
|
|
830
|
+
const portfolioSnapshot = portfolioSnapshots.get(buildPortfolioPositionKey(accountId, position.contract));
|
|
831
|
+
const priceDivisor = getIbkrPriceDivisor(position.contract);
|
|
832
|
+
positions.push({
|
|
833
|
+
ticker: position.contract.localSymbol || position.contract.symbol,
|
|
834
|
+
exchange: position.contract.primaryExch || position.contract.exchange || "",
|
|
835
|
+
shares: Math.abs(position.pos),
|
|
836
|
+
avgCost: normalizeIbkrPriceValue(portfolioSnapshot?.avgCost ?? position.avgCost, priceDivisor),
|
|
837
|
+
currency: position.contract.currency || "USD",
|
|
838
|
+
accountId,
|
|
839
|
+
name: position.contract.description || position.contract.localSymbol || position.contract.symbol,
|
|
840
|
+
assetCategory: position.contract.secType,
|
|
841
|
+
markPrice: normalizeIbkrPriceValue(portfolioSnapshot?.marketPrice ?? position.marketPrice, priceDivisor),
|
|
842
|
+
marketValue: portfolioSnapshot?.marketValue ?? position.marketValue,
|
|
843
|
+
unrealizedPnl: portfolioSnapshot?.unrealizedPNL ?? position.unrealizedPNL,
|
|
844
|
+
side: position.pos < 0 ? "short" : "long",
|
|
845
|
+
multiplier: position.contract.multiplier,
|
|
846
|
+
brokerContract: this.contractToRef(position.contract),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return positions;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async getQuote(
|
|
854
|
+
ticker: string,
|
|
855
|
+
config: IbkrGatewayConfig,
|
|
856
|
+
exchange = "",
|
|
857
|
+
instrument?: BrokerContractRef | null,
|
|
858
|
+
): Promise<Quote> {
|
|
859
|
+
return this.dedup(`quote:${ticker}:${exchange}`, async () => {
|
|
860
|
+
await this.connect(config);
|
|
861
|
+
const contract = await withTimeout(this.resolveContract(ticker, exchange, instrument ?? null), IBKR_DATA_TIMEOUT, "resolveContract");
|
|
862
|
+
const details = await withTimeout(this.getPrimaryContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails");
|
|
863
|
+
const marketData = await this.withMarketDataFallback(
|
|
864
|
+
config,
|
|
865
|
+
() => withTimeout(this.api!.getMarketDataSnapshot(contract, "", false), IBKR_DATA_TIMEOUT, "getMarketDataSnapshot"),
|
|
866
|
+
);
|
|
867
|
+
const quote = this.marketDataToQuote(contract, details, marketData);
|
|
868
|
+
quote.dataSource = hasDelayedMarketData(marketData) || this.activeMarketDataType !== MarketDataType.REALTIME
|
|
869
|
+
? "delayed"
|
|
870
|
+
: "live";
|
|
871
|
+
return quote;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
subscribeQuotes(
|
|
876
|
+
config: IbkrGatewayConfig,
|
|
877
|
+
targets: QuoteSubscriptionTarget[],
|
|
878
|
+
onQuote: QuoteStreamListener,
|
|
879
|
+
): () => void {
|
|
880
|
+
const uniqueTargets = [...new Map(
|
|
881
|
+
targets
|
|
882
|
+
.map((target) => normalizeQuoteStreamTarget(target))
|
|
883
|
+
.filter((target): target is QuoteSubscriptionTarget => target != null)
|
|
884
|
+
.map((target) => [buildQuoteStreamKey(target), target] as const),
|
|
885
|
+
).values()];
|
|
886
|
+
|
|
887
|
+
for (const target of uniqueTargets) {
|
|
888
|
+
const key = buildQuoteStreamKey(target);
|
|
889
|
+
const stream = this.quoteStreams.get(key) ?? {
|
|
890
|
+
target,
|
|
891
|
+
listeners: new Map<QuoteStreamListener, QuoteSubscriptionTarget>(),
|
|
892
|
+
stop: () => {},
|
|
893
|
+
};
|
|
894
|
+
stream.target = target;
|
|
895
|
+
stream.listeners.set(onQuote, target);
|
|
896
|
+
this.quoteStreams.set(key, stream);
|
|
897
|
+
|
|
898
|
+
if (!this.quoteStreamStarts.has(key) && stream.listeners.size === 1) {
|
|
899
|
+
const startPromise = this.ensureQuoteStream(key, target, config)
|
|
900
|
+
.catch((error) => {
|
|
901
|
+
gatewayLog.warn("Quote stream setup failed", {
|
|
902
|
+
instanceId: this.instanceId,
|
|
903
|
+
symbol: target.symbol,
|
|
904
|
+
exchange: target.exchange ?? "",
|
|
905
|
+
error: error instanceof Error ? error.message : String(error ?? ""),
|
|
906
|
+
});
|
|
907
|
+
})
|
|
908
|
+
.finally(() => {
|
|
909
|
+
this.quoteStreamStarts.delete(key);
|
|
910
|
+
});
|
|
911
|
+
this.quoteStreamStarts.set(key, startPromise);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return () => {
|
|
916
|
+
for (const target of uniqueTargets) {
|
|
917
|
+
const key = buildQuoteStreamKey(target);
|
|
918
|
+
const stream = this.quoteStreams.get(key);
|
|
919
|
+
if (!stream) continue;
|
|
920
|
+
stream.listeners.delete(onQuote);
|
|
921
|
+
if (stream.listeners.size === 0) {
|
|
922
|
+
stream.stop();
|
|
923
|
+
this.quoteStreams.delete(key);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
async getTickerFinancials(
|
|
930
|
+
ticker: string,
|
|
931
|
+
config: IbkrGatewayConfig,
|
|
932
|
+
exchange = "",
|
|
933
|
+
instrument?: BrokerContractRef | null,
|
|
934
|
+
): Promise<TickerFinancials> {
|
|
935
|
+
return this.dedup(`financials:${ticker}:${exchange}`, async () => {
|
|
936
|
+
await this.connect(config);
|
|
937
|
+
const contract = await withTimeout(this.resolveContract(ticker, exchange, instrument ?? null), IBKR_DATA_TIMEOUT, "resolveContract");
|
|
938
|
+
|
|
939
|
+
const [quote, priceHistory, snapshotXml, statementsXml] = await Promise.all([
|
|
940
|
+
this.getQuote(ticker, config, exchange, instrument),
|
|
941
|
+
this.getPriceHistory(ticker, config, exchange, "1Y", instrument),
|
|
942
|
+
this.fetchFundamentalData(contract, "ReportSnapshot"),
|
|
943
|
+
this.fetchFundamentalData(contract, "ReportsFinStatements"),
|
|
944
|
+
]);
|
|
945
|
+
|
|
946
|
+
const fundamentals = snapshotXml ? parseReportSnapshot(snapshotXml) : {};
|
|
947
|
+
const statements = statementsXml ? parseFinStatements(statementsXml) : { annual: [], quarterly: [] };
|
|
948
|
+
|
|
949
|
+
// Compute 1Y return from price history
|
|
950
|
+
if (priceHistory.length >= 2) {
|
|
951
|
+
const oldest = priceHistory[0]!.close;
|
|
952
|
+
const newest = priceHistory[priceHistory.length - 1]!.close;
|
|
953
|
+
if (oldest > 0) {
|
|
954
|
+
fundamentals.return1Y = (newest - oldest) / oldest;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
quote,
|
|
960
|
+
fundamentals,
|
|
961
|
+
annualStatements: statements.annual,
|
|
962
|
+
quarterlyStatements: statements.quarterly,
|
|
963
|
+
priceHistory,
|
|
964
|
+
};
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private async fetchFundamentalData(contract: Contract, reportType: string): Promise<string | null> {
|
|
969
|
+
try {
|
|
970
|
+
return await withTimeout(
|
|
971
|
+
this.api!.getFundamentalData(contract, reportType),
|
|
972
|
+
IBKR_DATA_TIMEOUT,
|
|
973
|
+
`getFundamentalData(${reportType})`,
|
|
974
|
+
);
|
|
975
|
+
} catch {
|
|
976
|
+
return null; // Paper account or no Reuters subscription — Yahoo fallback handles it
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async getPriceHistory(
|
|
981
|
+
ticker: string,
|
|
982
|
+
config: IbkrGatewayConfig,
|
|
983
|
+
exchange: string,
|
|
984
|
+
range: TimeRange,
|
|
985
|
+
instrument?: BrokerContractRef | null,
|
|
986
|
+
): Promise<PricePoint[]> {
|
|
987
|
+
return this.dedup(`history:${ticker}:${exchange}:${range}`, async () => {
|
|
988
|
+
await this.connect(config);
|
|
989
|
+
const contract = await withTimeout(this.resolveContract(ticker, exchange, instrument ?? null), IBKR_DATA_TIMEOUT, "resolveContract");
|
|
990
|
+
const params = HISTORY_PARAMS[range];
|
|
991
|
+
const detailsPromise = withTimeout(this.getPrimaryContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails")
|
|
992
|
+
.catch(() => undefined);
|
|
993
|
+
const bars = await this.withMarketDataFallback(
|
|
994
|
+
config,
|
|
995
|
+
() => withTimeout(this.api!.getHistoricalData(
|
|
996
|
+
contract,
|
|
997
|
+
"",
|
|
998
|
+
params.duration,
|
|
999
|
+
params.size,
|
|
1000
|
+
WhatToShow.TRADES,
|
|
1001
|
+
1,
|
|
1002
|
+
1,
|
|
1003
|
+
), IBKR_DATA_TIMEOUT, "getHistoricalData"),
|
|
1004
|
+
);
|
|
1005
|
+
const details = await detailsPromise;
|
|
1006
|
+
const priceDivisor = getIbkrPriceDivisor(contract, details);
|
|
1007
|
+
|
|
1008
|
+
return bars.map((bar) => ({
|
|
1009
|
+
date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
|
|
1010
|
+
open: normalizeIbkrPriceValue(bar.open, priceDivisor),
|
|
1011
|
+
high: normalizeIbkrPriceValue(bar.high, priceDivisor),
|
|
1012
|
+
low: normalizeIbkrPriceValue(bar.low, priceDivisor),
|
|
1013
|
+
close: normalizeIbkrPriceValue(bar.close ?? bar.open ?? bar.high ?? bar.low ?? 0, priceDivisor) ?? 0,
|
|
1014
|
+
volume: bar.volume,
|
|
1015
|
+
}));
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async getDetailedPriceHistory(
|
|
1020
|
+
ticker: string,
|
|
1021
|
+
config: IbkrGatewayConfig,
|
|
1022
|
+
exchange: string,
|
|
1023
|
+
startDate: Date,
|
|
1024
|
+
endDate: Date,
|
|
1025
|
+
barSize: string,
|
|
1026
|
+
instrument?: BrokerContractRef | null,
|
|
1027
|
+
): Promise<PricePoint[]> {
|
|
1028
|
+
const ibkrBarSize = GENERIC_BAR_SIZE_MAP[barSize];
|
|
1029
|
+
if (!ibkrBarSize) return [];
|
|
1030
|
+
|
|
1031
|
+
const key = `detail:${ticker}:${exchange}:${barSize}:${startDate.getTime()}:${endDate.getTime()}`;
|
|
1032
|
+
return this.dedup(key, async () => {
|
|
1033
|
+
await this.connect(config);
|
|
1034
|
+
const contract = await withTimeout(this.resolveContract(ticker, exchange, instrument ?? null), IBKR_DATA_TIMEOUT, "resolveContract");
|
|
1035
|
+
const detailsPromise = withTimeout(this.getPrimaryContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails")
|
|
1036
|
+
.catch(() => undefined);
|
|
1037
|
+
|
|
1038
|
+
// Format endDateTime as "yyyyMMdd HH:mm:ss" for IBKR
|
|
1039
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1040
|
+
const endDateTime = `${endDate.getFullYear()}${pad(endDate.getMonth() + 1)}${pad(endDate.getDate())} ${pad(endDate.getHours())}:${pad(endDate.getMinutes())}:${pad(endDate.getSeconds())}`;
|
|
1041
|
+
|
|
1042
|
+
// Compute duration from date range
|
|
1043
|
+
const spanMs = endDate.getTime() - startDate.getTime();
|
|
1044
|
+
const spanDays = Math.ceil(spanMs / (1000 * 60 * 60 * 24));
|
|
1045
|
+
let durationStr: string;
|
|
1046
|
+
if (spanDays <= 1) durationStr = `${Math.max(Math.ceil(spanMs / (1000 * 60 * 60)), 1)} hours`;
|
|
1047
|
+
else if (spanDays <= 30) durationStr = `${spanDays} D`;
|
|
1048
|
+
else if (spanDays <= 365) durationStr = `${Math.ceil(spanDays / 30)} M`;
|
|
1049
|
+
else durationStr = `${Math.ceil(spanDays / 365)} Y`;
|
|
1050
|
+
|
|
1051
|
+
const bars = await this.withMarketDataFallback(
|
|
1052
|
+
config,
|
|
1053
|
+
() => withTimeout(this.api!.getHistoricalData(
|
|
1054
|
+
contract,
|
|
1055
|
+
endDateTime,
|
|
1056
|
+
durationStr,
|
|
1057
|
+
ibkrBarSize,
|
|
1058
|
+
WhatToShow.TRADES,
|
|
1059
|
+
1,
|
|
1060
|
+
1,
|
|
1061
|
+
), IBKR_DATA_TIMEOUT, "getDetailedHistoricalData"),
|
|
1062
|
+
);
|
|
1063
|
+
const details = await detailsPromise;
|
|
1064
|
+
const priceDivisor = getIbkrPriceDivisor(contract, details);
|
|
1065
|
+
|
|
1066
|
+
return bars.map((bar) => ({
|
|
1067
|
+
date: new Date(typeof bar.time === "number" ? bar.time * 1000 : Date.parse(String(bar.time))),
|
|
1068
|
+
open: normalizeIbkrPriceValue(bar.open, priceDivisor),
|
|
1069
|
+
high: normalizeIbkrPriceValue(bar.high, priceDivisor),
|
|
1070
|
+
low: normalizeIbkrPriceValue(bar.low, priceDivisor),
|
|
1071
|
+
close: normalizeIbkrPriceValue(bar.close ?? bar.open ?? bar.high ?? bar.low ?? 0, priceDivisor) ?? 0,
|
|
1072
|
+
volume: bar.volume,
|
|
1073
|
+
}));
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async listOpenOrders(config: IbkrGatewayConfig): Promise<BrokerOrder[]> {
|
|
1078
|
+
await this.connect(config);
|
|
1079
|
+
return this.loadOpenOrders();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async listExecutions(config: IbkrGatewayConfig): Promise<BrokerExecution[]> {
|
|
1083
|
+
await this.connect(config);
|
|
1084
|
+
return this.loadExecutions();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async previewOrder(config: IbkrGatewayConfig, request: BrokerOrderRequest): Promise<BrokerOrderPreview> {
|
|
1088
|
+
await this.connect(config);
|
|
1089
|
+
const contract = await this.resolveContract(request.contract.symbol, request.contract.exchange || "", request.contract);
|
|
1090
|
+
const rawApi = this.getRawApi();
|
|
1091
|
+
const orderId = await this.api!.getNextValidOrderId();
|
|
1092
|
+
const order = this.buildOrder(request, true);
|
|
1093
|
+
|
|
1094
|
+
return new Promise<BrokerOrderPreview>((resolve, reject) => {
|
|
1095
|
+
const timeoutId = setTimeout(() => {
|
|
1096
|
+
cleanup();
|
|
1097
|
+
reject(new Error("Timed out while previewing order"));
|
|
1098
|
+
}, 10_000);
|
|
1099
|
+
|
|
1100
|
+
const cleanup = () => {
|
|
1101
|
+
clearTimeout(timeoutId);
|
|
1102
|
+
rawApi.off(EventName.openOrder, onOpenOrder);
|
|
1103
|
+
rawApi.off(EventName.error, onError);
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
const onOpenOrder = (incomingOrderId: number, _contract: Contract, _order: Order, orderState: OrderState) => {
|
|
1107
|
+
if (incomingOrderId !== orderId) return;
|
|
1108
|
+
cleanup();
|
|
1109
|
+
resolve({
|
|
1110
|
+
initMarginBefore: orderState.initMarginBefore,
|
|
1111
|
+
initMarginAfter: orderState.initMarginAfter,
|
|
1112
|
+
maintMarginBefore: orderState.maintMarginBefore,
|
|
1113
|
+
maintMarginAfter: orderState.maintMarginAfter,
|
|
1114
|
+
equityWithLoanBefore: orderState.equityWithLoanBefore,
|
|
1115
|
+
equityWithLoanAfter: orderState.equityWithLoanAfter,
|
|
1116
|
+
commission: orderState.commission,
|
|
1117
|
+
commissionCurrency: orderState.commissionCurrency,
|
|
1118
|
+
warningText: orderState.warningText,
|
|
1119
|
+
});
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
const onError = (error: Error, code: number, reqId: number) => {
|
|
1123
|
+
if (reqId !== orderId) return;
|
|
1124
|
+
cleanup();
|
|
1125
|
+
reject(new Error(error.message || `IBKR error ${code}`));
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
rawApi.on(EventName.openOrder, onOpenOrder);
|
|
1129
|
+
rawApi.on(EventName.error, onError);
|
|
1130
|
+
rawApi.placeOrder(orderId, contract, order);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async placeOrder(config: IbkrGatewayConfig, request: BrokerOrderRequest): Promise<BrokerOrder> {
|
|
1135
|
+
await this.connect(config);
|
|
1136
|
+
const contract = await this.resolveContract(request.contract.symbol, request.contract.exchange || "", request.contract);
|
|
1137
|
+
const order = this.buildOrder(request, false);
|
|
1138
|
+
const rawApi = this.getRawApi();
|
|
1139
|
+
const orderId = await this.api!.getNextValidOrderId();
|
|
1140
|
+
|
|
1141
|
+
await new Promise<void>((resolve, reject) => {
|
|
1142
|
+
const timeoutId = setTimeout(() => {
|
|
1143
|
+
cleanup();
|
|
1144
|
+
reject(new Error("Timed out waiting for order acknowledgement"));
|
|
1145
|
+
}, 10_000);
|
|
1146
|
+
|
|
1147
|
+
const cleanup = () => {
|
|
1148
|
+
clearTimeout(timeoutId);
|
|
1149
|
+
rawApi.off(EventName.openOrder, onOpenOrder);
|
|
1150
|
+
rawApi.off(EventName.orderStatus, onOrderStatus);
|
|
1151
|
+
rawApi.off(EventName.error, onError);
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
const onOpenOrder = (incomingOrderId: number) => {
|
|
1155
|
+
if (incomingOrderId !== orderId) return;
|
|
1156
|
+
cleanup();
|
|
1157
|
+
resolve();
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
const onOrderStatus = (incomingOrderId: number, status: string) => {
|
|
1161
|
+
if (incomingOrderId !== orderId) return;
|
|
1162
|
+
cleanup();
|
|
1163
|
+
resolve();
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const onError = (error: Error, code: number, reqId: number) => {
|
|
1167
|
+
if (reqId !== orderId) return;
|
|
1168
|
+
cleanup();
|
|
1169
|
+
reject(new Error(error.message || `IBKR error ${code}`));
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
rawApi.on(EventName.openOrder, onOpenOrder);
|
|
1173
|
+
rawApi.on(EventName.orderStatus, onOrderStatus);
|
|
1174
|
+
rawApi.on(EventName.error, onError);
|
|
1175
|
+
rawApi.placeOrder(orderId, contract, order);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const openOrders = await this.listOpenOrders(config);
|
|
1179
|
+
return openOrders.find((openOrder) => openOrder.orderId === orderId) ?? {
|
|
1180
|
+
orderId,
|
|
1181
|
+
brokerInstanceId: this.instanceId,
|
|
1182
|
+
accountId: request.accountId,
|
|
1183
|
+
status: "Submitted",
|
|
1184
|
+
action: request.action,
|
|
1185
|
+
orderType: request.orderType,
|
|
1186
|
+
quantity: request.quantity,
|
|
1187
|
+
filled: 0,
|
|
1188
|
+
remaining: request.quantity,
|
|
1189
|
+
limitPrice: request.limitPrice,
|
|
1190
|
+
stopPrice: request.stopPrice,
|
|
1191
|
+
tif: request.tif,
|
|
1192
|
+
updatedAt: Date.now(),
|
|
1193
|
+
contract: request.contract,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async modifyOrder(config: IbkrGatewayConfig, orderId: number, request: BrokerOrderRequest): Promise<BrokerOrder> {
|
|
1198
|
+
await this.connect(config);
|
|
1199
|
+
const contract = await this.resolveContract(request.contract.symbol, request.contract.exchange || "", request.contract);
|
|
1200
|
+
const rawApi = this.getRawApi();
|
|
1201
|
+
const order = this.buildOrder({ ...request }, false);
|
|
1202
|
+
|
|
1203
|
+
await new Promise<void>((resolve, reject) => {
|
|
1204
|
+
const timeoutId = setTimeout(() => {
|
|
1205
|
+
cleanup();
|
|
1206
|
+
reject(new Error("Timed out waiting for order modification acknowledgement"));
|
|
1207
|
+
}, 10_000);
|
|
1208
|
+
|
|
1209
|
+
const cleanup = () => {
|
|
1210
|
+
clearTimeout(timeoutId);
|
|
1211
|
+
rawApi.off(EventName.openOrder, onOpenOrder);
|
|
1212
|
+
rawApi.off(EventName.orderStatus, onOrderStatus);
|
|
1213
|
+
rawApi.off(EventName.error, onError);
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
const onOpenOrder = (incomingOrderId: number) => {
|
|
1217
|
+
if (incomingOrderId !== orderId) return;
|
|
1218
|
+
cleanup();
|
|
1219
|
+
resolve();
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
const onOrderStatus = (incomingOrderId: number, status: string) => {
|
|
1223
|
+
if (incomingOrderId !== orderId) return;
|
|
1224
|
+
cleanup();
|
|
1225
|
+
resolve();
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
const onError = (error: Error, code: number, reqId: number) => {
|
|
1229
|
+
if (reqId !== orderId) return;
|
|
1230
|
+
cleanup();
|
|
1231
|
+
reject(new Error(error.message || `IBKR error ${code}`));
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
rawApi.on(EventName.openOrder, onOpenOrder);
|
|
1235
|
+
rawApi.on(EventName.orderStatus, onOrderStatus);
|
|
1236
|
+
rawApi.on(EventName.error, onError);
|
|
1237
|
+
rawApi.placeOrder(orderId, contract, order);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
const openOrders = await this.listOpenOrders(config);
|
|
1241
|
+
return openOrders.find((openOrder) => openOrder.orderId === orderId) ?? {
|
|
1242
|
+
orderId,
|
|
1243
|
+
brokerInstanceId: this.instanceId,
|
|
1244
|
+
accountId: request.accountId,
|
|
1245
|
+
status: "Submitted",
|
|
1246
|
+
action: request.action,
|
|
1247
|
+
orderType: request.orderType,
|
|
1248
|
+
quantity: request.quantity,
|
|
1249
|
+
filled: 0,
|
|
1250
|
+
remaining: request.quantity,
|
|
1251
|
+
limitPrice: request.limitPrice,
|
|
1252
|
+
stopPrice: request.stopPrice,
|
|
1253
|
+
tif: request.tif,
|
|
1254
|
+
updatedAt: Date.now(),
|
|
1255
|
+
contract: request.contract,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async cancelOrder(config: IbkrGatewayConfig, orderId: number): Promise<void> {
|
|
1260
|
+
await this.connect(config);
|
|
1261
|
+
this.api!.cancelOrder(orderId);
|
|
1262
|
+
await this.listOpenOrders(config);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private async connectWithClientFallback(config: IbkrGatewayConfig, configKey: string): Promise<void> {
|
|
1266
|
+
let resolvedConfig: ResolvedIbkrGatewayConnection;
|
|
1267
|
+
try {
|
|
1268
|
+
resolvedConfig = await resolveGatewayConnection(config);
|
|
1269
|
+
} catch (error: any) {
|
|
1270
|
+
const message = error?.message || String(error || "");
|
|
1271
|
+
this.updateSnapshot({
|
|
1272
|
+
...this.snapshot,
|
|
1273
|
+
status: {
|
|
1274
|
+
state: "error",
|
|
1275
|
+
updatedAt: Date.now(),
|
|
1276
|
+
mode: "gateway",
|
|
1277
|
+
message,
|
|
1278
|
+
},
|
|
1279
|
+
lastError: message,
|
|
1280
|
+
});
|
|
1281
|
+
throw error;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const candidates = this.getClientIdCandidates(resolvedConfig.requestedClientId);
|
|
1285
|
+
let lastConflict: Error | null = null;
|
|
1286
|
+
|
|
1287
|
+
for (const clientId of candidates) {
|
|
1288
|
+
const claimed = await this.tryClaimClientLock(resolvedConfig, clientId, resolvedConfig.requestedClientId);
|
|
1289
|
+
if (!claimed) continue;
|
|
1290
|
+
|
|
1291
|
+
try {
|
|
1292
|
+
await this.connectInternal(
|
|
1293
|
+
{ ...config, host: resolvedConfig.host, port: resolvedConfig.port, clientId },
|
|
1294
|
+
configKey,
|
|
1295
|
+
buildConnectionNote(resolvedConfig.requestedClientId, clientId, resolvedConfig.requestedPort, resolvedConfig.port),
|
|
1296
|
+
);
|
|
1297
|
+
this.resolvedConnection = {
|
|
1298
|
+
...resolvedConfig,
|
|
1299
|
+
clientId,
|
|
1300
|
+
};
|
|
1301
|
+
void resolvedGatewayListener?.(this.instanceId, this.resolvedConnection);
|
|
1302
|
+
return;
|
|
1303
|
+
} catch (error: any) {
|
|
1304
|
+
const code = getIbErrorCode(error);
|
|
1305
|
+
const message = getIbErrorMessage(error) || error?.message || String(error || "");
|
|
1306
|
+
await this.disconnect();
|
|
1307
|
+
if (!isClientIdInUseError(code, message)) {
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
lastConflict = new Error(message || `IBKR client ID ${clientId} is already in use.`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
throw lastConflict ?? new Error(
|
|
1315
|
+
`No IBKR client IDs are available near ${resolvedConfig.requestedClientId}. Close other sessions or choose a different client ID.`,
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private async connectInternal(
|
|
1320
|
+
config: IbkrGatewayConfig,
|
|
1321
|
+
configKey: string,
|
|
1322
|
+
connectionNote?: string,
|
|
1323
|
+
): Promise<void> {
|
|
1324
|
+
if (this.api && this.configKey !== configKey) {
|
|
1325
|
+
await this.disconnect();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
this.connectionNote = connectionNote;
|
|
1329
|
+
this.updateSnapshot({
|
|
1330
|
+
...this.snapshot,
|
|
1331
|
+
status: {
|
|
1332
|
+
state: "connecting",
|
|
1333
|
+
updatedAt: Date.now(),
|
|
1334
|
+
mode: "gateway",
|
|
1335
|
+
message: this.connectionNote,
|
|
1336
|
+
},
|
|
1337
|
+
lastError: undefined,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
if (!this.api) {
|
|
1341
|
+
this.api = new IBApiNext({
|
|
1342
|
+
host: config.host,
|
|
1343
|
+
port: config.port,
|
|
1344
|
+
reconnectInterval: 2_000,
|
|
1345
|
+
});
|
|
1346
|
+
this.bindConnectionEvents(this.api);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
let unsubscribeConflict = () => {};
|
|
1350
|
+
const conflictPromise = new Promise<never>((_, reject) => {
|
|
1351
|
+
const subscription = this.api!.error.subscribe((err) => {
|
|
1352
|
+
const code = getIbErrorCode(err);
|
|
1353
|
+
const message = getIbErrorMessage(err);
|
|
1354
|
+
if (!isClientIdInUseError(code, message)) return;
|
|
1355
|
+
subscription.unsubscribe();
|
|
1356
|
+
reject(new Error(message || `IBKR client ID ${config.clientId} is already in use.`));
|
|
1357
|
+
});
|
|
1358
|
+
unsubscribeConflict = () => subscription.unsubscribe();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
this.api.connect(config.clientId);
|
|
1362
|
+
try {
|
|
1363
|
+
await Promise.race([
|
|
1364
|
+
firstValueFrom(this.api.connectionState.pipe(
|
|
1365
|
+
filter((state) => state === ConnectionState.Connected),
|
|
1366
|
+
take(1),
|
|
1367
|
+
timeout(10_000),
|
|
1368
|
+
)),
|
|
1369
|
+
conflictPromise,
|
|
1370
|
+
]);
|
|
1371
|
+
} finally {
|
|
1372
|
+
unsubscribeConflict();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
gatewayLog.info("Connected to IBKR gateway", {
|
|
1376
|
+
instanceId: this.instanceId,
|
|
1377
|
+
requestedClientId: this.claimedClientLock?.requestedClientId ?? config.clientId,
|
|
1378
|
+
actualClientId: config.clientId,
|
|
1379
|
+
host: config.host,
|
|
1380
|
+
port: config.port,
|
|
1381
|
+
});
|
|
1382
|
+
this.autoMarketData = (config.marketDataType ?? "auto") === "auto";
|
|
1383
|
+
this.activeMarketDataType = marketDataTypeFromConfig(config);
|
|
1384
|
+
this.api.setMarketDataType(this.activeMarketDataType);
|
|
1385
|
+
this.configKey = configKey;
|
|
1386
|
+
await Promise.allSettled([
|
|
1387
|
+
this.loadAccountsAndUpdateSnapshot(),
|
|
1388
|
+
this.loadOpenOrders(),
|
|
1389
|
+
this.loadExecutions(),
|
|
1390
|
+
]);
|
|
1391
|
+
this.updateSnapshot({
|
|
1392
|
+
...this.snapshot,
|
|
1393
|
+
status: {
|
|
1394
|
+
state: "connected",
|
|
1395
|
+
updatedAt: Date.now(),
|
|
1396
|
+
mode: "gateway",
|
|
1397
|
+
message: this.connectionNote,
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private bindConnectionEvents(api: IBApiNext): void {
|
|
1403
|
+
api.connectionState.subscribe((state) => {
|
|
1404
|
+
if (state === ConnectionState.Connected) {
|
|
1405
|
+
this.updateSnapshot({
|
|
1406
|
+
...this.snapshot,
|
|
1407
|
+
status: {
|
|
1408
|
+
state: "connected",
|
|
1409
|
+
updatedAt: Date.now(),
|
|
1410
|
+
mode: "gateway",
|
|
1411
|
+
message: this.connectionNote,
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
} else if (state === ConnectionState.Connecting) {
|
|
1415
|
+
this.updateSnapshot({
|
|
1416
|
+
...this.snapshot,
|
|
1417
|
+
status: {
|
|
1418
|
+
state: "connecting",
|
|
1419
|
+
updatedAt: Date.now(),
|
|
1420
|
+
mode: "gateway",
|
|
1421
|
+
message: this.connectionNote,
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
} else {
|
|
1425
|
+
this.updateSnapshot({
|
|
1426
|
+
...this.snapshot,
|
|
1427
|
+
status: { state: "disconnected", updatedAt: Date.now(), mode: "gateway" },
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
// Capture account IDs as they arrive from the managedAccounts event on connection.
|
|
1433
|
+
const rawApi = this.getRawApi();
|
|
1434
|
+
rawApi.on(EventName.managedAccounts, (accountsList: string) => {
|
|
1435
|
+
this.cachedAccountIds = accountsList.split(",").map((s: string) => s.trim()).filter(Boolean);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
api.error.subscribe((err) => {
|
|
1439
|
+
const code = getIbErrorCode(err);
|
|
1440
|
+
const message = getIbErrorMessage(err);
|
|
1441
|
+
if (isMarketDataPermissionError(code, message)) {
|
|
1442
|
+
this.updateSnapshot({
|
|
1443
|
+
...this.snapshot,
|
|
1444
|
+
status: {
|
|
1445
|
+
state: "connected",
|
|
1446
|
+
updatedAt: Date.now(),
|
|
1447
|
+
mode: "gateway",
|
|
1448
|
+
message: this.autoMarketData
|
|
1449
|
+
? "Live API market data unavailable; delayed quotes will be used when IBKR allows them."
|
|
1450
|
+
: message,
|
|
1451
|
+
},
|
|
1452
|
+
lastError: message,
|
|
1453
|
+
});
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (isIbInformationalWarning(code, message)) {
|
|
1457
|
+
this.updateSnapshot({
|
|
1458
|
+
...this.snapshot,
|
|
1459
|
+
status: {
|
|
1460
|
+
...this.snapshot.status,
|
|
1461
|
+
updatedAt: Date.now(),
|
|
1462
|
+
mode: "gateway",
|
|
1463
|
+
message,
|
|
1464
|
+
},
|
|
1465
|
+
});
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const keepConnectionState = this.snapshot.status.state === "connected" || this.snapshot.status.state === "connecting";
|
|
1470
|
+
this.updateSnapshot({
|
|
1471
|
+
...this.snapshot,
|
|
1472
|
+
status: keepConnectionState
|
|
1473
|
+
? {
|
|
1474
|
+
...this.snapshot.status,
|
|
1475
|
+
updatedAt: Date.now(),
|
|
1476
|
+
mode: "gateway",
|
|
1477
|
+
message,
|
|
1478
|
+
}
|
|
1479
|
+
: { state: "error", updatedAt: Date.now(), mode: "gateway", message },
|
|
1480
|
+
lastError: message,
|
|
1481
|
+
});
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private getClientIdCandidates(requestedClientId: number): number[] {
|
|
1486
|
+
const preferred = this.claimedClientLock?.clientId;
|
|
1487
|
+
const candidates = new Set<number>();
|
|
1488
|
+
if (preferred && Number.isFinite(preferred) && preferred > 0) {
|
|
1489
|
+
candidates.add(preferred);
|
|
1490
|
+
}
|
|
1491
|
+
for (let offset = 0; offset < IBKR_CLIENT_ID_SEARCH_SPAN; offset += 1) {
|
|
1492
|
+
candidates.add(requestedClientId + offset);
|
|
1493
|
+
}
|
|
1494
|
+
return [...candidates];
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
private async tryClaimClientLock(
|
|
1498
|
+
config: IbkrGatewayConfig,
|
|
1499
|
+
clientId: number,
|
|
1500
|
+
requestedClientId: number,
|
|
1501
|
+
): Promise<boolean> {
|
|
1502
|
+
const path = buildClientLockPath(config, clientId);
|
|
1503
|
+
const existing = this.claimedClientLock;
|
|
1504
|
+
if (
|
|
1505
|
+
existing
|
|
1506
|
+
&& existing.clientId === clientId
|
|
1507
|
+
&& existing.requestedClientId === requestedClientId
|
|
1508
|
+
) {
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
await mkdir(IBKR_CLIENT_LOCK_DIR, { recursive: true });
|
|
1513
|
+
const metadata: ClientLockMetadata = {
|
|
1514
|
+
pid: process.pid,
|
|
1515
|
+
ownerToken: this.clientLockOwner,
|
|
1516
|
+
requestedClientId,
|
|
1517
|
+
clientId,
|
|
1518
|
+
host: config.host,
|
|
1519
|
+
port: config.port,
|
|
1520
|
+
instanceId: this.instanceId,
|
|
1521
|
+
cwd: process.cwd(),
|
|
1522
|
+
timestamp: Date.now(),
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
try {
|
|
1526
|
+
const handle = await open(path, "wx");
|
|
1527
|
+
await handle.writeFile(JSON.stringify(metadata), "utf-8");
|
|
1528
|
+
await handle.close();
|
|
1529
|
+
await this.releaseClientLock();
|
|
1530
|
+
this.claimedClientLock = { clientId, requestedClientId, path };
|
|
1531
|
+
return true;
|
|
1532
|
+
} catch (error: any) {
|
|
1533
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const lock = await readClientLock(path);
|
|
1537
|
+
if (!lock || !isProcessAlive(lock.pid)) {
|
|
1538
|
+
await rm(path, { force: true }).catch(() => {});
|
|
1539
|
+
return this.tryClaimClientLock(config, clientId, requestedClientId);
|
|
1540
|
+
}
|
|
1541
|
+
if (lock.ownerToken === this.clientLockOwner) {
|
|
1542
|
+
await this.releaseClientLock();
|
|
1543
|
+
this.claimedClientLock = { clientId, requestedClientId, path };
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
return false;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
private async releaseClientLock(): Promise<void> {
|
|
1550
|
+
const lock = this.claimedClientLock;
|
|
1551
|
+
this.claimedClientLock = null;
|
|
1552
|
+
if (!lock) return;
|
|
1553
|
+
await rm(lock.path, { force: true }).catch(() => {});
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
private updateSnapshot(snapshot: IbkrSnapshot): void {
|
|
1557
|
+
this.snapshot = snapshot;
|
|
1558
|
+
for (const listener of this.listeners) listener();
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
private teardownQuoteStreams(): void {
|
|
1562
|
+
for (const stream of this.quoteStreams.values()) {
|
|
1563
|
+
stream.stop();
|
|
1564
|
+
}
|
|
1565
|
+
this.quoteStreams.clear();
|
|
1566
|
+
this.quoteStreamStarts.clear();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
private async ensureQuoteStream(
|
|
1570
|
+
key: string,
|
|
1571
|
+
target: QuoteSubscriptionTarget,
|
|
1572
|
+
config: IbkrGatewayConfig,
|
|
1573
|
+
): Promise<void> {
|
|
1574
|
+
await this.connect(config);
|
|
1575
|
+
const contract = await withTimeout(
|
|
1576
|
+
this.resolveContract(target.symbol, target.exchange ?? "", target.context?.instrument ?? null),
|
|
1577
|
+
IBKR_DATA_TIMEOUT,
|
|
1578
|
+
"resolveContract",
|
|
1579
|
+
);
|
|
1580
|
+
const details = await withTimeout(this.getPrimaryContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails");
|
|
1581
|
+
const priceDivisor = getIbkrPriceDivisor(contract, details);
|
|
1582
|
+
const stream = this.quoteStreams.get(key);
|
|
1583
|
+
if (!stream || stream.listeners.size === 0) {
|
|
1584
|
+
this.quoteStreams.delete(key);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const seededQuote = await this.loadSeedQuote(contract, details, config).catch(() => null);
|
|
1589
|
+
if (seededQuote) {
|
|
1590
|
+
stream.lastQuote = seededQuote;
|
|
1591
|
+
for (const [listener, listenerTarget] of stream.listeners.entries()) {
|
|
1592
|
+
listener(listenerTarget, seededQuote);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const subscription = this.api!.getMarketData(contract, IBKR_QUOTE_STREAM_TICKS, false, false).subscribe({
|
|
1597
|
+
next: (update) => this.emitQuoteStreamUpdate(key, contract, details, update),
|
|
1598
|
+
error: (error) => {
|
|
1599
|
+
gatewayLog.warn("Quote stream error", {
|
|
1600
|
+
instanceId: this.instanceId,
|
|
1601
|
+
symbol: target.symbol,
|
|
1602
|
+
exchange: target.exchange ?? "",
|
|
1603
|
+
error: error instanceof Error ? error.message : String(error ?? ""),
|
|
1604
|
+
});
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
const tradeSubscription = this.api!.getTickByTickAllLastDataUpdates(contract, 0, false).subscribe({
|
|
1608
|
+
next: (tick) => this.emitTickByTickAllLastUpdate(key, contract, details, tick, priceDivisor),
|
|
1609
|
+
error: (error) => {
|
|
1610
|
+
gatewayLog.warn("Tick-by-tick trade stream error", {
|
|
1611
|
+
instanceId: this.instanceId,
|
|
1612
|
+
symbol: target.symbol,
|
|
1613
|
+
exchange: target.exchange ?? "",
|
|
1614
|
+
error: error instanceof Error ? error.message : String(error ?? ""),
|
|
1615
|
+
});
|
|
1616
|
+
},
|
|
1617
|
+
});
|
|
1618
|
+
const bidAskStop = this.startTickByTickBidAskStream(
|
|
1619
|
+
contract,
|
|
1620
|
+
(update) => this.emitTickByTickBidAskUpdate(key, priceDivisor, update),
|
|
1621
|
+
(error) => {
|
|
1622
|
+
gatewayLog.warn("Tick-by-tick bid/ask stream error", {
|
|
1623
|
+
instanceId: this.instanceId,
|
|
1624
|
+
symbol: target.symbol,
|
|
1625
|
+
exchange: target.exchange ?? "",
|
|
1626
|
+
error,
|
|
1627
|
+
});
|
|
1628
|
+
},
|
|
1629
|
+
);
|
|
1630
|
+
|
|
1631
|
+
const liveStream = this.quoteStreams.get(key);
|
|
1632
|
+
if (!liveStream || liveStream.listeners.size === 0) {
|
|
1633
|
+
subscription.unsubscribe();
|
|
1634
|
+
tradeSubscription.unsubscribe();
|
|
1635
|
+
bidAskStop();
|
|
1636
|
+
this.quoteStreams.delete(key);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
liveStream.stop = () => {
|
|
1640
|
+
subscription.unsubscribe();
|
|
1641
|
+
tradeSubscription.unsubscribe();
|
|
1642
|
+
bidAskStop();
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
private emitQuoteStreamUpdate(
|
|
1647
|
+
key: string,
|
|
1648
|
+
contract: Contract,
|
|
1649
|
+
details: ContractDetails,
|
|
1650
|
+
update: MarketDataUpdate,
|
|
1651
|
+
): void {
|
|
1652
|
+
const stream = this.quoteStreams.get(key);
|
|
1653
|
+
if (!stream || stream.listeners.size === 0) return;
|
|
1654
|
+
|
|
1655
|
+
try {
|
|
1656
|
+
const quote = this.marketDataToQuote(contract, details, update.all);
|
|
1657
|
+
quote.providerId = "ibkr";
|
|
1658
|
+
quote.dataSource = hasDelayedMarketData(update.all) || this.activeMarketDataType !== MarketDataType.REALTIME
|
|
1659
|
+
? "delayed"
|
|
1660
|
+
: "live";
|
|
1661
|
+
stream.lastQuote = quote;
|
|
1662
|
+
for (const [listener, listenerTarget] of stream.listeners.entries()) {
|
|
1663
|
+
listener(listenerTarget, quote);
|
|
1664
|
+
}
|
|
1665
|
+
} catch {
|
|
1666
|
+
// Ignore partial tick snapshots until IBKR has sent a usable quote.
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
private emitTickByTickAllLastUpdate(
|
|
1671
|
+
key: string,
|
|
1672
|
+
contract: Contract,
|
|
1673
|
+
details: ContractDetails,
|
|
1674
|
+
tick: TickByTickAllLast,
|
|
1675
|
+
priceDivisor: number,
|
|
1676
|
+
): void {
|
|
1677
|
+
const stream = this.quoteStreams.get(key);
|
|
1678
|
+
if (!stream || stream.listeners.size === 0) return;
|
|
1679
|
+
|
|
1680
|
+
const nextQuote = applyTickByTickAllLastToQuote(
|
|
1681
|
+
stream.lastQuote,
|
|
1682
|
+
contract,
|
|
1683
|
+
details,
|
|
1684
|
+
tick,
|
|
1685
|
+
priceDivisor,
|
|
1686
|
+
stream.lastQuote?.dataSource ?? (this.activeMarketDataType === MarketDataType.REALTIME ? "live" : "delayed"),
|
|
1687
|
+
);
|
|
1688
|
+
if (!nextQuote) return;
|
|
1689
|
+
stream.lastQuote = nextQuote;
|
|
1690
|
+
for (const [listener, listenerTarget] of stream.listeners.entries()) {
|
|
1691
|
+
listener(listenerTarget, nextQuote);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
private emitTickByTickBidAskUpdate(
|
|
1696
|
+
key: string,
|
|
1697
|
+
priceDivisor: number,
|
|
1698
|
+
update: TickByTickBidAskUpdate,
|
|
1699
|
+
): void {
|
|
1700
|
+
const stream = this.quoteStreams.get(key);
|
|
1701
|
+
if (!stream || stream.listeners.size === 0) return;
|
|
1702
|
+
const nextQuote = applyTickByTickBidAskToQuote(stream.lastQuote, update, priceDivisor);
|
|
1703
|
+
if (!nextQuote) return;
|
|
1704
|
+
stream.lastQuote = nextQuote;
|
|
1705
|
+
for (const [listener, listenerTarget] of stream.listeners.entries()) {
|
|
1706
|
+
listener(listenerTarget, nextQuote);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
private async loadSeedQuote(
|
|
1711
|
+
contract: Contract,
|
|
1712
|
+
details: ContractDetails,
|
|
1713
|
+
config: IbkrGatewayConfig,
|
|
1714
|
+
): Promise<Quote | null> {
|
|
1715
|
+
try {
|
|
1716
|
+
const marketData = await this.withMarketDataFallback(
|
|
1717
|
+
config,
|
|
1718
|
+
() => withTimeout(this.api!.getMarketDataSnapshot(contract, "", false), IBKR_DATA_TIMEOUT, "getMarketDataSnapshot"),
|
|
1719
|
+
);
|
|
1720
|
+
const quote = this.marketDataToQuote(contract, details, marketData);
|
|
1721
|
+
quote.providerId = "ibkr";
|
|
1722
|
+
quote.dataSource = hasDelayedMarketData(marketData) || this.activeMarketDataType !== MarketDataType.REALTIME
|
|
1723
|
+
? "delayed"
|
|
1724
|
+
: "live";
|
|
1725
|
+
return quote;
|
|
1726
|
+
} catch {
|
|
1727
|
+
return null;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
private startTickByTickBidAskStream(
|
|
1732
|
+
contract: Contract,
|
|
1733
|
+
onUpdate: (update: TickByTickBidAskUpdate) => void,
|
|
1734
|
+
onError: (message: string) => void,
|
|
1735
|
+
): () => void {
|
|
1736
|
+
const rawApi = this.getRawApi();
|
|
1737
|
+
const reqId = (this.api as any)?.nextReqId;
|
|
1738
|
+
if (typeof reqId !== "number" || !Number.isFinite(reqId)) {
|
|
1739
|
+
onError("No request id available for tick-by-tick bid/ask stream");
|
|
1740
|
+
return () => {};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const handleBidAsk = (
|
|
1744
|
+
incomingReqId: number,
|
|
1745
|
+
time: number,
|
|
1746
|
+
bidPrice: number,
|
|
1747
|
+
askPrice: number,
|
|
1748
|
+
bidSize: number,
|
|
1749
|
+
askSize: number,
|
|
1750
|
+
) => {
|
|
1751
|
+
if (incomingReqId !== reqId) return;
|
|
1752
|
+
onUpdate({ time, bidPrice, askPrice, bidSize, askSize });
|
|
1753
|
+
};
|
|
1754
|
+
const handleError = (error: Error, code: number, incomingReqId: number) => {
|
|
1755
|
+
if (incomingReqId !== reqId) return;
|
|
1756
|
+
onError(error?.message || `IBKR error ${code}`);
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
rawApi.on(EventName.tickByTickBidAsk, handleBidAsk);
|
|
1760
|
+
rawApi.on(EventName.error, handleError);
|
|
1761
|
+
rawApi.reqTickByTickData(reqId, contract, "BidAsk", 0, false);
|
|
1762
|
+
|
|
1763
|
+
return () => {
|
|
1764
|
+
rawApi.off(EventName.tickByTickBidAsk, handleBidAsk);
|
|
1765
|
+
rawApi.off(EventName.error, handleError);
|
|
1766
|
+
try {
|
|
1767
|
+
rawApi.cancelTickByTickData(reqId);
|
|
1768
|
+
} catch {
|
|
1769
|
+
// ignore shutdown races
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
private async withMarketDataFallback<T>(
|
|
1775
|
+
config: IbkrGatewayConfig,
|
|
1776
|
+
operation: () => Promise<T>,
|
|
1777
|
+
): Promise<T> {
|
|
1778
|
+
try {
|
|
1779
|
+
return await operation();
|
|
1780
|
+
} catch (error: any) {
|
|
1781
|
+
const message = error?.message || String(error || "");
|
|
1782
|
+
const isPermission = isMarketDataPermissionError(undefined, message);
|
|
1783
|
+
const isTimeout = message.includes("timed out");
|
|
1784
|
+
const isNoData = message.includes("No valid market data");
|
|
1785
|
+
if (
|
|
1786
|
+
!this.autoMarketData
|
|
1787
|
+
|| this.activeMarketDataType === MarketDataType.DELAYED
|
|
1788
|
+
|| !(isPermission || isTimeout || isNoData)
|
|
1789
|
+
) {
|
|
1790
|
+
throw error;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
this.api?.setMarketDataType(MarketDataType.DELAYED);
|
|
1794
|
+
this.activeMarketDataType = MarketDataType.DELAYED;
|
|
1795
|
+
this.updateSnapshot({
|
|
1796
|
+
...this.snapshot,
|
|
1797
|
+
status: {
|
|
1798
|
+
state: "connected",
|
|
1799
|
+
updatedAt: Date.now(),
|
|
1800
|
+
mode: "gateway",
|
|
1801
|
+
message: "Using delayed IBKR market data because live API market data is not enabled for this account.",
|
|
1802
|
+
},
|
|
1803
|
+
lastError: message,
|
|
1804
|
+
});
|
|
1805
|
+
return operation();
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
private async loadAccounts(): Promise<BrokerAccount[]> {
|
|
1810
|
+
let managedAccounts: string[];
|
|
1811
|
+
try {
|
|
1812
|
+
managedAccounts = await withTimeout(this.api!.getManagedAccounts(), IBKR_DATA_TIMEOUT, "getManagedAccounts");
|
|
1813
|
+
} catch {
|
|
1814
|
+
// getManagedAccounts() can hang after reconnects or when another client with the same
|
|
1815
|
+
// clientId is connected. Fall back to requesting via the raw event API, or use cached IDs.
|
|
1816
|
+
if (this.cachedAccountIds.length > 0) {
|
|
1817
|
+
managedAccounts = this.cachedAccountIds;
|
|
1818
|
+
} else {
|
|
1819
|
+
managedAccounts = await withTimeout(
|
|
1820
|
+
new Promise<string[]>((resolve) => {
|
|
1821
|
+
const rawApi = this.getRawApi();
|
|
1822
|
+
const handler = (accountsList: string) => {
|
|
1823
|
+
rawApi.off(EventName.managedAccounts, handler);
|
|
1824
|
+
resolve(accountsList.split(",").map((s: string) => s.trim()).filter(Boolean));
|
|
1825
|
+
};
|
|
1826
|
+
rawApi.on(EventName.managedAccounts, handler);
|
|
1827
|
+
rawApi.reqManagedAccts();
|
|
1828
|
+
}),
|
|
1829
|
+
IBKR_DATA_TIMEOUT,
|
|
1830
|
+
"getManagedAccounts-fallback",
|
|
1831
|
+
);
|
|
1832
|
+
this.cachedAccountIds = managedAccounts;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (!managedAccounts.length) return [];
|
|
1836
|
+
let summary: ReadonlyMap<string, ReadonlyMap<string, ReadonlyMap<string, { value: string }>>> | undefined;
|
|
1837
|
+
try {
|
|
1838
|
+
const result = await firstValueFrom(
|
|
1839
|
+
this.api!.getAccountSummary(
|
|
1840
|
+
"All",
|
|
1841
|
+
"NetLiquidation,TotalCashValue,SettledCash,AvailableFunds,BuyingPower,ExcessLiquidity,InitMarginReq,MaintMarginReq,$LEDGER:ALL",
|
|
1842
|
+
)
|
|
1843
|
+
.pipe(take(1), timeout(10_000)),
|
|
1844
|
+
);
|
|
1845
|
+
summary = result.all;
|
|
1846
|
+
} catch {
|
|
1847
|
+
// Account summary may fail; return accounts with basic info only.
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const updatedAt = Date.now();
|
|
1851
|
+
const aggregateTags = summary?.get("All");
|
|
1852
|
+
const allowAggregateCashBalances = managedAccounts.length === 1;
|
|
1853
|
+
return managedAccounts.map((accountId) => summarizeBrokerAccount(
|
|
1854
|
+
accountId,
|
|
1855
|
+
summary?.get(accountId),
|
|
1856
|
+
updatedAt,
|
|
1857
|
+
aggregateTags,
|
|
1858
|
+
allowAggregateCashBalances,
|
|
1859
|
+
));
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
private async loadAccountsAndUpdateSnapshot(): Promise<BrokerAccount[]> {
|
|
1863
|
+
const accounts = await this.loadAccounts();
|
|
1864
|
+
this.updateSnapshot({ ...this.snapshot, accounts });
|
|
1865
|
+
return accounts;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
private async loadPortfolioSnapshotsForAccounts(
|
|
1869
|
+
accountIds: string[],
|
|
1870
|
+
): Promise<Map<string, AccountPortfolioSnapshotPosition>> {
|
|
1871
|
+
const snapshots = new Map<string, AccountPortfolioSnapshotPosition>();
|
|
1872
|
+
for (const accountId of accountIds) {
|
|
1873
|
+
const positions = await this.loadPortfolioSnapshotForAccount(accountId);
|
|
1874
|
+
for (const position of positions) {
|
|
1875
|
+
snapshots.set(buildPortfolioPositionKey(accountId, position.contract), position);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
return snapshots;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
private async loadPortfolioSnapshotForAccount(accountId: string): Promise<AccountPortfolioSnapshotPosition[]> {
|
|
1882
|
+
if (!this.api || !accountId) return [];
|
|
1883
|
+
|
|
1884
|
+
try {
|
|
1885
|
+
const update = await firstValueFrom(
|
|
1886
|
+
this.api.getAccountUpdates(accountId).pipe(
|
|
1887
|
+
filter((value) => value.changed == null && value.added == null && value.removed == null),
|
|
1888
|
+
take(1),
|
|
1889
|
+
timeout(IBKR_DATA_TIMEOUT),
|
|
1890
|
+
),
|
|
1891
|
+
);
|
|
1892
|
+
return (update.all.portfolio?.get(accountId) ?? []) as AccountPortfolioSnapshotPosition[];
|
|
1893
|
+
} catch (error: any) {
|
|
1894
|
+
gatewayLog.warn("Failed to load IBKR portfolio snapshot", {
|
|
1895
|
+
instanceId: this.instanceId,
|
|
1896
|
+
accountId,
|
|
1897
|
+
error: error?.message || String(error || ""),
|
|
1898
|
+
});
|
|
1899
|
+
return [];
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private async loadOpenOrders(): Promise<BrokerOrder[]> {
|
|
1904
|
+
const orders = await withTimeout(this.api!.getAllOpenOrders(), IBKR_DATA_TIMEOUT, "getAllOpenOrders");
|
|
1905
|
+
const mapped = orders.map((order) => this.openOrderToBrokerOrder(order));
|
|
1906
|
+
this.updateSnapshot({ ...this.snapshot, openOrders: mapped });
|
|
1907
|
+
return mapped;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
private async loadExecutions(): Promise<BrokerExecution[]> {
|
|
1911
|
+
const executions = await withTimeout(this.api!.getExecutionDetails({}), IBKR_DATA_TIMEOUT, "getExecutionDetails");
|
|
1912
|
+
const mapped = executions.map((detail) => ({
|
|
1913
|
+
execId: detail.execution.execId || `${detail.execution.orderId ?? "exec"}-${detail.execution.time ?? Date.now()}`,
|
|
1914
|
+
brokerInstanceId: this.instanceId,
|
|
1915
|
+
orderId: detail.execution.orderId,
|
|
1916
|
+
accountId: detail.execution.acctNumber,
|
|
1917
|
+
side: detail.execution.side || "",
|
|
1918
|
+
shares: detail.execution.shares ?? 0,
|
|
1919
|
+
price: detail.execution.price ?? 0,
|
|
1920
|
+
time: detail.execution.time ? Date.parse(detail.execution.time) : Date.now(),
|
|
1921
|
+
exchange: detail.execution.exchange,
|
|
1922
|
+
contract: this.contractToRef(detail.contract),
|
|
1923
|
+
}));
|
|
1924
|
+
this.updateSnapshot({ ...this.snapshot, executions: mapped });
|
|
1925
|
+
return mapped;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private contractDescriptionToSearchResult(description: ContractDescription): InstrumentSearchResult | null {
|
|
1929
|
+
const contract = description.contract;
|
|
1930
|
+
if (!contract?.symbol) return null;
|
|
1931
|
+
return {
|
|
1932
|
+
providerId: "ibkr",
|
|
1933
|
+
brokerInstanceId: this.instanceId,
|
|
1934
|
+
symbol: contract.localSymbol || contract.symbol,
|
|
1935
|
+
name: contract.description || contract.symbol,
|
|
1936
|
+
exchange: contract.primaryExch || contract.exchange || "",
|
|
1937
|
+
type: contract.secType || "",
|
|
1938
|
+
currency: contract.currency,
|
|
1939
|
+
primaryExchange: contract.primaryExch,
|
|
1940
|
+
brokerContract: this.contractToRef(contract),
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
private contractToRef(contract: Contract): BrokerContractRef {
|
|
1945
|
+
return {
|
|
1946
|
+
brokerId: "ibkr",
|
|
1947
|
+
brokerInstanceId: this.instanceId,
|
|
1948
|
+
conId: contract.conId,
|
|
1949
|
+
symbol: contract.symbol || "",
|
|
1950
|
+
localSymbol: contract.localSymbol,
|
|
1951
|
+
secType: contract.secType,
|
|
1952
|
+
exchange: contract.exchange,
|
|
1953
|
+
primaryExchange: contract.primaryExch,
|
|
1954
|
+
currency: contract.currency,
|
|
1955
|
+
lastTradeDateOrContractMonth: contract.lastTradeDateOrContractMonth,
|
|
1956
|
+
right: contract.right === OptionType.Call ? "C" : contract.right === OptionType.Put ? "P" : undefined,
|
|
1957
|
+
strike: contract.strike,
|
|
1958
|
+
multiplier: contract.multiplier != null ? String(contract.multiplier) : undefined,
|
|
1959
|
+
tradingClass: contract.tradingClass,
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
private refToContract(ref: BrokerContractRef): Contract {
|
|
1964
|
+
return {
|
|
1965
|
+
conId: ref.conId,
|
|
1966
|
+
symbol: ref.symbol,
|
|
1967
|
+
localSymbol: ref.localSymbol,
|
|
1968
|
+
secType: (ref.secType as SecType | undefined) ?? SecType.STK,
|
|
1969
|
+
exchange: "SMART",
|
|
1970
|
+
primaryExch: ref.primaryExchange || ref.exchange || undefined,
|
|
1971
|
+
currency: ref.currency || "USD",
|
|
1972
|
+
lastTradeDateOrContractMonth: ref.lastTradeDateOrContractMonth,
|
|
1973
|
+
right: ref.right === "C" ? OptionType.Call : ref.right === "P" ? OptionType.Put : undefined,
|
|
1974
|
+
strike: ref.strike,
|
|
1975
|
+
multiplier: ref.multiplier ? parseFloat(ref.multiplier) : undefined,
|
|
1976
|
+
tradingClass: ref.tradingClass,
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
private async resolveContract(ticker: string, exchange: string, instrument: BrokerContractRef | null): Promise<Contract> {
|
|
1981
|
+
if (instrument) {
|
|
1982
|
+
if (instrument.conId) {
|
|
1983
|
+
return this.refToContract(instrument);
|
|
1984
|
+
}
|
|
1985
|
+
return this.getPrimaryContractDetails(this.refToContract(instrument)).then((detail) => detail.contract);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
const directMatches = await withTimeout(this.getDirectContractMatches(ticker), IBKR_DATA_TIMEOUT, "getDirectContractMatches");
|
|
1989
|
+
const direct = directMatches.find((result) => {
|
|
1990
|
+
const symbol = result.symbol.toUpperCase();
|
|
1991
|
+
const upperTicker = ticker.toUpperCase();
|
|
1992
|
+
return symbol === upperTicker || symbol === ticker;
|
|
1993
|
+
});
|
|
1994
|
+
if (direct?.brokerContract) {
|
|
1995
|
+
return this.refToContract(direct.brokerContract);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
const symbolResults = (await withTimeout(this.api!.getMatchingSymbols(ticker), IBKR_DATA_TIMEOUT, "getMatchingSymbols"))
|
|
1999
|
+
.map((description) => this.contractDescriptionToSearchResult(description))
|
|
2000
|
+
.filter((value): value is InstrumentSearchResult => value != null);
|
|
2001
|
+
const matched = symbolResults.find((result) => {
|
|
2002
|
+
const symbol = result.symbol.toUpperCase();
|
|
2003
|
+
const upperTicker = ticker.toUpperCase();
|
|
2004
|
+
return symbol === upperTicker || symbol === ticker;
|
|
2005
|
+
});
|
|
2006
|
+
if (matched?.brokerContract) {
|
|
2007
|
+
return this.refToContract(matched.brokerContract);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
const fallbackContract: Contract = {
|
|
2011
|
+
symbol: ticker,
|
|
2012
|
+
exchange: exchange || "SMART",
|
|
2013
|
+
currency: "USD",
|
|
2014
|
+
secType: SecType.STK,
|
|
2015
|
+
};
|
|
2016
|
+
const details = await this.getPrimaryContractDetails(fallbackContract);
|
|
2017
|
+
return details.contract;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
private async getPrimaryContractDetails(contract: Contract): Promise<ContractDetails> {
|
|
2021
|
+
const details = await withTimeout(this.api!.getContractDetails(contract), IBKR_DATA_TIMEOUT, "getContractDetails");
|
|
2022
|
+
if (!details.length) {
|
|
2023
|
+
throw new Error(`Unable to resolve contract ${contract.symbol || contract.localSymbol || contract.conId}`);
|
|
2024
|
+
}
|
|
2025
|
+
return details[0]!;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
private async getDirectContractMatches(query: string): Promise<InstrumentSearchResult[]> {
|
|
2029
|
+
const trimmed = query.trim();
|
|
2030
|
+
if (!trimmed) return [];
|
|
2031
|
+
|
|
2032
|
+
const candidates: Contract[] = [];
|
|
2033
|
+
const optionLike = /^\S+\s+\d{6}[CP]\d{8}$/i.test(trimmed);
|
|
2034
|
+
|
|
2035
|
+
if (optionLike) {
|
|
2036
|
+
candidates.push({
|
|
2037
|
+
localSymbol: trimmed,
|
|
2038
|
+
secType: SecType.OPT,
|
|
2039
|
+
exchange: "SMART",
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
candidates.push({
|
|
2044
|
+
localSymbol: trimmed,
|
|
2045
|
+
exchange: "SMART",
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
const seen = new Set<string>();
|
|
2049
|
+
const results: InstrumentSearchResult[] = [];
|
|
2050
|
+
|
|
2051
|
+
for (const candidate of candidates) {
|
|
2052
|
+
try {
|
|
2053
|
+
const details = await withTimeout(this.api!.getContractDetails(candidate), IBKR_DATA_TIMEOUT, "getDirectContractDetails");
|
|
2054
|
+
for (const detail of details) {
|
|
2055
|
+
const contract = detail.contract;
|
|
2056
|
+
const result: InstrumentSearchResult = {
|
|
2057
|
+
providerId: "ibkr",
|
|
2058
|
+
brokerInstanceId: this.instanceId,
|
|
2059
|
+
symbol: contract.localSymbol || contract.symbol || trimmed,
|
|
2060
|
+
name: detail.longName || detail.marketName || contract.description || contract.symbol || trimmed,
|
|
2061
|
+
exchange: contract.primaryExch || contract.exchange || "",
|
|
2062
|
+
type: contract.secType || "",
|
|
2063
|
+
currency: contract.currency,
|
|
2064
|
+
primaryExchange: contract.primaryExch,
|
|
2065
|
+
brokerContract: this.contractToRef(contract),
|
|
2066
|
+
};
|
|
2067
|
+
const key = `${result.symbol}|${result.exchange}|${result.type}`;
|
|
2068
|
+
if (seen.has(key)) continue;
|
|
2069
|
+
seen.add(key);
|
|
2070
|
+
results.push(result);
|
|
2071
|
+
}
|
|
2072
|
+
} catch {
|
|
2073
|
+
// Ignore exact-match lookup failures and fall back to symbol search.
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
return results;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
private marketDataToQuote(contract: Contract, details: ContractDetails, marketData: ReadonlyMap<number, { value?: number; ingressTm: number }>): Quote {
|
|
2081
|
+
const priceDivisor = getIbkrPriceDivisor(contract, details);
|
|
2082
|
+
const mark = normalizeIbkrPriceValue(marketData.get(IBApiTickType.MARK_PRICE)?.value, priceDivisor);
|
|
2083
|
+
const last = normalizeIbkrPriceValue(
|
|
2084
|
+
marketData.get(IBApiTickType.LAST)?.value
|
|
2085
|
+
?? marketData.get(IBApiTickType.DELAYED_LAST)?.value
|
|
2086
|
+
?? marketData.get(IBApiTickType.MARK_PRICE)?.value
|
|
2087
|
+
?? marketData.get(IBApiTickType.CLOSE)?.value,
|
|
2088
|
+
priceDivisor,
|
|
2089
|
+
);
|
|
2090
|
+
if (last == null || last <= 0) {
|
|
2091
|
+
throw new Error(`No valid market data for ${contract.symbol || contract.localSymbol || contract.conId}`);
|
|
2092
|
+
}
|
|
2093
|
+
const close = normalizeIbkrPriceValue(
|
|
2094
|
+
marketData.get(IBApiTickType.CLOSE)?.value
|
|
2095
|
+
?? marketData.get(IBApiTickType.DELAYED_CLOSE)?.value
|
|
2096
|
+
?? last,
|
|
2097
|
+
priceDivisor,
|
|
2098
|
+
) ?? last;
|
|
2099
|
+
const change = last - close;
|
|
2100
|
+
const ingressTm = marketData.get(IBApiTickType.LAST)?.ingressTm
|
|
2101
|
+
?? marketData.get(IBApiTickType.DELAYED_LAST)?.ingressTm
|
|
2102
|
+
?? marketData.get(IBApiTickType.MARK_PRICE)?.ingressTm
|
|
2103
|
+
?? Date.now();
|
|
2104
|
+
|
|
2105
|
+
return {
|
|
2106
|
+
symbol: contract.localSymbol || contract.symbol || "",
|
|
2107
|
+
price: last,
|
|
2108
|
+
currency: contract.currency || "USD",
|
|
2109
|
+
change,
|
|
2110
|
+
changePercent: close ? (change / close) * 100 : 0,
|
|
2111
|
+
previousClose: close,
|
|
2112
|
+
high52w: normalizeIbkrPriceValue(marketData.get(IBApiTickType.HIGH_52_WEEK)?.value, priceDivisor),
|
|
2113
|
+
low52w: normalizeIbkrPriceValue(marketData.get(IBApiTickType.LOW_52_WEEK)?.value, priceDivisor),
|
|
2114
|
+
volume: marketData.get(IBApiTickType.VOLUME)?.value ?? marketData.get(IBApiTickType.DELAYED_VOLUME)?.value,
|
|
2115
|
+
name: details.longName || details.marketName || contract.symbol,
|
|
2116
|
+
lastUpdated: ingressTm,
|
|
2117
|
+
exchangeName: details.validExchanges?.split(",")[0],
|
|
2118
|
+
fullExchangeName: details.validExchanges?.split(",")[0],
|
|
2119
|
+
marketState: "REGULAR",
|
|
2120
|
+
bid: normalizeIbkrPriceValue(
|
|
2121
|
+
marketData.get(IBApiTickType.BID)?.value ?? marketData.get(IBApiTickType.DELAYED_BID)?.value,
|
|
2122
|
+
priceDivisor,
|
|
2123
|
+
),
|
|
2124
|
+
ask: normalizeIbkrPriceValue(
|
|
2125
|
+
marketData.get(IBApiTickType.ASK)?.value ?? marketData.get(IBApiTickType.DELAYED_ASK)?.value,
|
|
2126
|
+
priceDivisor,
|
|
2127
|
+
),
|
|
2128
|
+
bidSize: marketData.get(IBApiTickType.BID_SIZE)?.value ?? marketData.get(IBApiTickType.DELAYED_BID_SIZE)?.value,
|
|
2129
|
+
askSize: marketData.get(IBApiTickType.ASK_SIZE)?.value ?? marketData.get(IBApiTickType.DELAYED_ASK_SIZE)?.value,
|
|
2130
|
+
open: normalizeIbkrPriceValue(
|
|
2131
|
+
marketData.get(IBApiTickType.OPEN)?.value ?? marketData.get(IBApiTickType.DELAYED_OPEN)?.value,
|
|
2132
|
+
priceDivisor,
|
|
2133
|
+
),
|
|
2134
|
+
high: normalizeIbkrPriceValue(
|
|
2135
|
+
marketData.get(IBApiTickType.HIGH)?.value ?? marketData.get(IBApiTickType.DELAYED_HIGH)?.value,
|
|
2136
|
+
priceDivisor,
|
|
2137
|
+
),
|
|
2138
|
+
low: normalizeIbkrPriceValue(
|
|
2139
|
+
marketData.get(IBApiTickType.LOW)?.value ?? marketData.get(IBApiTickType.DELAYED_LOW)?.value,
|
|
2140
|
+
priceDivisor,
|
|
2141
|
+
),
|
|
2142
|
+
mark,
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
private openOrderToBrokerOrder(openOrder: {
|
|
2147
|
+
orderId: number;
|
|
2148
|
+
contract: Contract;
|
|
2149
|
+
order: Order;
|
|
2150
|
+
orderState: OrderState;
|
|
2151
|
+
orderStatus?: {
|
|
2152
|
+
status: string;
|
|
2153
|
+
filled: number;
|
|
2154
|
+
remaining: number;
|
|
2155
|
+
avgFillPrice: number;
|
|
2156
|
+
};
|
|
2157
|
+
}): BrokerOrder {
|
|
2158
|
+
return {
|
|
2159
|
+
orderId: openOrder.orderId,
|
|
2160
|
+
brokerInstanceId: this.instanceId,
|
|
2161
|
+
accountId: openOrder.order.account,
|
|
2162
|
+
status: openOrder.orderStatus?.status || openOrder.orderState.status || "Unknown",
|
|
2163
|
+
action: (openOrder.order.action || "BUY") as BrokerOrder["action"],
|
|
2164
|
+
orderType: openOrder.order.orderType || "",
|
|
2165
|
+
quantity: openOrder.order.totalQuantity ?? 0,
|
|
2166
|
+
filled: openOrder.orderStatus?.filled ?? 0,
|
|
2167
|
+
remaining: openOrder.orderStatus?.remaining ?? openOrder.order.totalQuantity ?? 0,
|
|
2168
|
+
avgFillPrice: openOrder.orderStatus?.avgFillPrice,
|
|
2169
|
+
limitPrice: openOrder.order.lmtPrice,
|
|
2170
|
+
stopPrice: openOrder.order.auxPrice,
|
|
2171
|
+
tif: openOrder.order.tif,
|
|
2172
|
+
warningText: openOrder.orderState.warningText,
|
|
2173
|
+
updatedAt: Date.now(),
|
|
2174
|
+
contract: this.contractToRef(openOrder.contract),
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
private buildOrder(request: BrokerOrderRequest, whatIf: boolean): Order {
|
|
2179
|
+
return {
|
|
2180
|
+
account: request.accountId,
|
|
2181
|
+
action: request.action === "BUY" ? OrderAction.BUY : OrderAction.SELL,
|
|
2182
|
+
totalQuantity: request.quantity,
|
|
2183
|
+
orderType: request.orderType as OrderType,
|
|
2184
|
+
lmtPrice: request.limitPrice,
|
|
2185
|
+
auxPrice: request.stopPrice,
|
|
2186
|
+
tif: (request.tif ?? TimeInForce.DAY) as typeof TimeInForce[keyof typeof TimeInForce],
|
|
2187
|
+
outsideRth: request.outsideRth ?? false,
|
|
2188
|
+
whatIf,
|
|
2189
|
+
transmit: true,
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
private getRawApi(): any {
|
|
2194
|
+
return (this.api as any)?.api ?? this.api;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
export class IbkrGatewayServiceManager {
|
|
2199
|
+
private services = new Map<string, IbkrGatewayService>();
|
|
2200
|
+
|
|
2201
|
+
getService(instanceId: string): IbkrGatewayService {
|
|
2202
|
+
let service = this.services.get(instanceId);
|
|
2203
|
+
if (!service) {
|
|
2204
|
+
service = new IbkrGatewayService(instanceId);
|
|
2205
|
+
this.services.set(instanceId, service);
|
|
2206
|
+
}
|
|
2207
|
+
return service;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
getSnapshot(instanceId?: string): IbkrSnapshot {
|
|
2211
|
+
if (!instanceId) return DEFAULT_SNAPSHOT;
|
|
2212
|
+
return this.getService(instanceId).getSnapshot();
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
subscribe(instanceId: string | undefined, listener: () => void): () => void {
|
|
2216
|
+
if (!instanceId) return () => {};
|
|
2217
|
+
return this.getService(instanceId).subscribe(listener);
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async removeInstance(instanceId: string): Promise<void> {
|
|
2221
|
+
const service = this.services.get(instanceId);
|
|
2222
|
+
if (!service) return;
|
|
2223
|
+
await service.disconnect();
|
|
2224
|
+
this.services.delete(instanceId);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
export const ibkrGatewayManager = new IbkrGatewayServiceManager();
|