gloomberb 0.18.4 → 0.18.5

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 (282) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +32 -1
  5. package/src/account/auth-model.ts +7 -3
  6. package/src/api-client/auth.ts +9 -4
  7. package/src/api-client/catalysts.ts +2 -1
  8. package/src/api-client/data.ts +14 -0
  9. package/src/api-client/index.ts +160 -11
  10. package/src/api-client/live-scan.ts +91 -0
  11. package/src/api-client/members.ts +12 -0
  12. package/src/api-client/request.ts +222 -41
  13. package/src/api-client/research-activity.ts +3 -10
  14. package/src/api-client/types.ts +6 -0
  15. package/src/app/keybindings/index.ts +3 -0
  16. package/src/app/keybindings/labels.ts +18 -1
  17. package/src/app/keybindings/resolve.ts +14 -0
  18. package/src/app/runtime/broker-import.ts +43 -26
  19. package/src/architecture/core-builtin-allowlist.json +1 -1
  20. package/src/cli/commands/fred-series.ts +5 -0
  21. package/src/cli/commands/keybindings.ts +23 -0
  22. package/src/cli/commands/market.ts +68 -22
  23. package/src/cli/commands/options-chain.ts +59 -3
  24. package/src/cli/commands/overview.ts +119 -31
  25. package/src/cli/commands/search.ts +18 -2
  26. package/src/cli/commands/ticker.ts +2 -1
  27. package/src/cli/errors.ts +2 -2
  28. package/src/cli/export-source.ts +29 -1
  29. package/src/cli/glossary.ts +49 -0
  30. package/src/cli/history-rows.ts +30 -7
  31. package/src/cli/known-gaps.ts +5 -2
  32. package/src/cli/listing-arg.ts +16 -5
  33. package/src/cli/pane-functions/freshness.ts +25 -13
  34. package/src/cli/pane-functions/function-help.ts +10 -3
  35. package/src/cli/pane-functions/headless.ts +39 -5
  36. package/src/cli/pane-functions/index.ts +10 -3
  37. package/src/cli/pane-functions/market-session.ts +15 -5
  38. package/src/cli/pane-functions/report.ts +5 -1
  39. package/src/cli/pane-functions/resolver.ts +18 -2
  40. package/src/cli/quote-answer.ts +10 -4
  41. package/src/cli/report-tables.ts +18 -7
  42. package/src/cli/restore-plugins.ts +13 -1
  43. package/src/cli/result.ts +57 -5
  44. package/src/components/chart/composite/composite-chart.tsx +22 -4
  45. package/src/components/chart/composite/format.ts +27 -0
  46. package/src/components/chart/composite/levels.ts +2 -0
  47. package/src/components/chart/composite/panel-series.ts +1 -0
  48. package/src/components/chart/composite/panel-surface.tsx +9 -2
  49. package/src/components/chart/composite/range-statistics.ts +62 -0
  50. package/src/components/chart/composite/scene.ts +7 -3
  51. package/src/components/chart/composite/series-axis-tags.tsx +16 -9
  52. package/src/components/chart/composite/types.ts +4 -0
  53. package/src/components/command-bar/panel/prompt-binding.ts +0 -8
  54. package/src/components/command-bar/workflow/alert.ts +1 -1
  55. package/src/components/layout/header.tsx +26 -40
  56. package/src/components/layout/pane/footer/index.tsx +1 -0
  57. package/src/components/layout/pane/footer/model.ts +27 -0
  58. package/src/components/onboarding/onboarding-frame.tsx +1 -1
  59. package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
  60. package/src/components/ui/display.tsx +5 -1
  61. package/src/core/state/app/layout.ts +2 -0
  62. package/src/core/state/app/state.ts +9 -0
  63. package/src/core/state/app/types.ts +3 -0
  64. package/src/data/saved-copies.ts +55 -16
  65. package/src/docs/snapshot.json +361 -84
  66. package/src/i18n/es.ts +1 -1
  67. package/src/i18n/ja.ts +1 -1
  68. package/src/i18n/ko.ts +1 -1
  69. package/src/i18n/zh-cn.ts +1 -1
  70. package/src/i18n/zh-tw.ts +1 -1
  71. package/src/market-data/coordinator/financials.ts +10 -2
  72. package/src/market-data/coordinator/index.ts +3 -1
  73. package/src/market-data/coordinator/quotes.ts +4 -2
  74. package/src/market-data/market/change-basis.ts +33 -5
  75. package/src/market-data/market/dated-previous-close.ts +203 -0
  76. package/src/market-data/market/futures-week.ts +68 -0
  77. package/src/market-data/market/status.ts +16 -4
  78. package/src/market-data/options-alternatives.ts +60 -0
  79. package/src/market-data/quotes/contributions.ts +20 -2
  80. package/src/market-data/quotes/resolution.ts +3 -0
  81. package/src/market-data/selectors.ts +3 -0
  82. package/src/notifications/notification-log.ts +6 -4
  83. package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
  84. package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
  85. package/src/plugins/builtin/alerts/cli.ts +59 -20
  86. package/src/plugins/builtin/alerts/command.ts +7 -0
  87. package/src/plugins/builtin/alerts/constants.ts +1 -1
  88. package/src/plugins/builtin/alerts/fired-log.ts +45 -4
  89. package/src/plugins/builtin/alerts/format.ts +8 -2
  90. package/src/plugins/builtin/alerts/headless.ts +10 -5
  91. package/src/plugins/builtin/alerts/history-rows.ts +5 -2
  92. package/src/plugins/builtin/alerts/history.ts +2 -0
  93. package/src/plugins/builtin/alerts/index.tsx +66 -16
  94. package/src/plugins/builtin/alerts/live.ts +3 -3
  95. package/src/plugins/builtin/alerts/metric-command.ts +61 -0
  96. package/src/plugins/builtin/alerts/metric-data.ts +70 -0
  97. package/src/plugins/builtin/alerts/metric-model.ts +175 -0
  98. package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
  99. package/src/plugins/builtin/alerts/pane.tsx +42 -29
  100. package/src/plugins/builtin/alerts/storage.ts +33 -10
  101. package/src/plugins/builtin/alerts/types.ts +3 -0
  102. package/src/plugins/builtin/backtest/headless.ts +10 -5
  103. package/src/plugins/builtin/backtest/pane.tsx +9 -4
  104. package/src/plugins/builtin/backtest/presets.ts +29 -3
  105. package/src/plugins/builtin/broker-manager/index.tsx +3 -2
  106. package/src/plugins/builtin/broker-manager/model.ts +6 -1
  107. package/src/plugins/builtin/broker-trading/controller.ts +22 -12
  108. package/src/plugins/builtin/broker-trading/index.tsx +13 -1
  109. package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
  110. package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
  111. package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
  112. package/src/plugins/builtin/catalysts/model.ts +2 -1
  113. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
  114. package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
  115. package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
  116. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
  117. package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
  118. package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
  119. package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
  120. package/src/plugins/builtin/chart-composer/formula.ts +71 -18
  121. package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
  122. package/src/plugins/builtin/chart-composer/headless.ts +13 -2
  123. package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
  124. package/src/plugins/builtin/chart-composer/presets.ts +50 -15
  125. package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
  126. package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
  127. package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
  128. package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
  129. package/src/plugins/builtin/chat/content/composer.tsx +13 -6
  130. package/src/plugins/builtin/chat/content/footer.tsx +6 -3
  131. package/src/plugins/builtin/chat/content/index.tsx +50 -21
  132. package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
  133. package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
  134. package/src/plugins/builtin/chat/index.tsx +23 -0
  135. package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
  136. package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
  137. package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
  138. package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
  139. package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
  140. package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
  141. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
  142. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  143. package/src/plugins/builtin/econ/calendar-model.ts +14 -9
  144. package/src/plugins/builtin/econ/figures.ts +72 -0
  145. package/src/plugins/builtin/econ/index.tsx +33 -3
  146. package/src/plugins/builtin/econ/types.ts +2 -0
  147. package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
  148. package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
  149. package/src/plugins/builtin/econ-statistics/view.ts +3 -0
  150. package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
  151. package/src/plugins/builtin/exposure/charts.tsx +2 -2
  152. package/src/plugins/builtin/exposure/model.ts +13 -1
  153. package/src/plugins/builtin/exposure/pane.tsx +4 -4
  154. package/src/plugins/builtin/futures/index.tsx +1 -0
  155. package/src/plugins/builtin/futures/model.ts +2 -1
  156. package/src/plugins/builtin/futures/table.tsx +2 -2
  157. package/src/plugins/builtin/fx-matrix/client.ts +6 -1
  158. package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
  159. package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
  160. package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
  161. package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
  162. package/src/plugins/builtin/iv-history/headless.ts +3 -0
  163. package/src/plugins/builtin/macro-day/pane.tsx +7 -5
  164. package/src/plugins/builtin/market-movers/footer.ts +2 -2
  165. package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
  166. package/src/plugins/builtin/members/client.ts +19 -4
  167. package/src/plugins/builtin/members/headless.ts +20 -7
  168. package/src/plugins/builtin/members/index.tsx +9 -9
  169. package/src/plugins/builtin/members/model.ts +23 -4
  170. package/src/plugins/builtin/members/pane.tsx +25 -15
  171. package/src/plugins/builtin/options/analytics.ts +26 -2
  172. package/src/plugins/builtin/options/model-spot.ts +5 -2
  173. package/src/plugins/builtin/options/strike-window.ts +2 -1
  174. package/src/plugins/builtin/options/table.ts +1 -7
  175. package/src/plugins/builtin/options/view.tsx +25 -8
  176. package/src/plugins/builtin/options-positioning/client.ts +19 -0
  177. package/src/plugins/builtin/options-positioning/headless.ts +54 -14
  178. package/src/plugins/builtin/options-positioning/index.tsx +3 -3
  179. package/src/plugins/builtin/options-positioning/model.ts +47 -8
  180. package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
  181. package/src/plugins/builtin/perps/freshness.ts +12 -10
  182. package/src/plugins/builtin/perps/live-rows.ts +10 -3
  183. package/src/plugins/builtin/perps/pane.tsx +15 -11
  184. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
  185. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  186. package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
  187. package/src/plugins/builtin/rate-path/pane.tsx +2 -2
  188. package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
  189. package/src/plugins/builtin/scanner/feed.ts +2 -2
  190. package/src/plugins/builtin/scanner/index.ts +24 -1
  191. package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
  192. package/src/plugins/builtin/scanner/live-feed.ts +168 -0
  193. package/src/plugins/builtin/scanner/live-headless.ts +114 -0
  194. package/src/plugins/builtin/scanner/live-model.ts +351 -0
  195. package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
  196. package/src/plugins/builtin/scanner/live-track.ts +186 -0
  197. package/src/plugins/builtin/series-inventory.ts +1 -0
  198. package/src/plugins/builtin/shared/macro-releases.ts +65 -0
  199. package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
  200. package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
  201. package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
  202. package/src/plugins/builtin/themes/headless.ts +6 -4
  203. package/src/plugins/builtin/themes/model.ts +1 -1
  204. package/src/plugins/builtin/themes/pane.tsx +3 -3
  205. package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
  206. package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
  207. package/src/plugins/builtin/vol-surface/client.ts +6 -4
  208. package/src/plugins/builtin/vol-surface/headless.ts +62 -13
  209. package/src/plugins/builtin/vol-surface/index.tsx +2 -2
  210. package/src/plugins/builtin/vol-surface/model.ts +22 -7
  211. package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
  212. package/src/plugins/builtin/vol-surface/tables.ts +31 -3
  213. package/src/plugins/builtin/volatility/dvol.ts +46 -0
  214. package/src/plugins/builtin/volatility/pane.tsx +2 -2
  215. package/src/plugins/builtin/volatility/series.ts +26 -0
  216. package/src/plugins/builtin/world-indices/footer.ts +1 -1
  217. package/src/plugins/catalog-browser.ts +13 -8
  218. package/src/portfolio/column-values.ts +5 -5
  219. package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
  220. package/src/renderers/browser/app-services.ts +1 -7
  221. package/src/renderers/browser/config-host.ts +2 -55
  222. package/src/renderers/browser/main.tsx +7 -57
  223. package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
  224. package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
  225. package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
  226. package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
  227. package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
  228. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
  229. package/src/renderers/electrobun/bun/index.ts +25 -1
  230. package/src/renderers/electrobun/shared/app-name.ts +17 -0
  231. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
  232. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
  233. package/src/renderers/opentui/toast-host.tsx +71 -43
  234. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  235. package/src/sources/provider-router/batches.ts +10 -2
  236. package/src/sources/provider-router/financial-routes.ts +3 -3
  237. package/src/sources/provider-router/history.ts +28 -7
  238. package/src/sources/provider-router/index.ts +146 -10
  239. package/src/sources/provider-router/search.ts +13 -2
  240. package/src/state/app/context/index.tsx +3 -0
  241. package/src/sync/auth-transition.ts +19 -9
  242. package/src/sync/baseline.ts +10 -4
  243. package/src/sync/controller.ts +66 -16
  244. package/src/sync/react.ts +4 -1
  245. package/src/sync/types.ts +3 -0
  246. package/src/telemetry/usage-counts.ts +0 -18
  247. package/src/theme/colors.ts +8 -0
  248. package/src/tickers/company-name.ts +59 -0
  249. package/src/tickers/listing-venues.ts +23 -0
  250. package/src/tickers/search/bare-symbol.ts +36 -0
  251. package/src/tickers/search/index.ts +36 -7
  252. package/src/tickers/search/ranking.ts +10 -1
  253. package/src/tickers/search/types.ts +5 -0
  254. package/src/time-series/base-series.ts +13 -5
  255. package/src/time-series/daily-valuation.ts +62 -0
  256. package/src/time-series/field-catalog.ts +1 -3
  257. package/src/time-series/forward-valuation.ts +32 -0
  258. package/src/time-series/fundamentals.ts +155 -7
  259. package/src/time-series/hooks.ts +1 -0
  260. package/src/time-series/price-units.ts +72 -2
  261. package/src/time-series/resolve-context.ts +5 -1
  262. package/src/time-series/resolve.ts +61 -12
  263. package/src/time-series/series-presentation.ts +40 -6
  264. package/src/time-series/spec.ts +2 -1
  265. package/src/time-series/studies.ts +270 -72
  266. package/src/time-series/transforms.ts +1 -0
  267. package/src/time-series/types.ts +5 -0
  268. package/src/time-series/valuation-releases.ts +39 -0
  269. package/src/types/data-provider.ts +9 -1
  270. package/src/types/desktop-window.ts +2 -0
  271. package/src/types/financials.ts +41 -1
  272. package/src/types/headless.ts +42 -4
  273. package/src/types/instrument.ts +3 -0
  274. package/src/types/plugin.ts +1 -1
  275. package/src/updater/command-names.ts +12 -7
  276. package/src/utils/financial-statements.ts +6 -1
  277. package/src/utils/network-failure.ts +9 -6
  278. package/src/utils/option-expiry.ts +48 -1
  279. package/src/utils/options.ts +10 -0
  280. package/src/utils/plugin-proxy-hosts.json +1 -0
  281. package/src/utils/price-history.ts +5 -0
  282. package/src/renderers/browser/first-screen.ts +0 -193
package/src/i18n/es.ts CHANGED
@@ -1345,7 +1345,7 @@ export const es: Record<string, string> = {
1345
1345
  "Free: 12h delayed. Pro: real-time.": "Gratis: 12 h de retraso. Pro: tiempo real.",
1346
1346
  "GLOOM CLOUD": "GLOOM CLOUD",
1347
1347
  "GLOOM CLOUD PRO": "GLOOM CLOUD PRO",
1348
- "GLOOMBERB SETUP": "CONFIGURACIÓN DE GLOOMBERB",
1348
+ "GLOOM SETUP": "CONFIGURACIÓN DE GLOOM",
1349
1349
  "Gloom Cloud is connected": "Gloom Cloud está conectado",
1350
1350
  "How do you want to start?": "¿Cómo quieres empezar?",
1351
1351
  "Imported {count} positions from {broker}. Your workspace is ready to explore.": "Se importaron {count} posiciones de {broker}. Tu espacio de trabajo está listo para explorar.",
package/src/i18n/ja.ts CHANGED
@@ -1333,7 +1333,7 @@ export const ja: Record<string, string> = {
1333
1333
  "Free: 12h delayed. Pro: real-time.": "無料:12時間遅延。Pro:リアルタイム。",
1334
1334
  "GLOOM CLOUD": "GLOOM CLOUD",
1335
1335
  "GLOOM CLOUD PRO": "GLOOM CLOUD PRO",
1336
- "GLOOMBERB SETUP": "GLOOMBERB セットアップ",
1336
+ "GLOOM SETUP": "GLOOM セットアップ",
1337
1337
  "Gloom Cloud is connected": "Gloom Cloudに接続しました",
1338
1338
  "How do you want to start?": "どの方法で始めますか?",
1339
1339
  "Imported {count} positions from {broker}. Your workspace is ready to explore.": "{broker}から{count}件のポジションをインポートしました。ワークスペースの準備が整いました。",
package/src/i18n/ko.ts CHANGED
@@ -1331,7 +1331,7 @@ export const ko: Record<string, string> = {
1331
1331
  "Free: 12h delayed. Pro: real-time.": "무료: 12시간 지연. Pro: 실시간.",
1332
1332
  "GLOOM CLOUD": "GLOOM CLOUD",
1333
1333
  "GLOOM CLOUD PRO": "GLOOM CLOUD PRO",
1334
- "GLOOMBERB SETUP": "GLOOMBERB 설정",
1334
+ "GLOOM SETUP": "GLOOM 설정",
1335
1335
  "Gloom Cloud is connected": "Gloom Cloud가 연결되었습니다",
1336
1336
  "How do you want to start?": "어떻게 시작하시겠어요?",
1337
1337
  "Imported {count} positions from {broker}. Your workspace is ready to explore.": "{broker}에서 {count}개 보유 종목을 가져왔습니다. 이제 작업 공간을 둘러볼 수 있습니다.",
package/src/i18n/zh-cn.ts CHANGED
@@ -1333,7 +1333,7 @@ export const zhCN: Record<string, string> = {
1333
1333
  "Free: 12h delayed. Pro: real-time.": "免费:延迟 12 小时。Pro:实时。",
1334
1334
  "GLOOM CLOUD": "GLOOM CLOUD",
1335
1335
  "GLOOM CLOUD PRO": "GLOOM CLOUD PRO",
1336
- "GLOOMBERB SETUP": "GLOOMBERB 设置",
1336
+ "GLOOM SETUP": "GLOOM 设置",
1337
1337
  "Gloom Cloud is connected": "Gloom Cloud 已连接",
1338
1338
  "How do you want to start?": "你想如何开始?",
1339
1339
  "Imported {count} positions from {broker}. Your workspace is ready to explore.": "已从 {broker} 导入 {count} 个持仓。你的工作区已准备好,可以开始探索。",
package/src/i18n/zh-tw.ts CHANGED
@@ -1333,7 +1333,7 @@ export const zhTW: Record<string, string> = {
1333
1333
  "Free: 12h delayed. Pro: real-time.": "免費:延遲 12 小時。Pro:即時。",
1334
1334
  "GLOOM CLOUD": "GLOOM CLOUD",
1335
1335
  "GLOOM CLOUD PRO": "GLOOM CLOUD PRO",
1336
- "GLOOMBERB SETUP": "GLOOMBERB 設定",
1336
+ "GLOOM SETUP": "GLOOM 設定",
1337
1337
  "Gloom Cloud is connected": "Gloom Cloud 已連線",
1338
1338
  "How do you want to start?": "你想如何開始?",
1339
1339
  "Imported {count} positions from {broker}. Your workspace is ready to explore.": "已從 {broker} 匯入 {count} 個部位。你的工作區已準備好,可以開始探索。",
@@ -11,6 +11,7 @@ import {
11
11
  toMarketDataContext,
12
12
  } from "../selectors";
13
13
  import { traceMarketData } from "../trace";
14
+ import { savedCopyAt } from "../../data/saved-copies";
14
15
  import { createBaselineChartRequest } from "./chart";
15
16
  import {
16
17
  EXPECTED_EMPTY,
@@ -94,8 +95,10 @@ function storeFinancialsSnapshot(
94
95
  if (normalized.quote) {
95
96
  const quoteKey = buildQuoteKey(instrument);
96
97
  const currentQuoteEntry = stores.quoteStore.get(quoteKey);
97
- // A tick streamed while the snapshot was in flight is newer than its quote.
98
- if (!heldQuoteSupersedes(currentQuoteEntry, normalized.quote)) {
98
+ // A tick streamed while the snapshot was in flight is newer than its quote. And a snapshot read
99
+ // from the cache carries the quote saved with it: no newer than a saved copy the quote's own
100
+ // failed refresh answered with, it is that copy, and the quote keeps saying so.
101
+ if (!heldQuoteSupersedes(currentQuoteEntry, normalized.quote) && !repeatsHeldSavedCopy(currentQuoteEntry, normalized.quote)) {
99
102
  stores.quoteStore.set(
100
103
  quoteKey,
101
104
  readyEntry(currentQuoteEntry, normalized.quote, normalized.quote.providerId ?? source, attempts),
@@ -112,6 +115,11 @@ function storeFinancialsSnapshot(
112
115
  return entry;
113
116
  }
114
117
 
118
+ function repeatsHeldSavedCopy(current: QueryEntry<Quote>, incoming: Quote): boolean {
119
+ const held = current.data ?? current.lastGoodData;
120
+ return !!held && savedCopyAt(held) != null && savedCopyAt(incoming) == null && !(incoming.lastUpdated > held.lastUpdated);
121
+ }
122
+
115
123
  /** Fresh data, or a failure too recent to ask about again unless forced. */
116
124
  function canReuseSnapshotEntry(entry: QueryEntry<TickerFinancials>): boolean {
117
125
  return hasFreshEntryData(entry, SNAPSHOT_CACHE_TTL_MS) || hasRecentFailedAttempt(entry, SNAPSHOT_FAILURE_RETRY_MS);
@@ -82,6 +82,7 @@ import {
82
82
  type QuoteSubscriptionHandle,
83
83
  type QuoteSubscriptionRequest,
84
84
  } from "./quotes";
85
+ import { withSavedCopyMarkOf } from "../../data/saved-copies";
85
86
 
86
87
  /** How long a streamed tick may reuse the quote fields read from the cache. */
87
88
  const STREAM_QUOTE_BASELINE_TTL_MS = 60_000;
@@ -747,7 +748,8 @@ export class MarketDataCoordinator {
747
748
  const resolvedQuote = quote.stale === true ? quote : this.resolveIncomingQuote(instrument, quote);
748
749
  const startedAt = Date.now();
749
750
  const receivedAt = resolvedQuote.stale === true ? resolvedQuote.receivedAt : startedAt;
750
- const storedQuote = receivedAt == null ? resolvedQuote : { ...resolvedQuote, receivedAt };
751
+ // A tick merged onto a saved copy is the stream's answer, not the saved copy.
752
+ const storedQuote = withSavedCopyMarkOf(receivedAt == null ? resolvedQuote : { ...resolvedQuote, receivedAt }, quote);
751
753
  const currentQuote = current.data ?? current.lastGoodData;
752
754
  if (areStreamQuotesEquivalent(currentQuote, storedQuote)) {
753
755
  const previousReceipt = currentQuote?.receivedAt;
@@ -7,6 +7,7 @@ import { QueryStore } from "../query-store";
7
7
  import type { QueryEntry } from "../result-types";
8
8
  import { buildQuoteKey, instrumentFromQuoteTarget, toMarketDataContext } from "../selectors";
9
9
  import { measurePerf } from "../../utils/perf-marks";
10
+ import { withSavedCopyMarkOf } from "../../data/saved-copies";
10
11
  import {
11
12
  EXPECTED_EMPTY,
12
13
  SNAPSHOT_CACHE_TTL_MS,
@@ -112,7 +113,8 @@ export async function loadQuoteEntry({
112
113
  cacheMode: options.forceRefresh ? "refresh" : "default",
113
114
  },
114
115
  );
115
- const resolvedQuote = resolveQuote?.(instrument, quote) ?? quote;
116
+ // Resolved onto an older quote, the answer stays a saved copy exactly when the router served one.
117
+ const resolvedQuote = withSavedCopyMarkOf(resolveQuote?.(instrument, quote) ?? quote, quote);
116
118
  const source = resolvedQuote.providerId ?? dataProvider.id;
117
119
  const attempts = [createAttempt(source, startedAt, "success")];
118
120
  return quoteStore.update(key, (current) => readyQuoteEntry(current, resolvedQuote, source, attempts, { keepNewerHeldQuote: true, acceptLastKnown: true }));
@@ -183,7 +185,7 @@ export async function loadQuoteBatchEntries({
183
185
  if (item.error != null) results.set(key, storeQuoteFailure(quoteStore, key, dataProvider.id, startedAt, item.error));
184
186
  return;
185
187
  }
186
- const quote = resolveQuote?.(instrument, item.quote) ?? item.quote;
188
+ const quote = withSavedCopyMarkOf(resolveQuote?.(instrument, item.quote) ?? item.quote, item.quote);
187
189
  const source = quote.providerId ?? dataProvider.id;
188
190
  const attempts = [createAttempt(source, Date.now(), "success")];
189
191
  results.set(key, quoteStore.update(key, (current) => readyQuoteEntry(current, quote, source, attempts, { keepNewerHeldQuote: true, acceptLastKnown: true })));
@@ -7,7 +7,7 @@ import { formatUtcDayTime } from "../../utils/utc-time";
7
7
  import { zonedDateKey } from "../../utils/zoned-date-time";
8
8
  import { futuresChangeBasis, futuresSettleText, isFuturesInstrument } from "../futures-contract";
9
9
  import { quoteTradingDay } from "../quotes/day-range";
10
- import { previousPublishedSession } from "./freshness";
10
+ import { hasPublishedSessionCalendar, previousPublishedSession } from "./freshness";
11
11
  import { getActiveQuoteDisplay, getRegularSessionDisplay, regularSessionDisplayDate } from "./status";
12
12
 
13
13
  const DAY_MS = 86_400_000;
@@ -119,7 +119,9 @@ function previousIndexSession(quote: Quote, date: string): string | null {
119
119
  * The session before the one a move is dated to: from the venue's published
120
120
  * calendar, with the closures in between (an index's, the venue whose days it
121
121
  * follows); otherwise for an index from its own sessions; for a currency pair
122
- * the weekday before. Null when nothing dates it.
122
+ * the weekday before; for any other listing the day its own daily closes date
123
+ * its previous close to (`previousCloseDate`, PreviousCloseDates). Null
124
+ * when nothing dates it.
123
125
  */
124
126
  function previousSession(quote: Quote, sessionDate: string): DatedChangeBasis | null {
125
127
  const venue = calendarVenue(quote);
@@ -128,9 +130,34 @@ function previousSession(quote: Quote, sessionDate: string): DatedChangeBasis |
128
130
  const index = previousIndexSession(quote, sessionDate);
129
131
  if (index) return { kind: "close", date: index };
130
132
  if (isCurrencyPair(quote)) return { kind: "close", date: weekdayBefore(sessionDate) };
133
+ const dated = quote.previousCloseDate;
134
+ if (dated && ISO_DATE.test(dated) && dated < sessionDate) return { kind: "close", date: dated };
131
135
  return null;
132
136
  }
133
137
 
138
+ /**
139
+ * Whether something other than the listing's own daily closes dates every
140
+ * move of the quote: a future's terms, a coin's UTC day, a perpetual's 24
141
+ * hours, a currency pair's weekday, or its venue's calendar for the year of
142
+ * `now`. A few lookups, for a caller that runs on every tick before it asks
143
+ * quoteChangeBasis.
144
+ */
145
+ export function isChangeBasisDatedWithoutCloses(quote: Quote, now = Date.now()): boolean {
146
+ if (isSpotCoin(quote) || isCurrencyPair(quote) || isFuturesInstrument(quote.symbol, quote.instrumentType)) return true;
147
+ if (isPerpetualExchange(quote.listingExchangeName || quote.exchangeName)) return true;
148
+ return hasPublishedSessionCalendar(calendarVenue(quote), new Date(now).toISOString().slice(0, 10));
149
+ }
150
+
151
+ /**
152
+ * The exchange-local date of the session a quote's regular move is dated to,
153
+ * the one `quoteChangeBasis` measures from the session before; null when
154
+ * nothing dates it.
155
+ */
156
+ export function changeBasisSessionDate(quote: Quote): string | null {
157
+ const date = regularSessionDisplayDate(quote) ?? quoteTradingDay(quote);
158
+ return date && ISO_DATE.test(date) ? date : null;
159
+ }
160
+
134
161
  /**
135
162
  * What the quote's move is measured from. A coin moves from its previous UTC
136
163
  * day's close, the price at 00:00 UTC, unless Gloom Cloud says it moves over
@@ -138,9 +165,10 @@ function previousSession(quote: Quote, sessionDate: string): DatedChangeBasis |
138
165
  * settlement or trade its terms name, or the close of the trade date before
139
166
  * on its venue's holiday calendar (futuresChangeBasis). Anything else is
140
167
  * the close of the session before the move's, dated by the venue's calendar;
141
- * a venue without one has a previous close that is left undated, never
142
- * guessed. Null for a quote with no move: none reported, or a listing that no
143
- * longer trades.
168
+ * on a venue without one, by the listing's own daily closes once they were
169
+ * read (PreviousCloseDates), and otherwise left undated, never guessed.
170
+ * Null for a quote with no move: none reported, or a listing that no longer
171
+ * trades.
144
172
  */
145
173
  export function quoteChangeBasis(
146
174
  quote: Quote | null | undefined,
@@ -0,0 +1,203 @@
1
+ import type { MarketDataRequestContext } from "../../types/data-provider";
2
+ import type { PricePoint, Quote } from "../../types/financials";
3
+ import { abortable, settleWithin, withDeadline } from "../../utils/async-deadline";
4
+ import { canonicalExchange } from "../../utils/exchanges";
5
+ import { logFailure } from "../../utils/log-failure";
6
+ import { dailyBarDate } from "../../utils/price-history";
7
+ import { classifyInstrumentType } from "../../tickers/instrument-kind";
8
+ import { changeBasisSessionDate, isChangeBasisDatedWithoutCloses, quoteChangeBasis } from "./change-basis";
9
+
10
+ /** History closes come as float32 (30.93000030517578) where the quote says 30.93: one close. */
11
+ const SAME_CLOSE_RELATIVE = 1e-5;
12
+ /**
13
+ * How long a reading that named no day stands before the closes are read
14
+ * again: a history a session behind catches up, a failed read is retried.
15
+ */
16
+ const UNDATED_RETRY_MS = 10 * 60_000;
17
+ /** Readings kept at once; the oldest goes first. */
18
+ const MAX_READINGS = 2_000;
19
+ /** Daily histories read at once. */
20
+ const MAX_READS_IN_FLIGHT = 6;
21
+ /** How long one read may take before its slot goes to the next: a hung read must not stall the rest. */
22
+ const READ_DEADLINE_MS = 10_000;
23
+
24
+ /** The listing a quote was asked for, as its daily closes are read: its context names a broker's contract. */
25
+ export interface DatedCloseListing {
26
+ symbol: string;
27
+ exchange: string;
28
+ context?: MarketDataRequestContext;
29
+ }
30
+
31
+ /** A listing's recent daily closes (a month is enough). */
32
+ export type ReadDailyCloses = (listing: DatedCloseListing) => Promise<readonly Pick<PricePoint, "date" | "close">[]>;
33
+
34
+ export interface PreviousCloseDatesOptions {
35
+ /** Called with the latest copy of a quote, dated, when a read lands that names its day. */
36
+ onDated?: (listing: DatedCloseListing, quote: Quote) => void;
37
+ now?: () => number;
38
+ maxReadsInFlight?: number;
39
+ readDeadlineMs?: number;
40
+ }
41
+
42
+ interface Reading {
43
+ date: string | null;
44
+ at: number;
45
+ }
46
+
47
+ /** One listing whatever spelling of its exchange a request used. */
48
+ export function datedCloseListingKey(listing: DatedCloseListing): string {
49
+ return `${listing.symbol.trim().toUpperCase()}|${canonicalExchange(listing.exchange)}`;
50
+ }
51
+
52
+ function sameClose(left: number, right: number): boolean {
53
+ return Math.abs(left - right) <= Math.max(Math.abs(left), Math.abs(right)) * SAME_CLOSE_RELATIVE;
54
+ }
55
+
56
+ /**
57
+ * What dates a quote's previous close by its listing's daily closes: the
58
+ * listing, the session the move is dated to and the close itself. Null for a
59
+ * quote something else dates (its venue's calendar, a coin's UTC day, a
60
+ * future's terms, a close the source dated), one with no move from a previous
61
+ * close, and an option, whose chain would read a history per contract. The
62
+ * venue's calendar is checked first, since this runs on every tick.
63
+ */
64
+ function readingKey(quote: Quote | null | undefined, listing: DatedCloseListing, now: number): string | null {
65
+ if (!quote || isChangeBasisDatedWithoutCloses(quote, now) || classifyInstrumentType(quote.instrumentType) === "option") return null;
66
+ if (quoteChangeBasis(quote)?.kind !== "previous close") return null;
67
+ const previousClose = quote.previousClose;
68
+ const sessionDate = changeBasisSessionDate(quote);
69
+ if (previousClose == null || !Number.isFinite(previousClose) || !sessionDate) return null;
70
+ return `${datedCloseListingKey(listing)}|${sessionDate}|${previousClose}`;
71
+ }
72
+
73
+ /**
74
+ * The day of the latest daily close before the move's session, when that
75
+ * close is the quote's previous close; null otherwise, so a gap in the
76
+ * history never names the wrong day.
77
+ */
78
+ function latestCloseBefore(points: readonly Pick<PricePoint, "date" | "close">[], quote: Quote): string | null {
79
+ const sessionDate = changeBasisSessionDate(quote)!;
80
+ const exchange = quote.listingExchangeName ?? quote.exchangeName ?? "";
81
+ const before = points
82
+ .map((point) => ({ time: new Date(point.date).getTime(), close: point.close }))
83
+ .filter((point) => Number.isFinite(point.time) && Number.isFinite(point.close))
84
+ .map((point) => ({ date: dailyBarDate(point.time, exchange), close: point.close }))
85
+ .filter((point) => point.date < sessionDate)
86
+ .sort((left, right) => left.date.localeCompare(right.date))
87
+ .at(-1);
88
+ return before && sameClose(before.close, quote.previousClose!) ? before.date : null;
89
+ }
90
+
91
+ function withDate<T extends Quote | null | undefined>(quote: T, date: string | null | undefined): T {
92
+ return date && quote ? { ...quote, previousCloseDate: date } : quote;
93
+ }
94
+
95
+ /**
96
+ * Dates the previous close a quote's move is from where the venue's calendar
97
+ * cannot (LSE and Helsinki publish none the app carries), by the listing's
98
+ * own daily closes, so every surface that reads quotes through the app's
99
+ * quote router names the day (`quoteChangeBasis`). The closes are read once
100
+ * per listing, session and close, and the answer is kept: a refresh, a tick
101
+ * or a cached read of the same quote is dated without another read. A few
102
+ * reads run at once, each for at most ten seconds.
103
+ */
104
+ export class PreviousCloseDates {
105
+ private readonly readings = new Map<string, Reading>();
106
+ private readonly pending = new Map<string, Promise<string | null>>();
107
+ /** The latest copy of each quote a read is pending for, which the read dates when it lands. */
108
+ private readonly latestCopies = new Map<string, { quote: Quote; listing: DatedCloseListing }>();
109
+ private readonly waiting: Array<() => void> = [];
110
+ private active = 0;
111
+ private readonly now: () => number;
112
+ private readonly maxReadsInFlight: number;
113
+ private readonly readDeadlineMs: number;
114
+
115
+ constructor(private readonly readCloses: ReadDailyCloses, private readonly options: PreviousCloseDatesOptions = {}) {
116
+ this.now = options.now ?? (() => Date.now());
117
+ this.maxReadsInFlight = options.maxReadsInFlight ?? MAX_READS_IN_FLIGHT;
118
+ this.readDeadlineMs = options.readDeadlineMs ?? READ_DEADLINE_MS;
119
+ }
120
+
121
+ /** The quote dated by what was already read for it; as it was when nothing was. Never reads. */
122
+ known<T extends Quote | null | undefined>(quote: T, listing: DatedCloseListing): T {
123
+ const key = readingKey(quote, listing, this.now());
124
+ return key ? withDate(quote, this.readings.get(key)?.date) : quote;
125
+ }
126
+
127
+ /**
128
+ * The quote dated by what was already read for it, starting the read when
129
+ * no reading for it stands. Never waits: the read dates the copies after
130
+ * it, and `onDated` the latest one.
131
+ */
132
+ observe<T extends Quote | null | undefined>(quote: T, listing: DatedCloseListing): T {
133
+ const key = readingKey(quote, listing, this.now());
134
+ if (!key) return quote;
135
+ const reading = this.readings.get(key);
136
+ if (!this.stands(reading)) void this.start(key, quote!, listing);
137
+ return withDate(quote, reading?.date);
138
+ }
139
+
140
+ /**
141
+ * Starts the reads these quotes need and waits for them, at most `waitMs`
142
+ * in all and no longer than `signal` lets it; `known` then dates them.
143
+ */
144
+ async settle(
145
+ entries: ReadonlyArray<{ quote: Quote; listing: DatedCloseListing }>,
146
+ { waitMs, signal }: { waitMs: number; signal?: AbortSignal },
147
+ ): Promise<void> {
148
+ if (signal?.aborted) return;
149
+ const reads: Promise<string | null>[] = [];
150
+ for (const { quote, listing } of entries) {
151
+ const key = readingKey(quote, listing, this.now());
152
+ if (key && !this.stands(this.readings.get(key))) reads.push(this.start(key, quote, listing));
153
+ }
154
+ if (reads.length) await settleWithin(abortable(Promise.all(reads), signal, "Stopped waiting for daily closes"), waitMs);
155
+ }
156
+
157
+ /** Whether a reading answers without another read: a day it named, or no day named lately. */
158
+ private stands(reading: Reading | undefined): boolean {
159
+ return !!reading && (!!reading.date || this.now() - reading.at < UNDATED_RETRY_MS);
160
+ }
161
+
162
+ private start(key: string, quote: Quote, listing: DatedCloseListing): Promise<string | null> {
163
+ this.latestCopies.set(key, { quote, listing });
164
+ return this.pending.get(key) ?? this.read(key, quote, listing);
165
+ }
166
+
167
+ private read(key: string, quote: Quote, listing: DatedCloseListing): Promise<string | null> {
168
+ const read = this.inSlot(() => withDeadline(Promise.resolve().then(() => this.readCloses(listing)), this.readDeadlineMs, "Reading daily closes timed out"))
169
+ .then((points) => latestCloseBefore(points, quote))
170
+ .catch(logFailure("market-data.dated-previous-close", "Read daily closes failed", null))
171
+ .then((date) => {
172
+ this.pending.delete(key);
173
+ this.readings.delete(key);
174
+ if (this.readings.size >= MAX_READINGS) this.readings.delete(this.readings.keys().next().value!);
175
+ this.readings.set(key, { date, at: this.now() });
176
+ const latest = this.latestCopies.get(key);
177
+ this.latestCopies.delete(key);
178
+ if (date && latest && this.options.onDated) {
179
+ try {
180
+ this.options.onDated(latest.listing, withDate(latest.quote, date));
181
+ } catch (error) {
182
+ logFailure("market-data.dated-previous-close", "Passing on a dated quote failed")(error);
183
+ }
184
+ }
185
+ return date;
186
+ });
187
+ this.pending.set(key, read);
188
+ return read;
189
+ }
190
+
191
+ private async inSlot<T>(work: () => Promise<T>): Promise<T> {
192
+ if (this.active < this.maxReadsInFlight) this.active += 1;
193
+ else await new Promise<void>((resolve) => this.waiting.push(resolve));
194
+ try {
195
+ return await work();
196
+ } finally {
197
+ // A finished read hands its slot to the next one waiting.
198
+ const next = this.waiting.shift();
199
+ if (next) next();
200
+ else this.active -= 1;
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,68 @@
1
+ import { canonicalExchange } from "../../utils/exchanges";
2
+ import { zonedDateTimeParts, zonedWallClockToUtcMs } from "../../utils/zoned-date-time";
3
+
4
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
5
+ const FRIDAY = 5;
6
+
7
+ /**
8
+ * CME Group's Globex week runs from Sunday 17:00 Central to Friday 16:00
9
+ * Central, 17:00 in New York, which ICE Futures U.S. keeps too
10
+ * (cmegroup.com/trading-hours, ice.com). Nothing trades between, whatever
11
+ * state a quote stamped before the Friday close still carries. The venues
12
+ * have no other calendar here: their daily breaks and holiday sessions vary
13
+ * by contract, so the quote's own state says those.
14
+ */
15
+ const WEEK_ZONE = "America/Chicago";
16
+ const WEEK_CLOSE_MINUTES = 16 * 60;
17
+ const WEEK_OPEN_MINUTES = 17 * 60;
18
+
19
+ /**
20
+ * The futures venues the weekly gap covers, and whether the week's first open
21
+ * is theirs to name: every Globex contract opens by Sunday 17:00 Central, but
22
+ * an ICE U.S. one opens when its own hours say, from Sunday evening (cotton)
23
+ * to Monday morning (coffee), so the venue has no one reopen to give.
24
+ */
25
+ const WEEK_VENUES: Readonly<Record<string, { reopens: boolean }>> = {
26
+ CME: { reopens: true },
27
+ CBT: { reopens: true },
28
+ NYM: { reopens: true },
29
+ CMX: { reopens: true },
30
+ NYB: { reopens: false },
31
+ };
32
+
33
+ export interface FuturesWeekendClosure {
34
+ /** The Friday close that began it. */
35
+ closedAt: number;
36
+ /** The Sunday open that ends it, when the venue's contracts all open then or later. */
37
+ reopensAt?: number;
38
+ /** That open's local date, `YYYY-MM-DD` (always a Sunday). */
39
+ reopensOn?: string;
40
+ }
41
+
42
+ /**
43
+ * The weekend gap of a futures venue's week that `time` falls in, from the
44
+ * Friday close to the Sunday open; null on any other instant, and for a venue
45
+ * that is not a futures exchange here. Only ever the closed side: a moment
46
+ * outside it is not known to be open.
47
+ */
48
+ export function futuresWeekendClosure(exchange: string | undefined, time: number): FuturesWeekendClosure | null {
49
+ const venue = WEEK_VENUES[canonicalExchange(exchange)];
50
+ if (!venue || !Number.isFinite(time)) return null;
51
+ const { year, month, day } = zonedDateTimeParts(time, WEEK_ZONE);
52
+ const today = Date.UTC(year, month - 1, day) / MS_PER_DAY;
53
+ const weekday = new Date(today * MS_PER_DAY).getUTCDay();
54
+ // The latest Friday on or before the local date: a Monday to Thursday reads last week's, whose gap has ended.
55
+ const friday = today - ((weekday - FRIDAY + 7) % 7);
56
+ const at = (days: number, minutes: number) => {
57
+ const date = new Date(days * MS_PER_DAY);
58
+ return zonedWallClockToUtcMs(WEEK_ZONE, date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(),
59
+ Math.floor(minutes / 60), minutes % 60, 0);
60
+ };
61
+ const closedAt = at(friday, WEEK_CLOSE_MINUTES);
62
+ const reopensAt = at(friday + 2, WEEK_OPEN_MINUTES);
63
+ if (time < closedAt || time >= reopensAt) return null;
64
+ return {
65
+ closedAt,
66
+ ...(venue.reopens ? { reopensAt, reopensOn: new Date((friday + 2) * MS_PER_DAY).toISOString().slice(0, 10) } : {}),
67
+ };
68
+ }
@@ -6,6 +6,7 @@ import { quoteTradingDay } from "../quotes/day-range";
6
6
  import { isQuoteBeforeLatestSession } from "../quotes/freshness";
7
7
  import { delistingLabel, delistingStateLabel } from "../quotes/delisting";
8
8
  import { preMarketOpenOn, regularSessionCloseOn } from "./freshness";
9
+ import { futuresWeekendClosure } from "./futures-week";
9
10
 
10
11
  const CLOSED_CHANGE_MUTING_RATIO = 0.55;
11
12
  const US_SESSION_TIME = new Intl.DateTimeFormat("en-US", {
@@ -97,6 +98,17 @@ function closeLabel(date: string): string {
97
98
  return `${WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]} close`;
98
99
  }
99
100
 
101
+ /**
102
+ * The market state a quote's venue is in now. A futures venue's weekend gap
103
+ * closes every contract, so it outranks the state a quote stamped before the
104
+ * Friday close (a stale print reading REGULAR on a Sunday); every other quote
105
+ * keeps the state its source reported.
106
+ */
107
+ function quoteMarketState(quote: Quote | null | undefined, now = Date.now()): MarketState | undefined {
108
+ if (!quote?.marketState) return undefined;
109
+ return futuresWeekendClosure(quote.listingExchangeName || quote.exchangeName, now) ? "CLOSED" : quote.marketState;
110
+ }
111
+
100
112
  /**
101
113
  * A quote's market state as a status cell names it (marketStateLabel), STALE
102
114
  * for a print older than its venue's latest session, and the close it stands
@@ -109,7 +121,7 @@ export function quoteMarketStateLabel(quote: Quote | null | undefined, now = Dat
109
121
  if (!quote?.marketState) return null;
110
122
  if (isQuoteBeforeLatestSession(quote, now)) return STALE_SESSION_LABEL;
111
123
  const awaiting = preMarketCloseAwaitingPrint(quote);
112
- return awaiting ? closeLabel(awaiting.date) : marketStateLabel(quote.marketState);
124
+ return awaiting ? closeLabel(awaiting.date) : marketStateLabel(quoteMarketState(quote, now)!);
113
125
  }
114
126
 
115
127
  /** An extended session's column, named as marketStateLabel names its state. */
@@ -216,7 +228,7 @@ function sessionLabel(quote: Quote | null | undefined, now: number, standingClos
216
228
  if (isQuoteBeforeLatestSession(quote, now)) return STALE_SESSION_LABEL;
217
229
  if (overnightSessionDisplay(quote)) return "OVERNIGHT";
218
230
  const awaiting = standingClose ? preMarketCloseAwaitingPrint(quote) : null;
219
- return awaiting ? closeLabel(awaiting.date) : marketStateLabel(quote.marketState);
231
+ return awaiting ? closeLabel(awaiting.date) : marketStateLabel(quoteMarketState(quote, now)!);
220
232
  }
221
233
 
222
234
  /**
@@ -230,7 +242,7 @@ export function quoteSessionColor(quote: Quote | null | undefined, palette: Them
230
242
  if (!quote?.marketState) return palette.textDim;
231
243
  if (isQuoteBeforeLatestSession(quote, now)) return palette.warning;
232
244
  if (overnightSessionDisplay(quote)) return marketStateColor("POST", palette);
233
- return marketStateColor(preMarketCloseAwaitingPrint(quote) ? "CLOSED" : quote.marketState, palette);
245
+ return marketStateColor(preMarketCloseAwaitingPrint(quote) ? "CLOSED" : quoteMarketState(quote, now)!, palette);
234
246
  }
235
247
 
236
248
  /**
@@ -239,7 +251,7 @@ export function quoteSessionColor(quote: Quote | null | undefined, palette: Them
239
251
  * with no known session rather than an open market.
240
252
  */
241
253
  export function quoteDotMarketState(quote: Quote | null | undefined, now = Date.now()): MarketState | undefined {
242
- return quote && !quote.delisting && !isQuoteBeforeLatestSession(quote, now) ? quote.marketState : undefined;
254
+ return quote && !quote.delisting && !isQuoteBeforeLatestSession(quote, now) ? quoteMarketState(quote, now) : undefined;
243
255
  }
244
256
 
245
257
  /** Closed prices are final snapshots, so they should not look live or directional. */
@@ -1,3 +1,5 @@
1
+ import { isRoundTheClockCoin } from "../utils/crypto-pair";
2
+ import { parsePublicTickerKey } from "../utils/exchanges";
1
3
  import { parseFuturesGeneric } from "../utils/futures-generic";
2
4
 
3
5
  /** A US-listed fund whose options track an instrument that has none of its own. */
@@ -67,3 +69,61 @@ export function isOptionsUnavailableError(error: unknown): boolean {
67
69
  export function optionsUnavailableTitle(symbol: string): string {
68
70
  return `Options are not available for ${symbol.trim().toUpperCase()}.`;
69
71
  }
72
+
73
+ /**
74
+ * The coins whose options Gloom Cloud serves, from Deribit, as BTC-USD and
75
+ * ETH-USD: the standard options path (options, OMON, OPX, OVDV).
76
+ */
77
+ const COIN_OPTION_UNDERLYINGS: Readonly<Record<string, string>> = { BTC: "BTC-USD", ETH: "ETH-USD" };
78
+
79
+ /** BTC-USD or ETH-USD for a coin pair whose options are listed (on the coin board or bare); null otherwise. */
80
+ export function listedCoinOptions(symbol: string): string | null {
81
+ const parsed = parsePublicTickerKey(symbol.trim().toUpperCase());
82
+ if (!isRoundTheClockCoin(parsed.symbol, parsed.exchange)) return null;
83
+ const pair = /^([A-Z0-9]+)-USD$/.exec(parsed.symbol)?.[1];
84
+ return pair ? COIN_OPTION_UNDERLYINGS[pair] ?? null : null;
85
+ }
86
+
87
+ /** A coin (SOL-USD, DOGE-USD) nothing lists options on: only BTC and ETH have them. */
88
+ export function isCoinWithoutOptions(symbol: string): boolean {
89
+ const parsed = parsePublicTickerKey(symbol.trim().toUpperCase());
90
+ return isRoundTheClockCoin(parsed.symbol, parsed.exchange) && listedCoinOptions(symbol) == null;
91
+ }
92
+
93
+ /**
94
+ * What exists instead for a coin without options, worded for the CLI:
95
+ * BTC and ETH options, and the bitcoin-linked US listings with their own,
96
+ * each as the command that reads it (`options`, or `fn OPX`).
97
+ */
98
+ export function coinOptionsAlternativesText(command = "options"): string {
99
+ const run = (symbol: string) => `gloomberb ${command} ${symbol}`;
100
+ return `Listed crypto options cover BTC and ETH: ${run("BTC-USD")}, ${run("ETH-USD")}.`
101
+ + ` Bitcoin-linked US listings have their own: ${run("IBIT")}.`;
102
+ }
103
+
104
+ /** The same for a pane, which has no commands to give. */
105
+ export const COIN_OPTIONS_HINT = "Listed crypto options cover BTC-USD and ETH-USD; bitcoin-linked US funds such as IBIT list their own.";
106
+
107
+ /**
108
+ * An options function's `refuseSymbol`: a coin nothing lists options on
109
+ * (SOL-USD) is answered before any request, with what exists instead.
110
+ */
111
+ export function coinWithoutOptionsRefusal(code: string) {
112
+ return (key: string): { message: string; details: string } | null => isCoinWithoutOptions(key)
113
+ ? { message: optionsUnavailableTitle(parsePublicTickerKey(key.trim().toUpperCase()).symbol), details: coinOptionsAlternativesText(`fn ${code}`) }
114
+ : null;
115
+ }
116
+
117
+ /**
118
+ * A CLI options function's `symbolAlias` (`gloomberb fn OPX BTC`, `shot`): a
119
+ * bare BTC or ETH is read as the coin, with a note naming the Grayscale fund's
120
+ * listing (BTC:ARCA) for its own options (`code` is the function, OPX). The
121
+ * command bar and panes keep a bare BTC as the fund, as everywhere else.
122
+ */
123
+ export function coinOptionsAlias(code: string) {
124
+ return (typed: string): { symbol: string; note: string } | null => {
125
+ const key = typed.trim().toUpperCase();
126
+ const pair = COIN_OPTION_UNDERLYINGS[key];
127
+ return pair ? { symbol: pair, note: `${key} read as ${pair}, the coin's options. For the US fund that trades as ${key}: gloomberb fn ${code} ${key}:ARCA` } : null;
128
+ };
129
+ }
@@ -20,7 +20,7 @@ const PRICE_DENOMINATED_DESCRIPTIVE_FIELDS: ReadonlySet<string> = new Set(["high
20
20
  * reconciled separately (reconcileQuoteDayRange).
21
21
  */
22
22
  const TRADING_DAY_FIELDS = [
23
- "changeSessionDate", "previousClose", "open", "volume", "mark", "bid", "ask", "bidSize", "askSize",
23
+ "changeSessionDate", "previousClose", "previousCloseDate", "open", "volume", "mark", "bid", "ask", "bidSize", "askSize",
24
24
  "lastTradePrice", "lastTradeTime",
25
25
  ] as const;
26
26
 
@@ -36,6 +36,12 @@ function isOtherTradingDay(current: Quote, next: Quote): boolean {
36
36
  return currentDay !== null && nextDay !== null && nextDay !== currentDay;
37
37
  }
38
38
 
39
+ /** Whether two observations are known to describe the same trading day. */
40
+ function isSameTradingDay(current: Quote, next: Quote): boolean {
41
+ const currentDay = quoteTradingDay(current);
42
+ return currentDay !== null && currentDay === quoteTradingDay(next);
43
+ }
44
+
39
45
  function inferQuoteProviderId(quote: Quote | QuoteContribution): string {
40
46
  if (quote.providerId?.trim()) return quote.providerId;
41
47
  return "quote";
@@ -195,7 +201,7 @@ export function mergeQuoteContribution(
195
201
  // Close provenance belongs to this observation; never borrow an anchor
196
202
  // from another session, listing or currency when a new quote omits it.
197
203
  ...(!samePriceBasis ? {
198
- previousClose: next.previousClose, bid: next.bid, ask: next.ask,
204
+ previousClose: next.previousClose, previousCloseDate: next.previousCloseDate, bid: next.bid, ask: next.ask,
199
205
  open: next.open, mark: next.mark, high52w: next.high52w, low52w: next.low52w,
200
206
  lastTradePrice: next.lastTradePrice, lastTradeTime: next.lastTradeTime,
201
207
  preMarketPrice: next.preMarketPrice, preMarketChange: next.preMarketChange,
@@ -232,6 +238,18 @@ export function mergeQuoteContribution(
232
238
  delete merged.changeBasis;
233
239
  } else if (next.previousClose === undefined) {
234
240
  delete merged.previousClose;
241
+ delete merged.previousCloseDate;
242
+ }
243
+ // The day a previous close is from is said of that close and that session: a tick that names
244
+ // none keeps it while both are the same, and drops it otherwise. A close can repeat across
245
+ // sessions (a flat day), so the trading day decides, not the close alone.
246
+ if (next.previousCloseDate === undefined) {
247
+ const sameClose = next.previousClose === undefined || next.previousClose === current.previousClose;
248
+ if (samePriceBasis && sameClose && merged.previousClose === current.previousClose && current.previousCloseDate && isSameTradingDay(current, next)) {
249
+ merged.previousCloseDate = current.previousCloseDate;
250
+ } else {
251
+ delete merged.previousCloseDate;
252
+ }
235
253
  }
236
254
 
237
255
  if (isOtherTradingDay(current, next)) {