@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
@@ -1,1628 +0,0 @@
1
- import { alignTimeSeries, effectiveTimeSeriesPointTime, scalarPointValue } from "./alignment";
2
- import { mergePriceHistoryIntegrity } from "../utils/price-history-integrity";
3
- import { resolveCurrencyUnit, US_CENTS } from "../utils/currency-units";
4
- import { isCurrencyCode } from "../utils/currency-pair";
5
- import { stablePriceFractionDigits } from "../market-data/market/format";
6
- import { isRealizedVolatilityEstimator, realizedVolatilityCadenceIssue, rollingRealizedVolatility } from "../market-data/realized-volatility";
7
- import { latestTradingSessionOpen } from "../market-data/market/trading-sessions";
8
- import { CHART_RESOLUTION_STEP_MS, isIntradayResolution, type ManualChartResolution } from "./resolution";
9
- import { anchoredVwap, averageTrueRange, sessionVwap, type StudyBar, type VwapValue } from "./trader-studies";
10
- import { zonedDateTimeParts } from "../utils/zoned-date-time";
11
- import type {
12
- ChartStudyKind,
13
- ChartStudySpec,
14
- ResolvedSeries,
15
- SeriesAxis,
16
- SeriesInterpolation,
17
- SeriesPeriod,
18
- SeriesStyle,
19
- TimeSeriesPoint,
20
- } from "./types";
21
- import { isFiniteNumber } from "../utils/guards";
22
- import { studyMessageName } from "./series-name";
23
- import { dailyLeverageGrowth, isLeverageFactor, leverageDecayPoints, leverageFactorLabel, leverageSymbol, sessionCloses, type StudyAnchor } from "./leverage";
24
- import {
25
- GALLONS_PER_BARREL,
26
- isBarrelsPerTonne,
27
- isPerBarrelUnit,
28
- isPerGallonUnit,
29
- perBarrelUnit,
30
- perTonneUnit,
31
- quantityIdentity,
32
- unitCurrencyToken,
33
- } from "./price-units";
34
-
35
- export interface StudyResolutionResult {
36
- series: ResolvedSeries[];
37
- warnings: string[];
38
- errors: string[];
39
- }
40
-
41
- interface NumericSample {
42
- point: TimeSeriesPoint;
43
- value: number;
44
- }
45
-
46
- export interface IndexedValue {
47
- index: number;
48
- value: number;
49
- }
50
-
51
- const STUDY_COLORS = ["#f6c85f", "#4dabf7", "#b197fc", "#63e6be", "#ffa94d", "#ff6b6b"];
52
- /**
53
- * Overlays that sit on the price keep colours of their own, never the blue the
54
- * first price series takes; the profile is grey so it stays behind the bars.
55
- */
56
- const KIND_COLORS: Partial<Record<ChartStudyKind, string>> = {
57
- vwap: "#ffa94d",
58
- "anchored-vwap": "#b197fc",
59
- "volume-profile": "#adb5bd",
60
- atr: "#63e6be",
61
- leverage: "#e599f7",
62
- };
63
- /** The decay study's two lines: the fund against the daily line, the daily line against the period multiple. */
64
- const DECAY_COLORS = ["#ffa94d", "#66d9e8"] as const;
65
- /**
66
- * One per anchor, so eight lines never repeat a colour. None is red or pink
67
- * (falling candles take the theme's negative), orange (VWAP), yellow (the
68
- * first moving average) or blue (the price).
69
- */
70
- const ANCHOR_COLORS = ["#b197fc", "#66d9e8", "#8ce99a", "#e599f7", "#9775fa", "#3bc9db", "#c0eb75", "#d0bfff"];
71
-
72
- function positiveInteger(value: unknown, fallback: number): number {
73
- return isFiniteNumber(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback;
74
- }
75
-
76
- function samplesFor(series: ResolvedSeries): NumericSample[] {
77
- return [...series.points]
78
- .sort((left, right) => left.date.getTime() - right.date.getTime())
79
- .flatMap((point) => {
80
- const value = scalarPointValue(point);
81
- return value === null ? [] : [{ point, value }];
82
- });
83
- }
84
-
85
- function derivedPoint(sample: NumericSample, value: number | null): TimeSeriesPoint {
86
- return {
87
- date: new Date(sample.point.date),
88
- observedAt: new Date(sample.point.observedAt),
89
- availableAt: sample.point.availableAt ? new Date(sample.point.availableAt) : undefined,
90
- value,
91
- periodLabel: sample.point.periodLabel,
92
- provenance: {
93
- providerId: sample.point.provenance?.providerId,
94
- quality: "derived",
95
- },
96
- };
97
- }
98
-
99
- function outputSeries(
100
- spec: ChartStudySpec,
101
- input: ResolvedSeries,
102
- options: {
103
- id?: string;
104
- label: string;
105
- points: TimeSeriesPoint[];
106
- color: string;
107
- unit?: string;
108
- unitGroup?: string;
109
- style?: SeriesStyle;
110
- interpolation?: SeriesInterpolation;
111
- axis?: Exclude<SeriesAxis, "auto">;
112
- nativeFrequency?: SeriesPeriod;
113
- historyResolution?: ManualChartResolution | null;
114
- /** The unit the legend states; by default the input's, while the output keeps its unit. */
115
- statedUnit?: string | null;
116
- /** The kind of price the output is, for its decimals; by default the input's, while the output keeps its unit. */
117
- priceAssetCategory?: string | null;
118
- /** The decimals a value worked out from prices prints at. */
119
- valueDecimals?: number;
120
- },
121
- ): ResolvedSeries {
122
- const historyResolution = options.historyResolution === undefined ? input.historyResolution : options.historyResolution;
123
- const sameUnit = (options.unit ?? input.unit) === input.unit && (options.unitGroup ?? input.unitGroup) === input.unitGroup;
124
- const statedUnit = options.statedUnit === undefined ? sameUnit ? input.statedUnit : undefined : options.statedUnit ?? undefined;
125
- return {
126
- id: options.id ?? spec.id,
127
- label: options.label,
128
- color: options.color,
129
- unit: options.unit ?? input.unit,
130
- unitGroup: options.unitGroup ?? input.unitGroup,
131
- ...(statedUnit ? { statedUnit } : {}),
132
- ...(options.valueDecimals !== undefined ? { valueDecimals: options.valueDecimals } : {}),
133
- priceAssetCategory: options.priceAssetCategory === undefined
134
- ? sameUnit ? input.priceAssetCategory : undefined
135
- : options.priceAssetCategory ?? undefined,
136
- nativeFrequency: options.nativeFrequency ?? input.nativeFrequency,
137
- ...(historyResolution !== undefined ? { historyResolution } : {}),
138
- ...(input.historyStart && historyResolution === input.historyResolution ? { historyStart: input.historyStart } : {}),
139
- ...(input.barSize && historyResolution === input.historyResolution ? { barSize: input.barSize } : {}),
140
- dataShape: "scalar",
141
- style: options.style ?? "line",
142
- transform: "raw",
143
- axis: spec.axis === "auto" ? options.axis ?? input.axis : spec.axis,
144
- panelId: spec.panelId,
145
- interpolation: options.interpolation ?? "none",
146
- timeBasis: historyResolution === null && input.timeBasis ? { ...input.timeBasis, cadenceMs: undefined } : input.timeBasis,
147
- observationKind: input.observationKind,
148
- points: options.points,
149
- };
150
- }
151
-
152
- /** Exported so backtest rules and chart studies share identical math. */
153
- export function sma(values: readonly number[], period: number): IndexedValue[] {
154
- if (values.length < period) return [];
155
- const result: IndexedValue[] = [];
156
- let sum = 0;
157
- for (let index = 0; index < values.length; index += 1) {
158
- sum += values[index]!;
159
- if (index >= period) sum -= values[index - period]!;
160
- if (index >= period - 1) result.push({ index, value: sum / period });
161
- }
162
- return result;
163
- }
164
-
165
- export function ema(values: readonly number[], period: number): IndexedValue[] {
166
- if (values.length < period) return [];
167
- const result: IndexedValue[] = [];
168
- let current = values.slice(0, period).reduce((sum, value) => sum + value, 0) / period;
169
- result.push({ index: period - 1, value: current });
170
- const multiplier = 2 / (period + 1);
171
- for (let index = period; index < values.length; index += 1) {
172
- current = values[index]! * multiplier + current * (1 - multiplier);
173
- result.push({ index, value: current });
174
- }
175
- return result;
176
- }
177
-
178
- export function rsi(values: readonly number[], period: number): IndexedValue[] {
179
- if (values.length < period + 1) return [];
180
- let averageGain = 0;
181
- let averageLoss = 0;
182
- for (let index = 1; index <= period; index += 1) {
183
- const change = values[index]! - values[index - 1]!;
184
- if (change > 0) averageGain += change;
185
- else averageLoss -= change;
186
- }
187
- averageGain /= period;
188
- averageLoss /= period;
189
- const result: IndexedValue[] = [{
190
- index: period,
191
- value: averageLoss === 0 ? 100 : 100 - 100 / (1 + averageGain / averageLoss),
192
- }];
193
- for (let index = period + 1; index < values.length; index += 1) {
194
- const change = values[index]! - values[index - 1]!;
195
- averageGain = (averageGain * (period - 1) + Math.max(change, 0)) / period;
196
- averageLoss = (averageLoss * (period - 1) + Math.max(-change, 0)) / period;
197
- result.push({
198
- index,
199
- value: averageLoss === 0 ? 100 : 100 - 100 / (1 + averageGain / averageLoss),
200
- });
201
- }
202
- return result;
203
- }
204
-
205
- function indexedPoints(samples: readonly NumericSample[], values: readonly IndexedValue[]): TimeSeriesPoint[] {
206
- return values.flatMap(({ index, value }) => {
207
- const sample = samples[index];
208
- return sample ? [derivedPoint(sample, value)] : [];
209
- });
210
- }
211
-
212
- function studyPeriod(spec: ChartStudySpec, fallback: number): number {
213
- return positiveInteger(spec.parameters.period, fallback);
214
- }
215
-
216
- function studyWarmupPoints(spec: ChartStudySpec): number {
217
- if (spec.kind === "realized-vol") {
218
- const window = positiveInteger(spec.parameters.window, 30);
219
- return spec.parameters.estimator === undefined || spec.parameters.estimator === "close-to-close" || spec.parameters.estimator === "yang-zhang"
220
- ? window : window - 1;
221
- }
222
- if (spec.kind === "sma" || spec.kind === "ema" || spec.kind === "bollinger") {
223
- return studyPeriod(spec, 20) - 1;
224
- }
225
- if (spec.kind === "rsi") return studyPeriod(spec, 14);
226
- if (spec.kind === "macd") {
227
- const slow = positiveInteger(spec.parameters.slow, 26);
228
- const signal = positiveInteger(spec.parameters.signal, 9);
229
- return slow + signal - 2;
230
- }
231
- if (spec.kind === "correlation") return studyPeriod(spec, 20);
232
- if (spec.kind === "atr") return studyPeriod(spec, 14) - 1;
233
- return 0;
234
- }
235
-
236
- export function maxStudyWarmupPoints(specs: readonly ChartStudySpec[]): number {
237
- return Math.max(0, ...specs.filter((spec) => spec.visible !== false).map(studyWarmupPoints));
238
- }
239
-
240
- function resolveRealizedVolatility(spec: ChartStudySpec, input: ResolvedSeries, color: string): ResolvedSeries[] {
241
- const window = positiveInteger(spec.parameters.window, 30);
242
- const estimator = isRealizedVolatilityEstimator(spec.parameters.estimator) ? spec.parameters.estimator : "close-to-close";
243
- // Retain invalid rows and the last correction for each date. Dropping one
244
- // would turn two separated returns into adjacent observations.
245
- const byDate = new Map(input.points.map((point) => [point.date.getTime(), point]));
246
- const source = [...byDate.values()].sort((a, b) => a.date.getTime() - b.date.getTime());
247
- const calculated = rollingRealizedVolatility(source.map((point) => ({
248
- date: point.date,
249
- close: point.provenance?.priceHistoryIntegrity || point.provenance?.valuationPriceIssues?.length
250
- ? Number.NaN : (point.close === undefined ? point.value : point.close) ?? Number.NaN,
251
- open: point.open ?? undefined,
252
- high: point.high ?? undefined,
253
- low: point.low ?? undefined,
254
- })), { windows: [window], estimator });
255
- const warmup = studyWarmupPoints(spec);
256
- const points = calculated.map((point, index): TimeSeriesPoint => {
257
- const original = source[index]!;
258
- const value = point.values[window];
259
- const derived = derivedPoint({ point: original, value: 0 }, value == null ? null : value * 100);
260
- if (value == null) {
261
- const affected = source.slice(Math.max(0, index - warmup), index + 1);
262
- const integrity = affected.flatMap((entry) => entry.provenance?.priceHistoryIntegrity ? [entry.provenance.priceHistoryIntegrity] : []);
263
- if (integrity.length) derived.provenance!.priceHistoryIntegrity = mergePriceHistoryIntegrity(...integrity);
264
- }
265
- return derived;
266
- });
267
- return [outputSeries(spec, input, {
268
- label: `RV(${window}, ${estimator}) ${input.label}`,
269
- points,
270
- color,
271
- unit: "%",
272
- unitGroup: "percent",
273
- axis: "left",
274
- })];
275
- }
276
-
277
- function resolveMovingAverage(
278
- spec: ChartStudySpec,
279
- input: ResolvedSeries,
280
- color: string,
281
- exponential: boolean,
282
- ): ResolvedSeries[] {
283
- const period = studyPeriod(spec, 20);
284
- const samples = samplesFor(input);
285
- const calculated = exponential
286
- ? ema(samples.map(({ value }) => value), period)
287
- : sma(samples.map(({ value }) => value), period);
288
- const name = exponential ? "EMA" : "SMA";
289
- return [outputSeries(spec, input, {
290
- label: `${name}(${period}) ${input.label}`,
291
- points: indexedPoints(samples, calculated),
292
- color,
293
- })];
294
- }
295
-
296
- function resolveBollinger(
297
- spec: ChartStudySpec,
298
- input: ResolvedSeries,
299
- color: string,
300
- ): ResolvedSeries[] {
301
- const period = studyPeriod(spec, 20);
302
- const deviations = isFiniteNumber(spec.parameters.stdDev) && spec.parameters.stdDev > 0
303
- ? spec.parameters.stdDev
304
- : 2;
305
- const samples = samplesFor(input);
306
- const values = samples.map(({ value }) => value);
307
- const middle = sma(values, period);
308
- const upper: IndexedValue[] = [];
309
- const lower: IndexedValue[] = [];
310
- for (const point of middle) {
311
- const window = values.slice(point.index - period + 1, point.index + 1);
312
- const variance = window.reduce((sum, value) => sum + (value - point.value) ** 2, 0) / period;
313
- const deviation = Math.sqrt(variance) * deviations;
314
- upper.push({ index: point.index, value: point.value + deviation });
315
- lower.push({ index: point.index, value: point.value - deviation });
316
- }
317
- const label = `Bollinger(${period},${deviations}) ${input.label}`;
318
- return [
319
- outputSeries(spec, input, {
320
- id: `${spec.id}:upper`,
321
- label: `${label} Upper`,
322
- points: indexedPoints(samples, upper),
323
- color,
324
- }),
325
- outputSeries(spec, input, {
326
- id: `${spec.id}:middle`,
327
- label: `${label} Middle`,
328
- points: indexedPoints(samples, middle),
329
- color,
330
- }),
331
- outputSeries(spec, input, {
332
- id: `${spec.id}:lower`,
333
- label: `${label} Lower`,
334
- points: indexedPoints(samples, lower),
335
- color,
336
- }),
337
- ];
338
- }
339
-
340
- function resolveRsi(spec: ChartStudySpec, input: ResolvedSeries, color: string): ResolvedSeries[] {
341
- const period = studyPeriod(spec, 14);
342
- const samples = samplesFor(input);
343
- return [outputSeries(spec, input, {
344
- label: `RSI(${period}) ${input.label}`,
345
- points: indexedPoints(samples, rsi(samples.map(({ value }) => value), period)),
346
- color,
347
- unit: "index",
348
- unitGroup: "oscillator-0-100",
349
- axis: "left",
350
- })];
351
- }
352
-
353
- function resolveMacd(spec: ChartStudySpec, input: ResolvedSeries, color: string): ResolvedSeries[] {
354
- const fastPeriod = positiveInteger(spec.parameters.fast, 12);
355
- const slowPeriod = positiveInteger(spec.parameters.slow, 26);
356
- const signalPeriod = positiveInteger(spec.parameters.signal, 9);
357
- if (fastPeriod >= slowPeriod) return [];
358
- const samples = samplesFor(input);
359
- const values = samples.map(({ value }) => value);
360
- const fast = new Map(ema(values, fastPeriod).map((point) => [point.index, point.value]));
361
- const macd = ema(values, slowPeriod).flatMap(({ index, value }) => {
362
- const fastValue = fast.get(index);
363
- return fastValue === undefined ? [] : [{ index, value: fastValue - value }];
364
- });
365
- const signalOnMacd = ema(macd.map(({ value }) => value), signalPeriod);
366
- const signal = signalOnMacd.flatMap(({ index, value }) => {
367
- const source = macd[index];
368
- return source ? [{ index: source.index, value }] : [];
369
- });
370
- const macdByIndex = new Map(macd.map((point) => [point.index, point.value]));
371
- const histogram = signal.map(({ index, value }) => ({ index, value: macdByIndex.get(index)! - value }));
372
- const label = `MACD(${fastPeriod},${slowPeriod},${signalPeriod}) ${input.label}`;
373
- return [
374
- outputSeries(spec, input, {
375
- id: `${spec.id}:macd`,
376
- label,
377
- points: indexedPoints(samples, macd),
378
- color,
379
- axis: "left",
380
- }),
381
- outputSeries(spec, input, {
382
- id: `${spec.id}:signal`,
383
- label: `${label} Signal`,
384
- points: indexedPoints(samples, signal),
385
- color: STUDY_COLORS[1]!,
386
- axis: "left",
387
- }),
388
- outputSeries(spec, input, {
389
- id: `${spec.id}:histogram`,
390
- label: `${label} Histogram`,
391
- points: indexedPoints(samples, histogram),
392
- color: STUDY_COLORS[4]!,
393
- style: "columns",
394
- axis: "left",
395
- }),
396
- ];
397
- }
398
-
399
- function resolveVolume(spec: ChartStudySpec, input: ResolvedSeries, color: string, nameInput: boolean): ResolvedSeries[] {
400
- // No bar has volume to plot (spot FX quotes 0 for every bar; a coin's window
401
- // can miss every bar's), so the study adds no panel rather than an empty one.
402
- if (input.points.length > 0 && !input.points.some((point) => isFiniteNumber(point.volume) && point.volume !== 0)) return [];
403
- // Left to its own colour, volume follows the candles: the price's colour for
404
- // a bar that rose, the falling colour for one that fell. A colour picked for it stays flat.
405
- const directional = !spec.color;
406
- const points = [...input.points]
407
- .sort((left, right) => left.date.getTime() - right.date.getTime())
408
- .flatMap((point) => isFiniteNumber(point.volume)
409
- ? [{
410
- ...derivedPoint({ point, value: point.volume }, point.volume),
411
- ...(directional && isFiniteNumber(point.open) && isFiniteNumber(point.close) && point.close < point.open ? { fell: true } : {}),
412
- }]
413
- : []);
414
- return [outputSeries(spec, input, {
415
- label: nameInput ? `Volume ${input.label}` : "Volume",
416
- points,
417
- color: directional ? input.color : color,
418
- unit: input.volumeUnit ?? "",
419
- unitGroup: "volume",
420
- style: "columns",
421
- axis: "left",
422
- })];
423
- }
424
-
425
- const DAY_MS = 86_400_000;
426
- /** Most anchors one anchored VWAP keeps; each draws its own line. */
427
- const MAX_VWAP_ANCHORS = 8;
428
- export const DEFAULT_PROFILE_ROWS = 24;
429
-
430
- /** Anchor times of an anchored VWAP, kept as `anchor1`, `anchor2`, ... so the spec stays numeric. */
431
- export function vwapAnchors(spec: Pick<ChartStudySpec, "parameters">): number[] {
432
- return [...new Set(Object.entries(spec.parameters)
433
- .filter(([key, value]) => /^anchor\d+$/.test(key) && isFiniteNumber(value))
434
- .map(([, value]) => value as number))]
435
- .sort((left, right) => left - right)
436
- .slice(0, MAX_VWAP_ANCHORS);
437
- }
438
-
439
- export function withVwapAnchors(
440
- parameters: ChartStudySpec["parameters"],
441
- anchors: readonly number[],
442
- ): ChartStudySpec["parameters"] {
443
- const kept = Object.fromEntries(Object.entries(parameters).filter(([key]) => !/^anchor\d+$/.test(key)));
444
- const sorted = [...new Set(anchors.filter(Number.isFinite))].sort((left, right) => left - right).slice(-MAX_VWAP_ANCHORS);
445
- return { ...kept, ...Object.fromEntries(sorted.map((anchor, index) => [`anchor${index + 1}`, anchor])) };
446
- }
447
-
448
- /** Bars with a range and volume; a close-only point stands for a bar with no range. */
449
- function studyBars(input: ResolvedSeries): Array<StudyBar & { sample: NumericSample }> {
450
- return samplesFor(input).flatMap((sample) => {
451
- const close = isFiniteNumber(sample.point.close) ? sample.point.close : sample.value;
452
- const high = isFiniteNumber(sample.point.high) ? sample.point.high : close;
453
- const low = isFiniteNumber(sample.point.low) ? sample.point.low : close;
454
- return [{
455
- time: sample.point.date.getTime(),
456
- high: Math.max(high, low, close),
457
- low: Math.min(high, low, close),
458
- close,
459
- volume: isFiniteNumber(sample.point.volume) ? sample.point.volume : 0,
460
- sample,
461
- }];
462
- });
463
- }
464
-
465
- function isIntradayInput(input: ResolvedSeries, marketResolution?: ManualChartResolution): boolean {
466
- const resolution = input.historyResolution === undefined ? marketResolution : input.historyResolution;
467
- if (resolution) return isIntradayResolution(resolution);
468
- const cadence = input.timeBasis?.cadenceMs;
469
- return cadence !== undefined && cadence < DAY_MS;
470
- }
471
-
472
- function localDay(time: number, timeZone: string | undefined): number {
473
- if (!timeZone) return Math.floor(time / DAY_MS);
474
- const { year, month, day } = zonedDateTimeParts(time, timeZone);
475
- return Date.UTC(year, month - 1, day) / DAY_MS;
476
- }
477
-
478
- /**
479
- * `step` at each of `times` (ascending) for a step function that never falls
480
- * as time moves on, such as a session's open or a local day. It is asked at
481
- * both ends of a run and a run whose ends agree takes that value throughout,
482
- * so a few calendar lookups per session cover every bar. The live chart
483
- * resolves on every quote, and a lookup per 1-minute bar took tens of
484
- * milliseconds over a week.
485
- */
486
- function risingSteps<T>(times: readonly number[], step: (time: number) => T): T[] {
487
- const values = new Array<T>(times.length);
488
- if (times.length === 0) return values;
489
- const last = times.length - 1;
490
- values[0] = step(times[0]!);
491
- values[last] = step(times[last]!);
492
- const fill = (from: number, to: number): void => {
493
- if (to - from < 2) return;
494
- if (values[from] === values[to]) {
495
- values.fill(values[from]!, from + 1, to);
496
- return;
497
- }
498
- const middle = (from + to) >> 1;
499
- values[middle] = step(times[middle]!);
500
- fill(from, middle);
501
- fill(middle, to);
502
- };
503
- fill(0, last);
504
- return values;
505
- }
506
-
507
- /** When the session holding `time` opened on the input's listing, or null for a venue without known hours. */
508
- function sessionOpenOf(input: ResolvedSeries): (time: number) => number | null {
509
- const symbol = input.listing?.symbol;
510
- const exchange = input.listing?.exchange || input.timeBasis?.exchange;
511
- return (time) => latestTradingSessionOpen(symbol, exchange, time);
512
- }
513
-
514
- /** The bar spacing: the requested cadence, else the history's resolution, else a minute. */
515
- function barStep(input: ResolvedSeries): number {
516
- return input.timeBasis?.cadenceMs
517
- ?? (input.historyResolution ? CHART_RESOLUTION_STEP_MS[input.historyResolution] : undefined)
518
- ?? 60_000;
519
- }
520
-
521
- /**
522
- * Each bar's session, named by the open it follows, so the sums restart at
523
- * the open and a bar before it continues the previous session: the regular
524
- * open for stocks, the venue's published session open for futures (17:00
525
- * Central on CME Globex). A venue without known hours starts over with each
526
- * local day. The first session is left out when the history begins after its
527
- * open, since its sums would be missing the bars before.
528
- */
529
- function regularSessionKeys(bars: readonly StudyBar[], input: ResolvedSeries): Array<number | null> {
530
- const step = barStep(input);
531
- const times = bars.map((bar) => bar.time);
532
- const opens = risingSteps(times, sessionOpenOf(input));
533
- const days = opens.includes(null) ? risingSteps(times, (time) => localDay(time, input.timeBasis?.timeZone)) : [];
534
- const keys = opens.map((open, index) => open ?? -1 - days[index]!);
535
- const first = keys[0];
536
- if (first !== undefined && first >= 0 && bars[0]!.time >= first + step) {
537
- return keys.map((key) => key === first ? null : key);
538
- }
539
- return keys;
540
- }
541
-
542
- function vwapOutputs(
543
- spec: ChartStudySpec,
544
- input: ResolvedSeries,
545
- bars: ReadonlyArray<{ sample: NumericSample }>,
546
- values: readonly VwapValue[],
547
- options: { id: string; label: string; color: string; bands: number; sessionOf?: (index: number) => number | null },
548
- ): ResolvedSeries[] {
549
- // A value opening a new session follows a missing one just before it, so
550
- // the line restarts there instead of joining the sessions with a diagonal.
551
- const at = (pick: (value: VwapValue) => number) => values.flatMap((value, position) => {
552
- const bar = bars[value.index];
553
- if (!bar) return [];
554
- const point = derivedPoint(bar.sample, pick(value));
555
- const previous = values[position - 1];
556
- if (!previous || !options.sessionOf || options.sessionOf(previous.index) === options.sessionOf(value.index)) return [point];
557
- const before = new Date(point.date.getTime() - 1);
558
- return [{ ...derivedPoint(bar.sample, null), date: before, observedAt: before }, point];
559
- });
560
- const outputs = [outputSeries(spec, input, {
561
- id: options.id,
562
- label: options.label,
563
- points: at((value) => value.value),
564
- color: options.color,
565
- })];
566
- if (options.bands > 0) {
567
- outputs.push(
568
- outputSeries(spec, input, {
569
- id: `${options.id}:upper`,
570
- label: `${options.label} +${options.bands}σ`,
571
- points: at((value) => value.value + value.deviation * options.bands),
572
- color: options.color,
573
- }),
574
- outputSeries(spec, input, {
575
- id: `${options.id}:lower`,
576
- label: `${options.label} -${options.bands}σ`,
577
- points: at((value) => value.value - value.deviation * options.bands),
578
- color: options.color,
579
- }),
580
- );
581
- }
582
- return outputs;
583
- }
584
-
585
- function hasVolume(bars: readonly StudyBar[]): boolean {
586
- return bars.some((bar) => bar.volume > 0);
587
- }
588
-
589
- function resolveVwap(
590
- spec: ChartStudySpec,
591
- input: ResolvedSeries,
592
- color: string,
593
- nameInput: boolean,
594
- marketResolution: ManualChartResolution | undefined,
595
- warnings: string[],
596
- ): ResolvedSeries[] {
597
- const label = nameInput ? `VWAP ${input.label}` : "VWAP";
598
- if (!isIntradayInput(input, marketResolution)) {
599
- warnings.push(`${label} needs intraday bars: choose 1D or 1W, or a minute timeframe.`);
600
- return [];
601
- }
602
- const bars = studyBars(input);
603
- if (!hasVolume(bars)) {
604
- warnings.push(`${label} needs traded volume, which ${input.label} does not report.`);
605
- return [];
606
- }
607
- const keys = regularSessionKeys(bars, input);
608
- const bands = Math.min(3, Math.max(0, Math.round(isFiniteNumber(spec.parameters.bands) ? spec.parameters.bands : 0)));
609
- return vwapOutputs(spec, input, bars, sessionVwap(bars, (_bar, index) => keys[index] ?? null), {
610
- id: spec.id,
611
- label,
612
- color,
613
- bands,
614
- sessionOf: (index) => keys[index] ?? null,
615
- });
616
- }
617
-
618
- const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
619
-
620
- /** `Sep 30 09:30` on intraday bars, in the exchange's time; `Sep 30 2025` on daily ones. */
621
- function formatAnchor(anchor: number, input: ResolvedSeries, intraday: boolean): string {
622
- const parts = zonedDateTimeParts(anchor, intraday ? input.timeBasis?.timeZone ?? "UTC" : "UTC");
623
- const date = `${MONTHS[parts.month - 1]} ${parts.day}`;
624
- return intraday
625
- ? `${date} ${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}`
626
- : `${date} ${parts.year}`;
627
- }
628
-
629
- /**
630
- * Whether the loaded bars reach back to an anchor: a bar at or before it, or
631
- * the first bar opening the same local day the anchor names.
632
- */
633
- function historyReaches(bars: readonly StudyBar[], anchor: number, input: ResolvedSeries): boolean {
634
- const first = bars[0];
635
- if (!first) return false;
636
- if (first.time <= anchor) return true;
637
- const timeZone = input.timeBasis?.timeZone;
638
- if (localDay(first.time, timeZone) !== localDay(anchor, timeZone)) return false;
639
- const open = sessionOpenOf(input)(first.time);
640
- return open === null || first.time < open + barStep(input);
641
- }
642
-
643
- function resolveAnchoredVwap(
644
- spec: ChartStudySpec,
645
- input: ResolvedSeries,
646
- color: string,
647
- marketResolution: ManualChartResolution | undefined,
648
- warnings: string[],
649
- ): ResolvedSeries[] {
650
- const anchors = vwapAnchors(spec);
651
- if (anchors.length === 0) return [];
652
- const bars = studyBars(input);
653
- if (!hasVolume(bars)) {
654
- warnings.push(`Anchored VWAP needs traded volume, which ${input.label} does not report.`);
655
- return [];
656
- }
657
- const intraday = isIntradayInput(input, marketResolution);
658
- const resolution = input.historyResolution ?? marketResolution;
659
- const barMs = input.timeBasis?.cadenceMs ?? (resolution ? CHART_RESOLUTION_STEP_MS[resolution] : 0);
660
- return anchors.flatMap((anchor, offset) => {
661
- const label = `AVWAP ${formatAnchor(anchor, input, intraday)}`;
662
- if (!historyReaches(bars, anchor, input)) {
663
- warnings.push(`${label} starts before the loaded history; choose a longer range.`);
664
- return [];
665
- }
666
- return vwapOutputs(spec, input, bars, anchoredVwap(bars, anchor, barMs), {
667
- id: `${spec.id}:${anchor}`,
668
- label,
669
- color: offset === 0 ? color : ANCHOR_COLORS[offset % ANCHOR_COLORS.length]!,
670
- bands: 0,
671
- });
672
- });
673
- }
674
-
675
- function resolveAtr(spec: ChartStudySpec, input: ResolvedSeries, color: string, nameInput: boolean): ResolvedSeries[] {
676
- const period = studyPeriod(spec, 14);
677
- const bars = studyBars(input);
678
- return [outputSeries(spec, input, {
679
- label: nameInput ? `ATR(${period}) ${input.label}` : `ATR(${period})`,
680
- points: averageTrueRange(bars, period).flatMap(({ index, value }) => {
681
- const bar = bars[index];
682
- return bar ? [derivedPoint(bar.sample, value)] : [];
683
- }),
684
- color,
685
- axis: "left",
686
- })];
687
- }
688
-
689
- /** The input's bars, passed through for the chart to profile over what is in view. */
690
- function resolveVolumeProfile(
691
- spec: ChartStudySpec,
692
- input: ResolvedSeries,
693
- color: string,
694
- warnings: string[],
695
- ): ResolvedSeries[] {
696
- if (input.transform !== "raw") {
697
- warnings.push("Volume profile needs prices; switch the price series back to its own values.");
698
- return [];
699
- }
700
- const bars = studyBars(input);
701
- if (!hasVolume(bars)) {
702
- warnings.push(`Volume profile needs traded volume, which ${input.label} does not report.`);
703
- return [];
704
- }
705
- const rows = Math.min(100, Math.max(4, Math.round(isFiniteNumber(spec.parameters.rows) ? spec.parameters.rows : DEFAULT_PROFILE_ROWS)));
706
- return [{
707
- ...outputSeries(spec, input, {
708
- label: "VP POC",
709
- points: bars.map(({ sample }) => ({
710
- ...derivedPoint(sample, sample.value),
711
- open: sample.point.open,
712
- high: sample.point.high,
713
- low: sample.point.low,
714
- close: sample.point.close,
715
- volume: sample.point.volume,
716
- })),
717
- color,
718
- }),
719
- profile: { rows },
720
- }];
721
- }
722
-
723
- interface PairedSample {
724
- point: TimeSeriesPoint;
725
- left: number;
726
- right: number;
727
- }
728
-
729
- function seriesCurrency(series: ResolvedSeries): string | null {
730
- const grouped = series.unitGroup.match(/:([A-Z]{3})$/i)?.[1];
731
- if (grouped) return grouped.toUpperCase();
732
- if (!/^(?:price|currency-total|per-share)$/.test(series.unitGroup)) return null;
733
- return series.unit.match(/^([A-Z]{3})(?:$|\/)/i)?.[1]?.toUpperCase() ?? null;
734
- }
735
-
736
- function unitFactorIdentity(value: string): string {
737
- // "US cents", as futures terms word a grain's quote, is the USX the quote names.
738
- const token = unitCurrencyToken(value) ?? value.trim();
739
- // Three letters are a currency unless they spell a quantity: "bbl" is a barrel.
740
- if (/^[A-Za-z]{3}$/.test(token) && quantityIdentity(token) === token.toLowerCase()) {
741
- const { currency, divisor } = resolveCurrencyUnit(token);
742
- return `${currency}:${divisor}`;
743
- }
744
- return quantityIdentity(token);
745
- }
746
-
747
- /** Whether a unit's factor is money (USD, GBp, US cents) rather than a quantity it buys. */
748
- function isCurrencyFactor(value: string): boolean {
749
- const currency = /^([A-Z]{3}):\d+$/.exec(unitFactorIdentity(value))?.[1];
750
- return !!currency && (currency === US_CENTS || isCurrencyCode(currency));
751
- }
752
-
753
- function knownUnitSignature(series: Pick<ResolvedSeries, "unit" | "unitGroup">): string | null {
754
- const unit = series.unit.trim();
755
- if (!unit || /(?:^|\W)(?:currency|unknown|unspecified|n\/a)(?:$|\W)/i.test(unit)
756
- || /^(?:units?|[-?]+)$/i.test(unit)) return null;
757
- const parts = unit.split("/");
758
- if (parts.some((part) => !part.trim() || /^[-?]+$/.test(part.trim()))) return null;
759
-
760
- const monetary = series.unitGroup.match(/^(price|currency-total|per-share)(?::([^:]+))?$/);
761
- // A futures contract writes its unit spaced ("USD / gallon"); a catalog series does not ("USD/gallon").
762
- const currency = unitCurrencyToken(parts[0]!);
763
- if (monetary && (!currency || (monetary[2] && unitFactorIdentity(monetary[2]) !== unitFactorIdentity(currency)))) return null;
764
-
765
- // Currency alone does not establish a price's physical/share basis. A scalar
766
- // multiplier cannot establish it either. Explicit USD/share price and EPS
767
- // units, however, match despite different groups.
768
- if (monetary?.[1] === "price" && !unit.includes("/")) return null;
769
- const basis = monetary && !unit.includes("/") ? monetary[1] : "";
770
- // Keep currency scale and compound order: GBp/GBX are pence, not pounds;
771
- // USD/EUR cannot subtract EUR/USD.
772
- return `${basis ?? ""}|${parts.map(unitFactorIdentity).join("/")}`;
773
- }
774
-
775
- /** A leg as an error names it: its ticker (HO=F), else its label. */
776
- function legName(series: ResolvedSeries): string {
777
- return series.listing?.symbol || series.label;
778
- }
779
-
780
- /** What a leg's unit is, in words: "is USD / gallon", "is in %", "is in USD per an unknown quantity". */
781
- function unitPhrase(series: ResolvedSeries, unit: string): string {
782
- const text = unit.trim();
783
- // A rate reads as itself (HO=F is USD / gallon); an amount or a scale is what a leg is in (REVENUE is in USD).
784
- if (knownUnitSignature({ unit: text, unitGroup: series.unitGroup }) !== null) return text.includes("/") ? `is ${text}` : `is in ${text}`;
785
- if (unitCurrencyToken(text) && /^price(?::|$)/.test(series.unitGroup)) return `is in ${text} per an unknown quantity`;
786
- return "has no known unit";
787
- }
788
-
789
- function unitCurrency(series: ResolvedSeries, unit: string): string | null {
790
- const token = unitCurrencyToken(unit.split("/")[0] ?? "");
791
- return token ? unitFactorIdentity(token) : seriesCurrency(series);
792
- }
793
-
794
- /**
795
- * Why two legs cannot be added or subtracted, naming each one's unit and the
796
- * fix: a per-gallon leg beside a per-barrel one is 42 gallons short of it.
797
- */
798
- function unitMismatchMessage(
799
- kind: "spread" | "formula",
800
- left: { series: ResolvedSeries; unit: string },
801
- right: { series: ResolvedSeries; unit: string },
802
- ): string {
803
- // Two fields of one listing are told apart by their labels.
804
- const [leftName, rightName] = legName(left.series) === legName(right.series)
805
- ? [left.series.label, right.series.label]
806
- : [legName(left.series), legName(right.series)];
807
- const named = `${leftName} ${unitPhrase(left.series, left.unit)} and ${rightName} ${unitPhrase(right.series, right.unit)}`;
808
- const leftCurrency = unitCurrency(left.series, left.unit);
809
- const rightCurrency = unitCurrency(right.series, right.unit);
810
- const gallons = isPerGallonUnit(left.unit) && isPerBarrelUnit(right.unit) ? leftName
811
- : isPerBarrelUnit(left.unit) && isPerGallonUnit(right.unit) ? rightName : null;
812
- if (gallons && leftCurrency === rightCurrency) {
813
- return `${named}: write ${GALLONS_PER_BARREL}*${gallons} to compare per barrel.`;
814
- }
815
- const subject = kind === "spread" ? "a spread" : "a formula";
816
- if (leftCurrency && rightCurrency && leftCurrency !== rightCurrency) return `${named}: ${subject} does not convert currencies.`;
817
- const known = knownUnitSignature({ unit: left.unit, unitGroup: left.series.unitGroup }) !== null
818
- && knownUnitSignature({ unit: right.unit, unitGroup: right.series.unitGroup }) !== null;
819
- return `${named}: ${subject} needs ${kind === "spread" ? "both" : "every leg"} in one${known ? "" : " known"} unit.`;
820
- }
821
-
822
- function unitFactors(unit: string): { numerator: string[]; denominator: string[] } {
823
- const [numerator = "", ...denominator] = unit.split("/");
824
- const factor = (value: string) => value.split("·").flatMap((part) => {
825
- const normalized = part.trim();
826
- if (!normalized || normalized === "1") return [];
827
- return [normalized.toLowerCase() === "shares" ? "share" : normalized];
828
- });
829
- return {
830
- numerator: factor(numerator),
831
- denominator: denominator.flatMap(factor),
832
- };
833
- }
834
-
835
- interface SeriesUnit {
836
- unit: string;
837
- unitGroup: string;
838
- }
839
-
840
- const UNKNOWN_UNIT: SeriesUnit = { unit: "unknown", unitGroup: "derived-unit:unknown" };
841
-
842
- function isPriceGroup(unitGroup: string): boolean {
843
- return /^price(?::|$)/.test(unitGroup);
844
- }
845
-
846
- /**
847
- * The unit of `left` times (`power` 1) or over (-1) `right`, the factors they
848
- * share cancelling: USD / barrel over an FX rate's USD / EUR is EUR / barrel.
849
- * Units are spaced as either side writes them ("USD / gallon"). A price over
850
- * or times a price that leaves one currency per one quantity is a price in
851
- * that currency; anything else is a derived unit, or a plain ratio.
852
- */
853
- function combinedUnit(left: SeriesUnit, right: SeriesUnit, power: 1 | -1): SeriesUnit {
854
- if (!knownUnitSignature(left) || !knownUnitSignature(right)) return UNKNOWN_UNIT;
855
- const leftFactors = unitFactors(left.unit);
856
- const rightFactors = unitFactors(right.unit);
857
- const numerator = [...leftFactors.numerator, ...(power < 0 ? rightFactors.denominator : rightFactors.numerator)];
858
- const denominator = [...leftFactors.denominator, ...(power < 0 ? rightFactors.numerator : rightFactors.denominator)];
859
- for (let index = numerator.length - 1; index >= 0; index -= 1) {
860
- const match = denominator.findIndex((factor) => (
861
- unitFactorIdentity(factor) === unitFactorIdentity(numerator[index]!)
862
- ));
863
- if (match < 0) continue;
864
- numerator.splice(index, 1);
865
- denominator.splice(match, 1);
866
- }
867
- const slash = [left.unit, right.unit].some((unit) => /\s\/\s/.test(unit)) ? " / " : "/";
868
- const unit = denominator.length === 0
869
- ? numerator.join("·") || "x"
870
- : `${numerator.join("·") || "1"}${slash}${denominator.join("·")}`;
871
- if (isPriceGroup(left.unitGroup) && isPriceGroup(right.unitGroup) && numerator.length === 1 && isCurrencyFactor(numerator[0]!)
872
- && (denominator.length === 0 || (denominator.length === 1 && !isCurrencyFactor(denominator[0]!)))) {
873
- return { unit, unitGroup: `price:${unitCurrencyToken(numerator[0]!) ?? numerator[0]!}` };
874
- }
875
- const groupFactor = (value: string) => {
876
- const identity = unitFactorIdentity(value);
877
- return (/^[A-Za-z]{3}$/.test(value.trim()) ? identity.replace(/:1$/, "") : identity).toLowerCase();
878
- };
879
- const groupNumerator = numerator.map(groupFactor).join("·");
880
- const groupDenominator = denominator.map(groupFactor).join("·");
881
- const group = groupDenominator ? `${groupNumerator || "1"}/${groupDenominator}` : groupNumerator;
882
- return {
883
- unit,
884
- unitGroup: unit === "x" ? "ratio" : `derived-unit:${group}`,
885
- };
886
- }
887
-
888
- function ratioUnit(left: SeriesUnit, right: SeriesUnit): SeriesUnit {
889
- return combinedUnit(left, right, -1);
890
- }
891
-
892
- /** An FX rate's unit: one currency per another, as USD / EUR. */
893
- function isRateUnit(unit: string): boolean {
894
- const { numerator, denominator } = unitFactors(unit);
895
- return numerator.length === 1 && denominator.length === 1 && isCurrencyFactor(numerator[0]!) && isCurrencyFactor(denominator[0]!);
896
- }
897
-
898
- /**
899
- * Why a price times (`power` 1) or over (-1) an FX rate is no price, with how
900
- * to convert it instead: a crack in USD / barrel over USDJPY=X (JPY / USD) is
901
- * USD·USD / barrel·JPY, and times it JPY / barrel. A ratio, which cannot
902
- * multiply, names the formula to write (`priceName` its numerator). Null when
903
- * the rate converts the price, or either is not what this asks about.
904
- */
905
- function fxConversionIssue(price: SeriesUnit, rate: ResolvedSeries, power: 1 | -1, result: SeriesUnit, priceName?: string): string | null {
906
- if (!isPriceGroup(price.unitGroup) || !isPriceGroup(rate.unitGroup) || !isRateUnit(rate.unit) || isPriceGroup(result.unitGroup)) {
907
- return null;
908
- }
909
- const name = legName(rate);
910
- const done = `${power < 0 ? "Dividing" : "Multiplying"} by ${name} (${rate.unit}) leaves ${result.unit}`;
911
- const other = combinedUnit(price, rate, power < 0 ? 1 : -1);
912
- if (isPriceGroup(other.unitGroup)) {
913
- const fix = priceName ? `write (${priceName})${power < 0 ? "*" : "/"}${name}` : `${power < 0 ? "multiply" : "divide"} by ${name}`;
914
- return `${done}; ${fix} instead to get ${other.unit}.`;
915
- }
916
- const currency = price.unitGroup.slice("price:".length) || "its currency";
917
- return `${done}; ${price.unit} converts only with a rate that has ${currency} on one side.`;
918
- }
919
-
920
- /** The distinct currencies a unit still counts, once its factors cancel. */
921
- function unitCurrencies(unit: string): Set<string> {
922
- const { numerator, denominator } = unitFactors(unit);
923
- return new Set([...numerator, ...denominator].filter(isCurrencyFactor).map((factor) => unitFactorIdentity(factor).split(":")[0]!));
924
- }
925
-
926
- /**
927
- * The decimals a value worked out from prices prints at: the most its legs
928
- * print, from two to four, and no more than a value of its own size needs:
929
- * four under 10, two from 10. A crack of 4.9842 USD / gallon heating oil and
930
- * 116.72 USD / barrel Brent reads 92.62; a gasoline-heating oil spread reads
931
- * 1.7726. Undefined when a leg is not a price or the value has none yet.
932
- */
933
- function derivedValueDecimals(inputs: readonly ResolvedSeries[], points: readonly TimeSeriesPoint[]): number | undefined {
934
- let digits = 0;
935
- for (const input of inputs) {
936
- if (!isPriceGroup(input.unitGroup) || input.transform !== "raw") return undefined;
937
- const reference = latestScalarValue(input.points);
938
- if (reference === undefined) return undefined;
939
- digits = Math.max(digits, stablePriceFractionDigits({
940
- assetCategory: input.priceAssetCategory,
941
- currency: unitCurrencyToken(input.unit.split("/")[0] ?? "") ?? undefined,
942
- referencePrice: reference,
943
- sessionPrices: [reference],
944
- }));
945
- }
946
- const size = latestScalarValue(points);
947
- if (size === undefined) return undefined;
948
- return Math.min(size < 10 ? 4 : 2, Math.max(2, digits));
949
- }
950
-
951
- /** The size of a series' last nonzero value, which its decimals are read from. */
952
- function latestScalarValue(points: readonly TimeSeriesPoint[]): number | undefined {
953
- for (let index = points.length - 1; index >= 0; index -= 1) {
954
- const value = scalarPointValue(points[index]!);
955
- if (value !== null && Number.isFinite(value) && value !== 0) return Math.abs(value);
956
- }
957
- return undefined;
958
- }
959
-
960
- function pairedSamples(left: ResolvedSeries, right: ResolvedSeries, carryForward: boolean): PairedSample[] {
961
- return alignTimeSeries([left, right], {
962
- mode: "intersection",
963
- // Ratios/spreads compare latest known levels; correlations need actual
964
- // shared observations before calculating returns. Carrying a closed market
965
- // invents zero returns and mismatches both sides of a weekend/holiday move.
966
- // Both modes still respect the publication time of each observation.
967
- carryForward,
968
- }).flatMap((row) => {
969
- const leftValue = row.values[left.id];
970
- const rightValue = row.values[right.id];
971
- if (!isFiniteNumber(leftValue?.value) || !isFiniteNumber(rightValue?.value)) return [];
972
- const availability = Math.max(
973
- leftValue.point.availableAt?.getTime() ?? leftValue.point.date.getTime(),
974
- rightValue.point.availableAt?.getTime() ?? rightValue.point.date.getTime(),
975
- );
976
- return [{
977
- left: leftValue.value,
978
- right: rightValue.value,
979
- point: {
980
- date: new Date(row.date),
981
- observedAt: new Date(row.date),
982
- availableAt: Number.isFinite(availability) ? new Date(availability) : undefined,
983
- value: null,
984
- provenance: { quality: "derived" as const },
985
- },
986
- }];
987
- });
988
- }
989
-
990
- function pairedFrequency(left: ResolvedSeries, right: ResolvedSeries): SeriesPeriod {
991
- return left.nativeFrequency === right.nativeFrequency ? left.nativeFrequency : "auto";
992
- }
993
-
994
- function pairedHistoryResolution(left: ResolvedSeries, right: ResolvedSeries): ManualChartResolution | null | undefined {
995
- return left.historyResolution === right.historyResolution ? left.historyResolution : null;
996
- }
997
-
998
- function resolvePairStudy(
999
- spec: ChartStudySpec,
1000
- left: ResolvedSeries,
1001
- right: ResolvedSeries,
1002
- color: string,
1003
- ): ResolvedSeries[] {
1004
- const paired = pairedSamples(left, right, spec.kind !== "correlation");
1005
- if (spec.kind === "ratio" || spec.kind === "spread") {
1006
- const multiplier = isFiniteNumber(spec.parameters.multiplier) ? spec.parameters.multiplier : 1;
1007
- const points = paired.map((sample) => derivedPoint(
1008
- { point: sample.point, value: sample.left },
1009
- spec.kind === "ratio"
1010
- ? sample.right === 0 ? null : sample.left / sample.right
1011
- : sample.left - sample.right * multiplier,
1012
- ));
1013
- const ratioStudy = spec.kind === "ratio";
1014
- const outputUnit = ratioStudy
1015
- ? ratioUnit(left, right)
1016
- : { unit: left.unit, unitGroup: left.unitGroup };
1017
- // A price over an FX rate is a price in the other currency (EUR / barrel),
1018
- // stated as a future's unit is; a plain ratio keeps its own decimals.
1019
- const convertedPrice = ratioStudy && isPriceGroup(outputUnit.unitGroup);
1020
- const valueDecimals = !ratioStudy || convertedPrice ? derivedValueDecimals([left, right], points) : undefined;
1021
- return [outputSeries(spec, left, {
1022
- ...(convertedPrice ? { statedUnit: outputUnit.unit, priceAssetCategory: null } : {}),
1023
- ...(valueDecimals !== undefined ? { valueDecimals } : {}),
1024
- label: ratioStudy
1025
- ? `${left.label} / ${right.label}`
1026
- : `${left.label} - ${multiplier === 1 ? "" : `${multiplier}×`}${right.label}`,
1027
- points,
1028
- color,
1029
- unit: outputUnit.unit,
1030
- unitGroup: outputUnit.unitGroup,
1031
- // Pair formulas are calculated with as-of carry on the union of both
1032
- // inputs' event dates. Their value is therefore piecewise constant until
1033
- // either input changes, even when an input is displayed as columns.
1034
- style: "step",
1035
- interpolation: "step-after",
1036
- axis: "left",
1037
- nativeFrequency: pairedFrequency(left, right),
1038
- historyResolution: pairedHistoryResolution(left, right),
1039
- })];
1040
- }
1041
-
1042
- const period = studyPeriod(spec, 20);
1043
- const useReturns = spec.parameters.returns !== 0;
1044
- const values = useReturns
1045
- ? paired.slice(1).flatMap((sample, index) => {
1046
- const previous = paired[index]!;
1047
- if (previous.left === 0 || previous.right === 0) return [];
1048
- return [{
1049
- point: sample.point,
1050
- left: (sample.left - previous.left) / Math.abs(previous.left),
1051
- right: (sample.right - previous.right) / Math.abs(previous.right),
1052
- }];
1053
- })
1054
- : paired;
1055
- const points: TimeSeriesPoint[] = [];
1056
- for (let index = period - 1; index < values.length; index += 1) {
1057
- const window = values.slice(index - period + 1, index + 1);
1058
- const leftMean = window.reduce((sum, item) => sum + item.left, 0) / period;
1059
- const rightMean = window.reduce((sum, item) => sum + item.right, 0) / period;
1060
- let covariance = 0;
1061
- let leftVariance = 0;
1062
- let rightVariance = 0;
1063
- for (const item of window) {
1064
- const leftDelta = item.left - leftMean;
1065
- const rightDelta = item.right - rightMean;
1066
- covariance += leftDelta * rightDelta;
1067
- leftVariance += leftDelta ** 2;
1068
- rightVariance += rightDelta ** 2;
1069
- }
1070
- const denominator = Math.sqrt(leftVariance * rightVariance);
1071
- const value = denominator === 0 ? null : Math.max(-1, Math.min(1, covariance / denominator));
1072
- points.push(derivedPoint({ point: values[index]!.point, value: values[index]!.left }, value));
1073
- }
1074
- return [outputSeries(spec, left, {
1075
- label: `Correlation(${period}) ${left.label} / ${right.label}`,
1076
- points,
1077
- color,
1078
- unit: "correlation",
1079
- unitGroup: "correlation",
1080
- axis: "left",
1081
- nativeFrequency: pairedFrequency(left, right),
1082
- historyResolution: pairedHistoryResolution(left, right),
1083
- })];
1084
- }
1085
-
1086
- /** Formulas of levels, which carry each input's latest value to the other inputs' dates. */
1087
- /** The study's own colour, or the next after it no series or formula on the chart has taken. */
1088
- function freeStudyColor(index: number, used: ReadonlySet<string>): string {
1089
- for (let offset = 0; offset < STUDY_COLORS.length; offset += 1) {
1090
- const color = STUDY_COLORS[(index + offset) % STUDY_COLORS.length]!;
1091
- if (!used.has(color.toLowerCase())) return color;
1092
- }
1093
- return STUDY_COLORS[index % STUDY_COLORS.length]!;
1094
- }
1095
-
1096
- function isLevelFormula(kind: ChartStudyKind): boolean {
1097
- return kind === "ratio" || kind === "spread" || kind === "formula";
1098
- }
1099
-
1100
- interface FormulaLeg {
1101
- /** The input's place in the study's inputs. */
1102
- index: number;
1103
- /** Its multiplier, its sign included. */
1104
- weight: number;
1105
- /** The multiplier carries the 42 gallons that make a per-gallon price per barrel. */
1106
- perBarrel: boolean;
1107
- /** The barrels in a tonne the multiplier carries (7.45 for gasoil), making a per-barrel price per tonne. */
1108
- perTonne: number | null;
1109
- }
1110
-
1111
- interface FormulaTerms {
1112
- /** The inputs the formula adds up. */
1113
- legs: FormulaLeg[];
1114
- /** The inputs the whole sum is multiplied (1) or divided (-1) by: `/EURUSD=X`. */
1115
- factors: Array<{ index: number; power: 1 | -1 }>;
1116
- divisor: number;
1117
- }
1118
-
1119
- /**
1120
- * A formula study's terms: `weight1`, `perBarrel1`, `perTonne1` ... per
1121
- * input it adds up, `power1` ... (1 or -1) per input it multiplies or divides
1122
- * the sum by, and an optional `divisor`. A saved or hand-edited spec can carry
1123
- * a multiplier or divisor no formula was written with (a value that is not a
1124
- * number is dropped when the spec loads, so it arrives missing); that is an
1125
- * error, not a silent 1.
1126
- */
1127
- function formulaTerms(spec: ChartStudySpec, inputs: readonly ResolvedSeries[]): FormulaTerms | { error: string } {
1128
- const legs: FormulaLeg[] = [];
1129
- const factors: FormulaTerms["factors"] = [];
1130
- for (const [index, input] of inputs.entries()) {
1131
- const power = spec.parameters[`power${index + 1}`];
1132
- if (power !== undefined) {
1133
- if (power !== 1 && power !== -1) return { error: `The formula neither multiplies nor divides by ${legName(input)}; write the formula again.` };
1134
- factors.push({ index, power });
1135
- continue;
1136
- }
1137
- const weight = spec.parameters[`weight${index + 1}`];
1138
- if (!isFiniteNumber(weight)) return { error: `The multiplier of ${legName(input)} is missing or not a number; write the formula again.` };
1139
- if (weight === 0) return { error: `${legName(input)} has a multiplier of 0, which removes it; leave it out of the formula instead.` };
1140
- const perTonne = spec.parameters[`perTonne${index + 1}`];
1141
- legs.push({
1142
- index,
1143
- weight,
1144
- perBarrel: spec.parameters[`perBarrel${index + 1}`] === 1,
1145
- perTonne: isFiniteNumber(perTonne) && isBarrelsPerTonne(perTonne) ? perTonne : null,
1146
- });
1147
- }
1148
- if (legs.length === 0) return { error: "The formula has no series to add up, only series it divides by; write the formula again." };
1149
- const divisor = spec.parameters.divisor ?? 1;
1150
- if (!isFiniteNumber(divisor)) return { error: "The formula's divisor is not a number; write the formula again." };
1151
- if (divisor === 0) return { error: "A formula cannot be divided by 0." };
1152
- return { legs, factors, divisor };
1153
- }
1154
-
1155
- /**
1156
- * A leg's unit once its multiplier is applied: 42 gallons of a per-gallon
1157
- * price are a barrel's worth, and 7.45 barrels of gasoil a tonne's.
1158
- */
1159
- function formulaLegUnit(leg: FormulaLeg, input: ResolvedSeries): string {
1160
- const unit = leg.perBarrel ? perBarrelUnit(input.unit) : input.unit;
1161
- return leg.perTonne ? perTonneUnit(unit) : unit;
1162
- }
1163
-
1164
- /** Why a formula's legs cannot be summed, or null when every leg counts the same known unit. */
1165
- function formulaUnitMismatch(terms: FormulaTerms, inputs: readonly ResolvedSeries[]): string | null {
1166
- if (terms.legs.length < 2) return null;
1167
- const legs = terms.legs.map((leg) => ({ series: inputs[leg.index]!, unit: formulaLegUnit(leg, inputs[leg.index]!) }));
1168
- const signatures = legs.map(({ series, unit }) => knownUnitSignature({ unit, unitGroup: series.unitGroup }));
1169
- const other = signatures.findIndex((signature, index) => index > 0 && (signature === null || signature !== signatures[0]));
1170
- if (signatures[0] !== null && other < 0) return null;
1171
- return unitMismatchMessage("formula", legs[0]!, legs[Math.max(1, other)]!);
1172
- }
1173
-
1174
- function formatFormulaNumber(value: number): string {
1175
- return String(Number(value.toPrecision(10)));
1176
- }
1177
-
1178
- /**
1179
- * A formula as the legend names it, from the terms it was written with:
1180
- * `42×HO=F - CL=F`, `(2×42×RB=F + 42×HO=F - 3×CL=F)/3`, `(42×HO=F - BZ=F)/EURUSD=X`.
1181
- */
1182
- function formulaLabel(inputs: readonly ResolvedSeries[], terms: FormulaTerms): string {
1183
- const sum = terms.legs.map((leg, position) => {
1184
- const magnitude = Math.abs(leg.weight);
1185
- // The conversions it carries are named as written: 7.45×42×HO=F.
1186
- const conversions = [...(leg.perTonne ? [leg.perTonne] : []), ...(leg.perBarrel ? [GALLONS_PER_BARREL] : [])];
1187
- const count = conversions.reduce((rest, factor) => rest / factor, magnitude);
1188
- const factors = [...(Math.abs(count - 1) < 1e-9 ? [] : [formatFormulaNumber(count)]), ...conversions.map(String)];
1189
- const term = [...factors, legName(inputs[leg.index]!)].join("×");
1190
- if (position === 0) return leg.weight < 0 ? `-${term}` : term;
1191
- return `${leg.weight < 0 ? "-" : "+"} ${term}`;
1192
- }).join(" ");
1193
- if (terms.divisor === 1 && terms.factors.length === 0) return sum;
1194
- return [
1195
- `(${sum})`,
1196
- ...(terms.divisor === 1 ? [] : [`/${formatFormulaNumber(terms.divisor)}`]),
1197
- ...terms.factors.map(({ index, power }) => `${power < 0 ? "/" : "×"}${legName(inputs[index]!)}`),
1198
- ].join("");
1199
- }
1200
-
1201
- /** Every input's value on each date they share, each carried forward to the others' dates. */
1202
- function alignedSamples(inputs: readonly ResolvedSeries[]): Array<{ point: TimeSeriesPoint; values: number[] }> {
1203
- return alignTimeSeries(inputs, { mode: "intersection", carryForward: true }).flatMap((row) => {
1204
- const aligned = inputs.map((input) => row.values[input.id]);
1205
- if (aligned.some((entry) => !isFiniteNumber(entry?.value))) return [];
1206
- const availability = Math.max(...aligned.map((entry) => entry!.point.availableAt?.getTime() ?? entry!.point.date.getTime()));
1207
- return [{
1208
- values: aligned.map((entry) => entry!.value as number),
1209
- point: {
1210
- date: new Date(row.date),
1211
- observedAt: new Date(row.date),
1212
- availableAt: Number.isFinite(availability) ? new Date(availability) : undefined,
1213
- value: null,
1214
- provenance: { quality: "derived" as const },
1215
- },
1216
- }];
1217
- });
1218
- }
1219
-
1220
- /**
1221
- * A weighted sum of the legs over a divisor, in the unit the legs share once
1222
- * converted, then times or over the series it is scaled by, in the unit
1223
- * that arithmetic leaves: a crack in USD / barrel over EURUSD=X is EUR / barrel.
1224
- */
1225
- /**
1226
- * A formula's unit: its legs' once converted, then times or over each series
1227
- * it is scaled by. Legs that count different units, or an FX rate the wrong
1228
- * way up, are an error that says why: a crack over USDJPY=X is no price, and
1229
- * times it JPY / barrel.
1230
- */
1231
- function formulaUnit(terms: FormulaTerms, inputs: readonly ResolvedSeries[]): SeriesUnit | { error: string } {
1232
- const mismatch = formulaUnitMismatch(terms, inputs);
1233
- if (mismatch) return { error: mismatch };
1234
- const first = inputs[terms.legs[0]!.index]!;
1235
- let unit: SeriesUnit = { unit: formulaLegUnit(terms.legs[0]!, first), unitGroup: first.unitGroup };
1236
- for (const factor of terms.factors) {
1237
- const series = inputs[factor.index]!;
1238
- const next = combinedUnit(unit, series, factor.power);
1239
- const issue = fxConversionIssue(unit, series, factor.power, next);
1240
- if (issue) return { error: issue };
1241
- unit = next;
1242
- }
1243
- return unit;
1244
- }
1245
-
1246
- function resolveFormula(
1247
- spec: ChartStudySpec,
1248
- inputs: readonly ResolvedSeries[],
1249
- terms: FormulaTerms,
1250
- unit: SeriesUnit,
1251
- color: string,
1252
- ): ResolvedSeries[] {
1253
- const first = inputs[terms.legs[0]!.index]!;
1254
- // Rounded off the float's last digits: 42 × 4.6233 - 96.12 is 98.0586, not 98.05860000000001.
1255
- const points = alignedSamples(inputs).map(({ point, values }) => {
1256
- let value: number | null = terms.legs.reduce((sum, leg) => sum + values[leg.index]! * leg.weight, 0) / terms.divisor;
1257
- for (const factor of terms.factors) {
1258
- const scale = values[factor.index]!;
1259
- value = value === null || (factor.power < 0 && scale === 0) ? null : factor.power < 0 ? value / scale : value * scale;
1260
- }
1261
- return derivedPoint({ point, value: 0 }, value === null ? null : Number(value.toPrecision(12)));
1262
- });
1263
- const frequencies = new Set(inputs.map((input) => input.nativeFrequency));
1264
- const resolutions = new Set(inputs.map((input) => input.historyResolution));
1265
- const scaled = terms.factors.length > 0;
1266
- // A price per quantity reads with its unit beside it, as a future's does.
1267
- const statedUnit = scaled
1268
- ? isPriceGroup(unit.unitGroup) && unit.unit.includes("/") ? unit.unit : null
1269
- : inputs.some((input) => input.statedUnit) ? unit.unit : null;
1270
- const valueDecimals = !scaled || isPriceGroup(unit.unitGroup) ? derivedValueDecimals(inputs, points) : undefined;
1271
- return [outputSeries(spec, first, {
1272
- label: formulaLabel(inputs, terms),
1273
- points,
1274
- color,
1275
- unit: unit.unit,
1276
- unitGroup: unit.unitGroup,
1277
- statedUnit,
1278
- // A weighted sum is no traded price: its decimals are its legs', not a contract's tick.
1279
- priceAssetCategory: null,
1280
- ...(valueDecimals !== undefined ? { valueDecimals } : {}),
1281
- // As the pair formulas: as-of levels, constant until an input changes.
1282
- style: "step",
1283
- interpolation: "step-after",
1284
- axis: "left",
1285
- nativeFrequency: frequencies.size === 1 ? first.nativeFrequency : "auto",
1286
- historyResolution: resolutions.size === 1 ? first.historyResolution : null,
1287
- })];
1288
- }
1289
-
1290
- function requiredInputs(kind: ChartStudyKind): number {
1291
- return kind === "ratio" || kind === "spread" || kind === "correlation" || kind === "leverage-decay" ? 2 : 1;
1292
- }
1293
-
1294
- /** The objective's line and its decay compound session returns: they need daily bars, whatever the chart's range. */
1295
- function dailyBarsIssue(spec: ChartStudySpec, input: ResolvedSeries, marketResolution: ManualChartResolution | undefined): string | null {
1296
- const resolution = input.historyResolution === undefined ? marketResolution : input.historyResolution;
1297
- if (input.nativeFrequency === "daily" && (resolution === undefined || resolution === "1d")) return null;
1298
- return `${studyMessageName(spec)}: ${leverageFactorLabel(Number(spec.parameters.factor))} daily needs daily bars. Choose D bars.`;
1299
- }
1300
-
1301
- /**
1302
- * The line a leveraged or inverse fund's daily objective draws from its index
1303
- * fund, in percent from where the chart compares (3× daily QQQ beside TQQQ),
1304
- * on the percent axis the compared legs share.
1305
- */
1306
- function resolveLeverageLine(spec: ChartStudySpec, index: ResolvedSeries, color: string, anchor: StudyAnchor | undefined): ResolvedSeries[] {
1307
- const factor = spec.parameters.factor as number;
1308
- const closes = sessionCloses(index, anchor);
1309
- const growth = dailyLeverageGrowth(closes.map((entry) => entry.close), factor);
1310
- return [outputSeries(spec, index, {
1311
- label: `${leverageFactorLabel(factor)} daily ${leverageSymbol(index)}`,
1312
- points: closes.map((entry, position) => derivedPoint({ point: entry.point, value: entry.close }, (growth[position]! - 1) * 100)),
1313
- color,
1314
- unit: "%",
1315
- unitGroup: "percent",
1316
- statedUnit: null,
1317
- priceAssetCategory: null,
1318
- axis: "left",
1319
- })];
1320
- }
1321
-
1322
- /**
1323
- * How far the fund falls short of its daily objective, in percentage points
1324
- * from where the chart compares: the fund against the daily line (fees,
1325
- * financing, tracking), and the daily line against the factor times the index
1326
- * fund's return over the period (compounding, which daily resets cost in a
1327
- * market that swings).
1328
- */
1329
- function resolveLeverageDecay(
1330
- spec: ChartStudySpec,
1331
- fund: ResolvedSeries,
1332
- index: ResolvedSeries,
1333
- anchors: ReadonlyMap<string, StudyAnchor> | undefined,
1334
- ): ResolvedSeries[] {
1335
- const factor = spec.parameters.factor as number;
1336
- const decay = leverageDecayPoints(sessionCloses(fund, anchors?.get(fund.id)), sessionCloses(index, anchors?.get(index.id)), factor);
1337
- const daily = `${leverageFactorLabel(factor)} daily`;
1338
- const line = (id: string, label: string, color: string, value: (point: (typeof decay)[number]) => number) => outputSeries(spec, fund, {
1339
- id: `${spec.id}:${id}`,
1340
- label,
1341
- points: decay.map((entry) => derivedPoint({ point: entry.point, value: entry.fund }, value(entry))),
1342
- color: spec.color ?? color,
1343
- unit: "pp",
1344
- // Not a percent group: a difference of returns reads in points (-0.96 pp), not as a return.
1345
- unitGroup: "pp",
1346
- statedUnit: null,
1347
- priceAssetCategory: null,
1348
- axis: "left",
1349
- });
1350
- return [
1351
- line("fund", `${leverageSymbol(fund)} decay vs ${daily} ${leverageSymbol(index)}`, DECAY_COLORS[0], (entry) => entry.fund - entry.daily),
1352
- // `3× QQQ`: three times QQQ's return over the period, the multiple a daily reset does not deliver.
1353
- line("compounding", `${daily} ${leverageSymbol(index)} decay vs ${leverageFactorLabel(factor)} ${leverageSymbol(index)}`, DECAY_COLORS[1], (entry) => entry.daily - entry.period),
1354
- ];
1355
- }
1356
-
1357
- /**
1358
- * A contradictory observation interrupts the input history. Running studies on
1359
- * each continuous segment keeps finite windows from skipping the missing bar
1360
- * and makes recursive indicators warm up again from valid observations.
1361
- */
1362
- function resolveInterruptedStudy(
1363
- inputs: readonly ResolvedSeries[],
1364
- spec: ChartStudySpec,
1365
- nameVolumeInput: boolean,
1366
- ): StudyResolutionResult | null {
1367
- const gaps = new Map<number, TimeSeriesPoint>();
1368
- for (const input of inputs) {
1369
- for (const point of input.points) {
1370
- const integrity = point.provenance?.priceHistoryIntegrity;
1371
- const priceIssues = point.provenance?.valuationPriceIssues;
1372
- if (!integrity && !priceIssues?.length) continue;
1373
- const timestamp = effectiveTimeSeriesPointTime(point);
1374
- const previous = gaps.get(timestamp)?.provenance;
1375
- gaps.set(timestamp, {
1376
- date: new Date(timestamp), observedAt: new Date(timestamp), value: null,
1377
- provenance: {
1378
- quality: "derived",
1379
- ...(integrity || previous?.priceHistoryIntegrity ? { priceHistoryIntegrity: previous?.priceHistoryIntegrity && integrity
1380
- ? mergePriceHistoryIntegrity(previous.priceHistoryIntegrity, integrity) : integrity ?? previous?.priceHistoryIntegrity } : {}),
1381
- ...(priceIssues?.length || previous?.valuationPriceIssues?.length
1382
- ? { valuationPriceIssues: [...(previous?.valuationPriceIssues ?? []), ...(priceIssues ?? [])] } : {}),
1383
- },
1384
- });
1385
- }
1386
- }
1387
- if (!gaps.size) return null;
1388
- const times = [...gaps.keys()].sort((left, right) => left - right);
1389
- const outputs = new Map<string, ResolvedSeries>();
1390
- const warnings = new Set<string>();
1391
- const errors = new Set<string>();
1392
- const cursors = inputs.map((input) => ({
1393
- input,
1394
- points: [...input.points].sort((left, right) => effectiveTimeSeriesPointTime(left) - effectiveTimeSeriesPointTime(right)),
1395
- index: 0,
1396
- previous: undefined as TimeSeriesPoint | undefined,
1397
- }));
1398
- let start = Number.NEGATIVE_INFINITY;
1399
- for (const end of [...times, Number.POSITIVE_INFINITY]) {
1400
- const segmentInputs = cursors.map((cursor) => {
1401
- while (cursor.index < cursor.points.length && effectiveTimeSeriesPointTime(cursor.points[cursor.index]!) <= start) {
1402
- cursor.previous = cursor.points[cursor.index++];
1403
- }
1404
- const previous = cursor.previous;
1405
- const points: TimeSeriesPoint[] = [];
1406
- while (cursor.index < cursor.points.length && effectiveTimeSeriesPointTime(cursor.points[cursor.index]!) < end) {
1407
- const point = cursor.points[cursor.index++]!;
1408
- points.push(point);
1409
- cursor.previous = point;
1410
- }
1411
- // A valid unaffected peer remains usable for as-of ratio/spread levels.
1412
- // Correlations instead restart their shared observations and returns.
1413
- if (isLevelFormula(spec.kind)) {
1414
- if (previous && !previous.provenance?.priceHistoryIntegrity && !previous.provenance?.valuationPriceIssues?.length) points.unshift(previous);
1415
- }
1416
- return { ...cursor.input, points };
1417
- });
1418
- const segment = resolveStudySpecs(segmentInputs, [spec], nameVolumeInput);
1419
- for (const output of segment.series) {
1420
- const points = output.points.filter((point) => effectiveTimeSeriesPointTime(point) > start);
1421
- const gap = gaps.get(start);
1422
- if (gap && !isLevelFormula(spec.kind) && spec.kind !== "volume") {
1423
- // A gap just outside the visible window still invalidates the next
1424
- // warmup observations. Keep those nulls and their original diagnostic
1425
- // explicit so clipping cannot hide why a study is unavailable.
1426
- const firstComputed = points[0] ? effectiveTimeSeriesPointTime(points[0]) : Number.POSITIVE_INFINITY;
1427
- points.unshift(...segmentInputs[0]!.points.filter((point) => effectiveTimeSeriesPointTime(point) < firstComputed).map((point) => ({
1428
- date: new Date(point.date), observedAt: new Date(point.observedAt),
1429
- availableAt: point.availableAt ? new Date(point.availableAt) : undefined,
1430
- value: null, provenance: gap.provenance,
1431
- })));
1432
- }
1433
- const previous = outputs.get(output.id);
1434
- outputs.set(output.id, { ...output, points: [...(previous?.points ?? []), ...points] });
1435
- }
1436
- for (const warning of segment.warnings) {
1437
- if (!warning.includes("not enough valid history")) warnings.add(warning);
1438
- }
1439
- for (const error of segment.errors) errors.add(error);
1440
- start = end;
1441
- }
1442
- for (const output of outputs.values()) {
1443
- output.points.push(...gaps.values());
1444
- output.points.sort((left, right) => left.date.getTime() - right.date.getTime());
1445
- }
1446
- return { series: [...outputs.values()], warnings: [...warnings], errors: [...errors] };
1447
- }
1448
-
1449
- /** Base series that must be calculated for currently visible studies. */
1450
- export function activeStudyInputSeriesIds(
1451
- studySpecs: readonly ChartStudySpec[],
1452
- ): Set<string> {
1453
- return new Set(studySpecs
1454
- .filter((spec) => spec.visible !== false)
1455
- .flatMap((spec) => spec.inputSeriesIds));
1456
- }
1457
-
1458
- /**
1459
- * `anchors`: where each input series is measured from and to, for studies
1460
- * that read returns over the chart's period (a leveraged fund's daily line and
1461
- * its decay start where a comparison starts its legs). Without one, a series'
1462
- * own first and last observations.
1463
- */
1464
- export function resolveStudies(
1465
- baseSeries: readonly ResolvedSeries[],
1466
- studySpecs: readonly ChartStudySpec[],
1467
- marketResolution?: ManualChartResolution,
1468
- historicalPriceSeries?: ReadonlyMap<string, ResolvedSeries>,
1469
- anchors?: ReadonlyMap<string, StudyAnchor>,
1470
- ): StudyResolutionResult {
1471
- // With one security on the chart its volume is just "Volume"; beside a
1472
- // comparison the legend names whose volume it is.
1473
- const nameVolumeInput = baseSeries.filter((series) => series.observationKind === "market").length > 1;
1474
- return resolveStudySpecs(baseSeries, studySpecs, nameVolumeInput, marketResolution, historicalPriceSeries, anchors);
1475
- }
1476
-
1477
- function resolveStudySpecs(
1478
- baseSeries: readonly ResolvedSeries[],
1479
- studySpecs: readonly ChartStudySpec[],
1480
- nameVolumeInput: boolean,
1481
- marketResolution?: ManualChartResolution,
1482
- historicalPriceSeries?: ReadonlyMap<string, ResolvedSeries>,
1483
- anchors?: ReadonlyMap<string, StudyAnchor>,
1484
- ): StudyResolutionResult {
1485
- const byId = new Map(baseSeries.map((series) => [series.id, series]));
1486
- // A formula never takes a colour a series on the chart has: a crack in euros is not drawn in its EURUSD leg's yellow.
1487
- const usedColors = new Set(baseSeries.map((series) => series.color.toLowerCase()));
1488
- const resolved: ResolvedSeries[] = [];
1489
- const warnings: string[] = [];
1490
- const errors: string[] = [];
1491
-
1492
- studySpecs.forEach((spec, index) => {
1493
- if (spec.visible === false) return;
1494
- const required = spec.kind === "formula" ? Math.max(1, spec.inputSeriesIds.length) : requiredInputs(spec.kind);
1495
- const inputs = spec.inputSeriesIds.map((id) => spec.kind === "realized-vol"
1496
- ? historicalPriceSeries?.get(id) ?? byId.get(id) : byId.get(id));
1497
- if (inputs.length !== required || inputs.some((input) => !input)) {
1498
- errors.push(`${studyMessageName(spec)}: ${spec.kind} requires ${required} valid input series.`);
1499
- return;
1500
- }
1501
- const input = inputs[0]!;
1502
- if (spec.kind === "spread") {
1503
- const pairedInput = inputs[1]!;
1504
- const inputUnit = knownUnitSignature(input);
1505
- if (inputUnit === null || inputUnit !== knownUnitSignature(pairedInput)) {
1506
- errors.push(`${studyMessageName(spec)}: ${unitMismatchMessage("spread", { series: input, unit: input.unit }, { series: pairedInput, unit: pairedInput.unit })}`);
1507
- return;
1508
- }
1509
- }
1510
- let formula: { terms: FormulaTerms; unit: SeriesUnit } | null = null;
1511
- if (spec.kind === "formula") {
1512
- const terms = formulaTerms(spec, inputs as ResolvedSeries[]);
1513
- const unit = "error" in terms ? terms : formulaUnit(terms, inputs as ResolvedSeries[]);
1514
- if ("error" in unit) {
1515
- errors.push(`${studyMessageName(spec)}: ${unit.error}`);
1516
- return;
1517
- }
1518
- formula = { terms: terms as FormulaTerms, unit };
1519
- }
1520
- const color = spec.color ?? KIND_COLORS[spec.kind]
1521
- ?? (isLevelFormula(spec.kind) ? freeStudyColor(index, usedColors) : STUDY_COLORS[index % STUDY_COLORS.length]!);
1522
- if (isLevelFormula(spec.kind)) usedColors.add(color.toLowerCase());
1523
- if (spec.kind === "leverage" || spec.kind === "leverage-decay") {
1524
- const index = inputs.at(-1)!;
1525
- if (!isLeverageFactor(spec.parameters.factor)) {
1526
- errors.push(`${studyMessageName(spec)}: choose a nonzero leverage factor of at most 5.`);
1527
- return;
1528
- }
1529
- const issue = inputs.map((entry) => dailyBarsIssue(spec, entry!, marketResolution)).find(Boolean);
1530
- if (issue) {
1531
- warnings.push(issue);
1532
- return;
1533
- }
1534
- const outputs = spec.kind === "leverage"
1535
- ? resolveLeverageLine(spec, index, color, anchors?.get(index.id))
1536
- : resolveLeverageDecay(spec, input, index, anchors);
1537
- if (outputs.every((output) => output.points.length < 2)) {
1538
- warnings.push(`${studyMessageName(spec)}: not enough shared daily closes to measure ${leverageFactorLabel(spec.parameters.factor as number)} daily ${leverageSymbol(index)}.`);
1539
- }
1540
- resolved.push(...outputs);
1541
- return;
1542
- }
1543
- if (spec.kind === "realized-vol") {
1544
- const window = spec.parameters.window ?? 30;
1545
- if (typeof window !== "number" || !Number.isInteger(window) || window < 2
1546
- || (spec.parameters.estimator !== undefined && !isRealizedVolatilityEstimator(spec.parameters.estimator))) {
1547
- errors.push(`${studyMessageName(spec)}: choose a supported volatility estimator and a whole window of at least two sessions.`);
1548
- return;
1549
- }
1550
- const inputResolution = input.historyResolution === undefined ? marketResolution : input.historyResolution;
1551
- if (input.nativeFrequency !== "daily"
1552
- || (inputResolution !== undefined && inputResolution !== "1d")
1553
- || (input.timeBasis?.cadenceMs !== undefined && input.timeBasis.cadenceMs !== 86_400_000)
1554
- || !(input.unitGroup === "price" || input.unitGroup.startsWith("price:"))) {
1555
- errors.push(`${studyMessageName(spec)}: realized volatility requires daily prices. Choose Auto or 1D resolution and a daily price source.`);
1556
- return;
1557
- }
1558
- const cadenceIssue = realizedVolatilityCadenceIssue(input.points);
1559
- if (cadenceIssue) {
1560
- errors.push(`${studyMessageName(spec)}: ${cadenceIssue}`);
1561
- return;
1562
- }
1563
- const outputs = resolveRealizedVolatility(spec, input, color);
1564
- if (outputs.every((output) => output.points.every((point) => point.value === null))) {
1565
- warnings.push(`${studyMessageName(spec)}: not enough valid daily history to calculate ${spec.parameters.estimator ?? "close-to-close"} realized volatility.`);
1566
- }
1567
- resolved.push(...outputs);
1568
- return;
1569
- }
1570
- const interrupted = resolveInterruptedStudy(inputs as ResolvedSeries[], { ...spec, color }, nameVolumeInput);
1571
- if (interrupted) {
1572
- resolved.push(...interrupted.series);
1573
- warnings.push(...interrupted.warnings);
1574
- errors.push(...interrupted.errors);
1575
- return;
1576
- }
1577
- let outputs: ResolvedSeries[] = [];
1578
- if (spec.kind === "sma") outputs = resolveMovingAverage(spec, input, color, false);
1579
- else if (spec.kind === "ema") outputs = resolveMovingAverage(spec, input, color, true);
1580
- else if (spec.kind === "bollinger") outputs = resolveBollinger(spec, input, color);
1581
- else if (spec.kind === "rsi") outputs = resolveRsi(spec, input, color);
1582
- else if (spec.kind === "macd") outputs = resolveMacd(spec, input, color);
1583
- else if (spec.kind === "atr") outputs = resolveAtr(spec, input, color, nameVolumeInput);
1584
- else if (spec.kind === "vwap" || spec.kind === "anchored-vwap" || spec.kind === "volume-profile") {
1585
- outputs = spec.kind === "vwap"
1586
- ? resolveVwap(spec, input, color, nameVolumeInput, marketResolution, warnings)
1587
- : spec.kind === "anchored-vwap"
1588
- ? resolveAnchoredVwap(spec, input, color, marketResolution, warnings)
1589
- : resolveVolumeProfile(spec, input, color, warnings);
1590
- resolved.push(...outputs);
1591
- return;
1592
- }
1593
- else if (spec.kind === "formula" && formula) outputs = resolveFormula(spec, inputs as ResolvedSeries[], formula.terms, formula.unit, color);
1594
- else if (spec.kind === "volume") {
1595
- outputs = resolveVolume(spec, input, color, nameVolumeInput);
1596
- if (!input.volumeUnit && outputs.some((output) => output.points.length > 0)) {
1597
- warnings.push(`Volume unit unknown: ${input.label}.`);
1598
- }
1599
- }
1600
- else {
1601
- const pairedInput = inputs[1]!;
1602
- const inputCurrency = seriesCurrency(input);
1603
- const pairedCurrency = seriesCurrency(pairedInput);
1604
- const differentCurrencies = inputCurrency !== null
1605
- && pairedCurrency !== null
1606
- && inputCurrency !== pairedCurrency;
1607
- // Over an FX rate a price is converted, leaving one currency: EUR / barrel.
1608
- const ratio = spec.kind === "ratio" ? ratioUnit(input, pairedInput) : null;
1609
- if (ratio && differentCurrencies && unitCurrencies(ratio.unit).size !== 1) {
1610
- const issue = fxConversionIssue(input, pairedInput, -1, ratio, legName(input));
1611
- warnings.push(`${studyMessageName(spec)}: ${issue
1612
- ?? `${spec.kind} inputs use different currencies (${inputCurrency} and ${pairedCurrency}); raw values are not FX-converted.`}`);
1613
- }
1614
- if (spec.kind === "correlation" && input.nativeFrequency !== pairedInput.nativeFrequency) {
1615
- warnings.push(`${studyMessageName(spec)}: correlation mixes ${input.nativeFrequency} and ${pairedInput.nativeFrequency} observations; only matching observation times contribute.`);
1616
- }
1617
- outputs = resolvePairStudy(spec, input, pairedInput, color);
1618
- }
1619
- if (
1620
- spec.kind !== "volume"
1621
- && (outputs.length === 0 || outputs.every((output) => output.points.length === 0))
1622
- ) {
1623
- warnings.push(`${studyMessageName(spec)}: not enough valid history to calculate ${spec.kind}.`);
1624
- }
1625
- resolved.push(...outputs);
1626
- });
1627
- return { series: resolved, warnings, errors };
1628
- }