@gloom-sh/terminal 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
@@ -70,19 +70,23 @@ async function routeAnswers(route: FreeRoute, check: NonNullable<FreeRoute["answ
70
70
  * symbol the routes read `<ticker>` and the first is named; without a way to
71
71
  * read data, only a route that answers for every symbol is.
72
72
  */
73
- async function answeringFreeRoute(lock: PaneLock, routes: readonly FreeRoute[]): Promise<FreeRoute | null> {
73
+ async function answeringFreeRoute(lock: PaneLock, routes: readonly FreeRoute[]): Promise<{ route: FreeRoute | null; noneHasData: boolean }> {
74
74
  const { symbol, context } = lock;
75
- if (!symbol) return routes[0] ?? null;
75
+ if (!symbol) return { route: routes[0] ?? null, noneHasData: false };
76
76
  // Routes past the first that needs no check are never named, so they are not checked.
77
77
  const unchecked = routes.findIndex((route) => !route.answers);
78
78
  const candidates = unchecked < 0 ? routes : routes.slice(0, unchecked + 1);
79
79
  const deadline = Date.now() + ROUTE_CHECK_WAIT_MS;
80
80
  const checks = candidates.map((route) => !route.answers ? Promise.resolve(true)
81
- : context ? routeAnswers(route, route.answers, symbol, context, deadline) : Promise.resolve(false));
81
+ : context ? routeAnswers(route, route.answers, symbol, context, deadline) : Promise.resolve(null));
82
+ const answered: Array<boolean | null> = [];
82
83
  for (const [index, check] of checks.entries()) {
83
- if (await check) return candidates[index]!;
84
+ const answer = await check;
85
+ if (answer) return { route: candidates[index]!, noneHasData: false };
86
+ answered.push(answer);
84
87
  }
85
- return null;
88
+ // Every check answered no: a failed or stalled one is not known to.
89
+ return { route: null, noneHasData: candidates.length > 0 && answered.every((answer) => answer === false) };
86
90
  }
87
91
 
88
92
  /**
@@ -114,6 +118,14 @@ export async function proLockNote(
114
118
  * command repeats the ticker only when the reader gave one. Default `ticker`.
115
119
  */
116
120
  argument?: "ticker" | "optional" | "none";
121
+ /**
122
+ * For a function that reads what its free routes read (RISK the SEC's
123
+ * 10-Ks and 20-Fs, FLOW the listing's options): the sentence the note
124
+ * says when every route answered that the symbol has none. The note then
125
+ * names no command for the symbol, which would read nothing once unlocked
126
+ * either: IAG:LSE files neither form, and LHA:XETRA has no options served.
127
+ */
128
+ noneFor?: (ticker: string) => string;
117
129
  },
118
130
  ): Promise<string> {
119
131
  const { gate, symbol, token: typed, verifyEmail } = lock;
@@ -129,12 +141,15 @@ export async function proLockNote(
129
141
  const unlock = gate === "pro"
130
142
  ? `To read ${pane.shows} in ${token}, upgrade in the app (gloom, then Ctrl+P, UPGRADE)`
131
143
  : `To read ${pane.shows} in ${token}, open the app (gloom), ${account}${pro ? ", then upgrade (Ctrl+P, UPGRADE)" : ""}`;
132
- const bare = pane.argument === "none" || (pane.argument === "optional" && !symbol);
133
- const command = bare ? `gloom fn ${token}` : `gloom fn ${token} ${ticker}`;
134
- const free = pane.free ? await answeringFreeRoute(lock, pane.free) : null;
144
+ const { route: free, noneHasData } = pane.free ? await answeringFreeRoute(lock, pane.free) : { route: null, noneHasData: false };
145
+ // Unlocking reads nothing for a symbol none of the function's own data covers: it is named only where it has some.
146
+ const uncovered = noneHasData && !!pane.noneFor;
147
+ const bare = pane.argument === "none" || (pane.argument === "optional" && (!symbol || uncovered));
148
+ const command = bare ? `gloom fn ${token}` : `gloom fn ${token} ${uncovered ? "<ticker>" : ticker}`;
135
149
  return [
136
150
  `${token} ${what}: ${pane.does}. ${without} it shows none of ${pane.shows}.`,
137
151
  ...free ? [`Free, with no sign-in: ${free.text(ticker, symbol ?? "the company")}`] : [],
152
+ ...uncovered ? [pane.noneFor!(ticker)] : [],
138
153
  `${unlock}; ${command} then reads with that session.`,
139
154
  ].join("\n");
140
155
  }
@@ -1,5 +1,14 @@
1
- /** The note a loaded catalog series carries when it is drawn from an old copy. */
2
- export function cachedSeriesWarning(resource: { stale?: boolean; refreshError?: string | null }): string | undefined {
3
- if (resource.refreshError) return `Refresh failed (${resource.refreshError}); showing cached data.`;
4
- return resource.stale ? "Showing cached data; the source has not updated since." : undefined;
1
+ /**
2
+ * What a loaded catalog series says of an old copy. A copy that answered
3
+ * because its refresh failed carries when it was saved (`savedAt`), which the
4
+ * chart's footer shows as "saved copy from …", the note every pane gives it.
5
+ * A cached copy the source has not updated since is a note on the chart.
6
+ */
7
+ export function cachedSeriesNote(resource: { stale?: boolean; refreshError?: string | null; savedAt?: number | null }): {
8
+ warning?: string;
9
+ savedAt?: number;
10
+ } {
11
+ if (resource.savedAt != null && Number.isFinite(resource.savedAt)) return { savedAt: resource.savedAt };
12
+ if (resource.refreshError) return { warning: `Refresh failed (${resource.refreshError}); showing cached data.` };
13
+ return resource.stale ? { warning: "Showing cached data; the source has not updated since." } : {};
5
14
  }
@@ -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 { loadShortVolume } from "./client";
4
4
 
5
5
  /** The share of a ticker's daily on-exchange volume reported as short sales. */
@@ -14,10 +14,9 @@ export const SHORT_VOLUME_SERIES: readonly SeriesDefinition[] = [{
14
14
  input: "ticker",
15
15
  load: async ({ ticker }) => {
16
16
  const resource = await loadShortVolume(ticker!, "nms");
17
- const warning = cachedSeriesWarning(resource);
18
17
  return {
19
18
  points: resource.payload.history.map((point) => ({ date: point.date, value: point.ratioPercent })),
20
- ...(warning ? { warning } : {}),
19
+ ...cachedSeriesNote(resource),
21
20
  };
22
21
  },
23
22
  }];
@@ -1,9 +1,10 @@
1
1
  import { unavailableText } from "../../../components/ui/status";
2
2
  import { apiClient } from "../../../api-client";
3
- import { ApiRequestError, isAccessDenied } from "../../../api-client/errors";
3
+ import { isAccessDenied } from "../../../api-client/errors";
4
4
  import type { SocialMentionDayPosts, SocialMentionPost, SocialMentionsPayload, SocialMentionsRange } from "../../../api-client/social-mentions";
5
- import { createPluginCache } from "../../../data/plugin-cache";
5
+ import { createPluginCache, savedCopyForTransientFailure } from "../../../data/plugin-cache";
6
6
  import { CLOUD_SESSION_REQUIRED } from "../shared/research-cloud-session";
7
+ import { unavailableOnServer } from "../shared/cloud-resource";
7
8
 
8
9
  export const socialMentionsCache = createPluginCache<SocialMentionsPayload>({
9
10
  kind: "social-mentions", source: "gloom-cloud", schemaVersion: 1,
@@ -56,12 +57,15 @@ export function validateSocialMentions(payload: SocialMentionsPayload, symbol: s
56
57
  export async function fetchSocialMentions(symbol: string, range: SocialMentionsRange = "1y",
57
58
  client: Pick<typeof apiClient, "getCloudSocialMentions"> = apiClient): Promise<SocialMentionsPayload> {
58
59
  try { return validateSocialMentions(await client.getCloudSocialMentions(symbol, range), symbol, range); }
59
- catch (error) {
60
- if (error instanceof ApiRequestError && error.status === 404) throw new Error(unavailableText("social mentions"));
61
- throw error;
62
- }
60
+ catch (error) { throw unavailableOnServer(error, unavailableText("social mentions")); }
61
+ }
62
+ export interface SocialMentionsResource {
63
+ payload: SocialMentionsPayload;
64
+ stale: boolean;
65
+ refreshError: string | null;
66
+ /** When the copy shown was saved, when it is one because its refresh failed. */
67
+ savedAt?: number;
63
68
  }
64
- export interface SocialMentionsResource { payload: SocialMentionsPayload; stale: boolean; refreshError: string | null }
65
69
  const cacheKey = (symbol: string, range: SocialMentionsRange) => `${range}:${symbol}`;
66
70
  export function cachedSocialMentions(symbol: string, range: SocialMentionsRange): SocialMentionsResource | null {
67
71
  const cached = socialMentionsCache.get(cacheKey(symbol, range), { allowExpired: true });
@@ -70,9 +74,10 @@ export function cachedSocialMentions(symbol: string, range: SocialMentionsRange)
70
74
  catch { return null; }
71
75
  }
72
76
  export async function loadSocialMentions(symbol: string, range: SocialMentionsRange, force = false): Promise<SocialMentionsResource> {
73
- const result = await socialMentionsCache.load(cacheKey(symbol, range), () => fetchSocialMentions(symbol, range), { force });
74
- if (result.error instanceof ApiRequestError && [401, 403].includes(result.error.status ?? 0)) throw result.error;
75
- return { payload: validateSocialMentions(result.data, symbol, range), stale: result.stale, refreshError: result.refreshError ?? null };
77
+ // A refusal (a signed-out session, any other 4xx but 408 and 429) or a reply that fails validation shows as itself.
78
+ const result = savedCopyForTransientFailure(await socialMentionsCache.load(cacheKey(symbol, range), () => fetchSocialMentions(symbol, range), { force }));
79
+ return { payload: validateSocialMentions(result.data, symbol, range), stale: result.stale, refreshError: result.refreshError ?? null,
80
+ ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}) };
76
81
  }
77
82
 
78
83
  /**
@@ -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 { loadSocialMentions } from "./client";
4
4
 
5
5
  /** How often a ticker is posted about each day, over the last year. */
@@ -14,10 +14,9 @@ export const SOCIAL_MENTION_SERIES: readonly SeriesDefinition[] = [{
14
14
  style: "columns",
15
15
  load: async ({ ticker }) => {
16
16
  const resource = await loadSocialMentions(ticker!, "1y");
17
- const warning = cachedSeriesWarning(resource);
18
17
  return {
19
18
  points: resource.payload.x.days.map((row) => ({ date: row.day, value: row.mentions })),
20
- ...(warning ? { warning } : {}),
19
+ ...cachedSeriesNote(resource),
21
20
  };
22
21
  },
23
22
  }];
@@ -10,7 +10,7 @@ import type {
10
10
  } from "./types";
11
11
  import type { PluginPersistence } from "../../../types/plugin";
12
12
  import { apiClient } from "../../../api-client";
13
- import { ApiRequestError, isPermanentClientError } from "../../../api-client/errors";
13
+ import { ApiRequestError, isPermanentClientError, isTransientFailure } from "../../../api-client/errors";
14
14
  import { httpFetch } from "../../../utils/http-transport";
15
15
  import { asRecord, finiteOrNull } from "../../../utils/guards";
16
16
  import { logFailure } from "../../../utils/log-failure";
@@ -30,11 +30,12 @@ const FORMS_13F_CACHE_POLICY = {
30
30
  } as const;
31
31
 
32
32
  export interface Forms13FReadOptions {
33
+ /** A part of the answer that could not be read, said beside what was. */
33
34
  onWarning?: (warning: string) => void;
34
35
  /**
35
36
  * A read answered from the copy saved at `savedAt` (ms) because its refresh
36
- * failed, said by when it was saved (a command's closing line) instead of
37
- * as an `onWarning` line.
37
+ * failed. Only a caller that says when its copy was saved (the footer's
38
+ * "saved copy from", a command's closing line) is answered from one.
38
39
  */
39
40
  onSavedCopy?: (savedAt: number) => void;
40
41
  forceRefresh?: boolean;
@@ -156,23 +157,17 @@ async function fetchForms13F<T>(
156
157
  } catch (error) {
157
158
  options.signal?.throwIfAborted();
158
159
  if (current()) failedRefreshes.add(key);
159
- if (isPermanentClientError(error)) {
160
- if (current()) store?.deleteResource(CACHE_KIND, key, { sourceKey: CACHE_SOURCE });
160
+ // A saved copy answers only for a failure asking again may get past; a refusal or a reply that
161
+ // fails validation is the error, and a missing or denied resource also drops its copy.
162
+ if (!isTransientFailure(error)) {
163
+ if (isPermanentClientError(error) && current()) store?.deleteResource(CACHE_KIND, key, { sourceKey: CACHE_SOURCE });
161
164
  throw error;
162
165
  }
163
166
  const retained = options.cache !== false
164
167
  ? store?.getResource<T>(CACHE_KIND, key, { ...cacheOptions, allowExpired: true }) : null;
165
- // A caller may retain old research only when it can surface its provenance.
166
- if (!retained) throw error;
167
- if (options.onSavedCopy && Number.isFinite(retained.fetchedAt)) {
168
- options.onSavedCopy(retained.fetchedAt);
169
- return retained.value;
170
- }
171
- if (!options.onWarning) throw error;
172
- const retrieved = new Date(retained.fetchedAt);
173
- const age = Number.isFinite(retrieved.getTime()) ? `retrieved ${retrieved.toISOString()}` : "with unavailable retrieval time";
174
- const reason = (error instanceof Error ? error.message : String(error)).trim() || "Request failed";
175
- options.onWarning(`${key}: refresh failed: ${reason}. Retained data ${age}.`);
168
+ // A caller may retain old research only when it can say when it was saved.
169
+ if (!retained || !options.onSavedCopy || !Number.isFinite(retained.fetchedAt)) throw error;
170
+ options.onSavedCopy(retained.fetchedAt);
176
171
  return retained.value;
177
172
  } finally {
178
173
  if (activeRequests.get(key) === slot.promise) activeRequests.delete(key);
@@ -106,6 +106,8 @@ export interface BrowserLoadResult {
106
106
  period?: string;
107
107
  quarter?: string;
108
108
  warning?: string;
109
+ /** A refresh failed and the oldest copy that answered instead was saved at this time (ms). */
110
+ savedAt?: number;
109
111
  hasMore?: boolean;
110
112
  nextOffset?: number;
111
113
  reportsLoaded?: Promise<void>;
@@ -118,11 +120,31 @@ export async function loadBrowserRows(
118
120
  options: Pick<Forms13FReadOptions, "forceRefresh" | "onSavedCopy"> & { offset?: number; limit?: number } = {},
119
121
  ): Promise<BrowserLoadResult> {
120
122
  const warnings: string[] = [];
121
- const result = await loadBrowserPage(tab, query, signal, { ...options, onWarning: warning => warnings.push(warning) });
123
+ const page: { result?: BrowserLoadResult; savedAt?: number } = {};
124
+ const onSavedCopy = (savedAt: number) => {
125
+ page.savedAt = Math.min(page.savedAt ?? savedAt, savedAt);
126
+ // The rows' reports answer after the page does, and their copies date it too.
127
+ if (page.result) page.result.savedAt = page.savedAt;
128
+ options.onSavedCopy?.(savedAt);
129
+ };
130
+ const result = await loadBrowserPage(tab, query, signal, { ...options, onSavedCopy, onWarning: warning => warnings.push(warning) });
131
+ page.result = result;
132
+ if (page.savedAt != null) result.savedAt = page.savedAt;
122
133
  if (warnings.length) result.warning = [...new Set([result.warning, ...warnings].filter(Boolean))].join(" ") || undefined;
123
134
  return result;
124
135
  }
125
136
 
137
+ /** The oldest saved copy a load answered from (`savedAt`), each also passed on to `onSavedCopy`. */
138
+ function savedCopies(onSavedCopy?: (savedAt: number) => void) {
139
+ const copies: { savedAt?: number; note: (savedAt: number) => void } = {
140
+ note: (savedAt) => {
141
+ copies.savedAt = Math.min(copies.savedAt ?? savedAt, savedAt);
142
+ onSavedCopy?.(savedAt);
143
+ },
144
+ };
145
+ return copies;
146
+ }
147
+
126
148
  async function loadBrowserPage(
127
149
  tab: ThirteenFBrowserTab,
128
150
  query: string,
@@ -440,7 +462,8 @@ export async function loadFundPerformance(
440
462
  options: Pick<Forms13FReadOptions, "forceRefresh" | "onSavedCopy"> & { splitFactor?: SplitFactorLookup } = {},
441
463
  ): Promise<FundPerformanceResult> {
442
464
  const warnings: string[] = [];
443
- const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: options.onSavedCopy, onWarning: (warning: string) => warnings.push(warning) };
465
+ const saved = savedCopies(options.onSavedCopy);
466
+ const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: saved.note, onWarning: (warning: string) => warnings.push(warning) };
444
467
  const detail = await loadFundDetail(cik, fallbackName, signal, options);
445
468
  const period = detail.latestForm?.periodOfReport;
446
469
  const quarter = period ? periodToQuarter(period) : null;
@@ -482,7 +505,9 @@ export async function loadFundPerformance(
482
505
  : filed?.skipped.length ? `No share position held through the quarter to ${period} that could be priced across it.`
483
506
  : `No share position held through the quarter to ${period}.`;
484
507
  const warning = [...new Set([...(detail.warnings ?? []), ...warnings])].join(" ") || undefined;
508
+ const savedAt = Math.min(detail.savedAt ?? Infinity, saved.savedAt ?? Infinity);
485
509
  return {
510
+ ...(Number.isFinite(savedAt) ? { savedAt } : {}),
486
511
  rows: [row], ...(quarter ? { quarter } : {}), ...(period ? { period } : {}), hasMore: false, basis, ranking,
487
512
  ...(filed?.splits.length ? { splits: filed.splits } : {}),
488
513
  ...(filed?.skipped.length ? { skipped: filed.skipped } : {}),
@@ -500,7 +525,8 @@ export async function loadFundDetail(
500
525
  ): Promise<FundDetailData> {
501
526
  const now = new Date();
502
527
  const warnings: string[] = [];
503
- const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: options.onSavedCopy, onWarning: (warning: string) => warnings.push(warning) };
528
+ const saved = savedCopies(options.onSavedCopy);
529
+ const apiOptions = { forceRefresh: options.forceRefresh, onSavedCopy: saved.note, onWarning: (warning: string) => warnings.push(warning) };
504
530
 
505
531
  const forms = await listThirteenFForms(
506
532
  cik,
@@ -554,6 +580,7 @@ export async function loadFundDetail(
554
580
  previousReport,
555
581
  warnings,
556
582
  filingGap,
583
+ ...(saved.savedAt != null ? { savedAt: saved.savedAt } : {}),
557
584
  };
558
585
  }
559
586
 
@@ -562,12 +589,13 @@ export async function loadFilingPositions(
562
589
  accessionNumber: string,
563
590
  signal?: AbortSignal,
564
591
  options: { forceRefresh?: boolean; offset?: number; limit?: number } = {},
565
- ): Promise<{ rows: ThirteenFHoldingRecord[]; hasMore: boolean; warnings: string[] }> {
592
+ ): Promise<{ rows: ThirteenFHoldingRecord[]; hasMore: boolean; warnings: string[]; savedAt?: number }> {
566
593
  const warnings: string[] = [];
594
+ const saved = savedCopies();
567
595
  const result = await listThirteenFFormHoldingsPage(cik, accessionNumber, signal, {
568
- ...options, onWarning: warning => warnings.push(warning),
596
+ ...options, onSavedCopy: saved.note, onWarning: warning => warnings.push(warning),
569
597
  });
570
- return { ...result, warnings };
598
+ return { ...result, warnings, ...(saved.savedAt != null ? { savedAt: saved.savedAt } : {}) };
571
599
  }
572
600
 
573
601
  /** `2026Q2` for a quarter end such as 2026-06-30. */
@@ -10,6 +10,7 @@ import { usePluginPaneState, usePluginTickerActions } from "../../runtime";
10
10
  import { useMineTickers } from "../shared/mine-tickers";
11
11
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
12
12
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
13
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
13
14
  import { useAsyncResource } from "../../../react/async-resource";
14
15
  import { cachedFunds, cachedMembers, loadFunds, loadMembers } from "../members/client";
15
16
  import { coveredFund } from "../members/model";
@@ -115,7 +116,10 @@ export function FundOverlapView({ data, focused, width }: { data: FundDetailData
115
116
  const period = peer ? overlapPeriod(data, peer) : null;
116
117
  const mismatch = peer && !period;
117
118
  const listError = etf ? etfFile.error : error ?? paged.error?.message ?? paged.moreError?.message ?? null;
118
- usePaneStatusFooter({ registrationId: "13f-overlap", loading: loading || loadingMore || paged.loading || (!!etf && etfFile.loading), error: listError, hints: open ? [{ id: "mine", key: "m", label: mineOnly ? "all tickers" : "mine", title: mineOnly ? "All Tickers" : "Mine Only", onPress: () => setMineOnly(value => !value) }] : [{ id: "search", key: "/", label: "search", onPress: focusSearch }] });
119
+ // Either side answered from a copy a failed refresh left says when it was saved.
120
+ const etfSavedAt = etf ? etfFile.data?.savedAt : null;
121
+ const savedInfo = useMemo(() => savedCopyFooterInfo([data.savedAt, peer?.savedAt, etfSavedAt]), [data.savedAt, peer?.savedAt, etfSavedAt]);
122
+ usePaneStatusFooter({ registrationId: "13f-overlap", loading: loading || loadingMore || paged.loading || (!!etf && etfFile.loading), error: listError, info: savedInfo, hints: open ? [{ id: "mine", key: "m", label: mineOnly ? "all tickers" : "mine", title: mineOnly ? "All Tickers" : "Mine Only", onPress: () => setMineOnly(value => !value) }] : [{ id: "search", key: "/", label: "search", onPress: focusSearch }] });
119
123
  usePaneNoticeFooter({ registrationId: "13f-overlap-notice", focused, notices: [
120
124
  ...(peer?.warnings ?? []),
121
125
  ...(mismatch ? ["No reporting quarter is loaded for both funds; overlap is unavailable."] : []),
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import {
3
3
  DataTableStackView,
4
4
  DataTableView,
5
- EmptyState, PaneStatusBody, QueryBar, StatGrid, usePagedRows, usePaneNoticeFooter, usePaneTabs, useQueryBarSearch, useTableLoadMore, type DataTableKeyEvent,
5
+ EmptyState, PaneStatusBody, QueryBar, StatGrid, usePagedRows, usePaneFooter, usePaneNoticeFooter, usePaneTabs, useQueryBarSearch, useTableLoadMore, type DataTableKeyEvent,
6
6
  type DataTableRootKeyContext, type PageRequest, type PaneFooterSegment, type PaneHint, type StatItem
7
7
  } from "../../../components";
8
8
  import { useShortcut } from "../../../react/input";
@@ -26,7 +26,7 @@ import { usePaneStatusFooter, usePaneStatusLinkFooter } from "../../../component
26
26
  import { corporateSplitFactor, loadBrowserRows, loadFilingPositions, loadFundDetail, loadSplitFactors } from "./data";
27
27
  import { FundOverlapView } from "./overlap-pane";
28
28
  import { ThirteenFCrowdingPane, ThirteenFTickerHoldingsView } from "./signals-pane";
29
- import { PaneFooterScope } from "../../../components/layout/pane/footer";
29
+ import { PaneFooterScope, savedCopyFooterInfo } from "../../../components/layout/pane/footer";
30
30
  import { handleRefreshKey } from "../../../components/data-table/table-pane";
31
31
  import { useClaimResearchTabKeys } from "../ticker-detail/research-tab-keys";
32
32
  import {
@@ -129,6 +129,9 @@ function ThirteenFBrowserPane({ focused, width, height, onDetailChange }: PanePr
129
129
  const notes = [...(pagesSettled ? browser.pages.map((page) => page.warning) : []), browser.moreError?.message];
130
130
  return [...new Set(notes.filter(Boolean))].join(" ") || null;
131
131
  }, [browser.moreError, browser.pages, pagesSettled, reportPaint]);
132
+ // A page a failed refresh answered from a saved copy says when it was saved, as every pane does.
133
+ const savedInfo = useMemo(() => savedCopyFooterInfo(pagesSettled ? browser.pages.map((page) => page.savedAt) : []),
134
+ [browser.pages, pagesSettled, reportPaint]);
132
135
  const [detailSeed, setDetailSeed] = useState<FundSeed | null>(() => (
133
136
  initialCik ? { cik: String(initialCik), name: normalizedQuery || String(initialCik) } : null
134
137
  ));
@@ -166,7 +169,9 @@ function ThirteenFBrowserPane({ focused, width, height, onDetailChange }: PanePr
166
169
  }, { allowEditable: true });
167
170
 
168
171
  const browserSort = useMemo(() => browserSortFor(sortPreference, browserMode), [sortPreference, browserMode]);
169
- const sortedRows = useMemo(() => sortBrowserRows(rows, browserSort), [rows, browserSort, reportPaint]);
172
+ // The rows' reports fill the rows in place after their page answers (from the network or a saved copy);
173
+ // a row the table drew before then is drawn again only as a new object.
174
+ const sortedRows = useMemo(() => sortBrowserRows(rows.map((row) => ({ ...row })), browserSort), [rows, browserSort, reportPaint]);
170
175
  const columns = useMemo(() => {
171
176
  if (browserMode !== "performance") return buildBrowserColumns(false);
172
177
  const history = rows[0]?.priorReturns ?? [];
@@ -202,7 +207,8 @@ function ThirteenFBrowserPane({ focused, width, height, onDetailChange }: PanePr
202
207
  const browserStatusInfo = useMemo<PaneFooterSegment[]>(() => [
203
208
  ...(loadingMore ? [{ id: "loading-more", parts: [{ text: "loading more", tone: "muted" as const }] }] : []),
204
209
  ...(warning ? [{ id: "warning", parts: [{ text: warning, tone: "warning" as const }] }] : []),
205
- ], [loadingMore, warning]);
210
+ ...savedInfo,
211
+ ], [loadingMore, warning, savedInfo]);
206
212
  const searchHints = useMemo(() => [{ id: "search", key: "/", label: "search", onPress: focusSearch }], [focusSearch]);
207
213
  usePaneStatusFooter({
208
214
  registrationId: THIRTEENF_PANE_ID,
@@ -544,6 +550,9 @@ export function FundDetailView({
544
550
  const detailHints = useMemo<PaneHint[]>(() => hasHoldingFiling
545
551
  ? [{ id: "filing", key: "f", label: "iling", title: "Open Filing", onPress: openSelectedFilingInPane }]
546
552
  : [], [hasHoldingFiling, openSelectedFilingInPane]);
553
+ // An open filing and the overlap tab say when their own saved copies are from.
554
+ const detailSavedInfo = useMemo(() => savedCopyFooterInfo(openFiling || activeTab === "overlap" ? [] : [data?.savedAt]),
555
+ [activeTab, data?.savedAt, openFiling]);
547
556
  usePaneStatusLinkFooter({
548
557
  registrationId: "thirteenf-detail",
549
558
  focused,
@@ -551,6 +560,7 @@ export function FundDetailView({
551
560
  source: openFiling ? "SEC 13F" : null,
552
561
  loading: activeTab !== "overlap" && status === "loading",
553
562
  error: activeTab === "overlap" ? null : error,
563
+ info: detailSavedInfo,
554
564
  showOpenHint: true,
555
565
  hints: detailHints,
556
566
  });
@@ -590,7 +600,7 @@ export function FundDetailView({
590
600
  onBack={closeOpenFiling}
591
601
  detailTitle={openFiling ? `${openFiling.periodOfReport} filing` : "13F filing"}
592
602
  detailContent={openFiling ? (
593
- <FilingDetailView focused={focused} filing={openFiling} width={width} sourceWarnings={data?.warnings ?? []} />
603
+ <FilingDetailView focused={focused} filing={openFiling} width={width} sourceWarnings={data?.warnings ?? []} sourceSavedAt={data?.savedAt} />
594
604
  ) : (
595
605
  <Box flexGrow={1} />
596
606
  )}
@@ -659,11 +669,14 @@ function FilingDetailView({
659
669
  filing,
660
670
  width,
661
671
  sourceWarnings,
672
+ sourceSavedAt,
662
673
  }: {
663
674
  focused: boolean;
664
675
  filing: FundTimelineRow;
665
676
  width: number;
666
677
  sourceWarnings: string[];
678
+ /** When the fund's saved copy was saved, when a failed refresh left it on screen. */
679
+ sourceSavedAt?: number;
667
680
  }) {
668
681
  const { pinTicker } = usePluginTickerActions();
669
682
  const [sortPreference, setSortPreference] = usePluginPaneState<FundSortPreference<FilingPositionColumnId>>(
@@ -711,6 +724,9 @@ function FilingDetailView({
711
724
  );
712
725
 
713
726
  usePaneNoticeFooter({ registrationId: "thirteenf-filing-notices", notices: [...new Set([...sourceWarnings, ...warnings])], focused });
727
+ const savedInfo = useMemo(() => savedCopyFooterInfo([sourceSavedAt, ...(pagesSettled ? positions.pages.map((page) => page.savedAt) : [])]),
728
+ [pagesSettled, positions.pages, sourceSavedAt]);
729
+ usePaneFooter("thirteenf-filing-saved", () => ({ info: savedInfo }), [savedInfo]);
714
730
 
715
731
  // An untyped amendment may replace or add to the report, so only it is a caveat.
716
732
  const kind = amendmentKind(filing);
@@ -99,6 +99,8 @@ export interface FundDetailData {
99
99
  warnings?: string[];
100
100
  /** Set when the latest report predates the newest quarter past its deadline. */
101
101
  filingGap?: ThirteenFFilingGap | null;
102
+ /** A refresh failed and the oldest copy that answered instead was saved at this time (ms). */
103
+ savedAt?: number;
102
104
  }
103
105
 
104
106
  /** A manager a 13F-NT names as reporting its holdings. */
@@ -24,7 +24,8 @@ import {
24
24
  barPriceDecimals, formatBarPrice, historyAllStart, historyBarsAfterHole, historyChangeBaseNote, historyMissingPeriodsNote, quotedPriceDivisor,
25
25
  } from "../../../../cli/history-rows";
26
26
  import {
27
- MISSING_VOLUME_TEXT, missingVolumeCount, missingVolumeNotice, pricePointValues, priceHistoryIntegrityNotice,
27
+ MISSING_VOLUME_TEXT, missingVolumeCount, missingVolumeNotice, pricePointValues, pricePointWickFlags, priceHistoryIntegrityNotice,
28
+ priceHistoryWickNotice,
28
29
  } from "../../../../utils/price-history-integrity";
29
30
  import {
30
31
  useAssetData,
@@ -257,6 +258,7 @@ export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
257
258
  [points, venue],
258
259
  );
259
260
  const integrityNotice = priceHistoryIntegrityNotice(rows.filter((row) => row.point.integrity).length);
261
+ const wickNotice = useMemo(() => priceHistoryWickNotice(points.filter((point) => pricePointWickFlags(point).length > 0).length), [points]);
260
262
  const volumeMissing = useMemo(() => missingVolumeCount(rows.map((row) => ({
261
263
  ...row.point, date: row.time, partial: row.partial, filled: row.filled,
262
264
  }))), [rows]);
@@ -356,7 +358,7 @@ export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
356
358
 
357
359
  usePaneNoticeFooter({
358
360
  registrationId: "historical-prices:integrity",
359
- notices: [integrityNotice, volumeNotice, ...gapNotices].filter((notice): notice is string => !!notice),
361
+ notices: [integrityNotice, wickNotice, volumeNotice, ...gapNotices].filter((notice): notice is string => !!notice),
360
362
  focused,
361
363
  title: "Price history data",
362
364
  });
@@ -404,6 +406,7 @@ export function HistoricalPricesPane({ focused, width, height }: PaneProps) {
404
406
  : loading ? (rows.length ? "Refreshing" : "Loading") : rows.length ? "Available" : "Empty"],
405
407
  ...(allStart ? [["Source starts", allStart]] : []),
406
408
  ...(integrityNotice ? [["Warning", integrityNotice]] : []),
409
+ ...(wickNotice ? [["Warning", wickNotice]] : []),
407
410
  ...gapNotices.map((notice) => ["Gap note", notice]),
408
411
  ...(volumeNotice ? [["Volume note", volumeNotice]] : []),
409
412
  ...(error ? [["Error", error]] : []),
@@ -505,13 +505,46 @@ function coversLatestYear(ttm: FinancialTableStatement, latestYear: FinancialSta
505
505
  return days <= 7;
506
506
  }
507
507
 
508
+ /** Counts of the shares a company has issued or has outstanding, which are never zero; treasury shares can be. */
509
+ const OUTSTANDING_COUNT_FIELDS = ["basicShares", "dilutedShares", "shareIssued", "ordinarySharesNumber"] as const;
510
+
511
+ /**
512
+ * Share counts that cannot be the company's: zero, or under a thousandth of its
513
+ * typical count (IAG's 4,642 for Q3 2025, millions read as shares, which shows
514
+ * as 0.00 bn and a fall of 100%). They are missing, not a value to grow from.
515
+ */
516
+ function withoutImpossibleShareCounts(
517
+ annualStatements: FinancialStatement[],
518
+ quarterlyStatements: FinancialStatement[],
519
+ ): { annualStatements: FinancialStatement[]; quarterlyStatements: FinancialStatement[] } {
520
+ const all = [...annualStatements, ...quarterlyStatements];
521
+ const floors = new Map<string, number>();
522
+ for (const field of OUTSTANDING_COUNT_FIELDS) {
523
+ const counts = all.map((row) => row[field]).filter((value): value is number => typeof value === "number" && value > 0).sort((a, b) => a - b);
524
+ floors.set(field, counts.length ? counts[Math.floor(counts.length / 2)]! / 1000 : 0);
525
+ }
526
+ const impossible = (row: FinancialStatement, field: typeof OUTSTANDING_COUNT_FIELDS[number]) => {
527
+ const value = row[field];
528
+ return typeof value === "number" && (value <= 0 || value < floors.get(field)!);
529
+ };
530
+ const clean = (rows: FinancialStatement[]) => rows.map((row) => {
531
+ const drop = OUTSTANDING_COUNT_FIELDS.filter((field) => impossible(row, field));
532
+ if (!drop.length) return row;
533
+ const next = { ...row };
534
+ for (const field of drop) delete next[field];
535
+ return next;
536
+ });
537
+ return { annualStatements: clean(annualStatements), quarterlyStatements: clean(quarterlyStatements) };
538
+ }
539
+
508
540
  export function selectFinancialStatements(
509
541
  period: FinancialPeriod,
510
542
  statement: string,
511
- annualStatements: FinancialStatement[],
512
- quarterlyStatements: FinancialStatement[],
543
+ servedAnnual: FinancialStatement[],
544
+ servedQuarterly: FinancialStatement[],
513
545
  limit = period === "annual" ? 5 : 6,
514
546
  ) {
547
+ const { annualStatements, quarterlyStatements } = withoutImpossibleShareCounts(servedAnnual, servedQuarterly);
515
548
  // A period with nothing on this statement (a fiscal year-end balance point on
516
549
  // the income statement) would render as an all-dash column.
517
550
  const tabRows = FINANCIAL_SUB_TABS.find((tab) => tab.key === statement)?.rows;