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
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ Open command mode with `Ctrl+P`, then type a command. Press `` ` `` to open tick
|
|
|
60
60
|
| `TOP` | Ranked market stories |
|
|
61
61
|
| `MOST` | Market movers |
|
|
62
62
|
| `PF` | Portfolios and watchlists |
|
|
63
|
+
| `KELLY AAPL` | Position sizing |
|
|
63
64
|
| `HELP` | Full in-app shortcut list |
|
|
64
65
|
|
|
65
66
|
## What It Does
|
package/package.json
CHANGED
|
@@ -54,7 +54,11 @@ export function useDesktopApplicationMenuRuntime({
|
|
|
54
54
|
break;
|
|
55
55
|
case "layout-gridlock": {
|
|
56
56
|
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
57
|
-
pluginRegistry.updateLayoutFn(gridlockAllPanes(
|
|
57
|
+
pluginRegistry.updateLayoutFn(gridlockAllPanes(
|
|
58
|
+
stateRef.current.config.layout,
|
|
59
|
+
{ x: 0, y: 0, width, height },
|
|
60
|
+
pluginRegistry.panes,
|
|
61
|
+
));
|
|
58
62
|
notifyGridlockComplete(pluginRegistry.notify.bind(pluginRegistry), () => {
|
|
59
63
|
dispatch({ type: "UNDO_LAYOUT" });
|
|
60
64
|
});
|
|
@@ -164,7 +164,10 @@ export function useTickerRefreshRuntime({
|
|
|
164
164
|
dispatch({ type: "SET_REFRESHING", symbol, refreshing: true });
|
|
165
165
|
}
|
|
166
166
|
try {
|
|
167
|
-
const entries = await marketData.loadSnapshotsBatch(
|
|
167
|
+
const entries = await marketData.loadSnapshotsBatch(
|
|
168
|
+
instrumentEntries.map((entry) => entry.instrument),
|
|
169
|
+
{ forceRefresh: true },
|
|
170
|
+
);
|
|
168
171
|
entries.forEach((entry, index) => {
|
|
169
172
|
const ticker = instrumentEntries[index]?.ticker;
|
|
170
173
|
const data = entry.data ?? entry.lastGoodData;
|
package/src/app.tsx
CHANGED
|
@@ -60,7 +60,16 @@ interface AppInnerProps {
|
|
|
60
60
|
function ThemedAppRoot({ children }: { children: ReactNode }) {
|
|
61
61
|
const themeColors = useThemeColors();
|
|
62
62
|
return (
|
|
63
|
-
<Box
|
|
63
|
+
<Box
|
|
64
|
+
flexDirection="column"
|
|
65
|
+
flexGrow={1}
|
|
66
|
+
flexShrink={1}
|
|
67
|
+
flexBasis={0}
|
|
68
|
+
minWidth={0}
|
|
69
|
+
minHeight={0}
|
|
70
|
+
overflow="hidden"
|
|
71
|
+
backgroundColor={themeColors.bg}
|
|
72
|
+
>
|
|
64
73
|
{children}
|
|
65
74
|
</Box>
|
|
66
75
|
);
|
|
@@ -124,7 +124,7 @@ export function loadPersistedBrokerAccountMap(
|
|
|
124
124
|
|
|
125
125
|
for (const instance of brokerInstances) {
|
|
126
126
|
const broker = brokers.get(instance.brokerType);
|
|
127
|
-
if (!broker?.listAccounts) continue;
|
|
127
|
+
if (!broker?.listAccounts && !broker?.importPortfolioSnapshot) continue;
|
|
128
128
|
const accounts = loadPersistedBrokerAccounts(resources, instance, broker);
|
|
129
129
|
if (!accounts || accounts.length === 0) continue;
|
|
130
130
|
accountMap[instance.id] = accounts;
|
|
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createDefaultConfig, type BrokerInstanceConfig } from "../types/config";
|
|
3
3
|
import type { BrokerAdapter } from "../types/broker";
|
|
4
4
|
import type { TickerRecord } from "../types/ticker";
|
|
5
|
+
import { AppPersistence } from "../data/app-persistence";
|
|
6
|
+
import { loadPersistedBrokerAccounts, persistBrokerAccounts } from "./account-cache";
|
|
5
7
|
import {
|
|
6
8
|
restoreBrokerPortfoliosFromTickerPositions,
|
|
7
9
|
syncBrokerInstance,
|
|
@@ -164,6 +166,111 @@ describe("syncBrokerInstance", () => {
|
|
|
164
166
|
]);
|
|
165
167
|
});
|
|
166
168
|
|
|
169
|
+
test("imports account and position data from one broker portfolio snapshot", async () => {
|
|
170
|
+
const instance = createBrokerInstance();
|
|
171
|
+
const config = {
|
|
172
|
+
...createDefaultConfig("/tmp/gloomberb-sync-broker-snapshot"),
|
|
173
|
+
portfolios: [],
|
|
174
|
+
brokerInstances: [instance],
|
|
175
|
+
};
|
|
176
|
+
const tickerRepository = createTickerRepository();
|
|
177
|
+
const persistence = new AppPersistence(":memory:");
|
|
178
|
+
let listAccountsCalled = false;
|
|
179
|
+
let importPositionsCalled = false;
|
|
180
|
+
const broker: BrokerAdapter = {
|
|
181
|
+
...createDemoBroker(),
|
|
182
|
+
listAccounts: async () => {
|
|
183
|
+
listAccountsCalled = true;
|
|
184
|
+
return [];
|
|
185
|
+
},
|
|
186
|
+
importPositions: async () => {
|
|
187
|
+
importPositionsCalled = true;
|
|
188
|
+
return [];
|
|
189
|
+
},
|
|
190
|
+
importPortfolioSnapshot: async () => ({
|
|
191
|
+
accounts: [{
|
|
192
|
+
accountId: "ACC-1",
|
|
193
|
+
name: "Snapshot Account",
|
|
194
|
+
currency: "USD",
|
|
195
|
+
netLiquidation: 125_000,
|
|
196
|
+
grossPositionValue: 175_000,
|
|
197
|
+
totalCashValue: -50_000,
|
|
198
|
+
}],
|
|
199
|
+
positions: [{
|
|
200
|
+
ticker: "AAPL",
|
|
201
|
+
exchange: "NASDAQ",
|
|
202
|
+
shares: 12,
|
|
203
|
+
avgCost: 180,
|
|
204
|
+
currency: "USD",
|
|
205
|
+
accountId: "ACC-1",
|
|
206
|
+
name: "Apple Inc.",
|
|
207
|
+
assetCategory: "STK",
|
|
208
|
+
}],
|
|
209
|
+
}),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const result = await syncBrokerInstance({
|
|
214
|
+
config,
|
|
215
|
+
instanceId: "demo-broker",
|
|
216
|
+
brokers: new Map([["demo", broker]]),
|
|
217
|
+
tickerRepository: tickerRepository as any,
|
|
218
|
+
resources: persistence.resources,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(listAccountsCalled).toBe(false);
|
|
222
|
+
expect(importPositionsCalled).toBe(false);
|
|
223
|
+
expect(result.brokerAccounts[0]?.netLiquidation).toBe(125_000);
|
|
224
|
+
expect(result.positions).toHaveLength(1);
|
|
225
|
+
expect(loadPersistedBrokerAccounts(persistence.resources, instance, broker)).toEqual(result.brokerAccounts);
|
|
226
|
+
} finally {
|
|
227
|
+
persistence.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("preserves the last account snapshot when a broker portfolio snapshot fails", async () => {
|
|
232
|
+
const instance = createBrokerInstance();
|
|
233
|
+
const config = {
|
|
234
|
+
...createDefaultConfig("/tmp/gloomberb-sync-broker-snapshot-failure"),
|
|
235
|
+
portfolios: [],
|
|
236
|
+
brokerInstances: [instance],
|
|
237
|
+
};
|
|
238
|
+
const tickerRepository = createTickerRepository();
|
|
239
|
+
const persistence = new AppPersistence(":memory:");
|
|
240
|
+
const broker: BrokerAdapter = {
|
|
241
|
+
...createDemoBroker(),
|
|
242
|
+
importPortfolioSnapshot: async () => {
|
|
243
|
+
throw new Error("account snapshot unavailable");
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
persistBrokerAccounts(persistence.resources, instance, broker, [{
|
|
249
|
+
accountId: "ACC-1",
|
|
250
|
+
name: "Stale Account",
|
|
251
|
+
currency: "USD",
|
|
252
|
+
netLiquidation: 99_000,
|
|
253
|
+
}]);
|
|
254
|
+
|
|
255
|
+
await expect(syncBrokerInstance({
|
|
256
|
+
config,
|
|
257
|
+
instanceId: "demo-broker",
|
|
258
|
+
brokers: new Map([["demo", broker]]),
|
|
259
|
+
tickerRepository: tickerRepository as any,
|
|
260
|
+
resources: persistence.resources,
|
|
261
|
+
})).rejects.toThrow("account snapshot unavailable");
|
|
262
|
+
|
|
263
|
+
expect(loadPersistedBrokerAccounts(persistence.resources, instance, broker)).toEqual([{
|
|
264
|
+
accountId: "ACC-1",
|
|
265
|
+
name: "Stale Account",
|
|
266
|
+
currency: "USD",
|
|
267
|
+
netLiquidation: 99_000,
|
|
268
|
+
}]);
|
|
269
|
+
} finally {
|
|
270
|
+
persistence.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
167
274
|
test("preserves broker portfolios across sequential profile syncs", async () => {
|
|
168
275
|
const config = {
|
|
169
276
|
...createDefaultConfig("/tmp/gloomberb-sync-broker-instances"),
|
|
@@ -131,17 +131,30 @@ export async function syncBrokerInstance({
|
|
|
131
131
|
const tickers = await loadTickerMap(tickerRepository, existingTickers);
|
|
132
132
|
|
|
133
133
|
let brokerAccounts: BrokerAccount[] = [];
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
let positions: BrokerPosition[];
|
|
135
|
+
if (broker.importPortfolioSnapshot) {
|
|
136
|
+
const snapshot = await broker.importPortfolioSnapshot(instance);
|
|
137
|
+
brokerAccounts = snapshot.accounts;
|
|
138
|
+
positions = snapshot.positions;
|
|
139
|
+
if (resources) {
|
|
140
|
+
try {
|
|
141
|
+
persistBrokerAccounts(resources, instance, broker, brokerAccounts);
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (broker.listAccounts) {
|
|
146
|
+
try {
|
|
147
|
+
brokerAccounts = await broker.listAccounts(instance);
|
|
148
|
+
if (resources) {
|
|
149
|
+
try {
|
|
150
|
+
persistBrokerAccounts(resources, instance, broker, brokerAccounts);
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw error;
|
|
141
155
|
}
|
|
142
|
-
} catch {
|
|
143
|
-
brokerAccounts = [];
|
|
144
156
|
}
|
|
157
|
+
positions = await broker.importPositions(instance);
|
|
145
158
|
}
|
|
146
159
|
|
|
147
160
|
const accountMetadata = new Map(
|
|
@@ -154,7 +167,6 @@ export async function syncBrokerInstance({
|
|
|
154
167
|
]),
|
|
155
168
|
);
|
|
156
169
|
|
|
157
|
-
const positions = await broker.importPositions(instance);
|
|
158
170
|
const syncedAt = Date.now();
|
|
159
171
|
|
|
160
172
|
let nextConfig = config;
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "os";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import type { AppConfig } from "../types/config";
|
|
6
|
-
import type { TickerFinancials } from "../types/financials";
|
|
6
|
+
import type { OptionsChain, TickerFinancials } from "../types/financials";
|
|
7
7
|
import type { TickerRecord } from "../types/ticker";
|
|
8
8
|
import type { PaneRuntimeState } from "../core/state/app/state";
|
|
9
9
|
import {
|
|
@@ -20,6 +20,7 @@ export interface DesktopPaneShotPayload {
|
|
|
20
20
|
heightPx: number;
|
|
21
21
|
tickers: TickerRecord[];
|
|
22
22
|
financials: Array<[string, TickerFinancials]>;
|
|
23
|
+
optionsChains: Array<[string, OptionsChain]>;
|
|
23
24
|
paneState: Record<string, PaneRuntimeState>;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dirname, resolve } from "path";
|
|
2
2
|
import { mkdir } from "fs/promises";
|
|
3
3
|
import type { PaneRuntimeState } from "../../core/state/app/state";
|
|
4
|
-
import type { TickerFinancials } from "../../types/financials";
|
|
4
|
+
import type { OptionsChain, TickerFinancials } from "../../types/financials";
|
|
5
5
|
import type { TickerRecord } from "../../types/ticker";
|
|
6
6
|
import { slugifyName } from "../../utils/slugify";
|
|
7
7
|
import { renderDesktopPaneScreenshot, type DesktopPaneShotPayload } from "../desktop-pane-shot";
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
|
|
19
19
|
const DESKTOP_CELL_WIDTH_PX = 8;
|
|
20
20
|
const DESKTOP_CELL_HEIGHT_PX = 18;
|
|
21
|
+
const OPTIONS_PANE_ID = "options";
|
|
21
22
|
|
|
22
23
|
export function defaultScreenshotPath(resolved: ResolvedPaneFunction, rawArg: string): string {
|
|
23
24
|
const suffix = slugifyName([resolved.token, rawArg].filter(Boolean).join("-"), "pane");
|
|
@@ -70,11 +71,21 @@ async function buildDesktopShotPayload(
|
|
|
70
71
|
|
|
71
72
|
const tickers: TickerRecord[] = [];
|
|
72
73
|
const financials: Array<[string, TickerFinancials]> = [];
|
|
74
|
+
const optionsChains: Array<[string, OptionsChain]> = [];
|
|
75
|
+
const includeOptionsChains = resolved.pane.id === OPTIONS_PANE_ID || resolved.template?.paneId === OPTIONS_PANE_ID;
|
|
73
76
|
for (const symbol of collectShotSymbols(resolved, rawArg)) {
|
|
74
77
|
const entry = await fetchTickerFinancials(context, symbol);
|
|
75
78
|
const data = await withShotPriceHistory(context, symbol, entry.tickerFile, entry.financials);
|
|
76
79
|
tickers.push(entry.tickerFile ?? createFallbackTicker(symbol, data, context));
|
|
77
80
|
financials.push([symbol, data]);
|
|
81
|
+
if (includeOptionsChains && context.dataProvider.getOptionsChain) {
|
|
82
|
+
const exchange = entry.tickerFile?.metadata.exchange
|
|
83
|
+
?? data.quote?.listingExchangeName
|
|
84
|
+
?? data.quote?.exchangeName
|
|
85
|
+
?? "";
|
|
86
|
+
const chain = await context.dataProvider.getOptionsChain(symbol, exchange);
|
|
87
|
+
optionsChains.push([symbol, chain]);
|
|
88
|
+
}
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
return {
|
|
@@ -86,6 +97,7 @@ async function buildDesktopShotPayload(
|
|
|
86
97
|
heightPx,
|
|
87
98
|
tickers,
|
|
88
99
|
financials,
|
|
100
|
+
optionsChains,
|
|
89
101
|
paneState,
|
|
90
102
|
};
|
|
91
103
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildBarChartScene,
|
|
4
|
+
renderBarChart,
|
|
5
|
+
renderBarChartAxis,
|
|
6
|
+
renderBarChartYAxis,
|
|
7
|
+
renderNativeBarChart,
|
|
8
|
+
resolveNativeBarPixelBounds,
|
|
9
|
+
resolveBarChartHover,
|
|
10
|
+
} from "./bar-chart-renderer";
|
|
3
11
|
|
|
4
12
|
const series = [
|
|
5
13
|
{
|
|
@@ -27,27 +35,124 @@ describe("bar chart renderer", () => {
|
|
|
27
35
|
const scene = buildBarChartScene(series, {
|
|
28
36
|
width: 24,
|
|
29
37
|
height: 8,
|
|
30
|
-
colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
|
|
38
|
+
colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
|
|
31
39
|
});
|
|
32
40
|
|
|
33
41
|
expect(scene).not.toBeNull();
|
|
34
42
|
expect(scene!.categories).toEqual(["2024", "2025"]);
|
|
35
43
|
expect(scene!.bars).toHaveLength(4);
|
|
36
44
|
expect(scene!.zeroRow).toBeGreaterThanOrEqual(0);
|
|
45
|
+
expect(scene!.bars.find((bar) => bar.value < 0)?.color).toBe("#ff5555");
|
|
37
46
|
});
|
|
38
47
|
|
|
39
48
|
test("renders text fallback and native bitmap output", () => {
|
|
40
49
|
const scene = buildBarChartScene(series, {
|
|
41
50
|
width: 24,
|
|
42
51
|
height: 8,
|
|
43
|
-
colors: {
|
|
52
|
+
colors: {
|
|
53
|
+
bgColor: "#000000",
|
|
54
|
+
gridColor: "#333333",
|
|
55
|
+
axisColor: "#666666",
|
|
56
|
+
negativeColor: "#ff5555",
|
|
57
|
+
hoverColor: "#ffffff",
|
|
58
|
+
},
|
|
44
59
|
})!;
|
|
45
60
|
|
|
46
61
|
expect(renderBarChart(scene).join("\n")).toContain("█");
|
|
47
62
|
|
|
48
63
|
const bitmap = renderNativeBarChart(scene, 96, 48);
|
|
64
|
+
const negativePixel = Array.from({ length: bitmap.pixels.length / 4 }).some((_, pixelIndex) => {
|
|
65
|
+
const index = pixelIndex * 4;
|
|
66
|
+
return bitmap.pixels[index]! > 200 && bitmap.pixels[index + 1]! < 100 && bitmap.pixels[index + 2]! < 100;
|
|
67
|
+
});
|
|
49
68
|
expect(bitmap.width).toBe(96);
|
|
50
69
|
expect(bitmap.height).toBe(48);
|
|
51
70
|
expect(bitmap.pixels.some((value) => value > 0)).toBe(true);
|
|
71
|
+
expect(negativePixel).toBe(true);
|
|
72
|
+
|
|
73
|
+
const hover = resolveBarChartHover(scene, scene.bars[0]!.x);
|
|
74
|
+
expect(renderBarChart(scene, hover).join("\n")).toContain("▓");
|
|
75
|
+
const hoverBitmap = renderNativeBarChart(scene, 96, 48, hover);
|
|
76
|
+
const hoverPixel = Array.from({ length: hoverBitmap.pixels.length / 4 }).some((_, pixelIndex) => {
|
|
77
|
+
const index = pixelIndex * 4;
|
|
78
|
+
return hoverBitmap.pixels[index]! > 240 && hoverBitmap.pixels[index + 1]! > 240 && hoverBitmap.pixels[index + 2]! > 240;
|
|
79
|
+
});
|
|
80
|
+
expect(hoverBitmap.pixels).not.toEqual(bitmap.pixels);
|
|
81
|
+
expect(hoverPixel).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("renders y-axis labels and resolves hover targets", () => {
|
|
85
|
+
const scene = buildBarChartScene(series, {
|
|
86
|
+
width: 24,
|
|
87
|
+
height: 8,
|
|
88
|
+
colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666" },
|
|
89
|
+
})!;
|
|
90
|
+
|
|
91
|
+
const yAxis = renderBarChartYAxis(scene, 8, (value) => String(Math.round(value)));
|
|
92
|
+
const labelCount = yAxis.filter((line) => line.trim().length > 0).length;
|
|
93
|
+
expect(labelCount).toBeGreaterThanOrEqual(5);
|
|
94
|
+
expect(yAxis.join("\n")).toContain("22");
|
|
95
|
+
expect(yAxis.join("\n")).toContain("0");
|
|
96
|
+
|
|
97
|
+
const hover = resolveBarChartHover(scene, scene.bars[1]!.x);
|
|
98
|
+
expect(hover).toMatchObject({
|
|
99
|
+
seriesId: "NVDA",
|
|
100
|
+
seriesLabel: "NVDA",
|
|
101
|
+
category: "2024",
|
|
102
|
+
value: 20,
|
|
103
|
+
x: scene.bars[1]!.x,
|
|
104
|
+
width: scene.bars[1]!.width,
|
|
105
|
+
row: scene.bars[1]!.row,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("renders dense single-series bars with consistent separators and readable x-axis labels", () => {
|
|
110
|
+
const denseSeries = [{
|
|
111
|
+
id: "value",
|
|
112
|
+
label: "Value",
|
|
113
|
+
color: "#00ff66",
|
|
114
|
+
points: Array.from({ length: 14 }, (_unused, index) => ({
|
|
115
|
+
category: `${2015 + index} Q1`,
|
|
116
|
+
value: index + 1,
|
|
117
|
+
})),
|
|
118
|
+
}];
|
|
119
|
+
const scene = buildBarChartScene(denseSeries, {
|
|
120
|
+
width: 47,
|
|
121
|
+
height: 8,
|
|
122
|
+
colors: { bgColor: "#000000", gridColor: "#333333", axisColor: "#666666", negativeColor: "#ff5555" },
|
|
123
|
+
})!;
|
|
124
|
+
|
|
125
|
+
const lastBar = scene.bars.at(-1)!;
|
|
126
|
+
expect(lastBar.x + lastBar.width).toBe(scene.width);
|
|
127
|
+
expect(renderBarChart(scene)[scene.zeroRow]).not.toContain("─");
|
|
128
|
+
|
|
129
|
+
const bitmapWidth = 470;
|
|
130
|
+
const pixelBounds = scene.bars.map((bar) => resolveNativeBarPixelBounds(scene, bar, bitmapWidth));
|
|
131
|
+
const pixelWidths = pixelBounds.map((bounds) => bounds.rightExclusive - bounds.left);
|
|
132
|
+
expect(Math.max(...pixelWidths) - Math.min(...pixelWidths)).toBeLessThanOrEqual(1);
|
|
133
|
+
expect(pixelBounds[0]!.left).toBe(0);
|
|
134
|
+
expect(pixelBounds.at(-1)!.rightExclusive).toBe(bitmapWidth);
|
|
135
|
+
const pixelGaps: number[] = [];
|
|
136
|
+
for (let index = 1; index < pixelBounds.length; index++) {
|
|
137
|
+
pixelGaps.push(pixelBounds[index]!.left - pixelBounds[index - 1]!.rightExclusive);
|
|
138
|
+
}
|
|
139
|
+
expect(new Set(pixelGaps)).toEqual(new Set([1]));
|
|
140
|
+
|
|
141
|
+
const bitmap = renderNativeBarChart(scene, bitmapWidth, 80);
|
|
142
|
+
const zeroY = Math.round((scene.zeroRow / Math.max(scene.height - 1, 1)) * (bitmap.height - 1));
|
|
143
|
+
let separatorColumns = 0;
|
|
144
|
+
for (let x = 0; x < bitmap.width; x++) {
|
|
145
|
+
const index = (zeroY * bitmap.width + x) * 4;
|
|
146
|
+
if (bitmap.pixels[index + 1]! <= 120) separatorColumns++;
|
|
147
|
+
}
|
|
148
|
+
expect(separatorColumns).toBe(scene.categories.length - 1);
|
|
149
|
+
|
|
150
|
+
const axis = renderBarChartAxis(scene, 1);
|
|
151
|
+
expect(axis).toHaveLength(1);
|
|
152
|
+
expect(axis.every((line) => line.length === scene.width)).toBe(true);
|
|
153
|
+
expect(axis.join("\n")).toContain("2015");
|
|
154
|
+
expect(axis.join("\n")).not.toContain("Q1");
|
|
155
|
+
expect(axis.join("\n")).not.toContain("20 20");
|
|
156
|
+
expect(axis[0]!.endsWith(" ")).toBe(true);
|
|
52
157
|
});
|
|
53
158
|
});
|