gloomberb 0.15.0 → 0.15.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 (189) hide show
  1. package/bin/gloomberb +9 -1
  2. package/package.json +1 -1
  3. package/src/api-client/central-bank-rates.ts +3 -0
  4. package/src/api-client/types.ts +1 -1
  5. package/src/app/pane-runtime/index.tsx +7 -5
  6. package/src/app/runtime/broker-import.test.tsx +89 -0
  7. package/src/app/runtime/broker-import.ts +29 -8
  8. package/src/brokers/sync-broker-instance.ts +67 -0
  9. package/src/cli/commands/overview.ts +6 -2
  10. package/src/cli/native-smoke.ts +2 -0
  11. package/src/components/chart/composite/composite-chart.tsx +21 -1
  12. package/src/components/chart/composite/format.ts +71 -8
  13. package/src/components/chart/composite/fx-precision.test.ts +21 -3
  14. package/src/components/chart/composite/scene.ts +13 -1
  15. package/src/components/chart/composite/types.ts +4 -0
  16. package/src/components/layout/market-summary.ts +4 -2
  17. package/src/components/ticker/badge/format.test.ts +3 -3
  18. package/src/components/ticker/badge/format.ts +11 -9
  19. package/src/components/ticker/badge/text.test.tsx +6 -6
  20. package/src/components/ui/index.ts +2 -1
  21. package/src/components/ui/multi-select/dialog.tsx +46 -4
  22. package/src/components/ui/number-prompt-dialog.tsx +71 -0
  23. package/src/core/state/app/state.ts +8 -2
  24. package/src/market-data/coordinator/events.ts +5 -0
  25. package/src/market-data/coordinator/index.ts +44 -10
  26. package/src/market-data/market/format.test.ts +54 -0
  27. package/src/market-data/market/format.ts +164 -5
  28. package/src/market-data/query-store.test.ts +87 -0
  29. package/src/market-data/query-store.ts +66 -9
  30. package/src/plugins/builtin/alerts/format.ts +4 -3
  31. package/src/plugins/builtin/analytics/display.ts +3 -3
  32. package/src/plugins/builtin/analytics/index.test.tsx +22 -22
  33. package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
  34. package/src/plugins/builtin/analytics/pane-model.ts +8 -8
  35. package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
  36. package/src/plugins/builtin/analytics/risk-client.ts +6 -8
  37. package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
  38. package/src/plugins/builtin/analytics/risk-model.ts +8 -0
  39. package/src/plugins/builtin/analytics/view.tsx +2 -2
  40. package/src/plugins/builtin/backtest/engine.test.ts +0 -1
  41. package/src/plugins/builtin/backtest/engine.ts +1 -4
  42. package/src/plugins/builtin/backtest/pane.tsx +1 -1
  43. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  44. package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
  45. package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
  46. package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
  47. package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
  48. package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
  49. package/src/plugins/builtin/chart-composer/pane.tsx +50 -3
  50. package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
  51. package/src/plugins/builtin/chart-composer/presets.ts +48 -0
  52. package/src/plugins/builtin/chart-composer/settings.ts +42 -11
  53. package/src/plugins/builtin/chat/chat.test.tsx +2 -2
  54. package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
  55. package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
  56. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
  57. package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
  58. package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
  59. package/src/plugins/builtin/congress-trades/table.tsx +1 -1
  60. package/src/plugins/builtin/cot/client.ts +2 -1
  61. package/src/plugins/builtin/cot/model.test.ts +9 -0
  62. package/src/plugins/builtin/cot/model.ts +30 -1
  63. package/src/plugins/builtin/cot/pane.tsx +2 -4
  64. package/src/plugins/builtin/credit-conditions/client.ts +2 -1
  65. package/src/plugins/builtin/crypto-board/client.ts +2 -1
  66. package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
  67. package/src/plugins/builtin/crypto-board/model.ts +11 -3
  68. package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
  69. package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
  70. package/src/plugins/builtin/custom-view/pane.tsx +18 -7
  71. package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
  72. package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
  73. package/src/plugins/builtin/debt-maturities/client.ts +16 -3
  74. package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
  75. package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
  76. package/src/plugins/builtin/debt-maturities/model.ts +2 -1
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
  78. package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
  79. package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
  80. package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
  81. package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
  82. package/src/plugins/builtin/econ/index.tsx +4 -2
  83. package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
  84. package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
  85. package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
  86. package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
  87. package/src/plugins/builtin/equity-screener/headless.ts +18 -10
  88. package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
  89. package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
  90. package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
  91. package/src/plugins/builtin/futures/table.tsx +4 -3
  92. package/src/plugins/builtin/futures-curve/client.ts +2 -1
  93. package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
  94. package/src/plugins/builtin/futures-curve/model.ts +18 -14
  95. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  96. package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
  97. package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
  98. package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
  99. package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
  100. package/src/plugins/builtin/insider/index.tsx +15 -12
  101. package/src/plugins/builtin/insider/pane.test.tsx +24 -0
  102. package/src/plugins/builtin/market-movers/headless.ts +2 -2
  103. package/src/plugins/builtin/market-movers/model.ts +23 -10
  104. package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
  105. package/src/plugins/builtin/market-movers/screener.ts +3 -0
  106. package/src/plugins/builtin/market-movers/table.ts +2 -2
  107. package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
  108. package/src/plugins/builtin/market-valuation/client.ts +6 -1
  109. package/src/plugins/builtin/money-markets/client.ts +2 -1
  110. package/src/plugins/builtin/money-markets/pane.tsx +0 -1
  111. package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
  112. package/src/plugins/builtin/options/footer.test.ts +2 -2
  113. package/src/plugins/builtin/options/footer.ts +2 -2
  114. package/src/plugins/builtin/options/market-reference.test.ts +2 -2
  115. package/src/plugins/builtin/options/market-reference.ts +14 -2
  116. package/src/plugins/builtin/options/table.ts +5 -8
  117. package/src/plugins/builtin/options/view.test.tsx +2 -2
  118. package/src/plugins/builtin/options/view.tsx +1 -1
  119. package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
  120. package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
  121. package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
  122. package/src/plugins/builtin/options-calculator/surface.ts +13 -6
  123. package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
  124. package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
  125. package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
  126. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
  127. package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
  128. package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
  129. package/src/plugins/builtin/rate-path/client.ts +2 -1
  130. package/src/plugins/builtin/rate-path/headless.ts +1 -1
  131. package/src/plugins/builtin/rate-path/pane.tsx +0 -1
  132. package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
  133. package/src/plugins/builtin/realized-vol/headless.ts +1 -1
  134. package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
  135. package/src/plugins/builtin/realized-vol/model.ts +24 -9
  136. package/src/plugins/builtin/relative-rotation/client.ts +2 -1
  137. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
  138. package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
  139. package/src/plugins/builtin/risk-factors/data.ts +8 -1
  140. package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
  141. package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
  142. package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
  143. package/src/plugins/builtin/short-volume/client.ts +2 -1
  144. package/src/plugins/builtin/short-volume/pane.tsx +0 -1
  145. package/src/plugins/builtin/thirteenf/headless.ts +3 -3
  146. package/src/plugins/builtin/thirteenf/model.ts +14 -4
  147. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
  148. package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
  149. package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
  150. package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
  151. package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
  152. package/src/plugins/builtin/thirteenf/table.ts +9 -3
  153. package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
  154. package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
  155. package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
  156. package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
  157. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  158. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
  159. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
  160. package/src/plugins/builtin/time-sales/model.test.ts +17 -1
  161. package/src/plugins/builtin/time-sales/model.ts +14 -3
  162. package/src/plugins/builtin/time-sales/pane.tsx +8 -4
  163. package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
  164. package/src/plugins/builtin/vol-surface/client.ts +4 -1
  165. package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
  166. package/src/plugins/builtin/vol-surface/model.ts +14 -2
  167. package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
  168. package/src/plugins/builtin/volatility/client.test.ts +13 -8
  169. package/src/plugins/builtin/volatility/client.ts +3 -2
  170. package/src/plugins/builtin/volatility/headless.test.ts +2 -2
  171. package/src/plugins/builtin/volatility/model.test.ts +4 -0
  172. package/src/plugins/builtin/volatility/model.ts +6 -7
  173. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
  174. package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
  175. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
  176. package/src/renderers/electrobun/bun/index.ts +2 -1
  177. package/src/renderers/opentui/render-timing-smoke.ts +20 -0
  178. package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
  179. package/src/sources/yahoo-finance.test.ts +5 -1
  180. package/src/sources/yahoo-finance.ts +2 -1
  181. package/src/time-series/forward-valuation.ts +6 -0
  182. package/src/time-series/resolve.ts +5 -1
  183. package/src/time-series/valuation-currency.test.ts +2 -0
  184. package/src/time-series/valuation-currency.ts +3 -1
  185. package/src/utils/exchange-rate-snapshot.test.ts +12 -1
  186. package/src/utils/exchange-rate-snapshot.ts +3 -2
  187. package/src/utils/format.ts +55 -20
  188. package/src/utils/fx-market-hours.test.ts +28 -0
  189. package/src/utils/fx-market-hours.ts +42 -0
@@ -3,7 +3,7 @@ import {
3
3
  REALIZED_VOLATILITY_WINDOWS, realizedVolatilityCadenceIssue, realizedVolatilityResult, rollingRealizedVolatility, volatilityCone,
4
4
  type RealizedVolatilityEstimator, type RollingRealizedVolatilityPoint, type VolatilityConeStatistics,
5
5
  } from "../shared/volatility";
6
- import type { SurfaceSnapshot } from "../vol-surface/model";
6
+ import type { SurfaceExpiry, SurfaceSnapshot } from "../vol-surface/model";
7
7
 
8
8
  export interface RealizedVolatilityModel {
9
9
  symbol: string;
@@ -99,32 +99,47 @@ export interface CurrentAtmIvSnapshot {
99
99
  reference: CurrentAtmIvReference | null;
100
100
  error: string | null;
101
101
  warnings: string[];
102
+ /** The underlying has no option chain in the sources, so there is no IV to show and nothing failed. */
103
+ noOptionChain?: boolean;
102
104
  }
103
105
 
104
106
  /** A LEAPS ATM IV is not comparable with a 10 to 260 session realized cone. */
105
107
  export const MAX_CURRENT_ATM_IV_DAYS = 90;
106
108
 
107
- /** Use a listed expiry nearest 30 days, preserving its own observation date and tenor. */
109
+ /**
110
+ * Use a listed expiry nearest 30 days, preserving its own observation date and tenor. Warnings and
111
+ * failures are the chosen slice's and the surface's own, not those of expiries the reference does not use.
112
+ */
108
113
  export function projectCurrentAtmIv(surface: SurfaceSnapshot): CurrentAtmIvSnapshot {
109
- const warnings = [...surface.warnings];
110
- const candidates = surface.expiries.filter((expiry) => expiry.atmIV != null
111
- && Number.isFinite(expiry.atmIV) && expiry.atmIV > 0 && expiry.years > 0 && expiry.years * 365 <= MAX_CURRENT_ATM_IV_DAYS);
114
+ if (surface.catalogue.length === 0 && surface.failures.some((failure) => failure.expiration == null && failure.reasonCode === "NO_DATA")) {
115
+ return { reference: null, error: null, warnings: [], noOptionChain: true };
116
+ }
117
+ const eligible = surface.expiries.filter((expiry) => expiry.years > 0 && expiry.years * 365 <= MAX_CURRENT_ATM_IV_DAYS);
118
+ const usable = (expiry: SurfaceExpiry) => expiry.atmIV != null && Number.isFinite(expiry.atmIV) && expiry.atmIV > 0;
119
+ const distance = (expiry: SurfaceExpiry) => Math.abs(expiry.years * 365 - 30);
120
+ const warnings: string[] = [];
121
+ const candidates = eligible.filter(usable);
112
122
  const fresh = candidates.filter((expiry) => !expiry.stale && !expiry.error);
113
123
  if (fresh.length < candidates.length) warnings.push("Stale or failed ATM IV slices excluded from the current reference");
114
124
  const dated = fresh.filter((expiry) => expiry.asOf != null && Number.isFinite(Date.parse(expiry.asOf)));
115
125
  if (dated.length < fresh.length) warnings.push("Some ATM IV observations have no valid source date");
116
- dated.sort((a, b) => Math.abs(a.years * 365 - 30) - Math.abs(b.years * 365 - 30) || a.expiration - b.expiration);
126
+ dated.sort((a, b) => distance(a) - distance(b) || a.expiration - b.expiration);
117
127
  const expiry = dated[0];
118
- const errors = [...new Set(surface.failures.map((failure) => failure.message))];
119
128
  if (!expiry) {
120
- return { reference: null, error: errors.join("; ") || "Current ATM IV unavailable from cleaned option quotes", warnings };
129
+ const errors = [...new Set(surface.failures.map((failure) => failure.message))];
130
+ return { reference: null, error: errors.join("; ") || "Current ATM IV unavailable from cleaned option quotes",
131
+ warnings: [...new Set([...eligible.flatMap((entry) => entry.warnings), ...warnings])] };
121
132
  }
122
133
  const daysToExpiry = expiry.years * 365;
134
+ if (eligible.some((entry) => !usable(entry) && distance(entry) < distance(expiry))) {
135
+ warnings.push(`Expiries nearer 30 days have no usable ATM quotes; reference uses the ${Math.round(daysToExpiry)}d expiry`);
136
+ }
123
137
  if (surface.spotAsOf == null) warnings.push("Underlying quote observation date unavailable");
138
+ const errors = [...new Set(surface.failures.filter((failure) => failure.expiration == null).map((failure) => failure.message))];
124
139
  return { reference: {
125
140
  value: expiry.atmIV!, date: new Date(expiry.asOf!),
126
141
  label: `ATM IV ${Math.round(daysToExpiry)}d`,
127
142
  daysToExpiry, expiration: expiry.expiration, source: expiry.source, stale: expiry.stale,
128
143
  ivSource: surface.settings.ivSource, spot: surface.spot, spotAsOf: surface.spotAsOf ?? null,
129
- }, error: errors.join("; ") || null, warnings: [...new Set(warnings)] };
144
+ }, error: errors.join("; ") || null, warnings: [...new Set([...expiry.warnings, ...warnings])] };
130
145
  }
@@ -186,7 +186,8 @@ export function cachedRotation(
186
186
  return value
187
187
  ? {
188
188
  payload: value.data,
189
- stale: value.stale,
189
+ // The pane revalidates this copy on mount; only a failed refresh makes it stale.
190
+ stale: false,
190
191
  refreshError: null as string | null,
191
192
  }
192
193
  : null;
@@ -1,5 +1,4 @@
1
1
  import { formatPriceEarnings } from "../../../utils/price-earnings";
2
- import { describeFundamentalMarketCap } from "../../../utils/market-capitalization";
3
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
3
  import { TextAttributes } from "../../../ui";
5
4
  import {
@@ -210,17 +209,11 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
210
209
  const rowErrors = rows.filter((row) => row.error).map((row) => `${row.symbol}: ${row.error}`);
211
210
  const status = [error, ...rowErrors, staleSymbols.length ? `Stale quotes: ${staleSymbols.join(", ")}` : null,
212
211
  missingFx ? "Market-cap FX unavailable" : null].filter(Boolean).join(" · ") || null;
213
- const selectedRow = sortedRows[selectedIdx];
214
- const selectedCapNotice = selectedRow?.marketCapProvenance?.kind === "fundamentals"
215
- ? `${selectedRow.symbol} cap: ${describeFundamentalMarketCap(selectedRow.marketCapProvenance)}.` : undefined;
216
212
 
217
213
  usePaneNoticeFooter({
218
214
  registrationId: "relative-valuation-notices",
219
- notices: [
220
- ...rows.filter((row) => row.fundamentalsProvenance?.stale)
221
- .map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
222
- ...(selectedCapNotice ? [selectedCapNotice] : []),
223
- ],
215
+ notices: rows.filter((row) => row.fundamentalsProvenance?.stale)
216
+ .map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
224
217
  focused,
225
218
  });
226
219
 
@@ -236,7 +229,7 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
236
229
  case "changePercent":
237
230
  return { text: row.changePercent != null ? formatPercentRaw(row.changePercent) : "-", color: selectedColor ?? priceColor(row.changePercent ?? 0) };
238
231
  case "marketCap":
239
- return { text: formatCompact(row.marketCap ?? undefined), color: selectedColor ?? colors.textDim };
232
+ return { text: formatCompact(row.marketCap ?? undefined, { fixedDecimals: true }), color: selectedColor ?? colors.textDim };
240
233
  case "trailingPE":
241
234
  return { text: formatPriceEarnings(row.reportedMultiples.trailingPE), color: selectedColor ?? colors.text };
242
235
  case "forwardPE":
@@ -25,6 +25,13 @@ for(const status of [401,403,404])test(`report ${status} cannot fall back to cac
25
25
  await expect(loadRiskReportWithClient(api,"CONTROL",2025)).rejects.toMatchObject({status});expect(store.getResource("report","CONTROL:2025",{...options,allowExpired:true})).toBeNull();
26
26
  });
27
27
 
28
+ test("a list 404 is an empty cached list",async()=>{
29
+ const store=new MemoryPluginPersistence();attachRiskFactorsPersistence(store);const api=client();let calls=0;
30
+ api.getRiskReports=async()=>{calls++;throw new ApiRequestError("No risk factor reports for this ticker",404);};
31
+ await expect(loadRiskReportsWithClient(api,"CONTROL")).resolves.toMatchObject({reports:[],stale:false});
32
+ await loadRiskReportsWithClient(api,"CONTROL");expect(calls).toBe(1);expect(store.getResource("reports","CONTROL",options)?.value).toMatchObject({reports:[]});
33
+ });
34
+
28
35
  test("transient historical refresh retains immutable source dates and expired content",async()=>{
29
36
  const store=new MemoryPluginPersistence();attachRiskFactorsPersistence(store);store.seedResource("report","CONTROL:2025",report(2025),{...options,stale:true,expired:true});
30
37
  const before=store.getResource("report","CONTROL:2025",{...options,allowExpired:true})!;const api=client();api.getRiskReport=async()=>{throw new ApiRequestError("Temporary outage",503);};
@@ -96,7 +96,14 @@ function loadCached<T extends object>(
96
96
  export function loadRiskReportsWithClient(client: RiskApiClient, ticker: string, options?: { force?: boolean }): Promise<RiskReportsResult> {
97
97
  const key = ticker.trim().toUpperCase();
98
98
  return loadCached(client, LIST_KIND, key, activeListFetches, LIST_CACHE_POLICY, options?.force ?? false, async () => {
99
- const payload = await client.getRiskReports(key);
99
+ let payload: CloudRiskReportListPayload;
100
+ try {
101
+ payload = await client.getRiskReports(key);
102
+ } catch (error) {
103
+ // The list answers 404 when no 10-K risk report is on file (20-F filers, funds).
104
+ if (error instanceof ApiRequestError && error.status === 404) return { company: null, reports: [] };
105
+ throw error;
106
+ }
100
107
  if (!payload || !Array.isArray(payload.reports)) throw new Error(`Risk report list unavailable for ${key}.`);
101
108
  return payload;
102
109
  });
@@ -44,6 +44,9 @@ test("historical year loading and failure cannot display the previous report or
44
44
  for(const status of [503,404])test(`initial report ${status} stops loading and exposes the cause`,async()=>{
45
45
  transport(path=>path.endsWith("/CONTROL")?Response.json(list([2026])):new Response("Detail unavailable",{status}));await mount();const failed=capture(`initial-detail-${status}`);expect(failed).toContain("Detail unavailable");expect(failed).not.toContain("Loading...");expect(failed).not.toContain("[o]pen filing");cases.push({case:`initial-detail-${status}`,requests:[...requests]});
46
46
  });
47
+ test("a ticker without a 10-K report shows the empty state, not a load error",async()=>{
48
+ transport(()=>Response.json({message:"No risk factor reports for this ticker"},{status:404}));await mount();const empty=capture("list-404");expect(empty).toContain("No 10-K risk factors on file for CONTROL.");expect(empty).not.toContain("Report list:");expect(empty).not.toContain("Could not load risk reports");expect(requests).toEqual(["/public/risks/CONTROL"]);
49
+ });
47
50
  test("changing ticker while a historical report is pending cannot adopt the old security or year",async()=>{
48
51
  const pending=deferred<Response>();transport(path=>path.endsWith("/CONTROL")?Response.json(list([2026,2025])):path.endsWith("/CONTROL/2025")?pending.promise:path.endsWith("/CONTROL/2026")?Response.json(report(2026)):path.endsWith("/OTHER")?Response.json({...list([2024]),company:{...list([2024]).company,ticker:"OTHER"}}):Response.json({...report(2024),ticker:"OTHER",docUrl:"https://www.sec.gov/Archives/other-2024.htm",overview:"Only OTHER 2024 risk analysis"}));
49
52
  await mount();await click("2025 10-K");await act(async()=>selectTicker("OTHER"));await settle();const next=capture("ticker-changed");expect(next).toContain("Only OTHER 2024 risk analysis");expect(requests).not.toContain("/public/risks/OTHER/2025");pending.resolve(Response.json(report(2025)));await settle();const completed=capture("ticker-old-completion");expect(completed).toContain("Only OTHER 2024 risk analysis");expect(completed).not.toContain("Only 2025 risk analysis");await click("[o]pen filing");expect(opened.at(-1)).toBe("https://www.sec.gov/Archives/other-2024.htm");cases.push({case:"ticker-ownership",requests:[...requests],opened:[...opened]});
@@ -97,14 +97,14 @@ describe("screener live quotes", () => {
97
97
  });
98
98
  });
99
99
 
100
- test("replacement snapshots clear unavailable fields and select only the qualified listing", () => {
100
+ test("replacement snapshots clear unavailable fields, keep the listing currency and select only the qualified listing", () => {
101
101
  const rows = [{ symbol: "ACME", exchange: "NASDAQ", name: "US", currency: "USD", price: 10, change: 1, changePercent: 10, volume: 200, lastUpdated: 100 },
102
102
  { symbol: "ACME", exchange: "LSE", name: "UK", currency: "GBP", price: 8, change: 0, changePercent: 0, volume: 0, lastUpdated: 100 }];
103
103
  const targets = buildScreenerQuoteTargets(rows, "ACME:XLON");
104
104
  expect(targets.map(target => target.selected)).toEqual([false, true]);
105
- const entries = new Map([[buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "USD", lastUpdated: 200 })]]);
105
+ const entries = new Map([[buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "", lastUpdated: 200 })]]);
106
106
  expect(overlayScreenerQuoteEntries(rows, entries)).toEqual([
107
- expect.objectContaining({ price: 12, change: null, changePercent: null, volume: null, lastUpdated: 200 }), rows[1]!,
107
+ expect.objectContaining({ price: 12, change: null, changePercent: null, volume: null, currency: "USD", lastUpdated: 200 }), rows[1]!,
108
108
  ]);
109
109
  entries.set(buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "USD", lastUpdated: 50, change: 0, changePercent: 0, volume: 0 }));
110
110
  expect(overlayScreenerQuoteEntries(rows, entries)).toEqual(rows);
@@ -3,6 +3,7 @@ import { publicTickerKey } from "../../../utils/exchanges";
3
3
  import type { Quote } from "../../../types/financials";
4
4
  import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors";
5
5
  import type { QueryEntry } from "../../../market-data/result-types";
6
+ import { resolveCurrencyUnit } from "../../../utils/currency-units";
6
7
 
7
8
  const STREAM_FRESHNESS_MS = 2 * 60_000;
8
9
  const STREAM_CONNECTING_GRACE_MS = 15_000;
@@ -17,6 +18,7 @@ export interface ScreenerQuoteRow {
17
18
  currency: string;
18
19
  exchange: string;
19
20
  lastUpdated?: number;
21
+ previousClose?: number;
20
22
  }
21
23
 
22
24
  export interface ScreenerQuoteFreshness {
@@ -48,6 +50,18 @@ function finite(value: number | undefined): value is number {
48
50
  return typeof value === "number" && Number.isFinite(value);
49
51
  }
50
52
 
53
+ /** Venues quote these in either the major or the minor unit (GBP or GBp). */
54
+ const TWO_UNIT_CURRENCIES = new Set(["GBP", "ILS", "ZAR"]);
55
+
56
+ /** A quote without a currency keeps the listing's, so a streamed price never
57
+ * loses its symbol, unless the listing's currency has two units and the
58
+ * quote's could be either. */
59
+ function overlayCurrency(row: ScreenerQuoteRow, quote: Quote): string {
60
+ const quoted = quote.currency?.trim();
61
+ if (quoted) return quoted;
62
+ return TWO_UNIT_CURRENCIES.has(resolveCurrencyUnit(row.currency).currency) ? "" : row.currency;
63
+ }
64
+
51
65
  /**
52
66
  * The last overlay of each source row. Every stream flush hands over a new
53
67
  * entries map, but most rows' quotes did not move; returning the same row
@@ -87,7 +101,8 @@ function overlayQuote<T extends ScreenerQuoteRow>(row: T, quote: Quote): T {
87
101
  ? quote.changePercent
88
102
  : null,
89
103
  volume: finite(quote.volume) && quote.volume >= 0 ? quote.volume : null,
90
- currency: quote.currency?.trim() || "",
104
+ currency: overlayCurrency(row, quote),
105
+ previousClose: finite(quote.previousClose) ? quote.previousClose : undefined,
91
106
  lastUpdated: quote.lastUpdated,
92
107
  };
93
108
  }
@@ -84,7 +84,8 @@ const cacheKey = (symbol: string, scope: ShortVolumeScope) => `${scope}:${symbol
84
84
  export function cachedShortVolume(symbol: string, scope: ShortVolumeScope): ShortVolumeResource | null {
85
85
  const cached = shortVolumeCache.get(cacheKey(symbol, scope), { allowExpired: true });
86
86
  if (!cached) return null;
87
- try { return { payload: validateShortVolume(cached.data, symbol, scope), stale: cached.stale, refreshError: null }; }
87
+ // The pane revalidates this copy on mount; only a failed refresh makes it stale.
88
+ try { return { payload: validateShortVolume(cached.data, symbol, scope), stale: false, refreshError: null }; }
88
89
  catch { return null; }
89
90
  }
90
91
  export async function loadShortVolume(symbol: string, scope: ShortVolumeScope, force = false): Promise<ShortVolumeResource> {
@@ -74,7 +74,6 @@ export function ShortVolumePane({ width, height, focused }: Pick<PaneProps, "wid
74
74
  info: data ? [
75
75
  ...(updatedAgo ? [{ id: "updated", parts: [{ text: updatedAgo, tone: "muted" as const }] }] : []),
76
76
  ...(resource.data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
77
- ...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
78
77
  ] : [],
79
78
  });
80
79
  if (!data && isCloudSessionRequired(resource.error)) return <SignInWall action="view daily short volume" needsVerification={session.needsVerification} />;
@@ -5,7 +5,7 @@ import type {
5
5
  HeadlessPaneLoadArgs,
6
6
  } from "../../../types/plugin";
7
7
  import { normalizeCik } from "./api";
8
- import { buildFundOverlap } from "./overlap";
8
+ import { buildFundOverlap, overlapPeriod } from "./overlap";
9
9
  import { appendTickerHoldings, loadCrowding, loadTickerHoldings, type TickerHoldings } from "./signals";
10
10
  import {
11
11
  loadBrowserRows,
@@ -261,9 +261,9 @@ export function createThirteenFHeadless(
261
261
  const fund = await resolveFund(query, args, ctx, dependencies);
262
262
  const other = await resolveFund(String(args.options.compare ?? ""), args, ctx, dependencies);
263
263
  const [first, second] = await Promise.all([dependencies.loadDetail(fund.cik, fund.name, args, ctx), dependencies.loadDetail(other.cik, other.name, args, ctx)]);
264
- const samePeriod = !!first.latestForm && first.latestForm.periodOfReport === second.latestForm?.periodOfReport;
264
+ const comparedPeriod = overlapPeriod(first, second);
265
265
  const rows = buildFundOverlap(first, second);
266
- return { columns: [{ key: "ticker", header: "Ticker" }, { key: "type", header: "Type" }, { key: "issuer", header: "Issuer" }, { key: "weight", header: "First weight", align: "right", format: weight }, { key: "comparedWeight", header: "Second weight", align: "right", format: weight }], rows: rows.slice(0, limit).map(row => ({ ...row })), errors: [...(first.warnings ?? []), ...(second.warnings ?? []), ...(!samePeriod ? ["The funds have different latest reporting quarters."] : [])], metadata: { firstFund: first.name, secondFund: second.name, firstPeriod: first.latestForm?.periodOfReport ?? null, secondPeriod: second.latestForm?.periodOfReport ?? null, truncated: rows.length > limit } };
266
+ return { columns: [{ key: "ticker", header: "Ticker" }, { key: "type", header: "Type" }, { key: "issuer", header: "Issuer" }, { key: "weight", header: "First weight", align: "right", format: weight }, { key: "comparedWeight", header: "Second weight", align: "right", format: weight }], rows: rows.slice(0, limit).map(row => ({ ...row })), errors: [...(first.warnings ?? []), ...(second.warnings ?? []), ...(!comparedPeriod ? ["No reporting quarter is loaded for both funds."] : [])], metadata: { firstFund: first.name, secondFund: second.name, firstPeriod: first.latestForm?.periodOfReport ?? null, secondPeriod: second.latestForm?.periodOfReport ?? null, comparedPeriod, truncated: rows.length > limit, notices: comparedPeriod ? [`Quarter compared: ${comparedPeriod}`] : [] } };
267
267
  }
268
268
  if (view === "crowding") {
269
269
  const { rows: sourceRows, ...metadata } = await loadCrowding(ctx.signal);
@@ -165,6 +165,15 @@ export function buildBrowserRows(options: {
165
165
  });
166
166
  }
167
167
 
168
+ export type ThirteenFAmendmentKind = "restatement" | "new-holdings" | "unknown";
169
+
170
+ /** How a 13F-HR/A combines with its period's report; null for an original filing. */
171
+ export function amendmentKind(form: Pick<ThirteenFFormSummary, "isAmendment" | "amendmentType">): ThirteenFAmendmentKind | null {
172
+ if (!form.isAmendment) return null;
173
+ const type = form.amendmentType?.trim().toUpperCase();
174
+ return type === "RESTATEMENT" ? "restatement" : type === "NEW HOLDINGS" ? "new-holdings" : "unknown";
175
+ }
176
+
168
177
  function browserReportTotal(
169
178
  form: ThirteenFFormSummary | undefined,
170
179
  report: ThirteenFPeriodReport | undefined,
@@ -172,7 +181,8 @@ function browserReportTotal(
172
181
  ): number | null | undefined {
173
182
  if (!form) return undefined;
174
183
  if (report?.periodOfReport === form.periodOfReport) return report.complete ? report[key] : null;
175
- return !form.isAmendment || form.amendmentType?.trim().toUpperCase() === "RESTATEMENT" ? form[key] : null;
184
+ const kind = amendmentKind(form);
185
+ return kind == null || kind === "restatement" ? form[key] : null;
176
186
  }
177
187
 
178
188
  export function dedupeLatestForms(forms: ThirteenFFormSummary[]): ThirteenFFormSummary[] {
@@ -194,11 +204,11 @@ export function buildPeriodReports(forms: ThirteenFFormSummary[]): ThirteenFPeri
194
204
  const unique = [...new Map(forms.map((form) => [form.accessionNumber, form])).values()];
195
205
  for (const form of unique.sort(compareFormRecency)) {
196
206
  let report = byPeriod.get(form.periodOfReport);
197
- const amendmentType = form.amendmentType?.trim().toUpperCase();
198
- if (!form.isAmendment || amendmentType === "RESTATEMENT") {
207
+ const kind = amendmentKind(form);
208
+ if (kind == null || kind === "restatement") {
199
209
  report = { periodOfReport: form.periodOfReport, filings: [form], complete: true,
200
210
  tableValueTotal: form.tableValueTotal, tableEntryTotal: form.tableEntryTotal };
201
- } else if (amendmentType === "NEW HOLDINGS") {
211
+ } else if (kind === "new-holdings") {
202
212
  report = report ?? { periodOfReport: form.periodOfReport, filings: [], complete: false,
203
213
  tableValueTotal: null, tableEntryTotal: null };
204
214
  report.filings.push(form);
@@ -12,9 +12,15 @@ import { normalizeCik, searchThirteenFFunds } from "./api";
12
12
  import { loadFundDetail } from "./data";
13
13
  import { formatWeightMaybe } from "./format";
14
14
  import { isCikQuery } from "./model";
15
- import { buildFundOverlap, type FundOverlapRow } from "./overlap";
15
+ import { buildFundOverlap, overlapPeriod, type FundOverlapRow } from "./overlap";
16
16
  import type { FundDetailData, ThirteenFFund } from "./types";
17
17
 
18
+ /** The quarter compared, and the fund's newer latest quarter when it has one. */
19
+ function periodDetail(data: FundDetailData | null, period: string | null): string | undefined {
20
+ const latest = data?.latestForm?.periodOfReport;
21
+ return period && latest && latest !== period ? `${period} · latest ${latest}` : latest;
22
+ }
23
+
18
24
  export function FundOverlapView({ data, focused, width }: { data: FundDetailData; focused: boolean; width: number }) {
19
25
  const [query, setQuery] = usePluginPaneState<string>("overlap:query", "");
20
26
  const [target, setTarget] = usePluginPaneState<ThirteenFFund | null>("overlap:target", null);
@@ -74,16 +80,17 @@ export function FundOverlapView({ data, focused, width }: { data: FundDetailData
74
80
  if (isPlainKey(event, "/") && !target) { event.preventDefault?.(); focusSearch(); }
75
81
  if (isPlainKey(event, "m") && target) { event.preventDefault?.(); setMineOnly(value => !value); }
76
82
  });
77
- const mismatch = peer && data.latestForm?.periodOfReport !== peer.latestForm?.periodOfReport;
83
+ const period = peer ? overlapPeriod(data, peer) : null;
84
+ const mismatch = peer && !period;
78
85
  usePaneStatusFooter({ registrationId: "13f-overlap", loading: loading || loadingMore, error, hints: target ? [{ 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 }] });
79
- usePaneNoticeFooter({ registrationId: "13f-overlap-notice", focused, notices: [...(peer?.warnings ?? []), ...(mismatch ? ["The funds have different latest reporting quarters; overlap is unavailable."] : [])] });
86
+ usePaneNoticeFooter({ registrationId: "13f-overlap-notice", focused, notices: [...(peer?.warnings ?? []), ...(mismatch ? ["No reporting quarter is loaded for both funds; overlap is unavailable."] : [])] });
80
87
  const rows = useMemo(() => (peer ? buildFundOverlap(data, peer) : []).filter(row => !mineOnly || mine.has(row.ticker)).sort((a, b) => ((a[sort.id] ?? -1) - (b[sort.id] ?? -1)) * (sort.desc ? -1 : 1)), [peer, data, mineOnly, mine, sort]);
81
88
  const positionColumns: DataTableColumn[] = [{ id: "mine", label: "MINE", width: 5, align: "left" }, { id: "ticker", label: "TICKER", width: 10, align: "left" }, { id: "type", label: "TYPE", width: 6, align: "left" }, { id: "issuer", label: "ISSUER", width: Math.max(18, width - 58), align: "left" }, { id: "weight", label: "FIRST %", width: 12, align: "right" }, { id: "comparedWeight", label: "SECOND %", width: 12, align: "right" }];
82
89
  return <DataTableStackView<ThirteenFFund, DataTableColumn>
83
90
  focused={focused && !searchFocused} detailOpen={!!target} onBack={() => setTarget(null)} detailTitle={target?.name}
84
91
  detailContent={<Box flexDirection="column" flexGrow={1}>
85
- <KeyValueRow label="First" value={data.name} detail={data.latestForm?.periodOfReport} />
86
- <KeyValueRow label="Second" value={peer?.name ?? target?.name ?? ""} detail={peer?.latestForm?.periodOfReport} />
92
+ <KeyValueRow label="First" value={data.name} detail={periodDetail(data, period)} />
93
+ <KeyValueRow label="Second" value={peer?.name ?? target?.name ?? ""} detail={periodDetail(peer, period)} />
87
94
  <QueryBar width={width} filters={[{ id: "mine", kind: "toggle", label: "Mine", value: mineOnly, onChange: setMineOnly }]} />
88
95
  <DataTableView<FundOverlapRow, DataTableColumn> focused={focused} columns={positionColumns} items={rows} getItemKey={row => row.id}
89
96
  selection={{ kind: "id", selectedId: selectedPositionId, getId: row => row.id, onChange: setSelectedPositionId }}
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
- import { buildFundOverlap } from "./overlap";
2
+ import { buildFundOverlap, overlapPeriod } from "./overlap";
3
3
  import type { FundDetailData, ThirteenFFormSummary, ThirteenFHoldingRecord } from "./types";
4
4
  const form: ThirteenFFormSummary = { url: "", accessionNumber: "current", submissionType: "13F-HR", periodOfReport: "2026-06-30", filedAsOfDate: "2026-08-14", cik: "1", companyName: "Fund", tableValueTotal: 100, tableEntryTotal: 1, isAmendment: false };
5
5
  const holding = (overrides: Partial<ThirteenFHoldingRecord> = {}): ThirteenFHoldingRecord => ({ accessionNumber: "current", cik: "1", cusip: "one", ticker: "AAA", issuer: "A", titleOfClass: "COM", putCall: "", shareType: "SH", value: 20, shares: 2, investmentDiscretion: "SOLE", votingAuthoritySole: 2, votingAuthorityShared: 0, votingAuthorityNone: 0, ...overrides });
@@ -15,3 +15,15 @@ test("overlap cannot mix quarter ends and incomplete reports do not acquire weig
15
15
  const partial = detail([holding()], { latestReport: { periodOfReport: form.periodOfReport, filings: [form], complete: false, tableValueTotal: null, tableEntryTotal: null } });
16
16
  expect(buildFundOverlap(first, partial)[0]?.comparedWeight).toBeNull();
17
17
  });
18
+ test("a fund one quarter ahead compares at the other fund's latest quarter, weighted against that report", () => {
19
+ const previousForm = { ...form, accessionNumber: "prior", periodOfReport: "2026-03-31", tableValueTotal: 50 };
20
+ const report = (source: ThirteenFFormSummary, complete = true) => ({ periodOfReport: source.periodOfReport, filings: [source], complete, tableValueTotal: source.tableValueTotal, tableEntryTotal: 1 });
21
+ const ahead = detail([holding({ cusip: "new" })], { previousForm, previousHoldings: [holding({ value: 10 })], latestReport: report(form), previousReport: report(previousForm) });
22
+ const behind = detail([holding()], { latestForm: previousForm, latestReport: report(previousForm) });
23
+ expect(overlapPeriod(ahead, behind)).toBe("2026-03-31");
24
+ expect(buildFundOverlap(ahead, behind)).toEqual([{ id: "ONE||SH", ticker: "AAA", issuer: "A", type: "SH", weight: 0.2, comparedWeight: 0.4 }]);
25
+ const failed = { ...ahead, previousReport: report(previousForm, false), previousHoldings: [] };
26
+ expect(overlapPeriod(failed, behind)).toBeNull();
27
+ expect(buildFundOverlap(failed, behind)).toEqual([]);
28
+ expect(overlapPeriod(ahead, { ...ahead, cik: "2" })).toBe("2026-06-30");
29
+ });
@@ -9,11 +9,39 @@ export interface FundOverlapRow {
9
9
  weight: number | null;
10
10
  comparedWeight: number | null;
11
11
  }
12
+
13
+ /** Quarter ends a fund can be compared at: its latest report, and the previous one when it loaded. */
14
+ function comparablePeriods(data: FundDetailData): string[] {
15
+ return [
16
+ data.latestForm?.periodOfReport,
17
+ data.previousReport?.complete !== false ? data.previousForm?.periodOfReport : undefined,
18
+ ].filter((period): period is string => !!period);
19
+ }
20
+
21
+ /**
22
+ * The latest quarter end both funds reported among their two most recent
23
+ * reports, so a fund that has not filed the newest quarter yet still compares.
24
+ */
25
+ export function overlapPeriod(first: FundDetailData, second: FundDetailData): string | null {
26
+ const periods = new Set(comparablePeriods(second));
27
+ return comparablePeriods(first).filter(period => periods.has(period)).sort().at(-1) ?? null;
28
+ }
29
+
30
+ /** The fund's report for a period, as if it were the latest, with no prior quarter to show exits against. */
31
+ function atPeriod(data: FundDetailData, period: string): FundDetailData | null {
32
+ if (data.latestForm?.periodOfReport === period) return data;
33
+ if (data.previousForm?.periodOfReport !== period || data.previousReport?.complete === false) return null;
34
+ return { ...data, latestForm: data.previousForm, latestReport: data.previousReport, latestHoldings: data.previousHoldings, previousForm: null, previousReport: undefined, previousHoldings: [] };
35
+ }
36
+
12
37
  export function buildFundOverlap(first: FundDetailData, second: FundDetailData): FundOverlapRow[] {
13
- if (!first.latestForm || first.latestForm.periodOfReport !== second.latestForm?.periodOfReport) return [];
38
+ const period = overlapPeriod(first, second);
39
+ const left = period ? atPeriod(first, period) : null;
40
+ const right = period ? atPeriod(second, period) : null;
41
+ if (!left || !right) return [];
14
42
  // Rows are keyed by CUSIP, option side and share type, including when ticker aliases change.
15
- const peer = new Map(buildFundHoldingRows(second).filter(row => row.action !== "exit").map(row => [row.id, row]));
16
- return buildFundHoldingRows(first).filter(row => row.action !== "exit" && peer.has(row.id)).map(row => ({
43
+ const peer = new Map(buildFundHoldingRows(right).filter(row => row.action !== "exit").map(row => [row.id, row]));
44
+ return buildFundHoldingRows(left).filter(row => row.action !== "exit" && peer.has(row.id)).map(row => ({
17
45
  id: row.id, ticker: row.ticker, issuer: row.issuer, type: row.putCall || row.shareType,
18
46
  weight: row.weight, comparedWeight: peer.get(row.id)!.weight,
19
47
  })).sort((a, b) => (b.weight ?? -1) - (a.weight ?? -1));
@@ -248,6 +248,7 @@ describe("ThirteenFPane", () => {
248
248
 
249
249
  const holdingsFrame = testSetup!.captureCharFrame();
250
250
  expect(holdingsFrame).toContain("Holdings");
251
+ expect(holdingsFrame).toContain("filed 2026-05-15, restated");
251
252
  expect(holdingsFrame).toContain("AAPL");
252
253
  expect(holdingsFrame).not.toContain("Accession");
253
254
  });
@@ -271,7 +272,7 @@ describe("ThirteenFPane", () => {
271
272
  const detailFrame = testSetup!.captureCharFrame();
272
273
  expect(detailFrame).toContain("Accession");
273
274
  expect(detailFrame).toContain("0000000001-26-000001");
274
- expect(detailFrame).toContain("RESTATEMENT");
275
+ expect(detailFrame).toContain("Restatement");
275
276
  expect(detailFrame).toContain("Alpha Capital");
276
277
  expect(detailFrame).toContain("Apple Inc.");
277
278
  expect(detailFrame).toContain("CALL");
@@ -34,6 +34,7 @@ import {
34
34
  DEFAULT_TIMELINE_SORT,
35
35
  FUND_DETAIL_TABS,
36
36
  THIRTEENF_PANE_ID,
37
+ amendmentKind,
37
38
  hasComparable13FQuarter,
38
39
  browserSortFor,
39
40
  buildBrowserColumns,
@@ -520,7 +521,6 @@ export function FundDetailView({
520
521
  const currentSourceUrl = activeTab === "filings"
521
522
  ? openFiling?.url ?? selectedFiling?.url
522
523
  : selectedHoldingFiling?.url ?? latestForm?.url;
523
- const statusFiling = openFiling ?? latestForm;
524
524
  useEffect(() => {
525
525
  if (holdingSelectedId && visibleHoldingRows.some((row) => row.id === holdingSelectedId)) return;
526
526
  setHoldingSelectedId(visibleHoldingRows[0]?.id ?? null);
@@ -610,7 +610,8 @@ export function FundDetailView({
610
610
  });
611
611
 
612
612
  const holdingStats: StatItem[] = [
613
- { id: "reported", label: "Reported", value: data?.latestForm?.periodOfReport ?? "--", detail: `filed ${data?.latestForm?.filedAsOfDate || "--"}` },
613
+ { id: "reported", label: "Reported", value: data?.latestForm?.periodOfReport ?? "--",
614
+ detail: `filed ${data?.latestForm?.filedAsOfDate || "--"}${data?.latestForm && amendmentKind(data.latestForm) === "restatement" ? ", restated" : ""}` },
614
615
  data && hasComparable13FQuarter(data)
615
616
  ? { id: "compared", label: "Compared with", value: data.previousForm!.periodOfReport }
616
617
  : { id: "compared", label: "Compared with", value: "No prior quarter", tone: "muted" },
@@ -632,11 +633,6 @@ export function FundDetailView({
632
633
  const detailHints = useMemo<PaneHint[]>(() => hasHoldingFiling
633
634
  ? [{ id: "filing", key: "f", label: "iling", title: "Open Filing", onPress: openSelectedFilingInPane }]
634
635
  : [], [hasHoldingFiling, openSelectedFilingInPane]);
635
- const detailStatusInfo = useMemo<PaneFooterSegment[]>(() => (
636
- statusFiling?.isAmendment
637
- ? [{ id: "amended", parts: [{ text: "amended", tone: "warning" }] }]
638
- : []
639
- ), [statusFiling?.isAmendment]);
640
636
  usePaneStatusLinkFooter({
641
637
  registrationId: "thirteenf-detail",
642
638
  focused,
@@ -644,7 +640,6 @@ export function FundDetailView({
644
640
  source: openFiling ? "SEC 13F" : null,
645
641
  loading: activeTab !== "overlap" && status === "loading",
646
642
  error: activeTab === "overlap" ? null : error,
647
- info: activeTab === "overlap" ? [] : detailStatusInfo,
648
643
  showOpenHint: true,
649
644
  hints: detailHints,
650
645
  });
@@ -873,11 +868,19 @@ function FilingDetailView({
873
868
 
874
869
  usePaneNoticeFooter({ registrationId: "thirteenf-filing-notices", notices: [...new Set([...sourceWarnings, ...warnings])], focused });
875
870
 
871
+ // An untyped amendment may replace or add to the report, so only it is a caveat.
872
+ const kind = amendmentKind(filing);
873
+ const amendmentStat: StatItem | null = kind && {
874
+ id: "amendment",
875
+ label: "Amendment",
876
+ value: kind === "restatement" ? "Restatement" : kind === "new-holdings" ? "New holdings" : "Unknown type",
877
+ tone: kind === "unknown" ? "warning" : undefined,
878
+ };
876
879
  // The fund names the stack and `o` opens the filing, so neither repeats here.
877
880
  const summaryItems: StatItem[] = [
878
881
  { id: "filed", label: "Filed", value: filing.filedAsOfDate || "--" },
879
882
  { id: "form", label: "Form", value: filing.submissionType || "--" },
880
- ...(filing.isAmendment ? [{ id: "amendment", label: "Amendment", value: filing.amendmentType ?? "amended", tone: "warning" as const }] : []),
883
+ ...(amendmentStat ? [amendmentStat] : []),
881
884
  { id: "cik", label: "CIK", value: filing.cik },
882
885
  { id: "accession", label: "Accession", value: filing.accessionNumber },
883
886
  ];
@@ -13,7 +13,7 @@ import {
13
13
  formatShares,
14
14
  formatShortDate,
15
15
  } from "./format";
16
- import { positionType } from "./model";
16
+ import { amendmentKind, positionType } from "./model";
17
17
  import type {
18
18
  FilingPositionColumn,
19
19
  FilingPositionRow,
@@ -157,6 +157,12 @@ export function renderFilingPositionCell(
157
157
  }
158
158
  }
159
159
 
160
+ function amendmentLabel(row: FundTimelineRow): string | null {
161
+ const kind = amendmentKind(row);
162
+ if (!kind) return null;
163
+ return kind === "restatement" ? "Restatement" : kind === "new-holdings" ? "New holdings" : "Amendment (unknown)";
164
+ }
165
+
160
166
  export function renderTimelineCell(
161
167
  row: FundTimelineRow,
162
168
  column: FundTimelineColumn,
@@ -184,8 +190,8 @@ export function renderTimelineCell(
184
190
  };
185
191
  case "form":
186
192
  return {
187
- text: row.isAmendment ? row.amendmentType?.toUpperCase() === "RESTATEMENT" ? "Restatement" : row.amendmentType?.toUpperCase() === "NEW HOLDINGS" ? "New holdings" : "Amendment (unknown)" : row.submissionType,
188
- color: selectedColor ?? (row.isAmendment ? colors.warning : colors.textDim),
193
+ text: amendmentLabel(row) ?? row.submissionType,
194
+ color: selectedColor ?? (amendmentKind(row) === "unknown" ? colors.warning : colors.textDim),
189
195
  };
190
196
  }
191
197
  }
@@ -200,12 +200,6 @@ export function financialStatementCurrency(
200
200
  }
201
201
 
202
202
  export function financialStatementLimitations(financials: TickerFinancials | null | undefined): string[] {
203
- const industry = financials?.profile?.industry ?? "";
204
- const records = [financials?.fundamentals, ...(financials?.annualStatements ?? []), ...(financials?.quarterlyStatements ?? [])];
205
- const hasMetric = (keys: string[]) => records.some((record) => keys.some((key) => {
206
- const value = (record as Record<string, unknown> | undefined)?.[key];
207
- return typeof value === "number" && Number.isFinite(value);
208
- }));
209
203
  const limitations: string[] = [];
210
204
  const operatingNotice = financialOperatingSourceNotice(financials);
211
205
  if (operatingNotice) limitations.push(operatingNotice);
@@ -215,12 +209,6 @@ export function financialStatementLimitations(financials: TickerFinancials | nul
215
209
  if ([...(financials?.annualStatements ?? []), ...(financials?.quarterlyStatements ?? [])].some(row => row.unavailableFields?.includes("netIncome"))) {
216
210
  limitations.push("Parent net income is unavailable for some reported periods.");
217
211
  }
218
- if (/\breit\b/i.test(industry) && !hasMetric(["fundsFromOperations", "adjustedFundsFromOperations", "ffo", "affo"])) {
219
- limitations.push("FFO/AFFO are unavailable.");
220
- }
221
- if (/\bbanks?\b/i.test(industry) && !hasMetric(["commonEquityTier1Ratio", "cet1Ratio", "riskWeightedAssets"])) {
222
- limitations.push("Bank capital measures, including CET1 and risk-weighted assets, are unavailable.");
223
- }
224
212
  return limitations;
225
213
  }
226
214
 
@@ -24,7 +24,10 @@ export const paneSchemas = {
24
24
  discovery: {
25
25
  id: "financial-statements",
26
26
  aliases: ["financials", "financial statement", "income statement", "balance sheet", "cash flow statement"],
27
- limitations: ["One company per invocation; use GF for cross-company metric comparisons."],
27
+ limitations: [
28
+ "One company per invocation; use GF for cross-company metric comparisons.",
29
+ "Excludes bank regulatory capital (CET1, risk-weighted assets) and REIT FFO/AFFO.",
30
+ ],
28
31
  screenshotReadiness: "ready",
29
32
  },
30
33
  },
@@ -770,7 +770,7 @@ describe("TickerResearchPane", () => {
770
770
 
771
771
  const frame = testSetup.captureCharFrame();
772
772
  expect(frame).toContain("€125");
773
- expect(frame).toContain("2.2B USD");
773
+ expect(frame).toContain("2.20B USD");
774
774
  expect(frame).toContain("Account");
775
775
  expect(frame).toContain("Qty");
776
776
  expect(frame).toContain("Avg");