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
@@ -1,5 +1,5 @@
1
- import { canonicalExchange } from "../../utils/exchanges";
2
- import { companyNameExtraWords, sameCompany } from "../company-name";
1
+ import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
2
+ import { companyLines, companyNameExtraWords, namedByService, type CompanyLineFacts } from "../company-name";
3
3
  import { answersOnlyByKeyword, isQualifiedTickerQuery } from "./ranking";
4
4
  import type { TickerSearchCandidate } from "./types";
5
5
 
@@ -29,6 +29,69 @@ export function lineDistance(type: string, exchange: string, symbol: string): 0
29
29
  return SECONDARY_VENUES.has(exchange) || /\d/.test(symbol) && /[A-Za-z]/.test(symbol) ? 1 : 0;
30
30
  }
31
31
 
32
+ /** How the primary-listing choice weighs one of a company's lines. */
33
+ export interface WeighedCompanyLine {
34
+ /** `listingDistance`: 0 its own line, 1 a line quoting shares listed elsewhere, 2 a receipt. */
35
+ distance: number;
36
+ /** The search score the service gives the listings people look up most; null when it gives none. */
37
+ score: number | null;
38
+ }
39
+
40
+ /**
41
+ * The line a company's search lists as its primary listing instead of `lead`,
42
+ * the line search put first, often the one-off cross-listing whose short name
43
+ * matched the query (LUFTHANSA on Budapest): its lines go by how far each is
44
+ * from being its own line, then by the search score. The lead gives way only
45
+ * to a closer line, or to one searched at least twice as often, or any scored
46
+ * line when it has no score. With `named` (the service marked the lead with
47
+ * the name as typed) no score outweighs the mark: only a closer line does.
48
+ * `lines` holds the lead. `search` and a company's name typed for a listing
49
+ * (`findListingOfCompanyName`) choose by this one rule, so both open the same
50
+ * listing.
51
+ */
52
+ export function primaryCompanyLine<T>(
53
+ lead: T,
54
+ lines: readonly T[],
55
+ weigh: (line: T) => WeighedCompanyLine,
56
+ options: { named?: boolean } = {},
57
+ ): T {
58
+ const order = (line: T) => lines.indexOf(line);
59
+ const [home] = [...lines].sort((a, b) => weigh(a).distance - weigh(b).distance
60
+ || (weigh(b).score ?? -1) - (weigh(a).score ?? -1) || order(a) - order(b));
61
+ if (!home || home === lead) return lead;
62
+ const [best, first] = [weigh(home), weigh(lead)];
63
+ const closer = best.distance < first.distance;
64
+ const moreSearched = !options.named && best.distance === first.distance && best.score != null
65
+ && (first.score == null || best.score >= first.score * 2);
66
+ return closer || moreSearched ? home : lead;
67
+ }
68
+
69
+ /** A row as `companyLines` weighs it. */
70
+ function lineFacts(candidate: TickerSearchCandidate): CompanyLineFacts {
71
+ return {
72
+ name: candidateName(candidate),
73
+ exchange: candidateExchange(candidate),
74
+ receipt: listingDistance(candidate) === 2,
75
+ home: candidate.result?.homeListing === true,
76
+ };
77
+ }
78
+
79
+ /** The rows of `lead`'s company among `items` (`companyLines`): never a line of another business its name is in (Siemens Energy for Siemens). */
80
+ function companyRows<T extends TickerSearchCandidate>(lead: T, items: readonly T[]): T[] {
81
+ return companyLines(lead, items.filter((item) => item === lead || isListedEquity(item)), lineFacts);
82
+ }
83
+
84
+ /** `primaryCompanyLine` among `items` of `start`'s company, for the command bar's rows. */
85
+ function primaryLineOf<T extends TickerSearchCandidate>(start: T, items: readonly T[], query: string): T {
86
+ const name = candidateName(start);
87
+ const named = !!start.result && namedByService(query, start.result);
88
+ const lines = companyRows(start, items).filter((item) => item === start
89
+ || !named || companyNameExtraWords(candidateName(item), name) != null);
90
+ return primaryCompanyLine(start, lines, (item) => ({
91
+ distance: listingDistance(item), score: item.result?.popularity ?? item.popularity ?? null,
92
+ }), { named });
93
+ }
94
+
32
95
  function candidateName(candidate: TickerSearchCandidate): string {
33
96
  return candidate.result?.name || candidate.detail.split(" | ")[0] || "";
34
97
  }
@@ -51,24 +114,46 @@ function isListedEquity(candidate: TickerSearchCandidate): boolean {
51
114
  * service marks as the company's home (`homeListing`) takes the lead from
52
115
  * the company's first row. Without that mark, a receipt in the lead gives
53
116
  * way to the company's own shares on an exchange (`listingDistance` 0, not
54
- * an OTC or regional line), the name nearest the receipt's first. A query that names a venue (LVMH:TSX,
55
- * 7203.T) asked for that line, and a saved row keeps the lead.
117
+ * an OTC or regional line), the name nearest the receipt's first. Then the
118
+ * line `search` calls the company's primary listing (`primaryCompanyLine`)
119
+ * takes the lead, so a name opens what `search` lists first: LHA on XETRA
120
+ * for "lufthansa", not LUFTHANSA on Budapest. A company's lines are never
121
+ * another business's its name is in (`companyLines`: Siemens Energy AG for
122
+ * Siemens AG). The row the service marks with the word as typed is the
123
+ * company the word means, ahead of a line abroad whose symbol is the word:
124
+ * "siemens" is SIE on XETRA, not SIEMENS on NSE (Siemens Ltd.); a US line
125
+ * keeps its ticker. A query that names a venue (LVMH:TSX, 7203.T) asked for
126
+ * that line, and a saved row keeps the lead.
56
127
  */
57
128
  export function withHomeListingLead<T extends TickerSearchCandidate>(items: T[], query: string): T[] {
58
129
  if (isQualifiedTickerQuery(query)) return items;
59
130
  const lead = items.find((item) => item.instrumentClass !== "fund" && item.instrumentClass !== "derivative"
60
131
  && !answersOnlyByKeyword(item, query));
61
132
  if (!lead || !isListedEquity(lead)) return items;
133
+ const typedSymbol = lead.symbol.toUpperCase() === query.trim().toUpperCase();
134
+ const meant = typedSymbol && !isUsListingExchange(candidateExchange(lead)) && !(lead.result && namedByService(query, lead.result))
135
+ // A company's shares under another symbol: an index the code also names (SPX) or the word's own line elsewhere (SAP) is not it.
136
+ ? items.find((item) => item !== lead && isListedEquity(item) && item.instrumentClass === "equity" && !!item.result
137
+ && item.symbol.toUpperCase() !== lead.symbol.toUpperCase() && namedByService(query, item.result))
138
+ : undefined;
139
+ if (meant) return moveAhead(items, meant, lead);
62
140
  const name = candidateName(lead);
63
- const lines = items.filter((item) => item !== lead && isListedEquity(item) && sameCompany(name, candidateName(item)));
141
+ const lines = companyRows(lead, items).filter((item) => item !== lead);
64
142
  const nearest = (candidates: T[]) => [...candidates].sort((a, b) => nameGap(name, candidateName(a)) - nameGap(name, candidateName(b))
65
143
  || items.indexOf(a) - items.indexOf(b))[0];
66
144
  const marked = lines.filter((item) => item.result?.homeListing === true);
145
+ const own = listingDistance(lead) === 2 ? nearest(lines.filter((item) => listingDistance(item) === 0)) : undefined;
67
146
  const home = lead.result?.homeListing === true ? undefined
68
147
  : marked.length ? nearest(marked)
69
- : listingDistance(lead) === 2 ? nearest(lines.filter((item) => listingDistance(item) === 0))
70
- : undefined;
71
- if (!home) return items;
148
+ // A listing searched by its own symbol is that listing, as `search` keeps it.
149
+ : typedSymbol ? own
150
+ : primaryLineOf(own ?? lead, [lead, ...lines], query);
151
+ if (!home || home === lead) return items;
152
+ return moveAhead(items, home, lead);
153
+ }
154
+
155
+ /** `items` with `home` moved just ahead of `lead`. */
156
+ function moveAhead<T>(items: readonly T[], home: T, lead: T): T[] {
72
157
  const rest = items.filter((item) => item !== home);
73
158
  const at = rest.indexOf(lead);
74
159
  return [...rest.slice(0, at), home, ...rest.slice(at)];
@@ -54,7 +54,7 @@ export {
54
54
  rankTickerSearchItems,
55
55
  } from "./ranking";
56
56
  export { upsertTickerFromSearchResult } from "./upsert";
57
- export { candidateExchange, listingDistance } from "./home-listing";
57
+ export { candidateExchange, listingDistance, primaryCompanyLine } from "./home-listing";
58
58
 
59
59
  const MAX_NAMED_LISTINGS = 6;
60
60
 
@@ -1,5 +1,5 @@
1
1
  import type { PricePoint, TickerFinancials } from "../types/financials";
2
- import { pricePointIntegrity, pricePointValues, priceHistoryIntegrityNotice, mergePriceHistoryIntegrity, type PriceHistoryIntegrity } from "../utils/price-history-integrity";
2
+ import { pricePointIntegrity, pricePointValues, pricePointWicks, priceHistoryIntegrityNotice, mergePriceHistoryIntegrity, type PriceHistoryIntegrity } from "../utils/price-history-integrity";
3
3
  import { canonicalTimeSeriesFieldId, isFundamentalFieldId, isMarketFieldId } from "./field-catalog";
4
4
  import { extractFundamentalSeries } from "./fundamentals";
5
5
  import type { ChartSeriesPriceHistoryIntegrity, ResolvedSeries, SecuritySeriesSource, SeriesPeriod, TimeSeriesPoint } from "./types";
@@ -73,8 +73,10 @@ function aggregatePriceHistory(
73
73
  for (const point of sorted) {
74
74
  const key = periodKey(point.date, period);
75
75
  const open = isFiniteNumber(point.open) ? point.open : point.close;
76
- const high = isFiniteNumber(point.high) ? point.high : point.close;
77
- const low = isFiniteNumber(point.low) ? point.low : point.close;
76
+ // A wick too far from its bar's body to have traded does not set the week's or month's range.
77
+ const wicks = pricePointWicks(point);
78
+ const high = isFiniteNumber(wicks.high) ? wicks.high : isFiniteNumber(point.high) ? Math.max(open, point.close) : point.close;
79
+ const low = isFiniteNumber(wicks.low) ? wicks.low : isFiniteNumber(point.low) ? Math.min(open, point.close) : point.close;
78
80
  const current = buckets.get(key);
79
81
  if (!current) {
80
82
  buckets.set(key, {
@@ -63,7 +63,24 @@ export function observationDate(time: number, series: Pick<ResolvedSeries, "time
63
63
  return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
64
64
  }
65
65
 
66
- /** Date-only research windows select local sessions; runtime timestamps remain exact. */
66
+ /**
67
+ * The day the weekly or monthly bar holding `date` is dated by: its week's
68
+ * Monday or its month's 1st, as `history --from` keeps the week or month its
69
+ * first date falls in. A daily bar is its own day.
70
+ */
71
+ function windowBarStart(date: string, resolution: "1d" | "1wk" | "1mo"): string {
72
+ if (resolution === "1mo") return `${date.slice(0, 7)}-01`;
73
+ if (resolution === "1d") return date;
74
+ const day = Date.parse(`${date}T00:00:00Z`);
75
+ return new Date(day - ((new Date(day).getUTCDay() + 6) % 7) * 86_400_000).toISOString().slice(0, 10);
76
+ }
77
+
78
+ /**
79
+ * Date-only research windows select local sessions; runtime timestamps remain
80
+ * exact. Weekly and monthly bars keep the bar the window starts in: a window
81
+ * from 2019-12-31 in monthly bars starts with December 2019's, whose close is
82
+ * that day's, so a comparison is measured from it.
83
+ */
67
84
  export function priceObservationWindowFilter(
68
85
  series: ResolvedSeries,
69
86
  bounds: { start: number | null; end: number | null },
@@ -74,11 +91,12 @@ export function priceObservationWindowFilter(
74
91
  const calendarWindow = calendarBars && dateWindow
75
92
  && /^\d{4}-\d{2}-\d{2}$/.test(dateWindow.start)
76
93
  && /^\d{4}-\d{2}-\d{2}$/.test(dateWindow.end) ? dateWindow : null;
94
+ const start = calendarWindow ? windowBarStart(calendarWindow.start, resolution as "1d" | "1wk" | "1mo") : null;
77
95
  return (time) => {
78
96
  if (!Number.isFinite(time)) return false;
79
97
  const key = calendarWindow ? observationDate(time, series) : null;
80
98
  return key !== null
81
- ? key >= calendarWindow!.start && key <= calendarWindow!.end
99
+ ? key >= start! && key <= calendarWindow!.end
82
100
  : (bounds.start === null || time >= bounds.start) && (bounds.end === null || time <= bounds.end);
83
101
  };
84
102
  }
@@ -1,4 +1,5 @@
1
1
  import { logFailure } from "../utils/log-failure";
2
+ import { servedFromSavedCopy } from "../data/saved-copies";
2
3
  import { hasValidQuoteObservationTime } from "../market-data/quotes/freshness";
3
4
  import { delistingLabel } from "../market-data/quotes/delisting";
4
5
  import { financialPeriodCoverage, financialPeriodCoverageWarnings, limitSeriesObservations } from "./financial-period-coverage";
@@ -827,7 +828,7 @@ async function resolveChartSpecPass(
827
828
  cache.capabilitySeriesByRequest.set(key, pending);
828
829
  cache.revision += 1;
829
830
  }
830
- return baseCapabilitySeries(seriesSpec, await pending, index);
831
+ return asSavedCopy(baseCapabilitySeries(seriesSpec, await pending, index));
831
832
  }
832
833
  if (seriesSpec.source.kind === "economic") {
833
834
  const request: FredSeriesRequest = {
@@ -845,6 +846,8 @@ async function resolveChartSpecPass(
845
846
  priorityWarnings.push(`${result.label}: ${coverageNotice}`);
846
847
  coverageNotices.push(`${result.label}: ${coverageNotice}`);
847
848
  }
849
+ // A saved copy that answered for a failed refresh is dated in the footer, as every pane's is.
850
+ if (result && fred.source === "stale-fallback") return asSavedCopy({ ...result, savedAt: fred.fetchedAt });
848
851
  const freshnessWarning = staleFredWarning(fred);
849
852
  if (result && freshnessWarning) priorityWarnings.push(`${result.label}: ${freshnessWarning}`);
850
853
  return result;
@@ -1180,6 +1183,7 @@ async function resolveChartSpecPass(
1180
1183
  loading: false,
1181
1184
  errors,
1182
1185
  warnings: [...new Set([...priorityWarnings, ...warnings])],
1186
+ ...savedCopyOf(rawSeries.filter((entry) => visibleSeriesIds.has(entry.id))),
1183
1187
  ...(coverageNotices.length ? { coverageNotices: [...new Set(coverageNotices)] } : {}),
1184
1188
  ...(returnBasisNotice ? { returnBasisNotice } : {}),
1185
1189
  ...(delistings.size ? { delistings: [...delistings.values()] } : {}),
@@ -1188,3 +1192,18 @@ async function resolveChartSpecPass(
1188
1192
  ...(builtBarSize && rawSeries.some((entry) => entry.barSize === builtBarSize) ? { barSize: builtBarSize } : {}),
1189
1193
  };
1190
1194
  }
1195
+
1196
+ /**
1197
+ * A series drawn from a copy a failed refresh left answering (`savedAt`),
1198
+ * marked as one, so a command charting it ends with "couldn't refresh; saved
1199
+ * copy from …" as the app's footer says.
1200
+ */
1201
+ function asSavedCopy(series: ResolvedSeries): ResolvedSeries {
1202
+ return series.savedAt != null ? servedFromSavedCopy(series, series.savedAt) : series;
1203
+ }
1204
+
1205
+ /** The oldest saved copy among `series` that answered for a failed refresh, which the chart's footer dates. */
1206
+ function savedCopyOf(series: readonly ResolvedSeries[]): { savedAt?: number } {
1207
+ const saved = series.flatMap((entry) => entry.savedAt != null && Number.isFinite(entry.savedAt) ? [entry.savedAt] : []);
1208
+ return saved.length ? { savedAt: Math.min(...saved) } : {};
1209
+ }
@@ -265,6 +265,8 @@ export interface ResolvedSeries {
265
265
  /** Rejected valuation price inputs, retained independently of usable observations. */
266
266
  valuationPriceIssues?: import("./valuation-price").ValuationPriceIssue[];
267
267
  warning?: string;
268
+ /** When the copy the points came from was saved (ms), when its refresh failed and that copy answered. */
269
+ savedAt?: number;
268
270
  /** Listed in the legend so it can be restored, but not drawn. */
269
271
  hidden?: boolean;
270
272
  /**
@@ -324,6 +326,11 @@ export interface ChartResolutionResult {
324
326
  loading: boolean;
325
327
  errors: string[];
326
328
  warnings: string[];
329
+ /**
330
+ * The oldest saved copy a shown series answered from after its refresh
331
+ * failed (ms), which the chart's footer dates: "saved copy from …".
332
+ */
333
+ savedAt?: number;
327
334
  /**
328
335
  * Where a series' source starts after the range asked for, and why (a
329
336
  * publisher's cap on history), as `warnings` also carries it, so a report
@@ -268,7 +268,8 @@ export interface TtmConversion {
268
268
  }
269
269
 
270
270
  /**
271
- * How a served enterprise value ties to market cap + total debt - cash from the latest balance sheet served beside it.
271
+ * How a served enterprise value ties to market cap + total debt (+ leases, `leases`) - cash from the latest balance sheet
272
+ * served beside it.
272
273
  * `matches` within 5%; `differs` beyond it (or beyond 2% on the other side of the market cap from net debt), within
273
274
  * 0.7x to 1.5x, shown labelled as the provider's; `withdrawn` outside that band, with no EV or EV/revenue; `unchecked`
274
275
  * without a recent balance sheet carrying both lines. `computed`: an insurer's or a holding company's EV, which is
@@ -284,15 +285,24 @@ export interface EnterpriseValueCheck {
284
285
  /** The market cap the EV is measured on; absent only on a not-meaningful source EV served without one. */
285
286
  marketCap?: number;
286
287
  totalDebt?: number;
288
+ /** The balance sheet's lease liabilities the bridge adds to total debt, as FA's net debt does (utils/lease-liabilities); absent when none were added. */
289
+ leases?: number;
287
290
  cash?: number;
288
291
  /** YYYY-MM-DD of the balance sheet the debt and cash come from. */
289
292
  balanceSheetDate?: string;
290
- /** marketCap + totalDebt - cash. */
293
+ /** marketCap + totalDebt + leases - cash. */
291
294
  computed?: number;
292
295
  /** `cash` is cash and equivalents alone: an insurer's or a holding company's investments stay in its EV. */
293
296
  investmentsKept?: true;
294
- /** Why the EV is not meaningful (`not-meaningful`). */
297
+ /** Why the EV is not meaningful (`not-meaningful`), or why a withdrawn EV no balance sheet checked is withdrawn. */
295
298
  reason?: string;
299
+ /** A balance sheet in another currency than the market cap: its currency and the rate at its date
300
+ * (`currency` per unit of `from`) that `totalDebt`, `leases` and `cash` were converted at. */
301
+ fx?: { from: string; rate: number };
302
+ /** The provider converted its EV from the statements' currency. */
303
+ convertedFrom?: string;
304
+ /** Withdrawn because the provider applied that conversion twice: its EV over `computed` is the conversion rate. */
305
+ convertedTwice?: true;
296
306
  }
297
307
 
298
308
  /** How a trailing P/E converts the EPS it divides by, when that EPS is in another currency than the price. */
@@ -784,6 +794,11 @@ export interface FinancialStatement {
784
794
  date: string;
785
795
  /** Source of the fiscal period date; does not establish metric publication dates. */
786
796
  dateSource?: "sec" | "provider";
797
+ /**
798
+ * The accounting standard the issuer reports in, from the taxonomy of its latest annual report to the SEC (10-K,
799
+ * 20-F or 40-F): BABA's 20-F is US GAAP, NVO's IFRS. Absent for a company that files none with the SEC.
800
+ */
801
+ accountingStandard?: "us-gaap" | "ifrs";
787
802
  /** Original vendor period date when independent filing evidence changes it. */
788
803
  providerDate?: string;
789
804
  /** Filing evidence for the period identity only, not availability of every field. */
package/src/ui/host.tsx CHANGED
@@ -709,7 +709,12 @@ export interface SaveTextFileRequest {
709
709
 
710
710
  export interface RendererHost {
711
711
  requestExit(): void;
712
- startWindowDrag?(): Promise<void> | void;
712
+ /**
713
+ * From a press on a title bar. `press.detail` counts the clicks: where the
714
+ * system's title bars maximize on a double-click, the second press does that
715
+ * instead of moving the window.
716
+ */
717
+ startWindowDrag?(press?: { detail?: number }): Promise<void> | void;
713
718
  controlWindow?(action: "minimize" | "toggle-maximize" | "close"): Promise<void> | void;
714
719
  /**
715
720
  * Opens a link in the person's browser. Resolves false when no browser could
@@ -1,31 +1,88 @@
1
- import type { FinancialStatement, StatementFill, StatementFillField } from "../types/financials";
1
+ import type { FinancialStatement, StatementFill, StatementFillField, TickerFinancials } from "../types/financials";
2
+ import { settleWithin } from "./async-deadline";
3
+ import { logFailure } from "./log-failure";
2
4
  import { copyIncomeField, incomeFieldOwner, INCOME_STATEMENT_FIELDS, isIncomeStatementField } from "./income-statement";
3
5
  import { mergeStatementGaps } from "./statement-gaps";
4
6
 
5
7
  export const FINANCIAL_VINTAGE_NOTICE = "Latest available statements may include restatements. Historical as-of values are not reconstructed.";
6
8
 
9
+ /** The oldest dated period of a report's columns (a TTM column has no date), or null when none has one. */
10
+ function oldestPeriod(dates: readonly string[]): string | null {
11
+ return dates.filter((date) => /^\d{4}-\d{2}-\d{2}/.test(date)).map((date) => date.slice(0, 10)).sort()[0] ?? null;
12
+ }
13
+
7
14
  /**
8
15
  * The closing lines of a statements report that say how far back it goes, from
9
- * the oldest period it served: the report holds no older ones, and `GF` reads
10
- * one metric over more periods, as far as its own history reaches. Empty when
11
- * no period has a date, so a report with nothing in it says nothing.
16
+ * the oldest period it served: the report holds no older ones. `older` says
17
+ * whether `GF`, which reads one metric from the listing's extended history
18
+ * (`hasOlderStatements`), goes further back: it is pointed to only then, and
19
+ * when it does not, the listing has no older ones at all (IAG on LSE: its
20
+ * statements start in 2022, in GF too). Null when that could not be told in
21
+ * time: the report then says nothing of older periods. Empty too when no
22
+ * period has a date, so a report with nothing in it says nothing.
12
23
  */
13
24
  export function statementHistoryNotes(
14
25
  period: "annual" | "quarterly",
15
26
  dates: readonly string[],
16
27
  symbol: string,
28
+ older: boolean | null,
17
29
  ): string[] {
18
- const oldest = dates.filter((date) => /^\d{4}-\d{2}-\d{2}/.test(date)).map((date) => date.slice(0, 10)).sort()[0];
19
- if (!oldest) return [];
20
- return period === "annual"
21
- ? [
22
- `annual history from ${oldest.slice(0, 4)}; older years not served`,
23
- `earlier years, one metric at a time: gloom fn GF ${symbol} --period annual --periods 40`,
24
- ]
25
- : [
26
- `quarterly history from ${oldest}; older quarters not served`,
27
- `earlier quarters, one metric at a time: gloom fn GF ${symbol} --period quarterly --periods 40`,
28
- ];
30
+ const oldest = oldestPeriod(dates);
31
+ if (!oldest || older === null) return [];
32
+ const [from, unit] = period === "annual" ? [oldest.slice(0, 4), "years"] : [oldest, "quarters"];
33
+ if (older === false) return [`${period} history from ${from}; older ${unit} not available for this listing`];
34
+ return [
35
+ `${period} history from ${from}; older ${unit} not served`,
36
+ ...older ? [`earlier ${unit}, one metric at a time: gloom fn GF ${symbol} --period ${period} --periods 40`] : [],
37
+ ];
38
+ }
39
+
40
+ /**
41
+ * How long a statements report waits on the extended history for its
42
+ * older-periods note, as a lock note waits on its routes' checks: a read still
43
+ * out then leaves the report on time, without the note. The read goes on and
44
+ * warms the cache for the next report.
45
+ */
46
+ const OLDER_STATEMENTS_WAIT_MS = 3_000;
47
+
48
+ /**
49
+ * Whether the listing's extended statement history, which `GF` reads, has a
50
+ * period older than the oldest of `dates`, a report's columns: a US filer's
51
+ * filings reach years back; a listing abroad has nothing past what the report
52
+ * served. Null when the history could not be read within `waitMs`.
53
+ */
54
+ export async function hasOlderStatements(
55
+ loadExtended: () => Promise<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>,
56
+ period: "annual" | "quarterly",
57
+ dates: readonly string[],
58
+ waitMs = OLDER_STATEMENTS_WAIT_MS,
59
+ ): Promise<boolean | null> {
60
+ const oldest = oldestPeriod(dates);
61
+ if (!oldest) return null;
62
+ const read = Promise.resolve().then(loadExtended).catch((error: unknown) => {
63
+ logFailure("utils.financial-statements", "Reading the extended statement history failed")(error);
64
+ throw error;
65
+ });
66
+ const extended = await settleWithin(read, waitMs);
67
+ if (!extended) return null;
68
+ const rows = period === "annual" ? extended.annualStatements : extended.quarterlyStatements;
69
+ return rows.some((row) => /^\d{4}-\d{2}-\d{2}/.test(row.date) && row.date.slice(0, 10) < oldest);
70
+ }
71
+
72
+ /** Venues whose companies report half-yearly and yearly, with no quarterly statements: Australia's (ASX, and Cboe Australia, which trades ASX companies) and New Zealand's. */
73
+ const HALF_YEARLY_VENUES = new Set(["ASX", "CXA", "NZX"]);
74
+ const DAY_MS = 86_400_000;
75
+
76
+ /**
77
+ * Whether a listing's company reports half-yearly rather than quarterly: it
78
+ * lists on a venue whose companies do (Qantas on ASX), or the periods of its
79
+ * "quarterly" statements are half a year apart.
80
+ */
81
+ export function reportsHalfYearly(exchange: string | null | undefined, quarterly: readonly Pick<FinancialStatement, "date">[]): boolean {
82
+ if (exchange && HALF_YEARLY_VENUES.has(exchange.toUpperCase())) return true;
83
+ const times = quarterly.map(({ date }) => Date.parse(`${date.slice(0, 10)}T00:00:00Z`)).filter(Number.isFinite).sort((a, b) => a - b);
84
+ const gaps = times.slice(1).map((time, index) => (time - times[index]!) / DAY_MS);
85
+ return gaps.length > 0 && gaps.every((days) => days > 150 && days < 215);
29
86
  }
30
87
 
31
88
  /** How old a statement's latest period may be before a report says how old it is. */
@@ -106,7 +163,7 @@ function retainedFill(merged: FinancialStatement, rows: Array<FinancialStatement
106
163
 
107
164
  export const SEC_EPS_BASIS_NOTICE = "SEC EPS uses corroborated split-adjusted share bases. Unverified bases are unavailable.";
108
165
 
109
- const STATEMENT_METADATA_KEYS = new Set(["date", "dateSource", "providerDate", "dateEvidence", "currency", "availableAt", "fieldAvailability", "epsBasis", "fieldSources", "unavailableFields", "withdrawnObservations", "operatingResult", "operatingResultAggregation", "earningsResult", "unavailableEarnings", "filledFrom"]);
166
+ const STATEMENT_METADATA_KEYS = new Set(["date", "dateSource", "accountingStandard", "providerDate", "dateEvidence", "currency", "availableAt", "fieldAvailability", "epsBasis", "fieldSources", "unavailableFields", "withdrawnObservations", "operatingResult", "operatingResultAggregation", "earningsResult", "unavailableEarnings", "filledFrom"]);
110
167
  const NEARBY_PERIOD_END_MS = 7 * 24 * 60 * 60 * 1_000;
111
168
 
112
169
  /** An explicit field map is authoritative: omitted fields have unknown availability. */
@@ -45,7 +45,10 @@ export function enterpriseValueCheck(
45
45
  // Not meaningful needs no figure of its own: a source EV below zero may come with no cap or currency.
46
46
  if (check.status === "not-meaningful") return withdrawn ? check : undefined;
47
47
  if (!check.currency) return undefined;
48
- if (check.status === "withdrawn") return withdrawn && finiteNumber(check.computed) && finiteNumber(check.source) ? check : undefined;
48
+ // A withdrawn EV comes with the figure it was checked against, or with why no balance sheet checked it.
49
+ if (check.status === "withdrawn") {
50
+ return withdrawn && ((finiteNumber(check.computed) && finiteNumber(check.source)) || !!check.reason?.trim()) ? check : undefined;
51
+ }
49
52
  if (check.status === "computed") {
50
53
  return finiteNumber(check.computed) && fundamentals?.enterpriseValue === check.computed ? check : undefined;
51
54
  }
@@ -87,8 +90,9 @@ export function enterpriseValueLabel(fundamentals: Parameters<typeof enterpriseV
87
90
  /**
88
91
  * The reconciliation line under a labelled, withdrawn or not meaningful EV: "EV 61.75B USD (provider) vs 63.75B
89
92
  * computed from market cap + total debt - cash (balance sheet 2026-06-30).", "EV N/M: cash and short-term investments
90
- * exceed market cap + total debt (balance sheet 2026-06-30)." For an EV withdrawn without a check, that the source
91
- * failed validation. Undefined when the EV matches, is unchecked or computed, or has no check. Read it from the
93
+ * exceed market cap + total debt (balance sheet 2026-06-30)." A balance sheet in another currency names the rate it
94
+ * was converted at ("balance sheet 2026-06-30, EUR at 0.8614 GBP"). A withdrawn EV no balance sheet checked gives
95
+ * Gloom Cloud's reason; one withdrawn without a check, that the source failed validation. Undefined when the EV matches, is unchecked or computed, or has no check. Read it from the
92
96
  * fundamentals the rows show (atRegularSessionPrice moves the check with the EV and the market cap), so its figures
93
97
  * are theirs.
94
98
  */
@@ -97,14 +101,22 @@ export function enterpriseValueReconciliationNote(fundamentals: Parameters<typeo
97
101
  if (notMeaningful) return `EV N/M: ${notMeaningful.charAt(0).toLowerCase()}${notMeaningful.slice(1)}.`;
98
102
  const check = enterpriseValueCheck(fundamentals);
99
103
  if (!check && enterpriseValueWithdrawn(fundamentals)) return "EV unavailable: the provider's figure failed validation.";
104
+ const reason = check?.reason?.trim().replace(/\.$/, "");
105
+ if (check?.status === "withdrawn" && !finiteNumber(check.computed) && reason) {
106
+ return `EV unavailable: ${reason.charAt(0).toLowerCase()}${reason.slice(1)}.`;
107
+ }
100
108
  if (!check || !finiteNumber(check.computed) || !finiteNumber(check.source)
101
109
  || (check.status !== "differs" && check.status !== "withdrawn")) return undefined;
102
110
  const source = `${formatCompact(check.source)} ${check.currency}`;
103
111
  const computed = formatCompact(check.computed);
104
- const basis = `market cap + total debt - cash${check.balanceSheetDate ? ` (balance sheet ${check.balanceSheetDate})` : ""}`;
112
+ const leases = finiteNumber(check.leases) && check.leases > 0 ? " + leases" : "";
113
+ const fx = check.fx && finiteNumber(check.fx.rate) ? `, ${check.fx.from} at ${check.fx.rate.toPrecision(4)} ${check.currency}` : "";
114
+ const sheet = check.balanceSheetDate ? ` (balance sheet ${check.balanceSheetDate}${fx})` : "";
115
+ const basis = `market cap + total debt${leases} - cash${sheet}`;
105
116
  if (check.status === "differs") return `EV ${source} (provider) vs ${computed} computed from ${basis}.`;
106
117
  const ratio = check.computed > 0 ? ` is ${(check.source / check.computed).toFixed(1)}x` : " is far from";
107
- return `EV unavailable: the provider's ${source}${ratio} the ${computed} computed from ${basis}.`;
118
+ const twice = check.convertedTwice && check.convertedFrom ? `; it converts ${check.convertedFrom} to ${check.currency} twice` : "";
119
+ return `EV unavailable: the provider's ${source}${ratio} the ${computed} computed from ${basis}${twice}.`;
108
120
  }
109
121
 
110
122
  /**
@@ -0,0 +1,54 @@
1
+ import type { FinancialStatement } from "../types/financials";
2
+
3
+ /**
4
+ * How net debt treats the lease liabilities a listing's balance sheets report (docs/financial-ratios.md#leases):
5
+ *
6
+ * - `included`: added to total debt. IFRS 16 puts every lessee lease on the balance sheet and reports EBITDA before
7
+ * lease costs, so leverage with the leases in is like for like (IAG's June 2026 balance sheet: 6.96bn of
8
+ * borrowings, 6.91bn of leases, 9.18bn of cash).
9
+ * - `excluded`: a company reporting under US GAAP, whatever form it files (a 10-K, or a 20-F as BABA, PDD and JD
10
+ * do). Its finance leases are already in total debt and its operating lease cost is in EBITDA, so its operating
11
+ * leases stay out.
12
+ *
13
+ * The standard is the one Gloom Cloud reads from the issuer's latest annual report to the SEC
14
+ * (`accountingStandard`), the signal its enterprise value check bridges by. Where no row states it, a row dated by
15
+ * a 10-K or 10-Q (`dateSource: "sec"`) is US GAAP, and a company that files none with the SEC is IFRS.
16
+ */
17
+ export type LeaseBasis = "included" | "excluded";
18
+
19
+ type LeaseLines = Pick<FinancialStatement, "capitalLeaseObligations" | "dateSource" | "accountingStandard">;
20
+
21
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
22
+
23
+ /** The listing's lease basis, or undefined when no balance sheet on file reports lease liabilities. */
24
+ export function leaseBasis(statements: Iterable<LeaseLines>): LeaseBasis | undefined {
25
+ let leases = false;
26
+ let standard: FinancialStatement["accountingStandard"];
27
+ let secDated = false;
28
+ for (const statement of statements) {
29
+ if (finite(statement.capitalLeaseObligations) && statement.capitalLeaseObligations > 0) leases = true;
30
+ standard ??= statement.accountingStandard;
31
+ if (statement.dateSource === "sec") secDated = true;
32
+ }
33
+ if (!leases) return undefined;
34
+ return (standard ? standard === "us-gaap" : secDated) ? "excluded" : "included";
35
+ }
36
+
37
+ /** Amounts are whole units; some providers round a leg to the thousand. */
38
+ const close = (a: number, b: number) => Math.abs(a - b) <= Math.max(1_000, Math.abs(a) * 1e-6);
39
+
40
+ /**
41
+ * A balance sheet's total debt and lease liabilities. A total that already holds the lease line (long-term and
42
+ * current debt plus leases) is read as the borrowings beside it, so the leases count once. Undefined without both
43
+ * lines: a missing lease line is never zero.
44
+ */
45
+ export function debtAndLeases(
46
+ statement: Pick<FinancialStatement, "totalDebt" | "capitalLeaseObligations" | "longTermDebt" | "currentDebt">,
47
+ ): { totalDebt: number; leases: number } | undefined {
48
+ const total = statement.totalDebt;
49
+ const leases = statement.capitalLeaseObligations;
50
+ if (!finite(total) || !finite(leases) || leases < 0) return undefined;
51
+ const { longTermDebt: long, currentDebt: current } = statement;
52
+ const holds = leases > 0 && (finite(long) || finite(current)) && close(total, (long ?? 0) + (current ?? 0) + leases);
53
+ return { totalDebt: holds ? total - leases : total, leases };
54
+ }