@gloom-sh/terminal 0.19.1 → 0.19.3

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 (554) hide show
  1. package/README.md +7 -1
  2. package/package.json +2 -1
  3. package/patches/electrobun@1.18.1.patch +108 -1
  4. package/src/account/upgrade-dialog.tsx +4 -23
  5. package/src/api-client/coalesce.ts +118 -0
  6. package/src/api-client/credit-boards.ts +13 -0
  7. package/src/api-client/crypto-etf-flows.ts +6 -0
  8. package/src/api-client/crypto-markets.ts +40 -0
  9. package/src/api-client/data.ts +201 -14
  10. package/src/api-client/equity-screener.ts +10 -0
  11. package/src/api-client/errors.ts +58 -0
  12. package/src/api-client/gpu.ts +29 -0
  13. package/src/api-client/index.ts +5 -2
  14. package/src/api-client/plan-access.ts +10 -1
  15. package/src/api-client/plan-rules.ts +35 -0
  16. package/src/api-client/research-activity.ts +1 -32
  17. package/src/api-client/types.ts +21 -1
  18. package/src/app/attention-notice.tsx +94 -0
  19. package/src/app/runtime/startup.ts +3 -1
  20. package/src/app.tsx +23 -1
  21. package/src/architecture/core-builtin-allowlist.json +2 -1
  22. package/src/capabilities/chart-series.ts +2 -1
  23. package/src/capabilities/factories.ts +1 -0
  24. package/src/cli/ascii-output.ts +42 -0
  25. package/src/cli/base-converter.ts +10 -2
  26. package/src/cli/command-options.ts +16 -1
  27. package/src/cli/commands/crypto-hints.ts +23 -4
  28. package/src/cli/commands/market.ts +101 -27
  29. package/src/cli/commands/news-search.ts +24 -5
  30. package/src/cli/commands/overview.ts +14 -10
  31. package/src/cli/commands/search.ts +84 -25
  32. package/src/cli/commands/system.ts +22 -30
  33. package/src/cli/commands/ticker.ts +49 -9
  34. package/src/cli/commands/version.ts +33 -0
  35. package/src/cli/context.ts +6 -9
  36. package/src/cli/data-dir.ts +7 -0
  37. package/src/cli/desktop-pane-shot-api-policy.ts +85 -0
  38. package/src/cli/desktop-pane-shot-browser.ts +320 -0
  39. package/src/cli/desktop-pane-shot-routes.ts +26 -1
  40. package/src/cli/desktop-pane-shot.ts +196 -256
  41. package/src/cli/early-command.ts +42 -0
  42. package/src/cli/earnings-calendar.ts +9 -0
  43. package/src/cli/entry.ts +19 -13
  44. package/src/cli/errors.ts +12 -2
  45. package/src/cli/glossary.ts +2 -1
  46. package/src/cli/help.ts +1 -1
  47. package/src/cli/history-export.ts +22 -2
  48. package/src/cli/history-rows.ts +189 -11
  49. package/src/cli/index.ts +18 -1
  50. package/src/cli/known-gaps.ts +51 -5
  51. package/src/cli/listing-arg.ts +63 -10
  52. package/src/cli/options.ts +1 -1
  53. package/src/cli/output-file.ts +60 -13
  54. package/src/cli/pane-functions/catalog.ts +14 -3
  55. package/src/cli/pane-functions/data.ts +1 -1
  56. package/src/cli/pane-functions/dom.ts +45 -0
  57. package/src/cli/pane-functions/freshness.ts +46 -9
  58. package/src/cli/pane-functions/function-help.ts +17 -9
  59. package/src/cli/pane-functions/headless.ts +22 -14
  60. package/src/cli/pane-functions/index.ts +6 -2
  61. package/src/cli/pane-functions/options.ts +6 -2
  62. package/src/cli/pane-functions/resolver.ts +27 -5
  63. package/src/cli/pane-functions/screenshot-assessment.ts +33 -0
  64. package/src/cli/pane-functions/screenshot-bridge.ts +94 -13
  65. package/src/cli/pane-functions/screenshot-http.ts +70 -0
  66. package/src/cli/pane-functions/screenshot-render.ts +19 -5
  67. package/src/cli/quote-answer.ts +49 -6
  68. package/src/cli/registry.ts +41 -13
  69. package/src/cli/result.ts +12 -6
  70. package/src/cli/ticker-resolution.ts +2 -1
  71. package/src/cli/types.ts +2 -0
  72. package/src/components/chart/composite/format.ts +3 -0
  73. package/src/components/chart-table/header.tsx +1 -1
  74. package/src/components/chart-table/layout.ts +28 -11
  75. package/src/components/command-bar/action-runtime.ts +4 -0
  76. package/src/components/command-bar/commands/direct/index.ts +18 -33
  77. package/src/components/command-bar/commands/direct/runtime.ts +5 -0
  78. package/src/components/command-bar/commands/registry.ts +10 -1
  79. package/src/components/command-bar/routes/root/command-items.ts +9 -4
  80. package/src/components/command-bar/routes/root/runtime.ts +51 -6
  81. package/src/components/command-bar/selection-runtime.ts +12 -29
  82. package/src/components/command-bar/surface/index.tsx +9 -3
  83. package/src/components/company-logo.tsx +2 -1
  84. package/src/components/layout/detached-pane-shell.tsx +2 -2
  85. package/src/components/layout/header.tsx +10 -5
  86. package/src/components/layout/market-summary.ts +25 -7
  87. package/src/components/layout/pane/footer/index.tsx +8 -2
  88. package/src/components/layout/pane/footer/model.ts +73 -6
  89. package/src/components/layout/pane/header.tsx +2 -2
  90. package/src/components/layout/pane/status-footer.ts +2 -1
  91. package/src/components/market-board/index.tsx +3 -2
  92. package/src/components/onboarding/desks-step.tsx +12 -4
  93. package/src/components/onboarding/first-run-workspace.ts +81 -4
  94. package/src/components/onboarding/onboarding-frame.tsx +4 -2
  95. package/src/components/onboarding/onboarding-keyboard.ts +8 -5
  96. package/src/components/onboarding/onboarding-wizard.tsx +60 -26
  97. package/src/components/onboarding/portfolio-step/positions-panel.tsx +7 -2
  98. package/src/components/onboarding/run-setup-again.ts +24 -0
  99. package/src/components/onboarding/use-onboarding-progress.ts +33 -15
  100. package/src/components/onboarding/use-onboarding-upgrade.ts +2 -6
  101. package/src/components/onboarding/wizard-model.ts +57 -3
  102. package/src/components/onboarding/wizard-positions.ts +1 -1
  103. package/src/components/ui/delayed-mark.tsx +56 -0
  104. package/src/components/ui/display.tsx +16 -5
  105. package/src/components/ui/external-link.tsx +3 -6
  106. package/src/components/ui/icon.tsx +16 -3
  107. package/src/components/ui/logo-mark.tsx +8 -5
  108. package/src/components/ui/stat-grid.tsx +263 -19
  109. package/src/core/app-services.ts +11 -2
  110. package/src/data/app-persistence.ts +9 -7
  111. package/src/data/config/store/node.ts +11 -5
  112. package/src/data/config/store/normalize.ts +16 -3
  113. package/src/data/plugin-cache.ts +15 -0
  114. package/src/data/plugin-state-store.ts +81 -24
  115. package/src/data/profile-permissions.ts +47 -0
  116. package/src/data/resource-store.ts +165 -90
  117. package/src/data/session-store.ts +31 -8
  118. package/src/data/sqlite/access.ts +324 -0
  119. package/src/data/sqlite/checkpointer.ts +285 -0
  120. package/src/data/sqlite/database.ts +80 -4
  121. package/src/data/sqlite/retry.ts +50 -9
  122. package/src/data/ticker-store.ts +57 -23
  123. package/src/docs/snapshot.json +191 -78
  124. package/src/i18n/es.ts +18 -2
  125. package/src/i18n/ja.ts +18 -2
  126. package/src/i18n/ko.ts +18 -2
  127. package/src/i18n/zh-cn.ts +18 -2
  128. package/src/i18n/zh-tw.ts +18 -2
  129. package/src/layout/desk-keys.ts +4 -1
  130. package/src/layout/desks.ts +38 -5
  131. package/src/layout-marketplace/gallery-desktop.tsx +45 -16
  132. package/src/market-data/batched-reads.ts +16 -0
  133. package/src/market-data/coordinator/auxiliary.ts +4 -2
  134. package/src/market-data/coordinator/index.ts +11 -2
  135. package/src/market-data/futures-board.ts +1 -1
  136. package/src/market-data/futures-contract.ts +3 -1
  137. package/src/market-data/market/index-level.ts +6 -4
  138. package/src/market-data/published-apac-sessions.ts +6 -0
  139. package/src/market-data/realized-volatility.ts +4 -2
  140. package/src/news/aggregator.ts +7 -4
  141. package/src/news/news-model.ts +11 -0
  142. package/src/news/story-id.ts +22 -0
  143. package/src/news/ticker-symbols.ts +17 -1
  144. package/src/notifications/app-notifier.ts +35 -14
  145. package/src/plugins/auto-update-node.ts +10 -6
  146. package/src/plugins/builtin/account-management/form-components.tsx +1 -1
  147. package/src/plugins/builtin/account-management/index.tsx +2 -3
  148. package/src/plugins/builtin/account-management/model.ts +1 -1
  149. package/src/plugins/builtin/alerts/cli-forms.ts +15 -1
  150. package/src/plugins/builtin/alerts/events-pane.tsx +7 -5
  151. package/src/plugins/builtin/alerts/events.ts +10 -2
  152. package/src/plugins/builtin/alerts/index.tsx +5 -5
  153. package/src/plugins/builtin/alerts/research-builder.ts +5 -1
  154. package/src/plugins/builtin/alerts/research-rules.ts +18 -0
  155. package/src/plugins/builtin/alerts/targets.ts +4 -1
  156. package/src/plugins/builtin/attention/headless.ts +1 -1
  157. package/src/plugins/builtin/attention/index.tsx +2 -4
  158. package/src/plugins/builtin/attention/listing.ts +1 -1
  159. package/src/plugins/builtin/attention/series.ts +1 -1
  160. package/src/plugins/builtin/awards/index.tsx +1 -2
  161. package/src/plugins/builtin/backtest/client.ts +1 -0
  162. package/src/plugins/builtin/backtest/index.tsx +1 -2
  163. package/src/plugins/builtin/backtest/rules.ts +3 -1
  164. package/src/plugins/builtin/backtest/window.ts +8 -7
  165. package/src/plugins/builtin/broker-manager/detail.tsx +16 -3
  166. package/src/plugins/builtin/broker-manager/index.tsx +1 -1
  167. package/src/plugins/builtin/buildout/cells.tsx +5 -0
  168. package/src/plugins/builtin/buildout/company/flow.tsx +367 -0
  169. package/src/plugins/builtin/buildout/company/id.ts +2 -0
  170. package/src/plugins/builtin/buildout/company/model.ts +247 -0
  171. package/src/plugins/builtin/buildout/company/pane.tsx +317 -0
  172. package/src/plugins/builtin/buildout/data-runtime.ts +31 -10
  173. package/src/plugins/builtin/buildout/facilities.ts +309 -0
  174. package/src/plugins/builtin/buildout/format.ts +9 -0
  175. package/src/plugins/builtin/buildout/index.tsx +51 -5
  176. package/src/plugins/builtin/buildout/model/index.ts +62 -10
  177. package/src/plugins/builtin/buildout/model/normalizers.ts +5 -0
  178. package/src/plugins/builtin/buildout/model/types.ts +11 -2
  179. package/src/plugins/builtin/buildout/pane/index.tsx +91 -20
  180. package/src/plugins/builtin/buildout/pane/status.tsx +1 -1
  181. package/src/plugins/builtin/buildout/pane/themes.tsx +110 -0
  182. package/src/plugins/builtin/buildout/table-model.ts +26 -2
  183. package/src/plugins/builtin/builtin-plugin-meta.ts +16 -0
  184. package/src/plugins/builtin/cds/headless.ts +10 -2
  185. package/src/plugins/builtin/cds/model.ts +7 -3
  186. package/src/plugins/builtin/central-bank-rates/index.tsx +1 -2
  187. package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -1
  188. package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
  189. package/src/plugins/builtin/changelog/index.tsx +3 -2
  190. package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
  191. package/src/plugins/builtin/chart-composer/headless-schema.ts +10 -0
  192. package/src/plugins/builtin/chart-composer/headless.ts +88 -21
  193. package/src/plugins/builtin/chart-composer/index.tsx +2 -3
  194. package/src/plugins/builtin/chart-composer/pane.tsx +30 -2
  195. package/src/plugins/builtin/chart-composer/series-expression.ts +3 -2
  196. package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
  197. package/src/plugins/builtin/chart-composer/studies.ts +1 -1
  198. package/src/plugins/builtin/chat/controller/index.ts +1 -1
  199. package/src/plugins/builtin/chat/controller/messages.ts +15 -4
  200. package/src/plugins/builtin/chat/ghost-user.ts +8 -0
  201. package/src/plugins/builtin/chat/index.tsx +5 -5
  202. package/src/plugins/builtin/clinical-trials/index.tsx +1 -2
  203. package/src/plugins/builtin/cloud/company-picker.tsx +1 -1
  204. package/src/plugins/builtin/cloud/index.tsx +1 -2
  205. package/src/plugins/builtin/cloud/plugin.tsx +6 -4
  206. package/src/plugins/builtin/cloud/team/module.tsx +1 -1
  207. package/src/plugins/builtin/cloud/thesis/module.tsx +1 -1
  208. package/src/plugins/builtin/cloud/trial-offer-status-widget.tsx +24 -1
  209. package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +39 -15
  210. package/src/plugins/builtin/comment-letters/index.tsx +1 -2
  211. package/src/plugins/builtin/commodity-balances/client.ts +4 -1
  212. package/src/plugins/builtin/commodity-balances/index.tsx +3 -3
  213. package/src/plugins/builtin/commodity-balances/pane.tsx +2 -2
  214. package/src/plugins/builtin/congress-trades/aggregates.ts +13 -2
  215. package/src/plugins/builtin/congress-trades/detail.tsx +20 -4
  216. package/src/plugins/builtin/congress-trades/headless.ts +2 -0
  217. package/src/plugins/builtin/congress-trades/model.ts +92 -64
  218. package/src/plugins/builtin/congress-trades/table.tsx +14 -2
  219. package/src/plugins/builtin/connections/index.ts +2 -3
  220. package/src/plugins/builtin/cot/client.ts +28 -2
  221. package/src/plugins/builtin/cot/headless.ts +4 -1
  222. package/src/plugins/builtin/cot/index.tsx +5 -4
  223. package/src/plugins/builtin/cot/model.ts +58 -7
  224. package/src/plugins/builtin/cot/pane.tsx +6 -2
  225. package/src/plugins/builtin/cpi/client.ts +6 -5
  226. package/src/plugins/builtin/cpi/index.tsx +1 -2
  227. package/src/plugins/builtin/credit-boards/cdx-pane.tsx +2 -1
  228. package/src/plugins/builtin/credit-boards/client.ts +7 -2
  229. package/src/plugins/builtin/credit-boards/headless.ts +25 -12
  230. package/src/plugins/builtin/credit-boards/model.ts +29 -0
  231. package/src/plugins/builtin/credit-boards/series.ts +3 -4
  232. package/src/plugins/builtin/credit-boards/sovr-pane.tsx +9 -4
  233. package/src/plugins/builtin/credit-conditions/client.ts +22 -14
  234. package/src/plugins/builtin/crypto-board/bitcoin-network.ts +143 -0
  235. package/src/plugins/builtin/crypto-board/client.ts +5 -5
  236. package/src/plugins/builtin/crypto-board/headless.ts +4 -3
  237. package/src/plugins/builtin/crypto-board/index.tsx +4 -2
  238. package/src/plugins/builtin/crypto-board/series.ts +6 -4
  239. package/src/plugins/builtin/crypto-etf-flows/client.ts +9 -2
  240. package/src/plugins/builtin/crypto-etf-flows/headless.ts +61 -6
  241. package/src/plugins/builtin/crypto-etf-flows/index.tsx +1 -2
  242. package/src/plugins/builtin/crypto-etf-flows/model.ts +14 -1
  243. package/src/plugins/builtin/crypto-etf-flows/pane.tsx +8 -5
  244. package/src/plugins/builtin/debt-maturities/headless.ts +4 -3
  245. package/src/plugins/builtin/debt-maturities/model.ts +17 -1
  246. package/src/plugins/builtin/debt-maturities/pane.tsx +18 -4
  247. package/src/plugins/builtin/debt-maturities/series.ts +3 -3
  248. package/src/plugins/builtin/debug/index.tsx +1 -1
  249. package/src/plugins/builtin/dividend-yield/client.ts +32 -2
  250. package/src/plugins/builtin/dividend-yield/headless.ts +58 -33
  251. package/src/plugins/builtin/dividend-yield/index.tsx +37 -2
  252. package/src/plugins/builtin/dividend-yield/pane.tsx +32 -13
  253. package/src/plugins/builtin/dividend-yield/ranking-reads.ts +73 -0
  254. package/src/plugins/builtin/dividend-yield/ranking-view.tsx +163 -0
  255. package/src/plugins/builtin/dividend-yield/ranking.ts +1 -1
  256. package/src/plugins/builtin/dividend-yield/view.ts +21 -0
  257. package/src/plugins/builtin/docs/index.tsx +2 -3
  258. package/src/plugins/builtin/doe/client.ts +6 -5
  259. package/src/plugins/builtin/doe/headless.ts +16 -3
  260. package/src/plugins/builtin/doe/index.tsx +5 -4
  261. package/src/plugins/builtin/doe/prices.ts +2 -3
  262. package/src/plugins/builtin/doe/series.ts +4 -4
  263. package/src/plugins/builtin/earnings/board.tsx +2 -1
  264. package/src/plugins/builtin/earnings/history.tsx +2 -1
  265. package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
  266. package/src/plugins/builtin/earnings-calls/data.ts +19 -11
  267. package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
  268. package/src/plugins/builtin/earnings-ripple/client.ts +32 -7
  269. package/src/plugins/builtin/earnings-ripple/index.tsx +1 -2
  270. package/src/plugins/builtin/econ/calendar-model.ts +29 -6
  271. package/src/plugins/builtin/econ/detail-view.tsx +16 -2
  272. package/src/plugins/builtin/econ-statistics/index.tsx +1 -2
  273. package/src/plugins/builtin/equity-screener/client.ts +1 -1
  274. package/src/plugins/builtin/equity-screener/headless.ts +1 -0
  275. package/src/plugins/builtin/equity-screener/ideas.ts +30 -1
  276. package/src/plugins/builtin/equity-screener/index.tsx +1 -2
  277. package/src/plugins/builtin/equity-screener/model.ts +28 -4
  278. package/src/plugins/builtin/equity-screener/pane.tsx +5 -0
  279. package/src/plugins/builtin/equity-screener/screen-check.ts +23 -1
  280. package/src/plugins/builtin/estimate-revisions/index.tsx +1 -2
  281. package/src/plugins/builtin/executives/data.ts +6 -1
  282. package/src/plugins/builtin/executives/pane.tsx +9 -21
  283. package/src/plugins/builtin/exposure/holdings.ts +35 -13
  284. package/src/plugins/builtin/exposure/index.tsx +1 -2
  285. package/src/plugins/builtin/fear-greed/cache.ts +4 -0
  286. package/src/plugins/builtin/fear-greed/cli.ts +44 -0
  287. package/src/plugins/builtin/fear-greed/index.tsx +5 -53
  288. package/src/plugins/builtin/fear-greed/pane.tsx +2 -1
  289. package/src/plugins/builtin/fear-greed/series.ts +6 -4
  290. package/src/plugins/builtin/fixed-income/index.tsx +1 -2
  291. package/src/plugins/builtin/futures/board-reads.ts +25 -0
  292. package/src/plugins/builtin/futures/coalesced-bump.ts +44 -0
  293. package/src/plugins/builtin/futures/index.tsx +15 -0
  294. package/src/plugins/builtin/futures/model.ts +14 -0
  295. package/src/plugins/builtin/futures/use-board-terms.ts +22 -15
  296. package/src/plugins/builtin/futures/use-front-returns.ts +20 -14
  297. package/src/plugins/builtin/futures-curve/headless.ts +6 -1
  298. package/src/plugins/builtin/futures-curve/index.tsx +1 -2
  299. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  300. package/src/plugins/builtin/gpu/board.tsx +51 -12
  301. package/src/plugins/builtin/gpu/client.ts +19 -3
  302. package/src/plugins/builtin/gpu/equities.tsx +5 -2
  303. package/src/plugins/builtin/gpu/headless.ts +14 -2
  304. package/src/plugins/builtin/gpu/index.tsx +1 -2
  305. package/src/plugins/builtin/gpu/model.ts +20 -2
  306. package/src/plugins/builtin/gpu/pane.tsx +1 -1
  307. package/src/plugins/builtin/gpu/series.ts +2 -3
  308. package/src/plugins/builtin/help/index.tsx +1 -1
  309. package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
  310. package/src/plugins/builtin/holders/index-holders.ts +24 -12
  311. package/src/plugins/builtin/holders/index.tsx +2 -3
  312. package/src/plugins/builtin/ipo-calendar/index.tsx +1 -2
  313. package/src/plugins/builtin/iv-history/index.tsx +1 -2
  314. package/src/plugins/builtin/jodi/client.ts +4 -1
  315. package/src/plugins/builtin/jodi/index.tsx +1 -2
  316. package/src/plugins/builtin/kelly-sizer/index.tsx +1 -2
  317. package/src/plugins/builtin/layout-manager/index.tsx +9 -8
  318. package/src/plugins/builtin/macro-day/pane.tsx +4 -2
  319. package/src/plugins/builtin/market-halts/index.tsx +1 -2
  320. package/src/plugins/builtin/market-heatmap/index.tsx +11 -2
  321. package/src/plugins/builtin/market-movers/client.ts +1 -0
  322. package/src/plugins/builtin/market-movers/index.tsx +16 -5
  323. package/src/plugins/builtin/market-movers/screener.ts +23 -6
  324. package/src/plugins/builtin/market-movers/session-body.tsx +6 -2
  325. package/src/plugins/builtin/market-valuation/index.tsx +1 -2
  326. package/src/plugins/builtin/members/index.tsx +1 -2
  327. package/src/plugins/builtin/money-markets/index.tsx +1 -2
  328. package/src/plugins/builtin/money-markets/pane.tsx +2 -1
  329. package/src/plugins/builtin/money-markets/series.ts +3 -5
  330. package/src/plugins/builtin/news/headless.ts +7 -0
  331. package/src/plugins/builtin/news/wire/index.tsx +1 -1
  332. package/src/plugins/builtin/news/wire/news/pop-out.tsx +9 -3
  333. package/src/plugins/builtin/notes/index.tsx +3 -6
  334. package/src/plugins/builtin/openfda/index.tsx +1 -2
  335. package/src/plugins/builtin/options/view.tsx +6 -5
  336. package/src/plugins/builtin/options-calculator/index.tsx +1 -2
  337. package/src/plugins/builtin/options-positioning/index.tsx +1 -2
  338. package/src/plugins/builtin/options-scenario/index.tsx +2 -4
  339. package/src/plugins/builtin/perps/index.tsx +1 -2
  340. package/src/plugins/builtin/plugin-marketplace/index.tsx +2 -3
  341. package/src/plugins/builtin/plugin-marketplace/pane.tsx +2 -1
  342. package/src/plugins/builtin/plugin-module.ts +16 -2
  343. package/src/plugins/builtin/portfolio-list/cli/render.ts +53 -3
  344. package/src/plugins/builtin/portfolio-list/index.tsx +1 -2
  345. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -0
  346. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +2 -2
  347. package/src/plugins/builtin/power/gas-client.ts +2 -1
  348. package/src/plugins/builtin/power/index.tsx +1 -2
  349. package/src/plugins/builtin/power/prices-client.ts +3 -2
  350. package/src/plugins/builtin/rate-path/index.tsx +1 -2
  351. package/src/plugins/builtin/realized-vol/headless.ts +2 -0
  352. package/src/plugins/builtin/realized-vol/index.tsx +1 -2
  353. package/src/plugins/builtin/realized-vol/model.ts +16 -2
  354. package/src/plugins/builtin/realized-vol/pane.tsx +3 -3
  355. package/src/plugins/builtin/relative-rotation/index.tsx +1 -2
  356. package/src/plugins/builtin/research/analyst-headless.ts +33 -6
  357. package/src/plugins/builtin/research/analyst-model.ts +37 -2
  358. package/src/plugins/builtin/research/analyst-pane.tsx +25 -5
  359. package/src/plugins/builtin/research/index.tsx +5 -8
  360. package/src/plugins/builtin/research-search/index.tsx +1 -2
  361. package/src/plugins/builtin/revenue-breakdown/headless.ts +22 -8
  362. package/src/plugins/builtin/revenue-breakdown/model.ts +23 -0
  363. package/src/plugins/builtin/reverse-dcf/pane.tsx +4 -2
  364. package/src/plugins/builtin/risk-factors/data.ts +8 -4
  365. package/src/plugins/builtin/risk-factors/index.tsx +3 -1
  366. package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
  367. package/src/plugins/builtin/roadmap/index.tsx +2 -3
  368. package/src/plugins/builtin/roadmap/pane.tsx +49 -2
  369. package/src/plugins/builtin/scanner/hilo-model.ts +18 -1
  370. package/src/plugins/builtin/scanner/hilo-pane.tsx +12 -2
  371. package/src/plugins/builtin/scanner/live-pane.tsx +7 -4
  372. package/src/plugins/builtin/scanner/lock-note.ts +26 -3
  373. package/src/plugins/builtin/sec/filing-content.ts +38 -6
  374. package/src/plugins/builtin/sec/headless.ts +5 -1
  375. package/src/plugins/builtin/sec/index.tsx +4 -2
  376. package/src/plugins/builtin/sec/model.ts +2 -0
  377. package/src/plugins/builtin/sectors/index.tsx +11 -5
  378. package/src/plugins/builtin/series-inventory.ts +2 -0
  379. package/src/plugins/builtin/shared/cloud-resource.ts +73 -8
  380. package/src/plugins/builtin/shared/cloud-upgrade.ts +24 -1
  381. package/src/plugins/builtin/shared/forming-bars.ts +29 -0
  382. package/src/plugins/builtin/shared/fred-points.ts +6 -1
  383. package/src/plugins/builtin/shared/fx-rate-freshness.ts +17 -0
  384. package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
  385. package/src/plugins/builtin/shared/series-detail-chart.tsx +4 -2
  386. package/src/plugins/builtin/shared/series-warning.ts +13 -4
  387. package/src/plugins/builtin/shared/use-quote-board.ts +5 -0
  388. package/src/plugins/builtin/short-interest/publications-pane.tsx +2 -1
  389. package/src/plugins/builtin/short-volume/pane.tsx +2 -1
  390. package/src/plugins/builtin/short-volume/series.ts +2 -3
  391. package/src/plugins/builtin/social-mentions/client.ts +15 -10
  392. package/src/plugins/builtin/social-mentions/index.tsx +1 -2
  393. package/src/plugins/builtin/social-mentions/pane.tsx +2 -1
  394. package/src/plugins/builtin/social-mentions/series.ts +2 -3
  395. package/src/plugins/builtin/supply-chain/client.ts +22 -1
  396. package/src/plugins/builtin/supply-chain/graph-client.ts +22 -1
  397. package/src/plugins/builtin/supply-chain/index.tsx +1 -2
  398. package/src/plugins/builtin/thirteenf/api.ts +127 -29
  399. package/src/plugins/builtin/thirteenf/data.ts +165 -35
  400. package/src/plugins/builtin/thirteenf/index.tsx +2 -4
  401. package/src/plugins/builtin/thirteenf/model.ts +2 -0
  402. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
  403. package/src/plugins/builtin/thirteenf/pane.tsx +41 -16
  404. package/src/plugins/builtin/thirteenf/types.ts +14 -0
  405. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +22 -6
  406. package/src/plugins/builtin/ticker-detail/data-panes/returns-history.ts +81 -11
  407. package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
  408. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
  409. package/src/plugins/builtin/ticker-detail/headless.ts +70 -11
  410. package/src/plugins/builtin/ticker-detail/index.tsx +5 -10
  411. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +16 -6
  412. package/src/plugins/builtin/time-sales/index.tsx +1 -2
  413. package/src/plugins/builtin/time-sales/pane.tsx +3 -1
  414. package/src/plugins/builtin/trace-bonds/headless.ts +11 -2
  415. package/src/plugins/builtin/trace-bonds/model.ts +44 -10
  416. package/src/plugins/builtin/trace-bonds/pane.tsx +12 -7
  417. package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
  418. package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
  419. package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
  420. package/src/plugins/builtin/treasury-auctions/pane.tsx +10 -4
  421. package/src/plugins/builtin/vol-surface/client.ts +18 -3
  422. package/src/plugins/builtin/vol-surface/index.tsx +1 -2
  423. package/src/plugins/builtin/volatility/client.ts +43 -16
  424. package/src/plugins/builtin/volatility/headless.ts +5 -2
  425. package/src/plugins/builtin/volatility/index.tsx +1 -2
  426. package/src/plugins/builtin/volatility/model.ts +17 -6
  427. package/src/plugins/builtin/volatility/pane.tsx +6 -2
  428. package/src/plugins/builtin/volatility/series.ts +2 -1
  429. package/src/plugins/builtin/world-indices/footer.ts +6 -1
  430. package/src/plugins/builtin/world-venue-map/client.ts +10 -1
  431. package/src/plugins/builtin/world-venue-map/index.ts +6 -2
  432. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +48 -7
  433. package/src/plugins/builtin/world-venue-map/layers.ts +17 -0
  434. package/src/plugins/builtin/world-venue-map/local-layers.ts +141 -0
  435. package/src/plugins/builtin/world-venue-map/map-symbols.ts +18 -0
  436. package/src/plugins/builtin/world-venue-map/map.tsx +4 -2
  437. package/src/plugins/builtin/world-venue-map/scope.ts +63 -0
  438. package/src/plugins/builtin/yield-curve/client.ts +4 -6
  439. package/src/plugins/builtin/yield-curve/curves.ts +16 -2
  440. package/src/plugins/builtin/yield-curve/headless.ts +3 -3
  441. package/src/plugins/bundle.ts +52 -2
  442. package/src/plugins/catalog-browser.ts +2 -2
  443. package/src/plugins/catalog-cli.ts +20 -0
  444. package/src/plugins/host-link.ts +13 -1
  445. package/src/plugins/installer.ts +64 -42
  446. package/src/plugins/loader.ts +43 -11
  447. package/src/plugins/peer-imports.ts +201 -0
  448. package/src/plugins/peer-retry.ts +26 -29
  449. package/src/plugins/plugin-process.ts +128 -0
  450. package/src/plugins/redirection-guard.ts +45 -0
  451. package/src/remote/endpoint.ts +16 -3
  452. package/src/renderers/browser/main.tsx +3 -2
  453. package/src/renderers/cloudflare/http-proxy.ts +7 -123
  454. package/src/renderers/dom/desktop/icons.tsx +5 -2
  455. package/src/renderers/dom/host/image-surface.tsx +46 -21
  456. package/src/renderers/dom/styles.css +7 -0
  457. package/src/renderers/dom/toast-host.tsx +2 -0
  458. package/src/renderers/electrobun/bun/desktop/backend.ts +7 -1
  459. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
  460. package/src/renderers/electrobun/bun/desktop/initialization.ts +12 -3
  461. package/src/renderers/electrobun/bun/desktop/rpc-timing.ts +88 -0
  462. package/src/renderers/electrobun/bun/desktop/update.ts +20 -4
  463. package/src/renderers/electrobun/bun/desktop/view-ids.ts +8 -4
  464. package/src/renderers/electrobun/bun/external-plugins.ts +4 -0
  465. package/src/renderers/electrobun/bun/window/attention.ts +54 -0
  466. package/src/renderers/electrobun/bun/window/create.ts +6 -2
  467. package/src/renderers/electrobun/bun/window/native-timing.ts +48 -0
  468. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +82 -4
  469. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +13 -5
  470. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +3 -0
  471. package/src/renderers/electrobun/view/main.tsx +7 -2
  472. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  473. package/src/renderers/electrobun/view/ui-host.tsx +9 -2
  474. package/src/renderers/opentui/browser-open.ts +10 -4
  475. package/src/renderers/opentui/image/surface.tsx +1 -1
  476. package/src/renderers/opentui/start.tsx +12 -6
  477. package/src/renderers/opentui/toast-host.tsx +2 -1
  478. package/src/series/registry.ts +18 -0
  479. package/src/series/resolve.ts +2 -1
  480. package/src/series/standalone.ts +10 -1
  481. package/src/series/types.ts +12 -0
  482. package/src/series/validate.ts +11 -2
  483. package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
  484. package/src/sources/gloomberb-cloud/index.ts +28 -5
  485. package/src/sources/gloomberb-cloud/news.ts +24 -3
  486. package/src/sources/gloomberb-cloud/normalizers.ts +17 -2
  487. package/src/sources/provider-errors.ts +26 -3
  488. package/src/sources/provider-router/history.ts +5 -0
  489. package/src/sources/provider-router/index.ts +13 -0
  490. package/src/sync/core-contributors.ts +32 -0
  491. package/src/telemetry/attention-counts.ts +64 -6
  492. package/src/telemetry/attention-setting.ts +33 -0
  493. package/src/telemetry/crash-reports-node.ts +3 -1
  494. package/src/telemetry/stall-clocks.ts +93 -0
  495. package/src/telemetry/stall-reports-node.ts +25 -9
  496. package/src/telemetry/stall-reports.ts +218 -13
  497. package/src/test-support/outage.ts +8 -0
  498. package/src/test-support/preload.ts +9 -0
  499. package/src/tickers/company-name-listing.ts +59 -29
  500. package/src/tickers/company-name.ts +84 -1
  501. package/src/tickers/listing-fate.ts +14 -3
  502. package/src/tickers/saved-instrument.ts +17 -0
  503. package/src/tickers/search/home-listing.ts +104 -8
  504. package/src/tickers/search/index.ts +5 -2
  505. package/src/tickers/search/types.ts +6 -1
  506. package/src/time-series/bar-aggregation.ts +7 -1
  507. package/src/time-series/base-series.ts +3 -3
  508. package/src/time-series/chart-bounds.ts +1 -1
  509. package/src/time-series/chart-data.ts +5 -2
  510. package/src/time-series/chart-seed.ts +1 -1
  511. package/src/time-series/coin-session-close.ts +211 -0
  512. package/src/time-series/live-bars.ts +13 -2
  513. package/src/time-series/live-quotes.ts +1 -1
  514. package/src/time-series/market.ts +5 -3
  515. package/src/time-series/official-close.ts +159 -0
  516. package/src/time-series/price-comparison.ts +75 -8
  517. package/src/time-series/resolve.ts +32 -4
  518. package/src/time-series/series-presentation.ts +1 -1
  519. package/src/time-series/studies/formula.ts +205 -0
  520. package/src/time-series/studies/indicators.ts +299 -0
  521. package/src/time-series/studies/leverage.ts +75 -0
  522. package/src/time-series/studies/pair.ts +138 -0
  523. package/src/time-series/studies/resolve.ts +333 -0
  524. package/src/time-series/studies/shared.ts +141 -0
  525. package/src/time-series/studies/units.ts +243 -0
  526. package/src/time-series/studies/vwap.ts +274 -0
  527. package/src/time-series/types.ts +7 -0
  528. package/src/types/config.ts +18 -2
  529. package/src/types/data-provider.ts +23 -0
  530. package/src/types/financials.ts +40 -3
  531. package/src/types/headless.ts +42 -3
  532. package/src/types/plugin.ts +32 -3
  533. package/src/ui/host.tsx +17 -1
  534. package/src/ui/toast.tsx +26 -1
  535. package/src/utils/ascii-text.ts +140 -0
  536. package/src/utils/cli-output.ts +91 -18
  537. package/src/utils/concurrency.ts +24 -0
  538. package/src/utils/exchanges.ts +6 -0
  539. package/src/utils/external-url.ts +20 -7
  540. package/src/utils/financial-statements.ts +73 -16
  541. package/src/utils/fundamentals.ts +17 -5
  542. package/src/utils/futures-generic.ts +4 -4
  543. package/src/utils/http-proxy-upstream.ts +124 -0
  544. package/src/utils/lease-liabilities.ts +54 -0
  545. package/src/utils/main-thread-stall.ts +122 -5
  546. package/src/utils/perf-marks.ts +58 -12
  547. package/src/utils/price-history-integrity.ts +47 -1
  548. package/src/utils/private-files.ts +141 -0
  549. package/src/utils/public-address.ts +99 -0
  550. package/src/account/upgrade-personalization.ts +0 -57
  551. package/src/account/upgrade-tickers.ts +0 -124
  552. package/src/api-client/upgrade-experiment.ts +0 -91
  553. package/src/plugins/builtin/cloud/browser.tsx +0 -3
  554. package/src/time-series/studies.ts +0 -1628
@@ -62,6 +62,8 @@ import { describeFundamentalMarketCap } from "../../../utils/market-capitalizati
62
62
  import { liveFiftyTwoWeekRange, liveMarketCapitalization } from "../../../market-data/quotes/live-valuation";
63
63
  import { instrumentTypeLabel } from "../../../tickers/instrument-kind";
64
64
  import { ClippedText } from "../../../components/ui/display";
65
+ import { DelayedMark, delayedMarkWidth } from "../../../components/ui/delayed-mark";
66
+ import { useFreeQuoteDelay } from "../../../api-client/plan-access";
65
67
  import { safeExternalUrl } from "../../../utils/external-url";
66
68
  import { cashRunway, SHORT_RUNWAY_HISTORY } from "../../../utils/cash-runway";
67
69
  import { useOfferingNote } from "./financials/offerings";
@@ -126,6 +128,7 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
126
128
  const figureCells = useFigureCells();
127
129
 
128
130
  const quote = financials?.quote;
131
+ const quoteDelayMinutes = useFreeQuoteDelay(quote);
129
132
  const fundamentals = financials?.fundamentals;
130
133
  const capitalization = liveMarketCapitalization(quote, fundamentals);
131
134
  const profile = financials?.profile;
@@ -195,8 +198,7 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
195
198
  id: `${ticker.metadata.ticker}:price`,
196
199
  label: `${ticker.metadata.ticker} Price`,
197
200
  color: priceColor(chartDelta),
198
- // An index's axis reads in points: ^VIX at 20, never $20.
199
- unit: unknownHistoryBasis ? "unknown" : priceSeriesUnit(quoteCurrency, instrumentType),
201
+ unit: unknownHistoryBasis ? "unknown" : priceSeriesUnit(quoteCurrency, instrumentType, quote?.priceBasis),
200
202
  style: "area",
201
203
  axis: "right",
202
204
  panelId: "price",
@@ -251,7 +253,10 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
251
253
  const venueText = listingVenue ? (companyName ? ` (${listingVenue})` : listingVenue) : "";
252
254
  const sessionLabel = quoteSessionLabel(quote);
253
255
  const marketStateText = sessionLabel ? t(sessionLabel) : "";
254
- const hasIdentityLine = Boolean(companyName || venueText || marketStateText);
256
+ // Signed out or free, the headline price is the delayed one: the mark says so after the session.
257
+ const delayMinutes = sessionQuote ? quoteDelayMinutes : null;
258
+ const delayCells = delayMinutes != null ? delayedMarkWidth(delayMinutes, nativePaneChrome === true) + 1 : 0;
259
+ const hasIdentityLine = Boolean(companyName || venueText || marketStateText || delayMinutes != null);
255
260
  // The desktop logo is a square from the top of the company name's capitals down to the
256
261
  // price's baseline, so its edges meet the text's and the three read as one block. The
257
262
  // extended-hours line hangs below, as it comes and goes with the session. Without a
@@ -290,7 +295,8 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
290
295
  + quoteChangeCells + 1 + displayWidth(changeBasisText) <= quoteTextWidth;
291
296
  const companyNameWidth = Math.max(8, quoteTextWidth
292
297
  - displayWidth(venueText)
293
- - (marketStateText ? displayWidth(marketStateText) + 1 : 0));
298
+ - (marketStateText ? displayWidth(marketStateText) + 1 : 0)
299
+ - delayCells);
294
300
  // A listing that no longer trades keeps its delisting date whole on a narrow pane: the venue gives way, then the name.
295
301
  const delistedRoom = quote?.delisting && marketStateText ? quoteTextWidth - displayWidth(marketStateText) - 1 : null;
296
302
  const identityVenue = delistedRoom !== null && delistedRoom - displayWidth(venueText) < 8 ? "" : venueText;
@@ -401,7 +407,7 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
401
407
  />
402
408
  <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
403
409
  {hasIdentityLine && (
404
- <Box flexDirection="row" minWidth={0} overflow="hidden">
410
+ <Box flexDirection="row" alignItems="center" minWidth={0} overflow="hidden">
405
411
  {identityName && (
406
412
  <ClippedText fg={colors.textDim} text={identityName} width={identityNameWidth} />
407
413
  )}
@@ -411,6 +417,11 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
411
417
  {identityName || identityVenue ? " " : ""}{marketStateText}
412
418
  </Text>
413
419
  )}
420
+ {delayMinutes != null && (
421
+ <Box flexShrink={0} paddingLeft={identityName || identityVenue || marketStateText ? 1 : 0}>
422
+ <DelayedMark minutes={delayMinutes} />
423
+ </Box>
424
+ )}
414
425
  </Box>
415
426
  )}
416
427
 
@@ -546,7 +557,6 @@ function ResolvedOverviewTab({ width, height, focused = false, ticker, financial
546
557
  series={[priceSeries]}
547
558
  panels={[{ id: "price" }]}
548
559
  levels={trade && researchTradeMatches(trade, instrumentFromTicker(ticker)) ? { seriesId: priceSeries.id, items: trade.levels } : undefined}
549
- axisWidth={8}
550
560
  showLegend={false}
551
561
  />
552
562
  </Box>
@@ -1,9 +1,8 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
2
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
3
  import { timeSalesHeadless } from "./headless";
4
- import { TimeSalesPane } from "./pane";
5
4
  export const timeSalesModule: PluginModule = {
6
- panes: [{ id: "time-sales", name: "Time and Sales", icon: "T", component: TimeSalesPane,
5
+ panes: [{ id: "time-sales", name: "Time and Sales", icon: "T", get component(): typeof import("./pane").TimeSalesPane { const { TimeSalesPane } = require("./pane"); return TimeSalesPane; },
7
6
  defaultPosition: "right", tickerFollower: true, defaultMode: "floating", defaultFloatingSize: { width: 104, height: 28 },
8
7
  tableExport: true, headless: timeSalesHeadless, settings: { title: "Time and Sales", fields: [{ key: "tab", label: "View", type: "select", options: [{ value: "trades", label: "Trades" }, { value: "quotes", label: "NBBO" }] }] } }],
9
8
  paneTemplates: [
@@ -100,8 +100,10 @@ function TimeSalesView({ width, height, focused, symbol, exchange }: PaneProps &
100
100
  }, [data]);
101
101
  const tradeDecision = useRef<TapeDigits | null>(null);
102
102
  const quoteDecision = useRef<TapeDigits | null>(null);
103
+ // Decimals come from the prints alone: a session extreme can sit a fraction of a cent off a tick (NVDA's 200.9199 low)
104
+ // and must not turn every trade into four decimals. The session range reads at the prints' decimals instead.
103
105
  const tradeDigits = useMemo(() => (tradeDecision.current = stickyTapePriceDigits(tradeDecision.current, symbol,
104
- [...(data?.trades.map((trade) => trade.price) ?? []), data?.session.low ?? null, data?.session.high ?? null]))?.digits ?? 2, [data, symbol]);
106
+ data?.trades.map((trade) => trade.price) ?? []))?.digits ?? 2, [data, symbol]);
105
107
  const quoteDigits = useMemo(() => (quoteDecision.current = stickyTapePriceDigits(quoteDecision.current, symbol,
106
108
  data?.quotes.flatMap((quote) => [quote.bid, quote.ask]) ?? []))?.digits ?? 2, [data, symbol]);
107
109
  // The session range stands beside the tab's own prices, so it reads at their decimals: the quote tab's bid and ask, not the trades'.
@@ -8,7 +8,7 @@ import {
8
8
  quotedCompanyName,
9
9
  type CorporateBond,
10
10
  } from "./client";
11
- import { bondStackPriority, formatBondChange, formatBondDecimal, type BondColumnId } from "./model";
11
+ import { bondStackPriority, formatBondChange, formatBondDecimal, sharesOneIssuer, type BondColumnId } from "./model";
12
12
 
13
13
  const decimal = (digits: number) => (value: unknown) => (
14
14
  typeof value === "number" ? formatBondDecimal(value, digits) : "—"
@@ -33,6 +33,15 @@ const COLUMNS: HeadlessPaneColumn[] = [
33
33
  column("grade", { key: "traceGradeCode", header: "Grade" }),
34
34
  ];
35
35
 
36
+ /**
37
+ * One issuer's tape (`sharesOneIssuer`) leaves the issuer column out of the
38
+ * text table, as the pane does; exports and piped output keep it.
39
+ */
40
+ function reportColumns(bonds: readonly CorporateBond[]): HeadlessPaneColumn[] {
41
+ if (!sharesOneIssuer(bonds)) return COLUMNS;
42
+ return COLUMNS.map((spec) => spec.key === "issuerName" ? { ...spec, minTableWidth: Number.MAX_SAFE_INTEGER } : spec);
43
+ }
44
+
36
45
  function reportRow(bond: CorporateBond) {
37
46
  return {
38
47
  id: bond.id,
@@ -85,7 +94,7 @@ export const traceBondsHeadless: HeadlessPaneDefinition<"bundle"> = {
85
94
  sections: [{
86
95
  // An empty answer is its message alone, dated in its own words.
87
96
  title: bonds.length > 0 ? (tradeDate ? `Last sales ${tradeDate}` : "Last sales") : query ? "" : "Last sales",
88
- columns: COLUMNS,
97
+ columns: reportColumns(bonds),
89
98
  rows: bonds.map(reportRow),
90
99
  ...(query ? { empty: noPrintsText(query, tradeDate, issuer) } : {}),
91
100
  }],
@@ -85,26 +85,32 @@ export function sortBonds(bonds: readonly CorporateBond[], sort: BondSort): Corp
85
85
  });
86
86
  }
87
87
 
88
+ /**
89
+ * What a sale says leads: its price, yield and CUSIP, after the issuer when
90
+ * the rows are several issuers', before what describes the bond (coupon,
91
+ * maturity) and the rest of the sale.
92
+ */
88
93
  const BOND_COLUMNS: readonly BondColumn[] = [
89
94
  { id: "issuer", label: "Issuer", width: 18, align: "left", flexGrow: 1 },
95
+ { id: "price", label: "Price", width: 9, align: "right" },
96
+ { id: "yield", label: "Yield", width: 8, align: "right" },
90
97
  { id: "cusip", label: "CUSIP", width: 10, align: "left" },
91
98
  { id: "coupon", label: "Coupon", width: 7, align: "right" },
92
99
  { id: "maturity", label: "Maturity", width: 11, align: "left" },
93
- { id: "price", label: "Price", width: 9, align: "right" },
94
- { id: "yield", label: "Yield", width: 8, align: "right" },
95
100
  { id: "change", label: "Chg", width: 8, align: "right" },
96
101
  { id: "traded", label: "Traded", width: 11, align: "left" },
97
102
  { id: "grade", label: "Grade", width: 6, align: "left" },
98
103
  ];
99
104
 
100
105
  /**
101
- * What a narrow tape gives up first. The issuer, coupon and maturity name a
102
- * bond, so two of one issuer's issues stay told apart, and its price is
103
- * what was printed; they never go. The trade date, the default sort and
104
- * how old a last sale is, outlasts the CUSIP and yield. The headless report
105
- * moves its columns under the rows in the same order (`bondStackPriority`).
106
+ * What a narrow tape gives up first. The price and yield are what was printed
107
+ * and the CUSIP tells two issues with one coupon and maturity apart, so they
108
+ * never go, nor does the issuer while it names the row; the descriptive
109
+ * columns go before them, the coupon last as it is the narrowest. The
110
+ * headless report moves its columns under the rows in the same order
111
+ * (`bondStackPriority`).
106
112
  */
107
- const BOND_NARROW_DROP_ORDER: readonly BondColumnId[] = ["grade", "change", "cusip", "yield", "traded"];
113
+ const BOND_NARROW_DROP_ORDER: readonly BondColumnId[] = ["grade", "change", "traded", "maturity", "coupon"];
108
114
 
109
115
  /** `stackPriority` of each column in the text report: the higher, the sooner it moves under its row. */
110
116
  export function bondStackPriority(columnId: BondColumnId): number {
@@ -112,6 +118,34 @@ export function bondStackPriority(columnId: BondColumnId): number {
112
118
  return index < 0 ? 0 : BOND_NARROW_DROP_ORDER.length - index;
113
119
  }
114
120
 
115
- export function buildBondColumns(width: number): BondColumn[] {
116
- return fitChartTableColumns(BOND_COLUMNS, width, BOND_NARROW_DROP_ORDER);
121
+ /** The tape's symbol root of an issue (`WHR` of `WHR4800338` and `WHR.GM`), or null without one. */
122
+ function symbolRoot(symbol: string | null): string | null {
123
+ return symbol?.match(/^[A-Z]+/)?.[0] ?? null;
124
+ }
125
+
126
+ /**
127
+ * Whether every row is the same issuer's, so an issuer column would only
128
+ * repeat it: more than one row, all under one symbol root, or all one name
129
+ * where a row has no symbol. One row keeps the name, the only thing naming it.
130
+ */
131
+ export function sharesOneIssuer(bonds: readonly Pick<CorporateBond, "issuerName" | "symbol">[]): boolean {
132
+ if (bonds.length < 2) return false;
133
+ const roots = bonds.map((bond) => symbolRoot(bond.symbol));
134
+ if (roots.every((root) => root != null)) return new Set(roots).size === 1;
135
+ return new Set(bonds.map((bond) => bond.issuerName.trim().toUpperCase())).size === 1;
136
+ }
137
+
138
+ /**
139
+ * The sort the tape shows: a saved issuer sort, while one issuer's tape leaves
140
+ * the issuer column out, sorts as the default, which the header can show; the
141
+ * saved sort comes back with the column.
142
+ */
143
+ export function shownBondSort(sort: BondSort, sharedIssuer: boolean): BondSort {
144
+ return sharedIssuer && sort.columnId === "issuer" ? DEFAULT_BOND_SORT : sort;
145
+ }
146
+
147
+ /** The columns at `width`, without the issuer's when every row shares it (`sharesOneIssuer`). */
148
+ export function buildBondColumns(width: number, { sharedIssuer = false }: { sharedIssuer?: boolean } = {}): BondColumn[] {
149
+ const columns = sharedIssuer ? BOND_COLUMNS.filter((column) => column.id !== "issuer") : BOND_COLUMNS;
150
+ return fitChartTableColumns(columns, width, BOND_NARROW_DROP_ORDER);
117
151
  }
@@ -40,6 +40,8 @@ import {
40
40
  formatBondChange,
41
41
  formatBondDecimal,
42
42
  isBondColumnId,
43
+ sharesOneIssuer,
44
+ shownBondSort,
43
45
  sortBonds,
44
46
  type BondColumn,
45
47
  type BondSort,
@@ -73,8 +75,11 @@ export function TraceBondsPane({ focused, width, height }: PaneProps) {
73
75
  useAutoRefresh(updatedAt, load);
74
76
  usePaneRefreshKey(() => void reload(), { focused, enabled: !searchActive });
75
77
 
76
- const rows = useMemo(() => sortBonds(bonds, sort), [bonds, sort]);
77
- const columns = useMemo(() => buildBondColumns(width), [width]);
78
+ // One issuer's tape says its name once, in what was asked, not on every row.
79
+ const sharedIssuer = useMemo(() => sharesOneIssuer(bonds), [bonds]);
80
+ const columns = useMemo(() => buildBondColumns(width, { sharedIssuer }), [sharedIssuer, width]);
81
+ const shownSort = shownBondSort(sort, sharedIssuer);
82
+ const rows = useMemo(() => sortBonds(bonds, shownSort), [bonds, shownSort]);
78
83
  const tradeDate = answered ? data.tradeDate : null;
79
84
  const footerInfo = useMemo(
80
85
  () => (tradeDate ? [{ id: "trade-date", parts: [{ text: tradeDate }] }] : []),
@@ -96,11 +101,11 @@ export function TraceBondsPane({ focused, width, height }: PaneProps) {
96
101
 
97
102
  const onHeaderClick = useCallback((columnId: string) => {
98
103
  if (!isBondColumnId(columnId)) return;
99
- setSort((current) => nextHeaderSort(current, columnId, {
104
+ setSort((current) => nextHeaderSort(shownBondSort(current, sharedIssuer), columnId, {
100
105
  firstDirection: firstBondSortDirection,
101
106
  resetTo: DEFAULT_BOND_SORT,
102
107
  }));
103
- }, [setSort]);
108
+ }, [setSort, sharedIssuer]);
104
109
 
105
110
  const handleRootKeyDown = useCallback((
106
111
  event: DataTableKeyEvent,
@@ -190,13 +195,13 @@ export function TraceBondsPane({ focused, width, height }: PaneProps) {
190
195
  }}
191
196
  columns={columns}
192
197
  items={rows}
193
- sortColumnId={sort.columnId}
194
- sortDirection={sort.direction}
198
+ sortColumnId={shownSort.columnId}
199
+ sortDirection={shownSort.direction}
195
200
  onHeaderClick={onHeaderClick}
196
201
  onSortChange={(columnId, direction) => {
197
202
  if (isBondColumnId(columnId)) setSort({ columnId, direction });
198
203
  }}
199
- resetScrollKey={`${needle}:${sort.columnId ?? ""}:${sort.direction}`}
204
+ resetScrollKey={`${needle}:${shownSort.columnId ?? ""}:${shownSort.direction}`}
200
205
  getItemKey={(row) => row.id}
201
206
  renderCell={renderCell}
202
207
  selectedTextOverridesCellColor
@@ -1,4 +1,5 @@
1
- import { createPluginCache } from "../../../data/plugin-cache";
1
+ import { createPluginCache, savedCopyForTransientFailure } from "../../../data/plugin-cache";
2
+ import { servedFromSavedCopy } from "../../../data/saved-copies";
2
3
  import type { ConnectionHealthRegistry } from "../../../core/connection-health";
3
4
  import type { PluginPersistence } from "../../../types/plugin";
4
5
  import {
@@ -37,6 +38,8 @@ export interface TreasuryAuctionsResult {
37
38
  fetchedAt: number;
38
39
  /** True when the network failed and expired cache was served instead. */
39
40
  stale: boolean;
41
+ /** The refresh failed and the copy saved at this time (ms) answered instead. */
42
+ savedAt?: number;
40
43
  refreshError?: string;
41
44
  }
42
45
 
@@ -84,11 +87,18 @@ export async function loadTreasuryAuctions(
84
87
  window: AuctionHistoryWindow = AUCTION_HISTORY_DAYS,
85
88
  ): Promise<TreasuryAuctionsResult> {
86
89
  if (window === AUCTION_HISTORY_ALL) return loadAllTreasuryAuctions(force, loader);
87
- const result = await cache.load(`recent:${window}`, walk(loader, () => auctionWindowQuery(window)), { force });
88
- return {
90
+ // A saved board answers only for a failure asking again may get past; a refusal or an invalid page is the error.
91
+ const result = savedCopyForTransientFailure(await cache.load(`recent:${window}`, walk(loader, () => auctionWindowQuery(window)), { force }));
92
+ return answered({
89
93
  auctions: result.data, fetchedAt: result.fetchedAt, stale: result.stale,
94
+ ...(result.source === "stale-fallback" ? { savedAt: result.fetchedAt } : {}),
90
95
  ...(result.refreshError ? { refreshError: result.refreshError } : {}),
91
- };
96
+ });
97
+ }
98
+
99
+ /** A board a saved copy answered: marked as one, so a command ends by saying when it was saved, not with its failure. */
100
+ function answered(result: TreasuryAuctionsResult): TreasuryAuctionsResult {
101
+ return result.savedAt != null ? servedFromSavedCopy(result, result.savedAt) : result;
92
102
  }
93
103
 
94
104
  /**
@@ -100,16 +110,18 @@ export async function loadTreasuryAuctions(
100
110
  async function loadAllTreasuryAuctions(force: boolean, loader: AuctionLoader): Promise<TreasuryAuctionsResult> {
101
111
  const boundary = auctionArchiveBoundary();
102
112
  const [recent, archive] = await Promise.all([
103
- cache.load(`since:${boundary}`, walk(loader, () => ({ from: boundary })), { force }),
113
+ cache.load(`since:${boundary}`, walk(loader, () => ({ from: boundary })), { force }).then(savedCopyForTransientFailure),
104
114
  archiveCache.load(`archive:${boundary}`, walk(loader, () => ({
105
115
  from: AUCTION_HISTORY_START, before: boundary, pageSize: AUCTION_ARCHIVE_PAGE_SIZE,
106
- }))),
116
+ }))).then(savedCopyForTransientFailure),
107
117
  ]);
108
118
  const seen = new Set<string>();
109
119
  const auctions = [...recent.data, ...archive.data].filter((auction) => !seen.has(auction.id) && Boolean(seen.add(auction.id)));
110
120
  const refreshError = recent.refreshError ?? archive.refreshError;
111
- return {
121
+ const savedAt = Math.min(...[recent, archive].map((part) => part.source === "stale-fallback" ? part.fetchedAt : Infinity));
122
+ return answered({
112
123
  auctions, fetchedAt: recent.fetchedAt, stale: recent.stale || archive.stale,
124
+ ...(Number.isFinite(savedAt) ? { savedAt } : {}),
113
125
  ...(refreshError ? { refreshError } : {}),
114
- };
126
+ });
115
127
  }
@@ -1,3 +1,4 @@
1
+ import { ApiRequestError } from "../../../api-client/errors";
1
2
  import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
3
  import type { TreasuryAuction, TreasuryAuctionRaw } from "./types";
3
4
 
@@ -222,9 +223,8 @@ export async function fetchTreasuryAuctions(
222
223
  ): Promise<TreasuryAuction[]> {
223
224
  return fetchAuctionPages(async (page) => {
224
225
  const response = await TREASURY_FETCH.fetch(buildAuctionsUrl(query, page));
225
- if (!response.ok) {
226
- throw new Error(`Treasury Fiscal Data request failed (${response.status})`);
227
- }
226
+ // The status stays: an overloaded server is a failure a saved board may answer for, a refusal is not.
227
+ if (!response.ok) throw new ApiRequestError(`Treasury Fiscal Data request failed (${response.status})`, response.status);
228
228
  return response.json();
229
229
  });
230
230
  }
@@ -5,6 +5,7 @@ import type {
5
5
  import { formatCompact } from "../../../utils/format";
6
6
  import { AUCTION_HISTORY_ALL, AUCTION_HISTORY_START, fetchTreasuryAuctions, type AuctionHistoryWindow } from "./client";
7
7
  import { loadTreasuryAuctions, type TreasuryAuctionsResult } from "./cache";
8
+ import { unansweredRefreshError } from "../../../data/saved-copies";
8
9
  import type { TreasuryAuction } from "./types";
9
10
  import { FIXED_INCOME_VIEW_STATE } from "../fixed-income/model";
10
11
  import {
@@ -156,7 +157,8 @@ function createTreasuryAuctionsHeadless(
156
157
  ];
157
158
  return {
158
159
  complete: !result.refreshError,
159
- errors: result.refreshError ? [result.refreshError] : [],
160
+ // A saved copy answering the failed refresh ends the report by saying when it was saved instead.
161
+ errors: [unansweredRefreshError(result)].filter((error): error is string => !!error),
160
162
  notes,
161
163
  rows: auctions.map((auction) => ({
162
164
  ...auction,
@@ -13,6 +13,8 @@ import {
13
13
  type DataTableRootKeyContext,
14
14
  } from "../../../components";
15
15
  import { loadingErrorFooterInfo, usePaneRefreshKey } from "../../../components/data-table/table-pane";
16
+ import { ageFooterSegment, savedCopyFooterInfo } from "../../../components/layout/pane/footer";
17
+ import { STALE_SEGMENT } from "../../../components/layout/pane/status-footer";
16
18
  import { usePaneInstance } from "../../../state/app/context";
17
19
  import { colors } from "../../../theme/colors";
18
20
  import type { PaneProps } from "../../../types/plugin";
@@ -208,8 +210,10 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
208
210
  const auctions = resource.data?.auctions ?? NO_AUCTIONS;
209
211
  const stale = resource.data?.stale ?? false;
210
212
  const fetchedAt = resource.data?.fetchedAt ?? null;
213
+ // A refresh that failed shows the auctions an earlier read saved, and says when that copy is from.
214
+ const savedAt = resource.data?.savedAt ?? null;
211
215
  // A cache that could not be refreshed arrives as a result, not a failure.
212
- const error = resource.error ?? (resource.loading ? null : resource.data?.refreshError ?? null);
216
+ const error = resource.error ?? (resource.loading || savedAt != null ? null : resource.data?.refreshError ?? null);
213
217
  // Once auctions have loaded, a refresh runs without a loading state.
214
218
  const loading = resource.loading && !resource.data;
215
219
  const [filter, setFilter] = usePluginPaneState<AuctionFilter>("activeTab", "all");
@@ -302,9 +306,10 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
302
306
 
303
307
  usePaneFooter(TREASURY_AUCTIONS_PANE_ID, () => {
304
308
  const info = loadingErrorFooterInfo(loading, error);
305
- if (stale) info.push({ id: "stale", parts: [{ text: "stale cache", tone: "warning" }] });
306
- if (fetchedAt) {
307
- info.push({ id: "updated", parts: [{ text: formatRelativeAge(fetchedAt), tone: "muted" }] });
309
+ if (savedAt != null) info.push(...savedCopyFooterInfo([savedAt]));
310
+ else if (stale) info.push(STALE_SEGMENT);
311
+ if (fetchedAt && savedAt == null) {
312
+ info.push(ageFooterSegment(formatRelativeAge(fetchedAt)));
308
313
  }
309
314
  return {
310
315
  info,
@@ -323,6 +328,7 @@ export function TreasuryAuctionsPane({ focused, width, height }: PaneProps) {
323
328
  fetchedAt,
324
329
  focusSearch,
325
330
  loading,
331
+ savedAt,
326
332
  stale,
327
333
  ]);
328
334
 
@@ -17,7 +17,14 @@ import { expiredExpiryText, findListedExpiry, isLiveOptionExpiry, liveOptionExpi
17
17
  import { optionModelSpot } from "../options/model-spot";
18
18
 
19
19
  const DEFAULT_SURFACE_EXPIRY_LIMIT = 18;
20
+ /** Expiries read at a time from a source that takes them one by one. */
20
21
  const SURFACE_LOAD_CONCURRENCY = 4;
22
+ /**
23
+ * Expiries asked at once from a source that batches them (`batchesExpiries`):
24
+ * the API client sends one listing's expiries asked in one moment in batch
25
+ * requests of six, side by side.
26
+ */
27
+ const SURFACE_BATCHED_CONCURRENCY = 48;
21
28
  /** Each kept expiry must be at least this much further out than the previous one. */
22
29
  const SURFACE_TENOR_RATIO = 1.35;
23
30
 
@@ -43,8 +50,10 @@ export function selectSurfaceExpiries(catalogue: readonly number[], limit: numbe
43
50
  }
44
51
 
45
52
  export interface SurfaceLoaderDependencies {
46
- loadOptions(request: OptionsRequest, options?: { forceRefresh?: boolean }): Promise<QueryEntry<OptionsChain>>;
53
+ loadOptions(request: OptionsRequest, options?: { forceRefresh?: boolean; batch?: boolean }): Promise<QueryEntry<OptionsChain>>;
47
54
  loadYieldCurve(): Promise<YieldPoint[]>;
55
+ /** Whether `loadOptions` reaches a source that batches expiries asked together; absent, four are read at a time. */
56
+ batchesExpiries?(): boolean | Promise<boolean>;
48
57
  now?: () => number;
49
58
  }
50
59
 
@@ -58,6 +67,7 @@ export function createSurfaceDependencies(
58
67
  loadOptions: (request, options) => coordinator
59
68
  ? coordinator.loadOptions(request, options)
60
69
  : Promise.reject(new Error("Market data coordinator unavailable")),
70
+ batchesExpiries: () => coordinator?.batchesOptions() ?? false,
61
71
  loadYieldCurve: () => cloudApi.getCloudYieldCurve(),
62
72
  };
63
73
  }
@@ -179,6 +189,8 @@ export async function loadVolatilitySurface(
179
189
  };
180
190
  checkAbort();
181
191
  publish();
192
+ // Asked while the catalogue loads, so it costs the expiries no wait.
193
+ const batched = Promise.resolve().then(() => dependencies.batchesExpiries?.()).then((answer) => answer === true, () => false);
182
194
  const treasury = Promise.resolve().then(() => dependencies.loadYieldCurve()).then((value) => {
183
195
  if (request.signal?.aborted) return;
184
196
  curve = value;
@@ -233,6 +245,9 @@ export async function loadVolatilitySurface(
233
245
  publish();
234
246
  }
235
247
 
248
+ // A source that takes expiries one by one is asked four at a time, and a cancelled load asks for no more.
249
+ const batch = await abortable(batched, request.signal, CANCELLED);
250
+ const concurrency = batch ? SURFACE_BATCHED_CONCURRENCY : SURFACE_LOAD_CONCURRENCY;
236
251
  let next = 0;
237
252
  const worker = async () => {
238
253
  while (!request.signal?.aborted && next < selected.length) {
@@ -240,7 +255,7 @@ export async function loadVolatilitySurface(
240
255
  try {
241
256
  // The catalogue response already refreshed its own slice, unless it came from cache.
242
257
  const entry = await dependencies.loadOptions({ instrument: request.instrument, expirationDate: expiration },
243
- { forceRefresh: !!request.forceRefresh && (!!request.expiries || expiration !== catalogueExpiration) });
258
+ { forceRefresh: !!request.forceRefresh && (!!request.expiries || expiration !== catalogueExpiration), ...(batch ? { batch } : {}) });
244
259
  if (request.signal?.aborted) return;
245
260
  entries.set(expiration, entry);
246
261
  if (!resolveEntryValue(entry) && !entry.error) errors.set(expiration, "Options chain unavailable");
@@ -251,7 +266,7 @@ export async function loadVolatilitySurface(
251
266
  publish();
252
267
  }
253
268
  };
254
- await abortable(Promise.all([treasury, ...Array.from({ length: Math.min(SURFACE_LOAD_CONCURRENCY, selected.length) }, worker)]), request.signal, CANCELLED);
269
+ await abortable(Promise.all([treasury, ...Array.from({ length: Math.min(concurrency, selected.length) }, worker)]), request.signal, CANCELLED);
255
270
  checkAbort();
256
271
  finished = true;
257
272
  const result = snapshot();
@@ -1,11 +1,10 @@
1
1
  import type { PluginModule } from "../plugin-module";
2
2
  import { createTickerSurfacePaneTemplate } from "../shared/ticker-surface";
3
- import { VolSurfacePane } from "./pane";
4
3
  import { volSurfaceHeadless } from "./headless";
5
4
 
6
5
  export const volSurfaceModule: PluginModule = {
7
6
  panes: [{
8
- id: "vol-surface", name: "Volatility Surface", icon: "V", component: VolSurfacePane,
7
+ id: "vol-surface", name: "Volatility Surface", icon: "V", get component(): typeof import("./pane").VolSurfacePane { const { VolSurfacePane } = require("./pane"); return VolSurfacePane; },
9
8
  defaultPosition: "right", tickerFollower: true, defaultMode: "floating", defaultFloatingSize: { width: 126, height: 36 },
10
9
  tableExport: true, headless: volSurfaceHeadless,
11
10
  settings: {
@@ -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());