gloomberb 0.18.4 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +32 -1
  5. package/src/account/auth-model.ts +7 -3
  6. package/src/api-client/auth.ts +9 -4
  7. package/src/api-client/catalysts.ts +2 -1
  8. package/src/api-client/data.ts +14 -0
  9. package/src/api-client/index.ts +160 -11
  10. package/src/api-client/live-scan.ts +91 -0
  11. package/src/api-client/members.ts +12 -0
  12. package/src/api-client/request.ts +222 -41
  13. package/src/api-client/research-activity.ts +3 -10
  14. package/src/api-client/types.ts +6 -0
  15. package/src/app/keybindings/index.ts +3 -0
  16. package/src/app/keybindings/labels.ts +18 -1
  17. package/src/app/keybindings/resolve.ts +14 -0
  18. package/src/app/runtime/broker-import.ts +43 -26
  19. package/src/architecture/core-builtin-allowlist.json +1 -1
  20. package/src/cli/commands/fred-series.ts +5 -0
  21. package/src/cli/commands/keybindings.ts +23 -0
  22. package/src/cli/commands/market.ts +68 -22
  23. package/src/cli/commands/options-chain.ts +59 -3
  24. package/src/cli/commands/overview.ts +119 -31
  25. package/src/cli/commands/search.ts +18 -2
  26. package/src/cli/commands/ticker.ts +2 -1
  27. package/src/cli/errors.ts +2 -2
  28. package/src/cli/export-source.ts +29 -1
  29. package/src/cli/glossary.ts +49 -0
  30. package/src/cli/history-rows.ts +30 -7
  31. package/src/cli/known-gaps.ts +5 -2
  32. package/src/cli/listing-arg.ts +16 -5
  33. package/src/cli/pane-functions/freshness.ts +25 -13
  34. package/src/cli/pane-functions/function-help.ts +10 -3
  35. package/src/cli/pane-functions/headless.ts +39 -5
  36. package/src/cli/pane-functions/index.ts +10 -3
  37. package/src/cli/pane-functions/market-session.ts +15 -5
  38. package/src/cli/pane-functions/report.ts +5 -1
  39. package/src/cli/pane-functions/resolver.ts +18 -2
  40. package/src/cli/quote-answer.ts +10 -4
  41. package/src/cli/report-tables.ts +18 -7
  42. package/src/cli/restore-plugins.ts +13 -1
  43. package/src/cli/result.ts +57 -5
  44. package/src/components/chart/composite/composite-chart.tsx +22 -4
  45. package/src/components/chart/composite/format.ts +27 -0
  46. package/src/components/chart/composite/levels.ts +2 -0
  47. package/src/components/chart/composite/panel-series.ts +1 -0
  48. package/src/components/chart/composite/panel-surface.tsx +9 -2
  49. package/src/components/chart/composite/range-statistics.ts +62 -0
  50. package/src/components/chart/composite/scene.ts +7 -3
  51. package/src/components/chart/composite/series-axis-tags.tsx +16 -9
  52. package/src/components/chart/composite/types.ts +4 -0
  53. package/src/components/command-bar/panel/prompt-binding.ts +0 -8
  54. package/src/components/command-bar/workflow/alert.ts +1 -1
  55. package/src/components/layout/header.tsx +26 -40
  56. package/src/components/layout/pane/footer/index.tsx +1 -0
  57. package/src/components/layout/pane/footer/model.ts +27 -0
  58. package/src/components/onboarding/onboarding-frame.tsx +1 -1
  59. package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
  60. package/src/components/ui/display.tsx +5 -1
  61. package/src/core/state/app/layout.ts +2 -0
  62. package/src/core/state/app/state.ts +9 -0
  63. package/src/core/state/app/types.ts +3 -0
  64. package/src/data/saved-copies.ts +55 -16
  65. package/src/docs/snapshot.json +361 -84
  66. package/src/i18n/es.ts +1 -1
  67. package/src/i18n/ja.ts +1 -1
  68. package/src/i18n/ko.ts +1 -1
  69. package/src/i18n/zh-cn.ts +1 -1
  70. package/src/i18n/zh-tw.ts +1 -1
  71. package/src/market-data/coordinator/financials.ts +10 -2
  72. package/src/market-data/coordinator/index.ts +3 -1
  73. package/src/market-data/coordinator/quotes.ts +4 -2
  74. package/src/market-data/market/change-basis.ts +33 -5
  75. package/src/market-data/market/dated-previous-close.ts +203 -0
  76. package/src/market-data/market/futures-week.ts +68 -0
  77. package/src/market-data/market/status.ts +16 -4
  78. package/src/market-data/options-alternatives.ts +60 -0
  79. package/src/market-data/quotes/contributions.ts +20 -2
  80. package/src/market-data/quotes/resolution.ts +3 -0
  81. package/src/market-data/selectors.ts +3 -0
  82. package/src/notifications/notification-log.ts +6 -4
  83. package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
  84. package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
  85. package/src/plugins/builtin/alerts/cli.ts +59 -20
  86. package/src/plugins/builtin/alerts/command.ts +7 -0
  87. package/src/plugins/builtin/alerts/constants.ts +1 -1
  88. package/src/plugins/builtin/alerts/fired-log.ts +45 -4
  89. package/src/plugins/builtin/alerts/format.ts +8 -2
  90. package/src/plugins/builtin/alerts/headless.ts +10 -5
  91. package/src/plugins/builtin/alerts/history-rows.ts +5 -2
  92. package/src/plugins/builtin/alerts/history.ts +2 -0
  93. package/src/plugins/builtin/alerts/index.tsx +66 -16
  94. package/src/plugins/builtin/alerts/live.ts +3 -3
  95. package/src/plugins/builtin/alerts/metric-command.ts +61 -0
  96. package/src/plugins/builtin/alerts/metric-data.ts +70 -0
  97. package/src/plugins/builtin/alerts/metric-model.ts +175 -0
  98. package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
  99. package/src/plugins/builtin/alerts/pane.tsx +42 -29
  100. package/src/plugins/builtin/alerts/storage.ts +33 -10
  101. package/src/plugins/builtin/alerts/types.ts +3 -0
  102. package/src/plugins/builtin/backtest/headless.ts +10 -5
  103. package/src/plugins/builtin/backtest/pane.tsx +9 -4
  104. package/src/plugins/builtin/backtest/presets.ts +29 -3
  105. package/src/plugins/builtin/broker-manager/index.tsx +3 -2
  106. package/src/plugins/builtin/broker-manager/model.ts +6 -1
  107. package/src/plugins/builtin/broker-trading/controller.ts +22 -12
  108. package/src/plugins/builtin/broker-trading/index.tsx +13 -1
  109. package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
  110. package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
  111. package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
  112. package/src/plugins/builtin/catalysts/model.ts +2 -1
  113. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
  114. package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
  115. package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
  116. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
  117. package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
  118. package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
  119. package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
  120. package/src/plugins/builtin/chart-composer/formula.ts +71 -18
  121. package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
  122. package/src/plugins/builtin/chart-composer/headless.ts +13 -2
  123. package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
  124. package/src/plugins/builtin/chart-composer/presets.ts +50 -15
  125. package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
  126. package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
  127. package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
  128. package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
  129. package/src/plugins/builtin/chat/content/composer.tsx +13 -6
  130. package/src/plugins/builtin/chat/content/footer.tsx +6 -3
  131. package/src/plugins/builtin/chat/content/index.tsx +50 -21
  132. package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
  133. package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
  134. package/src/plugins/builtin/chat/index.tsx +23 -0
  135. package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
  136. package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
  137. package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
  138. package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
  139. package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
  140. package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
  141. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
  142. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  143. package/src/plugins/builtin/econ/calendar-model.ts +14 -9
  144. package/src/plugins/builtin/econ/figures.ts +72 -0
  145. package/src/plugins/builtin/econ/index.tsx +33 -3
  146. package/src/plugins/builtin/econ/types.ts +2 -0
  147. package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
  148. package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
  149. package/src/plugins/builtin/econ-statistics/view.ts +3 -0
  150. package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
  151. package/src/plugins/builtin/exposure/charts.tsx +2 -2
  152. package/src/plugins/builtin/exposure/model.ts +13 -1
  153. package/src/plugins/builtin/exposure/pane.tsx +4 -4
  154. package/src/plugins/builtin/futures/index.tsx +1 -0
  155. package/src/plugins/builtin/futures/model.ts +2 -1
  156. package/src/plugins/builtin/futures/table.tsx +2 -2
  157. package/src/plugins/builtin/fx-matrix/client.ts +6 -1
  158. package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
  159. package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
  160. package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
  161. package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
  162. package/src/plugins/builtin/iv-history/headless.ts +3 -0
  163. package/src/plugins/builtin/macro-day/pane.tsx +7 -5
  164. package/src/plugins/builtin/market-movers/footer.ts +2 -2
  165. package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
  166. package/src/plugins/builtin/members/client.ts +19 -4
  167. package/src/plugins/builtin/members/headless.ts +20 -7
  168. package/src/plugins/builtin/members/index.tsx +9 -9
  169. package/src/plugins/builtin/members/model.ts +23 -4
  170. package/src/plugins/builtin/members/pane.tsx +25 -15
  171. package/src/plugins/builtin/options/analytics.ts +26 -2
  172. package/src/plugins/builtin/options/model-spot.ts +5 -2
  173. package/src/plugins/builtin/options/strike-window.ts +2 -1
  174. package/src/plugins/builtin/options/table.ts +1 -7
  175. package/src/plugins/builtin/options/view.tsx +25 -8
  176. package/src/plugins/builtin/options-positioning/client.ts +19 -0
  177. package/src/plugins/builtin/options-positioning/headless.ts +54 -14
  178. package/src/plugins/builtin/options-positioning/index.tsx +3 -3
  179. package/src/plugins/builtin/options-positioning/model.ts +47 -8
  180. package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
  181. package/src/plugins/builtin/perps/freshness.ts +12 -10
  182. package/src/plugins/builtin/perps/live-rows.ts +10 -3
  183. package/src/plugins/builtin/perps/pane.tsx +15 -11
  184. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
  185. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  186. package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
  187. package/src/plugins/builtin/rate-path/pane.tsx +2 -2
  188. package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
  189. package/src/plugins/builtin/scanner/feed.ts +2 -2
  190. package/src/plugins/builtin/scanner/index.ts +24 -1
  191. package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
  192. package/src/plugins/builtin/scanner/live-feed.ts +168 -0
  193. package/src/plugins/builtin/scanner/live-headless.ts +114 -0
  194. package/src/plugins/builtin/scanner/live-model.ts +351 -0
  195. package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
  196. package/src/plugins/builtin/scanner/live-track.ts +186 -0
  197. package/src/plugins/builtin/series-inventory.ts +1 -0
  198. package/src/plugins/builtin/shared/macro-releases.ts +65 -0
  199. package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
  200. package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
  201. package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
  202. package/src/plugins/builtin/themes/headless.ts +6 -4
  203. package/src/plugins/builtin/themes/model.ts +1 -1
  204. package/src/plugins/builtin/themes/pane.tsx +3 -3
  205. package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
  206. package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
  207. package/src/plugins/builtin/vol-surface/client.ts +6 -4
  208. package/src/plugins/builtin/vol-surface/headless.ts +62 -13
  209. package/src/plugins/builtin/vol-surface/index.tsx +2 -2
  210. package/src/plugins/builtin/vol-surface/model.ts +22 -7
  211. package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
  212. package/src/plugins/builtin/vol-surface/tables.ts +31 -3
  213. package/src/plugins/builtin/volatility/dvol.ts +46 -0
  214. package/src/plugins/builtin/volatility/pane.tsx +2 -2
  215. package/src/plugins/builtin/volatility/series.ts +26 -0
  216. package/src/plugins/builtin/world-indices/footer.ts +1 -1
  217. package/src/plugins/catalog-browser.ts +13 -8
  218. package/src/portfolio/column-values.ts +5 -5
  219. package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
  220. package/src/renderers/browser/app-services.ts +1 -7
  221. package/src/renderers/browser/config-host.ts +2 -55
  222. package/src/renderers/browser/main.tsx +7 -57
  223. package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
  224. package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
  225. package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
  226. package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
  227. package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
  228. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
  229. package/src/renderers/electrobun/bun/index.ts +25 -1
  230. package/src/renderers/electrobun/shared/app-name.ts +17 -0
  231. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
  232. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
  233. package/src/renderers/opentui/toast-host.tsx +71 -43
  234. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  235. package/src/sources/provider-router/batches.ts +10 -2
  236. package/src/sources/provider-router/financial-routes.ts +3 -3
  237. package/src/sources/provider-router/history.ts +28 -7
  238. package/src/sources/provider-router/index.ts +146 -10
  239. package/src/sources/provider-router/search.ts +13 -2
  240. package/src/state/app/context/index.tsx +3 -0
  241. package/src/sync/auth-transition.ts +19 -9
  242. package/src/sync/baseline.ts +10 -4
  243. package/src/sync/controller.ts +66 -16
  244. package/src/sync/react.ts +4 -1
  245. package/src/sync/types.ts +3 -0
  246. package/src/telemetry/usage-counts.ts +0 -18
  247. package/src/theme/colors.ts +8 -0
  248. package/src/tickers/company-name.ts +59 -0
  249. package/src/tickers/listing-venues.ts +23 -0
  250. package/src/tickers/search/bare-symbol.ts +36 -0
  251. package/src/tickers/search/index.ts +36 -7
  252. package/src/tickers/search/ranking.ts +10 -1
  253. package/src/tickers/search/types.ts +5 -0
  254. package/src/time-series/base-series.ts +13 -5
  255. package/src/time-series/daily-valuation.ts +62 -0
  256. package/src/time-series/field-catalog.ts +1 -3
  257. package/src/time-series/forward-valuation.ts +32 -0
  258. package/src/time-series/fundamentals.ts +155 -7
  259. package/src/time-series/hooks.ts +1 -0
  260. package/src/time-series/price-units.ts +72 -2
  261. package/src/time-series/resolve-context.ts +5 -1
  262. package/src/time-series/resolve.ts +61 -12
  263. package/src/time-series/series-presentation.ts +40 -6
  264. package/src/time-series/spec.ts +2 -1
  265. package/src/time-series/studies.ts +270 -72
  266. package/src/time-series/transforms.ts +1 -0
  267. package/src/time-series/types.ts +5 -0
  268. package/src/time-series/valuation-releases.ts +39 -0
  269. package/src/types/data-provider.ts +9 -1
  270. package/src/types/desktop-window.ts +2 -0
  271. package/src/types/financials.ts +41 -1
  272. package/src/types/headless.ts +42 -4
  273. package/src/types/instrument.ts +3 -0
  274. package/src/types/plugin.ts +1 -1
  275. package/src/updater/command-names.ts +12 -7
  276. package/src/utils/financial-statements.ts +6 -1
  277. package/src/utils/network-failure.ts +9 -6
  278. package/src/utils/option-expiry.ts +48 -1
  279. package/src/utils/options.ts +10 -0
  280. package/src/utils/plugin-proxy-hosts.json +1 -0
  281. package/src/utils/price-history.ts +5 -0
  282. package/src/renderers/browser/first-screen.ts +0 -193
@@ -7,6 +7,7 @@ import {
7
7
  latestRegularSessionOpen,
8
8
  nextRegularSessionOpen,
9
9
  } from "../../market-data/market/freshness";
10
+ import { futuresWeekendClosure } from "../../market-data/market/futures-week";
10
11
  import { currentFxClosureStart } from "../../utils/fx-market-hours";
11
12
  import { zonedDateKey } from "../../utils/zoned-date-time";
12
13
  import type { ReportTime } from "../../utils/utc-time";
@@ -21,6 +22,11 @@ export interface ReportMarket {
21
22
  reopensAt?: string;
22
23
  /** That open's date at its venue, so a Monday open in Sydney reads Monday. */
23
24
  reopensOn?: string;
25
+ /**
26
+ * The reopen is the week's first trade (a futures venue's Sunday evening),
27
+ * not a regular session's open: `reopens 22:00 UTC`, never a session's name.
28
+ */
29
+ firstTrade?: true;
24
30
  }
25
31
 
26
32
  /** One dated row of a quote report: the venue it trades on and the session its source reports. */
@@ -34,7 +40,7 @@ export interface SessionObservation {
34
40
 
35
41
  interface VenueSession {
36
42
  state: ReportMarketState | "always";
37
- next?: { date: string; open: number };
43
+ next?: { date: string; open: number; firstTrade?: true };
38
44
  /** The latest regular session that has closed: its local date and open. */
39
45
  last?: { date: string; open: number | null };
40
46
  timeZone?: string;
@@ -48,8 +54,9 @@ const US_EXCHANGE_TIME_ZONE = "America/New_York";
48
54
  /**
49
55
  * A venue's session now, from its calendar (weekends and published holidays),
50
56
  * or from the state its source reports when the app has no calendar for it,
51
- * such as an index publisher or a futures exchange; an index quote that states
52
- * none is placed on the sessions it carries. Null when none of them says.
57
+ * such as an index publisher or a futures exchange (whose weekend gap it
58
+ * knows); an index quote that states none is placed on the sessions it
59
+ * carries. Null when none of them says.
53
60
  */
54
61
  function venueSession(exchange: string, marketState: string | undefined, now: number, calendar?: QuoteIndexSession): VenueSession | null {
55
62
  if (isAlwaysOpenExchange(exchange)) return { state: "always" };
@@ -67,6 +74,9 @@ function venueSession(exchange: string, marketState: string | undefined, now: nu
67
74
  }
68
75
  return { state: "closed", next, last, timeZone };
69
76
  }
77
+ // A futures venue's weekend gap closes every contract, so it outranks the state a quote stamped before the Friday close.
78
+ const weekend = futuresWeekendClosure(exchange, now);
79
+ if (weekend) return { state: "closed", ...(weekend.reopensAt != null && weekend.reopensOn ? { next: { date: weekend.reopensOn, open: weekend.reopensAt, firstTrade: true as const } } : {}) };
70
80
  if (marketState === "REGULAR") return { state: "open" };
71
81
  const stated = marketState === "PRE" ? "pre-market" : marketState === "POST" ? "after-hours" : marketState && CLOSED_STATES.has(marketState) ? "closed" : null;
72
82
  if (!calendar) return stated ? { state: stated } : null;
@@ -209,7 +219,7 @@ export function reportMarketSession(
209
219
  if (states.size !== 1 || states.has("always")) return { market: null, closeDate: null, closeMarket: null };
210
220
  const state = [...states][0] as ReportMarketState;
211
221
  const reopen = state === "closed" || state === "pre-market"
212
- ? [...sessions.values()].reduce<{ date: string; open: number } | null>((first, session) => (
222
+ ? [...sessions.values()].reduce<NonNullable<VenueSession["next"]> | null>((first, session) => (
213
223
  session.next && (!first || session.next.open < first.open) ? session.next : first
214
224
  ), null)
215
225
  : null;
@@ -217,7 +227,7 @@ export function reportMarketSession(
217
227
  return {
218
228
  market: {
219
229
  state,
220
- ...(reopen ? { reopensAt: new Date(reopen.open).toISOString(), reopensOn: reopen.date } : {}),
230
+ ...(reopen ? { reopensAt: new Date(reopen.open).toISOString(), reopensOn: reopen.date, ...(reopen.firstTrade ? { firstTrade: true as const } : {}) } : {}),
221
231
  },
222
232
  closeDate,
223
233
  closeMarket: closeDate && newest ? closeMarketOf(newest.exchange, newest.session) : null,
@@ -5,6 +5,7 @@ import { buildHeadlessFunctionReport } from "./headless";
5
5
  import { appendDomReportFooter, buildDomFunctionReport, domReportNote, domReportView } from "./dom";
6
6
  import type { ReportFreshness } from "./freshness";
7
7
  import type { CliReportTables } from "../report-tables";
8
+ import { knownGapMessage } from "../known-gaps";
8
9
 
9
10
  export type PaneFunctionReportSource = "headless" | "dom";
10
11
 
@@ -72,7 +73,10 @@ export async function buildFunctionReport(
72
73
  // A pane `shot` can draw is still one command away.
73
74
  const screenshot = resolved.capability.screenshotReadiness;
74
75
  if (screenshot != null && screenshot !== "unsupported") {
75
- throw new Error(`${resolved.token} is an interactive pane with no data report; run \`${shotCommand(resolved.token, rawArg)}\` to see it.`);
76
+ const error = new Error(`${resolved.token} is an interactive pane with no data report; run \`${shotCommand(resolved.token, rawArg)}\` to see it.`);
77
+ // What the query names that no series is (`fn CAT PMI`): where it is, as `catalog` says it.
78
+ const gap = rawArg.trim() ? knownGapMessage(rawArg) : null;
79
+ throw gap ? Object.assign(error, { details: gap }) : error;
76
80
  }
77
81
  throw new Error(`${resolved.token} is an interactive pane and does not expose a data report.`);
78
82
  }
@@ -176,12 +176,16 @@ export async function applyListingArgument(
176
176
  const exchange = typeof args.options.exchange === "string" ? args.options.exchange : undefined;
177
177
  const { exchange: _exchange, ...options } = args.options;
178
178
  // A list entry with spaces, or an argument of words, is a company's name search finds, or the symbols it holds.
179
- const typed = takesOneTicker ? [args.arg] : await readNameEntries(splitSymbolArgs([args.arg], { phrases: true }), context.dataProvider);
180
- if (typed.length === 0) return args;
179
+ const entered = takesOneTicker ? [args.arg] : await readNameEntries(splitSymbolArgs([args.arg], { phrases: true }), context.dataProvider);
180
+ if (entered.length === 0) return args;
181
+ // A symbol the function reads as another (OPX's BTC is the coin, BTC-USD) is named in a note; --exchange keeps it as typed.
182
+ const aliases = exchange ? [] : entered.map((raw) => headless?.symbolAlias?.(raw) ?? null);
183
+ const typed = entered.map((raw, index) => aliases[index]?.symbol ?? raw);
181
184
  // RY.TO is the listing its quote names, RY:TSX, as `quote` reads it: a function's data is read by venue.
182
185
  let listings = await Promise.all(typed.map((raw) => resolveCliListing(raw, exchange, context, { ownExchangeWins: typed.length > 1, venueSuffix: true })));
183
186
  // A separator stuck to the end of a pasted list (`UPST;`) is not part of a symbol; it is named.
184
187
  const notes: string[] = takesOneTicker ? [] : stripStrayEdges(args.arg).notes;
188
+ notes.push(...aliases.flatMap((alias) => alias ? [alias.note] : []));
185
189
  const token = found.template?.shortcut?.prefix ?? args.target.toUpperCase();
186
190
  // Words search finds no company for, and that are not symbols either, name nothing a function can read: tickers do.
187
191
  const unnamed = takesList ? listings.filter((listing) => /\s/.test(listing.symbol)) : [];
@@ -213,6 +217,18 @@ export async function applyListingArgument(
213
217
  notes.push(...answers.map((answer) => `${answer.message} ${answer.details}`));
214
218
  }
215
219
  }
220
+ // A listing the function has nothing for (OPX's SOL-USD) says what exists instead; a list leaves it out.
221
+ const refusals = headless?.refuseSymbol ? listings.flatMap((listing) => {
222
+ const refusal = headless.refuseSymbol!(listing.key);
223
+ return refusal ? [{ listing, refusal }] : [];
224
+ }) : [];
225
+ if (refusals.length > 0) {
226
+ listings = listings.filter((listing) => !refusals.some((entry) => entry.listing === listing));
227
+ if (listings.length === 0) {
228
+ throw new ListingArgError(refusals.map(({ refusal }) => refusal.message).join("\n"), refusals.map(({ refusal }) => refusal.details).join("\n"));
229
+ }
230
+ notes.push(...refusals.map(({ refusal }) => `${refusal.message} ${refusal.details}`));
231
+ }
216
232
  const usOnly = headless?.usListingsOnly;
217
233
  const abroad = usOnly ? listings.filter((listing) => listingAbroadVenue(listing.key) != null) : [];
218
234
  if (usOnly && abroad.length > 0) {
@@ -29,6 +29,7 @@ import {
29
29
  type ListingVenue,
30
30
  } from "./listing-arg";
31
31
  import { withListingSuggestion } from "./not-a-ticker";
32
+ import { isNetworkFailure } from "../utils/network-failure";
32
33
  import type { MarketContext } from "./types";
33
34
 
34
35
  /** The text of an error, or null for none. */
@@ -76,6 +77,8 @@ export async function answerQuote(
76
77
  request: ListingDataRequest,
77
78
  refresh: boolean,
78
79
  venues: Promise<ListingVenue[]> | null,
80
+ /** The row prints what its move is from: a quote asked for again is dated too. */
81
+ { datesChangeBasis = false }: { datesChangeBasis?: boolean } = {},
79
82
  ): Promise<QuoteAnswer> {
80
83
  const root = listing.exchange ? null : indexRootFor(listing.symbol);
81
84
  // A bare root that quoted something else, such as MOVE (Corvex), points at the index too.
@@ -89,10 +92,12 @@ export async function answerQuote(
89
92
  const board = tradingBoardNote(listing, result.quote);
90
93
  return { result, listing, notes: [...choice, venue ?? [], board ?? [], also(result.quote), currencyCodeNote(listing, result.quote) ?? []].flat() };
91
94
  }
95
+ // No answer from the data service: no venue lookup or other listing would get one either.
96
+ if (isNetworkFailure(result.error)) return { result, listing, notes: [] };
92
97
  const notTraded = await notTradedMessage(listing, market);
93
98
  if (notTraded) return { result, listing, failure: { message: notTraded, oneLine: notTraded }, notes: [] };
94
99
  if (!isNoProviderError(result.error)) return { result, listing, notes: [] };
95
- const other = await quoteBareListing(listing, market, request, { refresh });
100
+ const other = await quoteBareListing(listing, market, request, { refresh, datesChangeBasis });
96
101
  if (other?.kind === "quoted") {
97
102
  return {
98
103
  result: { target: other.listing.request, quote: other.quote },
@@ -110,7 +115,7 @@ export async function answerQuote(
110
115
  listing.exchange ? null : knownGapForTicker(listing.symbol),
111
116
  ].filter((line): line is string => !!line);
112
117
  if (!other && tryLines.length === 0) {
113
- return await answerCompanyName(result, listing, market, request, refresh)
118
+ return await answerCompanyName(result, listing, market, request, refresh, datesChangeBasis)
114
119
  ?? { result: await withCurrencyPairHint(result, market), listing, notes: [] };
115
120
  }
116
121
  const message = other?.message ?? `${listing.symbol} has no ${request.noun}.`;
@@ -136,6 +141,7 @@ async function answerCompanyName(
136
141
  market: MarketContext,
137
142
  request: ListingDataRequest,
138
143
  refresh: boolean,
144
+ datesChangeBasis: boolean,
139
145
  ): Promise<QuoteAnswer | null> {
140
146
  if (!listing.typed || listing.exchange || !notATickerSymbol(errorMessage(result.error) ?? "")) return null;
141
147
  const named = await oneWordCompanyName(listing.typed, market);
@@ -147,8 +153,8 @@ async function answerCompanyName(
147
153
  });
148
154
  if (!resolved) return null;
149
155
  const found = { ...resolved, readAs: named.readAs, ...named.meant ? { meant: named.meant } : {} };
150
- const [quoted] = await market.dataProvider.getQuotesBatch([found.request], { forceRefresh: refresh, revalidate: true });
151
- return quoted?.quote ? answerQuote(quoted, found, market, request, refresh, null) : null;
156
+ const [quoted] = await market.dataProvider.getQuotesBatch([found.request], { forceRefresh: refresh, revalidate: true, datesChangeBasis });
157
+ return quoted?.quote ? answerQuote(quoted, found, market, request, refresh, null, { datesChangeBasis }) : null;
152
158
  }
153
159
 
154
160
  /** A listing a command found a quote for, with what `quote` says beside it. */
@@ -18,7 +18,9 @@ import {
18
18
  import { isEpochMilliseconds, parseReportTime } from "../utils/utc-time";
19
19
  import type { ReportFreshness } from "./pane-functions/freshness";
20
20
  import {
21
+ EXPORT_DATING_COLUMNS,
21
22
  EXPORT_IDENTITY_COLUMNS,
23
+ exportDatingCells,
22
24
  exportIdentityCells,
23
25
  exportSourceLines,
24
26
  type ExportIdentity,
@@ -46,6 +48,12 @@ export interface CliReportTables {
46
48
  footer: string[];
47
49
  /** What the rows are of, for the fixed columns every row ends with (`EXPORT_IDENTITY_COLUMNS`). */
48
50
  identity?: ExportIdentity;
51
+ /**
52
+ * The rows are statistics dated by a period and a release: every table also
53
+ * ends with `period`, `released` and `retrieved` (`EXPORT_DATING_COLUMNS`),
54
+ * less any it has by name, with `as_of` the release.
55
+ */
56
+ dated?: boolean;
49
57
  }
50
58
 
51
59
  /** A column as a report declares it: the text view draws `format(value, row)`, or the value itself. */
@@ -623,19 +631,22 @@ function csvLine(row: readonly unknown[]): string {
623
631
  function identifiedTable(table: CliReportTable, report: CliReportTables, freshness: ReportFreshness | undefined): CliReportTable {
624
632
  if (!freshness || table.columns.length === 0) return table;
625
633
  const identity = { ...report.identity, ...table.identity };
626
- const cells = exportIdentityCells(freshness, identity);
627
- const rowCells = (index: number) => {
634
+ const identityFor = (index: number) => {
628
635
  const own = table.rowIdentities?.[index];
629
- return own ? exportIdentityCells(freshness, { ...identity, ...own }) : cells;
636
+ return own ? { ...identity, ...own } : identity;
630
637
  };
631
638
  const added = EXPORT_IDENTITY_COLUMNS.flatMap((column, index) => table.columns.includes(column) ? [] : [index]);
632
- if (added.length === 0) return table;
639
+ const dating = report.dated
640
+ ? EXPORT_DATING_COLUMNS.flatMap((column, index) => table.columns.includes(column) ? [] : [index]) : [];
641
+ if (added.length === 0 && dating.length === 0) return table;
633
642
  return {
634
643
  ...table,
635
- columns: [...table.columns, ...added.map((index) => EXPORT_IDENTITY_COLUMNS[index]!)],
644
+ columns: [...table.columns, ...added.map((index) => EXPORT_IDENTITY_COLUMNS[index]!), ...dating.map((index) => EXPORT_DATING_COLUMNS[index]!)],
636
645
  rows: table.rows.map((row, index) => {
637
- const identityCells = rowCells(index);
638
- return [...row, ...added.map((column) => identityCells[column]!)];
646
+ const rowIdentity = identityFor(index);
647
+ const identityCells = exportIdentityCells(freshness, rowIdentity);
648
+ const datingCells = exportDatingCells(freshness, rowIdentity);
649
+ return [...row, ...added.map((column) => identityCells[column]!), ...dating.map((column) => datingCells[column]!)];
639
650
  }),
640
651
  };
641
652
  }
@@ -5,9 +5,18 @@ import { pluginSeedFailure, pluginSeedNotice, samePluginSeedFailure, seedExtract
5
5
  import { setPluginSeedNotice } from "../plugins/seed-notice";
6
6
  import { debugLog } from "../utils/debug-log";
7
7
  import type { AppConfig } from "../types/config";
8
+ import { withDeadline } from "../utils/async-deadline";
8
9
 
9
10
  const log = debugLog.createLogger("plugin-seed");
10
11
 
12
+ /**
13
+ * How long a launch waits for the plugin registry before it starts without
14
+ * the plugins left to install. A connection that hangs would otherwise hold
15
+ * the screen blank: the registry is read first, and git would hang the same
16
+ * way after it. They are retried at the next launch.
17
+ */
18
+ const REGISTRY_WAIT_MS = 8_000;
19
+
11
20
  /**
12
21
  * Reinstalls plugins that moved out of this repository into their own.
13
22
  *
@@ -40,7 +49,10 @@ export async function restoreExtractedPlugins(): Promise<Pick<AppConfig, "seeded
40
49
  // Without git every clone fails the same way: no registry read, no attempt.
41
50
  gitMissing ??= !gitInstalled();
42
51
  if (gitMissing) throw new Error("git is not installed");
43
- listings ??= loadRegistryListings();
52
+ // Only no answer at all stops here: the network hangs, and a clone would
53
+ // too, so nothing is tried until the next launch. A registry that fails
54
+ // is not reported as silent: the plugins install from their repositories.
55
+ listings ??= withDeadline(loadRegistryListings(), REGISTRY_WAIT_MS, "the plugin registry did not answer");
44
56
  return installListedPlugin(repo, { quiet: true }, await listings);
45
57
  });
46
58
  // Recorded, so doctor knows a launch tried, and a failure the next launch
package/src/cli/result.ts CHANGED
@@ -18,7 +18,10 @@ import { formatFreshnessLine, withSavedCopy, type ReportFreshness } from "./pane
18
18
  import { withSearchHints, type FredSeriesCheck } from "./not-a-ticker";
19
19
  import { renderReportCsv, renderReportNdjson, withCsvFooter, type CliReportTables } from "./report-tables";
20
20
  import {
21
+ EXPORT_DATING_COLUMNS,
21
22
  EXPORT_IDENTITY_COLUMNS,
23
+ exportDatingCells,
24
+ exportDatingFields,
22
25
  exportIdentityCells,
23
26
  exportIdentityFields,
24
27
  exportSource,
@@ -120,6 +123,15 @@ export interface CliResultRenderOptions<T = unknown, Row = Record<string, unknow
120
123
  exportIdentity?: ExportIdentity | ((row: Row) => ExportIdentity);
121
124
  /** What CSV says after the source lines as `# note:` lines: what the rows do not show, such as where a series begins. */
122
125
  exportNotes?: readonly string[];
126
+ /**
127
+ * The rows are statistics dated by a period and a release (`econ`, `fred`):
128
+ * CSV and NDJSON rows also end with `period`, `released` and `retrieved`
129
+ * (`EXPORT_DATING_COLUMNS`) after the five fixed columns, from the `period`
130
+ * and `released` of each row's `exportIdentity`, and `as_of` is the release.
131
+ * A column the rows already have by name keeps its own values. Needs a
132
+ * cited source, as the fixed columns do.
133
+ */
134
+ exportDated?: boolean;
123
135
  /**
124
136
  * Why a row's figures are empty (`Not a ticker: ZZZZQQ.`, `Chg%: no previous
125
137
  * close`), for the `status` column CSV writes after the row's own columns and
@@ -348,20 +360,31 @@ function renderCsv<Row extends Record<string, unknown>>(
348
360
  columns?: CliResultColumn<Row>[],
349
361
  identity?: (row: Row) => string[],
350
362
  status?: (row: Row) => string | null | undefined,
363
+ dating?: (row: Row) => string[],
351
364
  ): string {
352
365
  const resolvedColumns = columns && columns.length > 0
353
366
  ? columns
354
367
  : inferColumns(rows as Record<string, unknown>[]) as CliResultColumn<Row>[];
355
368
  const headers = resolvedColumns.map((column) => column.header);
369
+ // A dated export's own `released` column keeps its values; the others close the row.
370
+ const datingAdded = dating
371
+ ? EXPORT_DATING_COLUMNS.flatMap((column, index) => headers.includes(column) ? [] : [index]) : [];
356
372
  return serializeCsv([
357
- [...headers, ...status ? [statusHeader(headers)] : [], ...identity ? EXPORT_IDENTITY_COLUMNS : []],
373
+ [
374
+ ...headers,
375
+ ...status ? [statusHeader(headers)] : [],
376
+ ...identity ? EXPORT_IDENTITY_COLUMNS : [],
377
+ ...datingAdded.map((index) => EXPORT_DATING_COLUMNS[index]!),
378
+ ],
358
379
  ...rows.map((row) => {
359
380
  const values = resolvedColumns.map((column) => (column.value ? column.value(row) : row[column.key]));
360
381
  const blanked = resolvedColumns.flatMap((column, index) => isNonFiniteNumber(values[index]) ? [column.header] : []);
382
+ const datingCells = dating?.(row) ?? [];
361
383
  return [
362
384
  ...values.map((value) => isNonFiniteNumber(value) ? "" : value),
363
385
  ...status ? [exportStatusText(status(row), blanked)] : [],
364
386
  ...identity?.(row) ?? [],
387
+ ...datingAdded.map((index) => datingCells[index] ?? ""),
365
388
  ];
366
389
  }),
367
390
  ]);
@@ -534,13 +557,26 @@ function serializeCliRows<T, Row extends Record<string, unknown>>(
534
557
  return rows.map((row) => {
535
558
  if (!isPlainObject(row)) return JSON.stringify(row);
536
559
  const fields = ndjsonRow(row as Row, status, statusKey);
537
- return JSON.stringify(cited ? { ...fields, ...exportIdentityFields(cited, rowIdentity(row as Row, renderOptions.exportIdentity)) } : fields);
560
+ if (!cited) return JSON.stringify(fields);
561
+ const identity = rowIdentity(row as Row, renderOptions.exportIdentity);
562
+ // A dated export's own fields (a row's `released`) keep their values.
563
+ const dating = renderOptions.exportDated
564
+ ? Object.fromEntries(Object.entries(exportDatingFields(cited, identity)).filter(([key]) => !(key in fields))) : {};
565
+ return JSON.stringify({ ...fields, ...exportIdentityFields(cited, identity), ...dating });
538
566
  }).join("\n");
539
567
  }
540
568
  if (options.format === "csv") {
541
- if (!cited) return renderCsv(rows as Row[], renderOptions.columns, undefined, status);
542
- const csv = renderCsv(rows as Row[], renderOptions.columns, (row) => exportIdentityCells(cited, rowIdentity(row, renderOptions.exportIdentity)), status);
543
- return withCsvFooter(csv, [...exportSourceLines(cited), ...(renderOptions.exportNotes ?? []).map((note) => `note: ${note}`)]);
569
+ // The rows a window cut say so after them, as text and JSON do: a script has no other way to know.
570
+ const cutNote = window.note ? [`note: ${window.note}`] : [];
571
+ if (!cited) return withCsvFooter(renderCsv(rows as Row[], renderOptions.columns, undefined, status), cutNote);
572
+ const csv = renderCsv(
573
+ rows as Row[],
574
+ renderOptions.columns,
575
+ (row) => exportIdentityCells(cited, rowIdentity(row, renderOptions.exportIdentity)),
576
+ status,
577
+ renderOptions.exportDated ? (row) => exportDatingCells(cited, rowIdentity(row, renderOptions.exportIdentity)) : undefined,
578
+ );
579
+ return withCsvFooter(csv, [...exportSourceLines(cited), ...cutNote, ...(renderOptions.exportNotes ?? []).map((note) => `note: ${note}`)]);
544
580
  }
545
581
  if (renderOptions.text) {
546
582
  return renderOptions.text(result.data);
@@ -608,6 +644,22 @@ export function printCliResult<T, Row extends Record<string, unknown> = Record<s
608
644
  console.error(`${cliStyles.warning("warning:")} ${hinted(warning)}`);
609
645
  }
610
646
  }
647
+ // CSV says what its rows do not in closing `# note:` lines; NDJSON has no place for one among its rows,
648
+ // so the same notes go to stderr, where the warnings are.
649
+ if (options.format === "ndjson" && !options.quiet) {
650
+ for (const note of exportNdjsonNotes(result, options, renderOptions)) console.error(`note: ${note}`);
651
+ }
652
+ }
653
+
654
+ /** What a `--limit`, or the default a command caps its rows at, left out of a list, then the command's own notes: what CSV closes with as `# note:` lines. */
655
+ function exportNdjsonNotes<T, Row extends Record<string, unknown>>(
656
+ result: CliResult<T>,
657
+ options: CliGlobalOptions,
658
+ renderOptions: CliResultRenderOptions<T, Row>,
659
+ ): string[] {
660
+ if (renderOptions.tables || renderOptions.text) return [];
661
+ const cut = windowed(asRows(result.data, renderOptions.rows), options, renderOptions).note;
662
+ return [...cut ? [cut] : [], ...renderOptions.exportNotes ?? []];
611
663
  }
612
664
 
613
665
  export function serializeCliError(error: CliErrorObject, options: CliGlobalOptions): string {
@@ -7,6 +7,7 @@ import { AXIS_DOUBLE_PRESS_MS, isVerticalWheelDirection } from "./panel-gestures
7
7
  import { type ChartToolSpan, CHART_DRAWING_COLORS, isDrawingTool, type ChartDrawing } from "./tools";
8
8
  import { ChartDrawingStore, NO_DRAWINGS } from "./drawing-store";
9
9
  import { CompositeLegend } from "./legend";
10
+ import { compositeRangeStatisticsLevels } from "./range-statistics";
10
11
  import { ChartToolbar, CHART_TOOLBAR_WIDTH } from "./toolbar";
11
12
  import { AsciiText, Box, ChartSurface, Text, useUiCapabilities, type BoxRenderable } from "../../../ui";
12
13
  import { Button } from "../../ui/button";
@@ -204,6 +205,7 @@ function ThemedCompositeChart({
204
205
  timePick,
205
206
  pricePick,
206
207
  levels,
208
+ rangeStatisticsSeriesId,
207
209
  chartLayout,
208
210
  onChartLayoutChange,
209
211
  }: CompositeChartProps & { chartLayout: ChartLayoutPrefs; onChartLayoutChange: ChartLayoutUpdate }) {
@@ -337,6 +339,20 @@ function ThemedCompositeChart({
337
339
  textDim: colors?.textDim ?? activeThemeColors.textDim,
338
340
  negative: colors?.negative ?? activeThemeColors.negative,
339
341
  }), [activeThemeColors, colors, paneSurface]);
342
+ const rangeStatisticsLevels = useMemo(() => {
343
+ void lastTickKey;
344
+ return compositeRangeStatisticsLevels(
345
+ panelSeries.find((entry) => entry.id === rangeStatisticsSeriesId),
346
+ effectiveViewport,
347
+ resolvedColors.textDim,
348
+ formatValue,
349
+ );
350
+ }, [effectiveViewport, formatValue, lastTickKey, panelSeries, rangeStatisticsSeriesId, resolvedColors.textDim]);
351
+ const displayedLevels = useMemo(() => (
352
+ levels && rangeStatisticsLevels && levels.seriesId === rangeStatisticsLevels.seriesId
353
+ ? { ...levels, items: [...levels.items, ...rangeStatisticsLevels.items] }
354
+ : levels ?? rangeStatisticsLevels
355
+ ), [levels, rangeStatisticsLevels]);
340
356
  // A custom x axis means x is not time: a tenor, a fraction, a return. Its
341
357
  // last observation belongs at the right edge, under its own label.
342
358
  const rightOffsetRatio = xAxis ? 0 : COMPOSITE_RIGHT_OFFSET_RATIO;
@@ -351,6 +367,7 @@ function ThemedCompositeChart({
351
367
  timelineSeries: marketTimelineSeries,
352
368
  rightOffsetRatio,
353
369
  valueScales,
370
+ referenceLevels: rangeStatisticsLevels,
354
371
  });
355
372
  }, [
356
373
  clipToViewport,
@@ -361,6 +378,7 @@ function ThemedCompositeChart({
361
378
  panelSeries,
362
379
  panels,
363
380
  rightOffsetRatio,
381
+ rangeStatisticsLevels,
364
382
  valueScales,
365
383
  ]);
366
384
  // Gutters follow the axes the scene actually built. Reading the series list
@@ -426,7 +444,7 @@ function ThemedCompositeChart({
426
444
  availableAxisWidth,
427
445
  Math.max(
428
446
  Math.min(MINIMUM_AXIS_LABEL_WIDTH, maximumAxisWidth),
429
- ...(levels?.items ?? []).map((level) => level.axisLabel?.length ?? 0),
447
+ ...(displayedLevels?.items ?? []).map((level) => level.axisLabel?.length ?? 0),
430
448
  ...(layoutPanels ?? []).flatMap((panel) => (["left", "right"] as const).map((side) => {
431
449
  const width = compositeAxisLabelWidth(panel.axes[side], formatAxisValue, includeCursorLabels);
432
450
  if (!includeSeriesTags || !width) return includeCursorLabels ? width : Math.min(width, maximumAxisWidth);
@@ -437,7 +455,7 @@ function ThemedCompositeChart({
437
455
  return (width + 1) * lanes;
438
456
  })),
439
457
  ),
440
- ), [availableAxisWidth, formatAxisValue, includeCursorLabels, includeSeriesTags, layoutPanels, maximumAxisWidth, levels?.items]);
458
+ ), [availableAxisWidth, displayedLevels?.items, formatAxisValue, includeCursorLabels, includeSeriesTags, layoutPanels, maximumAxisWidth]);
441
459
  const leftAxisWidth = hasLeftAxis ? resolvedAxisWidth : 0;
442
460
  const rightAxisWidth = hasRightAxis ? resolvedAxisWidth : 0;
443
461
  const axisGap = resolvedAxisWidth > 0 ? 1 : 0;
@@ -689,7 +707,7 @@ function ThemedCompositeChart({
689
707
  onActivate,
690
708
  timePick,
691
709
  pricePick,
692
- levels,
710
+ levels: displayedLevels,
693
711
  visibleLegendSeries,
694
712
  visibleSeriesIds,
695
713
  onToggleSeries,
@@ -1136,7 +1154,7 @@ function ThemedCompositeChart({
1136
1154
  showTextFallback={showTextFallback}
1137
1155
  onPickTime={timePick?.onPick}
1138
1156
  onPickPrice={pricePick?.onPick}
1139
- levels={levelHost?.panel.id === panel.id ? levels ?? null : null}
1157
+ levels={levelHost?.panel.id === panel.id ? displayedLevels ?? null : null}
1140
1158
  selectedLevelId={selectedLevelId}
1141
1159
  onSelectLevel={setSelectedLevelId}
1142
1160
  valueScalable={interactive}
@@ -2,6 +2,7 @@ import { formatMarketPrice, formatMarketPriceWithCurrency, stablePriceFractionDi
2
2
  import { INDEX_POINTS_UNIT } from "../../../market-data/market/index-level";
3
3
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
4
4
  import type { CompositeAxisDomain, CompositePanelScene } from "./types";
5
+ import { formatNumber, formatPercentRaw } from "../../../utils/format";
5
6
  import { timeZoneLabel, zonedWallClockMs } from "../../../utils/zoned-date-time";
6
7
 
7
8
  const CURRENCY_SYMBOLS: Record<string, string> = {
@@ -237,6 +238,28 @@ function compactResolution(value: number): number {
237
238
  return absolute > 0 ? 10 ** (Math.floor(Math.log10(absolute)) - 2) : 0;
238
239
  }
239
240
 
241
+ /** A series of changes over a baseline: returns since the range began, year-on-year or quarter-on-quarter growth. */
242
+ function isChangeSeries(series: ResolvedSeries): boolean {
243
+ return series.unit.trim() === "%" && (series.transform === "percent" || series.transform === "yoy" || series.transform === "qoq");
244
+ }
245
+
246
+ /**
247
+ * A value worked out from prices (a formula, a spread, a price over an FX
248
+ * rate) at its legs' decimals: bare beside a stated unit (92.6164 USD /
249
+ * barrel), with its derived unit, or with its currency's sign ($12.34).
250
+ */
251
+ function formatDerivedValue(value: number, series: ResolvedSeries, decimals: number): string {
252
+ const number = formatNumber(Math.abs(value), decimals);
253
+ const sign = value < 0 && /[1-9]/.test(number) ? "-" : "";
254
+ if (series.statedUnit) return `${sign}${number}`;
255
+ const unit = series.unit.trim();
256
+ if (series.unitGroup.toLowerCase().startsWith("derived-unit:")) return `${sign}${number} ${unit}`;
257
+ const currency = unitCurrencyCode(unit);
258
+ if (!currency) return `${sign}${number}`;
259
+ const symbol = CURRENCY_SYMBOLS[currency];
260
+ return symbol ? `${sign}${symbol}${number}` : `${sign}${number} ${currency}`;
261
+ }
262
+
240
263
  /** `referencePrice` defaults to the series' own; a study on a price axis
241
264
  * passes the axis's reference so it shares the price's decimals. */
242
265
  export function formatCompositeSeriesValue(
@@ -244,6 +267,10 @@ export function formatCompositeSeriesValue(
244
267
  series: ResolvedSeries,
245
268
  referencePrice = seriesPriceReference(series),
246
269
  ): string {
270
+ // A return reads as every other return in the app, signed with two decimals, so a legend of them lines up
271
+ // (+34.02%, +0.76%, +15.80%) instead of one precision per magnitude.
272
+ if (isChangeSeries(series)) return formatPercentRaw(value);
273
+ if (series.valueDecimals !== undefined) return formatDerivedValue(value, series, series.valueDecimals);
247
274
  if (series.statedUnit) {
248
275
  // The number alone: the legend prints the unit beside it, as FUT prints
249
276
  // a price beside its unit (4.6242 USD / gallon, not $4.6242).
@@ -23,6 +23,8 @@ export interface CompositeChartLevel {
23
23
  label?: string;
24
24
  /** Exact instrument price, independent of the grid's compact tick formatter. */
25
25
  axisLabel?: string;
26
+ /** A named reference guide keeps its axis label ahead of ordinary grid ticks. */
27
+ prioritizeAxisLabel?: boolean;
26
28
  dashed?: boolean;
27
29
  }
28
30
 
@@ -30,6 +30,7 @@ function sameSeriesMeta(left: ResolvedSeries, right: ResolvedSeries): boolean {
30
30
  && left.unitGroup === right.unitGroup
31
31
  && left.statedUnit === right.statedUnit
32
32
  && left.priceAssetCategory === right.priceAssetCategory
33
+ && left.valueDecimals === right.valueDecimals
33
34
  && left.nativeFrequency === right.nativeFrequency
34
35
  && left.timestampMode === right.timestampMode
35
36
  && left.dataShape === right.dataShape
@@ -542,6 +542,7 @@ export function CompositePanelSurface({
542
542
  yRatio,
543
543
  label: level.axisLabel ?? (formatAxisValue ? formatAxisValue(level.value, levelDomain) : formatCompositeCursorValue(level.value, levelDomain)),
544
544
  color: level.color,
545
+ prioritizeAxisLabel: level.prioritizeAxisLabel,
545
546
  })) : [], [formatAxisValue, levelDomain, projectedLevels, selectedLevelId]);
546
547
  const buildAxisMarkers = (side: "left" | "right") => {
547
548
  if (!panel.axes[side]) return undefined;
@@ -557,7 +558,8 @@ export function CompositePanelSurface({
557
558
  );
558
559
  const fixed = [lastPriceMarker, axisMarkers].filter(onSide).map(toAxisMarker);
559
560
  // The prior close's label yields to every level's.
560
- const levelLabels = [...levelMarkers, priorCloseMarker].filter(onSide).map(toAxisMarker);
561
+ const referenceLabels = levelMarkers.filter((marker) => marker.prioritizeAxisLabel && onSide(marker)).map(toAxisMarker);
562
+ const levelLabels = [...levelMarkers.filter((marker) => !marker.prioritizeAxisLabel), priorCloseMarker].filter(onSide).map(toAxisMarker);
561
563
  // A label is a row tall, so close levels would stack into an unreadable
562
564
  // pile and hide the last price: each keeps its line, and only the labels
563
565
  // with a row to themselves show. Desktop badges sit between rows.
@@ -567,8 +569,13 @@ export function CompositePanelSurface({
567
569
  pixelY: tick.topPercent / 100 * Math.max(panel.height * cellHeightPx - 1, 1),
568
570
  }))
569
571
  : [...(side === "left" ? leftAxisLabels : rightAxisLabels)].map(([row, label]) => ({ id: undefined, row, pixelY: row * cellHeightPx, label, color: colors.textDim }));
572
+ const rowOf = (marker: ReturnType<typeof toAxisMarker>) => fractionalViewport ? marker.pixelY / cellHeightPx : marker.row;
573
+ // Named guides are part of the chart's readout: ordinary ticks yield to
574
+ // them, while a live price or cursor still keeps its place.
575
+ const references = fitAxisLabels(referenceLabels, fixed, rowOf);
570
576
  const markers = [
571
- ...fitAxisLabels(levelLabels, [...fixed, ...grid], (marker) => fractionalViewport ? marker.pixelY / cellHeightPx : marker.row),
577
+ ...references,
578
+ ...fitAxisLabels(levelLabels, [...references, ...fixed, ...grid], rowOf),
572
579
  ...fixed,
573
580
  ];
574
581
  return markers.length > 0 ? markers : undefined;
@@ -0,0 +1,62 @@
1
+ import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
2
+ import { formatCompositeSeriesValue } from "./format";
3
+ import type { CompositeViewportRange } from "./interactions";
4
+ import type { CompositeChartLevel, CompositeChartLevels } from "./levels";
5
+
6
+ /** Population statistics of the finite observations actually in the window. */
7
+ export function compositeRangeStatistics(points: readonly TimeSeriesPoint[], viewport: CompositeViewportRange) {
8
+ const start = viewport.start.getTime();
9
+ const end = viewport.end.getTime();
10
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return null;
11
+ let count = 0;
12
+ let mean = 0;
13
+ let squaredDeviations = 0;
14
+ for (const point of points) {
15
+ const time = point.date.getTime();
16
+ const value = Number.isFinite(point.value) ? point.value : point.close;
17
+ if (!Number.isFinite(time) || time < start || time > end
18
+ || typeof value !== "number" || !Number.isFinite(value)) continue;
19
+ count += 1;
20
+ // Welford's update avoids subtracting nearly equal squared sums when
21
+ // valuations are tightly clustered around a large level.
22
+ const delta = value - mean;
23
+ mean += delta / count;
24
+ squaredDeviations += delta * (value - mean);
25
+ }
26
+ if (!count || !Number.isFinite(mean) || !Number.isFinite(squaredDeviations)) return null;
27
+ return { count, mean, standardDeviation: Math.sqrt(Math.max(0, squaredDeviations / count)) };
28
+ }
29
+
30
+ /** Read-only levels share the chart's axis labels, vector, Kitty and text paths. */
31
+ export function compositeRangeStatisticsLevels(
32
+ series: ResolvedSeries | undefined,
33
+ viewport: CompositeViewportRange | null,
34
+ color: string,
35
+ formatValue: (value: number, series: ResolvedSeries) => string = formatCompositeSeriesValue,
36
+ ): CompositeChartLevels | null {
37
+ if (!series || series.hidden || !viewport) return null;
38
+ const statistics = compositeRangeStatistics(series.points, viewport);
39
+ if (!statistics || statistics.count < 20) return null;
40
+ const { mean, standardDeviation } = statistics;
41
+ const guides = [
42
+ { id: "mean", label: "Avg", value: mean, dashed: false },
43
+ { id: "upper", label: "+1σ", value: mean + standardDeviation, dashed: true },
44
+ { id: "lower", label: "−1σ", value: mean - standardDeviation, dashed: true },
45
+ ];
46
+ const items: CompositeChartLevel[] = [];
47
+ for (const guide of guides) {
48
+ // A constant window (including one observation) has only its mean.
49
+ if (!Number.isFinite(guide.value) || items.some((item) => item.value === guide.value)) continue;
50
+ items.push({
51
+ id: `range-statistics:${series.id}:${guide.id}`,
52
+ value: guide.value,
53
+ color,
54
+ dashed: guide.dashed,
55
+ axisLabel: `${guide.label} ${formatValue(guide.value, series)}`,
56
+ prioritizeAxisLabel: true,
57
+ editable: false,
58
+ actionable: false,
59
+ });
60
+ }
61
+ return { seriesId: series.id, items };
62
+ }