gloomberb 0.9.3 → 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 +1 -0
- 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 +10 -1
- 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/cli/desktop-pane-shot.ts +2 -1
- package/src/cli/pane-functions/screenshot.ts +13 -1
- 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/core/pointer.ts +32 -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/command-bar/layout-items/current-layout.ts +5 -1
- 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/cursor-occlusion.ts +196 -0
- package/src/components/layout/shell/drag/runtime.ts +9 -2
- package/src/components/layout/shell/index.test.tsx +92 -2
- package/src/components/layout/shell/index.tsx +37 -2
- 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/pane/layers.tsx +8 -2
- package/src/components/layout/shell/visible-layout.ts +17 -0
- package/src/components/layout/status-bar.test.tsx +6 -1
- package/src/components/layout/status-bar.tsx +5 -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/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/state/app/layout-reducer.ts +2 -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/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/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/index.test.ts +23 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
- 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/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/gridlock.ts +13 -5
- package/src/plugins/pane-manager/layout-state.ts +25 -0
- 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/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 +43 -0
- package/src/plugins/prediction-markets/pane.tsx +50 -22
- package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
- 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/input.tsx +115 -18
- package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
- package/src/renderers/electrobun/view/styles.css +11 -0
- package/src/renderers/opentui/host.tsx +17 -1
- 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/types/broker.ts +1 -0
- package/src/types/config.ts +10 -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/financial-statements.ts +23 -0
- package/src/version.ts +1 -1
- package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
import { useCallback, useMemo } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { Box, TextAttributes } from "../../../../../ui";
|
|
3
3
|
import {
|
|
4
4
|
DataTableView,
|
|
5
|
+
Tabs,
|
|
5
6
|
usePaneFooter,
|
|
6
7
|
type DataTableCell,
|
|
7
8
|
type DataTableKeyEvent,
|
|
8
9
|
} from "../../../../../components";
|
|
10
|
+
import { useShortcut } from "../../../../../react/input";
|
|
11
|
+
import type { BarChartHover } from "../../../../../components/chart/bar-chart-renderer";
|
|
9
12
|
import { StaticBarChartSurface } from "../../../../../components/chart/static/bar-chart-surface";
|
|
10
13
|
import { colors, priceColor } from "../../../../../theme/colors";
|
|
11
14
|
import { usePluginPaneState } from "../../../../runtime";
|
|
12
15
|
import { loadingErrorFooterInfo, refreshFooterHint, useClampSelectedIndex } from "../../../shared/table-pane";
|
|
13
16
|
import {
|
|
17
|
+
allMetricDefs,
|
|
14
18
|
buildFundamentalColumns,
|
|
15
19
|
buildGraphBarSeries,
|
|
16
|
-
defaultMetric,
|
|
17
20
|
formatMaybePercent,
|
|
18
|
-
isMetricForKind,
|
|
19
21
|
metricDef,
|
|
20
|
-
|
|
22
|
+
metricKind,
|
|
21
23
|
} from "./model";
|
|
22
24
|
import type {
|
|
23
25
|
FundamentalColumn,
|
|
@@ -41,6 +43,7 @@ export function FundamentalGraphContent({
|
|
|
41
43
|
setMetric,
|
|
42
44
|
chartKind,
|
|
43
45
|
setChartKind,
|
|
46
|
+
onCapture,
|
|
44
47
|
}: {
|
|
45
48
|
focused: boolean;
|
|
46
49
|
width: number;
|
|
@@ -55,39 +58,106 @@ export function FundamentalGraphContent({
|
|
|
55
58
|
setMetric: (updater: (current: GraphMetricKey) => GraphMetricKey) => void;
|
|
56
59
|
chartKind: GraphKind;
|
|
57
60
|
setChartKind: (updater: (current: GraphKind) => GraphKind) => void;
|
|
61
|
+
onCapture?: (capturing: boolean) => void;
|
|
58
62
|
}) {
|
|
59
63
|
const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>("selectedIdx", 0);
|
|
64
|
+
const [hoveredBar, setHoveredBar] = useState<BarChartHover | null>(null);
|
|
65
|
+
const [metricTabsFocused, setMetricTabsFocused] = useState(false);
|
|
60
66
|
const definition = metricDef(chartKind, metric);
|
|
67
|
+
const metricOptions = useMemo(() => allMetricDefs(), []);
|
|
68
|
+
const metricTabs = useMemo(() => metricOptions.map(({ definition }) => ({
|
|
69
|
+
label: definition.label,
|
|
70
|
+
value: definition.key,
|
|
71
|
+
})), [metricOptions]);
|
|
61
72
|
const multiSymbol = new Set(rows.map((row) => row.symbol)).size > 1;
|
|
62
73
|
const columns = useMemo(() => buildFundamentalColumns(width, multiSymbol), [multiSymbol, width]);
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
const tableRows = useMemo(() => [...rows].sort((left, right) => (
|
|
75
|
+
right.date.localeCompare(left.date) || left.symbol.localeCompare(right.symbol)
|
|
76
|
+
)), [rows]);
|
|
77
|
+
const boundedSelectedIdx = tableRows.length > 0 ? Math.min(selectedIdx, tableRows.length - 1) : -1;
|
|
78
|
+
const minTableHeight = tableRows.length > 0 ? 4 : 3;
|
|
79
|
+
const desiredChartHeight = height >= 14 ? Math.min(16, Math.max(9, Math.floor(height * 0.58))) : Math.max(4, Math.floor(height / 2));
|
|
80
|
+
const chartHeight = Math.max(1, Math.min(desiredChartHeight, Math.max(1, height - minTableHeight)));
|
|
81
|
+
const tableHeight = Math.max(0, height - chartHeight);
|
|
66
82
|
const chartSeries = useMemo(() => buildGraphBarSeries(rows), [rows]);
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
const selectMetric = useCallback((value: string) => {
|
|
84
|
+
const nextMetric = value as GraphMetricKey;
|
|
85
|
+
const nextKind = metricKind(nextMetric);
|
|
86
|
+
if (!nextKind) return;
|
|
87
|
+
setHoveredBar(null);
|
|
71
88
|
setChartKind(() => nextKind);
|
|
72
|
-
setMetric((
|
|
73
|
-
}, [
|
|
89
|
+
setMetric(() => nextMetric);
|
|
90
|
+
}, [setChartKind, setMetric]);
|
|
91
|
+
const focusMetricTabs = useCallback(() => {
|
|
92
|
+
if (metricTabsFocused) return;
|
|
93
|
+
setMetricTabsFocused(true);
|
|
94
|
+
onCapture?.(true);
|
|
95
|
+
}, [metricTabsFocused, onCapture]);
|
|
96
|
+
const releaseMetricTabs = useCallback(() => {
|
|
97
|
+
if (!metricTabsFocused) return;
|
|
98
|
+
setMetricTabsFocused(false);
|
|
99
|
+
onCapture?.(false);
|
|
100
|
+
}, [metricTabsFocused, onCapture]);
|
|
101
|
+
const selectMetricFromTabs = useCallback((value: string) => {
|
|
102
|
+
focusMetricTabs();
|
|
103
|
+
selectMetric(value);
|
|
104
|
+
}, [focusMetricTabs, selectMetric]);
|
|
105
|
+
const togglePeriod = useCallback(() => setPeriod((current) => current === "annual" ? "quarterly" : "annual"), [setPeriod]);
|
|
106
|
+
const hoveredValue = useMemo(() => {
|
|
107
|
+
if (!hoveredBar) return null;
|
|
108
|
+
const label = chartSeries.length > 1 ? `${hoveredBar.category} ${hoveredBar.seriesLabel}` : hoveredBar.category;
|
|
109
|
+
return `${label} ${definition.format(hoveredBar.value)}`;
|
|
110
|
+
}, [chartSeries.length, definition, hoveredBar]);
|
|
111
|
+
|
|
112
|
+
useClampSelectedIndex(tableRows.length, selectedIdx, setSelectedIdx);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!focused && metricTabsFocused) {
|
|
116
|
+
setMetricTabsFocused(false);
|
|
117
|
+
onCapture?.(false);
|
|
118
|
+
}
|
|
119
|
+
}, [focused, metricTabsFocused, onCapture]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => () => {
|
|
122
|
+
onCapture?.(false);
|
|
123
|
+
}, [onCapture]);
|
|
74
124
|
|
|
75
|
-
|
|
125
|
+
useShortcut((event) => {
|
|
126
|
+
if (event.defaultPrevented || event.propagationStopped || event.targetEditable) return;
|
|
127
|
+
if (event.ctrl || event.meta || event.alt || event.shift) return;
|
|
128
|
+
|
|
129
|
+
const isEnter = event.name === "enter" || event.name === "return";
|
|
130
|
+
const isEscape = event.name === "escape" || event.name === "esc";
|
|
131
|
+
if (isEnter) {
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
focusMetricTabs();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (isEscape && metricTabsFocused) {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
releaseMetricTabs();
|
|
141
|
+
}
|
|
142
|
+
}, { enabled: focused, phase: "before" });
|
|
76
143
|
|
|
77
144
|
const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
78
|
-
|
|
145
|
+
const isEnter = event.name === "enter" || event.name === "return";
|
|
146
|
+
if (isEnter) {
|
|
79
147
|
event.preventDefault?.();
|
|
80
|
-
|
|
148
|
+
event.stopPropagation?.();
|
|
149
|
+
focusMetricTabs();
|
|
81
150
|
return true;
|
|
82
151
|
}
|
|
83
|
-
if (event.name === "
|
|
152
|
+
if ((event.name === "escape" || event.name === "esc") && metricTabsFocused) {
|
|
84
153
|
event.preventDefault?.();
|
|
85
|
-
|
|
154
|
+
event.stopPropagation?.();
|
|
155
|
+
releaseMetricTabs();
|
|
86
156
|
return true;
|
|
87
157
|
}
|
|
88
|
-
if (event.name === "
|
|
158
|
+
if (event.name === "r") {
|
|
89
159
|
event.preventDefault?.();
|
|
90
|
-
|
|
160
|
+
reload();
|
|
91
161
|
return true;
|
|
92
162
|
}
|
|
93
163
|
if (event.name === "p") {
|
|
@@ -96,7 +166,7 @@ export function FundamentalGraphContent({
|
|
|
96
166
|
return true;
|
|
97
167
|
}
|
|
98
168
|
return false;
|
|
99
|
-
}, [
|
|
169
|
+
}, [focusMetricTabs, metricTabsFocused, releaseMetricTabs, reload, togglePeriod]);
|
|
100
170
|
|
|
101
171
|
const renderCell = useCallback((
|
|
102
172
|
row: FundamentalGraphRow,
|
|
@@ -111,7 +181,11 @@ export function FundamentalGraphContent({
|
|
|
111
181
|
case "date":
|
|
112
182
|
return { text: row.date, color: selectedColor ?? colors.textDim };
|
|
113
183
|
case "value":
|
|
114
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
text: definition.format(row.value),
|
|
186
|
+
color: row.value < 0 ? colors.negative : selectedColor ?? colors.textBright,
|
|
187
|
+
attributes: TextAttributes.BOLD,
|
|
188
|
+
};
|
|
115
189
|
case "growth":
|
|
116
190
|
return { text: formatMaybePercent(row.growth), color: selectedColor ?? priceColor(row.growth ?? 0) };
|
|
117
191
|
}
|
|
@@ -119,41 +193,68 @@ export function FundamentalGraphContent({
|
|
|
119
193
|
|
|
120
194
|
usePaneFooter("fundamental-graph", () => ({
|
|
121
195
|
info: [
|
|
122
|
-
{ id: "
|
|
123
|
-
{ id: "
|
|
124
|
-
{ id: "period", parts: [{ text: period, tone: "muted" as const }] },
|
|
196
|
+
{ id: "period", parts: [{ text: "period", tone: "label" as const }, { text: period, tone: "value" as const }] },
|
|
197
|
+
...(hoveredValue ? [{ id: "hover", parts: [{ text: hoveredValue, tone: "value" as const }] }] : []),
|
|
125
198
|
...loadingErrorFooterInfo(loading, error),
|
|
126
199
|
],
|
|
127
200
|
hints: [
|
|
128
|
-
{ id: "metric", key: "m", label: "etric", onPress: cycleMetric },
|
|
129
|
-
{ id: "kind", key: "g", label: "raph", onPress: toggleGraphKind },
|
|
130
201
|
{ id: "period", key: "p", label: "eriod", onPress: togglePeriod },
|
|
131
202
|
refreshFooterHint(reload),
|
|
132
203
|
],
|
|
133
|
-
}), [
|
|
204
|
+
}), [error, hoveredValue, loading, period, reload, togglePeriod]);
|
|
205
|
+
|
|
206
|
+
const metricHeader = (
|
|
207
|
+
<Tabs
|
|
208
|
+
tabs={metricTabs}
|
|
209
|
+
activeValue={metric}
|
|
210
|
+
onSelect={selectMetricFromTabs}
|
|
211
|
+
compact
|
|
212
|
+
focused={focused && metricTabsFocused}
|
|
213
|
+
keyboardNavigation={metricTabsFocused}
|
|
214
|
+
scrollable
|
|
215
|
+
variant="underline"
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
134
218
|
|
|
135
219
|
return (
|
|
136
|
-
<Box
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
sortDirection="asc"
|
|
152
|
-
onHeaderClick={() => {}}
|
|
153
|
-
getItemKey={(row) => row.key}
|
|
154
|
-
renderCell={renderCell}
|
|
155
|
-
emptyStateTitle={loading ? "Loading fundamentals..." : "No graph data"}
|
|
220
|
+
<Box
|
|
221
|
+
flexDirection="column"
|
|
222
|
+
width={width}
|
|
223
|
+
height={height}
|
|
224
|
+
overflow="hidden"
|
|
225
|
+
onMouseDown={focusMetricTabs}
|
|
226
|
+
>
|
|
227
|
+
<StaticBarChartSurface
|
|
228
|
+
width={width}
|
|
229
|
+
height={chartHeight}
|
|
230
|
+
series={chartSeries}
|
|
231
|
+
header={metricHeader}
|
|
232
|
+
formatValue={definition.format}
|
|
233
|
+
onHoverChange={setHoveredBar}
|
|
234
|
+
onMouseDown={focusMetricTabs}
|
|
156
235
|
/>
|
|
236
|
+
{tableHeight > 0 ? (
|
|
237
|
+
<DataTableView<FundamentalGraphRow, FundamentalColumn>
|
|
238
|
+
focused={focused}
|
|
239
|
+
selection={{
|
|
240
|
+
kind: "index",
|
|
241
|
+
selectedIndex: boundedSelectedIdx,
|
|
242
|
+
onChange: (index) => setSelectedIdx(index),
|
|
243
|
+
}}
|
|
244
|
+
onRootKeyDown={handleKeyDown}
|
|
245
|
+
rootWidth={width}
|
|
246
|
+
rootHeight={tableHeight}
|
|
247
|
+
columns={columns}
|
|
248
|
+
items={tableRows}
|
|
249
|
+
sortColumnId="date"
|
|
250
|
+
sortDirection="desc"
|
|
251
|
+
onHeaderClick={() => {}}
|
|
252
|
+
onTableMouseDown={focusMetricTabs}
|
|
253
|
+
getItemKey={(row) => row.key}
|
|
254
|
+
renderCell={renderCell}
|
|
255
|
+
emptyStateTitle={loading ? "Loading fundamentals..." : "No graph data"}
|
|
256
|
+
/>
|
|
257
|
+
) : null}
|
|
157
258
|
</Box>
|
|
158
259
|
);
|
|
159
260
|
}
|
|
@@ -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,
|