gloomberb 0.19.1 → 0.19.2

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.
Files changed (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -8,12 +8,12 @@ import {
8
8
  DataTableView,
9
9
  EmptyState, FigureList, PaneStatusBody, Prose, QueryBar, READING_WIDTH, SectionHeading,
10
10
  usePaneFooter,
11
- usePaneNoticeFooter,
12
11
  type DataTableCell,
13
12
  type DataTableColumn,
14
13
  type PaneFooterSegment,
15
14
  type PaneHint,
16
15
  } from "../../../components";
16
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
17
17
  import { useAsyncResource } from "../../../react/async-resource";
18
18
  import { usePaneStateValue } from "../../../state/app/context";
19
19
  import { colors, getChartIndicatorColor } from "../../../theme/colors";
@@ -45,13 +45,6 @@ import {
45
45
 
46
46
  export const EXECUTIVES_PANE_ID = "executives";
47
47
 
48
- function retainedDataNotice(subject: string, error: string, fetchedAt: number | null): string {
49
- const retrieved = new Date(fetchedAt ?? Number.NaN);
50
- const age = Number.isFinite(retrieved.getTime())
51
- ? `retrieved ${retrieved.toISOString()}` : "with unavailable retrieval time";
52
- return `${subject} refresh failed: ${error}. Retained data ${age}.`;
53
- }
54
-
55
48
  const PAY_PARTS: Array<{ key: keyof CloudExecutiveRowPayload; label: string }> =
56
49
  [
57
50
  { key: "salary", label: "Salary" },
@@ -301,18 +294,10 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
301
294
  // The free preview: each executive's total pay; the breakdown and earlier years are Pro.
302
295
  const preview = statement?.access === "preview" || list.data?.data?.access === "preview";
303
296
  const loading = list.loading || detail.loading;
304
- const listError = list.error ?? list.data?.refreshError;
305
- const statementError = detail.error ?? detail.data?.refreshError;
306
- usePaneNoticeFooter({
307
- registrationId: `${EXECUTIVES_PANE_ID}:data-notices`,
308
- notices: [
309
- list.data?.data && listError
310
- ? retainedDataNotice("Proxy list", listError, list.data.fetchedAt) : null,
311
- detail.data?.data && statementError
312
- ? retainedDataNotice(`${year} proxy`, statementError, detail.data.fetchedAt) : null,
313
- ].filter((notice): notice is string => notice !== null),
314
- focused,
315
- });
297
+ // A copy saved earlier answering a failed refresh says when it was saved, in the footer every pane uses;
298
+ // a failed refresh with only what is on screen says why. Without anything shown, the body says it.
299
+ const listError = list.data?.data ? list.error ?? (list.data.savedAt == null ? list.data.refreshError : null) ?? null : null;
300
+ const statementError = detail.data?.data ? detail.error ?? (detail.data.savedAt == null ? detail.data.refreshError : null) ?? null : null;
316
301
  const refresh = useCallback(() => {
317
302
  void list.reload();
318
303
  if (year !== null) void detail.reload();
@@ -338,6 +323,9 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
338
323
  const info: PaneFooterSegment[] = [];
339
324
  if (loading)
340
325
  info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
326
+ if (listError) info.push({ id: "list-error", parts: [{ text: `Proxy list: ${listError}`, tone: "warning" }] });
327
+ if (statementError) info.push({ id: "statement-error", parts: [{ text: `${year} proxy: ${statementError}`, tone: "warning" }] });
328
+ info.push(...savedCopyFooterInfo([list.data?.data ? list.data.savedAt : null, statement ? detail.data?.savedAt : null]));
341
329
  if (statement) {
342
330
  info.push({
343
331
  id: "filed",
@@ -351,7 +339,7 @@ function ExecutiveResearch({ ticker, listing, tier, focused, width, nested, guar
351
339
  if (yearHint) hints.push(yearHint);
352
340
  if (preview) hints.push({ id: "upgrade", key: CLOUD_PLAN_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: openUpgrade });
353
341
  return { info, hints };
354
- }, [loading, statement, openFiling, yearHint, preview, openUpgrade]);
342
+ }, [loading, listError, statementError, year, list.data, detail.data, statement, openFiling, yearHint, preview, openUpgrade]);
355
343
 
356
344
  const figures = useMemo(
357
345
  () => (statement ? figuresOf(statement) : []),
@@ -35,6 +35,10 @@ export type FearGreedCacheEntry = {
35
35
  export interface FearGreedLoadResult extends FearGreedCacheEntry {
36
36
  /** Set when the network failed and cached data was served instead. */
37
37
  refreshError?: string;
38
+ /** `stale-fallback` when a saved copy answered a failed refresh. */
39
+ source?: "cache" | "network" | "stale-fallback";
40
+ /** The failure a `stale-fallback` copy answered. */
41
+ error?: unknown;
38
42
  }
39
43
 
40
44
  const cache = createPluginCache<FearGreedData, PersistedFearGreedData>({
@@ -1,5 +1,6 @@
1
1
  import type { SeriesDefinition, SeriesLoadResult, SeriesPoint } from "../../../series/types";
2
- import { cachedSeriesWarning } from "../shared/series-warning";
2
+ import { savedCopyForTransientFailure } from "../../../data/plugin-cache";
3
+ import { cachedSeriesNote } from "../shared/series-warning";
3
4
  import { loadFearGreed } from "./cache";
4
5
  import type { FearGreedData } from "./data";
5
6
 
@@ -30,9 +31,10 @@ function series(id: string, name: string, unit: string, read: Reading, tags: rea
30
31
  tags: ["fear and greed", ...tags],
31
32
  refreshMs: REFRESH_MS,
32
33
  load: async (): Promise<SeriesLoadResult> => {
33
- const result = await loadFearGreed();
34
- const warning = cachedSeriesWarning(result);
35
- return { points: read(result.data), ...(warning ? { warning } : {}) };
34
+ // A saved copy answers only a failure asking again may get past; a refusal is the chart's error.
35
+ const result = savedCopyForTransientFailure(await loadFearGreed());
36
+ return { points: read(result.data),
37
+ ...cachedSeriesNote({ ...result, ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}) }) };
36
38
  },
37
39
  };
38
40
  }
@@ -1,4 +1,5 @@
1
1
  import { resolvePlanAccess } from "../../../api-client/plan-rules";
2
+ import { sourceDidNotAnswer } from "../../../api-client/errors";
2
3
  import { apiClient } from "../../../api-client";
3
4
  import type { GpuBoardPayload, GpuEventsPayload, GpuHistoryPayload, GpuHistoryQuery, GpuObservation } from "../../../api-client/gpu";
4
5
  import { createPluginCache } from "../../../data/plugin-cache";
@@ -94,7 +95,9 @@ export async function fetchGpuBoard(client: Pick<GpuApi, "getCloudGpuBoard"> = a
94
95
  try { payload = await client.getCloudGpuBoard(); }
95
96
  catch (error) { throw unavailableOnServer(error, GPU_NOT_AVAILABLE, [404, 503]); }
96
97
  const board = validateBoard(payload);
97
- if (board.status === "unavailable" || !board.rows.length) throw new Error(GPU_NOT_AVAILABLE);
98
+ // The server's source did not answer this time: a saved copy answers, as it does a 5xx.
99
+ if (board.status === "unavailable") throw sourceDidNotAnswer(GPU_NOT_AVAILABLE);
100
+ if (!board.rows.length) throw new Error(GPU_NOT_AVAILABLE);
98
101
  return board;
99
102
  }
100
103
 
@@ -1,5 +1,5 @@
1
1
  import type { SeriesDefinition } from "../../../series/types";
2
- import { cachedSeriesWarning } from "../shared/series-warning";
2
+ import { cachedSeriesNote } from "../shared/series-warning";
3
3
  import { loadGpuBoard, loadGpuHistory } from "./client";
4
4
  import { GPU_MODELS, gpuListIndex } from "./model";
5
5
 
@@ -27,12 +27,11 @@ export const GPU_SERIES: readonly SeriesDefinition[] = GPU_MODELS.map((model): S
27
27
  if (!index) throw new Error(`No ${model} list index is listed yet.`);
28
28
  const history = await loadGpuHistory(index.id);
29
29
  if (history.payload.access?.locked) throw new Error(GPU_PRO_ONLY);
30
- const warning = cachedSeriesWarning(history);
31
30
  return {
32
31
  points: history.payload.points
33
32
  .filter((point) => `${point.source}:${point.skuKey}` === index.id)
34
33
  .map((point) => ({ date: point.observedAt, value: point.pricePerGpuHr })),
35
- ...(warning ? { warning } : {}),
34
+ ...cachedSeriesNote(history),
36
35
  };
37
36
  },
38
37
  }));
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../../components";
12
12
  import { useCompanyDataFooter } from "../../../components/layout/pane/status-footer";
13
13
  import { handleRefreshKey, loadingErrorFooterInfo } from "../../../components/data-table/table-pane";
14
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
14
15
  import type { IssuerListingParams } from "../../../api-client/paths";
15
16
  import { useAsyncResource } from "../../../react/async-resource";
16
17
  import { useAutoRefresh } from "../../../react/auto-refresh";
@@ -43,8 +44,6 @@ import {
43
44
  import type { Holder13FMatch } from "./thirteenf-match";
44
45
  import type { HolderRow } from "./types";
45
46
 
46
- const STALE_SEGMENT = { id: "stale", parts: [{ text: "stale", tone: "warning" as const }] };
47
-
48
47
  /**
49
48
  * A table line: a filer's row, or the line beneath it giving the same shares
50
49
  * against today's count when the report's base is no longer today's.
@@ -1,4 +1,5 @@
1
1
  import { apiClient } from "../../../api-client";
2
+ import { sourceDidNotAnswer } from "../../../api-client/errors";
2
3
  import type { JodiBoardPayload } from "../../../api-client/commodity-balances";
3
4
  import { unavailableOnServer } from "../shared/cloud-resource";
4
5
  import type { BalanceCommodity, Board } from "./model";
@@ -32,7 +33,9 @@ function readBoard(payload: JodiBoardPayload): Balances {
32
33
  || (payload.gas != null && !validBoard(payload.gas, "gas"))) {
33
34
  throw new Error("The server returned an invalid oil and gas balance board");
34
35
  }
35
- if (payload.status === "unavailable" || payload.oil == null) throw new Error(JODI_NOT_AVAILABLE);
36
+ // The server's source did not answer this time, which may pass; a board with no oil has none yet.
37
+ if (payload.status === "unavailable") throw sourceDidNotAnswer(JODI_NOT_AVAILABLE);
38
+ if (payload.oil == null) throw new Error(JODI_NOT_AVAILABLE);
36
39
  if (!validBoard(payload.oil, "oil")) throw new Error("The server returned an invalid oil and gas balance board");
37
40
  return { oil: payload.oil, gas: payload.gas ?? null, stale: payload.stale === true, gaps: payload.gaps };
38
41
  }
@@ -119,6 +119,7 @@ export async function loadMarketMoverTab(
119
119
  ...hydrated,
120
120
  source: "gloom",
121
121
  stale: trending.stale || !!hydrated.unavailableSymbols?.length,
122
+ ...(trending.savedAt != null ? { savedAt: trending.savedAt } : {}),
122
123
  };
123
124
  }
124
125
  const category = CATEGORY_MAP[tab];
@@ -2,6 +2,8 @@ import { Box, useUiCapabilities } from "../../../ui";
2
2
  import { nextHeaderSort } from "../../../utils/sort-values";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { DataTableView, EmptyState, QueryBar, usePaneFooter, usePaneTabs, type DataTableKeyEvent } from "../../../components";
5
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
6
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
5
7
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
6
8
  import type { PaneProps } from "../../../types/plugin";
7
9
  import type { PluginModule } from "../plugin-module";
@@ -167,6 +169,8 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
167
169
  const [selectedSymbol, setSelectedSymbol] = useState<string | null>(null);
168
170
  const [sortPreference, setSortPreference] = useState<MarketMoverSortPreference>(DEFAULT_SORT_PREFERENCE);
169
171
  const [moversStale, setMoversStale] = useState(false);
172
+ // The list's refresh failed and a copy saved at this time (ms) answered instead.
173
+ const [moversSavedAt, setMoversSavedAt] = useState<number | null>(null);
170
174
  const [lastSuccess, setLastSuccess] = useState<{ key: string; at: number } | null>(null);
171
175
 
172
176
  const fetchGenRef = useRef(0);
@@ -241,11 +245,13 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
241
245
  if (!options?.background) setSelectedSymbol(null);
242
246
  }
243
247
  setMoversStale(result.stale);
248
+ setMoversSavedAt(result.savedAt ?? null);
244
249
  setLoadError(quoteError);
245
250
  if (!quoteError && !result.stale) setLastSuccess({ key: `${tab}:${minFloat ?? ""}`, at: Date.now() });
246
251
  } catch {
247
252
  if (fetchGenRef.current !== gen) return;
248
253
  setMoversStale(true);
254
+ setMoversSavedAt(null);
249
255
  setLoadError("Market movers temporarily unavailable");
250
256
  }
251
257
  finally {
@@ -288,14 +294,13 @@ function ScreenerMoversBody({ activeTab, focused, width, summaryQuotes, liveStre
288
294
  id: "feed",
289
295
  parts: [{ text: feedStatus, tone: feedStatus === "live" ? "value" as const : "muted" as const }],
290
296
  }] : []),
291
- ...(moversStale && loadedKey === listKey ? [{
292
- id: "stale",
293
- parts: [{ text: "stale", tone: "muted" as const }],
294
- }] : []),
297
+ // A saved copy answering a failed refresh says when it was saved; other stale rows say only that.
298
+ ...(loadedKey === listKey ? savedCopyFooterInfo([moversSavedAt]) : []),
299
+ ...(moversStale && moversSavedAt == null && loadedKey === listKey ? [STALE_SEGMENT] : []),
295
300
  // The FLOAT column shows while a minimum is set; a float the source does not date says so once.
296
301
  ...partialFloatFooter(columns.some((column) => column.id === "floatShares"), rows),
297
302
  ],
298
- }), [columns, feedStatus, listKey, loadedKey, loadError, loading, moversStale, rows, summaryQuotes]);
303
+ }), [columns, feedStatus, listKey, loadedKey, loadError, loading, moversStale, moversSavedAt, rows, summaryQuotes]);
299
304
 
300
305
  return (
301
306
  <DataTableView<MarketMoverRow, MarketMoverColumn>
@@ -5,6 +5,7 @@ import { logFailure } from "../../../utils/log-failure";
5
5
  import { savedCopyMark, servedFromSavedCopy } from "../../../data/saved-copies";
6
6
  import type { PluginPersistence } from "../../../types/plugin";
7
7
  import { apiClient } from "../../../api-client";
8
+ import { isTransientFailure, sourceDidNotAnswer } from "../../../api-client/errors";
8
9
  import type {
9
10
  CloudMarketResponse,
10
11
  CloudMarketScreenerCategory,
@@ -96,6 +97,8 @@ function writeCache<T>(key: string, data: T): void {
96
97
  export interface CachedResult<T> {
97
98
  data: T;
98
99
  stale: boolean;
100
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
101
+ savedAt?: number;
99
102
  }
100
103
 
101
104
  async function loadCached<T>(
@@ -119,11 +122,13 @@ async function loadCached<T>(
119
122
  return result;
120
123
  })
121
124
  .catch((error) => {
122
- // Serving the expired copy is right; hiding that it is expired is not. A command that shows it
123
- // ends with when the copy was saved.
124
- if (fallback) {
125
- failedFetches.add(key);
126
- return servedFromSavedCopy({ data: fallback.data, stale: true }, fallback.fetchedAt);
125
+ // The next read asks again rather than read the saved copy as current.
126
+ if (fallback) failedFetches.add(key);
127
+ // Serving the expired copy is right for a failure asking again may get past; hiding that it is
128
+ // expired is not. A command that shows it ends with when the copy was saved. A refusal or a
129
+ // reply that fails validation is the error, saved copy or not.
130
+ if (fallback && isTransientFailure(error)) {
131
+ return servedFromSavedCopy({ data: fallback.data, stale: true, savedAt: fallback.fetchedAt }, fallback.fetchedAt);
127
132
  }
128
133
  throw error;
129
134
  })
@@ -146,6 +151,8 @@ export interface MarketMoversResult {
146
151
  quotes: ScreenerQuote[];
147
152
  source: MarketMoversDataSource;
148
153
  stale: boolean;
154
+ /** Its refresh failed and the list saved at this time (ms) answered instead. */
155
+ savedAt?: number;
149
156
  }
150
157
 
151
158
  export interface PreferredMarketMoverSources {
@@ -268,6 +275,8 @@ export async function fetchScreenerResult(
268
275
  const session = category === "most_actives" ? undefined : options?.session;
269
276
  const load = async () => {
270
277
  const response = await api.getMarketMovers(category, count, options?.forceRefresh, session);
278
+ // The service's own source did not answer this time: a saved list answers, as it does a 5xx.
279
+ if (!response.data && response.status === "retryable_error") throw sourceDidNotAnswer("Market movers temporarily unavailable");
271
280
  if (!response.data) throw new Error("Invalid market movers response");
272
281
  return { data: parseScreenerResponse(response.data), stale: response.stale === true || response.data.stale === true };
273
282
  };
@@ -392,7 +401,13 @@ export async function fetchPreferredMarketMovers(
392
401
  const eligibility = sources.isCloudEligible();
393
402
  if (!(typeof eligibility === "boolean" ? eligibility : await eligibility)) {
394
403
  const result = await sources.fetchMarket(category, count, options);
395
- return { ...savedCopyMark(result), quotes: rankScreenerQuotes(category, result.data), source: "gloom", stale: result.stale };
404
+ return {
405
+ ...savedCopyMark(result),
406
+ quotes: rankScreenerQuotes(category, result.data),
407
+ source: "gloom",
408
+ stale: result.stale,
409
+ ...(result.savedAt != null ? { savedAt: result.savedAt } : {}),
410
+ };
396
411
  }
397
412
 
398
413
  const marketMetadata = sources.fetchMarket(
@@ -432,6 +447,7 @@ export async function fetchPreferredMarketMovers(
432
447
  quotes: rankScreenerQuotes(category, fallback.data),
433
448
  source: "gloom",
434
449
  stale: fallback.stale,
450
+ ...(fallback.savedAt != null ? { savedAt: fallback.savedAt } : {}),
435
451
  };
436
452
  }
437
453
 
@@ -448,6 +464,7 @@ export async function fetchTrendingResult(
448
464
  const load = async () => {
449
465
  const response = await (api.getMarketTrending ?? apiClient.getMarketTrending)(count);
450
466
  if (response.status === "empty") return { data: [], stale: response.stale === true };
467
+ if (!response.data && response.status === "retryable_error") throw sourceDidNotAnswer("Market trends temporarily unavailable");
451
468
  if (!response.data) throw new Error("Market trends unavailable");
452
469
  return { data: parseTrendingResponse(response.data), stale: response.stale === true };
453
470
  };
@@ -6,6 +6,7 @@ import type {
6
6
  } from "../../../api-client/market-movers";
7
7
  import { usePlanAccess } from "../../../api-client/plan-access";
8
8
  import { DataTableView, EmptyState, QueryBar, usePaneFooter, type DataTableKeyEvent } from "../../../components";
9
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
9
10
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
10
11
  import {
11
12
  buildScreenerQuoteTargets,
@@ -201,7 +202,7 @@ function SessionMoversTable({ view, session, focused, width, summaryQuotes, live
201
202
  id: "feed",
202
203
  parts: [{ text: feedStatus, tone: feedStatus === "live" ? "value" as const : "muted" as const }],
203
204
  }] : []),
204
- ...(payload?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "muted" as const }] }] : []),
205
+ ...(payload?.stale ? [STALE_SEGMENT] : []),
205
206
  // A float the source does not date says so once, while the FLOAT column shows.
206
207
  ...partialFloatFooter(columns.some((column) => column.id === "floatShares"), rows),
207
208
  ],
@@ -1,6 +1,6 @@
1
1
  import type { MoneyMarketObservation } from "../../../api-client/money-markets";
2
2
  import type { SeriesDefinition } from "../../../series/types";
3
- import { cachedSeriesWarning } from "../shared/series-warning";
3
+ import { cachedSeriesNote } from "../shared/series-warning";
4
4
  import { loadMoneyMarkets } from "./client";
5
5
 
6
6
  function observations(history: readonly MoneyMarketObservation[]) {
@@ -21,8 +21,7 @@ export const MONEY_MARKET_SERIES: readonly SeriesDefinition[] = [
21
21
  tags: ["liquidity", "Fed balance sheet", "TGA", "RRP", "money markets"],
22
22
  load: async () => {
23
23
  const resource = await loadMoneyMarkets();
24
- const warning = cachedSeriesWarning(resource);
25
- return { points: observations(resource.payload.netLiquidity.history), ...(warning ? { warning } : {}) };
24
+ return { points: observations(resource.payload.netLiquidity.history), ...cachedSeriesNote(resource) };
26
25
  },
27
26
  },
28
27
  {
@@ -34,8 +33,7 @@ export const MONEY_MARKET_SERIES: readonly SeriesDefinition[] = [
34
33
  tags: ["bills", "curve", "money markets"],
35
34
  load: async () => {
36
35
  const resource = await loadMoneyMarkets();
37
- const warning = cachedSeriesWarning(resource);
38
- return { points: observations(resource.payload.billsCurve.slope.history), ...(warning ? { warning } : {}) };
36
+ return { points: observations(resource.payload.billsCurve.slope.history), ...cachedSeriesNote(resource) };
39
37
  },
40
38
  },
41
39
  ];
@@ -1,4 +1,5 @@
1
1
  import { apiClient } from "../../../api-client";
2
+ import { sourceDidNotAnswer } from "../../../api-client/errors";
2
3
  import type { GasStorageBoard, GasStorageHistory, GasStorageRow, GasStorageSeasonal } from "../../../api-client/europe-energy";
3
4
  import { createPluginCache } from "../../../data/plugin-cache";
4
5
  import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
@@ -77,7 +78,7 @@ export async function fetchGasStorage(client: Pick<GasApi, "getCloudEuropeGas">
77
78
  } catch (error) {
78
79
  throw unavailableOnServer(error, GAS_STORAGE_NOT_AVAILABLE, [404, 503]);
79
80
  }
80
- if (board.status === "unavailable") throw new Error(GAS_STORAGE_NOT_AVAILABLE);
81
+ if (board.status === "unavailable") throw sourceDidNotAnswer(GAS_STORAGE_NOT_AVAILABLE);
81
82
  return board;
82
83
  }
83
84
 
@@ -1,4 +1,5 @@
1
1
  import { apiClient } from "../../../api-client";
2
+ import { sourceDidNotAnswer } from "../../../api-client/errors";
2
3
  import type { CarbonBoard, CarbonHistory, EuropePowerBoard, EuropePowerHistory, PowerPriceRow } from "../../../api-client/europe-energy";
3
4
  import { createPluginCache } from "../../../data/plugin-cache";
4
5
  import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
@@ -87,7 +88,7 @@ export async function fetchPowerPrices(client: Pick<PricesApi, "getCloudEuropePo
87
88
  } catch (error) {
88
89
  throw unavailableOnServer(error, POWER_PRICES_NOT_AVAILABLE, [404, 503]);
89
90
  }
90
- if (board.status === "unavailable") throw new Error(POWER_PRICES_NOT_AVAILABLE);
91
+ if (board.status === "unavailable") throw sourceDidNotAnswer(POWER_PRICES_NOT_AVAILABLE);
91
92
  return board;
92
93
  }
93
94
 
@@ -110,7 +111,7 @@ export async function fetchCarbonPrice(client: Pick<PricesApi, "getCloudEuropeCa
110
111
  } catch (error) {
111
112
  throw unavailableOnServer(error, CARBON_PRICE_NOT_AVAILABLE, [404, 503]);
112
113
  }
113
- if (board.status === "unavailable") throw new Error(CARBON_PRICE_NOT_AVAILABLE);
114
+ if (board.status === "unavailable") throw sourceDidNotAnswer(CARBON_PRICE_NOT_AVAILABLE);
114
115
  return board;
115
116
  }
116
117
 
@@ -3,7 +3,7 @@ import {
3
3
  type CloudRiskReportListPayload,
4
4
  type CloudRiskReportPayload,
5
5
  } from "../../../api-client";
6
- import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
6
+ import { ApiRequestError, isPermanentClientError, isTransientFailure } from "../../../api-client/errors";
7
7
  import { listingAbroad, type IssuerListingParams } from "../../../api-client/paths";
8
8
  import { servedFromSavedCopy } from "../../../data/saved-copies";
9
9
  import type { HeadlessPaneApiClient, PluginPersistence } from "../../../types/plugin";
@@ -21,6 +21,8 @@ type RiskApiClient = Pick<HeadlessPaneApiClient, "getRiskReports" | "getRiskRepo
21
21
  interface RiskFreshness {
22
22
  fetchedAt: number;
23
23
  stale: boolean;
24
+ /** Its refresh failed and the copy saved at this time (ms) answered instead. */
25
+ savedAt?: number;
24
26
  refreshError?: string;
25
27
  errorStatus?: number;
26
28
  }
@@ -72,9 +74,10 @@ function loadCached<T extends object>(
72
74
  // A failed forced refresh must be retried when this pane is reopened, even
73
75
  // when the previously cached value has not reached its ordinary TTL yet.
74
76
  if (current()) failedRefreshes.set(failedKey, store);
75
- // A missing or denied resource must not be replaced by previously cached content.
76
- if (isPermanentClientError(error)) {
77
- if (current()) store?.deleteResource(kind, key, { sourceKey: CACHE_SOURCE });
77
+ // A saved copy answers only for a failure asking again may get past: a refusal or a reply that
78
+ // fails validation is the error, and a missing or denied resource also drops its copy.
79
+ if (!isTransientFailure(error)) {
80
+ if (isPermanentClientError(error) && current()) store?.deleteResource(kind, key, { sourceKey: CACHE_SOURCE });
78
81
  throw error;
79
82
  }
80
83
  const fallback = store?.getResource<T>(kind, key, { ...options, allowExpired: true });
@@ -84,6 +87,7 @@ function loadCached<T extends object>(
84
87
  ...fallback.value,
85
88
  fetchedAt: fallback.fetchedAt,
86
89
  stale: true,
90
+ savedAt: fallback.fetchedAt,
87
91
  refreshError: (error instanceof Error ? error.message : String(error)).trim() || "Request failed",
88
92
  errorStatus: error instanceof ApiRequestError ? error.status : undefined,
89
93
  }, fallback.fetchedAt);
@@ -21,7 +21,8 @@ const description =
21
21
  * filing list, which needs no account: `filings` lists them with a link to
22
22
  * each, where Item 1A is the risk factors as written, without RISK's grouping
23
23
  * or its changes since the year before. A foreign issuer files a 20-F instead
24
- * (ASML), whose Item 3.D holds them.
24
+ * (ASML), whose Item 3.D holds them. A company that files neither (IAG on
25
+ * LSE) has nothing in RISK either, and the note says so.
25
26
  */
26
27
  function riskFactorsLockNote(lock: PaneLock): Promise<string> {
27
28
  return proLockNote(lock, {
@@ -35,6 +36,7 @@ function riskFactorsLockNote(lock: PaneLock): Promise<string> {
35
36
  text: (ticker, company) => `gloom filings ${ticker} --form 20-F lists ${company}'s annual reports, each with its filing date and a link to the filing, whose Item 3.D holds the risk factors as written.`,
36
37
  answers: (symbol, context) => filesForm(symbol, "20-F", context),
37
38
  }],
39
+ noneFor: (ticker) => `${ticker}'s company files no 10-K or 20-F with the SEC, so RISK has no risk factors for it.`,
38
40
  });
39
41
  }
40
42
 
@@ -8,6 +8,7 @@ import {
8
8
  type PaneFooterSegment,
9
9
  type PaneHint,
10
10
  } from "../../../components";
11
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
11
12
  import { useAsyncResource } from "../../../react/async-resource";
12
13
  import { colors } from "../../../theme/colors";
13
14
  import {
@@ -119,8 +120,9 @@ function RiskFactorsReader({
119
120
  const reportLoader = useCallback((force: boolean) => guard(loadRiskReport(ticker!, year!, { force, listing })), [guard, listing, ticker, year]);
120
121
  const detail = useAsyncResource(ticker && year !== null ? reportLoader : null, { clearOnError: isPermanentClientError });
121
122
  const report = detail.data;
122
- const listError = list.error ?? list.data?.refreshError ?? null;
123
- const reportError = detail.error ?? report?.refreshError ?? null;
123
+ // A copy saved earlier answering a failed refresh says when it was saved, in the footer every pane uses, not as an error.
124
+ const listError = list.error ?? (list.data?.savedAt == null ? list.data?.refreshError : null) ?? null;
125
+ const reportError = detail.error ?? (report?.savedAt == null ? report?.refreshError : null) ?? null;
124
126
  const refresh = useCallback(() => {
125
127
  void list.reload();
126
128
  if (year !== null) void detail.reload();
@@ -151,12 +153,7 @@ function RiskFactorsReader({
151
153
  info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
152
154
  if (listError) info.push({ id: "list-error", parts: [{ text: `Report list: ${listError}`, tone: "warning" }] });
153
155
  if (reportError) info.push({ id: "report-error", parts: [{ text: `${year} report: ${reportError}`, tone: "warning" }] });
154
- if ((listError || list.data?.stale) && list.data) {
155
- info.push({ id: "list-stale", parts: [{ text: `List cached ${new Date(list.data.fetchedAt).toISOString()}`, tone: "muted" }] });
156
- }
157
- if ((reportError || report?.stale) && report) {
158
- info.push({ id: "report-stale", parts: [{ text: `Report cached ${new Date(report.fetchedAt).toISOString()}`, tone: "muted" }] });
159
- }
156
+ info.push(...savedCopyFooterInfo([list.data?.savedAt, report?.savedAt]));
160
157
  const hints: PaneHint[] = report?.docUrl
161
158
  ? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }]
162
159
  : [];
@@ -1,12 +1,34 @@
1
1
  import type { PaneLock } from "../../../types/plugin";
2
- import { proLockNote } from "../shared/pro-lock-note";
2
+ import { isOptionsUnavailableError } from "../../../market-data/options-alternatives";
3
+ import { resolveHeadlessInstrument } from "../shared/headless-market-data";
4
+ import { proLockNote, type FreeRoute } from "../shared/pro-lock-note";
5
+
6
+ /**
7
+ * The listing's option chain, which needs no account, where options are
8
+ * served for it: LHA:XETRA has none, and `options LHA:XETRA` says so.
9
+ */
10
+ const OPTIONS_ROUTE: FreeRoute = {
11
+ text: (ticker, company) => `gloom options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.`,
12
+ answers: async (symbol, context) => {
13
+ const { symbol: ticker, exchange } = await resolveHeadlessInstrument(context, symbol);
14
+ if (!context.marketData.getOptionsChain) return false;
15
+ try {
16
+ const chain = await context.marketData.getOptionsChain(ticker, exchange ?? "");
17
+ return chain.expirationDates.length + chain.calls.length + chain.puts.length > 0;
18
+ } catch (error) {
19
+ if (isOptionsUnavailableError(error)) return false;
20
+ throw error;
21
+ }
22
+ },
23
+ };
3
24
 
4
25
  /**
5
26
  * FLOW is Pro whole: the one scanner with no delayed tier to fall back to.
6
27
  * The options chain needs no account and lists each contract's day volume and
7
28
  * open interest, the raw counts behind a sweep or a block, without the prints.
8
29
  * FLOW takes a ticker only to narrow the market to one name, so the note
9
- * repeats it only when the reader gave one.
30
+ * repeats it only when the reader gave one, and only when the listing has
31
+ * options served: FLOW's prints are of the same US options.
10
32
  */
11
33
  export function flowLockNote(lock: PaneLock): Promise<string> {
12
34
  return proLockNote(lock, {
@@ -14,6 +36,7 @@ export function flowLockNote(lock: PaneLock): Promise<string> {
14
36
  argument: "optional",
15
37
  does: "it streams real-time sweeps, blocks and large premium prints across US options exchanges",
16
38
  shows: "it",
17
- free: [{ text: (ticker, company) => `gloom options ${ticker} lists ${company}'s option chain, each contract with its volume and open interest.` }],
39
+ free: [OPTIONS_ROUTE],
40
+ noneFor: (ticker) => `Options are not available for ${ticker}, so FLOW has no prints of it.`,
18
41
  });
19
42
  }
@@ -1,6 +1,6 @@
1
1
  import { logFailure } from "../../../utils/log-failure";
2
- import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
3
- import type { PluginCacheResult } from "../../../data/plugin-cache";
2
+ import { ApiRequestError } from "../../../api-client/errors";
3
+ import { savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
4
4
 
5
5
  interface CloudCache<T> {
6
6
  get(key: string, options?: { allowExpired?: boolean }): PluginCacheResult<T> | null;
@@ -38,9 +38,11 @@ const isAbort = (error: unknown) => !!error && typeof error === "object" && (err
38
38
 
39
39
  /**
40
40
  * Loads through the cache, falling back to the cached copy when a refresh
41
- * fails. A refusal asking again will not change still throws: a refused
42
- * session, so the pane shows its sign-in wall instead of data the account can
43
- * no longer see, and any other permanent client error (an unknown series).
41
+ * fails for a reason asking again may get past (`isTransientFailure`). A
42
+ * refusal still throws: a refused session, so the pane shows its sign-in wall
43
+ * instead of data the account can no longer see, any other permanent client
44
+ * error (an unknown series, a route not served), and a reply that fails
45
+ * validation.
44
46
  *
45
47
  * Callers of one key share the request in flight, which runs on the first
46
48
  * caller's `signal`. When that caller goes away (a tab left while it loads,
@@ -60,8 +62,7 @@ export async function loadCloudResource<T>(
60
62
  throw error;
61
63
  });
62
64
  if (!result || cancelledByOther(result.error)) result = await cache.load(key, fetch, { force });
63
- const saved = result.source === "stale-fallback";
64
- if (saved && isPermanentClientError(result.error)) throw result.error;
65
+ const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
65
66
  // A copy served because its refresh failed is one by where it came from, whatever the failure said (or did not).
66
67
  return {
67
68
  payload: validate(result.data), stale: result.stale, refreshError: result.refreshError ?? null,
@@ -72,7 +73,11 @@ export async function loadCloudResource<T>(
72
73
  /**
73
74
  * Turns a route the server does not serve yet (404, or the given statuses)
74
75
  * into a message that says what is unavailable; other errors pass through.
76
+ * The status stays, so a 404 is still a refusal no saved copy answers for and
77
+ * a 503 still a failure one may.
75
78
  */
76
79
  export function unavailableOnServer(error: unknown, message: string, statuses: readonly number[] = [404]): unknown {
77
- return error instanceof ApiRequestError && statuses.includes(error.status ?? 0) ? new Error(message) : error;
80
+ return error instanceof ApiRequestError && statuses.includes(error.status ?? 0)
81
+ ? new ApiRequestError(message, error.status, error.retryAfterMs, error.code, error.details)
82
+ : error;
78
83
  }
@@ -1,11 +1,15 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import { loadCachedFredSeries } from "../../../sources/gloomberb-cloud/fred-series";
3
3
 
4
- /** A FRED series from `start` on, oldest first, through the shared FRED cache. */
4
+ /**
5
+ * A FRED series from `start` on, oldest first, through the shared FRED cache;
6
+ * `savedAt` when a failed refresh left the copy saved then answering.
7
+ */
5
8
  export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
6
9
  points: Array<{ date: string; value: number | null }>;
7
10
  stale: boolean;
8
11
  refreshError?: string;
12
+ savedAt?: number;
9
13
  }> {
10
14
  const startDate = start ? start.toISOString().slice(0, 10) : "1900-01-01";
11
15
  const result = await loadCachedFredSeries(
@@ -16,5 +20,6 @@ export async function loadFredPoints(seriesId: string, start?: Date): Promise<{
16
20
  points: (result.data.observations ?? []).map((row) => ({ date: row.date, value: row.value })),
17
21
  stale: result.stale,
18
22
  ...(result.refreshError ? { refreshError: result.refreshError } : {}),
23
+ ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}),
19
24
  };
20
25
  }