gloomberb 0.9.2 → 0.9.4
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 +24 -2
- package/bin/gloomberb +1 -1
- package/package.json +1 -1
- package/src/app/runtime/desktop-menu.ts +5 -1
- package/src/app/runtime/ticker-refresh.ts +4 -1
- package/src/app.tsx +20 -8
- package/src/brokers/account-cache.ts +1 -1
- package/src/brokers/sync-broker-instance.test.ts +107 -0
- package/src/brokers/sync-broker-instance.ts +22 -10
- package/src/capabilities/registry.ts +1 -0
- package/src/capabilities/types.ts +23 -0
- package/src/cli/commands/api.ts +119 -0
- package/src/cli/commands/automation.ts +173 -0
- package/src/cli/commands/command-utils.ts +53 -0
- package/src/cli/commands/market.ts +527 -0
- package/src/cli/commands/overview.ts +222 -0
- package/src/cli/commands/search.ts +39 -0
- package/src/cli/commands/system.ts +553 -0
- package/src/cli/commands/ticker.ts +104 -12
- package/src/cli/context.ts +35 -2
- package/src/cli/desktop-pane-shot.ts +2 -1
- package/src/cli/entry.ts +51 -0
- package/src/cli/errors.ts +56 -5
- package/src/cli/index.ts +73 -11
- package/src/cli/options.test.ts +38 -0
- package/src/cli/options.ts +95 -0
- package/src/cli/pane-functions/index.ts +10 -1
- package/src/cli/pane-functions/screenshot.ts +13 -1
- package/src/cli/registry.test.ts +40 -0
- package/src/cli/registry.ts +46 -5
- package/src/cli/result.test.ts +82 -0
- package/src/cli/result.ts +149 -0
- package/src/cli/types.ts +11 -0
- package/src/cli.test.ts +11 -11
- package/src/components/chart/bar-chart-renderer.test.ts +108 -3
- package/src/components/chart/bar-chart-renderer.ts +226 -27
- package/src/components/chart/comparison-stock-chart/interactions.ts +7 -1
- package/src/components/chart/comparison-stock-chart/legend.tsx +4 -0
- package/src/components/chart/comparison-stock-chart/render-data.ts +37 -8
- package/src/components/chart/comparison-stock-chart/selection-runtime.ts +19 -2
- package/src/components/chart/comparison-stock-chart/sources.ts +8 -4
- package/src/components/chart/comparison-stock-chart/toolbar.tsx +13 -3
- package/src/components/chart/comparison-stock-chart/view.tsx +12 -2
- package/src/components/chart/core/pane-settings.test.ts +27 -0
- package/src/components/chart/core/pane-settings.ts +60 -1
- package/src/components/chart/core/pointer.ts +34 -6
- package/src/components/chart/price-axis-labels.tsx +3 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
- package/src/components/chart/static/bar-chart-surface.tsx +151 -28
- package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
- package/src/components/chart/static/chart/surface.test.tsx +92 -0
- package/src/components/chart/static/chart/surface.tsx +167 -24
- package/src/components/chart/stock-chart/index.test.ts +25 -0
- package/src/components/chart/stock-chart/resolved/runtime.ts +1 -1
- package/src/components/chart/stock-chart/settings.ts +7 -3
- package/src/components/chart/stock-chart/viewport/runtime.ts +5 -5
- package/src/components/command-bar/layout-items/current-layout.ts +5 -1
- package/src/components/command-bar/pane-templates/workflow-route.ts +10 -0
- package/src/components/command-bar/surface/pane.test.tsx +76 -0
- package/src/components/data-table/view.tsx +13 -2
- package/src/components/feed-data-table/stack-view.tsx +9 -1
- package/src/components/index.ts +1 -1
- package/src/components/input-search-bar.test.tsx +103 -0
- package/src/components/input-search-bar.tsx +16 -0
- package/src/components/layout/detached-pane-shell.tsx +6 -3
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/pane/content.tsx +20 -8
- package/src/components/layout/pane/footer/registration.tsx +6 -2
- package/src/components/layout/pane/frame.tsx +4 -4
- package/src/components/layout/pane/header.tsx +40 -4
- package/src/components/layout/pane/index.tsx +3 -1
- package/src/components/layout/pane/sizing.test.ts +43 -0
- package/src/components/layout/pane/sizing.ts +13 -4
- package/src/components/layout/shell/active-drag.ts +5 -1
- package/src/components/layout/shell/cursor-occlusion.ts +196 -0
- package/src/components/layout/shell/drag/runtime.ts +28 -3
- package/src/components/layout/shell/fullscreen.ts +10 -0
- package/src/components/layout/shell/index.test.tsx +487 -4
- package/src/components/layout/shell/index.tsx +165 -4
- package/src/components/layout/shell/layout-state.test.ts +33 -0
- package/src/components/layout/shell/layout-state.ts +4 -9
- package/src/components/layout/shell/native/pointer-runtime.ts +10 -4
- package/src/components/layout/shell/pane/layers.tsx +34 -16
- package/src/components/layout/shell/pane/management-shortcuts.ts +19 -0
- package/src/components/layout/shell/shortcuts.ts +4 -0
- package/src/components/layout/shell/terminal-pointer-runtime.ts +29 -19
- package/src/components/layout/shell/visible-layout.ts +17 -0
- package/src/components/layout/shell/window-mode/index.ts +11 -3
- package/src/components/layout/shell/window-mode/overlays.tsx +8 -1
- package/src/components/layout/status-bar.test.tsx +111 -1
- package/src/components/layout/status-bar.tsx +37 -7
- package/src/components/layout/transient-layout.tsx +57 -0
- package/src/components/layout/window-edit/mode.test.ts +76 -5
- package/src/components/layout/window-edit/mode.ts +47 -2
- package/src/components/layout/window-edit/presentation.ts +2 -1
- package/src/components/onboarding/onboarding-steps.tsx +8 -28
- package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
- package/src/components/onboarding/onboarding-wizard.tsx +2 -4
- package/src/components/onboarding/portfolio-step/index.tsx +0 -12
- package/src/components/onboarding/portfolio-step/types.ts +1 -3
- package/src/components/onboarding/wizard-keyboard.ts +2 -5
- package/src/components/price-performance.tsx +9 -1
- package/src/components/table-view-shared.tsx +12 -4
- package/src/components/ui/data-table/opentui/index.tsx +25 -12
- package/src/components/ui/data-table/types.ts +4 -0
- package/src/components/ui/fields.tsx +10 -3
- package/src/components/ui/page-stack-view.tsx +6 -2
- package/src/components/ui/table-layout.test.ts +25 -0
- package/src/components/ui/table-layout.ts +22 -6
- package/src/components/ui/tabs.tsx +26 -10
- package/src/components/ui/ui.test.tsx +87 -0
- package/src/core/app-services.ts +7 -4
- package/src/core/state/app/layout-reducer.ts +2 -2
- package/src/data/config/store/index.ts +1 -2
- package/src/plugins/builtin/analytics/index.test.tsx +6 -2
- package/src/plugins/builtin/analytics/pane-model.ts +43 -8
- package/src/plugins/builtin/cloud-tweets/pane.tsx +2 -0
- package/src/plugins/builtin/cloud-tweets/search-bar.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +14 -2
- package/src/plugins/builtin/comparison-chart/index.test.tsx +130 -6
- package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
- package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
- package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
- package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
- package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
- package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
- package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
- package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
- package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
- package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
- package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
- package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
- package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
- package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
- package/src/plugins/builtin/layout-manager/index.tsx +5 -1
- package/src/plugins/builtin/news/wire/read-state.ts +9 -32
- package/src/plugins/builtin/options/view.test.tsx +141 -4
- package/src/plugins/builtin/options/view.tsx +76 -15
- package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +23 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +19 -2
- package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +3 -1
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -7
- package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
- package/src/plugins/builtin/shared/read-state.ts +43 -0
- package/src/plugins/builtin/substack/article-detail.test.tsx +10 -0
- package/src/plugins/builtin/substack/article-detail.tsx +39 -15
- package/src/plugins/builtin/substack/article-stack.tsx +6 -2
- package/src/plugins/builtin/substack/feed-tabs.tsx +12 -9
- package/src/plugins/builtin/substack/pane.tsx +8 -3
- package/src/plugins/builtin/substack/read-state.ts +55 -0
- package/src/plugins/builtin/thirteenf/pane.test.tsx +26 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/ibkr/broker-adapter.ts +19 -0
- package/src/plugins/ibkr/config.test.ts +31 -2
- package/src/plugins/ibkr/config.ts +9 -2
- package/src/plugins/ibkr/flex/index.test.ts +18 -9
- package/src/plugins/ibkr/flex/index.ts +30 -0
- package/src/plugins/ibkr/flex/statement-client.ts +68 -14
- package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
- package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
- package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
- package/src/plugins/pane-manager/docking.ts +9 -2
- package/src/plugins/pane-manager/gridlock.ts +13 -5
- package/src/plugins/pane-manager/layout-state.ts +25 -0
- package/src/plugins/pane-manager/queries.ts +8 -2
- package/src/plugins/pane-manager.test.ts +33 -0
- package/src/plugins/pane-manager.ts +6 -1
- package/src/plugins/prediction-markets/chart.tsx +10 -4
- package/src/plugins/prediction-markets/controller/index.ts +2 -0
- package/src/plugins/prediction-markets/controller/keyboard.ts +34 -5
- package/src/plugins/prediction-markets/detail/book.tsx +141 -96
- package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
- package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
- package/src/plugins/prediction-markets/detail.test.tsx +50 -0
- package/src/plugins/prediction-markets/pane.test.tsx +75 -0
- package/src/plugins/prediction-markets/pane.tsx +71 -23
- package/src/plugins/prediction-markets/test-helpers.tsx +42 -23
- package/src/renderers/electrobun/bun/application-menu/click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/click.ts +1 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/index.ts +13 -0
- package/src/renderers/electrobun/bun/window/focus.test.ts +35 -0
- package/src/renderers/electrobun/bun/window/focus.ts +5 -0
- package/src/renderers/electrobun/view/app-services.ts +6 -4
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
- package/src/renderers/electrobun/view/data-table/index.tsx +19 -4
- package/src/renderers/electrobun/view/data-table/row.tsx +30 -6
- package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
- package/src/renderers/electrobun/view/host/ascii-text.ts +50 -0
- package/src/renderers/electrobun/view/host/input.tsx +115 -18
- package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
- package/src/renderers/electrobun/view/host/text.test.ts +22 -0
- package/src/renderers/electrobun/view/host/text.tsx +24 -20
- package/src/renderers/electrobun/view/styles.css +11 -0
- package/src/renderers/electrobun/view/ui-host.tsx +1 -1
- package/src/renderers/opentui/host.tsx +17 -1
- package/src/renderers/opentui/start.tsx +13 -5
- package/src/renderers/opentui/ui-host.tsx +57 -25
- package/src/sources/provider-router/batches.ts +16 -2
- package/src/sources/provider-router/financial-routes.ts +5 -0
- package/src/sources/provider-router/financials.ts +11 -23
- package/src/sources/provider-router/index.test.ts +189 -0
- package/src/sources/provider-router/primary.ts +3 -2
- package/src/sources/sec-edgar.test.ts +83 -0
- package/src/sources/sec-edgar.ts +231 -0
- package/src/sources/yahoo-finance.test.ts +51 -0
- package/src/sources/yahoo-finance.ts +49 -1
- package/src/state/app/context/index.test.tsx +5 -8
- package/src/state/app/input-capture.ts +12 -0
- package/src/types/broker.ts +1 -0
- package/src/types/config.ts +10 -0
- package/src/types/plugin.ts +28 -0
- package/src/types/trading.ts +2 -0
- package/src/ui/host.tsx +16 -0
- package/src/ui/index.tsx +2 -0
- package/src/utils/cli-output.ts +6 -0
- package/src/utils/financial-statements.ts +23 -0
- package/src/utils/search-focus-navigation.ts +24 -0
- package/src/version.ts +1 -1
- package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
|
@@ -71,9 +71,19 @@ export function buildFundamentalGraphRows(
|
|
|
71
71
|
symbol = "",
|
|
72
72
|
period: FundamentalPeriod = "annual",
|
|
73
73
|
): FundamentalGraphRow[] {
|
|
74
|
-
const
|
|
74
|
+
const sortedStatements = [...statements]
|
|
75
75
|
.filter((statement) => typeof statement[metric] === "number")
|
|
76
76
|
.sort((left, right) => left.date.localeCompare(right.date));
|
|
77
|
+
const byCategory = new Map<string, FinancialStatement>();
|
|
78
|
+
for (const statement of sortedStatements) {
|
|
79
|
+
const category = periodCategory(statement.date, period);
|
|
80
|
+
const key = `${symbol}\u0000${category}`;
|
|
81
|
+
const previous = byCategory.get(key);
|
|
82
|
+
if (!previous || statement.date.localeCompare(previous.date) >= 0) {
|
|
83
|
+
byCategory.set(key, statement);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const sorted = [...byCategory.values()].sort((left, right) => left.date.localeCompare(right.date));
|
|
77
87
|
const maxAbs = Math.max(1, ...sorted.map((statement) => Math.abs(statement[metric] as number)));
|
|
78
88
|
return sorted.map((statement, index) => {
|
|
79
89
|
const value = statement[metric] as number;
|
|
@@ -159,10 +169,23 @@ export function buildValuationGraphRows(
|
|
|
159
169
|
});
|
|
160
170
|
}
|
|
161
171
|
|
|
162
|
-
function metricDefs(kind: GraphKind): ReadonlyArray<MetricDefinition> {
|
|
172
|
+
export function metricDefs(kind: GraphKind): ReadonlyArray<MetricDefinition> {
|
|
163
173
|
return kind === "valuation" ? VALUATION_METRICS : FUNDAMENTAL_METRICS;
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
export function allMetricDefs(): ReadonlyArray<{ kind: GraphKind; definition: MetricDefinition }> {
|
|
177
|
+
return [
|
|
178
|
+
...FUNDAMENTAL_METRICS.map((definition) => ({ kind: "fundamental" as const, definition })),
|
|
179
|
+
...VALUATION_METRICS.map((definition) => ({ kind: "valuation" as const, definition })),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function metricKind(metric: GraphMetricKey): GraphKind | null {
|
|
184
|
+
if (FUNDAMENTAL_METRICS.some((definition) => definition.key === metric)) return "fundamental";
|
|
185
|
+
if (VALUATION_METRICS.some((definition) => definition.key === metric)) return "valuation";
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
export function defaultMetric(kind: GraphKind): GraphMetricKey {
|
|
167
190
|
return kind === "valuation" ? "priceSales" : "totalRevenue";
|
|
168
191
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import type { PaneProps, PaneTemplateDef } from "../../../../types/plugin";
|
|
2
|
+
import type { PaneProps, PaneTemplateDef, TickerResearchTabProps } from "../../../../types/plugin";
|
|
3
3
|
import { formatTickerListInput } from "../../../../tickers/list";
|
|
4
4
|
import { usePaneInstance, usePaneTicker } from "../../../../state/app/context";
|
|
5
5
|
import { usePluginPaneState } from "../../../runtime";
|
|
@@ -26,7 +26,7 @@ export function FundamentalGraphPane({ focused, width, height }: PaneProps) {
|
|
|
26
26
|
const { symbol, exchange } = useBoundTicker();
|
|
27
27
|
const symbols = useMemo(() => symbolsFromPaneSettings(pane?.settings, symbol), [pane?.settings, symbol]);
|
|
28
28
|
const configuredKind = graphKindFromSettings(pane?.settings, "fundamental");
|
|
29
|
-
const [period, setPeriod] = usePluginPaneState<FundamentalPeriod>("period", "
|
|
29
|
+
const [period, setPeriod] = usePluginPaneState<FundamentalPeriod>("period", "quarterly");
|
|
30
30
|
const [chartKind, setChartKind] = usePluginPaneState<GraphKind>("chartKind", configuredKind);
|
|
31
31
|
const [metric, setMetric] = usePluginPaneState<GraphMetricKey>("metric", defaultMetric(configuredKind));
|
|
32
32
|
const resolvedMetric = isMetricForKind(chartKind, metric) ? metric : defaultMetric(chartKind);
|
|
@@ -54,9 +54,9 @@ export function FundamentalGraphPane({ focused, width, height }: PaneProps) {
|
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function FundamentalGraphsResearchTab({ focused, width, height }:
|
|
57
|
+
export function FundamentalGraphsResearchTab({ focused, width, height, onCapture }: TickerResearchTabProps) {
|
|
58
58
|
const { symbol, financials } = usePaneTicker();
|
|
59
|
-
const [period, setPeriod] = usePluginPaneState<FundamentalPeriod>("detailPeriod", "
|
|
59
|
+
const [period, setPeriod] = usePluginPaneState<FundamentalPeriod>("detailPeriod", "quarterly");
|
|
60
60
|
const [chartKind, setChartKind] = usePluginPaneState<GraphKind>("detailChartKind", "fundamental");
|
|
61
61
|
const [metric, setMetric] = usePluginPaneState<GraphMetricKey>("detailMetric", "totalRevenue");
|
|
62
62
|
const resolvedMetric = isMetricForKind(chartKind, metric) ? metric : defaultMetric(chartKind);
|
|
@@ -79,6 +79,7 @@ export function FundamentalGraphsResearchTab({ focused, width, height }: { focus
|
|
|
79
79
|
setMetric={setMetric}
|
|
80
80
|
chartKind={chartKind}
|
|
81
81
|
setChartKind={setChartKind}
|
|
82
|
+
onCapture={onCapture}
|
|
82
83
|
/>
|
|
83
84
|
);
|
|
84
85
|
}
|
|
@@ -49,6 +49,22 @@ describe("ticker data panes", () => {
|
|
|
49
49
|
]);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test("collapses duplicate quarterly statement dates into one displayed period", () => {
|
|
53
|
+
const rows = buildFundamentalGraphRows([
|
|
54
|
+
{ date: "2025-03-29", eps: 0.44 },
|
|
55
|
+
{ date: "2025-03-31", eps: 0.44 },
|
|
56
|
+
{ date: "2025-06-28", eps: 0.54 },
|
|
57
|
+
{ date: "2025-06-30", eps: 0.54 },
|
|
58
|
+
{ date: "2025-09-27", eps: 0.75 },
|
|
59
|
+
] satisfies FinancialStatement[], "eps", "AMD", "quarterly");
|
|
60
|
+
|
|
61
|
+
expect(rows.map((row) => [row.date, row.category, row.value])).toEqual([
|
|
62
|
+
["2025-03-31", "2025 Q1", 0.44],
|
|
63
|
+
["2025-06-30", "2025 Q2", 0.54],
|
|
64
|
+
["2025-09-27", "2025 Q3", 0.75],
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
52
68
|
test("builds valuation graph rows from available multiples", () => {
|
|
53
69
|
const rows = buildValuationGraphRows({
|
|
54
70
|
quote: { symbol: "AMD", price: 100, currency: "USD", change: 0, changePercent: 0, lastUpdated: 1, marketCap: 1_000 },
|
|
@@ -305,6 +305,25 @@ async function clickFrameText(text: string) {
|
|
|
305
305
|
await flushFrame();
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
async function emitKeypress(event: { name?: string; sequence?: string }) {
|
|
309
|
+
await act(async () => {
|
|
310
|
+
(testSetup!.renderer as any).keyInput.emit("keypress", {
|
|
311
|
+
ctrl: false,
|
|
312
|
+
meta: false,
|
|
313
|
+
option: false,
|
|
314
|
+
shift: false,
|
|
315
|
+
eventType: "press",
|
|
316
|
+
repeated: false,
|
|
317
|
+
preventDefault: () => {},
|
|
318
|
+
stopPropagation: () => {},
|
|
319
|
+
...event,
|
|
320
|
+
});
|
|
321
|
+
await Promise.resolve();
|
|
322
|
+
await testSetup!.renderOnce();
|
|
323
|
+
});
|
|
324
|
+
await flushFrame();
|
|
325
|
+
}
|
|
326
|
+
|
|
308
327
|
function spanLineText(line: { spans: Array<{ text: string }> }): string {
|
|
309
328
|
return line.spans.map((span) => span.text).join("");
|
|
310
329
|
}
|
|
@@ -531,6 +550,130 @@ describe("TickerResearchPane", () => {
|
|
|
531
550
|
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("financials");
|
|
532
551
|
});
|
|
533
552
|
|
|
553
|
+
test("shows metric tabs for fundamental graph charts and defaults them to quarterly data", async () => {
|
|
554
|
+
setSharedRegistryForTests(makeRegistry());
|
|
555
|
+
setOptionsProvider(createProvider(false));
|
|
556
|
+
|
|
557
|
+
testSetup = await testRender(
|
|
558
|
+
<DetailHarness
|
|
559
|
+
config={createDetailConfig("AAPL")}
|
|
560
|
+
ticker={makeTicker("AAPL")}
|
|
561
|
+
financials={makeFinancials({
|
|
562
|
+
annualStatements: [{ date: "2024-12-31", totalRevenue: 1_000 }],
|
|
563
|
+
quarterlyStatements: [
|
|
564
|
+
{ date: "2025-03-31", totalRevenue: 200 },
|
|
565
|
+
{ date: "2025-06-30", totalRevenue: 250 },
|
|
566
|
+
],
|
|
567
|
+
})}
|
|
568
|
+
activeTabId="fundamental-graphs"
|
|
569
|
+
height={28}
|
|
570
|
+
/>,
|
|
571
|
+
{ width: 90, height: 28 },
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
await flushFrame();
|
|
575
|
+
const frame = testSetup.captureCharFrame();
|
|
576
|
+
expect(frame).toContain("Revenue");
|
|
577
|
+
expect(frame).toContain("Gross Profit");
|
|
578
|
+
expect(frame).not.toContain("Revenue (quarterly)");
|
|
579
|
+
expect(frame).not.toContain("[g]roup");
|
|
580
|
+
expect(frame).toContain("2025-03-31");
|
|
581
|
+
expect(frame).toContain("2025-06-30");
|
|
582
|
+
expect(frame.indexOf("2025-06-30")).toBeLessThan(frame.indexOf("2025-03-31"));
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("captures arrows for graph metric tabs between enter and escape", async () => {
|
|
586
|
+
setSharedRegistryForTests(makeRegistry());
|
|
587
|
+
setOptionsProvider(createProvider(false));
|
|
588
|
+
|
|
589
|
+
testSetup = await testRender(
|
|
590
|
+
<DetailHarness
|
|
591
|
+
config={createDetailConfig("AAPL")}
|
|
592
|
+
ticker={makeTicker("AAPL")}
|
|
593
|
+
financials={makeFinancials({
|
|
594
|
+
annualStatements: [{ date: "2024-12-31", totalRevenue: 1_000, grossProfit: 400 }],
|
|
595
|
+
quarterlyStatements: [
|
|
596
|
+
{ date: "2025-03-31", totalRevenue: 200, grossProfit: 80 },
|
|
597
|
+
{ date: "2025-06-30", totalRevenue: 250, grossProfit: 120 },
|
|
598
|
+
],
|
|
599
|
+
})}
|
|
600
|
+
activeTabId="fundamental-graphs"
|
|
601
|
+
height={28}
|
|
602
|
+
/>,
|
|
603
|
+
{ width: 90, height: 28 },
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
await flushFrame();
|
|
607
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("fundamental-graphs");
|
|
608
|
+
|
|
609
|
+
await act(async () => {
|
|
610
|
+
testSetup!.mockInput.pressEnter();
|
|
611
|
+
await testSetup!.renderOnce();
|
|
612
|
+
});
|
|
613
|
+
await flushFrame();
|
|
614
|
+
|
|
615
|
+
await act(async () => {
|
|
616
|
+
testSetup!.mockInput.pressArrow("right");
|
|
617
|
+
await testSetup!.renderOnce();
|
|
618
|
+
});
|
|
619
|
+
await flushFrame();
|
|
620
|
+
|
|
621
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.pluginState?.["ticker-detail"]?.detailMetric).toBe("grossProfit");
|
|
622
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("fundamental-graphs");
|
|
623
|
+
|
|
624
|
+
await emitKeypress({ name: "escape", sequence: "\u001b" });
|
|
625
|
+
expect(detailHarnessState?.inputCaptured).toBe(false);
|
|
626
|
+
|
|
627
|
+
await act(async () => {
|
|
628
|
+
testSetup!.mockInput.pressArrow("right");
|
|
629
|
+
await testSetup!.renderOnce();
|
|
630
|
+
});
|
|
631
|
+
await flushFrame();
|
|
632
|
+
|
|
633
|
+
await settleTickerTabCommit();
|
|
634
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("chart");
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("clicking graph content focuses metric tabs for arrow navigation", async () => {
|
|
638
|
+
setSharedRegistryForTests(makeRegistry());
|
|
639
|
+
setOptionsProvider(createProvider(false));
|
|
640
|
+
|
|
641
|
+
testSetup = await testRender(
|
|
642
|
+
<DetailHarness
|
|
643
|
+
config={createDetailConfig("AAPL")}
|
|
644
|
+
ticker={makeTicker("AAPL")}
|
|
645
|
+
financials={makeFinancials({
|
|
646
|
+
annualStatements: [{ date: "2024-12-31", totalRevenue: 1_000, grossProfit: 400 }],
|
|
647
|
+
quarterlyStatements: [
|
|
648
|
+
{ date: "2025-03-31", totalRevenue: 200, grossProfit: 80 },
|
|
649
|
+
{ date: "2025-06-30", totalRevenue: 250, grossProfit: 120 },
|
|
650
|
+
],
|
|
651
|
+
})}
|
|
652
|
+
activeTabId="fundamental-graphs"
|
|
653
|
+
height={28}
|
|
654
|
+
/>,
|
|
655
|
+
{ width: 90, height: 28 },
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
await flushFrame();
|
|
659
|
+
|
|
660
|
+
await act(async () => {
|
|
661
|
+
await testSetup!.mockMouse.click(24, 6);
|
|
662
|
+
await testSetup!.renderOnce();
|
|
663
|
+
});
|
|
664
|
+
await flushFrame();
|
|
665
|
+
expect(detailHarnessState?.inputCaptured).toBe(true);
|
|
666
|
+
|
|
667
|
+
await act(async () => {
|
|
668
|
+
testSetup!.mockInput.pressArrow("right");
|
|
669
|
+
await testSetup!.renderOnce();
|
|
670
|
+
});
|
|
671
|
+
await flushFrame();
|
|
672
|
+
|
|
673
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.pluginState?.["ticker-detail"]?.detailMetric).toBe("grossProfit");
|
|
674
|
+
expect(detailHarnessState?.paneState[TEST_PANE_ID]?.activeTabId).toBe("fundamental-graphs");
|
|
675
|
+
});
|
|
676
|
+
|
|
534
677
|
test("shows Trade when an IBKR gateway profile exists", async () => {
|
|
535
678
|
setSharedRegistryForTests(makeRegistry());
|
|
536
679
|
setOptionsProvider(createProvider(false));
|
|
@@ -12,6 +12,7 @@ import { layoutManagerPlugin } from "./builtin/layout-manager";
|
|
|
12
12
|
import { predictionMarketsPlugin } from "./prediction-markets";
|
|
13
13
|
import { analyticsPlugin } from "./builtin/analytics";
|
|
14
14
|
import { alertsPlugin } from "./builtin/alerts";
|
|
15
|
+
import { kellySizerPlugin } from "./builtin/kelly-sizer";
|
|
15
16
|
import {
|
|
16
17
|
brokerPlugin,
|
|
17
18
|
macroPlugin,
|
|
@@ -36,6 +37,7 @@ export const uiBuiltinPlugins: GloomPlugin[] = [
|
|
|
36
37
|
marketOverviewPlugin,
|
|
37
38
|
macroPlugin,
|
|
38
39
|
analyticsPlugin,
|
|
40
|
+
kellySizerPlugin,
|
|
39
41
|
alertsPlugin,
|
|
40
42
|
];
|
|
41
43
|
|
|
@@ -40,6 +40,25 @@ export const ibkrBroker: BrokerAdapter = {
|
|
|
40
40
|
return importFlexPositions(normalized.flex);
|
|
41
41
|
},
|
|
42
42
|
|
|
43
|
+
async importPortfolioSnapshot(instance) {
|
|
44
|
+
const normalized = normalizeIbkrConfig(instance.config);
|
|
45
|
+
if (normalized.connectionMode === "gateway") {
|
|
46
|
+
const service = ibkrGatewayManager.getService(instance.id);
|
|
47
|
+
await refreshGatewayData(instance);
|
|
48
|
+
const [accounts, positions] = await Promise.all([
|
|
49
|
+
service.getAccounts(normalized.gateway),
|
|
50
|
+
service.getPositions(normalized.gateway),
|
|
51
|
+
]);
|
|
52
|
+
return { accounts, positions };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const xml = await loadFlexStatement(normalized.flex);
|
|
56
|
+
return {
|
|
57
|
+
accounts: parseFlexAccounts(xml),
|
|
58
|
+
positions: parseFlexPositions(xml),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
|
|
43
62
|
async connect(instance) {
|
|
44
63
|
const normalized = normalizeIbkrConfig(instance.config);
|
|
45
64
|
if (normalized.connectionMode !== "gateway") return;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildIbkrConfigFromValues,
|
|
4
|
+
IBKR_STATEMENT_URL,
|
|
5
|
+
LEGACY_IBKR_STATEMENT_URL,
|
|
6
|
+
normalizeIbkrConfig,
|
|
7
|
+
} from "./config";
|
|
3
8
|
|
|
4
9
|
describe("ibkr config helpers", () => {
|
|
5
10
|
test("builds nested manual gateway config from flat wizard values", () => {
|
|
@@ -45,6 +50,30 @@ describe("ibkr config helpers", () => {
|
|
|
45
50
|
expect(config.gatewaySetupMode).toBe("auto");
|
|
46
51
|
expect(config.flex.token).toBe("abc");
|
|
47
52
|
expect(config.flex.queryId).toBe("123");
|
|
48
|
-
expect(config.flex.endpoint).
|
|
53
|
+
expect(config.flex.endpoint).toBe(IBKR_STATEMENT_URL);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("migrates the legacy default Flex endpoint to the current IBKR endpoint", () => {
|
|
57
|
+
const config = normalizeIbkrConfig({
|
|
58
|
+
flex: {
|
|
59
|
+
token: "abc",
|
|
60
|
+
queryId: "123",
|
|
61
|
+
endpoint: LEGACY_IBKR_STATEMENT_URL,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(config.flex.endpoint).toBe(IBKR_STATEMENT_URL);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("preserves custom non-default Flex endpoints", () => {
|
|
69
|
+
const config = normalizeIbkrConfig({
|
|
70
|
+
flex: {
|
|
71
|
+
token: "abc",
|
|
72
|
+
queryId: "123",
|
|
73
|
+
endpoint: "https://example.com/custom/SendRequest",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(config.flex.endpoint).toBe("https://example.com/custom/SendRequest");
|
|
49
78
|
});
|
|
50
79
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { BrokerConfigField } from "../../types/broker";
|
|
2
2
|
import type { IbkrGatewayConfig, ResolvedIbkrGatewayConnection } from "./gateway/service";
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const LEGACY_IBKR_STATEMENT_URL = "https://gdcdyn.interactivebrokers.com/Universal/servlet/FlexStatementService.SendRequest";
|
|
5
|
+
export const IBKR_STATEMENT_URL = "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/SendRequest";
|
|
5
6
|
|
|
6
7
|
type IbkrConnectionMode = "flex" | "gateway";
|
|
7
8
|
type IbkrGatewaySetupMode = "auto" | "manual";
|
|
@@ -119,6 +120,12 @@ function coerceString(value: unknown, fallback = ""): string {
|
|
|
119
120
|
return typeof value === "string" ? value : fallback;
|
|
120
121
|
}
|
|
121
122
|
|
|
123
|
+
function normalizeFlexEndpoint(value: unknown): string {
|
|
124
|
+
const endpoint = coerceString(value, DEFAULT_FLEX_CONFIG.endpoint).trim();
|
|
125
|
+
if (!endpoint || endpoint === LEGACY_IBKR_STATEMENT_URL) return IBKR_STATEMENT_URL;
|
|
126
|
+
return endpoint;
|
|
127
|
+
}
|
|
128
|
+
|
|
122
129
|
function getMode(value: unknown): IbkrConnectionMode {
|
|
123
130
|
return value === "gateway" ? "gateway" : "flex";
|
|
124
131
|
}
|
|
@@ -166,7 +173,7 @@ export function normalizeIbkrConfig(raw?: Record<string, unknown>): IbkrConfig {
|
|
|
166
173
|
flex: {
|
|
167
174
|
token: coerceString(nestedFlex.token ?? input.token),
|
|
168
175
|
queryId: coerceString(nestedFlex.queryId ?? input.queryId),
|
|
169
|
-
endpoint:
|
|
176
|
+
endpoint: normalizeFlexEndpoint(nestedFlex.endpoint ?? input.endpoint),
|
|
170
177
|
},
|
|
171
178
|
gateway: {
|
|
172
179
|
host,
|
|
@@ -59,6 +59,10 @@ describe("parseFlexAccounts", () => {
|
|
|
59
59
|
<FxPosition accountId="DU12345" assetCategory="CASH" functionalCurrency="USD" fxCurrency="USD" quantity="-303029.144938754" value="-303029.144938754" />
|
|
60
60
|
<FxPosition accountId="DU12345" assetCategory="CASH" functionalCurrency="USD" fxCurrency="EUR" quantity="-351957.025" value="-405102.535775" />
|
|
61
61
|
</FxPositions>
|
|
62
|
+
<OpenPositions>
|
|
63
|
+
<OpenPosition accountId="DU12345" symbol="AAPL" assetCategory="STK" position="100" currency="USD" positionValue="100000" fxRateToBase="1" />
|
|
64
|
+
<OpenPosition accountId="DU12345" symbol="ASML" assetCategory="STK" position="10" currency="EUR" positionValue="50000" fxRateToBase="1.2" />
|
|
65
|
+
</OpenPositions>
|
|
62
66
|
</FlexStatement>
|
|
63
67
|
</FlexStatements>
|
|
64
68
|
</FlexQueryResponse>
|
|
@@ -71,7 +75,9 @@ describe("parseFlexAccounts", () => {
|
|
|
71
75
|
name: "Main",
|
|
72
76
|
currency: "USD",
|
|
73
77
|
source: "flex",
|
|
78
|
+
asOfDate: "2026-03-27",
|
|
74
79
|
netLiquidation: 764713.626876249,
|
|
80
|
+
grossPositionValue: 160000,
|
|
75
81
|
totalCashValue: -1050953.720462251,
|
|
76
82
|
settledCash: -917604.448220862,
|
|
77
83
|
cashBalances: [
|
|
@@ -110,7 +116,9 @@ describe("parseFlexAccounts", () => {
|
|
|
110
116
|
currency: "USD",
|
|
111
117
|
source: "flex",
|
|
112
118
|
updatedAt: new Date(2026, 2, 27).getTime(),
|
|
119
|
+
asOfDate: "2026-03-27",
|
|
113
120
|
netLiquidation: 12345.67,
|
|
121
|
+
grossPositionValue: undefined,
|
|
114
122
|
totalCashValue: undefined,
|
|
115
123
|
settledCash: undefined,
|
|
116
124
|
cashBalances: undefined,
|
|
@@ -190,9 +198,9 @@ describe("parseFlexPortfolioPerformance", () => {
|
|
|
190
198
|
|
|
191
199
|
describe("requestFlexStatement", () => {
|
|
192
200
|
test("uses the configured HTTP transport for statement requests", async () => {
|
|
193
|
-
const requests: string
|
|
194
|
-
setHttpFetchTransport(async (url) => {
|
|
195
|
-
requests.push(url);
|
|
201
|
+
const requests: Array<{ url: string; init: RequestInit | undefined }> = [];
|
|
202
|
+
setHttpFetchTransport(async (url, init) => {
|
|
203
|
+
requests.push({ url, init });
|
|
196
204
|
return new Response(
|
|
197
205
|
"<FlexStatementResponse><ReferenceCode>987654</ReferenceCode></FlexStatementResponse>",
|
|
198
206
|
{ status: 200 },
|
|
@@ -202,16 +210,17 @@ describe("requestFlexStatement", () => {
|
|
|
202
210
|
await expect(requestFlexStatement({
|
|
203
211
|
token: "secret-flex-token",
|
|
204
212
|
queryId: "12345",
|
|
205
|
-
endpoint: "https://
|
|
213
|
+
endpoint: "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/SendRequest",
|
|
206
214
|
})).resolves.toBe("987654");
|
|
207
215
|
|
|
208
216
|
expect(requests).toHaveLength(1);
|
|
209
|
-
expect(requests[0]).toContain("q=12345");
|
|
217
|
+
expect(requests[0]?.url).toContain("q=12345");
|
|
218
|
+
expect((requests[0]?.init?.headers as Record<string, string> | undefined)?.["User-Agent"]).toBeTruthy();
|
|
210
219
|
});
|
|
211
220
|
|
|
212
221
|
test("adds request context to vague IBKR Flex errors without exposing the token", async () => {
|
|
213
222
|
globalThis.fetch = (async () => new Response(
|
|
214
|
-
"<FlexStatementResponse><ErrorMessage>Load failed</ErrorMessage></FlexStatementResponse>",
|
|
223
|
+
"<FlexStatementResponse><ErrorCode>1001</ErrorCode><ErrorMessage>Load failed</ErrorMessage></FlexStatementResponse>",
|
|
215
224
|
{ status: 200 },
|
|
216
225
|
)) as unknown as typeof fetch;
|
|
217
226
|
|
|
@@ -220,14 +229,14 @@ describe("requestFlexStatement", () => {
|
|
|
220
229
|
await requestFlexStatement({
|
|
221
230
|
token: "secret-flex-token",
|
|
222
231
|
queryId: "12345",
|
|
223
|
-
endpoint: "https://
|
|
232
|
+
endpoint: "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/SendRequest",
|
|
224
233
|
});
|
|
225
234
|
} catch (error) {
|
|
226
235
|
message = error instanceof Error ? error.message : String(error);
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
expect(message).toContain("IBKR Flex request failed while requesting the statement: Load failed.");
|
|
230
|
-
expect(message).toContain("Endpoint
|
|
238
|
+
expect(message).toContain("IBKR Flex request failed while requesting the statement: IBKR error 1001: Load failed.");
|
|
239
|
+
expect(message).toContain("Endpoint SendRequest");
|
|
231
240
|
expect(message).toContain("query ID 12345");
|
|
232
241
|
expect(message).toContain("token configured");
|
|
233
242
|
expect(message).toContain("Flex Web Service is enabled");
|
|
@@ -63,6 +63,34 @@ function parseFlexDate(raw: string | undefined): string | null {
|
|
|
63
63
|
return new Date(parsed).toISOString().slice(0, 10);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function parseFlexGrossPositionValue(
|
|
67
|
+
body: string,
|
|
68
|
+
statementAccountId: string,
|
|
69
|
+
baseCurrency: string | undefined,
|
|
70
|
+
): number | undefined {
|
|
71
|
+
let total = 0;
|
|
72
|
+
let hasValue = false;
|
|
73
|
+
|
|
74
|
+
for (const entry of body.matchAll(/<OpenPosition\b([^>]*)\/?>/g)) {
|
|
75
|
+
const attributes = parseAttributes(entry[1] ?? "");
|
|
76
|
+
const accountId = attributes.accountId || statementAccountId;
|
|
77
|
+
if (!accountId || (statementAccountId && accountId !== statementAccountId)) continue;
|
|
78
|
+
|
|
79
|
+
const marketValue = parseNumber(attributes.positionValue);
|
|
80
|
+
if (marketValue == null) continue;
|
|
81
|
+
|
|
82
|
+
const positionCurrency = attributes.currency || baseCurrency;
|
|
83
|
+
const fxRateToBase = parseNumber(attributes.fxRateToBase)
|
|
84
|
+
?? (baseCurrency && positionCurrency === baseCurrency ? 1 : undefined);
|
|
85
|
+
if (fxRateToBase == null) continue;
|
|
86
|
+
|
|
87
|
+
total += Math.abs(marketValue * fxRateToBase);
|
|
88
|
+
hasValue = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return hasValue ? total : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
66
94
|
function buildContractRef(attributes: Record<string, string>, symbol: string, assetCategory: string): BrokerContractRef | undefined {
|
|
67
95
|
const conIdRaw = attributes.conid || attributes.conId;
|
|
68
96
|
const strikeRaw = attributes.strike;
|
|
@@ -211,7 +239,9 @@ export function parseFlexAccounts(xml: string): BrokerAccount[] {
|
|
|
211
239
|
currency: accountCurrency,
|
|
212
240
|
source: "flex",
|
|
213
241
|
updatedAt: parseFlexTimestamp(statementAttrs.whenGenerated, statementAttrs.toDate || statementAttrs.fromDate),
|
|
242
|
+
asOfDate: parseFlexDate(statementAttrs.toDate || statementAttrs.fromDate) ?? undefined,
|
|
214
243
|
netLiquidation: parseNumber(changeInNavAttrs?.endingValue),
|
|
244
|
+
grossPositionValue: parseFlexGrossPositionValue(body, accountId, accountCurrency),
|
|
215
245
|
totalCashValue: parseNumber(baseCashAttrs?.endingCash),
|
|
216
246
|
settledCash: parseNumber(baseCashAttrs?.endingSettledCash),
|
|
217
247
|
cashBalances: cashBalances.length > 0 ? cashBalances : undefined,
|
|
@@ -2,7 +2,8 @@ import { httpFetch } from "../../../utils/http-transport";
|
|
|
2
2
|
import type { FlexQueryConfig } from "../config";
|
|
3
3
|
import { IBKR_STATEMENT_URL } from "../config";
|
|
4
4
|
|
|
5
|
-
const IBKR_STATEMENT_GET_URL = "https://
|
|
5
|
+
const IBKR_STATEMENT_GET_URL = "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement";
|
|
6
|
+
const FLEX_USER_AGENT = "Gloomberb/1.0 Flex";
|
|
6
7
|
const FLEX_STATEMENT_CACHE_MS = 15 * 60_000;
|
|
7
8
|
const FLEX_STATEMENT_INITIAL_WAIT_MS = 3_000;
|
|
8
9
|
const FLEX_STATEMENT_POLL_DELAY_MS = 5_000;
|
|
@@ -29,10 +30,54 @@ function decodeXmlText(value: string): string {
|
|
|
29
30
|
.replace(/'/g, "'");
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function extractXmlTag(text: string, tagName: string): string | null {
|
|
34
|
+
const match = text.match(new RegExp(`<${tagName}>\\s*([^<]*?)\\s*<\\/${tagName}>`, "i"));
|
|
35
|
+
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
function extractFlexErrorMessage(text: string): string | null {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
return
|
|
39
|
+
const message = extractXmlTag(text, "ErrorMessage");
|
|
40
|
+
const code = extractXmlTag(text, "ErrorCode");
|
|
41
|
+
if (message && code) return `IBKR error ${code}: ${message}`;
|
|
42
|
+
return message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildFlexUrl(endpoint: string, params: Record<string, string>): string {
|
|
46
|
+
const url = new URL(endpoint);
|
|
47
|
+
for (const [key, value] of Object.entries(params)) {
|
|
48
|
+
url.searchParams.set(key, value);
|
|
49
|
+
}
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveFlexGetEndpoint(sendEndpoint: string | undefined): string {
|
|
54
|
+
if (!sendEndpoint || sendEndpoint === IBKR_STATEMENT_URL) return IBKR_STATEMENT_GET_URL;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(sendEndpoint);
|
|
58
|
+
url.search = "";
|
|
59
|
+
if (url.pathname.endsWith("/SendRequest")) {
|
|
60
|
+
url.pathname = url.pathname.replace(/\/SendRequest$/, "/GetStatement");
|
|
61
|
+
return url.toString();
|
|
62
|
+
}
|
|
63
|
+
if (url.pathname.endsWith("FlexStatementService.SendRequest")) {
|
|
64
|
+
url.pathname = url.pathname.replace(/FlexStatementService\.SendRequest$/, "FlexStatementService.GetStatement");
|
|
65
|
+
return url.toString();
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return IBKR_STATEMENT_GET_URL;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return IBKR_STATEMENT_GET_URL;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function flexRequestInit(): RequestInit {
|
|
75
|
+
return {
|
|
76
|
+
headers: {
|
|
77
|
+
"User-Agent": FLEX_USER_AGENT,
|
|
78
|
+
},
|
|
79
|
+
signal: AbortSignal.timeout(30_000),
|
|
80
|
+
};
|
|
36
81
|
}
|
|
37
82
|
|
|
38
83
|
function endpointName(endpoint: string | undefined): string {
|
|
@@ -69,11 +114,15 @@ function flexError(providerMessage: string, context: FlexErrorContext): Error {
|
|
|
69
114
|
|
|
70
115
|
export async function requestFlexStatement(config: FlexQueryConfig): Promise<string> {
|
|
71
116
|
const endpoint = config.endpoint || IBKR_STATEMENT_URL;
|
|
72
|
-
const url =
|
|
117
|
+
const url = buildFlexUrl(endpoint, {
|
|
118
|
+
t: config.token,
|
|
119
|
+
q: config.queryId,
|
|
120
|
+
v: "3",
|
|
121
|
+
});
|
|
73
122
|
let resp: Response;
|
|
74
123
|
let text: string;
|
|
75
124
|
try {
|
|
76
|
-
resp = await httpFetch(url,
|
|
125
|
+
resp = await httpFetch(url, flexRequestInit());
|
|
77
126
|
text = await resp.text();
|
|
78
127
|
} catch (error) {
|
|
79
128
|
throw flexError(error instanceof Error ? error.message : "Network request failed", {
|
|
@@ -84,8 +133,8 @@ export async function requestFlexStatement(config: FlexQueryConfig): Promise<str
|
|
|
84
133
|
});
|
|
85
134
|
}
|
|
86
135
|
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
136
|
+
const referenceCode = extractXmlTag(text, "ReferenceCode");
|
|
137
|
+
if (!referenceCode) {
|
|
89
138
|
throw flexError(extractFlexErrorMessage(text) || "No reference code returned", {
|
|
90
139
|
phase: "request",
|
|
91
140
|
endpoint,
|
|
@@ -95,7 +144,7 @@ export async function requestFlexStatement(config: FlexQueryConfig): Promise<str
|
|
|
95
144
|
});
|
|
96
145
|
}
|
|
97
146
|
|
|
98
|
-
return
|
|
147
|
+
return referenceCode;
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
async function getFlexStatement(
|
|
@@ -105,17 +154,22 @@ async function getFlexStatement(
|
|
|
105
154
|
): Promise<string> {
|
|
106
155
|
await new Promise((resolve) => setTimeout(resolve, FLEX_STATEMENT_INITIAL_WAIT_MS));
|
|
107
156
|
|
|
108
|
-
const
|
|
157
|
+
const endpoint = resolveFlexGetEndpoint(context.endpoint);
|
|
158
|
+
const url = buildFlexUrl(endpoint, {
|
|
159
|
+
t: token,
|
|
160
|
+
q: referenceCode,
|
|
161
|
+
v: "3",
|
|
162
|
+
});
|
|
109
163
|
for (let attempt = 0; attempt < FLEX_STATEMENT_MAX_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
|
110
164
|
let resp: Response;
|
|
111
165
|
let text: string;
|
|
112
166
|
try {
|
|
113
|
-
resp = await httpFetch(url,
|
|
167
|
+
resp = await httpFetch(url, flexRequestInit());
|
|
114
168
|
text = await resp.text();
|
|
115
169
|
} catch (error) {
|
|
116
170
|
throw flexError(error instanceof Error ? error.message : "Network request failed", {
|
|
117
171
|
phase: "download",
|
|
118
|
-
endpoint
|
|
172
|
+
endpoint,
|
|
119
173
|
queryId: context.queryId,
|
|
120
174
|
token,
|
|
121
175
|
referenceCode,
|
|
@@ -135,7 +189,7 @@ async function getFlexStatement(
|
|
|
135
189
|
if (providerMessage) {
|
|
136
190
|
throw flexError(providerMessage, {
|
|
137
191
|
phase: "download",
|
|
138
|
-
endpoint
|
|
192
|
+
endpoint,
|
|
139
193
|
queryId: context.queryId,
|
|
140
194
|
token,
|
|
141
195
|
referenceCode,
|
|
@@ -146,7 +200,7 @@ async function getFlexStatement(
|
|
|
146
200
|
|
|
147
201
|
throw flexError("statement generation timed out", {
|
|
148
202
|
phase: "download",
|
|
149
|
-
endpoint
|
|
203
|
+
endpoint,
|
|
150
204
|
queryId: context.queryId,
|
|
151
205
|
token,
|
|
152
206
|
referenceCode,
|
|
@@ -96,7 +96,7 @@ export async function loadIbkrAccounts({
|
|
|
96
96
|
const result = await firstValueFrom(
|
|
97
97
|
api.getAccountSummary(
|
|
98
98
|
"All",
|
|
99
|
-
"NetLiquidation,TotalCashValue,SettledCash,AvailableFunds,BuyingPower,ExcessLiquidity,InitMarginReq,MaintMarginReq,$LEDGER:ALL",
|
|
99
|
+
"NetLiquidation,GrossPositionValue,TotalCashValue,SettledCash,AvailableFunds,BuyingPower,ExcessLiquidity,InitMarginReq,MaintMarginReq,$LEDGER:ALL",
|
|
100
100
|
)
|
|
101
101
|
.pipe(take(1), timeout(10_000)),
|
|
102
102
|
);
|