gloomberb 0.9.3 → 0.9.5
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 +3 -2
- package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
- package/patches/react-reconciler@0.33.0.patch +10 -0
- 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/entry.ts +8 -1
- package/src/cli/native-smoke.ts +5 -0
- 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/native/bitmap-support.test.ts +27 -0
- package/src/components/chart/native/bitmap-support.ts +55 -0
- package/src/components/chart/native/kitty/index.test.ts +47 -0
- package/src/components/chart/native/surface/visibility.ts +8 -1
- 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/bitmap.ts +16 -9
- 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/command-bar/list/view.tsx +1 -1
- package/src/components/command-bar/theme-picker.tsx +1 -1
- package/src/components/data-table/view.tsx +0 -12
- package/src/components/layout/detached-pane-shell.tsx +6 -3
- package/src/components/layout/floating-pane.tsx +6 -5
- 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 +45 -4
- package/src/components/layout/pane/index.tsx +6 -4
- package/src/components/layout/pane/sizing.test.ts +43 -0
- package/src/components/layout/pane/sizing.ts +13 -4
- package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
- package/src/components/layout/shell/cursor-occlusion.ts +204 -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 +45 -3
- 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 +10 -4
- 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 +10 -6
- 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/pane-settings-dialog/desktop.tsx +1 -1
- package/src/components/price-performance.tsx +1 -1
- package/src/components/price-sparkline/view.tsx +15 -14
- package/src/components/speedometer-gauge/terminal.tsx +18 -15
- package/src/components/table-view-shared.tsx +12 -14
- package/src/components/ticker/list-table-view.tsx +1 -7
- package/src/components/ticker/list-table.test.tsx +0 -4
- package/src/components/ui/data-table/opentui/index.tsx +29 -22
- package/src/components/ui/data-table/types.ts +10 -4
- package/src/components/ui/fields.tsx +10 -3
- package/src/components/ui/list-view.tsx +1 -1
- 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 +27 -12
- package/src/components/ui/ui.test.tsx +87 -9
- package/src/core/state/app/layout-reducer.ts +2 -2
- package/src/plugins/builtin/account-management/form-components.tsx +2 -2
- package/src/plugins/builtin/alerts/pane.tsx +1 -1
- package/src/plugins/builtin/analytics/index.test.tsx +6 -2
- package/src/plugins/builtin/analytics/pane-model.ts +43 -8
- package/src/plugins/builtin/buildout/cells.tsx +1 -1
- package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
- package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
- package/src/plugins/builtin/chat/sidebar.tsx +1 -1
- package/src/plugins/builtin/chat/status-widget.tsx +1 -1
- package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
- package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
- package/src/plugins/builtin/debug/index.tsx +3 -4
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/holders/treemap.tsx +1 -1
- 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/table.ts +5 -5
- 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/ibkr/trade/ticket-panel.tsx +1 -1
- 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 -12
- package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
- package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
- package/src/renderers/electrobun/view/host/box.tsx +6 -1
- package/src/renderers/electrobun/view/host/input.tsx +115 -18
- package/src/renderers/electrobun/view/host/style.ts +1 -0
- package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
- package/src/renderers/electrobun/view/styles.css +18 -0
- package/src/renderers/opentui/host.tsx +17 -1
- package/src/renderers/opentui/native-smoke.ts +1 -0
- 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
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui";
|
|
3
|
+
import type { KeyEventLike } from "../../../react/input";
|
|
4
|
+
import { colors } from "../../../theme/colors";
|
|
5
|
+
import { NumberField } from "../../../components/ui";
|
|
6
|
+
import { formatCurrency, formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
7
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
8
|
+
import type { SensitivityGrid } from "./model";
|
|
9
|
+
import type { InlineField } from "./fields";
|
|
10
|
+
export type { InlineField } from "./fields";
|
|
11
|
+
|
|
12
|
+
export function truncateText(value: string, maxWidth: number): string {
|
|
13
|
+
if (maxWidth <= 0) return "";
|
|
14
|
+
if (value.length <= maxWidth) return value;
|
|
15
|
+
if (maxWidth <= 1) return value.slice(0, maxWidth);
|
|
16
|
+
return `${value.slice(0, maxWidth - 1)}…`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatPct(value: number, decimals = 1): string {
|
|
20
|
+
return `${formatNumber(value * 100, decimals)}%`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatSignedPct(value: number): string {
|
|
24
|
+
return formatPercentRaw(value * 100);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPlainShortcut(event: KeyEventLike, ...names: string[]): boolean {
|
|
28
|
+
if (isPlainKey(event, ...names)) return true;
|
|
29
|
+
const key = event.key?.toLowerCase();
|
|
30
|
+
return !event.ctrl && !event.meta && !event.super && !event.alt && names.includes(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parsePromptNumber(value: string): number | null {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatInputNumber(value: number, percent = false): string {
|
|
39
|
+
const scaled = percent ? value * 100 : value;
|
|
40
|
+
const decimals = percent
|
|
41
|
+
? Math.abs(scaled) >= 10 ? 1 : 2
|
|
42
|
+
: Math.abs(scaled) >= 100 ? 0 : 2;
|
|
43
|
+
return formatNumber(scaled, decimals).replace(/,/g, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function InlineFieldView({
|
|
47
|
+
field,
|
|
48
|
+
active,
|
|
49
|
+
width,
|
|
50
|
+
focused,
|
|
51
|
+
onFocus,
|
|
52
|
+
}: {
|
|
53
|
+
field: InlineField;
|
|
54
|
+
active: boolean;
|
|
55
|
+
width: number;
|
|
56
|
+
focused: boolean;
|
|
57
|
+
onFocus: () => void;
|
|
58
|
+
}) {
|
|
59
|
+
const labelWidth = Math.min(12, Math.max(7, Math.floor(width * 0.38)));
|
|
60
|
+
const suffixWidth = field.suffix ? field.suffix.length + 1 : field.percent ? 2 : 0;
|
|
61
|
+
const valueWidth = Math.max(5, width - labelWidth - suffixWidth - 2);
|
|
62
|
+
const inputNodeRef = useRef<InputRenderable | null>(null);
|
|
63
|
+
const displayValue = field.valueText ?? formatInputNumber(field.value ?? 0, field.percent);
|
|
64
|
+
const fieldRef = useRef(field);
|
|
65
|
+
const displayValueRef = useRef(displayValue);
|
|
66
|
+
const [text, setText] = useState(displayValue);
|
|
67
|
+
const latestTextRef = useRef(displayValue);
|
|
68
|
+
const committedTextRef = useRef(displayValue);
|
|
69
|
+
const wasActiveRef = useRef(active);
|
|
70
|
+
const dirtyRef = useRef(false);
|
|
71
|
+
fieldRef.current = field;
|
|
72
|
+
displayValueRef.current = displayValue;
|
|
73
|
+
const focusInput = useCallback(() => {
|
|
74
|
+
const input = inputNodeRef.current;
|
|
75
|
+
try {
|
|
76
|
+
input?.focus?.();
|
|
77
|
+
input?.setCursorOffset?.(0);
|
|
78
|
+
} catch {
|
|
79
|
+
// Renderer teardown can race queued focus attempts.
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
const fg = active
|
|
83
|
+
? colors.selectedText
|
|
84
|
+
: field.tone === "positive"
|
|
85
|
+
? colors.positive
|
|
86
|
+
: field.tone === "negative"
|
|
87
|
+
? colors.negative
|
|
88
|
+
: colors.text;
|
|
89
|
+
|
|
90
|
+
const commitText = useCallback((nextText: string): string | null => {
|
|
91
|
+
const currentField = fieldRef.current;
|
|
92
|
+
if (nextText.trim() === "") {
|
|
93
|
+
currentField.onClear?.();
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const parsed = parsePromptNumber(nextText);
|
|
97
|
+
if (parsed == null) return null;
|
|
98
|
+
const nextValue = currentField.percent ? parsed / 100 : parsed;
|
|
99
|
+
currentField.onValue?.(nextValue);
|
|
100
|
+
return formatInputNumber(nextValue, currentField.percent);
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const commitEditText = useCallback((nextText: string, fallbackText = displayValue) => {
|
|
104
|
+
latestTextRef.current = nextText;
|
|
105
|
+
const committedText = commitText(nextText);
|
|
106
|
+
committedTextRef.current = committedText ?? fallbackText;
|
|
107
|
+
setText(committedTextRef.current);
|
|
108
|
+
dirtyRef.current = false;
|
|
109
|
+
}, [commitText, displayValue]);
|
|
110
|
+
|
|
111
|
+
const getLiveInputText = useCallback(() => {
|
|
112
|
+
const input = inputNodeRef.current;
|
|
113
|
+
if (!input) return latestTextRef.current;
|
|
114
|
+
try {
|
|
115
|
+
return input.editBuffer.getText();
|
|
116
|
+
} catch {
|
|
117
|
+
return latestTextRef.current;
|
|
118
|
+
}
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const commitLiveInputText = useCallback(() => {
|
|
122
|
+
const liveText = getLiveInputText();
|
|
123
|
+
const nextText = typeof liveText === "string" ? liveText : latestTextRef.current;
|
|
124
|
+
if (!dirtyRef.current && (nextText.trim() === "" || nextText === committedTextRef.current)) return;
|
|
125
|
+
latestTextRef.current = nextText;
|
|
126
|
+
const committedText = commitText(nextText);
|
|
127
|
+
committedTextRef.current = committedText ?? displayValueRef.current;
|
|
128
|
+
dirtyRef.current = false;
|
|
129
|
+
}, [commitText, getLiveInputText]);
|
|
130
|
+
|
|
131
|
+
useLayoutEffect(() => {
|
|
132
|
+
const wasActive = wasActiveRef.current;
|
|
133
|
+
wasActiveRef.current = active;
|
|
134
|
+
if (!wasActive && active) {
|
|
135
|
+
dirtyRef.current = false;
|
|
136
|
+
latestTextRef.current = "";
|
|
137
|
+
committedTextRef.current = displayValue;
|
|
138
|
+
setText("");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (wasActive && !active) {
|
|
142
|
+
const nextText = dirtyRef.current ? latestTextRef.current : text;
|
|
143
|
+
if (dirtyRef.current) {
|
|
144
|
+
commitEditText(nextText);
|
|
145
|
+
} else {
|
|
146
|
+
committedTextRef.current = displayValue;
|
|
147
|
+
setText(displayValue);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!active) {
|
|
152
|
+
latestTextRef.current = displayValue;
|
|
153
|
+
committedTextRef.current = displayValue;
|
|
154
|
+
setText(displayValue);
|
|
155
|
+
}
|
|
156
|
+
}, [active, commitEditText, displayValue, text]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!active) return;
|
|
160
|
+
let animationFrame: number | null = null;
|
|
161
|
+
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
|
162
|
+
focusInput();
|
|
163
|
+
queueMicrotask(focusInput);
|
|
164
|
+
animationFrame = globalThis.requestAnimationFrame?.(focusInput) ?? null;
|
|
165
|
+
timeouts.push(setTimeout(focusInput, 0), setTimeout(focusInput, 32));
|
|
166
|
+
return () => {
|
|
167
|
+
if (animationFrame !== null) globalThis.cancelAnimationFrame?.(animationFrame);
|
|
168
|
+
for (const timeout of timeouts) clearTimeout(timeout);
|
|
169
|
+
};
|
|
170
|
+
}, [active, focusInput]);
|
|
171
|
+
|
|
172
|
+
useLayoutEffect(() => {
|
|
173
|
+
if (!active) return;
|
|
174
|
+
return () => {
|
|
175
|
+
commitLiveInputText();
|
|
176
|
+
};
|
|
177
|
+
}, [active, commitLiveInputText]);
|
|
178
|
+
|
|
179
|
+
const handleSubmit = (nextText: string) => {
|
|
180
|
+
commitEditText(nextText, nextText);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleBlur = (nextText: string) => {
|
|
184
|
+
if (!dirtyRef.current && (nextText.trim() === "" || nextText === committedTextRef.current)) return;
|
|
185
|
+
commitEditText(nextText, nextText);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (field.onPress && !field.onValue) {
|
|
189
|
+
return (
|
|
190
|
+
<Box
|
|
191
|
+
width={width}
|
|
192
|
+
height={1}
|
|
193
|
+
flexDirection="row"
|
|
194
|
+
backgroundColor={active ? colors.selected : colors.panel}
|
|
195
|
+
data-gloom-field-id={field.id}
|
|
196
|
+
onMouseDown={() => {
|
|
197
|
+
onFocus();
|
|
198
|
+
field.onPress?.();
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<Text fg={active ? colors.selectedText : colors.textDim}>
|
|
202
|
+
{truncateText(field.label, labelWidth).padEnd(labelWidth)}
|
|
203
|
+
</Text>
|
|
204
|
+
<Text fg={fg} attributes={active ? TextAttributes.BOLD : 0}>
|
|
205
|
+
{truncateText(field.valueText ?? "", Math.max(1, width - labelWidth))}
|
|
206
|
+
</Text>
|
|
207
|
+
</Box>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<Box
|
|
213
|
+
width={width}
|
|
214
|
+
height={1}
|
|
215
|
+
flexDirection="row"
|
|
216
|
+
backgroundColor={active ? colors.selected : colors.panel}
|
|
217
|
+
data-gloom-field-id={field.id}
|
|
218
|
+
onMouseDown={() => {
|
|
219
|
+
onFocus();
|
|
220
|
+
focusInput();
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<Text fg={active ? colors.selectedText : colors.textDim}>
|
|
224
|
+
{truncateText(field.label, labelWidth).padEnd(labelWidth)}
|
|
225
|
+
</Text>
|
|
226
|
+
{active ? (
|
|
227
|
+
<NumberField
|
|
228
|
+
inputRef={inputNodeRef}
|
|
229
|
+
focused={active}
|
|
230
|
+
value={text}
|
|
231
|
+
placeholder={displayValue}
|
|
232
|
+
allowNegative={field.allowNegative}
|
|
233
|
+
allowDecimal
|
|
234
|
+
width={valueWidth}
|
|
235
|
+
variant="plain"
|
|
236
|
+
backgroundColor={colors.selected}
|
|
237
|
+
textColor={fg}
|
|
238
|
+
placeholderColor={colors.textMuted}
|
|
239
|
+
onMouseDown={onFocus}
|
|
240
|
+
onChange={(nextText) => {
|
|
241
|
+
dirtyRef.current = true;
|
|
242
|
+
latestTextRef.current = nextText;
|
|
243
|
+
setText(nextText);
|
|
244
|
+
}}
|
|
245
|
+
onSubmit={handleSubmit}
|
|
246
|
+
onBlur={handleBlur}
|
|
247
|
+
/>
|
|
248
|
+
) : (
|
|
249
|
+
<Box width={valueWidth} height={1} onMouseDown={() => {
|
|
250
|
+
onFocus();
|
|
251
|
+
focusInput();
|
|
252
|
+
}}>
|
|
253
|
+
<Text fg={fg}>{truncateText(displayValue, valueWidth)}</Text>
|
|
254
|
+
</Box>
|
|
255
|
+
)}
|
|
256
|
+
{suffixWidth > 0 && (
|
|
257
|
+
<Text fg={active ? colors.selectedText : colors.textDim}>
|
|
258
|
+
{field.suffix ?? (field.percent ? "%" : "")}
|
|
259
|
+
</Text>
|
|
260
|
+
)}
|
|
261
|
+
</Box>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function MetricLine({
|
|
266
|
+
label,
|
|
267
|
+
value,
|
|
268
|
+
detail,
|
|
269
|
+
color,
|
|
270
|
+
width,
|
|
271
|
+
}: {
|
|
272
|
+
label: string;
|
|
273
|
+
value: string;
|
|
274
|
+
detail?: string;
|
|
275
|
+
color?: string;
|
|
276
|
+
width: number;
|
|
277
|
+
}) {
|
|
278
|
+
const labelWidth = Math.min(12, Math.max(8, Math.floor(width * 0.32)));
|
|
279
|
+
const valueWidth = Math.min(Math.max(8, width - labelWidth), Math.max(8, Math.floor(width * 0.42)));
|
|
280
|
+
const detailWidth = Math.max(0, width - labelWidth - valueWidth);
|
|
281
|
+
return (
|
|
282
|
+
<Box height={1} width={width} flexDirection="row" overflow="hidden">
|
|
283
|
+
<Box width={labelWidth} flexShrink={0}>
|
|
284
|
+
<Text fg={colors.textDim}>{label}</Text>
|
|
285
|
+
</Box>
|
|
286
|
+
<Box width={valueWidth} flexShrink={0}>
|
|
287
|
+
<Text fg={color ?? colors.text} attributes={TextAttributes.BOLD}>
|
|
288
|
+
{truncateText(value, valueWidth)}
|
|
289
|
+
</Text>
|
|
290
|
+
</Box>
|
|
291
|
+
{detail && detailWidth > 0 && (
|
|
292
|
+
<Text fg={colors.textDim}>{truncateText(detail, detailWidth)}</Text>
|
|
293
|
+
)}
|
|
294
|
+
</Box>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function SensitivityGridView({
|
|
299
|
+
width,
|
|
300
|
+
grid,
|
|
301
|
+
}: {
|
|
302
|
+
width: number;
|
|
303
|
+
grid: SensitivityGrid;
|
|
304
|
+
}) {
|
|
305
|
+
const labelWidth = 9;
|
|
306
|
+
const cellWidth = Math.max(8, Math.floor((width - labelWidth) / Math.max(1, grid.columns.length)));
|
|
307
|
+
return (
|
|
308
|
+
<Box flexDirection="column" paddingX={1}>
|
|
309
|
+
<Box height={1} flexDirection="row">
|
|
310
|
+
<Box width={labelWidth}>
|
|
311
|
+
<Text fg={colors.textDim}>{truncateText(grid.rowLabel, labelWidth)}</Text>
|
|
312
|
+
</Box>
|
|
313
|
+
{grid.columns.map((column) => (
|
|
314
|
+
<Box key={column} width={cellWidth}>
|
|
315
|
+
<Text fg={colors.textDim}>{truncateText(column, cellWidth - 1).padStart(cellWidth - 1)}</Text>
|
|
316
|
+
</Box>
|
|
317
|
+
))}
|
|
318
|
+
</Box>
|
|
319
|
+
{grid.rows.map((row, rowIndex) => (
|
|
320
|
+
<Box key={row} height={1} flexDirection="row">
|
|
321
|
+
<Box width={labelWidth}>
|
|
322
|
+
<Text fg={colors.textDim}>{truncateText(row, labelWidth)}</Text>
|
|
323
|
+
</Box>
|
|
324
|
+
{grid.cells[rowIndex]?.map((cell, cellIndex) => (
|
|
325
|
+
<Box key={`${row}:${cellIndex}`} width={cellWidth}>
|
|
326
|
+
<Text fg={colors.text}>{truncateText(cell.text, cellWidth - 1).padStart(cellWidth - 1)}</Text>
|
|
327
|
+
</Box>
|
|
328
|
+
))}
|
|
329
|
+
</Box>
|
|
330
|
+
))}
|
|
331
|
+
</Box>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function buildKellyCurveXAxisLabels(maxFraction: number): string[] {
|
|
336
|
+
if (!Number.isFinite(maxFraction) || maxFraction <= 0) return [];
|
|
337
|
+
return [0, 0.25, 0.5, 0.75, 1].map((ratio) => formatPct(maxFraction * ratio, 0));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function KellyCurveDecisionView({
|
|
341
|
+
width,
|
|
342
|
+
currentFraction,
|
|
343
|
+
targetFraction,
|
|
344
|
+
fullKellyFraction,
|
|
345
|
+
currentGrowth,
|
|
346
|
+
targetGrowth,
|
|
347
|
+
addTrimValue,
|
|
348
|
+
currency,
|
|
349
|
+
clipReasons,
|
|
350
|
+
}: {
|
|
351
|
+
width: number;
|
|
352
|
+
currentFraction: number;
|
|
353
|
+
targetFraction: number;
|
|
354
|
+
fullKellyFraction: number;
|
|
355
|
+
currentGrowth: number;
|
|
356
|
+
targetGrowth: number;
|
|
357
|
+
addTrimValue: number;
|
|
358
|
+
currency: string;
|
|
359
|
+
clipReasons: string[];
|
|
360
|
+
}) {
|
|
361
|
+
const moveLabel = addTrimValue >= 0 ? "add" : "trim";
|
|
362
|
+
const capLabel = clipReasons.length > 0 ? `cap ${clipReasons.join(", ")}` : "cap none";
|
|
363
|
+
const text = [
|
|
364
|
+
`${formatPct(currentFraction, 1)} -> ${formatPct(targetFraction, 1)}`,
|
|
365
|
+
`${moveLabel} ${formatCurrency(Math.abs(addTrimValue), currency)}`,
|
|
366
|
+
`growth ${formatSignedPct(currentGrowth)} -> ${formatSignedPct(targetGrowth)}`,
|
|
367
|
+
`full ${formatPct(fullKellyFraction, 0)}`,
|
|
368
|
+
capLabel,
|
|
369
|
+
].join(" ");
|
|
370
|
+
return (
|
|
371
|
+
<Box height={1} paddingX={1}>
|
|
372
|
+
<Text fg={colors.textDim}>{truncateText(text, Math.max(1, width - 2))}</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
@@ -105,7 +105,11 @@ export const layoutManagerPlugin: GloomPlugin = {
|
|
|
105
105
|
execute: async () => {
|
|
106
106
|
if (!getStateRef) return;
|
|
107
107
|
const { layout, termWidth, termHeight } = getStateRef();
|
|
108
|
-
persistLayout(ctx, gridlockAllPanes(
|
|
108
|
+
persistLayout(ctx, gridlockAllPanes(
|
|
109
|
+
layout,
|
|
110
|
+
{ x: 0, y: 0, width: termWidth, height: termHeight },
|
|
111
|
+
ctx.getPaneDef,
|
|
112
|
+
));
|
|
109
113
|
notifyGridlockComplete(ctx.notify, () => {
|
|
110
114
|
dispatchRef?.({ type: "UNDO_LAYOUT" });
|
|
111
115
|
});
|
|
@@ -130,9 +130,9 @@ function optionMoneynessBackground(
|
|
|
130
130
|
row: OptionTableRow,
|
|
131
131
|
contract: OptionContract | undefined,
|
|
132
132
|
columnId: OptionColumnId,
|
|
133
|
-
rowState: { selected: boolean
|
|
133
|
+
rowState: { selected: boolean },
|
|
134
134
|
): string | undefined {
|
|
135
|
-
if (rowState.selected
|
|
135
|
+
if (rowState.selected) return undefined;
|
|
136
136
|
const inTheMoney = inferColumnMoneyness(row, contract, columnId);
|
|
137
137
|
const sideColor = columnId.startsWith("call") ? colors.positive : colors.negative;
|
|
138
138
|
return inTheMoney
|
|
@@ -180,13 +180,13 @@ export function renderOptionCell(
|
|
|
180
180
|
row: OptionTableRow,
|
|
181
181
|
column: OptionColumn,
|
|
182
182
|
_index: number,
|
|
183
|
-
rowState: { selected: boolean
|
|
183
|
+
rowState: { selected: boolean },
|
|
184
184
|
): DataTableCell {
|
|
185
185
|
const selectedColor = rowState.selected ? colors.selectedText : undefined;
|
|
186
|
-
const rowSurface = rowState.selected ? colors.selected :
|
|
186
|
+
const rowSurface = rowState.selected ? colors.selected : colors.bg;
|
|
187
187
|
|
|
188
188
|
if (column.id === "strike") {
|
|
189
|
-
const backgroundColor = rowState.selected
|
|
189
|
+
const backgroundColor = rowState.selected
|
|
190
190
|
? undefined
|
|
191
191
|
: blendHex(colors.bg, row.isPositionStrike ? colors.borderFocused : colors.header, row.isPositionStrike ? 0.18 : 0.1);
|
|
192
192
|
const surface = backgroundColor ?? rowSurface;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, expect, test } from "bun:test";
|
|
2
2
|
import { act } from "react";
|
|
3
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
4
|
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
4
5
|
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../../market-data/coordinator";
|
|
5
6
|
import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app/context";
|
|
@@ -7,6 +8,7 @@ import { createTestDataProvider } from "../../../test-support/data-provider";
|
|
|
7
8
|
import { cloneLayout, createDefaultConfig } from "../../../types/config";
|
|
8
9
|
import type { OptionContract, OptionsChain, TickerFinancials } from "../../../types/financials";
|
|
9
10
|
import type { TickerRecord } from "../../../types/ticker";
|
|
11
|
+
import { formatExpDate } from "../../../utils/options";
|
|
10
12
|
import { OptionsView } from "./view";
|
|
11
13
|
|
|
12
14
|
const TEST_PANE_ID = "ticker-detail:options-test";
|
|
@@ -49,10 +51,14 @@ function makeContract(strike: number, side: "C" | "P", atmStrike = 101): OptionC
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
function makeChain(
|
|
54
|
+
function makeChain(
|
|
55
|
+
strikes = [100, 101],
|
|
56
|
+
atmStrike = 101,
|
|
57
|
+
expirationDates = [1_782_345_600],
|
|
58
|
+
): OptionsChain {
|
|
53
59
|
return {
|
|
54
60
|
underlyingSymbol: "AAPL",
|
|
55
|
-
expirationDates
|
|
61
|
+
expirationDates,
|
|
56
62
|
calls: strikes.map((strike) => makeContract(strike, "C", atmStrike)),
|
|
57
63
|
puts: strikes.map((strike) => makeContract(strike, "P", atmStrike)),
|
|
58
64
|
};
|
|
@@ -74,7 +80,17 @@ function makeFinancials(price: number): TickerFinancials {
|
|
|
74
80
|
};
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
function OptionsHarness({
|
|
83
|
+
function OptionsHarness({
|
|
84
|
+
ticker,
|
|
85
|
+
quotePrice,
|
|
86
|
+
width = 122,
|
|
87
|
+
onCapture = () => {},
|
|
88
|
+
}: {
|
|
89
|
+
ticker: TickerRecord;
|
|
90
|
+
quotePrice?: number;
|
|
91
|
+
width?: number;
|
|
92
|
+
onCapture?: (capturing: boolean) => void;
|
|
93
|
+
}) {
|
|
78
94
|
const config = createDefaultConfig("/tmp/gloomberb-options-test");
|
|
79
95
|
config.layout = {
|
|
80
96
|
dockRoot: { kind: "pane", instanceId: TEST_PANE_ID },
|
|
@@ -98,7 +114,7 @@ function OptionsHarness({ ticker, quotePrice }: { ticker: TickerRecord; quotePri
|
|
|
98
114
|
return (
|
|
99
115
|
<AppContext value={{ state, dispatch: () => {} }}>
|
|
100
116
|
<PaneInstanceProvider paneId={TEST_PANE_ID}>
|
|
101
|
-
<OptionsView width={
|
|
117
|
+
<OptionsView width={width} height={14} focused onCapture={onCapture} />
|
|
102
118
|
</PaneInstanceProvider>
|
|
103
119
|
</AppContext>
|
|
104
120
|
);
|
|
@@ -183,3 +199,124 @@ test("defaults the table around the nearest strike to the current quote", async
|
|
|
183
199
|
expect(frame).toContain("120");
|
|
184
200
|
expect(frame).not.toContain(" 50 ");
|
|
185
201
|
});
|
|
202
|
+
|
|
203
|
+
test("lets the expiration tab row use the full available width", async () => {
|
|
204
|
+
const expirationDates = Array.from({ length: 9 }, (_, index) => (
|
|
205
|
+
Math.floor(Date.UTC(2026, index, 20) / 1000)
|
|
206
|
+
));
|
|
207
|
+
const provider = createTestDataProvider({
|
|
208
|
+
getOptionsChain: async () => makeChain([100, 101], 101, expirationDates),
|
|
209
|
+
});
|
|
210
|
+
setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
|
|
211
|
+
|
|
212
|
+
await act(async () => {
|
|
213
|
+
testSetup = await testRender(
|
|
214
|
+
<OptionsHarness ticker={makeTicker("AAPL")} width={122} />,
|
|
215
|
+
{
|
|
216
|
+
width: 124,
|
|
217
|
+
height: 16,
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await renderSettled();
|
|
223
|
+
|
|
224
|
+
const frame = testSetup!.captureCharFrame();
|
|
225
|
+
expect(frame).toContain(formatExpDate(expirationDates.at(-1)!));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("keeps expiration tabs independently scrollable from a narrow strike table", async () => {
|
|
229
|
+
const expirationDates = Array.from({ length: 12 }, (_, index) => (
|
|
230
|
+
Math.floor(Date.UTC(2026, index, 20) / 1000)
|
|
231
|
+
));
|
|
232
|
+
const requestedExpirations: Array<number | undefined> = [];
|
|
233
|
+
const provider = createTestDataProvider({
|
|
234
|
+
getOptionsChain: async (_ticker, _exchange, expirationDate) => {
|
|
235
|
+
requestedExpirations.push(expirationDate);
|
|
236
|
+
return makeChain([100, 101], 101, expirationDates);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
|
|
240
|
+
const captures: boolean[] = [];
|
|
241
|
+
|
|
242
|
+
await act(async () => {
|
|
243
|
+
testSetup = await testRender(
|
|
244
|
+
<OptionsHarness ticker={makeTicker("AAPL")} width={54} onCapture={(capturing) => captures.push(capturing)} />,
|
|
245
|
+
{
|
|
246
|
+
width: 56,
|
|
247
|
+
height: 16,
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await renderSettled();
|
|
253
|
+
const bodyScroll = testSetup!.renderer.root.findDescendantById("options-table-body-scroll") as ScrollBoxRenderable | undefined;
|
|
254
|
+
const expirationTabsScroll = testSetup!.renderer.root.findDescendantById("options-expiration-tabs-scroll") as ScrollBoxRenderable | undefined;
|
|
255
|
+
expect(bodyScroll?.horizontalScrollBar.visible).toBe(true);
|
|
256
|
+
expect(expirationTabsScroll?.horizontalScrollBar.visible).toBe(false);
|
|
257
|
+
expect(testSetup!.captureCharFrame()).not.toContain(formatExpDate(expirationDates.at(-1)!));
|
|
258
|
+
|
|
259
|
+
await act(async () => {
|
|
260
|
+
testSetup!.mockInput.pressEnter();
|
|
261
|
+
await testSetup!.renderOnce();
|
|
262
|
+
});
|
|
263
|
+
await renderSettled();
|
|
264
|
+
expect(captures).toContain(true);
|
|
265
|
+
expect(captures.at(-1)).toBe(true);
|
|
266
|
+
|
|
267
|
+
for (let index = 1; index < expirationDates.length; index += 1) {
|
|
268
|
+
await act(async () => {
|
|
269
|
+
testSetup!.mockInput.pressKey("l");
|
|
270
|
+
await testSetup!.renderOnce();
|
|
271
|
+
});
|
|
272
|
+
await renderSettled();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
expect(bodyScroll?.horizontalScrollBar.visible).toBe(true);
|
|
276
|
+
expect(bodyScroll?.scrollLeft ?? 0).toBe(0);
|
|
277
|
+
expect(expirationTabsScroll?.scrollLeft ?? 0).toBeGreaterThan(0);
|
|
278
|
+
expect(requestedExpirations).toContain(expirationDates.at(-1));
|
|
279
|
+
expect(testSetup!.captureCharFrame()).toContain(formatExpDate(expirationDates.at(-1)!));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("clicking the option table focuses expiration tabs for arrow navigation", async () => {
|
|
283
|
+
const expirationDates = Array.from({ length: 3 }, (_, index) => (
|
|
284
|
+
Math.floor(Date.UTC(2026, index, 20) / 1000)
|
|
285
|
+
));
|
|
286
|
+
const requestedExpirations: Array<number | undefined> = [];
|
|
287
|
+
const provider = createTestDataProvider({
|
|
288
|
+
getOptionsChain: async (_ticker, _exchange, expirationDate) => {
|
|
289
|
+
requestedExpirations.push(expirationDate);
|
|
290
|
+
return makeChain([100, 101], 101, expirationDates);
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
|
|
294
|
+
const captures: boolean[] = [];
|
|
295
|
+
|
|
296
|
+
await act(async () => {
|
|
297
|
+
testSetup = await testRender(
|
|
298
|
+
<OptionsHarness ticker={makeTicker("AAPL")} width={80} onCapture={(capturing) => captures.push(capturing)} />,
|
|
299
|
+
{
|
|
300
|
+
width: 82,
|
|
301
|
+
height: 16,
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await renderSettled();
|
|
307
|
+
|
|
308
|
+
await act(async () => {
|
|
309
|
+
await testSetup!.mockMouse.click(8, 4);
|
|
310
|
+
await testSetup!.renderOnce();
|
|
311
|
+
});
|
|
312
|
+
await renderSettled();
|
|
313
|
+
expect(captures.at(-1)).toBe(true);
|
|
314
|
+
|
|
315
|
+
await act(async () => {
|
|
316
|
+
testSetup!.mockInput.pressArrow("right");
|
|
317
|
+
await testSetup!.renderOnce();
|
|
318
|
+
});
|
|
319
|
+
await renderSettled();
|
|
320
|
+
|
|
321
|
+
expect(requestedExpirations).toContain(expirationDates[1]);
|
|
322
|
+
});
|