gloomberb 0.13.2 → 0.13.3
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 +2 -0
- package/package.json +1 -1
- package/src/cli/desktop-pane-shot.ts +72 -2
- package/src/cli/pane-functions/cloud-session.test.ts +23 -0
- package/src/cli/pane-functions/cloud-session.ts +9 -0
- package/src/cli/pane-functions/screenshot.test.ts +27 -0
- package/src/cli/pane-functions/screenshot.ts +83 -1
- package/src/plugins/builtin/earnings/index.tsx +33 -9
- package/src/plugins/builtin/earnings/model.test.ts +29 -1
- package/src/plugins/builtin/research/analyst-pane.test.tsx +145 -0
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +27 -1
- package/src/plugins/builtin/research/event-model.ts +56 -0
- package/src/plugins/builtin/research/index.test.ts +43 -0
- package/src/plugins/builtin/short-interest/model.ts +15 -4
- package/src/plugins/builtin/short-interest/pane.tsx +1 -1
- package/src/renderers/browser/config-host.test.ts +70 -0
- package/src/renderers/browser/config-host.ts +74 -10
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +180 -19
- package/src/updater.test.ts +17 -1
- package/src/updater.ts +4 -2
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ curl -fsSL gloom.sh/install | bash
|
|
|
62
62
|
|
|
63
63
|
Both install `Gloomberb.app` and a `gloomberb` command that runs the TUI through the app bundle, so the bundled runtime is stored once.
|
|
64
64
|
|
|
65
|
+
`Gloomberb.app` is Apple Silicon (arm64) only. On an Intel Mac the install script installs the standalone `gloomberb` terminal app instead, and the Homebrew cask refuses to install rather than leaving an app that cannot launch.
|
|
66
|
+
|
|
65
67
|
Prefer a direct download?
|
|
66
68
|
|
|
67
69
|
- [Download Gloomberb for Mac](https://gloom.sh/download/desktop)
|
package/package.json
CHANGED
|
@@ -56,6 +56,33 @@ export interface DesktopPaneShotApiProxy {
|
|
|
56
56
|
sessionToken: string | null;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The page renders panes that fetch their own data. Left alone it can only
|
|
61
|
+
* reach the cloud API, so a pane whose values come from a provider the router
|
|
62
|
+
* merges in (analyst price targets, Yahoo dividends) rendered thinner than the
|
|
63
|
+
* same pane in the terminal. The bridge lets the page ask the Bun process to
|
|
64
|
+
* run those requests through the real provider router instead.
|
|
65
|
+
*/
|
|
66
|
+
export interface DesktopPaneShotBridge {
|
|
67
|
+
marketData(operation: string, args: unknown[]): Promise<unknown>;
|
|
68
|
+
httpFetch(request: DesktopPaneShotHttpRequest): Promise<DesktopPaneShotHttpResponse>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DesktopPaneShotHttpRequest {
|
|
72
|
+
url: string;
|
|
73
|
+
method?: string;
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
body?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DesktopPaneShotHttpResponse {
|
|
79
|
+
status: number;
|
|
80
|
+
statusText: string;
|
|
81
|
+
headers: Record<string, string>;
|
|
82
|
+
setCookie: string[];
|
|
83
|
+
body: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
export interface DesktopPaneShotRenderedCell {
|
|
60
87
|
columnId?: string;
|
|
61
88
|
columnLabel: string;
|
|
@@ -109,13 +136,15 @@ const SHOT_READY_TIMEOUT_MS = 45_000;
|
|
|
109
136
|
const CDP_CALL_TIMEOUT_MS = 10_000;
|
|
110
137
|
const DEFAULT_DEVICE_SCALE_FACTOR = 2;
|
|
111
138
|
const SHOT_API_PROXY_PREFIX = "/__gloom_cli_api__";
|
|
139
|
+
export const SHOT_MARKET_BRIDGE_PATH = "/__gloom_cli_market__";
|
|
140
|
+
export const SHOT_HTTP_BRIDGE_PATH = "/__gloom_cli_http__";
|
|
112
141
|
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
113
142
|
|
|
114
143
|
export async function renderDesktopPaneScreenshot(
|
|
115
144
|
payload: DesktopPaneShotPayload,
|
|
116
145
|
outputPath: string,
|
|
117
146
|
apiProxy: DesktopPaneShotApiProxy,
|
|
118
|
-
options: { captureImage?: boolean } = {},
|
|
147
|
+
options: { captureImage?: boolean; bridge?: DesktopPaneShotBridge } = {},
|
|
119
148
|
): Promise<DesktopPaneShotRenderResult> {
|
|
120
149
|
const tempDir = await mkdtemp(join(tmpdir(), "gloom-pane-shot-"));
|
|
121
150
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
@@ -123,7 +152,7 @@ export async function renderDesktopPaneScreenshot(
|
|
|
123
152
|
const outdir = join(tempDir, "assets");
|
|
124
153
|
await mkdir(outdir, { recursive: true });
|
|
125
154
|
await buildShotPage(outdir, payload);
|
|
126
|
-
server = serveShotPage(outdir, apiProxy);
|
|
155
|
+
server = serveShotPage(outdir, apiProxy, options.bridge ?? null);
|
|
127
156
|
const chrome = await findChromeExecutable();
|
|
128
157
|
return await capturePageScreenshot({
|
|
129
158
|
chrome,
|
|
@@ -183,6 +212,7 @@ async function buildShotPage(outdir: string, payload: DesktopPaneShotPayload): P
|
|
|
183
212
|
function serveShotPage(
|
|
184
213
|
outdir: string,
|
|
185
214
|
apiProxy: DesktopPaneShotApiProxy,
|
|
215
|
+
bridge: DesktopPaneShotBridge | null,
|
|
186
216
|
): ReturnType<typeof Bun.serve> {
|
|
187
217
|
const staticRoot = resolve(outdir);
|
|
188
218
|
return Bun.serve({
|
|
@@ -194,11 +224,51 @@ function serveShotPage(
|
|
|
194
224
|
if (url.pathname.startsWith(`${SHOT_API_PROXY_PREFIX}/`)) {
|
|
195
225
|
return proxyShotApiRequest(request, url, apiProxy);
|
|
196
226
|
}
|
|
227
|
+
if (url.pathname === SHOT_MARKET_BRIDGE_PATH) {
|
|
228
|
+
return runShotMarketBridge(request, bridge);
|
|
229
|
+
}
|
|
230
|
+
if (url.pathname === SHOT_HTTP_BRIDGE_PATH) {
|
|
231
|
+
return runShotHttpBridge(request, bridge);
|
|
232
|
+
}
|
|
197
233
|
return serveShotAsset(url, staticRoot);
|
|
198
234
|
},
|
|
199
235
|
});
|
|
200
236
|
}
|
|
201
237
|
|
|
238
|
+
async function runShotMarketBridge(
|
|
239
|
+
request: Request,
|
|
240
|
+
bridge: DesktopPaneShotBridge | null,
|
|
241
|
+
): Promise<Response> {
|
|
242
|
+
if (!bridge) return Response.json({ ok: false, error: "No market bridge" }, { status: 404 });
|
|
243
|
+
try {
|
|
244
|
+
const payload = await request.json() as { operation?: unknown; args?: unknown };
|
|
245
|
+
const operation = typeof payload.operation === "string" ? payload.operation : "";
|
|
246
|
+
const args = Array.isArray(payload.args) ? payload.args : [];
|
|
247
|
+
const data = await bridge.marketData(operation, args);
|
|
248
|
+
return Response.json({ ok: true, data });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return Response.json({ ok: false, error: errorMessage(error) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function runShotHttpBridge(
|
|
255
|
+
request: Request,
|
|
256
|
+
bridge: DesktopPaneShotBridge | null,
|
|
257
|
+
): Promise<Response> {
|
|
258
|
+
if (!bridge) return Response.json({ ok: false, error: "No HTTP bridge" }, { status: 404 });
|
|
259
|
+
try {
|
|
260
|
+
const payload = await request.json() as DesktopPaneShotHttpRequest;
|
|
261
|
+
const data = await bridge.httpFetch(payload);
|
|
262
|
+
return Response.json({ ok: true, data });
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return Response.json({ ok: false, error: errorMessage(error) });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function errorMessage(error: unknown): string {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|
|
271
|
+
|
|
202
272
|
async function serveShotAsset(url: URL, staticRoot: string): Promise<Response> {
|
|
203
273
|
let pathname: string;
|
|
204
274
|
try {
|
|
@@ -52,3 +52,26 @@ describe("pane function Cloud session", () => {
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe("cloud session token from the environment", () => {
|
|
57
|
+
test("prefers GLOOMBERB_SESSION_TOKEN over persisted state so containers can authenticate", () => {
|
|
58
|
+
const context = {
|
|
59
|
+
persistence: {
|
|
60
|
+
pluginState: {
|
|
61
|
+
get: () => ({ value: { sessionToken: "persisted-token" } }),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} as unknown as Parameters<typeof resolvePersistedCloudSessionToken>[0];
|
|
65
|
+
|
|
66
|
+
const previous = process.env.GLOOMBERB_SESSION_TOKEN;
|
|
67
|
+
try {
|
|
68
|
+
process.env.GLOOMBERB_SESSION_TOKEN = "env-token";
|
|
69
|
+
expect(resolvePersistedCloudSessionToken(context)).toBe("env-token");
|
|
70
|
+
delete process.env.GLOOMBERB_SESSION_TOKEN;
|
|
71
|
+
expect(resolvePersistedCloudSessionToken(context)).toBe("persisted-token");
|
|
72
|
+
} finally {
|
|
73
|
+
if (previous === undefined) delete process.env.GLOOMBERB_SESSION_TOKEN;
|
|
74
|
+
else process.env.GLOOMBERB_SESSION_TOKEN = previous;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -8,9 +8,18 @@ interface PersistedCloudSession {
|
|
|
8
8
|
sessionToken?: unknown;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A container has no local plugin state, so a bot or a CI job has no way to
|
|
13
|
+
* reach Pro gated panes. `GLOOMBERB_SESSION_TOKEN` gives headless callers the
|
|
14
|
+
* same session the desktop app persists after signing in, and it wins over the
|
|
15
|
+
* stored one so a service account can be pointed somewhere else without
|
|
16
|
+
* touching the user's own state.
|
|
17
|
+
*/
|
|
11
18
|
export function resolvePersistedCloudSessionToken(
|
|
12
19
|
context: Pick<MarketContext, "persistence">,
|
|
13
20
|
): string | null {
|
|
21
|
+
const fromEnv = process.env.GLOOMBERB_SESSION_TOKEN?.trim();
|
|
22
|
+
if (fromEnv) return fromEnv;
|
|
14
23
|
for (const key of CLOUD_SESSION_KEYS) {
|
|
15
24
|
const value = context.persistence.pluginState.get<PersistedCloudSession>(
|
|
16
25
|
CLOUD_PLUGIN_ID,
|
|
@@ -3,6 +3,7 @@ import type { RemoteUiNodeSnapshot } from "../../remote/types";
|
|
|
3
3
|
import {
|
|
4
4
|
chartSeriesEvidenceWithinRange,
|
|
5
5
|
chartEvidenceMismatchesFor,
|
|
6
|
+
createDesktopShotBridge,
|
|
6
7
|
isPaneScreenshotUsable,
|
|
7
8
|
missingActiveTabSelections,
|
|
8
9
|
resolveDesktopShotApiProxy,
|
|
@@ -94,6 +95,32 @@ describe("pane screenshot payload credentials", () => {
|
|
|
94
95
|
});
|
|
95
96
|
});
|
|
96
97
|
|
|
98
|
+
describe("pane screenshot market bridge", () => {
|
|
99
|
+
/**
|
|
100
|
+
* The page can only reach the cloud API on its own, and the cloud carries no
|
|
101
|
+
* per-rating price targets. Serving research requests from the router is what
|
|
102
|
+
* keeps a screenshot showing the same values as `gloomberb fn`.
|
|
103
|
+
*/
|
|
104
|
+
test("answers research requests from the routed provider and refuses anything else", async () => {
|
|
105
|
+
const calls: Array<[string, unknown[]]> = [];
|
|
106
|
+
const bridge = createDesktopShotBridge({
|
|
107
|
+
dataProvider: {
|
|
108
|
+
getAnalystResearch: (...args: unknown[]) => {
|
|
109
|
+
calls.push(["getAnalystResearch", args]);
|
|
110
|
+
return Promise.resolve({ symbol: "NKE", ratings: [{ currentPriceTarget: 42 }] });
|
|
111
|
+
},
|
|
112
|
+
} as any,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(await bridge.marketData("getAnalystResearch", ["NKE", "NYSE"])).toEqual({
|
|
116
|
+
symbol: "NKE",
|
|
117
|
+
ratings: [{ currentPriceTarget: 42 }],
|
|
118
|
+
});
|
|
119
|
+
expect(calls).toEqual([["getAnalystResearch", ["NKE", "NYSE"]]]);
|
|
120
|
+
await expect(bridge.marketData("getOptionsChain", ["NKE"])).rejects.toThrow(/does not serve/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
97
124
|
describe("pane screenshot active-state verification", () => {
|
|
98
125
|
const expected: PaneScreenshotExpectedSelection[] = [
|
|
99
126
|
{ control: "statement", label: "Cash Flow" },
|
|
@@ -11,6 +11,9 @@ const DEFAULT_SHOT_DEVICE_SCALE_FACTOR = 2;
|
|
|
11
11
|
import {
|
|
12
12
|
renderDesktopPaneScreenshot,
|
|
13
13
|
type DesktopPaneShotApiProxy,
|
|
14
|
+
type DesktopPaneShotBridge,
|
|
15
|
+
type DesktopPaneShotHttpRequest,
|
|
16
|
+
type DesktopPaneShotHttpResponse,
|
|
14
17
|
type DesktopPaneShotIntradayHistory,
|
|
15
18
|
type DesktopPaneShotPayload,
|
|
16
19
|
type DesktopPaneShotRenderResult,
|
|
@@ -139,6 +142,82 @@ export function resolveDesktopShotApiProxy(
|
|
|
139
142
|
};
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Data the page may request while it renders. The cloud API is only one of the
|
|
147
|
+
* router's sources, so calling it directly from the page dropped whatever
|
|
148
|
+
* another provider contributes: analyst rating price targets came back empty
|
|
149
|
+
* and corporate actions vanished whenever the cloud leg failed. Quotes are
|
|
150
|
+
* here for board panes that list symbols the payload was never built for.
|
|
151
|
+
* These run on the Bun side through the same router `gloomberb fn` uses.
|
|
152
|
+
*/
|
|
153
|
+
const SHOT_BRIDGE_MARKET_OPERATIONS = new Set([
|
|
154
|
+
"getAnalystResearch",
|
|
155
|
+
"getCorporateActions",
|
|
156
|
+
"getEarningsCalendar",
|
|
157
|
+
"getHolders",
|
|
158
|
+
"getPriceHistory",
|
|
159
|
+
"getQuote",
|
|
160
|
+
"getQuotesBatch",
|
|
161
|
+
"getSecFilings",
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const SHOT_BRIDGE_HTTP_TIMEOUT_MS = 20_000;
|
|
165
|
+
|
|
166
|
+
export function createDesktopShotBridge(
|
|
167
|
+
context: Pick<MarketContext, "dataProvider">,
|
|
168
|
+
): DesktopPaneShotBridge {
|
|
169
|
+
return {
|
|
170
|
+
async marketData(operation, args) {
|
|
171
|
+
if (!SHOT_BRIDGE_MARKET_OPERATIONS.has(operation)) {
|
|
172
|
+
throw new Error(`Screenshot market bridge does not serve "${operation}".`);
|
|
173
|
+
}
|
|
174
|
+
const provider = context.dataProvider as unknown as Record<string, unknown>;
|
|
175
|
+
const handler = provider[operation];
|
|
176
|
+
if (typeof handler !== "function") {
|
|
177
|
+
throw new Error(`No provider available for ${operation}.`);
|
|
178
|
+
}
|
|
179
|
+
return await (handler as (...values: unknown[]) => Promise<unknown>).apply(
|
|
180
|
+
context.dataProvider,
|
|
181
|
+
args,
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
httpFetch: (request) => runDesktopShotHttpFetch(request),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Panes such as DVD fetch a third-party API directly. A browser cannot: the
|
|
190
|
+
* request is cross-origin and dies in CORS, so the pane reported that the
|
|
191
|
+
* ticker pays no dividend. The desktop renderer already proxies these through
|
|
192
|
+
* its native half; the screenshot page gets the same treatment.
|
|
193
|
+
*/
|
|
194
|
+
async function runDesktopShotHttpFetch(
|
|
195
|
+
request: DesktopPaneShotHttpRequest,
|
|
196
|
+
): Promise<DesktopPaneShotHttpResponse> {
|
|
197
|
+
const target = new URL(request.url);
|
|
198
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
199
|
+
throw new Error(`Screenshot HTTP bridge refuses ${target.protocol} requests.`);
|
|
200
|
+
}
|
|
201
|
+
const response = await fetch(request.url, {
|
|
202
|
+
method: request.method ?? "GET",
|
|
203
|
+
headers: request.headers,
|
|
204
|
+
body: request.body,
|
|
205
|
+
redirect: "follow",
|
|
206
|
+
signal: AbortSignal.timeout(SHOT_BRIDGE_HTTP_TIMEOUT_MS),
|
|
207
|
+
});
|
|
208
|
+
const headers: Record<string, string> = {};
|
|
209
|
+
response.headers.forEach((value, name) => {
|
|
210
|
+
headers[name] = value;
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
status: response.status,
|
|
214
|
+
statusText: response.statusText,
|
|
215
|
+
headers,
|
|
216
|
+
setCookie: response.headers.getSetCookie?.() ?? [],
|
|
217
|
+
body: await response.text(),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
142
221
|
/** Same reason as the valuation legs: the renderer cannot fill its own cache. */
|
|
143
222
|
async function collectShotStatSeries(
|
|
144
223
|
resolved: ResolvedPaneFunction,
|
|
@@ -617,7 +696,10 @@ export async function renderDesktopShot({
|
|
|
617
696
|
scale ?? 1,
|
|
618
697
|
watermark ?? null,
|
|
619
698
|
);
|
|
620
|
-
render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, {
|
|
699
|
+
render = await renderDesktopPaneScreenshot(payload, outputPath, apiProxy, {
|
|
700
|
+
captureImage,
|
|
701
|
+
bridge: createDesktopShotBridge(context),
|
|
702
|
+
});
|
|
621
703
|
} finally {
|
|
622
704
|
apiClient.setSessionToken(previousSessionToken);
|
|
623
705
|
}
|
|
@@ -7,8 +7,11 @@ import { useAppSelector, usePaneInstance } from "../../../state/app/context";
|
|
|
7
7
|
import { parseTickerListInput, formatTickerListInput } from "../../../tickers/list";
|
|
8
8
|
import { useAssetData, usePluginPaneState, usePluginTickerActions } from "../../runtime";
|
|
9
9
|
import { useAutoRefresh } from "../shared/auto-refresh";
|
|
10
|
-
import type {
|
|
11
|
-
|
|
10
|
+
import type {
|
|
11
|
+
PaneSettingsContext,
|
|
12
|
+
PaneSettingsDef,
|
|
13
|
+
PaneTemplateCreateOptions,
|
|
14
|
+
} from "../../../types/plugin";
|
|
12
15
|
import {
|
|
13
16
|
attachEarningsCalendarPersistence,
|
|
14
17
|
loadEarningsCalendar,
|
|
@@ -188,6 +191,18 @@ function EarningsCalendarPane({ focused, width, height }: PaneProps) {
|
|
|
188
191
|
);
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/** Tickers named on the command bar or the CLI win over the active collection. */
|
|
195
|
+
function earningsScopeSymbols(options: PaneTemplateCreateOptions | undefined): string[] {
|
|
196
|
+
if (options?.symbols && options.symbols.length > 0) return options.symbols;
|
|
197
|
+
const raw = options?.arg?.trim() ?? "";
|
|
198
|
+
if (!raw) return [];
|
|
199
|
+
try {
|
|
200
|
+
return parseTickerListInput(raw);
|
|
201
|
+
} catch {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
191
206
|
/** The monitor's scope lives in pane settings, so it has to be editable there too. */
|
|
192
207
|
function earningsSettings(context: PaneSettingsContext): PaneSettingsDef {
|
|
193
208
|
const collections = [
|
|
@@ -197,7 +212,7 @@ function earningsSettings(context: PaneSettingsContext): PaneSettingsDef {
|
|
|
197
212
|
const symbols = scopedSymbolsFromSettings(context.settings);
|
|
198
213
|
return {
|
|
199
214
|
title: "Earnings Scope",
|
|
200
|
-
values: { symbolsText: symbols.length > 0 ?
|
|
215
|
+
values: { symbolsText: symbols.length > 0 ? formatTickerListInput(symbols) : "" },
|
|
201
216
|
fields: [
|
|
202
217
|
{
|
|
203
218
|
key: "symbolsText",
|
|
@@ -266,13 +281,22 @@ export const earningsModule: PluginModule = {
|
|
|
266
281
|
label: "Earnings Calendar",
|
|
267
282
|
description: "Upcoming earnings dates and estimates for your tickers.",
|
|
268
283
|
keywords: ["earn", "earnings", "calendar", "eps", "revenue", "quarterly"],
|
|
269
|
-
shortcut: { prefix: "ERN" },
|
|
284
|
+
shortcut: { prefix: "ERN", argPlaceholder: "tickers", argKind: "ticker-list" },
|
|
270
285
|
headless: earningsCalendarHeadless,
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
// The shortcut takes tickers, so honor them the way the report does.
|
|
287
|
+
// Ignoring them left `ERN NKE` scoped to the active collection, which
|
|
288
|
+
// rendered "No tickers in scope" while the report listed NKE's earnings.
|
|
289
|
+
createInstance: (context, options) => {
|
|
290
|
+
const symbols = earningsScopeSymbols(options);
|
|
291
|
+
return {
|
|
292
|
+
title: symbols.length > 0 ? `ERN ${formatTickerListInput(symbols)}` : undefined,
|
|
293
|
+
settings: symbols.length > 0
|
|
294
|
+
? { symbols, symbolsText: formatTickerListInput(symbols) }
|
|
295
|
+
: context.activeCollectionId
|
|
296
|
+
? { collectionId: context.activeCollectionId }
|
|
297
|
+
: undefined,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
276
300
|
},
|
|
277
301
|
{
|
|
278
302
|
id: "earnings-monitor-pane",
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { TickerRecord } from "../../../types/ticker";
|
|
3
|
-
import {
|
|
3
|
+
import type { PaneTemplateContext } from "../../../types/plugin";
|
|
4
|
+
import { earningsModule } from "./index";
|
|
5
|
+
import {
|
|
6
|
+
groupEarningsByRelativeDate,
|
|
7
|
+
resolveEarningsCollectionId,
|
|
8
|
+
scopedSymbolsFromSettings,
|
|
9
|
+
trackedEarningsSymbols,
|
|
10
|
+
} from "./model";
|
|
4
11
|
|
|
5
12
|
function ticker(
|
|
6
13
|
symbol: string,
|
|
@@ -22,6 +29,27 @@ function ticker(
|
|
|
22
29
|
};
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
describe("ERN pane scope", () => {
|
|
33
|
+
/**
|
|
34
|
+
* The shortcut takes tickers, but the template used to drop them and follow
|
|
35
|
+
* the active collection instead, so `ERN NKE` opened a pane that reported no
|
|
36
|
+
* tickers in scope while the same argument produced a report.
|
|
37
|
+
*/
|
|
38
|
+
test("scopes the pane to the tickers the shortcut was given", async () => {
|
|
39
|
+
const template = earningsModule.paneTemplates?.find(
|
|
40
|
+
(candidate) => candidate.id === "earnings-calendar-pane",
|
|
41
|
+
);
|
|
42
|
+
const context = { activeCollectionId: "main" } as unknown as PaneTemplateContext;
|
|
43
|
+
|
|
44
|
+
const scoped = await template?.createInstance?.(context, { arg: "nke, msft" });
|
|
45
|
+
expect(scopedSymbolsFromSettings(scoped?.settings)).toEqual(["NKE", "MSFT"]);
|
|
46
|
+
|
|
47
|
+
const unscoped = await template?.createInstance?.(context, undefined);
|
|
48
|
+
expect(scopedSymbolsFromSettings(unscoped?.settings)).toEqual([]);
|
|
49
|
+
expect(unscoped?.settings?.collectionId).toBe("main");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
25
53
|
describe("trackedEarningsSymbols", () => {
|
|
26
54
|
test("gives settings-less legacy panes one stable collection scope", () => {
|
|
27
55
|
expect(resolveEarningsCollectionId(undefined, "main")).toBe("main");
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
4
|
+
import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app/context";
|
|
5
|
+
import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
|
|
6
|
+
import { cloneLayout, createDefaultConfig } from "../../../types/config";
|
|
7
|
+
import type { AnalystResearchData } from "../../../types/financials";
|
|
8
|
+
import type { DataProvider } from "../../../types/data-provider";
|
|
9
|
+
import type { TickerRecord } from "../../../types/ticker";
|
|
10
|
+
import { Box } from "../../../ui";
|
|
11
|
+
import { PluginRenderProvider } from "../../runtime";
|
|
12
|
+
import { AnalystResearchView } from "./analyst-pane";
|
|
13
|
+
|
|
14
|
+
const TEST_PANE_ID = "analyst-research:NKE";
|
|
15
|
+
const WIDTH = 96;
|
|
16
|
+
const HEIGHT = 20;
|
|
17
|
+
|
|
18
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
19
|
+
|
|
20
|
+
const research: AnalystResearchData = {
|
|
21
|
+
providerId: "test",
|
|
22
|
+
symbol: "NKE",
|
|
23
|
+
currency: "USD",
|
|
24
|
+
priceTarget: { current: 38.4, average: 50.46, low: 23, median: 46.5, high: 94, currency: "USD" },
|
|
25
|
+
recommendationRating: 6.1,
|
|
26
|
+
recommendations: [],
|
|
27
|
+
ratings: [
|
|
28
|
+
{
|
|
29
|
+
date: "2026-08-26",
|
|
30
|
+
firm: "Truist Securities",
|
|
31
|
+
action: "Downgrade",
|
|
32
|
+
current: "Hold",
|
|
33
|
+
prior: "Buy",
|
|
34
|
+
currentPriceTarget: 42,
|
|
35
|
+
priorPriceTarget: 47,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
date: "2026-08-25",
|
|
39
|
+
firm: "Solo Target",
|
|
40
|
+
action: "Initiates",
|
|
41
|
+
current: "Buy",
|
|
42
|
+
currentPriceTarget: 61,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
date: "2026-08-24",
|
|
46
|
+
firm: "No Target",
|
|
47
|
+
action: "Reiterates",
|
|
48
|
+
current: "Buy",
|
|
49
|
+
prior: "Buy",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
earningsEstimates: [],
|
|
53
|
+
revenueEstimates: [],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function makeTicker(symbol: string): TickerRecord {
|
|
57
|
+
return {
|
|
58
|
+
metadata: {
|
|
59
|
+
ticker: symbol,
|
|
60
|
+
exchange: "NYSE",
|
|
61
|
+
currency: "USD",
|
|
62
|
+
name: symbol,
|
|
63
|
+
portfolios: [],
|
|
64
|
+
watchlists: [],
|
|
65
|
+
positions: [],
|
|
66
|
+
custom: {},
|
|
67
|
+
tags: [],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function AnalystHarness({ provider }: { provider: DataProvider }) {
|
|
73
|
+
const config = createDefaultConfig("/tmp/gloomberb-analyst-pane-test");
|
|
74
|
+
config.layout = {
|
|
75
|
+
dockRoot: { kind: "pane", instanceId: TEST_PANE_ID },
|
|
76
|
+
instances: [{
|
|
77
|
+
instanceId: TEST_PANE_ID,
|
|
78
|
+
paneId: "analyst-research",
|
|
79
|
+
binding: { kind: "fixed", symbol: "NKE" },
|
|
80
|
+
}],
|
|
81
|
+
floating: [],
|
|
82
|
+
detached: [],
|
|
83
|
+
};
|
|
84
|
+
config.layouts = [{ name: "Default", layout: cloneLayout(config.layout) }];
|
|
85
|
+
|
|
86
|
+
const state = createInitialState(config);
|
|
87
|
+
state.focusedPaneId = TEST_PANE_ID;
|
|
88
|
+
state.tickers = new Map([["NKE", makeTicker("NKE")]]);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
92
|
+
<PaneInstanceProvider paneId={TEST_PANE_ID}>
|
|
93
|
+
<PluginRenderProvider
|
|
94
|
+
pluginId="ticker-research"
|
|
95
|
+
runtime={createTestPluginRuntime({ getMarketData: () => provider })}
|
|
96
|
+
>
|
|
97
|
+
<Box flexDirection="column" width={WIDTH} height={HEIGHT}>
|
|
98
|
+
<AnalystResearchView width={WIDTH} height={HEIGHT} focused />
|
|
99
|
+
</Box>
|
|
100
|
+
</PluginRenderProvider>
|
|
101
|
+
</PaneInstanceProvider>
|
|
102
|
+
</AppContext>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function renderHarness(provider: DataProvider): Promise<void> {
|
|
107
|
+
await act(async () => {
|
|
108
|
+
testSetup = await testRender(<AnalystHarness provider={provider} />, { width: WIDTH, height: HEIGHT });
|
|
109
|
+
});
|
|
110
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
111
|
+
await act(async () => {
|
|
112
|
+
await Bun.sleep(1);
|
|
113
|
+
await testSetup!.renderOnce();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
if (testSetup) {
|
|
120
|
+
await act(async () => testSetup!.renderer.destroy());
|
|
121
|
+
testSetup = undefined;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The pane used to reach a source that carries the aggregate price target but
|
|
127
|
+
* no per-rating targets, so every TARGET cell rendered as a dash under a
|
|
128
|
+
* header that advertised an average target.
|
|
129
|
+
*/
|
|
130
|
+
test("renders each rating's price target and only dashes the rows without one", async () => {
|
|
131
|
+
await renderHarness({
|
|
132
|
+
id: "test",
|
|
133
|
+
name: "Test",
|
|
134
|
+
getAnalystResearch: async () => research,
|
|
135
|
+
} as unknown as DataProvider);
|
|
136
|
+
|
|
137
|
+
const frame = testSetup!.captureCharFrame();
|
|
138
|
+
expect(frame).toContain("$50.46 avg target");
|
|
139
|
+
expect(frame).toContain("$47 → $42");
|
|
140
|
+
expect(frame).toContain("$61");
|
|
141
|
+
const noTargetRow = frame.split("\n").find((line) => line.includes("No Target"));
|
|
142
|
+
expect(noTargetRow).toBeDefined();
|
|
143
|
+
expect(noTargetRow).not.toMatch(/\$\d/);
|
|
144
|
+
expect(noTargetRow).toContain("-");
|
|
145
|
+
});
|
|
@@ -34,7 +34,12 @@ import {
|
|
|
34
34
|
buildInlineFilingContentTargets,
|
|
35
35
|
useSecFilingContentCache,
|
|
36
36
|
} from "../sec/filing-content";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
buildEventRows,
|
|
39
|
+
eventSourceNotice,
|
|
40
|
+
type EventRow,
|
|
41
|
+
type EventStatus,
|
|
42
|
+
} from "./event-model";
|
|
38
43
|
|
|
39
44
|
export { buildEventRows } from "./event-model";
|
|
40
45
|
|
|
@@ -284,6 +289,18 @@ export function CorporateActionsView({
|
|
|
284
289
|
: allRows
|
|
285
290
|
), [allRows, variant]);
|
|
286
291
|
const columns = useMemo(() => buildEventColumns(), []);
|
|
292
|
+
const sourceNotice = useMemo(() => (
|
|
293
|
+
actionsLoading || analystLoading
|
|
294
|
+
? null
|
|
295
|
+
: eventSourceNotice({
|
|
296
|
+
variant,
|
|
297
|
+
symbol: symbol ?? "this ticker",
|
|
298
|
+
actions: actionsData,
|
|
299
|
+
actionsError,
|
|
300
|
+
estimates: analystData,
|
|
301
|
+
estimatesError: analystError,
|
|
302
|
+
})
|
|
303
|
+
), [actionsData, actionsError, actionsLoading, analystData, analystError, analystLoading, symbol, variant]);
|
|
287
304
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
288
305
|
const [openRowId, setOpenRowId] = useState<string | null>(null);
|
|
289
306
|
const detailScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
@@ -480,6 +497,15 @@ export function CorporateActionsView({
|
|
|
480
497
|
}}
|
|
481
498
|
onActivate={(row) => setOpenRowId(row.id)}
|
|
482
499
|
onDetailKeyDown={handleDetailKeyDown}
|
|
500
|
+
rootBefore={sourceNotice && rows.length > 0 ? (
|
|
501
|
+
<Box
|
|
502
|
+
height={1}
|
|
503
|
+
paddingX={1}
|
|
504
|
+
{...(sourceNotice.failed ? { "data-gloom-status": "error" } : {})}
|
|
505
|
+
>
|
|
506
|
+
<Text fg={sourceNotice.failed ? colors.negative : colors.textDim}>{sourceNotice.text}</Text>
|
|
507
|
+
</Box>
|
|
508
|
+
) : undefined}
|
|
483
509
|
rootWidth={width}
|
|
484
510
|
rootHeight={height}
|
|
485
511
|
onRootKeyDown={handleKeyDown}
|
|
@@ -243,3 +243,59 @@ export function buildEventRows(
|
|
|
243
243
|
|| left.period.localeCompare(right.period)
|
|
244
244
|
));
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
export interface EventSourceState {
|
|
248
|
+
variant: "corporate-actions" | "earnings-estimates";
|
|
249
|
+
symbol: string;
|
|
250
|
+
actions: CorporateActionsData | null;
|
|
251
|
+
actionsError: string | null;
|
|
252
|
+
estimates: AnalystResearchData | null;
|
|
253
|
+
estimatesError: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface EventSourceNotice {
|
|
257
|
+
text: string;
|
|
258
|
+
/** True when a source failed, as opposed to a source that had nothing. */
|
|
259
|
+
failed: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The table can still show a TTM line built from statements while both event
|
|
264
|
+
* sources returned nothing, which used to read as a working pane that happens
|
|
265
|
+
* to be almost empty. This says which source is missing so a thin pane
|
|
266
|
+
* explains itself instead of looking broken.
|
|
267
|
+
*/
|
|
268
|
+
export function eventSourceNotice(state: EventSourceState): EventSourceNotice | null {
|
|
269
|
+
const actionsLabel = state.variant === "earnings-estimates"
|
|
270
|
+
? "Reported earnings"
|
|
271
|
+
: "Corporate actions";
|
|
272
|
+
const notices: string[] = [];
|
|
273
|
+
|
|
274
|
+
if (state.actionsError) {
|
|
275
|
+
notices.push(`${actionsLabel} unavailable: ${state.actionsError}`);
|
|
276
|
+
} else if (state.actions && !hasCorporateActionRows(state.actions)) {
|
|
277
|
+
notices.push(state.variant === "earnings-estimates"
|
|
278
|
+
? `No reported earnings for ${state.symbol}`
|
|
279
|
+
: `No dividends, splits, or reported earnings for ${state.symbol}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (state.estimatesError) {
|
|
283
|
+
notices.push(`Analyst estimates unavailable: ${state.estimatesError}`);
|
|
284
|
+
} else if (state.estimates && !hasAnalystEstimates(state.estimates)) {
|
|
285
|
+
notices.push(`No analyst estimates for ${state.symbol}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (notices.length === 0) return null;
|
|
289
|
+
return {
|
|
290
|
+
text: notices.join(" "),
|
|
291
|
+
failed: !!state.actionsError || !!state.estimatesError,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function hasCorporateActionRows(data: CorporateActionsData): boolean {
|
|
296
|
+
return data.dividends.length > 0 || data.splits.length > 0 || data.earnings.length > 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasAnalystEstimates(data: AnalystResearchData): boolean {
|
|
300
|
+
return data.earningsEstimates.length > 0 || data.revenueEstimates.length > 0;
|
|
301
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type RatingSortPreference,
|
|
10
10
|
} from "./analyst-pane";
|
|
11
11
|
import { buildEventRows, matchEarningsSecFiling } from "./corporate-actions-pane";
|
|
12
|
+
import { eventSourceNotice } from "./event-model";
|
|
12
13
|
|
|
13
14
|
const ratings: AnalystRatingRecord[] = [
|
|
14
15
|
{
|
|
@@ -362,3 +363,45 @@ describe("event rows", () => {
|
|
|
362
363
|
expect(matchEarningsSecFiling(row, filings)?.accessionNumber).toBe("0000320193-26-000009");
|
|
363
364
|
});
|
|
364
365
|
});
|
|
366
|
+
|
|
367
|
+
describe("event source notice", () => {
|
|
368
|
+
const loaded = {
|
|
369
|
+
variant: "corporate-actions" as const,
|
|
370
|
+
symbol: "DBK",
|
|
371
|
+
actions: { symbol: "DBK", dividends: [{ exDate: "2026-05-29", amount: 1 }], splits: [], earnings: [] },
|
|
372
|
+
actionsError: null,
|
|
373
|
+
estimates: {
|
|
374
|
+
symbol: "DBK",
|
|
375
|
+
recommendations: [],
|
|
376
|
+
ratings: [],
|
|
377
|
+
earningsEstimates: [{ date: "2026-12-31", period: "current_year", average: 3.3, analysts: 10 }],
|
|
378
|
+
revenueEstimates: [],
|
|
379
|
+
} satisfies AnalystResearchData,
|
|
380
|
+
estimatesError: null,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
test("stays quiet when both sources delivered", () => {
|
|
384
|
+
expect(eventSourceNotice(loaded)).toBeNull();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A failed corporate-actions request left the table showing only estimate and
|
|
389
|
+
* TTM rows, which reads as a working pane for a company with no events.
|
|
390
|
+
*/
|
|
391
|
+
test("names a failed source and marks it as a failure", () => {
|
|
392
|
+
expect(eventSourceNotice({ ...loaded, actions: null, actionsError: "Cloud request failed" })).toEqual({
|
|
393
|
+
text: "Corporate actions unavailable: Cloud request failed",
|
|
394
|
+
failed: true,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("reports genuinely empty data without calling it a failure", () => {
|
|
399
|
+
expect(eventSourceNotice({
|
|
400
|
+
...loaded,
|
|
401
|
+
actions: { symbol: "DBK", dividends: [], splits: [], earnings: [] },
|
|
402
|
+
})).toEqual({
|
|
403
|
+
text: "No dividends, splits, or reported earnings for DBK",
|
|
404
|
+
failed: false,
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -97,17 +97,28 @@ export function nextSortPreference(
|
|
|
97
97
|
return { columnId: columnId as ShortInterestColumnId, direction: "desc" };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
/**
|
|
101
|
+
* FINRA settlement history carries no float, so percent of float is null for
|
|
102
|
+
* every row on that route. Keeping the column drew a header over a column of
|
|
103
|
+
* dashes, which reads as missing data rather than data the source never had.
|
|
104
|
+
*/
|
|
105
|
+
export function buildColumns(
|
|
106
|
+
width: number,
|
|
107
|
+
records: readonly ShortInterestRecord[] = [],
|
|
108
|
+
): ShortInterestColumn[] {
|
|
109
|
+
const hasPercentFloat = records.some((record) => record.shortPercentFloat != null);
|
|
101
110
|
const dateWidth = 12;
|
|
102
111
|
const sharesWidth = 12;
|
|
103
112
|
const ratioWidth = 12;
|
|
104
|
-
const
|
|
105
|
-
const
|
|
113
|
+
const percentWidth = hasPercentFloat ? 10 : 0;
|
|
114
|
+
const advWidth = Math.max(12, width - 2 - dateWidth - sharesWidth - ratioWidth - (hasPercentFloat ? 12 : 2));
|
|
106
115
|
return [
|
|
107
116
|
{ id: "settlementDate", label: "DATE", width: dateWidth, align: "left" },
|
|
108
117
|
{ id: "sharesShort", label: "SHARES SHORT", width: sharesWidth, align: "right" },
|
|
109
118
|
{ id: "shortRatio", label: "DAYS TO COVER", width: ratioWidth, align: "right" },
|
|
110
119
|
{ id: "averageDailyVolume", label: "AVG DAILY VOL", width: advWidth, align: "right" },
|
|
111
|
-
|
|
120
|
+
...(hasPercentFloat
|
|
121
|
+
? [{ id: "shortPercentFloat" as const, label: "% FLOAT", width: percentWidth, align: "right" as const }]
|
|
122
|
+
: []),
|
|
112
123
|
];
|
|
113
124
|
}
|
|
@@ -71,7 +71,7 @@ function ShortInterestView({ width, height, focused }: { width: number; height:
|
|
|
71
71
|
|
|
72
72
|
const rows = useMemo(() => buildRows(records), [records]);
|
|
73
73
|
const sortedRows = useMemo(() => sortRows(rows, sortPreference), [rows, sortPreference]);
|
|
74
|
-
const columns = useMemo(() => buildColumns(width), [width]);
|
|
74
|
+
const columns = useMemo(() => buildColumns(width, records), [records, width]);
|
|
75
75
|
const chartPoints = useMemo(() => recordsToChartPoints(records), [records]);
|
|
76
76
|
|
|
77
77
|
const boundedSelectedIdx = sortedRows.length > 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { getDockedPaneIds } from "../../plugins/pane-manager/dock-tree";
|
|
3
|
+
import {
|
|
4
|
+
BROWSER_DEFAULT_THEME,
|
|
5
|
+
BROWSER_ECON_CALENDAR_ID,
|
|
6
|
+
BROWSER_RESEARCH_CHART_ID,
|
|
7
|
+
BROWSER_RESEARCH_NEWS_ID,
|
|
8
|
+
BROWSER_RESEARCH_PANE_ID,
|
|
9
|
+
BROWSER_SECTORS_ID,
|
|
10
|
+
BROWSER_WORLD_INDICES_ID,
|
|
11
|
+
createBrowserConfigStore,
|
|
12
|
+
} from "./config-host";
|
|
13
|
+
import type { StorageLike } from "./storage";
|
|
14
|
+
|
|
15
|
+
function memoryStorage(): StorageLike {
|
|
16
|
+
const map = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
getItem: (key) => map.get(key) ?? null,
|
|
19
|
+
setItem: (key, value) => { map.set(key, value); },
|
|
20
|
+
removeItem: (key) => { map.delete(key); },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("a first visit opens six panes with one company down the middle", async () => {
|
|
25
|
+
const store = createBrowserConfigStore(memoryStorage(), "?ticker=aapl&tab=financials");
|
|
26
|
+
const config = await store.loadConfig("browser://local");
|
|
27
|
+
|
|
28
|
+
expect(config.theme).toBe(BROWSER_DEFAULT_THEME);
|
|
29
|
+
expect(config.layouts[0]?.name).toBe("Research");
|
|
30
|
+
expect(getDockedPaneIds(config.layout)).toEqual([
|
|
31
|
+
BROWSER_WORLD_INDICES_ID,
|
|
32
|
+
BROWSER_SECTORS_ID,
|
|
33
|
+
BROWSER_RESEARCH_PANE_ID,
|
|
34
|
+
BROWSER_RESEARCH_NEWS_ID,
|
|
35
|
+
BROWSER_RESEARCH_CHART_ID,
|
|
36
|
+
BROWSER_ECON_CALENDAR_ID,
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const byId = new Map(config.layout.instances.map((instance) => [instance.instanceId, instance]));
|
|
40
|
+
expect(byId.get(BROWSER_RESEARCH_PANE_ID)?.binding).toEqual({ kind: "fixed", symbol: "AAPL" });
|
|
41
|
+
expect(byId.get(BROWSER_RESEARCH_CHART_ID)?.binding).toEqual({
|
|
42
|
+
kind: "follow",
|
|
43
|
+
sourceInstanceId: BROWSER_RESEARCH_PANE_ID,
|
|
44
|
+
});
|
|
45
|
+
expect(byId.get(BROWSER_RESEARCH_NEWS_ID)?.binding).toEqual({
|
|
46
|
+
kind: "follow",
|
|
47
|
+
sourceInstanceId: BROWSER_RESEARCH_PANE_ID,
|
|
48
|
+
});
|
|
49
|
+
expect(config.layouts[0]?.paneState?.[BROWSER_RESEARCH_PANE_ID]).toEqual({ activeTabId: "financials" });
|
|
50
|
+
expect(config.onboardingComplete).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a visit without a research link still opens NVDA", async () => {
|
|
54
|
+
const store = createBrowserConfigStore(memoryStorage());
|
|
55
|
+
const config = await store.loadConfig("browser://local");
|
|
56
|
+
const research = config.layout.instances.find((instance) => instance.instanceId === BROWSER_RESEARCH_PANE_ID);
|
|
57
|
+
expect(research?.binding).toEqual({ kind: "fixed", symbol: "NVDA" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a returning visitor keeps the saved layout", async () => {
|
|
61
|
+
const storage = memoryStorage();
|
|
62
|
+
const first = createBrowserConfigStore(storage, "?ticker=NVDA");
|
|
63
|
+
await first.saveConfig(await first.loadConfig("browser://local"));
|
|
64
|
+
|
|
65
|
+
const second = createBrowserConfigStore(storage, "?ticker=MSFT");
|
|
66
|
+
const restored = await second.loadConfig("browser://local");
|
|
67
|
+
const research = restored.layout.instances.find((instance) => instance.instanceId === BROWSER_RESEARCH_PANE_ID);
|
|
68
|
+
expect(research?.binding).toEqual({ kind: "fixed", symbol: "NVDA" });
|
|
69
|
+
expect(getDockedPaneIds(restored.layout)).toHaveLength(6);
|
|
70
|
+
});
|
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
normalizeConfigForSave,
|
|
7
7
|
normalizeLoadedConfig,
|
|
8
8
|
} from "../../data/config/store/normalize";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CHART_COMPOSER_PANE_ID,
|
|
11
|
+
createDefaultConfig,
|
|
12
|
+
TICKER_RESEARCH_PANE_ID,
|
|
13
|
+
type AppConfig,
|
|
14
|
+
type DockLayoutNode,
|
|
15
|
+
type LayoutConfig,
|
|
16
|
+
} from "../../types/config";
|
|
10
17
|
import { researchEntryFromSearch } from "./research-entry";
|
|
11
18
|
import { BROWSER_STORAGE_KEYS, SafeJsonStorage, type StorageLike } from "./storage";
|
|
12
19
|
|
|
@@ -16,20 +23,77 @@ function browserReady(config: AppConfig): AppConfig {
|
|
|
16
23
|
return { ...config, onboardingComplete: true, onboardingProgress: undefined };
|
|
17
24
|
}
|
|
18
25
|
|
|
26
|
+
export const BROWSER_RESEARCH_PANE_ID = "ticker-detail:main";
|
|
27
|
+
export const BROWSER_RESEARCH_CHART_ID = "chart-composer:research";
|
|
28
|
+
export const BROWSER_RESEARCH_NEWS_ID = "ticker-news:research";
|
|
29
|
+
export const BROWSER_WORLD_INDICES_ID = "world-indices:main";
|
|
30
|
+
export const BROWSER_SECTORS_ID = "sectors:main";
|
|
31
|
+
export const BROWSER_ECON_CALENDAR_ID = "econ-calendar:main";
|
|
32
|
+
|
|
33
|
+
/** The browser ships the monochrome theme the website uses. */
|
|
34
|
+
export const BROWSER_DEFAULT_THEME = "white";
|
|
35
|
+
|
|
36
|
+
function column(first: string, second: string, ratio = 0.5): DockLayoutNode {
|
|
37
|
+
return {
|
|
38
|
+
kind: "split",
|
|
39
|
+
axis: "vertical",
|
|
40
|
+
ratio,
|
|
41
|
+
first: { kind: "pane", instanceId: first },
|
|
42
|
+
second: { kind: "pane", instanceId: second },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The first-visit workspace: six panes, with one company down the middle.
|
|
48
|
+
* The research pane owns the symbol; the news and chart panes follow it, so
|
|
49
|
+
* typing a new ticker while the research pane is focused swaps all three.
|
|
50
|
+
* The market panes on either side stay put.
|
|
51
|
+
*/
|
|
52
|
+
export function createBrowserResearchLayout(symbol: string): LayoutConfig {
|
|
53
|
+
const follow = { kind: "follow" as const, sourceInstanceId: BROWSER_RESEARCH_PANE_ID };
|
|
54
|
+
return {
|
|
55
|
+
dockRoot: {
|
|
56
|
+
kind: "split",
|
|
57
|
+
axis: "horizontal",
|
|
58
|
+
ratio: 0.3,
|
|
59
|
+
first: column(BROWSER_WORLD_INDICES_ID, BROWSER_SECTORS_ID),
|
|
60
|
+
second: {
|
|
61
|
+
kind: "split",
|
|
62
|
+
axis: "horizontal",
|
|
63
|
+
ratio: 0.57,
|
|
64
|
+
first: column(BROWSER_RESEARCH_PANE_ID, BROWSER_RESEARCH_NEWS_ID),
|
|
65
|
+
second: column(BROWSER_RESEARCH_CHART_ID, BROWSER_ECON_CALENDAR_ID),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
instances: [
|
|
69
|
+
{ instanceId: BROWSER_WORLD_INDICES_ID, paneId: "world-indices", binding: { kind: "none" } },
|
|
70
|
+
{ instanceId: BROWSER_SECTORS_ID, paneId: "sectors", binding: { kind: "none" } },
|
|
71
|
+
{
|
|
72
|
+
instanceId: BROWSER_RESEARCH_PANE_ID,
|
|
73
|
+
paneId: TICKER_RESEARCH_PANE_ID,
|
|
74
|
+
binding: { kind: "fixed", symbol },
|
|
75
|
+
settings: { hideTabs: false },
|
|
76
|
+
},
|
|
77
|
+
{ instanceId: BROWSER_RESEARCH_NEWS_ID, paneId: "ticker-news", binding: follow },
|
|
78
|
+
{ instanceId: BROWSER_RESEARCH_CHART_ID, paneId: CHART_COMPOSER_PANE_ID, binding: follow },
|
|
79
|
+
{ instanceId: BROWSER_ECON_CALENDAR_ID, paneId: "econ-calendar", binding: { kind: "none" } },
|
|
80
|
+
],
|
|
81
|
+
floating: [],
|
|
82
|
+
detached: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
19
86
|
function createBrowserDefaultConfig(dataDir: string, search = ""): AppConfig {
|
|
20
87
|
const config = createDefaultConfig(dataDir);
|
|
88
|
+
config.theme = BROWSER_DEFAULT_THEME;
|
|
21
89
|
const entry = researchEntryFromSearch(search) ?? { symbol: "NVDA", tab: "overview" };
|
|
22
|
-
// A first visit starts with
|
|
23
|
-
// their existing panes and bindings when a visitor returns.
|
|
90
|
+
// A first visit starts with a research workspace around one company. Saved
|
|
91
|
+
// layouts retain their existing panes and bindings when a visitor returns.
|
|
24
92
|
config.layouts[0] = {
|
|
25
93
|
name: "Research",
|
|
26
|
-
layout:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
binding: { kind: "fixed", symbol: entry.symbol }, settings: { hideTabs: false } }],
|
|
30
|
-
floating: [], detached: [],
|
|
31
|
-
},
|
|
32
|
-
paneState: { "ticker-detail:main": { activeTabId: entry.tab } },
|
|
94
|
+
layout: createBrowserResearchLayout(entry.symbol),
|
|
95
|
+
paneState: { [BROWSER_RESEARCH_PANE_ID]: { activeTabId: entry.tab } },
|
|
96
|
+
focusedPaneId: BROWSER_RESEARCH_PANE_ID,
|
|
33
97
|
};
|
|
34
98
|
config.layout = config.layouts[0].layout;
|
|
35
99
|
return browserReady(config);
|
|
@@ -35,8 +35,24 @@ import type {
|
|
|
35
35
|
AppTickerRepositoryPort,
|
|
36
36
|
} from "../../../core/app-service-ports";
|
|
37
37
|
import type { CachedResourceRecord, ResourceCacheKey, SetResourceOptions } from "../../../data/resource-store";
|
|
38
|
-
import type {
|
|
39
|
-
|
|
38
|
+
import type {
|
|
39
|
+
CachedFinancialsTarget,
|
|
40
|
+
DataProvider,
|
|
41
|
+
EarningsEvent,
|
|
42
|
+
QuoteBatchResult,
|
|
43
|
+
QuoteSubscriptionTarget,
|
|
44
|
+
SecFilingItem,
|
|
45
|
+
} from "../../../types/data-provider";
|
|
46
|
+
import type {
|
|
47
|
+
AnalystResearchData,
|
|
48
|
+
CorporateActionsData,
|
|
49
|
+
HolderData,
|
|
50
|
+
OptionsChain,
|
|
51
|
+
PricePoint,
|
|
52
|
+
Quote,
|
|
53
|
+
TickerFinancials,
|
|
54
|
+
} from "../../../types/financials";
|
|
55
|
+
import { setHttpFetchTransport } from "../../../utils/http-transport";
|
|
40
56
|
import type { TickerRecord } from "../../../types/ticker";
|
|
41
57
|
import type { AppState, PaneRuntimeState } from "../../../core/state/app/state";
|
|
42
58
|
import type { PaneDef } from "../../../types/plugin";
|
|
@@ -100,6 +116,9 @@ const SHOT_READY_STABLE_FRAMES = 10;
|
|
|
100
116
|
// headline) wait forever and time out.
|
|
101
117
|
const SHOT_LOADING_SELECTOR = "[data-gloom-status=\"loading\"]";
|
|
102
118
|
const SHOT_API_PROXY_PREFIX = "/__gloom_cli_api__";
|
|
119
|
+
// Served by the Bun process that owns this page, see src/cli/desktop-pane-shot.ts.
|
|
120
|
+
const SHOT_MARKET_BRIDGE_PATH = "/__gloom_cli_market__";
|
|
121
|
+
const SHOT_HTTP_BRIDGE_PATH = "/__gloom_cli_http__";
|
|
103
122
|
const TRACKED_RESPONSE_METHODS = new Set<PropertyKey>([
|
|
104
123
|
"arrayBuffer",
|
|
105
124
|
"blob",
|
|
@@ -183,6 +202,68 @@ function installShotCloudApiTransport(): void {
|
|
|
183
202
|
});
|
|
184
203
|
}
|
|
185
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Panes such as DVD and SI call a third-party API through httpFetch. A browser
|
|
207
|
+
* cannot do that cross-origin, so those requests died in CORS and the panes
|
|
208
|
+
* reported the ticker as having no data. The desktop renderer proxies the same
|
|
209
|
+
* calls through its native half; here the Bun process that serves this page
|
|
210
|
+
* runs them and returns the response, cookies included so the Yahoo crumb
|
|
211
|
+
* handshake still works.
|
|
212
|
+
*/
|
|
213
|
+
function installShotHttpFetchTransport(): void {
|
|
214
|
+
setHttpFetchTransport(async (url, init = {}) => {
|
|
215
|
+
const headers: Record<string, string> = {};
|
|
216
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
217
|
+
headers[name] = value;
|
|
218
|
+
});
|
|
219
|
+
const response = await window.fetch(SHOT_HTTP_BRIDGE_PATH, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "Content-Type": "application/json" },
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
url,
|
|
224
|
+
method: init.method,
|
|
225
|
+
headers,
|
|
226
|
+
body: typeof init.body === "string" ? init.body : undefined,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const result = await response.json() as {
|
|
230
|
+
ok: boolean;
|
|
231
|
+
error?: string;
|
|
232
|
+
data?: {
|
|
233
|
+
status: number;
|
|
234
|
+
statusText: string;
|
|
235
|
+
headers: Record<string, string>;
|
|
236
|
+
setCookie: string[];
|
|
237
|
+
body: string;
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
if (!result.ok || !result.data) throw new Error(result.error ?? `Request failed: ${url}`);
|
|
241
|
+
return createShotHttpResponse(result.data);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createShotHttpResponse(payload: {
|
|
246
|
+
status: number;
|
|
247
|
+
statusText: string;
|
|
248
|
+
headers: Record<string, string>;
|
|
249
|
+
setCookie: string[];
|
|
250
|
+
body: string;
|
|
251
|
+
}): Response {
|
|
252
|
+
// Yahoo's crumb handshake reads set-cookie, which fetch hides from a page.
|
|
253
|
+
const headers = new Headers(payload.headers);
|
|
254
|
+
const originalGet = headers.get.bind(headers);
|
|
255
|
+
headers.get = ((name: string) => (
|
|
256
|
+
name.toLowerCase() === "set-cookie" ? payload.setCookie[0] ?? null : originalGet(name)
|
|
257
|
+
)) as Headers["get"];
|
|
258
|
+
(headers as Headers & { getSetCookie?: () => string[] }).getSetCookie = () => [...payload.setCookie];
|
|
259
|
+
const response = new Response(payload.body, {
|
|
260
|
+
status: payload.status,
|
|
261
|
+
statusText: payload.statusText,
|
|
262
|
+
});
|
|
263
|
+
Object.defineProperty(response, "headers", { value: headers, configurable: true });
|
|
264
|
+
return response;
|
|
265
|
+
}
|
|
266
|
+
|
|
186
267
|
async function restoreShotCloudSession(): Promise<void> {
|
|
187
268
|
await apiClient.getSession().catch(() => null);
|
|
188
269
|
}
|
|
@@ -191,6 +272,51 @@ function resolveShotWork<T>(value: T): Promise<T> {
|
|
|
191
272
|
return trackShotWork(Promise.resolve(value));
|
|
192
273
|
}
|
|
193
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Runs an asset-data request in the Bun process so the page sees exactly what
|
|
277
|
+
* `gloomberb fn` sees. The page can only reach the cloud API on its own, and
|
|
278
|
+
* the cloud is one source among several: analyst rating price targets and part
|
|
279
|
+
* of the corporate action history come from providers the router merges in.
|
|
280
|
+
*/
|
|
281
|
+
async function requestShotMarketData<T>(operation: string, args: unknown[]): Promise<T> {
|
|
282
|
+
const response = await window.fetch(SHOT_MARKET_BRIDGE_PATH, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify({ operation, args }),
|
|
286
|
+
});
|
|
287
|
+
const result = await response.json() as { ok: boolean; data?: T; error?: string };
|
|
288
|
+
if (!result.ok) throw new Error(result.error ?? `Screenshot data request failed: ${operation}`);
|
|
289
|
+
return result.data as T;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** JSON has no Date, so every bridged date arrives as a string. */
|
|
293
|
+
function reviveDate(value: unknown): Date | undefined {
|
|
294
|
+
if (value instanceof Date) return value;
|
|
295
|
+
if (typeof value !== "string" && typeof value !== "number") return undefined;
|
|
296
|
+
const date = new Date(value);
|
|
297
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function reviveSecFilings(filings: SecFilingItem[]): SecFilingItem[] {
|
|
301
|
+
return filings.map((filing) => ({
|
|
302
|
+
...filing,
|
|
303
|
+
filingDate: reviveDate(filing.filingDate) ?? new Date(0),
|
|
304
|
+
...(filing.acceptedAt ? { acceptedAt: reviveDate(filing.acceptedAt) } : {}),
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function revivePricePoints(points: PricePoint[]): PricePoint[] {
|
|
309
|
+
return points.map((point) => ({ ...point, date: reviveDate(point.date) ?? new Date(0) }));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function reviveEarningsEvents(events: EarningsEvent[]): EarningsEvent[] {
|
|
313
|
+
return events.map((event) => ({
|
|
314
|
+
...event,
|
|
315
|
+
earningsDate: reviveDate(event.earningsDate) ?? new Date(0),
|
|
316
|
+
...(event.earningsCallDate ? { earningsCallDate: reviveDate(event.earningsCallDate) ?? null } : {}),
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
194
320
|
function isShotLoadingTextVisible(): boolean {
|
|
195
321
|
return document.querySelector(SHOT_LOADING_SELECTOR) !== null;
|
|
196
322
|
}
|
|
@@ -301,9 +427,13 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
|
|
|
301
427
|
if (!instrument.exchange) optionsChains.set(normalizeSymbol(instrument.symbol), data);
|
|
302
428
|
}
|
|
303
429
|
|
|
430
|
+
const findFinancials = (symbol: string, exchange?: string) => (
|
|
431
|
+
financials.get(canonicalTickerKey(symbol, exchange))
|
|
432
|
+
?? financials.get(normalizeSymbol(symbol))
|
|
433
|
+
);
|
|
434
|
+
|
|
304
435
|
const getFinancials = (symbol: string, exchange?: string) => {
|
|
305
|
-
const data =
|
|
306
|
-
?? financials.get(normalizeSymbol(symbol));
|
|
436
|
+
const data = findFinancials(symbol, exchange);
|
|
307
437
|
if (!data) throw new Error(`No screenshot market data available for ${symbol}.`);
|
|
308
438
|
return data;
|
|
309
439
|
};
|
|
@@ -323,18 +453,26 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
|
|
|
323
453
|
})));
|
|
324
454
|
},
|
|
325
455
|
getQuote(ticker, exchange) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return quote;
|
|
330
|
-
}));
|
|
456
|
+
const quote = findFinancials(ticker, exchange)?.quote;
|
|
457
|
+
if (quote) return resolveShotWork(quote);
|
|
458
|
+
return requestShotMarketData<Quote>("getQuote", [ticker, exchange]);
|
|
331
459
|
},
|
|
460
|
+
// Board panes such as WEI, MOST, and BI quote symbols the shot payload was
|
|
461
|
+
// never built for, and returning null for those drew a full table of
|
|
462
|
+
// dashes. The payload still wins wherever it has the symbol, so mapped
|
|
463
|
+
// capabilities keep rendering exactly the data their evidence checks use.
|
|
332
464
|
getQuotesBatch(targets: QuoteSubscriptionTarget[]) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
465
|
+
const resolved: QuoteBatchResult[] = [];
|
|
466
|
+
const missing: QuoteSubscriptionTarget[] = [];
|
|
467
|
+
for (const target of targets) {
|
|
468
|
+
const quote = findFinancials(target.symbol, target.exchange)?.quote;
|
|
469
|
+
if (quote) resolved.push({ target, quote });
|
|
470
|
+
else missing.push(target);
|
|
471
|
+
}
|
|
472
|
+
if (missing.length === 0) return resolveShotWork(resolved);
|
|
473
|
+
return requestShotMarketData<QuoteBatchResult[]>("getQuotesBatch", [missing])
|
|
474
|
+
.then((results) => [...resolved, ...results])
|
|
475
|
+
.catch(() => [...resolved, ...missing.map((target) => ({ target, quote: null }))]);
|
|
338
476
|
},
|
|
339
477
|
getExchangeRate(fromCurrency: string) {
|
|
340
478
|
// Returning 1 for every currency used to render the FX matrix as a grid
|
|
@@ -373,13 +511,18 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
|
|
|
373
511
|
return resolveShotWork(null);
|
|
374
512
|
},
|
|
375
513
|
getPriceHistory(ticker, exchange, range) {
|
|
514
|
+
const intraday = intradayHistory(ticker, exchange);
|
|
515
|
+
const local = intraday ? intraday.points : findFinancials(ticker, exchange)?.priceHistory;
|
|
516
|
+
// Sector and board panes chart symbols the payload does not carry, and
|
|
517
|
+
// an empty series turned their trailing-return columns into dashes.
|
|
518
|
+
if (!intraday && !local) {
|
|
519
|
+
return requestShotMarketData<PricePoint[]>("getPriceHistory", [ticker, exchange, range])
|
|
520
|
+
.then(revivePricePoints)
|
|
521
|
+
.catch(() => []);
|
|
522
|
+
}
|
|
376
523
|
return trackShotWork(Promise.resolve().then(() => {
|
|
377
|
-
const intraday = intradayHistory(ticker, exchange);
|
|
378
524
|
if (intraday?.unavailableReason) throw new Error(intraday.unavailableReason);
|
|
379
|
-
return clipPriceHistoryToRange(
|
|
380
|
-
intraday ? intraday.points : getFinancials(ticker, exchange).priceHistory ?? [],
|
|
381
|
-
range,
|
|
382
|
-
);
|
|
525
|
+
return clipPriceHistoryToRange(local ?? [], range);
|
|
383
526
|
}));
|
|
384
527
|
},
|
|
385
528
|
getPriceHistoryForResolution(ticker, exchange, bufferRange, resolution) {
|
|
@@ -410,6 +553,23 @@ function createShotDataProvider(payload: CliPaneShotPayload): DataProvider {
|
|
|
410
553
|
getChartResolutionSupport() {
|
|
411
554
|
return resolveShotWork(SHOT_CHART_RESOLUTION_SUPPORT);
|
|
412
555
|
},
|
|
556
|
+
getAnalystResearch(ticker, exchange) {
|
|
557
|
+
return requestShotMarketData<AnalystResearchData>("getAnalystResearch", [ticker, exchange]);
|
|
558
|
+
},
|
|
559
|
+
getCorporateActions(ticker, exchange) {
|
|
560
|
+
return requestShotMarketData<CorporateActionsData>("getCorporateActions", [ticker, exchange]);
|
|
561
|
+
},
|
|
562
|
+
getHolders(ticker, exchange) {
|
|
563
|
+
return requestShotMarketData<HolderData>("getHolders", [ticker, exchange]);
|
|
564
|
+
},
|
|
565
|
+
getSecFilings(ticker, count, exchange) {
|
|
566
|
+
return requestShotMarketData<SecFilingItem[]>("getSecFilings", [ticker, count, exchange])
|
|
567
|
+
.then(reviveSecFilings);
|
|
568
|
+
},
|
|
569
|
+
getEarningsCalendar(symbols) {
|
|
570
|
+
return requestShotMarketData<EarningsEvent[]>("getEarningsCalendar", [symbols])
|
|
571
|
+
.then(reviveEarningsEvents);
|
|
572
|
+
},
|
|
413
573
|
subscribeQuotes() {
|
|
414
574
|
return () => {};
|
|
415
575
|
},
|
|
@@ -684,6 +844,7 @@ async function render() {
|
|
|
684
844
|
revivePayloadDates(payload);
|
|
685
845
|
installShotFetchTracker();
|
|
686
846
|
installShotCloudApiTransport();
|
|
847
|
+
installShotHttpFetchTransport();
|
|
687
848
|
hydrateFredSeries(payload.fredSeries ?? []);
|
|
688
849
|
hydrateValuationSeries(payload.valuationSeries ?? []);
|
|
689
850
|
statsCache.hydrate(payload.statSeries ?? []);
|
package/src/updater.test.ts
CHANGED
|
@@ -25,7 +25,7 @@ afterEach(() => {
|
|
|
25
25
|
|
|
26
26
|
function expectedAssetName(compressed = false): string {
|
|
27
27
|
const os = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
28
|
-
const arch =
|
|
28
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
29
29
|
const extension = os === "windows" ? ".exe" : "";
|
|
30
30
|
return compressed ? `gloomberb-${os}-${arch}${extension}.gz` : `gloomberb-${os}-${arch}${extension}`;
|
|
31
31
|
}
|
|
@@ -37,6 +37,22 @@ describe("getAssetBaseNameForRuntime", () => {
|
|
|
37
37
|
arch: "x64",
|
|
38
38
|
})).toBe("gloomberb-windows-x64.exe");
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
test("keeps macOS arm64 runtimes on the arm64 asset", () => {
|
|
42
|
+
expect(getAssetBaseNameForRuntime({
|
|
43
|
+
platform: "darwin",
|
|
44
|
+
arch: "arm64",
|
|
45
|
+
})).toBe("gloomberb-darwin-arm64");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// An arm64 asset cannot run on an x64 macOS runtime, so it must never be
|
|
49
|
+
// offered as that runtime's update.
|
|
50
|
+
test("asks for an x64 asset on an x64 macOS runtime", () => {
|
|
51
|
+
expect(getAssetBaseNameForRuntime({
|
|
52
|
+
platform: "darwin",
|
|
53
|
+
arch: "x64",
|
|
54
|
+
})).toBe("gloomberb-darwin-x64");
|
|
55
|
+
});
|
|
40
56
|
});
|
|
41
57
|
|
|
42
58
|
describe("detectUpdateAction", () => {
|
package/src/updater.ts
CHANGED
|
@@ -62,8 +62,10 @@ export function getAssetBaseNameForRuntime(
|
|
|
62
62
|
: runtimeProcess?.platform === "win32"
|
|
63
63
|
? "windows"
|
|
64
64
|
: "linux";
|
|
65
|
-
//
|
|
66
|
-
|
|
65
|
+
// Always update to the architecture the current runtime actually is. macOS
|
|
66
|
+
// only publishes arm64 today, so an x64 macOS runtime resolves no asset and
|
|
67
|
+
// reports that instead of overwriting itself with an unrunnable binary.
|
|
68
|
+
const arch = runtimeProcess?.arch === "arm64" ? "arm64" : "x64";
|
|
67
69
|
const extension = os === "windows" ? ".exe" : "";
|
|
68
70
|
return `gloomberb-${os}-${arch}${extension}`;
|
|
69
71
|
}
|