gloomberb 0.19.0 → 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 (304) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +2 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/errors.ts +38 -0
  10. package/src/api-client/hiring.ts +3 -0
  11. package/src/api-client/index.ts +4 -0
  12. package/src/api-client/ipo.ts +24 -1
  13. package/src/api-client/mna.ts +3 -0
  14. package/src/api-client/paths.ts +9 -1
  15. package/src/api-client/roadmap.ts +85 -0
  16. package/src/api-client/types.ts +12 -0
  17. package/src/app/runtime/startup.ts +3 -1
  18. package/src/architecture/core-builtin-allowlist.json +2 -2
  19. package/src/capabilities/chart-series.ts +2 -1
  20. package/src/cli/commands/market.ts +44 -16
  21. package/src/cli/commands/news-search.ts +22 -0
  22. package/src/cli/commands/overview.ts +84 -19
  23. package/src/cli/commands/search.ts +13 -13
  24. package/src/cli/commands/ticker.ts +32 -2
  25. package/src/cli/context.ts +1 -0
  26. package/src/cli/errors.ts +17 -2
  27. package/src/cli/glossary.ts +8 -1
  28. package/src/cli/history-rows.ts +35 -7
  29. package/src/cli/index-roots.ts +0 -7
  30. package/src/cli/known-gaps.ts +11 -0
  31. package/src/cli/listing-arg.ts +74 -11
  32. package/src/cli/pane-functions/capabilities.ts +1 -0
  33. package/src/cli/pane-functions/discovery.ts +21 -1
  34. package/src/cli/pane-functions/freshness.ts +7 -1
  35. package/src/cli/pane-functions/function-help.ts +7 -0
  36. package/src/cli/pane-functions/index.ts +6 -3
  37. package/src/cli/quote-answer.ts +30 -7
  38. package/src/cli/saved-copy-persistence.ts +88 -0
  39. package/src/components/command-bar/list/model.ts +5 -0
  40. package/src/components/command-bar/routes/root/runtime.ts +29 -8
  41. package/src/components/command-bar/selection-runtime.ts +64 -32
  42. package/src/components/command-bar/surface/index.tsx +2 -0
  43. package/src/components/layout/detached-pane-shell.tsx +2 -2
  44. package/src/components/layout/header.tsx +2 -2
  45. package/src/components/layout/pane/capture-stamp.ts +2 -2
  46. package/src/components/layout/pane/footer/index.tsx +23 -6
  47. package/src/components/layout/pane/footer/model.ts +116 -16
  48. package/src/components/layout/pane/header.tsx +2 -2
  49. package/src/components/layout/pane/status-footer.ts +37 -3
  50. package/src/components/layout/status-widget-space.ts +9 -0
  51. package/src/components/onboarding/onboarding-frame.tsx +2 -1
  52. package/src/components/onboarding/onboarding-wizard.tsx +6 -3
  53. package/src/components/ticker/list-table-view.tsx +4 -20
  54. package/src/components/ui/display.tsx +29 -3
  55. package/src/components/ui/icon.tsx +12 -3
  56. package/src/core/app-services.ts +11 -2
  57. package/src/data/app-persistence.ts +9 -7
  58. package/src/data/plugin-cache.ts +20 -3
  59. package/src/data/plugin-state-store.ts +81 -24
  60. package/src/data/resource-store.ts +130 -58
  61. package/src/data/saved-copies.ts +8 -0
  62. package/src/data/session-store.ts +31 -8
  63. package/src/data/sqlite/access.ts +313 -0
  64. package/src/data/sqlite/checkpointer.ts +198 -0
  65. package/src/data/sqlite/database.ts +47 -4
  66. package/src/data/sqlite/retry.ts +50 -9
  67. package/src/data/ticker-store.ts +57 -23
  68. package/src/docs/snapshot.json +32 -5
  69. package/src/i18n/es.ts +2 -4
  70. package/src/i18n/ja.ts +2 -4
  71. package/src/i18n/ko.ts +2 -4
  72. package/src/i18n/zh-cn.ts +2 -4
  73. package/src/i18n/zh-tw.ts +2 -4
  74. package/src/market-data/depositary-pair.ts +25 -1
  75. package/src/market-data/quotes/metadata.ts +7 -0
  76. package/src/market-data/realized-volatility.ts +4 -2
  77. package/src/news/aggregator.ts +7 -1
  78. package/src/news/types.ts +6 -0
  79. package/src/plugins/auto-update-node.ts +10 -6
  80. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  81. package/src/plugins/builtin/alerts/cli.ts +34 -11
  82. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  83. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  84. package/src/plugins/builtin/alerts/headless.ts +10 -6
  85. package/src/plugins/builtin/cds/client.ts +142 -30
  86. package/src/plugins/builtin/cds/headless.ts +35 -8
  87. package/src/plugins/builtin/cds/model.ts +145 -18
  88. package/src/plugins/builtin/cds/pane.tsx +16 -5
  89. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  90. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  91. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  92. package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
  93. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  94. package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
  95. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  96. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  97. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  98. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  99. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  100. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  101. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  102. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  103. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  104. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  105. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  106. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  107. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  108. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  109. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  110. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  111. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  112. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  113. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  114. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  115. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  116. package/src/plugins/builtin/composite-plugins.ts +2 -1
  117. package/src/plugins/builtin/cot/client.ts +28 -2
  118. package/src/plugins/builtin/cot/headless.ts +4 -1
  119. package/src/plugins/builtin/cot/index.tsx +4 -2
  120. package/src/plugins/builtin/cot/model.ts +58 -7
  121. package/src/plugins/builtin/cot/pane.tsx +6 -2
  122. package/src/plugins/builtin/cpi/client.ts +6 -5
  123. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  124. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  125. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  126. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  127. package/src/plugins/builtin/crypto-board/series.ts +4 -4
  128. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  129. package/src/plugins/builtin/doe/client.ts +6 -5
  130. package/src/plugins/builtin/doe/headless.ts +3 -2
  131. package/src/plugins/builtin/doe/pane.tsx +13 -3
  132. package/src/plugins/builtin/doe/prices.ts +2 -3
  133. package/src/plugins/builtin/doe/series.ts +4 -4
  134. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  135. package/src/plugins/builtin/earnings-calls/data.ts +37 -17
  136. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  137. package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
  138. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  139. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  140. package/src/plugins/builtin/econ/detail-view.tsx +18 -4
  141. package/src/plugins/builtin/econ/figures.ts +48 -0
  142. package/src/plugins/builtin/econ/index.tsx +14 -5
  143. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  144. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  145. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  146. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  147. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  148. package/src/plugins/builtin/executives/data.ts +6 -1
  149. package/src/plugins/builtin/executives/pane.tsx +9 -21
  150. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  151. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  152. package/src/plugins/builtin/gpu/client.ts +4 -1
  153. package/src/plugins/builtin/gpu/series.ts +2 -3
  154. package/src/plugins/builtin/help/index.tsx +61 -59
  155. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  156. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  157. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  158. package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
  159. package/src/plugins/builtin/holders/headless.ts +4 -2
  160. package/src/plugins/builtin/holders/pane.tsx +3 -0
  161. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  162. package/src/plugins/builtin/insider/headless.ts +2 -2
  163. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  164. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  165. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  166. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  167. package/src/plugins/builtin/jodi/client.ts +4 -1
  168. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  169. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  170. package/src/plugins/builtin/macro-day/model.ts +39 -5
  171. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  172. package/src/plugins/builtin/market-movers/client.ts +3 -0
  173. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  174. package/src/plugins/builtin/market-movers/index.tsx +10 -5
  175. package/src/plugins/builtin/market-movers/screener.ts +27 -6
  176. package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
  177. package/src/plugins/builtin/mna/headless.ts +2 -0
  178. package/src/plugins/builtin/mna/pane.tsx +3 -1
  179. package/src/plugins/builtin/money-markets/series.ts +3 -5
  180. package/src/plugins/builtin/news/headless.ts +3 -1
  181. package/src/plugins/builtin/news/index.tsx +3 -0
  182. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  183. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  184. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  185. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  186. package/src/plugins/builtin/power/gas-client.ts +2 -1
  187. package/src/plugins/builtin/power/prices-client.ts +3 -2
  188. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  189. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  190. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  191. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  192. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  193. package/src/plugins/builtin/research/event-model.ts +30 -4
  194. package/src/plugins/builtin/research/index.tsx +7 -3
  195. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  196. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  197. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  198. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  199. package/src/plugins/builtin/risk-factors/data.ts +12 -6
  200. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  201. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  202. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  203. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  204. package/src/plugins/builtin/roadmap/model.ts +67 -0
  205. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  206. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  207. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  208. package/src/plugins/builtin/sec/headless.ts +2 -1
  209. package/src/plugins/builtin/sec/index.tsx +3 -0
  210. package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
  211. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  212. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  213. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  214. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  215. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  216. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  217. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  218. package/src/plugins/builtin/short-volume/series.ts +2 -3
  219. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  220. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  221. package/src/plugins/builtin/thirteenf/api.ts +15 -9
  222. package/src/plugins/builtin/thirteenf/data.ts +39 -11
  223. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  224. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  225. package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
  226. package/src/plugins/builtin/thirteenf/types.ts +2 -0
  227. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
  228. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  229. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  230. package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
  231. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  232. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  233. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  234. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  235. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  236. package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
  237. package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
  238. package/src/plugins/builtin/volatility/client.ts +43 -16
  239. package/src/plugins/builtin/volatility/headless.ts +5 -2
  240. package/src/plugins/builtin/volatility/model.ts +17 -6
  241. package/src/plugins/builtin/volatility/pane.tsx +11 -4
  242. package/src/plugins/builtin/volatility/series.ts +2 -1
  243. package/src/plugins/builtin/yield-curve/client.ts +59 -1
  244. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  245. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  246. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  247. package/src/plugins/catalog-browser.ts +2 -1
  248. package/src/plugins/host-link.ts +1 -1
  249. package/src/plugins/installer.ts +50 -39
  250. package/src/portfolio/column-values.ts +11 -7
  251. package/src/renderers/dom/desktop/icons.tsx +10 -2
  252. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  253. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  254. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  255. package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
  256. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  257. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  258. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  259. package/src/renderers/electrobun/bun/index.ts +26 -2
  260. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  261. package/src/renderers/electrobun/bun/window/create.ts +45 -4
  262. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  263. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  264. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  265. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  266. package/src/renderers/electrobun/launcher/preload.ts +13 -0
  267. package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
  268. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  269. package/src/renderers/electrobun/view/news-source.ts +2 -0
  270. package/src/renderers/electrobun/view/ui-host.tsx +16 -1
  271. package/src/series/resolve.ts +2 -1
  272. package/src/series/types.ts +5 -0
  273. package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
  274. package/src/sources/gloomberb-cloud/index.ts +42 -8
  275. package/src/telemetry/stall-reports-node.ts +13 -2
  276. package/src/telemetry/stall-reports.ts +189 -13
  277. package/src/test-support/outage.ts +8 -0
  278. package/src/tickers/company-name-listing.ts +110 -40
  279. package/src/tickers/company-name.ts +71 -2
  280. package/src/tickers/index-roots.ts +7 -0
  281. package/src/tickers/listing-fate.ts +2 -2
  282. package/src/tickers/search/home-listing.ts +93 -8
  283. package/src/tickers/search/index.ts +7 -3
  284. package/src/tickers/search/ranking.ts +5 -1
  285. package/src/tickers/search/upsert.ts +13 -0
  286. package/src/tickers/ticker-changes.ts +8 -4
  287. package/src/time-series/market.ts +5 -3
  288. package/src/time-series/price-comparison.ts +20 -2
  289. package/src/time-series/resolve.ts +20 -1
  290. package/src/time-series/types.ts +7 -0
  291. package/src/types/company-from.ts +40 -0
  292. package/src/types/data-provider.ts +7 -0
  293. package/src/types/financials.ts +45 -3
  294. package/src/types/headless.ts +6 -0
  295. package/src/ui/host.tsx +6 -1
  296. package/src/utils/crypto-pair.ts +121 -7
  297. package/src/utils/declared-dividends.ts +1 -1
  298. package/src/utils/financial-statements.ts +73 -16
  299. package/src/utils/fundamentals.ts +53 -10
  300. package/src/utils/lease-liabilities.ts +54 -0
  301. package/src/utils/main-thread-stall.ts +47 -2
  302. package/src/utils/perf-marks.ts +23 -11
  303. package/src/utils/price-history-integrity.ts +47 -1
  304. package/src/version.ts +2 -2
@@ -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";
@@ -105,6 +107,9 @@ function secTypeColor(secType: string, selected: boolean): string {
105
107
  }
106
108
  }
107
109
 
110
+ /** An announced auction's Rate in the table, the word other panes use for a figure not out yet (KPI's Later actual). */
111
+ const RESULTS_PENDING = "Pending";
112
+
108
113
  // Stable table adapter so memoized rows survive pane re-renders.
109
114
  const auctionKey = (auction: TreasuryAuction) => auction.id;
110
115
 
@@ -119,8 +124,8 @@ function renderAuctionCell(
119
124
 
120
125
  switch (column.id) {
121
126
  case "date":
122
- // Announced auctions have no results yet; every metric cell reads "—",
123
- // so the date carries the distinction instead of a second placeholder.
127
+ // Announced auctions have no results yet: Rate reads Pending, the other
128
+ // metric cells "—", and the date is bright.
124
129
  return {
125
130
  text: showYear ? auction.auctionDate : formatShortDate(auction.auctionDate, { year: false, utc: true, fallback: "\u2014" }),
126
131
  color: rowState.selected ? colors.selectedText : isPendingAuction(auction) ? colors.textBright : colors.textDim,
@@ -134,7 +139,10 @@ function renderAuctionCell(
134
139
  case "term":
135
140
  return { text: auction.securityTerm, color: selected ?? colors.text };
136
141
  case "rate":
137
- return { text: formatAuctionRate(auction, rateValue(auction), "—"), color: selected ?? colors.textBright };
142
+ // An announced auction says its results are pending, as its detail does; other metric cells stay "—".
143
+ return isPendingAuction(auction)
144
+ ? { text: RESULTS_PENDING, color: dimmed }
145
+ : { text: formatAuctionRate(auction, rateValue(auction), "—"), color: selected ?? colors.textBright };
138
146
  case "stopOut":
139
147
  return { text: formatBp(stopOutVsAverageBp(auction)), color: selected ?? colors.text };
140
148
  case "btc":
@@ -202,8 +210,10 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
202
210
  const auctions = resource.data?.auctions ?? NO_AUCTIONS;
203
211
  const stale = resource.data?.stale ?? false;
204
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;
205
215
  // A cache that could not be refreshed arrives as a result, not a failure.
206
- 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);
207
217
  // Once auctions have loaded, a refresh runs without a loading state.
208
218
  const loading = resource.loading && !resource.data;
209
219
  const [filter, setFilter] = usePluginPaneState<AuctionFilter>("activeTab", "all");
@@ -296,8 +306,9 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
296
306
 
297
307
  usePaneFooter(TREASURY_AUCTIONS_PANE_ID, () => {
298
308
  const info = loadingErrorFooterInfo(loading, error);
299
- if (stale) info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] });
300
- if (fetchedAt) {
309
+ if (savedAt != null) info.push(...savedCopyFooterInfo([savedAt]));
310
+ else if (stale) info.push(STALE_SEGMENT);
311
+ if (fetchedAt && savedAt == null) {
301
312
  info.push({ id: "updated", parts: [{ text: formatRelativeAge(fetchedAt), tone: "muted" }] });
302
313
  }
303
314
  return {
@@ -317,6 +328,7 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
317
328
  fetchedAt,
318
329
  focusSearch,
319
330
  loading,
331
+ savedAt,
320
332
  stale,
321
333
  ]);
322
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
  };
@@ -30,6 +30,11 @@ export interface VolatilityHistoryInput {
30
30
  stale?: boolean;
31
31
  error?: string | null;
32
32
  fetchedAt?: number | null;
33
+ /**
34
+ * When the copy shown was saved, when a failed refresh left it answering:
35
+ * the pane's footer says "saved copy from …" instead of the failure.
36
+ */
37
+ savedAt?: number | null;
33
38
  }
34
39
  export interface VolatilitySeriesInput {
35
40
  observations: CloudFredObservationPayload[];
@@ -37,6 +42,8 @@ export interface VolatilitySeriesInput {
37
42
  stale?: boolean;
38
43
  error?: string | null;
39
44
  fetchedAt?: number | null;
45
+ /** As `VolatilityHistoryInput.savedAt`. */
46
+ savedAt?: number | null;
40
47
  }
41
48
  export interface VolatilityInputs {
42
49
  history?: Partial<Record<VolatilityIndexId, VolatilityHistoryInput>>;
@@ -131,6 +138,8 @@ export interface VolatilityBoardRow {
131
138
  status: "available" | "limited" | "unavailable";
132
139
  stale: boolean;
133
140
  error: string | null;
141
+ /** When the copy shown was saved, when a failed refresh left it answering. */
142
+ savedAt?: number;
134
143
  warnings: string[];
135
144
  }
136
145
  export interface VolatilityData {
@@ -233,8 +242,9 @@ function fredHistory(inputs: VolatilityInputs["fred"]): FredVolatilityHistory {
233
242
  const input = inputs?.[definition.seriesId];
234
243
  const normalized = normalizeHistory(input?.observations ?? [], true);
235
244
  warnings.push(...normalized.warnings.map((warning) => `${definition.seriesId}: ${warning}`));
236
- if (input?.error) warnings.push(`${definition.seriesId}: ${input.error}`);
237
- if (input?.stale) warnings.push(`${definition.seriesId}: cached source is stale`);
245
+ // A saved copy is dated in the footer; its failed refresh is not repeated here.
246
+ if (input?.error && input.savedAt == null) warnings.push(`${definition.seriesId}: ${input.error}`);
247
+ if (input?.stale && input.savedAt == null) warnings.push(`${definition.seriesId}: cached source is stale`);
238
248
  const latest = normalized.history.at(-1);
239
249
  return { ...definition, title: input?.info?.title ?? definition.label, value: latest?.value ?? null,
240
250
  date: latest?.date ?? null, history: normalized.history,
@@ -300,15 +310,16 @@ function boardRow(definition: typeof VOLATILITY_INDICES[number], input: Volatili
300
310
  if (latest && latest.date !== normalized.rows.at(-1)?.date) warnings.push("Latest supplied close unavailable; showing last valid observation");
301
311
  if (latest && !adjacent) warnings.push("1D change unavailable: previous daily close missing or too far apart");
302
312
  if (latest && !broadCoverage) warnings.push(`1Y percentile unavailable: ${history.length} observations across ${coverageDays} days`);
303
- if (input?.stale) warnings.push("Cached daily history is stale");
304
- if (input?.error) warnings.push(input.error);
313
+ // A saved copy is dated in the footer; its failed refresh is not repeated here.
314
+ if (input?.stale && input.savedAt == null) warnings.push("Cached daily history is stale");
315
+ if (input?.error && input.savedAt == null) warnings.push(input.error);
305
316
  return { ...definition, unit: "index points", value: latest?.value ?? null, date: latest?.date ?? null,
306
317
  source: input?.source ?? null, previousDate: previous?.date ?? null, change1d,
307
318
  change1dPercent: change1d != null ? change1d / previous!.value! * 100 : null,
308
319
  percentile1y, sampleSize: history.length, coverageDays, history,
309
320
  missingDates: normalized.rows.filter((row) => row.value == null && Date.parse(row.date) > cutoff).map((row) => row.date),
310
321
  status: !latest ? "unavailable" : warnings.length > 0 ? "limited" : "available",
311
- stale: input?.stale ?? false, error: input?.error ?? null, warnings };
322
+ stale: input?.stale ?? false, error: input?.error ?? null, ...(input?.savedAt != null ? { savedAt: input.savedAt } : {}), warnings };
312
323
  }
313
324
  function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatilityHistory, term: VolatilityTermHistory): VolatilityCurve {
314
325
  const rows = VOLATILITY_CURVE_INDICES.map((definition) => board.find((row) => row.id === definition.id)!);
@@ -337,7 +348,7 @@ function alignedCurve(board: readonly VolatilityBoardRow[], fred: FredVolatility
337
348
  const spot = points[1]!.value, threeMonth = points[2]!.value;
338
349
  const warnings = points.flatMap((point, index) => [
339
350
  ...(point.value == null ? [`${point.label}: no close aligned to ${date ?? "a common date"}`] : []),
340
- ...(point.value != null && rows[index]!.stale ? [`${point.label}: aligned close is from stale cached history`] : []),
351
+ ...(point.value != null && rows[index]!.stale && rows[index]!.savedAt == null ? [`${point.label}: aligned close is from stale cached history`] : []),
341
352
  ]);
342
353
  const ratio = spot != null && threeMonth != null ? threeMonth / spot : null;
343
354
  const context = ratioPercentile(term, ratio, date);
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { ChartTableHeader, CurveSurface, curveGhostColors, DataTableView, formatPercentAxis, PaneStatusBody, useChartTableSelection,
3
3
  usePaneFooter, usePaneNoticeFooter, usePaneTabs, type DataTableColumn, type StatItem } from "../../../components";
4
4
  import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve";
5
+ import { savedCopyFooterInfo } from "../../../components/layout/pane/footer";
6
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
5
7
  import { dropColumnsToFit } from "../../../components/ui/table-layout";
6
8
  import { useAsyncResource } from "../../../react/async-resource";
7
9
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
@@ -170,10 +172,15 @@ export function VolatilityPane({ focused, width, height }: PaneProps) {
170
172
  const observationBasis = intraday ? "intraday" : sparse ? "observation" : settled ? "close" : DAY_SO_FAR_TEXT;
171
173
  usePaneFooter("volatility", () => ({ info: [
172
174
  ...(resource.loading ? [{ id: "loading", parts: [{ text: "loading volatility", tone: "muted" as const }] }] : []),
173
- ...(result?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
174
- ...(data ? [{ id: "basis", parts: [{ text: observationBasis, tone: "muted" as const }] }] : []),
175
- ...(asOf ? [{ id: "date", parts: [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] }] : []),
176
- ], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, data, asOf, observationTime, observationBasis, cycleTab]);
175
+ // A saved copy answering a failed refresh says when it was saved; other stale closes say only that.
176
+ ...savedCopyFooterInfo([result?.savedAt]),
177
+ ...(result?.stale && result.savedAt == null ? [STALE_SEGMENT] : []),
178
+ // One fact, "close 2026-09-25": the basis and its date read as one segment.
179
+ ...(data || asOf ? [{ id: "basis", parts: [
180
+ ...(data ? [{ text: observationBasis, tone: "muted" as const }] : []),
181
+ ...(asOf ? [{ text: observationTime ? `${observationTime.slice(0, 16).replace("T", " ")} UTC` : asOf, tone: "muted" as const }] : []),
182
+ ] }] : []),
183
+ ], hints: [{ id: "view", key: "v", label: "iew", onPress: cycleTab }] }), [resource.loading, result?.stale, result?.savedAt, data, asOf, observationTime, observationBasis, cycleTab]);
177
184
  const { strip: tabStrip, rows: tabRows } = usePaneTabs({ tabs: TABS, activeValue: tab, onSelect: setTab, focused, dense: true });
178
185
  const contentHeight = Math.max(5, height - tabRows);
179
186
  // The board needs only its rows; the selected index history takes the rest.
@@ -105,11 +105,12 @@ export const VOLATILITY_SERIES: readonly SeriesDefinition[] = [
105
105
  tags: ["term structure", "contango", "backwardation", "VIX"],
106
106
  description: "VIX 3M over VIX on each date both closed, from Cboe's daily closes and FRED's before them; above 1 is contango.",
107
107
  load: async ({ start, signal }) => {
108
- const { term, warning } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
108
+ const { term, warning, savedAt } = await loadVolatilityTermHistory({ ...(start ? { start } : {}), ...(signal ? { signal } : {}) });
109
109
  const attribution = termAttribution(term);
110
110
  return {
111
111
  points: term.ratioHistory.map((point) => ({ date: point.date, value: point.value })),
112
112
  ...(warning ? { warning } : {}),
113
+ ...(savedAt != null ? { savedAt } : {}),
113
114
  ...(attribution ? { attribution } : {}),
114
115
  };
115
116
  },
@@ -1,8 +1,10 @@
1
1
  import { apiClient } from "../../../api-client";
2
2
  import type { SwapCurvePayload, SwapTenor } from "../../../api-client/swap-curve";
3
- import { createPluginCache } from "../../../data/plugin-cache";
3
+ import { createPluginCache, savedCopyForTransientFailure, type PluginCacheResult } from "../../../data/plugin-cache";
4
4
  import { cachedCloudResource, loadCloudResource, unavailableOnServer } from "../shared/cloud-resource";
5
5
  import { validateSwapCurve } from "./swaps-model";
6
+ import { loadCurveData, type CurveData, type CurveId } from "./curves";
7
+ import { loadWorldRows, type WorldRow } from "./world";
6
8
 
7
9
  export const swapCurveCache = createPluginCache<SwapCurvePayload>({
8
10
  kind: "sofr-swap-curve", source: "gloom-cloud", schemaVersion: 1,
@@ -23,3 +25,59 @@ export async function fetchSwapCurve(tenor: SwapTenor,
23
25
  export const getCachedSwapCurve = (tenor: SwapTenor) => cachedCloudResource(swapCurveCache, tenor, validateSwapCurve);
24
26
  export const loadSwapCurve = (tenor: SwapTenor, force = false) =>
25
27
  loadCloudResource(swapCurveCache, tenor, () => fetchSwapCurve(tenor), { force, validate: validateSwapCurve });
28
+
29
+ const DAY_MS = 24 * 60 * 60_000;
30
+
31
+ /**
32
+ * The Curve and World tabs' curves as last read, so a refresh that fails
33
+ * (offline, a service error) shows the copy an earlier read saved instead of
34
+ * an empty tab. Every load still asks the server; the copy is the fallback.
35
+ */
36
+ export const curveViewCache = createPluginCache<CurveData>({
37
+ kind: "yield-curve-view", source: "gloom-cloud", schemaVersion: 1,
38
+ policy: { staleMs: 15 * 60_000, expireMs: 30 * DAY_MS },
39
+ });
40
+ export const worldCurvesCache = createPluginCache<WorldRow[]>({
41
+ kind: "world-curves", source: "gloom-cloud", schemaVersion: 1,
42
+ policy: { staleMs: 15 * 60_000, expireMs: 30 * DAY_MS },
43
+ });
44
+
45
+ /** What a tab shows, and when the copy shown was saved when it is one, its refresh having failed. */
46
+ export interface SavedCopyAnswer<T> {
47
+ data: T;
48
+ savedAt: number | null;
49
+ }
50
+
51
+ const curveKey = (curve: CurveId, requestedDate: string) => `${curve}:${requestedDate || "latest"}`;
52
+ /**
53
+ * A copy is the saved one by where it came from, whatever its failure said. A
54
+ * refusal (access denied, an unknown curve) or a reply that fails validation
55
+ * is said rather than covered by the copy, as `loadCloudResource` does.
56
+ */
57
+ function answer<T>(result: PluginCacheResult<T>): SavedCopyAnswer<T> {
58
+ const saved = savedCopyForTransientFailure(result).source === "stale-fallback";
59
+ return { data: result.data, savedAt: saved ? result.fetchedAt : null };
60
+ }
61
+
62
+ /** The curve on the date (its latest without one), or the copy an earlier read saved when it cannot be read. */
63
+ export async function loadSavedCurve(curve: CurveId, requestedDate: string,
64
+ client?: Parameters<typeof loadCurveData>[2]): Promise<SavedCopyAnswer<CurveData>> {
65
+ return answer(await curveViewCache.load(curveKey(curve, requestedDate), () => loadCurveData(curve, requestedDate, client), { force: true }));
66
+ }
67
+
68
+ /** The saved curve, for the tab's first paint while it is read again. */
69
+ export function cachedCurve(curve: CurveId, requestedDate: string): SavedCopyAnswer<CurveData> | null {
70
+ const cached = curveViewCache.get(curveKey(curve, requestedDate), { allowExpired: true });
71
+ return cached ? { data: cached.data, savedAt: null } : null;
72
+ }
73
+
74
+ /** Each market's latest curve, or the copy an earlier read saved when they cannot be read. */
75
+ export async function loadSavedWorldRows(client?: Parameters<typeof loadWorldRows>[0]): Promise<SavedCopyAnswer<WorldRow[]>> {
76
+ return answer(await worldCurvesCache.load("latest", () => loadWorldRows(client), { force: true }));
77
+ }
78
+
79
+ /** The saved world board, for the tab's first paint while it is read again. */
80
+ export function cachedWorldRows(): SavedCopyAnswer<WorldRow[]> | null {
81
+ const cached = worldCurvesCache.get("latest", { allowExpired: true });
82
+ return cached ? { data: cached.data, savedAt: null } : null;
83
+ }
@@ -4,16 +4,17 @@ import type {
4
4
  } from "../../../types/plugin";
5
5
  import {
6
6
  compareDate, curveCutoff, CURVE_OPTIONS, curveIdOf, curveOption, curveSpreadFigures, curveTenorMove, loadComparePoints,
7
- loadCurveData, parseCompareInput, parseCurveArgument, type CurveData, type CurveId,
7
+ parseCompareInput, parseCurveArgument, type CurveData, type CurveId,
8
8
  } from "./curves";
9
9
  import { yieldTenorRows, type YieldLookbackCurves } from "./chart";
10
10
  import { curveAsOf, type YieldPoint, yieldCurveErrors } from "./treasury-data";
11
11
  import { loadYieldCurveLookbacks, yieldCurveDate } from "./history";
12
- import { loadWorldRows, type WorldRow } from "./world";
12
+ import type { WorldRow } from "./world";
13
13
  import { formatBasisPoints, toBasisPoints } from "../../../utils/basis-points";
14
14
  import { DATE_OPTION_EXAMPLES } from "../../../utils/calendar-date";
15
15
  import { SWAP_TENORS, type SwapCurvePayload, type SwapTenor } from "../../../api-client/swap-curve";
16
- import { fetchSwapCurve } from "./client";
16
+ import { fetchSwapCurve, loadSavedCurve, loadSavedWorldRows } from "./client";
17
+ import { savedCopyMark } from "../../../data/saved-copies";
17
18
  import { formatSwapBp, selectedSwapTenor } from "./swaps-model";
18
19
 
19
20
  const percent = (value: unknown) => value == null ? "-" : `${Number(value).toFixed(2)}%`;
@@ -87,13 +88,14 @@ export interface YieldCurveHeadlessDependencies {
87
88
  }
88
89
 
89
90
  const defaultDependencies: Required<YieldCurveHeadlessDependencies> = {
90
- load: (curve, requestedDate, context) => loadCurveData(curve, requestedDate, context.apiClient),
91
+ // A curve that cannot be read is the copy an earlier read saved, marked so the report says so.
92
+ load: async (curve, requestedDate, context) => (await loadSavedCurve(curve, requestedDate, context.apiClient)).data,
91
93
  compare: (curve, date, context) => loadComparePoints(curve, date, context.apiClient),
92
94
  lookbacks: async (session, context) => Object.fromEntries(
93
95
  (await loadYieldCurveLookbacks(session, (id, options) => context.apiClient.getCloudFredSeries(id, options)))
94
96
  .map((lookback) => [lookback.id, lookback.points]),
95
97
  ),
96
- world: (context) => loadWorldRows(context.apiClient),
98
+ world: async (context) => (await loadSavedWorldRows(context.apiClient)).data,
97
99
  swaps: (tenor, context) => fetchSwapCurve(tenor, context.apiClient),
98
100
  };
99
101
 
@@ -184,7 +186,7 @@ export function createYieldCurveHeadless(
184
186
  return {
185
187
  freshness: { source: "Government bond publishers", status: "not-a-feed", basis: "daily curves" },
186
188
  columns: WORLD_COLUMNS,
187
- rows: rows.map((row) => ({ market: row.market, twoYear: row.twoYear, tenYear: row.tenYear, thirtyYear: row.thirtyYear,
189
+ rows: rows.map((row) => ({ ...savedCopyMark(row), market: row.market, twoYear: row.twoYear, tenYear: row.tenYear, thirtyYear: row.thirtyYear,
188
190
  twosTensBasisPoints: bp(row.twosTens), tenYearChangeBasisPoints: bp(row.tenYearChange), asOf: row.asOf })),
189
191
  metadata: { units: "Percent" },
190
192
  };
@@ -244,6 +246,7 @@ export function createYieldCurveHeadless(
244
246
  const day = ranked ? curveTenorMove(data, point.maturity, "1D") : null;
245
247
  const week = ranked ? curveTenorMove(data, point.maturity, "1W") : null;
246
248
  return {
249
+ ...savedCopyMark(data),
247
250
  ...point,
248
251
  change1dBasisPoints: change == null ? null : toBasisPoints(change, 1),
249
252
  ...(ranked ? {
@@ -8,6 +8,7 @@ import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve
8
8
  import { curveLookbackLabel } from "../../../components/chart/curve/model";
9
9
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
10
10
  import { useAsyncResource } from "../../../react/async-resource";
11
+ import { isPermanentClientError } from "../../../api-client/errors";
11
12
  import { usePaneSettingValue } from "../../../state/app/context";
12
13
  import { usePluginPaneState } from "../../../public/react";
13
14
  import { priceColor } from "../../../theme/colors";
@@ -18,6 +19,7 @@ import type { PluginModule } from "../plugin-module";
18
19
  import { GOVERNMENT_BOND_SERIES, TREASURY_SERIES } from "./series";
19
20
  import { useAutoRefresh } from "../../../react/auto-refresh";
20
21
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
22
+ import { savedCopyFooterSegment, type PaneFooterSegment } from "../../../components/layout/pane/footer";
21
23
  import { formatPercentileRank } from "../../../utils/format";
22
24
  import { SERIES_COLORS } from "../../../theme/series-colors";
23
25
  import type { CurveSeries } from "../../../components/chart/curve";
@@ -27,20 +29,28 @@ import {
27
29
  type YieldTenorRow,
28
30
  } from "./chart";
29
31
  import {
30
- compareDate, curveCutoff, curveIdOf, CURVE_OPTIONS, curveSpreadFigures, curveTenorMove, loadComparePoints, loadCurveData,
32
+ compareDate, curveCutoff, curveIdOf, CURVE_OPTIONS, curveSpreadFigures, curveTenorMove, loadComparePoints,
31
33
  parseCompareInput, parseCurveArgument, type CurveId,
32
34
  } from "./curves";
33
35
  import { formatMoveRank, MOVE_HORIZONS } from "../shared/move-rank";
34
36
  import { forwardCurve, type CurveBasis } from "./forward";
35
37
  import { loadYieldCurveLookbacks, yieldCurveDate } from "./history";
36
38
  import { curveAsOf, isYieldObservationDate, type YieldPoint, yieldCurveErrors } from "./treasury-data";
37
- import { loadWorldRows, type WorldRow } from "./world";
39
+ import type { WorldRow } from "./world";
38
40
  import { SwapsTab } from "./swaps";
39
- import { swapCurveCache } from "./client";
41
+ import {
42
+ cachedCurve, cachedWorldRows, curveViewCache, loadSavedCurve, loadSavedWorldRows, swapCurveCache, worldCurvesCache,
43
+ } from "./client";
40
44
 
41
45
  type GcTab = "curve" | "world" | "swaps";
42
46
  const TABS = [{ label: "Curve", value: "curve" }, { label: "World", value: "world" }, { label: "Swaps", value: "swaps" }];
43
47
  const EMPTY_POINTS: YieldPoint[] = [];
48
+
49
+ /** A tab showing the copy an earlier read saved, its refresh having failed, says when that copy is from. */
50
+ function useSavedCopyInfo(savedAt: number | null | undefined): PaneFooterSegment[] | undefined {
51
+ return useMemo(() => savedAt != null ? [savedCopyFooterSegment("saved-copy", savedAt)] : undefined, [savedAt]);
52
+ }
53
+
44
54
  /** Axis word for the loaded curve's basis. */
45
55
  const BASIS_LABEL: Record<CurveBasis, string> = {
46
56
  par: "Par yield",
@@ -165,8 +175,14 @@ function CurveTab({ curve, onCurveChange, focused, width, height }: {
165
175
  const compareField = useDraftField(compareInput);
166
176
  const editing = dateField.active || compareField.active;
167
177
 
168
- const loadCurve = useCallback(async () => loadCurveData(curve, requestedDate), [curve, requestedDate]);
169
- const { data, loading, error, updatedAt: lastUpdated, load } = useAsyncResource(loadCurve);
178
+ // The saved copy paints first and answers when a refresh fails; a failed refresh never empties the tab, unless
179
+ // asking again cannot help (access denied), which is said instead.
180
+ const loadCurve = useCallback(async () => loadSavedCurve(curve, requestedDate), [curve, requestedDate]);
181
+ const { data: answer, loading, error, updatedAt: lastUpdated, load } = useAsyncResource(loadCurve, {
182
+ initialData: () => cachedCurve(curve, requestedDate), clearOnError: isPermanentClientError,
183
+ });
184
+ const data = answer?.data ?? null;
185
+ const savedInfo = useSavedCopyInfo(answer?.savedAt);
170
186
  // A pending curve or date change must never relabel the previous curve as the new one.
171
187
  const current = data?.curve === curve && data.requestedDate === requestedDate ? data : null;
172
188
  const points = current?.points ?? EMPTY_POINTS;
@@ -260,6 +276,7 @@ function CurveTab({ curve, onCurveChange, focused, width, height }: {
260
276
  registrationId: "yield-curve",
261
277
  loading: loading || (compareKey != null && compareResource.loading && !comparePoints),
262
278
  error: error || sourceError || null,
279
+ info: savedInfo,
263
280
  hints: [
264
281
  { id: "date", key: "d", label: "ate", onPress: dateField.focus },
265
282
  { id: "compare", key: "v", label: "s", title: "Compare with a date", onPress: compareField.focus },
@@ -413,11 +430,14 @@ function WorldTab({ onOpen, focused, width, height }: {
413
430
  }) {
414
431
  const colors = useThemeColors();
415
432
  const [selected, setSelected] = usePluginPaneState<string | null>("yield-curve:market", null);
416
- const loadWorld = useCallback(() => loadWorldRows(), []);
417
- const { data, loading, error, updatedAt, load } = useAsyncResource(loadWorld);
433
+ const loadWorld = useCallback(() => loadSavedWorldRows(), []);
434
+ const { data: answer, loading, error, updatedAt, load } = useAsyncResource(loadWorld, {
435
+ initialData: cachedWorldRows, clearOnError: isPermanentClientError,
436
+ });
437
+ const data = answer?.data ?? null;
418
438
  useAutoRefresh(updatedAt, load);
419
439
  usePaneRefreshKey(() => void load(), { focused });
420
- usePaneStatusFooter({ registrationId: "yield-curve:world", loading, error: error || null });
440
+ usePaneStatusFooter({ registrationId: "yield-curve:world", loading, error: error || null, info: useSavedCopyInfo(answer?.savedAt) });
421
441
  const rows = data ?? [];
422
442
  const selectedId = rows.some((row) => row.id === selected) ? selected! : rows[0]?.id ?? null;
423
443
  // Every market's latest curve, each in its own colour; the selected row is
@@ -465,8 +485,12 @@ function WorldTab({ onOpen, focused, width, height }: {
465
485
  }
466
486
 
467
487
  export const yieldCurveModule: PluginModule = {
468
- setup(ctx) { swapCurveCache.attach(ctx.persistence); },
469
- dispose() { swapCurveCache.reset(); },
488
+ setup(ctx) {
489
+ for (const cache of [swapCurveCache, curveViewCache, worldCurvesCache]) cache.attach(ctx.persistence);
490
+ },
491
+ dispose() {
492
+ for (const cache of [swapCurveCache, curveViewCache, worldCurvesCache]) cache.reset();
493
+ },
470
494
  series: [...TREASURY_SERIES, ...GOVERNMENT_BOND_SERIES],
471
495
  panes: [{
472
496
  id: "yield-curve",
@@ -7,8 +7,9 @@ import {
7
7
  import { curveStrip, curveSurfaceMinRows } from "../../../components/chart/curve";
8
8
  import { usePaneRefreshKey } from "../../../components/data-table/table-pane";
9
9
  import { usePaneStatusFooter } from "../../../components/layout/pane/status-footer";
10
+ import { savedCopyFooterSegment } from "../../../components/layout/pane/footer";
10
11
  import { FILL_REST } from "../../../components/ui/fill";
11
- import { isAccessDenied } from "../../../api-client/errors";
12
+ import { isPermanentClientError } from "../../../api-client/errors";
12
13
  import { usePluginPaneState } from "../../../public/react";
13
14
  import { useAsyncResource } from "../../../react/async-resource";
14
15
  import { useAutoRefresh } from "../../../react/auto-refresh";
@@ -37,17 +38,20 @@ export function SwapsTab({ focused, width, height }: { focused: boolean; width:
37
38
  const tenor = selectedSwapTenor(selected);
38
39
  const loader = useCallback((force: boolean) => loadSwapCurve(tenor, force), [tenor]);
39
40
  const resource = useAsyncResource(loader, {
40
- initialData: () => getCachedSwapCurve(tenor), keepPreviousData: true, clearOnError: isAccessDenied,
41
+ initialData: () => getCachedSwapCurve(tenor), keepPreviousData: true, clearOnError: isPermanentClientError,
41
42
  });
42
43
  const payload = resource.data?.payload;
43
44
  const rows = payload?.asOf ? payload.points : EMPTY_ROWS;
44
45
  // A new tenor keeps the curve/table in place, but never relabels the old tenor's history.
45
46
  const history = payload?.history.tenor === tenor ? payload.history : null;
46
- const stale = !!payload?.asOf && (payload.stale || resource.data?.stale || false);
47
- const error = resource.error || resource.data?.refreshError || null;
47
+ // A refresh that failed shows the copy an earlier read saved, and says when that copy is from.
48
+ const savedAt = resource.data?.savedAt ?? null;
49
+ const savedInfo = useMemo(() => savedAt != null ? [savedCopyFooterSegment("saved-copy", savedAt)] : undefined, [savedAt]);
50
+ const stale = !!payload?.asOf && (payload.stale || (savedAt == null && resource.data?.stale) || false);
51
+ const error = resource.error || (savedAt == null ? resource.data?.refreshError : null) || null;
48
52
  useAutoRefresh(stale ? null : resource.updatedAt, resource.load);
49
53
  usePaneRefreshKey(resource.reload, { focused });
50
- usePaneStatusFooter({ registrationId: "yield-curve:swaps", loading: resource.loading, error, stale });
54
+ usePaneStatusFooter({ registrationId: "yield-curve:swaps", loading: resource.loading, error, stale, info: savedInfo });
51
55
  usePaneNoticeFooter({
52
56
  registrationId: "yield-curve:swaps:notices", focused,
53
57
  notices: payload?.asOf ? [