gloomberb 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/errors.ts +38 -0
  3. package/src/app/runtime/startup.ts +3 -1
  4. package/src/capabilities/chart-series.ts +2 -1
  5. package/src/cli/commands/market.ts +9 -4
  6. package/src/cli/commands/overview.ts +5 -6
  7. package/src/cli/commands/search.ts +13 -13
  8. package/src/cli/context.ts +1 -0
  9. package/src/cli/glossary.ts +2 -1
  10. package/src/cli/history-rows.ts +35 -7
  11. package/src/cli/known-gaps.ts +11 -0
  12. package/src/cli/listing-arg.ts +17 -1
  13. package/src/cli/quote-answer.ts +29 -6
  14. package/src/components/command-bar/routes/root/runtime.ts +27 -5
  15. package/src/components/command-bar/selection-runtime.ts +12 -29
  16. package/src/components/layout/detached-pane-shell.tsx +2 -2
  17. package/src/components/layout/header.tsx +2 -2
  18. package/src/components/layout/pane/footer/index.tsx +1 -0
  19. package/src/components/layout/pane/footer/model.ts +33 -1
  20. package/src/components/layout/pane/header.tsx +2 -2
  21. package/src/components/layout/pane/status-footer.ts +2 -1
  22. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  23. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  24. package/src/components/ui/display.tsx +16 -5
  25. package/src/components/ui/icon.tsx +8 -2
  26. package/src/core/app-services.ts +11 -2
  27. package/src/data/app-persistence.ts +9 -7
  28. package/src/data/plugin-cache.ts +15 -0
  29. package/src/data/plugin-state-store.ts +81 -24
  30. package/src/data/resource-store.ts +130 -58
  31. package/src/data/session-store.ts +31 -8
  32. package/src/data/sqlite/access.ts +313 -0
  33. package/src/data/sqlite/checkpointer.ts +198 -0
  34. package/src/data/sqlite/database.ts +47 -4
  35. package/src/data/sqlite/retry.ts +50 -9
  36. package/src/data/ticker-store.ts +57 -23
  37. package/src/market-data/realized-volatility.ts +4 -2
  38. package/src/plugins/auto-update-node.ts +10 -6
  39. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  40. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  41. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  42. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  43. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  44. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  45. package/src/plugins/builtin/cot/client.ts +28 -2
  46. package/src/plugins/builtin/cot/headless.ts +4 -1
  47. package/src/plugins/builtin/cot/index.tsx +4 -2
  48. package/src/plugins/builtin/cot/model.ts +58 -7
  49. package/src/plugins/builtin/cot/pane.tsx +6 -2
  50. package/src/plugins/builtin/cpi/client.ts +6 -5
  51. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  52. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  53. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  54. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  55. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  56. package/src/plugins/builtin/doe/client.ts +6 -5
  57. package/src/plugins/builtin/doe/prices.ts +2 -3
  58. package/src/plugins/builtin/doe/series.ts +4 -4
  59. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  60. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  61. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  62. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  63. package/src/plugins/builtin/executives/data.ts +6 -1
  64. package/src/plugins/builtin/executives/pane.tsx +9 -21
  65. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  66. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  67. package/src/plugins/builtin/gpu/client.ts +4 -1
  68. package/src/plugins/builtin/gpu/series.ts +2 -3
  69. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  70. package/src/plugins/builtin/jodi/client.ts +4 -1
  71. package/src/plugins/builtin/market-movers/client.ts +1 -0
  72. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  73. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  74. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  75. package/src/plugins/builtin/money-markets/series.ts +3 -5
  76. package/src/plugins/builtin/power/gas-client.ts +2 -1
  77. package/src/plugins/builtin/power/prices-client.ts +3 -2
  78. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  79. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  80. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  81. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  82. package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
  83. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  84. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  85. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  86. package/src/plugins/builtin/short-volume/series.ts +2 -3
  87. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  88. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  89. package/src/plugins/builtin/thirteenf/api.ts +11 -16
  90. package/src/plugins/builtin/thirteenf/data.ts +34 -6
  91. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  92. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  93. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  94. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  95. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  96. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  97. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  98. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  99. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  100. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  101. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  102. package/src/plugins/builtin/volatility/client.ts +43 -16
  103. package/src/plugins/builtin/volatility/headless.ts +5 -2
  104. package/src/plugins/builtin/volatility/model.ts +17 -6
  105. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  106. package/src/plugins/builtin/volatility/series.ts +2 -1
  107. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  108. package/src/plugins/installer.ts +50 -39
  109. package/src/renderers/dom/desktop/icons.tsx +3 -2
  110. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  111. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  112. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  113. package/src/renderers/electrobun/bun/window/create.ts +2 -0
  114. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  115. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  116. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  117. package/src/series/resolve.ts +2 -1
  118. package/src/series/types.ts +5 -0
  119. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  120. package/src/telemetry/stall-reports-node.ts +13 -2
  121. package/src/telemetry/stall-reports.ts +189 -13
  122. package/src/test-support/outage.ts +8 -0
  123. package/src/tickers/company-name-listing.ts +59 -29
  124. package/src/tickers/company-name.ts +65 -1
  125. package/src/tickers/search/home-listing.ts +93 -8
  126. package/src/tickers/search/index.ts +1 -1
  127. package/src/time-series/market.ts +5 -3
  128. package/src/time-series/price-comparison.ts +20 -2
  129. package/src/time-series/resolve.ts +20 -1
  130. package/src/time-series/types.ts +7 -0
  131. package/src/types/financials.ts +18 -3
  132. package/src/ui/host.tsx +6 -1
  133. package/src/utils/financial-statements.ts +73 -16
  134. package/src/utils/fundamentals.ts +17 -5
  135. package/src/utils/lease-liabilities.ts +54 -0
  136. package/src/utils/main-thread-stall.ts +47 -2
  137. package/src/utils/perf-marks.ts +23 -11
  138. package/src/utils/price-history-integrity.ts +47 -1
@@ -4,6 +4,7 @@ import { formatNumber, pickUnit } from "../../../../utils/format";
4
4
  import { pricePointIntegrity } from "../../../../utils/price-history-integrity";
5
5
  import { formatPerShareNumber } from "../../../../utils/reported-money";
6
6
  import { hasStatementGap } from "../../../../utils/statement-gaps";
7
+ import { debtAndLeases, leaseBasis, type LeaseBasis } from "../../../../utils/lease-liabilities";
7
8
  import { cashAndShortTermInvestments, excludesCashRunway, formatRunwayMonths, SHORT_RUNWAY_HISTORY, monthlyOperatingBurn, QUARTER_MONTHS, reportsShortTermTotal, runwayMonths, YEAR_MONTHS } from "../../../../utils/cash-runway";
8
9
  import {
9
10
  buildPreviousStatementMap,
@@ -58,6 +59,8 @@ export interface RatioPeriod {
58
59
  price: RatioAmount;
59
60
  /** The filer reports cash and short-term investments as a total in some period on file (utils/cash-runway). */
60
61
  shortTermTotalOnFile?: boolean;
62
+ /** How net debt treats the lease liabilities the listing's balance sheets report; absent when none do. */
63
+ leaseBasis?: LeaseBasis;
61
64
  }
62
65
 
63
66
  interface RatioInputDef {
@@ -75,6 +78,13 @@ interface RatioInputDef {
75
78
  trailing?: boolean;
76
79
  /** A shorter name for quarterly columns, so the label, TTM and its unit fit the label column. */
77
80
  trailingLabel?: string;
81
+ /** Debt net of cash: where the listing reports lease liabilities, a row naming them follows it (withLeaseRow). */
82
+ netDebt?: boolean;
83
+ /**
84
+ * Not an operand: a line the input before it includes or leaves out, shown under it. Neither its value nor
85
+ * its gap enters the ratio.
86
+ */
87
+ memo?: boolean;
78
88
  value: (period: RatioPeriod) => RatioAmount;
79
89
  }
80
90
 
@@ -245,12 +255,41 @@ function marketCap(period: RatioPeriod): RatioAmount {
245
255
  return period.price * shares;
246
256
  }
247
257
 
258
+ /**
259
+ * Total debt less cash and short-term investments, with the balance sheet's lease liabilities where the listing's
260
+ * are included (utils/lease-liabilities). A balance sheet without its lease line then has no net debt.
261
+ */
262
+ function netDebt({ statement, leaseBasis: basis }: RatioPeriod): RatioAmount {
263
+ const cash = cashAndShortTerm(statement);
264
+ if (basis === "included") {
265
+ const lines = debtAndLeases(statement);
266
+ return lines === undefined || cash === undefined ? "not-reported" : lines.totalDebt + lines.leases - cash;
267
+ }
268
+ const debt = finite(statement.totalDebt);
269
+ return debt === undefined || cash === undefined ? "not-reported" : debt - cash;
270
+ }
271
+
248
272
  function enterpriseValue(period: RatioPeriod): RatioAmount {
249
- const debt = finite(period.statement.totalDebt);
250
- const cash = cashAndShortTerm(period.statement);
251
- if (debt === undefined || cash === undefined) return "not-reported";
273
+ const debt = netDebt(period);
274
+ if (isGap(debt)) return debt;
252
275
  const cap = marketCap(period);
253
- return isGap(cap) ? cap : cap + debt - cash;
276
+ return isGap(cap) ? cap : cap + debt;
277
+ }
278
+
279
+ /** The lease liabilities net debt includes, or a US GAAP filer's operating leases it leaves out. */
280
+ const LEASE_ROW_LABEL: Record<LeaseBasis, string> = { included: "incl. leases", excluded: "excl. op. leases" };
281
+
282
+ /** The ratio with a row naming the listing's lease liabilities under its net debt, when it reports any. */
283
+ function withLeaseRow(def: RatioDef, basis: LeaseBasis | undefined): RatioDef {
284
+ const at = def.inputs.findIndex((input) => input.netDebt);
285
+ if (!basis || at < 0) return def;
286
+ const row: RatioInputDef = {
287
+ label: LEASE_ROW_LABEL[basis],
288
+ format: "money",
289
+ memo: true,
290
+ value: ({ statement }) => reported(finite(statement.capitalLeaseObligations)),
291
+ };
292
+ return { ...def, inputs: [...def.inputs.slice(0, at + 1), row, ...def.inputs.slice(at + 1)] };
254
293
  }
255
294
 
256
295
  const DSO: RatioDef = {
@@ -378,15 +417,7 @@ export const RATIO_TABS: RatioTabDef[] = [
378
417
  label: "Net Debt / EBITDA",
379
418
  format: "multiple",
380
419
  inputs: [
381
- {
382
- label: "Net Debt",
383
- format: "money",
384
- value: ({ statement }) => {
385
- const debt = finite(statement.totalDebt);
386
- const cash = cashAndShortTerm(statement);
387
- return debt === undefined || cash === undefined ? "not-reported" : debt - cash;
388
- },
389
- },
420
+ { label: "Net Debt", format: "money", netDebt: true, value: netDebt },
390
421
  trailingFlow("EBITDA", line("ebitda"), "÷"),
391
422
  ],
392
423
  compute: ([debt, ebitda]) => over(debt!, ebitda!),
@@ -517,7 +548,7 @@ export const RATIO_TABS: RatioTabDef[] = [
517
548
  label: "EV / EBITDA",
518
549
  format: "multiple",
519
550
  inputs: [
520
- { label: "Enterprise Value", format: "money", value: enterpriseValue },
551
+ { label: "Enterprise Value", format: "money", netDebt: true, value: enterpriseValue },
521
552
  trailingFlow("EBITDA", line("ebitda"), "÷"),
522
553
  ],
523
554
  // A negative enterprise value makes the multiple meaningless too.
@@ -568,10 +599,11 @@ function inputAmount(input: RatioInputDef, period: RatioPeriod): RatioAmount {
568
599
 
569
600
  export function evaluateRatio(def: RatioDef, period: RatioPeriod): RatioCell {
570
601
  const inputs = def.inputs.map((input) => inputAmount(input, period));
571
- const gap = GAP_ORDER.find((candidate) => inputs.includes(candidate));
602
+ const operands = inputs.filter((_, index) => !def.inputs[index]!.memo);
603
+ const gap = GAP_ORDER.find((candidate) => operands.includes(candidate));
572
604
  if (gap) return { value: gap, inputs };
573
- const values = inputs.map((amount, index) => (
574
- def.inputs[index]!.annualized && period.quarterly ? (amount as number) * QUARTERS_PER_YEAR : amount as number
605
+ const values = def.inputs.flatMap((input, index) => (
606
+ input.memo ? [] : [input.annualized && period.quarterly ? (inputs[index] as number) * QUARTERS_PER_YEAR : inputs[index] as number]
575
607
  ));
576
608
  const value = def.compute(values, period);
577
609
  return { value: isGap(value) || Number.isFinite(value) ? value : "not-meaningful", inputs };
@@ -702,6 +734,7 @@ export function ratioPeriods(
702
734
  onFile: { annual: readonly FinancialStatement[]; quarterly: readonly FinancialStatement[] } = { annual: [], quarterly: [] },
703
735
  ): RatioPeriod[] {
704
736
  const shortTermTotalOnFile = reportsShortTermTotal([...onFile.annual, ...onFile.quarterly, ...statements]);
737
+ const leases = leaseBasis([...onFile.annual, ...onFile.quarterly, ...statements]);
705
738
  return statements.map((statement) => {
706
739
  const quarterly = period === "quarterly" && statement.date !== "TTM";
707
740
  return {
@@ -711,6 +744,7 @@ export function ratioPeriods(
711
744
  ...(quarterly ? { trailing: trailingYear(statement, onFile.annual, onFile.quarterly) } : {}),
712
745
  price: price(statement),
713
746
  shortTermTotalOnFile,
747
+ ...(leases ? { leaseBasis: leases } : {}),
714
748
  };
715
749
  });
716
750
  }
@@ -758,11 +792,12 @@ export function buildRatioTableModel(
758
792
  ): RatioTableModel {
759
793
  const quarterly = periods.some((period) => period.quarterly);
760
794
  const totalOnFile = periods.some((period) => period.shortTermTotalOnFile);
795
+ const leases = periods.find((period) => period.leaseBasis)?.leaseBasis;
761
796
  const ratios = visibleRatios(tab, quarterly)
762
797
  .filter((def) => def.format !== "months" || !excludesCashRunway(profile))
763
798
  .map((def) => def.format === "months" && !totalOnFile
764
799
  ? { ...def, inputs: def.inputs.map((input, index) => index === 0 ? { ...input, label: "Cash" } : input) }
765
- : def);
800
+ : withLeaseRow(def, leases));
766
801
  const cells = new Map(ratios.map((def) => [def.id, periods.map((period) => evaluateRatio(def, period))]));
767
802
  const inputs = ratios.flatMap((def) => def.inputs.map((input, index) => {
768
803
  const values = cells.get(def.id)!.map((cell) => cell.inputs[index]).filter((value) => typeof value === "number");
@@ -1,4 +1,7 @@
1
- import { FINANCIAL_VINTAGE_NOTICE, filledPeriodsNote, stalePeriodNote, statementHistoryNotes } from "../../../utils/financial-statements";
1
+ import {
2
+ FINANCIAL_VINTAGE_NOTICE, filledPeriodsNote, hasOlderStatements, reportsHalfYearly, stalePeriodNote, statementHistoryNotes,
3
+ } from "../../../utils/financial-statements";
4
+ import { canonicalExchange } from "../../../utils/exchanges";
2
5
  import { hasValidQuoteObservationTime } from "../../../market-data/quotes/freshness";
3
6
  import { EXTENDED_SESSION_LABELS, getQuoteSessionFields, quoteHeadlineTime, type ExtendedSession } from "../../../market-data/market/status";
4
7
  import type { HeadlessPaneColumn, HeadlessPaneDefinition, HeadlessPaneFreshness, HeadlessPaneOptionValues } from "../../../types/headless";
@@ -6,7 +9,8 @@ import type { TimeRange } from "../../../time-series/range";
6
9
  import { formatNumber, formatPercentRaw } from "../../../utils/format";
7
10
  import { formatMarketPrice, formatMarketPriceWithCurrency, formatPriceObservation, penceFigure, quoteFormatOptions, withCurrencyMinorDigits, type MarketFormatOptions } from "../../../market-data/market/format";
8
11
  import {
9
- MISSING_VOLUME_TEXT, missingVolumeCount, missingVolumeNotice, pricePointValues, priceHistoryIntegrityNotice,
12
+ MISSING_VOLUME_TEXT, missingVolumeCount, missingVolumeNotice, pricePointValues, pricePointWickFlags, priceHistoryIntegrityNotice,
13
+ priceHistoryWickNotice,
10
14
  } from "../../../utils/price-history-integrity";
11
15
  import {
12
16
  buildFinancialTableModel, financialStatementCurrency, financialStatementDateNotice, financialStatementLimitations,
@@ -151,18 +155,23 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
151
155
  const stale = table ? stalePeriodNote(financialStatementPeriods(table.statements, table.subTab)) : null;
152
156
  // Years filled from the listing's exchange, with the lines this table shows.
153
157
  const filled = table ? filledPeriodsNote(table.statements, financialSubTabFields(table.subTab)) : null;
158
+ const periodDates = dates.map(({ date }) => date);
159
+ const missing = rows.length ? null : await missingPeriodsError(ctx, symbol, financials, requestedPeriod, hasRequestedStatements, options.statement);
154
160
  return {
155
161
  rows, columns, unavailableSymbols: rows.length ? [] : [symbol],
156
- ...(!hasRequestedStatements ? { errors: [`${symbol}: No ${requestedPeriod} financial statement coverage.`] } : {}),
162
+ ...(missing ? { errors: [missing] } : {}),
157
163
  // The report closes with how old its latest period is, how far back its periods go, and how to read further.
158
- notes: [...stale ? [stale] : [], ...filled ? [filled] : [], ...offering ? [offering] : [], ...statementHistoryNotes(requestedPeriod, dates.map(({ date }) => date), symbol)],
164
+ notes: [...stale ? [stale] : [], ...filled ? [filled] : [], ...offering ? [offering] : [],
165
+ ...statementHistoryNotes(requestedPeriod, periodDates, symbol, await reportOlderStatements(ctx, symbol, requestedPeriod, periodDates))],
159
166
  metadata: {
160
167
  symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null, unit: table?.unit ?? null, quoteCurrency: financials.quote?.currency ?? null,
161
168
  statement: table?.subTab.key ?? options.statement, statementLabel: table?.subTab.name ?? null,
162
169
  period: requestedPeriod, growthBasis: requestedPeriod === "quarterly" ? "QoQ" : "YoY", columns: dates,
163
170
  shareBasis: shareBases.size === 1 ? [...shareBases][0] ?? null : null,
164
171
  ...(receiptPeriods.length > 0 ? { adrRatio: financials.fundamentals?.adrRatio ?? financials.quote?.adrRatio ?? null } : {}),
165
- notices: rows.length ? [FINANCIAL_VINTAGE_NOTICE, ...(shareBasisNotice ? [shareBasisNotice] : [])] : [],
172
+ // A quarter's change is against the quarter before it, which a reader of a table of quarters must be told.
173
+ notices: rows.length ? [FINANCIAL_VINTAGE_NOTICE, ...requestedPeriod === "quarterly" ? [QUARTERLY_GROWTH_NOTICE] : [],
174
+ ...(shareBasisNotice ? [shareBasisNotice] : [])] : [],
166
175
  limitations: financialStatementLimitations(financials),
167
176
  dateProvenance: financialStatementDateNotice(table?.statements ?? []),
168
177
  },
@@ -172,6 +181,44 @@ export const financialStatementsHeadless: HeadlessPaneDefinition<"rows"> = {
172
181
 
173
182
  const reportAmount = (amount: RatioAmount) => (typeof amount === "number" ? amount : null);
174
183
 
184
+ /** What a table of quarters says its growth is measured against. */
185
+ const QUARTERLY_GROWTH_NOTICE = "Growth is vs prior quarter.";
186
+
187
+ type HeadlessContext = Parameters<typeof financialStatementsHeadless.load>[1];
188
+
189
+ /** Whether GF, reading the listing's extended statement history, goes back further than the report (statementHistoryNotes). */
190
+ function reportOlderStatements(ctx: HeadlessContext, symbol: string, period: "annual" | "quarterly", dates: readonly string[]) {
191
+ return hasOlderStatements(async () => {
192
+ const { symbol: bare, exchange } = await resolveHeadlessInstrument(ctx, symbol);
193
+ return ctx.marketData.getTickerFinancials(bare, exchange ?? "", { statementHistory: "extended" });
194
+ }, period, dates);
195
+ }
196
+
197
+ /**
198
+ * Why a statements report has no rows, and what to read instead: a listing
199
+ * with years but no quarters names its years, and says so of a company that
200
+ * reports half-yearly (Qantas on ASX). Null when it has years and quarters,
201
+ * whatever this table lacks.
202
+ */
203
+ async function missingPeriodsError(
204
+ ctx: HeadlessContext,
205
+ symbol: string,
206
+ financials: Awaited<ReturnType<typeof loadHeadlessFinancials>>,
207
+ period: "annual" | "quarterly",
208
+ hasRequestedStatements: boolean,
209
+ statement: unknown,
210
+ ): Promise<string | null> {
211
+ const hasYears = financials.annualStatements.length > 0;
212
+ if (period === "annual" || !hasYears) return hasRequestedStatements ? null : `${symbol}: No ${period} financial statement coverage.`;
213
+ const exchange = await resolveHeadlessInstrument(ctx, symbol).then(({ exchange }) => canonicalExchange(exchange ?? ""))
214
+ .catch(logFailure("ticker-detail.headless", "Resolving the listing of a statements report failed", ""));
215
+ const halfYearly = reportsHalfYearly(exchange, financials.quarterlyStatements);
216
+ if (hasRequestedStatements && !halfYearly) return null;
217
+ const tab = typeof statement === "string" && statement && statement !== "income" ? ` --statement ${statement}` : "";
218
+ const years = `For its years: gloom fn FA ${symbol} --period annual${tab}`;
219
+ return halfYearly ? `${symbol} reports half-yearly, not quarterly. ${years}` : `${symbol}: No quarterly financial statement coverage. ${years}`;
220
+ }
221
+
175
222
  /** The note that an offering filed after the latest balance sheet is not in its cash yet; a failed read leaves it out. */
176
223
  async function reportOfferingNote(
177
224
  ctx: Parameters<typeof financialStatementsHeadless.load>[1],
@@ -240,16 +287,18 @@ async function loadRatioReport(
240
287
  const runwayShown = liquidity && table.ratios.some((def) => def.format === "months"
241
288
  && table.cells.get(def.id)!.some((cell) => typeof cell.value === "number"));
242
289
  const offering = liquidity ? await reportOfferingNote(ctx, symbol, financials) : null;
290
+ const periodDates = dates.map(({ date }) => date);
291
+ const missing = rows.length ? null : await missingPeriodsError(ctx, symbol, financials, period, hasRequestedStatements, tab.key);
243
292
  return {
244
293
  rows, columns, unavailableSymbols: rows.length ? [] : [symbol],
245
- ...(!hasRequestedStatements ? { errors: [`${symbol}: No ${period} financial statement coverage.`] } : {}),
294
+ ...(missing ? { errors: [missing] } : {}),
246
295
  ...(tab.key === "valuation" && hasRequestedStatements && !history?.length ? { complete: false } : {}),
247
296
  notes: [
248
297
  ...[stalePeriodNote(dates.map(({ periodEnd }) => ({ date: periodEnd, lead: true })))].filter((note): note is string => !!note),
249
298
  ...[filledPeriodsNote(table?.periods.map(({ statement }) => statement) ?? [])].filter((note): note is string => !!note),
250
299
  ...offering ? [offering] : [],
251
300
  ...runwayShown ? [runwayMethod(reportsShortTermTotal([...financials.quarterlyStatements, ...financials.annualStatements]))] : [],
252
- ...statementHistoryNotes(period, dates.map(({ date }) => date), symbol),
301
+ ...statementHistoryNotes(period, periodDates, symbol, await reportOlderStatements(ctx, symbol, period, periodDates)),
253
302
  ],
254
303
  metadata: {
255
304
  symbol, name: financials.quote?.name ?? symbol, currency: statementCurrency ?? null,
@@ -453,7 +502,9 @@ export const historicalPricesHeadless: HeadlessPaneDefinition<"rows"> = {
453
502
  const drawdown = maxDrawdown(shown);
454
503
  const drawdownLine = drawdown ? maxDrawdownText(drawdown, { basis: `${historyBarSizeWord(size)} closes`, price: (value) => formatBarPrice(value, decimals),
455
504
  date: (value) => isDailyOrLongerBar(size) ? value.slice(0, 10) : formatUtcTime(value) }) : null;
456
- const reportNotices = [drawdownLine, notice].filter((line): line is string => line != null);
505
+ // A wick left out of the range is said, though the bar's close still counts.
506
+ const wickNotice = priceHistoryWickNotice(points.filter((point) => pricePointWickFlags(point).length > 0).length);
507
+ const reportNotices = [drawdownLine, notice, wickNotice].filter((line): line is string => line != null);
457
508
  return {
458
509
  rows: shown,
459
510
  columns: historyColumns(size, marked, false, false, decimals),
@@ -1,4 +1,5 @@
1
- import { createPluginCache } from "../../../data/plugin-cache";
1
+ import { createPluginCache, savedCopyForTransientFailure } from "../../../data/plugin-cache";
2
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
2
3
  import type { ConnectionHealthRegistry } from "../../../core/connection-health";
3
4
  import type { PluginPersistence } from "../../../types/plugin";
4
5
  import {
@@ -37,6 +38,8 @@ export interface TreasuryAuctionsResult {
37
38
  fetchedAt: number;
38
39
  /** True when the network failed and expired cache was served instead. */
39
40
  stale: boolean;
41
+ /** The refresh failed and the copy saved at this time (ms) answered instead. */
42
+ savedAt?: number;
40
43
  refreshError?: string;
41
44
  }
42
45
 
@@ -84,11 +87,18 @@ export async function loadTreasuryAuctions(
84
87
  window: AuctionHistoryWindow = AUCTION_HISTORY_DAYS,
85
88
  ): Promise<TreasuryAuctionsResult> {
86
89
  if (window === AUCTION_HISTORY_ALL) return loadAllTreasuryAuctions(force, loader);
87
- const result = await cache.load(`recent:${window}`, walk(loader, () => auctionWindowQuery(window)), { force });
88
- return {
90
+ // A saved board answers only for a failure asking again may get past; a refusal or an invalid page is the error.
91
+ const result = savedCopyForTransientFailure(await cache.load(`recent:${window}`, walk(loader, () => auctionWindowQuery(window)), { force }));
92
+ return answered({
89
93
  auctions: result.data, fetchedAt: result.fetchedAt, stale: result.stale,
94
+ ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}),
90
95
  ...(result.refreshError ? { refreshError: result.refreshError } : {}),
91
- };
96
+ });
97
+ }
98
+
99
+ /** A board a saved copy answered: marked as one, so a command ends by saying when it was saved, not with its failure. */
100
+ function answered(result: TreasuryAuctionsResult): TreasuryAuctionsResult {
101
+ return result.savedAt != null ? servedFromSavedCopy(result, result.savedAt) : result;
92
102
  }
93
103
 
94
104
  /**
@@ -100,16 +110,18 @@ export async function loadTreasuryAuctions(
100
110
  async function loadAllTreasuryAuctions(force: boolean, loader: AuctionLoader): Promise<TreasuryAuctionsResult> {
101
111
  const boundary = auctionArchiveBoundary();
102
112
  const [recent, archive] = await Promise.all([
103
- cache.load(`since:${boundary}`, walk(loader, () => ({ from: boundary })), { force }),
113
+ cache.load(`since:${boundary}`, walk(loader, () => ({ from: boundary })), { force }).then(savedCopyForTransientFailure),
104
114
  archiveCache.load(`archive:${boundary}`, walk(loader, () => ({
105
115
  from: AUCTION_HISTORY_START, before: boundary, pageSize: AUCTION_ARCHIVE_PAGE_SIZE,
106
- }))),
116
+ }))).then(savedCopyForTransientFailure),
107
117
  ]);
108
118
  const seen = new Set<string>();
109
119
  const auctions = [...recent.data, ...archive.data].filter((auction) => !seen.has(auction.id) && Boolean(seen.add(auction.id)));
110
120
  const refreshError = recent.refreshError ?? archive.refreshError;
111
- return {
121
+ const savedAt = Math.min(...[recent, archive].map((part) => part.source === "stale-fallback" ? part.fetchedAt : Infinity));
122
+ return answered({
112
123
  auctions, fetchedAt: recent.fetchedAt, stale: recent.stale || archive.stale,
124
+ ...(Number.isFinite(savedAt) ? { savedAt } : {}),
113
125
  ...(refreshError ? { refreshError } : {}),
114
- };
126
+ });
115
127
  }
@@ -1,3 +1,4 @@
1
+ import { ApiRequestError } from "../../../api-client/errors";
1
2
  import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
3
  import type { TreasuryAuction, TreasuryAuctionRaw } from "./types";
3
4
 
@@ -222,9 +223,8 @@ export async function fetchTreasuryAuctions(
222
223
  ): Promise<TreasuryAuction[]> {
223
224
  return fetchAuctionPages(async (page) => {
224
225
  const response = await TREASURY_FETCH.fetch(buildAuctionsUrl(query, page));
225
- if (!response.ok) {
226
- throw new Error(`Treasury Fiscal Data request failed (${response.status})`);
227
- }
226
+ // The status stays: an overloaded server is a failure a saved board may answer for, a refusal is not.
227
+ if (!response.ok) throw new ApiRequestError(`Treasury Fiscal Data request failed (${response.status})`, response.status);
228
228
  return response.json();
229
229
  });
230
230
  }
@@ -5,6 +5,7 @@ import type {
5
5
  import { formatCompact } from "../../../utils/format";
6
6
  import { AUCTION_HISTORY_ALL, AUCTION_HISTORY_START, fetchTreasuryAuctions, type AuctionHistoryWindow } from "./client";
7
7
  import { loadTreasuryAuctions, type TreasuryAuctionsResult } from "./cache";
8
+ import { unansweredRefreshError } from "../../../data/saved-copies";
8
9
  import type { TreasuryAuction } from "./types";
9
10
  import { FIXED_INCOME_VIEW_STATE } from "../fixed-income/model";
10
11
  import {
@@ -156,7 +157,8 @@ function createTreasuryAuctionsHeadless(
156
157
  ];
157
158
  return {
158
159
  complete: !result.refreshError,
159
- errors: result.refreshError ? [result.refreshError] : [],
160
+ // A saved copy answering the failed refresh ends the report by saying when it was saved instead.
161
+ errors: [unansweredRefreshError(result)].filter((error): error is string => !!error),
160
162
  notes,
161
163
  rows: auctions.map((auction) => ({
162
164
  ...auction,
@@ -13,6 +13,8 @@ import {
13
13
  type DataTableRootKeyContext,
14
14
  } from "../../../components";
15
15
  import { loadingErrorFooterInfo, usePaneRefreshKey } from "../../../components/data-table/table-pane";
16
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
17
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
16
18
  import { usePaneInstance } from "../../../state/app/context";
17
19
  import { colors } from "../../../theme/colors";
18
20
  import type { PaneProps } from "../../../types/plugin";
@@ -208,8 +210,10 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
208
210
  const auctions = resource.data?.auctions ?? NO_AUCTIONS;
209
211
  const stale = resource.data?.stale ?? false;
210
212
  const fetchedAt = resource.data?.fetchedAt ?? null;
213
+ // A refresh that failed shows the auctions an earlier read saved, and says when that copy is from.
214
+ const savedAt = resource.data?.savedAt ?? null;
211
215
  // A cache that could not be refreshed arrives as a result, not a failure.
212
- const error = resource.error ?? (resource.loading ? null : resource.data?.refreshError ?? null);
216
+ const error = resource.error ?? (resource.loading || savedAt != null ? null : resource.data?.refreshError ?? null);
213
217
  // Once auctions have loaded, a refresh runs without a loading state.
214
218
  const loading = resource.loading && !resource.data;
215
219
  const [filter, setFilter] = usePluginPaneState<AuctionFilter>("activeTab", "all");
@@ -302,8 +306,9 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
302
306
 
303
307
  usePaneFooter(TREASURY_AUCTIONS_PANE_ID, () => {
304
308
  const info = loadingErrorFooterInfo(loading, error);
305
- if (stale) info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] });
306
- if (fetchedAt) {
309
+ if (savedAt != null) info.push(...savedCopyFooterInfo([savedAt]));
310
+ else if (stale) info.push(STALE_SEGMENT);
311
+ if (fetchedAt && savedAt == null) {
307
312
  info.push({ id: "updated", parts: [{ text: formatRelativeAge(fetchedAt), tone: "muted" }] });
308
313
  }
309
314
  return {
@@ -323,6 +328,7 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
323
328
  fetchedAt,
324
329
  focusSearch,
325
330
  loading,
331
+ savedAt,
326
332
  stale,
327
333
  ]);
328
334
 
@@ -14,6 +14,7 @@ import { buildVolatilityData, buildVolatilityTermHistory, IMPLIED_CORRELATION_RO
14
14
  type VolatilitySeriesId, type VolatilitySeriesInput, type VolatilityTermHistory } from "./model";
15
15
  import { abortable, abortError } from "../../../utils/async-deadline";
16
16
  import { errorMessage } from "../../../utils/errors";
17
+ import { savedCopyAt } from "../../../data/saved-copies";
17
18
 
18
19
  const VOLATILITY_LOAD_CONCURRENCY = 4;
19
20
  const VOLATILITY_HISTORY_LIMIT = 400;
@@ -22,6 +23,8 @@ export interface VolatilityLoadResult {
22
23
  /** The daily inputs the data was built from, so live index levels can be laid over them. */
23
24
  inputs: VolatilityInputs;
24
25
  stale: boolean;
26
+ /** The oldest copy a failed refresh left answering, by when it was saved (ms); its failure is not in `errors`. */
27
+ savedAt?: number;
25
28
  errors: string[];
26
29
  phase: "loading" | "partial" | "ready" | "error";
27
30
  loaded: number;
@@ -76,14 +79,16 @@ const legError = (legs: ReadonlyArray<{ error?: string | null } | undefined>) =>
76
79
  * The 3M/30D ratio's history as the board builds it: the board's own VIX and
77
80
  * VIX 3M daily closes (one year, the same request and cache), FRED's
78
81
  * republished closes from `start` on the dates those lack. When Cboe's closes
79
- * fail FRED stands in, a day behind, and the warning says so; a ratio read
80
- * from cached closes names whose. With neither publisher's pair it rejects
81
- * with both reasons rather than return an empty history.
82
+ * fail FRED stands in, a day behind, and the warning says so. A ratio read
83
+ * from closes a failed refresh left in place carries when they were saved
84
+ * (`savedAt`), which the chart dates as every pane does; other cached closes
85
+ * name whose. With neither publisher's pair it rejects with both reasons
86
+ * rather than return an empty history.
82
87
  */
83
88
  export async function loadVolatilityTermHistory(
84
89
  { start, signal }: { start?: Date; signal?: AbortSignal } = {},
85
90
  dependencies: VolatilityLoaderDependencies & { loadFredPoints?: typeof loadFredPoints } = termDependencies(),
86
- ): Promise<{ term: VolatilityTermHistory; warning?: string }> {
91
+ ): Promise<{ term: VolatilityTermHistory; warning?: string; savedAt?: number }> {
87
92
  const loadFred = dependencies.loadFredPoints ?? loadFredPoints;
88
93
  const now = dependencies.now?.() ?? Date.now();
89
94
  const history: NonNullable<VolatilityInputs["history"]> = {};
@@ -101,7 +106,7 @@ export async function loadVolatilityTermHistory(
101
106
  try {
102
107
  const loaded = await loadFred(seriesId, start);
103
108
  fred[seriesId] = { observations: loaded.points, info: null, stale: loaded.points.length > 0 && loaded.stale,
104
- error: loaded.refreshError ?? null };
109
+ error: loaded.refreshError ?? null, ...(loaded.savedAt != null && loaded.points.length ? { savedAt: loaded.savedAt } : {}) };
105
110
  } catch (error) {
106
111
  fred[seriesId] = { observations: [], info: null, error: errorMessage(error) };
107
112
  }
@@ -119,12 +124,14 @@ export async function loadVolatilityTermHistory(
119
124
  } else if (fredError && !term.ratioHistory.some((point) => point.publisher === "FRED")) {
120
125
  notes.push(`FRED closes unavailable (${fredError}); showing Cboe's from ${term.ratioHistory[0]!.date}.`);
121
126
  }
122
- if (term.stale) {
127
+ const legs = term.publisher === "FRED" ? VOLATILITY_SERIES.map(({ seriesId }) => fred[seriesId]) : [history.vix, history.vix3m];
128
+ const savedAt = term.stale ? oldestSavedAt(legs) : undefined;
129
+ if (term.stale && savedAt == null) {
123
130
  const error = term.publisher === "FRED" ? fredError : cboeError;
124
131
  notes.push(error ? `Refresh failed (${error}); showing cached ${term.publisher} closes.`
125
132
  : `Showing cached ${term.publisher} closes; the source has not updated since.`);
126
133
  }
127
- return notes.length ? { term, warning: notes.join(" ") } : { term };
134
+ return { term, ...(notes.length ? { warning: notes.join(" ") } : {}), ...(savedAt != null ? { savedAt } : {}) };
128
135
  }
129
136
 
130
137
  /** A CBOE daily close as a daily bar dated at the 16:15 New York settlement. */
@@ -141,23 +148,39 @@ function validateFredData(seriesId: VolatilitySeriesId, data: FredSeriesData): F
141
148
  }
142
149
  return data;
143
150
  }
144
- function fredInput(result: Pick<FredSeriesLoadResult, "data" | "stale" | "fetchedAt"> & { refreshError?: string }): VolatilitySeriesInput {
151
+ function fredInput(result: Pick<FredSeriesLoadResult, "data" | "stale" | "fetchedAt"> & Partial<Pick<FredSeriesLoadResult, "source">>
152
+ & { refreshError?: string }): VolatilitySeriesInput {
153
+ const saved = result.source === "stale-fallback" && result.data.observations.length > 0;
145
154
  return { observations: result.data.observations, info: result.data.info, fetchedAt: result.fetchedAt,
146
155
  stale: result.data.observations.length > 0 && result.stale,
147
- error: result.refreshError ?? (result.data.observations.length ? null : "Daily FRED observations unavailable") };
156
+ error: result.refreshError ?? (result.data.observations.length ? null : "Daily FRED observations unavailable"),
157
+ ...(saved ? { savedAt: result.fetchedAt } : {}) };
148
158
  }
159
+ /**
160
+ * A history answered from the copy saved before its refetch failed is one only
161
+ * when the router says so (its saved-copy mark, which a command's answers
162
+ * carry): closes kept from an earlier answer, or a fresh answer with no data,
163
+ * say their error instead.
164
+ */
149
165
  function historyInput(entry: QueryEntry<PricePoint[]>, now: number): VolatilityHistoryInput {
150
166
  const history = resolveEntryValue(entry) ?? [];
167
+ const savedAt = history.length > 0 ? savedCopyAt(entry.history) : null;
151
168
  return { history, source: entry.source, fetchedAt: entry.fetchedAt,
152
- stale: history.length > 0 && (!!entry.error || (entry.staleAt != null && entry.staleAt <= now)),
153
- error: entry.error?.message ?? (history.length ? null : "Daily index history unavailable") };
169
+ stale: history.length > 0 && (savedAt != null || !!entry.error || (entry.staleAt != null && entry.staleAt <= now)),
170
+ error: entry.error?.message ?? (history.length ? null : "Daily index history unavailable"),
171
+ ...(savedAt != null ? { savedAt } : {}) };
172
+ }
173
+ /** The oldest of `inputs`' saved copies, by when each was saved. */
174
+ function oldestSavedAt(inputs: ReadonlyArray<{ savedAt?: number | null } | undefined>): number | undefined {
175
+ const saved = inputs.flatMap((input) => input?.savedAt != null ? [input.savedAt] : []);
176
+ return saved.length ? Math.min(...saved) : undefined;
154
177
  }
155
178
  /** Seeds the first paint; the load that replaces each input settles its freshness, so cache age is not stale. */
156
179
  function cachedInputs(dependencies: VolatilityLoaderDependencies, now: number): VolatilityInputs {
157
180
  const inputs: VolatilityInputs = { history: {}, fred: {} };
158
181
  for (const definition of VOLATILITY_INDICES) {
159
182
  const entry = dependencies.getChartEntry?.(volatilityHistoryRequest(definition.symbol));
160
- if (entry && resolveEntryValue(entry) != null) inputs.history![definition.id] = { ...historyInput(entry, now), stale: false };
183
+ if (entry && resolveEntryValue(entry) != null) inputs.history![definition.id] = { ...historyInput(entry, now), stale: false, savedAt: null };
161
184
  }
162
185
  for (const { seriesId } of VOLATILITY_SERIES) {
163
186
  const cached = getCachedFredSeries(requestFor(seriesId), { allowExpired: true });
@@ -176,12 +199,16 @@ function historyError(id: string, error: string): string {
176
199
  }
177
200
  function project(inputs: VolatilityInputs, loaded: number, total: number, pending: boolean): VolatilityLoadResult {
178
201
  const data = buildVolatilityData(inputs);
179
- const errors = [...Object.entries(inputs.history ?? {}).flatMap(([id, value]) => value.error ? [historyError(id, value.error)] : []),
180
- ...Object.entries(inputs.fred ?? {}).flatMap(([id, value]) => value.error ? [`${id}: ${value.error}`] : [])];
181
- const stale = [...Object.values(inputs.history ?? {}), ...Object.values(inputs.fred ?? {})].some((value) => value.stale);
202
+ // A saved copy answering a failed refresh says when it was saved (`savedAt`) instead of the failure.
203
+ const errors = [...Object.entries(inputs.history ?? {}).flatMap(([id, value]) => value.error && value.savedAt == null ? [historyError(id, value.error)] : []),
204
+ ...Object.entries(inputs.fred ?? {}).flatMap(([id, value]) => value.error && value.savedAt == null ? [`${id}: ${value.error}`] : [])];
205
+ const all = [...Object.values(inputs.history ?? {}), ...Object.values(inputs.fred ?? {})];
206
+ const stale = all.some((value) => value.stale);
207
+ const savedAt = oldestSavedAt(all);
182
208
  const phase = pending ? hasValues(data) ? "partial" : "loading" : !hasValues(data) ? "error"
183
209
  : errors.length || stale || data.warnings.length || data.board.some((row) => row.status !== "available") ? "partial" : "ready";
184
- return { data, inputs: { history: { ...inputs.history }, fred: { ...inputs.fred } }, stale, errors, phase, loaded, total };
210
+ return { data, inputs: { history: { ...inputs.history }, fred: { ...inputs.fred } }, stale, ...(savedAt != null ? { savedAt } : {}),
211
+ errors, phase, loaded, total };
185
212
  }
186
213
  export function getCachedVolatilityData(dependencies: VolatilityLoaderDependencies = createVolatilityDependencies()): VolatilityLoadResult | null {
187
214
  const inputs = cachedInputs(dependencies, dependencies.now?.() ?? Date.now());
@@ -2,6 +2,7 @@ import type { HeadlessBundleResult, HeadlessPaneContext, HeadlessPaneDefinition,
2
2
  import { createVolatilityDependencies, loadVolatilityData, type VolatilityLoadResult } from "./client";
3
3
  import { termStateLabel, type VolatilityData } from "./model";
4
4
  import { newestReportTime, oldestReportTime } from "../../../utils/utc-time";
5
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
5
6
 
6
7
  function formattedValue(value: unknown): string { return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "--"; }
7
8
  function formattedPercentile(value: unknown): string { return typeof value === "number" && Number.isFinite(value) ? value.toFixed(0) : "--"; }
@@ -60,6 +61,8 @@ export function createVolatilityHeadless(dependencies: VolatilityHeadlessDepende
60
61
  async load(args, context) {
61
62
  const result = await dependencies.load(args, context);
62
63
  context.signal.throwIfAborted();
64
+ // A saved copy answering a failed refresh ends the report with when it was saved.
65
+ const savedCopy = <T>(value: T) => result.savedAt != null ? servedFromSavedCopy(value, result.savedAt) : value;
63
66
  // Daily index closes, dated by the newest index on the board; a sparse index shows as the oldest.
64
67
  const boardDates = result.data.board.filter((row) => row.value != null).map((row) => row.date);
65
68
  const freshness = { source: "Cboe and FRED", status: "not-a-feed" as const, basis: "daily index closes", cadence: "daily" as const,
@@ -67,10 +70,10 @@ export function createVolatilityHeadless(dependencies: VolatilityHeadlessDepende
67
70
  return { ...projectVolatilityHeadless(result.data), freshness, errors: result.errors,
68
71
  complete: result.phase === "ready" && !result.stale && result.data.warnings.length === 0,
69
72
  unavailableSymbols: result.data.board.filter((row) => row.value == null).map((row) => row.symbol),
70
- metadata: { stale: result.stale, phase: result.phase, loaded: result.loaded, total: result.total,
73
+ metadata: savedCopy({ stale: result.stale, phase: result.phase, loaded: result.loaded, total: result.total,
71
74
  data: result.data, unit: "index points", observations: "daily history or isolated index observation",
72
75
  percentile: "Midrank within last calendar year; at least 200 observations spanning 300 days",
73
- vixFuturesAvailable: false, methodology: "docs/research-data.md#vix-curve-and-cross-asset-volatility-board" },
76
+ vixFuturesAvailable: false, methodology: "docs/research-data.md#vix-curve-and-cross-asset-volatility-board" }),
74
77
  };
75
78
  },
76
79
  };