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.
- package/package.json +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- 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 =
|
|
250
|
-
|
|
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
|
|
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
|
|
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.
|
|
574
|
-
|
|
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 {
|
|
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
|
-
...(
|
|
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] : [],
|
|
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
|
-
|
|
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
|
-
...(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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 (
|
|
306
|
-
if (
|
|
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
|
|
80
|
-
* from
|
|
81
|
-
*
|
|
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
|
-
|
|
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 ? {
|
|
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"> &
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
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
|
};
|