gloomberb 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/commodity-balances.ts +6 -0
  3. package/src/api-client/company-kpis.ts +9 -0
  4. package/src/api-client/crypto-markets.ts +9 -0
  5. package/src/api-client/data.ts +6 -5
  6. package/src/api-client/equity-screener.ts +2 -0
  7. package/src/api-client/types.ts +5 -0
  8. package/src/app/pane-runtime/plugin-bindings.ts +3 -1
  9. package/src/app/pane-runtime/ticker-open-runtime.ts +17 -5
  10. package/src/app/runtime/plugin-bindings.ts +6 -2
  11. package/src/brokers/logos.ts +66 -0
  12. package/src/brokers/profile-form.ts +3 -0
  13. package/src/brokers/remote-broker-adapter.ts +13 -3
  14. package/src/capabilities/index.ts +1 -1
  15. package/src/capabilities/service-capabilities.ts +7 -1
  16. package/src/cli/base-converter.ts +60 -18
  17. package/src/cli/commands/market.ts +281 -154
  18. package/src/cli/commands/news-search.ts +35 -8
  19. package/src/cli/commands/system.ts +35 -1
  20. package/src/cli/commands/ticker.ts +100 -50
  21. package/src/cli/currency-hints.ts +27 -0
  22. package/src/cli/fx-pair.ts +41 -4
  23. package/src/cli/glossary.ts +57 -4
  24. package/src/cli/helpers.ts +36 -3
  25. package/src/cli/index.ts +4 -1
  26. package/src/cli/known-gaps.ts +37 -1
  27. package/src/cli/listing-arg.ts +37 -2
  28. package/src/cli/pane-functions/catalog.ts +3 -1
  29. package/src/cli/pane-functions/discovery.ts +15 -1
  30. package/src/cli/pane-functions/dom.ts +9 -4
  31. package/src/cli/pane-functions/freshness.ts +4 -4
  32. package/src/cli/pane-functions/function-help.ts +27 -3
  33. package/src/cli/pane-functions/headless.ts +6 -5
  34. package/src/cli/pane-functions/index.ts +3 -2
  35. package/src/cli/pane-functions/lock-note.ts +7 -4
  36. package/src/cli/pane-functions/resolver.ts +2 -1
  37. package/src/cli/quote-answer.ts +179 -0
  38. package/src/cli/series-search.ts +2 -1
  39. package/src/cli/types.ts +4 -0
  40. package/src/components/chart/composite/composite-chart.tsx +11 -2
  41. package/src/components/chart/composite/format.ts +13 -1
  42. package/src/components/chart/composite/level-labels.ts +30 -0
  43. package/src/components/chart/composite/levels.ts +17 -6
  44. package/src/components/chart/composite/panel-surface.tsx +58 -8
  45. package/src/components/command-bar/routes/ticker-search/actions.ts +6 -0
  46. package/src/components/index.ts +2 -0
  47. package/src/components/layout/pane/content.tsx +16 -14
  48. package/src/components/layout/pane/footer/index.tsx +22 -3
  49. package/src/components/layout/pane/footer/model.ts +99 -4
  50. package/src/components/layout/pane/header-tabs.tsx +15 -1
  51. package/src/components/onboarding/onboarding-frame.tsx +9 -11
  52. package/src/components/onboarding/use-onboarding-broker-form.ts +5 -2
  53. package/src/components/onboarding/wizard-model.ts +10 -3
  54. package/src/components/ui/button.tsx +11 -4
  55. package/src/components/ui/choice-dialog.tsx +8 -5
  56. package/src/components/ui/display.tsx +11 -6
  57. package/src/components/ui/fields.tsx +100 -20
  58. package/src/components/ui/icon.tsx +6 -1
  59. package/src/components/ui/list-row-lines.tsx +87 -0
  60. package/src/components/ui/list-view.tsx +14 -0
  61. package/src/components/ui/logo-mark.tsx +64 -0
  62. package/src/components/ui/number-step.ts +54 -0
  63. package/src/components/ui/select-button.tsx +5 -4
  64. package/src/components/ui/select-field.tsx +3 -3
  65. package/src/components/ui/status.tsx +17 -3
  66. package/src/components/ui/tabs.tsx +9 -1
  67. package/src/components/ui/toggle.tsx +17 -3
  68. package/src/core/state/app/instrument.ts +8 -3
  69. package/src/data/config/store/normalize.ts +4 -1
  70. package/src/data/plugin-cache.ts +32 -6
  71. package/src/docs/snapshot.json +5 -5
  72. package/src/i18n/es.ts +9 -3
  73. package/src/i18n/ja.ts +9 -3
  74. package/src/i18n/ko.ts +9 -3
  75. package/src/i18n/zh-cn.ts +10 -4
  76. package/src/i18n/zh-tw.ts +10 -4
  77. package/src/market-data/commodity-names.ts +80 -0
  78. package/src/market-data/coordinator/auxiliary.ts +3 -1
  79. package/src/market-data/coordinator/chart.ts +3 -1
  80. package/src/market-data/futures-board.ts +38 -1
  81. package/src/market-data/futures-contract.ts +91 -0
  82. package/src/market-data/fx-closes.ts +78 -1
  83. package/src/market-data/market/change-basis.ts +219 -0
  84. package/src/market-data/market/format.ts +28 -12
  85. package/src/market-data/market/freshness.ts +38 -0
  86. package/src/market-data/market/status.ts +55 -9
  87. package/src/market-data/market/year-to-date.ts +46 -0
  88. package/src/market-data/quotes/contributions.ts +12 -0
  89. package/src/market-data/quotes/delisting.ts +34 -0
  90. package/src/market-data/quotes/live-valuation.ts +23 -0
  91. package/src/market-data/quotes/resolution.ts +8 -2
  92. package/src/market-data/tax-equivalent.ts +10 -2
  93. package/src/notifications/app-notifier.ts +2 -2
  94. package/src/notifications/notification-log.ts +8 -6
  95. package/src/plugins/builtin/alerts/cli.ts +26 -18
  96. package/src/plugins/builtin/alerts/headless.ts +1 -1
  97. package/src/plugins/builtin/alerts/index.tsx +13 -4
  98. package/src/plugins/builtin/attention/trending.tsx +8 -6
  99. package/src/plugins/builtin/backtest/client.ts +5 -2
  100. package/src/plugins/builtin/backtest/window.ts +16 -3
  101. package/src/plugins/builtin/broker-manager/add-flow.tsx +40 -8
  102. package/src/plugins/builtin/broker-manager/detail.tsx +26 -1
  103. package/src/plugins/builtin/broker-manager/index.tsx +20 -9
  104. package/src/plugins/builtin/broker-manager/model.ts +5 -0
  105. package/src/plugins/builtin/broker-manager/pane-actions.tsx +8 -1
  106. package/src/plugins/builtin/broker-manager/table.ts +5 -1
  107. package/src/plugins/builtin/broker-trading/account-picker.tsx +91 -0
  108. package/src/plugins/builtin/broker-trading/chart-levels.ts +54 -0
  109. package/src/plugins/builtin/broker-trading/command.ts +103 -0
  110. package/src/plugins/builtin/broker-trading/controller.ts +688 -0
  111. package/src/plugins/builtin/broker-trading/dock.tsx +182 -0
  112. package/src/plugins/builtin/broker-trading/headless.ts +76 -0
  113. package/src/plugins/builtin/broker-trading/index.tsx +77 -0
  114. package/src/plugins/builtin/broker-trading/order-feed.ts +383 -0
  115. package/src/plugins/builtin/broker-trading/orders-data.ts +133 -0
  116. package/src/plugins/builtin/broker-trading/orders.tsx +437 -0
  117. package/src/plugins/builtin/broker-trading/overview-orders.tsx +121 -0
  118. package/src/plugins/builtin/broker-trading/pane-model.ts +207 -0
  119. package/src/plugins/builtin/broker-trading/pane.tsx +505 -0
  120. package/src/plugins/builtin/broker-trading/strategy.ts +153 -0
  121. package/src/plugins/builtin/broker-trading/ticket-parts.tsx +117 -0
  122. package/src/plugins/builtin/broker-trading/ticket.tsx +573 -0
  123. package/src/plugins/builtin/buildout/data-runtime.ts +1 -1
  124. package/src/plugins/builtin/buildout/format.ts +1 -0
  125. package/src/plugins/builtin/buildout/model/index.ts +16 -7
  126. package/src/plugins/builtin/buildout/pane/index.tsx +3 -2
  127. package/src/plugins/builtin/buildout/pane/status.tsx +3 -3
  128. package/src/plugins/builtin/chart-composer/cli-options.ts +19 -0
  129. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +2 -1
  130. package/src/plugins/builtin/chart-composer/headless-schema.ts +3 -3
  131. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  132. package/src/plugins/builtin/chart-composer/option-range.ts +39 -0
  133. package/src/plugins/builtin/chart-composer/pane.tsx +31 -11
  134. package/src/plugins/builtin/chart-composer/presets.ts +2 -2
  135. package/src/plugins/builtin/chart-composer/series-catalog.ts +4 -2
  136. package/src/plugins/builtin/chart-composer/shortcuts.ts +3 -3
  137. package/src/plugins/builtin/chart-composer/viewport-labels.ts +5 -2
  138. package/src/plugins/builtin/chat/index.tsx +1 -1
  139. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  140. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +6 -1
  141. package/src/plugins/builtin/chat/unread-inbox.ts +1 -1
  142. package/src/plugins/builtin/commodity-balances/client.ts +6 -4
  143. package/src/plugins/builtin/commodity-balances/headless.ts +41 -7
  144. package/src/plugins/builtin/commodity-balances/index.tsx +19 -4
  145. package/src/plugins/builtin/commodity-balances/model.ts +22 -0
  146. package/src/plugins/builtin/commodity-balances/pane.tsx +36 -5
  147. package/src/plugins/builtin/company-kpis/client.ts +3 -2
  148. package/src/plugins/builtin/company-kpis/headless.ts +5 -7
  149. package/src/plugins/builtin/company-kpis/model.ts +85 -2
  150. package/src/plugins/builtin/company-kpis/pane.tsx +26 -12
  151. package/src/plugins/builtin/composite-plugins.ts +3 -2
  152. package/src/plugins/builtin/cot/headless.ts +1 -1
  153. package/src/plugins/builtin/cot/index.tsx +1 -1
  154. package/src/plugins/builtin/cot/model.ts +35 -12
  155. package/src/plugins/builtin/credit-boards/cdx-pane.tsx +2 -1
  156. package/src/plugins/builtin/credit-boards/sovr-pane.tsx +2 -1
  157. package/src/plugins/builtin/credit-conditions/index.tsx +2 -1
  158. package/src/plugins/builtin/crypto-board/client.ts +33 -2
  159. package/src/plugins/builtin/crypto-board/headless.ts +5 -2
  160. package/src/plugins/builtin/crypto-board/model.ts +28 -8
  161. package/src/plugins/builtin/dividend-yield/headless.ts +30 -8
  162. package/src/plugins/builtin/dividend-yield/provider-client.ts +3 -1
  163. package/src/plugins/builtin/earnings-calls/headless.ts +4 -4
  164. package/src/plugins/builtin/earnings-calls/lock-note.ts +12 -3
  165. package/src/plugins/builtin/econ/calendar-model.ts +35 -0
  166. package/src/plugins/builtin/econ/index.tsx +31 -4
  167. package/src/plugins/builtin/econ/print-watch.ts +279 -0
  168. package/src/plugins/builtin/equity-screener/headless.ts +20 -5
  169. package/src/plugins/builtin/equity-screener/model.ts +40 -2
  170. package/src/plugins/builtin/equity-screener/pane.tsx +24 -21
  171. package/src/plugins/builtin/estimate-revisions/headless.ts +3 -2
  172. package/src/plugins/builtin/estimate-revisions/lock-note.ts +29 -7
  173. package/src/plugins/builtin/exposure/client.ts +4 -1
  174. package/src/plugins/builtin/exposure/editor.tsx +3 -3
  175. package/src/plugins/builtin/exposure/evidence.ts +4 -1
  176. package/src/plugins/builtin/exposure/holdings.ts +3 -1
  177. package/src/plugins/builtin/exposure/pane.tsx +37 -16
  178. package/src/plugins/builtin/filing-events/index.tsx +9 -3
  179. package/src/plugins/builtin/futures/table.tsx +6 -3
  180. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  181. package/src/plugins/builtin/insider/base-values.ts +72 -0
  182. package/src/plugins/builtin/insider/headless.ts +29 -4
  183. package/src/plugins/builtin/jobs/headless.ts +2 -2
  184. package/src/plugins/builtin/jobs/lock-note.ts +14 -3
  185. package/src/plugins/builtin/market-halts/model.ts +31 -6
  186. package/src/plugins/builtin/market-halts/pane.tsx +3 -0
  187. package/src/plugins/builtin/market-valuation/pane.tsx +2 -1
  188. package/src/plugins/builtin/members/pane.tsx +8 -3
  189. package/src/plugins/builtin/news/wire/feed-config.ts +29 -12
  190. package/src/plugins/builtin/news/wire/index.tsx +7 -2
  191. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +11 -3
  192. package/src/plugins/builtin/news/wire/rss/cli.ts +3 -2
  193. package/src/plugins/builtin/news/wire/rss/source.ts +13 -4
  194. package/src/plugins/builtin/options/footer.ts +25 -20
  195. package/src/plugins/builtin/options/live-quotes.ts +33 -18
  196. package/src/plugins/builtin/options/view.tsx +77 -22
  197. package/src/plugins/builtin/options-calculator/pane.tsx +4 -2
  198. package/src/plugins/builtin/options-scenario/pane.tsx +41 -6
  199. package/src/plugins/builtin/portfolio-list/account-metrics.ts +3 -0
  200. package/src/plugins/builtin/portfolio-list/cli/render.ts +34 -10
  201. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  202. package/src/plugins/builtin/portfolio-list/pane/data.ts +23 -1
  203. package/src/plugins/builtin/portfolio-list/pane/index.tsx +33 -14
  204. package/src/plugins/builtin/portfolio-list/settings.ts +20 -2
  205. package/src/plugins/builtin/portfolio-list/summary/index.tsx +12 -0
  206. package/src/plugins/builtin/portfolio-list/summary/totals.ts +24 -3
  207. package/src/plugins/builtin/primary-dealers/client.ts +137 -62
  208. package/src/plugins/builtin/primary-dealers/headless.ts +30 -5
  209. package/src/plugins/builtin/primary-dealers/index.tsx +3 -2
  210. package/src/plugins/builtin/primary-dealers/model.ts +179 -129
  211. package/src/plugins/builtin/primary-dealers/pane.tsx +135 -15
  212. package/src/plugins/builtin/research/analyst-model.ts +5 -1
  213. package/src/plugins/builtin/risk-factors/index.tsx +12 -3
  214. package/src/plugins/builtin/scanner/feed.ts +6 -1
  215. package/src/plugins/builtin/scanner/headless.ts +1 -1
  216. package/src/plugins/builtin/scanner/lock-note.ts +3 -2
  217. package/src/plugins/builtin/series-inventory.ts +1 -0
  218. package/src/plugins/builtin/shared/cloud-denied.ts +7 -6
  219. package/src/plugins/builtin/shared/cloud-upgrade.ts +10 -11
  220. package/src/plugins/builtin/shared/free-routes.ts +24 -0
  221. package/src/plugins/builtin/shared/headless-options.ts +11 -0
  222. package/src/plugins/builtin/shared/pro-lock-note.ts +89 -7
  223. package/src/plugins/builtin/shared/research-trading.tsx +62 -0
  224. package/src/plugins/builtin/social-mentions/headless.ts +1 -1
  225. package/src/plugins/builtin/social-mentions/lock-note.ts +1 -1
  226. package/src/plugins/builtin/themes/headless.ts +8 -2
  227. package/src/plugins/builtin/themes/model.ts +43 -10
  228. package/src/plugins/builtin/themes/pane.tsx +8 -6
  229. package/src/plugins/builtin/ticker-detail/overview/model.ts +44 -10
  230. package/src/plugins/builtin/ticker-detail/overview/option-contract.ts +64 -0
  231. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +82 -21
  232. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -14
  233. package/src/plugins/builtin/ticker-detail/quote-footer.ts +5 -1
  234. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +37 -8
  235. package/src/plugins/builtin/time-sales/pane.tsx +2 -2
  236. package/src/plugins/builtin/trace-bonds/client.ts +14 -3
  237. package/src/plugins/builtin/trace-bonds/headless.ts +1 -1
  238. package/src/plugins/builtin/vol-surface/pane.tsx +2 -2
  239. package/src/plugins/builtin-editorial.ts +1 -1
  240. package/src/plugins/compat.ts +3 -0
  241. package/src/plugins/external-runtime.ts +5 -0
  242. package/src/plugins/loader.ts +6 -0
  243. package/src/plugins/plugin-icon.ts +61 -0
  244. package/src/plugins/registry/host-actions.ts +2 -1
  245. package/src/plugins/registry/index.ts +9 -1
  246. package/src/plugins/runtime/context.tsx +5 -0
  247. package/src/plugins/runtime/index.tsx +3 -0
  248. package/src/portfolio/column-values.ts +13 -4
  249. package/src/portfolio/cost-fx.ts +5 -28
  250. package/src/public/__snapshots__/public-api.test.ts.snap +5 -0
  251. package/src/public/broker.ts +8 -0
  252. package/src/public/market-data.ts +5 -0
  253. package/src/renderers/dom/desktop/control-styles.ts +7 -1
  254. package/src/renderers/dom/desktop/controls.tsx +39 -7
  255. package/src/renderers/dom/desktop/icons.tsx +14 -0
  256. package/src/renderers/dom/desktop/list-view.tsx +51 -19
  257. package/src/renderers/dom/desktop/menu.tsx +10 -1
  258. package/src/renderers/dom/desktop/select-field.tsx +8 -6
  259. package/src/renderers/dom/host/style.ts +6 -1
  260. package/src/renderers/dom/host/tabs.tsx +6 -0
  261. package/src/renderers/dom/styles.css +12 -5
  262. package/src/renderers/electrobun/bun/core-capabilities.ts +12 -0
  263. package/src/renderers/electrobun/bun/external-plugins.ts +3 -0
  264. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  265. package/src/renderers/electrobun/view/broker-remote-client.ts +16 -0
  266. package/src/renderers/electrobun/view/external-plugins.ts +1 -0
  267. package/src/series/validate.ts +2 -2
  268. package/src/sources/gloomberb-cloud/index.ts +50 -17
  269. package/src/sources/gloomberb-cloud/normalizers.ts +4 -0
  270. package/src/sources/history-result.ts +3 -0
  271. package/src/sources/provider-errors.ts +3 -1
  272. package/src/sources/provider-router/history.ts +49 -6
  273. package/src/sources/provider-router/index.ts +2 -1
  274. package/src/state/app/activity.ts +13 -0
  275. package/src/sync/controller.ts +4 -0
  276. package/src/sync/core-contributors.ts +62 -11
  277. package/src/sync/local-only.ts +121 -0
  278. package/src/sync/react.ts +6 -0
  279. package/src/sync/types.ts +3 -0
  280. package/src/tickers/listing-fate.ts +27 -8
  281. package/src/tickers/search/index.ts +24 -3
  282. package/src/time-series/price-comparison.ts +35 -0
  283. package/src/time-series/resolve.ts +52 -11
  284. package/src/time-series/spec.ts +1 -1
  285. package/src/time-series/types.ts +5 -0
  286. package/src/types/broker.ts +56 -1
  287. package/src/types/config.ts +6 -0
  288. package/src/types/data-provider.ts +5 -1
  289. package/src/types/financials.ts +23 -0
  290. package/src/types/headless.ts +12 -2
  291. package/src/types/instrument.ts +4 -0
  292. package/src/types/plugin.ts +24 -6
  293. package/src/types/price-history.ts +6 -1
  294. package/src/types/trading.ts +67 -0
  295. package/src/ui/host.tsx +4 -0
  296. package/src/utils/base-currency.ts +108 -0
  297. package/src/utils/currency-names.ts +128 -0
  298. package/src/utils/currency-pair.ts +2 -1
  299. package/src/utils/currency-units.ts +32 -1
  300. package/src/utils/dom-screenshot.ts +22 -9
  301. package/src/utils/exchange-notation.generated.ts +360 -0
  302. package/src/utils/exchanges.ts +15 -322
  303. package/src/utils/format.ts +31 -1
  304. package/src/utils/instrument-identity.ts +38 -1
  305. package/src/utils/options.ts +46 -1
  306. package/src/utils/price-history.ts +4 -0
  307. package/src/utils/reported-money.ts +8 -4
@@ -2,6 +2,9 @@ import { apiClient } from "../../api-client";
2
2
  import type { NewsArticle } from "../../news/types";
3
3
  import { mapCloudNewsArticle } from "../../sources/gloomberb-cloud/news";
4
4
  import { cliStyles } from "../../utils/cli-output";
5
+ import { logFailure } from "../../utils/log-failure";
6
+ import { commodityNamedIn, commoditySearchName, isCommodityName } from "../../market-data/commodity-names";
7
+ import type { FuturesContract } from "../../market-data/futures-board";
5
8
 
6
9
  /** What the service accepts of a search: two characters to two hundred, with a letter or a number in them. */
7
10
  const MIN_TEXT_LENGTH = 2;
@@ -60,8 +63,8 @@ export function newsSearchTextProblem(text: string): string | null {
60
63
 
61
64
  /**
62
65
  * The word a symbol lookup found no stories for, as text to search stories for.
63
- * Null under three characters, where the fuzzy match finds names that merely
64
- * contain it (ZN, Telomir-Zn) and the lookup's own answer is the better one.
66
+ * Null under three characters, where the word is mostly a piece of other
67
+ * names (ZN in Telomir-Zn) and the lookup's own answer is the better one.
65
68
  */
66
69
  export function newsFallbackText(symbol: string): string | null {
67
70
  const text = symbol.trim().replace(/^\$/, "");
@@ -69,15 +72,39 @@ export function newsFallbackText(symbol: string): string | null {
69
72
  }
70
73
 
71
74
  /**
72
- * The stories matching the text, newest first. The service answers best match
73
- * first and holds stories back from an account without real-time access, as
74
- * every other news request.
75
+ * The stories matching the text, in the service's order: stories with every
76
+ * word in the headline first, then the ones with some only in the summary,
77
+ * newest first within each. The service holds stories back from an account
78
+ * without real-time access, as every other news request.
75
79
  */
76
80
  export async function searchNewsStories(text: string, limit: number): Promise<NewsArticle[]> {
77
81
  const response = await apiClient.searchCloudNews(text, limit);
78
- return response.items
79
- .map((item) => mapCloudNewsArticle(item))
80
- .sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
82
+ return response.items.map((item) => mapCloudNewsArticle(item));
83
+ }
84
+
85
+ /** The futures news route a search offers when no story matches it: `gloomberb news KC=F` for "arabica". */
86
+ export interface CommodityNewsRoute {
87
+ contract: FuturesContract;
88
+ command: string;
89
+ }
90
+
91
+ /**
92
+ * The future of the commodity a search names, offered when no story matched:
93
+ * `news KC=F` searches the stories of coffee, so it is offered only when
94
+ * those are not what just found nothing and they have a story to show.
95
+ */
96
+ export async function commodityNewsRoute(text: string): Promise<CommodityNewsRoute | null> {
97
+ const contract = commodityNamedIn(text);
98
+ const name = contract ? commoditySearchName(contract) : null;
99
+ if (!contract || !name || isCommodityName(text, name)) return null;
100
+ const stories = await searchNewsStories(name, 1).catch(logFailure("cli.news-search", "Search a commodity's stories failed", []));
101
+ return stories.length > 0 ? { contract, command: `gloomberb news ${contract.symbol}` } : null;
102
+ }
103
+
104
+ /** What a search that matched no story says: never a near miss, and the commodity's futures news where it has some. */
105
+ export function noStoriesMessage(text: string, route: CommodityNewsRoute | null): string {
106
+ const none = `No stories match "${text}".`;
107
+ return route ? `${none} For the ${route.contract.name} future, try: ${route.command}` : none;
81
108
  }
82
109
 
83
110
  /** The line above the stories: "Stories matching "european gas"", with why a word typed as a symbol is searched. */
@@ -5,6 +5,7 @@ import { saveConfig } from "../../data/config/store";
5
5
  import { builtinPluginGroupMembers } from "../../plugins/ownership";
6
6
  import type { AppConfig, TelemetryConfig } from "../../types/config";
7
7
  import { canonicalTimeZone } from "../../utils/utc-time";
8
+ import { readBaseCurrency } from "../../utils/base-currency";
8
9
  import { NIGHTLY_CHANNEL_SUMMARY, parseUpdateChannel, withUpdateChannel } from "../../updater/channel";
9
10
  import type { CliCommandDef } from "../../types/plugin";
10
11
  import { resolveCliDataDir, withCliServices, withConfigData } from "../context";
@@ -65,6 +66,32 @@ function parseTimezoneSetting(value: string): string | null {
65
66
  );
66
67
  }
67
68
 
69
+ /**
70
+ * The code `config set baseCurrency` stores (`cad` is CAD); fails on a name
71
+ * or sign, naming the code it stands for, and on anything that is no code.
72
+ */
73
+ function parseBaseCurrencySetting(value: string): string {
74
+ const reading = readBaseCurrency(value);
75
+ if ("code" in reading) return reading.code;
76
+ const [first] = reading.suggestions;
77
+ return fail(reading.error, first
78
+ ? `baseCurrency takes an ISO 4217 code: gloomberb config set baseCurrency ${first}`
79
+ : "baseCurrency takes an ISO 4217 code such as USD, EUR or CAD.");
80
+ }
81
+
82
+ /** A saved base currency that is no code (an older version stored names), with what to set instead; null for a code. */
83
+ function baseCurrencyProblem(value: unknown): string | null {
84
+ const reading = readBaseCurrency(typeof value === "string" ? value : "");
85
+ if ("code" in reading) return null;
86
+ const [first] = reading.suggestions;
87
+ return `${reading.error} Set one: gloomberb config set baseCurrency ${first ?? "<code>"}`;
88
+ }
89
+
90
+ function describeBaseCurrency(value: unknown): string {
91
+ const problem = baseCurrencyProblem(value);
92
+ return problem ? `${String(value)} ${cliStyles.warning(`(${problem})`)}` : String(value);
93
+ }
94
+
68
95
  function describeTimezone(value: unknown): string {
69
96
  return typeof value === "string" && value ? value : "not set, times print in UTC alone";
70
97
  }
@@ -189,6 +216,9 @@ export function createSystemCliCommands(): CliCommandDef[] {
189
216
  checks.push({ check: "database", status: "ok", detail: join(services.dataDir, ".gloomberb-cache.db") });
190
217
  checks.push({ check: "plugins", status: "ok", detail: String(services.services.pluginRegistry.allPlugins.size) });
191
218
  checks.push({ check: "capabilities", status: "ok", detail: String(services.services.pluginRegistry.capabilities.manifests().length) });
219
+ // Only a saved value no conversion can price is worth a line.
220
+ const baseCurrencyIssue = baseCurrencyProblem(services.config.baseCurrency);
221
+ if (baseCurrencyIssue) checks.push({ check: "baseCurrency", status: "warn", detail: baseCurrencyIssue });
192
222
  const theme = services.config.theme;
193
223
  checks.push({
194
224
  check: "theme",
@@ -311,6 +341,7 @@ export function createSystemCliCommands(): CliCommandDef[] {
311
341
  ...(key === KEYBINDINGS_CONFIG_KEY ? { format: summarizeKeybindings } : {}),
312
342
  ...(key === "theme" ? { format: (value: unknown) => describeThemeId(String(value)) } : {}),
313
343
  ...(key === TIMEZONE_CONFIG_KEY ? { format: describeTimezone } : {}),
344
+ ...(key === "baseCurrency" ? { format: describeBaseCurrency } : {}),
314
345
  })),
315
346
  });
316
347
  return;
@@ -329,7 +360,8 @@ export function createSystemCliCommands(): CliCommandDef[] {
329
360
  }
330
361
  ctx.printResult({ data: { key, value: safeConfig[key] } }, {
331
362
  text: (data) => key === KEYBINDINGS_CONFIG_KEY ? renderKeybindings(data.value)
332
- : key === TIMEZONE_CONFIG_KEY ? describeTimezone(data.value) : describeConfigValue(data.value),
363
+ : key === TIMEZONE_CONFIG_KEY ? describeTimezone(data.value)
364
+ : key === "baseCurrency" ? describeBaseCurrency(data.value) : describeConfigValue(data.value),
333
365
  });
334
366
  return;
335
367
  }
@@ -367,6 +399,8 @@ export function createSystemCliCommands(): CliCommandDef[] {
367
399
  // A display name may come unquoted: config set theme White Phosphor.
368
400
  const parsedValue = key === "theme"
369
401
  ? requireThemeId(args.slice(2).join(" "))
402
+ : key === "baseCurrency"
403
+ ? parseBaseCurrencySetting(args.slice(2).join(" "))
370
404
  : key === TIMEZONE_CONFIG_KEY
371
405
  ? parseTimezoneSetting(value)
372
406
  : key === UPDATE_CHANNEL_CONFIG_KEY
@@ -8,11 +8,12 @@ import { describeFundamentalMarketCap, marketCapShareBasisNote, selectMarketCapi
8
8
  import {
9
9
  formatCompact,
10
10
  formatCurrency,
11
+ formatCurrencyCode,
11
12
  formatNumber,
12
13
  formatPercent,
13
14
  formatPercentRaw,
14
15
  } from "../../utils/format";
15
- import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity, formatMarketChangeWithCurrency, quoteFormatOptions, withCurrencyMinorDigits, type MarketFormatOptions } from "../../market-data/market/format";
16
+ import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity, quoteFormatOptions, withCurrencyMinorDigits, type MarketFormatOptions } from "../../market-data/market/format";
16
17
  import {
17
18
  cliStyles,
18
19
  cliTerminalWidth,
@@ -28,14 +29,16 @@ import {
28
29
  getExtendedSessionDisplay,
29
30
  getQuoteSessionFields,
30
31
  getRegularSessionDisplay,
32
+ quoteBookSessionLabel,
31
33
  quoteHeadlineTime,
32
34
  quoteMarketStateLabel,
33
35
  } from "../../market-data/market/status";
34
36
  import type { FuturesContractTermsPayload } from "../../api-client/futures-curve";
35
- import { futuresContractLines, futuresSettleText, isFuturesInstrument, loadFuturesContractTerms } from "../../market-data/futures-contract";
37
+ import { changeBasisJson, quoteChangeBasis, quotePreviousCloseDate } from "../../market-data/market/change-basis";
38
+ import { futuresContractLines, futuresSettleText, isFuturesInstrument, loadFuturesContractTerms, minorUnitPricePer } from "../../market-data/futures-contract";
39
+ import { currencyCodeLabel } from "../../utils/currency-units";
36
40
  import type { AppConfig } from "../../types/config";
37
41
  import type { DeclaredDividendAction, FinancialStatement, Quote, TickerFinancials } from "../../types/financials";
38
- import { quoteTradingDay } from "../../market-data/quotes/day-range";
39
42
  import { atRegularSessionPrice, regularSessionValuationPrice } from "../../market-data/quotes/regular-session-valuation";
40
43
  import { computeTickerPriceReturns } from "../../market-data/ticker-price-returns";
41
44
  import { instrumentTypeLabel, resolveTickerInstrumentKind } from "../../tickers/instrument-kind";
@@ -46,7 +49,8 @@ import type { TickerRecord } from "../../types/ticker";
46
49
  import type { CliCommandContext } from "../../types/plugin";
47
50
  import { getPortfolioPositionMetrics, getPortfolioQuoteDisplay, lotCostFxRate, resolvePortfolioMarketValue, resolvePortfolioPositionPnl } from "../../portfolio/position-metrics";
48
51
  import { isFuturesTicker } from "../../portfolio/futures-positions";
49
- import { createBaseConverter } from "../base-converter";
52
+ import { createBaseConverter, type BaseConversion } from "../base-converter";
53
+ import { formatFxRate } from "../fx-pair";
50
54
  import { initMarketData, withMarketData } from "../context";
51
55
  import { fail } from "../errors";
52
56
  import type { MarketContext } from "../types";
@@ -58,6 +62,7 @@ import {
58
62
  formatSignedCurrency,
59
63
  formatTimestamp,
60
64
  formatWatchlistNames,
65
+ quoteChangeText,
61
66
  } from "../helpers";
62
67
  import { NotesFiles } from "../../plugins/builtin/notes/files";
63
68
  import { isKnownNonUsListing, secFilingItemCodes } from "../../utils/sec";
@@ -106,10 +111,6 @@ async function loadRecentSecFilings(
106
111
  // Prose such as a company description stays readable on wide terminals.
107
112
  const MAX_PROSE_WIDTH = 100;
108
113
  const METADATA_SEPARATOR = " · ";
109
- const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
110
- const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
111
- /** The states the header reads as CLOSED: the venue is between sessions, so nobody is quoting. */
112
- const CLOSED_MARKET_STATES = new Set<string>(["CLOSED", "PREPRE", "POSTPOST"]);
113
114
 
114
115
  interface TickerCommandDependencies {
115
116
  /** `--exchange`: the listing, for a symbol that trades in several places. */
@@ -149,25 +150,22 @@ function appendMetricSection(lines: string[], title: string, metrics: Array<[str
149
150
  }
150
151
 
151
152
  /**
152
- * A closed market's bid and ask are the last quote of its session, not an open book (ITA's $202.00 against
153
- * $208.74 around a $206.65 close): say so, with the day the quote is from.
153
+ * Out of the regular session the bid and ask say whose book they are: the pre-market or after-hours book beside
154
+ * Last and the extended print, or a closed market's last quote with its day (ITA's $202.00 against $208.74
155
+ * around a $206.65 close is no open book).
154
156
  */
155
157
  function bidAskText(quote: Quote, options: MarketFormatOptions): string {
156
158
  const text = formatBidAsk(quote.bid, quote.ask, quote.bidSize, quote.askSize, quote.currency, options);
157
- if (text === "—" || !quote.marketState || !CLOSED_MARKET_STATES.has(quote.marketState)) return text;
158
- const day = quoteTradingDay(quote);
159
- const date = day ? new Date(`${day}T00:00:00Z`) : null;
160
- const label = date && Number.isFinite(date.getTime())
161
- ? `, ${WEEKDAYS[date.getUTCDay()]} ${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}` : "";
162
- return `${text} ${cliStyles.muted(`(last quote${label})`)}`;
159
+ const label = text === "—" ? null : quoteBookSessionLabel(quote);
160
+ return label ? `${text} ${cliStyles.muted(`(${label})`)}` : text;
163
161
  }
164
162
 
165
163
  function wrapProse(text: string): string {
166
164
  return wrapText(text, Math.min(cliTerminalWidth() ?? MAX_PROSE_WIDTH, MAX_PROSE_WIDTH)).join("\n");
167
165
  }
168
166
 
169
- function buildStatementMetrics(statement: FinancialStatement, currency?: string): Array<[string, string]> {
170
- const money = (value: number | undefined, perShare = false) => formatReportedMoney(value, currency, perShare);
167
+ function buildStatementMetrics(statement: FinancialStatement, currency?: string, codes = false): Array<[string, string]> {
168
+ const money = (value: number | undefined, perShare = false) => formatReportedMoney(value, currency, perShare, codes);
171
169
  return [
172
170
  ["Revenue", money(statement.totalRevenue)],
173
171
  ["Gross Profit", money(statement.grossProfit)],
@@ -303,8 +301,11 @@ function shouldFetchSecFilings(
303
301
  }
304
302
 
305
303
  async function appendTickerPositions(lines: string[], tickerFile: TickerRecord | null, quote: TickerFinancials["quote"],
306
- config: AppConfig, toBase: (value: number, currency: string) => Promise<number>): Promise<void> {
304
+ config: AppConfig, toBase: ReportBaseConverter): Promise<void> {
307
305
  const quoteCurrency = quote?.currency?.trim() || tickerFile?.metadata.currency?.trim() || "";
306
+ const codes = writesCurrencyCodes(config);
307
+ const money = codes ? formatCurrencyCode : formatCurrency;
308
+ const converted = new Set<string>();
308
309
  if (tickerFile && tickerFile.metadata.positions.length > 0) {
309
310
  lines.push("");
310
311
  lines.push(renderSection("Positions"));
@@ -319,6 +320,8 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
319
320
  const currentPrice = getPortfolioQuoteDisplay(metrics, quote)?.price ?? null;
320
321
  const currentPriceBase = currentPrice != null && quoteCurrency ? await toBase(currentPrice, quoteCurrency) : null;
321
322
  const positionRate = positionCurrency ? await toBase(1, positionCurrency) : Number.NaN;
323
+ if (positionCurrency) converted.add(positionCurrency.toUpperCase());
324
+ if (quoteCurrency && currentPrice != null) converted.add(quoteCurrency.toUpperCase());
322
325
  const baseMetrics = getPortfolioPositionMetrics({ ...tickerFile, metadata: { ...tickerFile.metadata, positions: [position] } }, undefined, quoteCurrency,
323
326
  { currency: config.baseCurrency, convert: value => value * positionRate }, quote);
324
327
  // A cost bought at an entered rate into the base currency converts at it, as its P&L does; any other at today's.
@@ -334,26 +337,51 @@ async function appendTickerPositions(lines: string[], tickerFile: TickerRecord |
334
337
  const stats: CliStatEntry[] = [
335
338
  [
336
339
  "Position",
337
- `${formatMarketQuantity(metrics.totalShares, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, quantityCurrency: positionCurrency })} ${metrics.priceBasis === "percent-of-par" ? "@" : `${tickerFile.metadata.assetCategory === "BOND" ? "units" : contracts ? "contracts" : "shares"} @`} ${positionCurrency ? formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis }) : "—"}`,
340
+ `${formatMarketQuantity(metrics.totalShares, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, quantityCurrency: positionCurrency })} ${metrics.priceBasis === "percent-of-par" ? "@" : `${tickerFile.metadata.assetCategory === "BOND" ? "units" : contracts ? "contracts" : "shares"} @`} ${positionCurrency ? formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, currencyCode: codes }) : "—"}`,
338
341
  ],
339
- ["Cost Basis", formatCurrency(costBasisBase, config.baseCurrency)],
340
- ["Market Value", formatCurrency(marketValueBase, config.baseCurrency)],
342
+ ["Cost Basis", money(costBasisBase, config.baseCurrency)],
343
+ ["Market Value", money(marketValueBase, config.baseCurrency)],
341
344
  [
342
345
  selectedPnl.basis === "broker-snapshot" ? "Broker P&L" : "P&L",
343
- pnl === null ? "—" : colorBySign(formatSignedCurrency(pnl, config.baseCurrency), pnl),
346
+ pnl === null ? "—" : colorBySign(formatSignedCurrency(pnl, config.baseCurrency, codes), pnl),
344
347
  ],
345
348
  ];
346
349
  if (position.markPrice != null) {
347
- stats.push(["Broker Mark", positionCurrency ? formatMarketPriceWithCurrency(position.markPrice, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis }) : "—"]);
350
+ stats.push(["Broker Mark", positionCurrency ? formatMarketPriceWithCurrency(position.markPrice, positionCurrency, { assetCategory: tickerFile.metadata.assetCategory, multiplier: position.multiplier, priceBasis: metrics.priceBasis, currencyCode: codes }) : "—"]);
348
351
  }
349
352
  lines.push(renderStats(stats));
350
353
  if (index < positions.length - 1) {
351
354
  lines.push("");
352
355
  }
353
356
  }
357
+ for (const note of conversionNotes(toBase, converted)) lines.push(cliStyles.muted(note));
354
358
  }
355
359
  }
356
360
 
361
+ /** The ticker report's converter: what `createBaseConverter` makes, whose rates a report states; a plain function in tests. */
362
+ type ReportBaseConverter = ((value: number, fromCurrency: string) => Promise<number>) & { conversions?(): BaseConversion[] };
363
+
364
+ /**
365
+ * A base currency other than USD reads every amount with its code (259.36
366
+ * USD, 878.15B CAD): $ alone is also the Canadian, Australian and other
367
+ * dollars. A USD base keeps the signs it always had.
368
+ */
369
+ export function writesCurrencyCodes(config: Pick<AppConfig, "baseCurrency"> | undefined): boolean {
370
+ const base = config?.baseCurrency?.trim().toUpperCase();
371
+ return !!base && base !== "USD";
372
+ }
373
+
374
+ /**
375
+ * "CAD at USD/CAD 1.40623, as of 2026-10-21 04:46 UTC.": the rate and its
376
+ * date for each currency in `currencies` the report converted into the base
377
+ * currency, so a converted figure is never without what it was converted at.
378
+ */
379
+ function conversionNotes(toBase: ReportBaseConverter, currencies: ReadonlySet<string>): string[] {
380
+ return (toBase.conversions?.() ?? [])
381
+ .filter((conversion) => currencies.has(conversion.currency) && conversion.currency !== conversion.baseCurrency)
382
+ .map(({ currency, baseCurrency, rate, asOf }) => `${baseCurrency} figures at ${currency}/${baseCurrency} ${formatFxRate(rate)}${asOf != null ? `, as of ${formatTimestamp(asOf)}` : ""}.`);
383
+ }
384
+
357
385
  /** A source's enterprise value is in the units of the capitalization it reports beside it,
358
386
  * which without a declared unit is the listing's quote currency (as the overview labels it). */
359
387
  function enterpriseValueCurrency(
@@ -378,9 +406,9 @@ interface FundamentalsMetric {
378
406
 
379
407
  type MetricFigure = Omit<FundamentalsMetric, "label">;
380
408
 
381
- /** An amount in the currency the source reported it in; `ccy?` when it named none, as the text says. */
382
- function reportedMoney(value: number | undefined, currency: string | undefined, perShare = false): MetricFigure {
383
- return { text: formatReportedMoney(value, currency, perShare), value, unit: currency?.trim() || "ccy?" };
409
+ /** An amount in the currency the source reported it in; `ccy?` when it named none, as the text says. `codes`: per share too (6.54 USD). */
410
+ function reportedMoney(value: number | undefined, currency: string | undefined, perShare = false, codes = false): MetricFigure {
411
+ return { text: formatReportedMoney(value, currency, perShare, codes), value, unit: currency?.trim() || "ccy?" };
384
412
  }
385
413
 
386
414
  /** The EV line: labelled "(provider)" when it is more than 5% from market cap + debt - cash, "unavailable" when withdrawn. */
@@ -570,6 +598,7 @@ function fundamentalsMetrics(
570
598
  enterpriseValue = reportedMoney(reportedEnterpriseValue(fundamentals), enterpriseValueCurrency(quote, fundamentals)),
571
599
  distributions: DividendDistributions | null = null,
572
600
  bond: FundBondFacts | null = null,
601
+ codes = false,
573
602
  ): FundamentalsMetric[] {
574
603
  const signed = (value: number) => colorBySign(formatPercent(value), value);
575
604
  const receipt = fundamentals?.sharesOutstanding != null && sharesOutstandingInReceipts(quote, fundamentals, profile?.description);
@@ -581,7 +610,7 @@ function fundamentalsMetrics(
581
610
  { label: "Forward P/E", ...multipleFigure(priceEarningsOnEarnings(fundamentals?.forwardPE, fundamentals?.forwardEps)) },
582
611
  { label: "PEG", ...multipleFigure(fundamentals?.pegRatio) },
583
612
  // The flows and EPS are one trailing-twelve-month block, the same twelve months for each line.
584
- { label: "EPS (TTM)", ...reportedMoney(fundamentals?.eps, fundamentals?.epsCurrency?.trim() || reportingCurrency, true) },
613
+ { label: "EPS (TTM)", ...reportedMoney(fundamentals?.eps, fundamentals?.epsCurrency?.trim() || reportingCurrency, true, codes) },
585
614
  {
586
615
  label: dividendYieldLabel(fundamentals?.dividendYieldBasis, bond),
587
616
  ...forwardYieldFigure(fundamentals, distributions),
@@ -684,6 +713,8 @@ type FundamentalsReportData = TickerFinancials & {
684
713
  bank?: BankFiguresPayload | null;
685
714
  /** In place of `bank`, for a bank whose figures could not be read. */
686
715
  bankNote?: string | null;
716
+ /** A base currency other than USD: every amount reads with its code (`writesCurrencyCodes`). */
717
+ currencyCodes?: boolean;
687
718
  };
688
719
 
689
720
  /** "Latest quarter 2026-06-30, 10-Q filed 2026-08-06." and the newer quarter's results release, for the lines under a report's figures. */
@@ -723,7 +754,10 @@ function valuationPriceMetrics(financials: FundamentalsReportData): Fundamentals
723
754
  const quote = financials.quote;
724
755
  const session = getRegularSessionDisplay(quote);
725
756
  if (!quote || !session) return [];
726
- const options = withCurrencyMinorDigits(quoteFormatOptions(quote, undefined, financials.quoteMetadata?.instrumentType), quote.currency);
757
+ const options = withCurrencyMinorDigits({
758
+ ...quoteFormatOptions(quote, undefined, financials.quoteMetadata?.instrumentType),
759
+ ...financials.currencyCodes ? { currencyCode: true } : {},
760
+ }, quote.currency);
727
761
  const price = (value: number) => formatMarketPriceWithCurrency(value, quote.currency, options);
728
762
  const extended = getExtendedSessionDisplay(quote);
729
763
  const extendedLabel = extended ? EXTENDED_SESSION_LABELS[extended.session] : "";
@@ -732,7 +766,8 @@ function valuationPriceMetrics(financials: FundamentalsReportData): Fundamentals
732
766
  session.changePercent != null && Number.isFinite(session.changePercent)
733
767
  ? {
734
768
  label: "Change",
735
- text: colorBySign(`${formatMarketChangeWithCurrency(session.change, quote.currency, options, session.price)} (${formatPercentRaw(session.changePercent)})`, session.change),
769
+ // With what the move is measured from, as `ticker` and `quote` say it: "vs Fri 16 Oct close".
770
+ text: quoteChangeText(quote, session, options),
736
771
  value: session.changePercent,
737
772
  unit: "%",
738
773
  }
@@ -767,7 +802,7 @@ function fundamentalsReportMetrics(snapshot: FundamentalsReportData, view: "fund
767
802
  ? { text: `${formatCompact(capitalization.value)} ${capitalization.currency}`, value: capitalization.value, unit: capitalization.currency }
768
803
  : { text: "—" };
769
804
  const metrics = fundamentalsMetrics(quote, fundamentals, financials.profile, marketCap, computeTickerPriceReturns(financials), fundamentalsCurrency(financials),
770
- undefined, snapshot.distributions ?? null, snapshot.bondFund ?? null);
805
+ undefined, snapshot.distributions ?? null, snapshot.bondFund ?? null, snapshot.currencyCodes === true);
771
806
  if (view === "fundamentals") {
772
807
  return [...metrics, ...floatAndShortInterestMetrics(quote, fundamentals, financials.profile)]
773
808
  .filter((metric) => metric.text !== "—");
@@ -927,7 +962,7 @@ export async function buildTickerReport({
927
962
  tickerFile: TickerRecord | null;
928
963
  financials: TickerFinancials;
929
964
  config: AppConfig;
930
- toBase: (value: number, fromCurrency: string) => Promise<number>;
965
+ toBase: ReportBaseConverter;
931
966
  notes?: string;
932
967
  /** Why there is no quote, when a source said; "Quote unavailable." otherwise. */
933
968
  quoteNote?: string;
@@ -944,8 +979,10 @@ export async function buildTickerReport({
944
979
  const profile = financials.profile;
945
980
  const name = quote?.name || tickerFile?.metadata.name || symbol;
946
981
  const baseQuoteOptions = quoteFormatOptions(quote, tickerFile?.metadata.assetCategory, financials.quoteMetadata?.instrumentType);
982
+ const baseCurrency = config.baseCurrency.trim().toUpperCase();
983
+ const codes = writesCurrencyCodes(config);
947
984
  // Pad money prices to the currency's minor unit so a range reads £35.10 - £35.485, never past it (JPY has none).
948
- const quoteOptions = withCurrencyMinorDigits(baseQuoteOptions, quote?.currency);
985
+ const quoteOptions = withCurrencyMinorDigits(codes ? { ...baseQuoteOptions, currencyCode: true } : baseQuoteOptions, quote?.currency);
949
986
  const lines: string[] = [];
950
987
 
951
988
  lines.push(`${cliStyles.accent(quote?.symbol ?? symbol)} ${cliStyles.bold(name)}`);
@@ -956,9 +993,10 @@ export async function buildTickerReport({
956
993
  const indexLevel = isIndexLevel(quote?.instrumentType?.trim() || financials.quoteMetadata?.instrumentType?.trim() || tickerFile?.metadata.assetCategory);
957
994
  const summaryParts = [
958
995
  listingVenueLabel(listingExchange, quote, financials, tickerFile) || undefined,
959
- indexLevel ? INDEX_POINTS_LABEL : quoteCurrency ? `Currency ${quoteCurrency}` : undefined,
996
+ indexLevel ? INDEX_POINTS_LABEL : quoteCurrency ? `Currency ${currencyCodeLabel(quoteCurrency)}` : undefined,
960
997
  quoteMarketStateLabel(quote) ?? undefined,
961
- quote?.dataSource ? `Source ${quote.dataSource.toUpperCase()}` : undefined,
998
+ // A listing that no longer trades has no delay to speak of: its quote is its last session's close.
999
+ quote?.dataSource && !quote.delisting ? `Source ${quote.dataSource.toUpperCase()}` : undefined,
962
1000
  ].filter((part): part is string => !!part);
963
1001
  if (summaryParts.length > 0) {
964
1002
  lines.push(cliStyles.muted(summaryParts.join(METADATA_SEPARATOR)));
@@ -994,21 +1032,25 @@ export async function buildTickerReport({
994
1032
  // Market cap and the multiples are measured at the regular session price the Quote section heads with.
995
1033
  const priced = atRegularSessionPrice(financials);
996
1034
  const capitalization = selectMarketCapitalization(priced.quote, priced.fundamentals);
997
- const convertedMarketCap = capitalization ? await toBase(capitalization.value, capitalization.currency) : Number.NaN;
1035
+ // Market cap and EV read in the currency they are reported in, as the revenue and income beside them do,
1036
+ // with the base currency's figure after them when it differs: 627.2B USD (878.15B CAD).
1037
+ const convertedCurrencies = new Set<string>();
1038
+ const inBase = async (value: number, currency: string): Promise<string> => {
1039
+ if (currency.trim().toUpperCase() === baseCurrency) return "";
1040
+ const converted = await toBase(value, currency);
1041
+ if (!Number.isFinite(converted)) return "";
1042
+ convertedCurrencies.add(currency.trim().toUpperCase());
1043
+ return ` (${formatCompact(converted)} ${baseCurrency})`;
1044
+ };
998
1045
  const marketCapText = capitalization
999
- ? Number.isFinite(convertedMarketCap)
1000
- ? `${formatCompact(convertedMarketCap)} ${config.baseCurrency}`
1001
- : `${formatCompact(capitalization.value)} ${capitalization.currency}`
1046
+ ? `${formatCompact(capitalization.value)} ${capitalization.currency}${await inBase(capitalization.value, capitalization.currency)}`
1002
1047
  : "—";
1003
1048
  // Shown in the same currency as the market cap, so the two can be compared.
1004
1049
  const enterpriseValue = reportedEnterpriseValue(priced.fundamentals);
1005
1050
  const evCurrency = enterpriseValueCurrency(quote, fundamentals);
1006
1051
  // A minor unit such as GBp stays as reported: the converter would read it as the major currency.
1007
- const convertedEnterpriseValue = enterpriseValue != null && evCurrency && /^[A-Z]{3}$/.test(evCurrency)
1008
- ? await toBase(enterpriseValue, evCurrency) : Number.NaN;
1009
- const enterpriseValueText = Number.isFinite(convertedEnterpriseValue)
1010
- ? `${formatCompact(convertedEnterpriseValue)} ${config.baseCurrency}`
1011
- : formatReportedMoney(enterpriseValue, evCurrency);
1052
+ const enterpriseValueText = `${formatReportedMoney(enterpriseValue, evCurrency)}${enterpriseValue != null && evCurrency && /^[A-Z]{3}$/.test(evCurrency)
1053
+ ? await inBase(enterpriseValue, evCurrency) : ""}`;
1012
1054
 
1013
1055
  if (quote) {
1014
1056
  // As the Overview reads it: the regular session, then the extended-hours print against its close.
@@ -1023,13 +1065,16 @@ export async function buildTickerReport({
1023
1065
  const volume = reportedQuoteVolume(quote);
1024
1066
  // A futures price is its latest trade; the exchange settlement is its own row, dated by its session.
1025
1067
  appendMetricSection(lines, "Quote", [
1026
- ["Last", colorBySign(formatMarketPriceWithCurrency(session.price, quote.currency, quoteOptions), session.change)],
1068
+ // A price in cents says what it is per: 314.95¢/lb.
1069
+ ["Last", colorBySign(`${formatMarketPriceWithCurrency(session.price, quote.currency, quoteOptions)}${minorUnitPricePer(futuresContract, quote.currency)}`, session.change)],
1027
1070
  ...futuresContract
1028
1071
  ? [["Settle", futuresSettleText(futuresContract, quote)] as [string, string]]
1029
1072
  : [],
1030
- ["Change", colorBySign(`${formatMarketChangeWithCurrency(session.change, quote.currency, quoteOptions, session.price)} (${formatPercentRaw(session.changePercent)})`, session.change)],
1073
+ // The move says what it is measured from: "vs Fri 16 Oct close", a future's "vs Fri 16 Oct settle".
1074
+ ["Change", quoteChangeText(quote, session, quoteOptions, futuresContract)],
1031
1075
  ["Open", quote.open != null ? formatMarketPriceWithCurrency(quote.open, quote.currency, quoteOptions) : "—"],
1032
- ["Day Range", quote.low != null || quote.high != null
1076
+ // A listing that no longer trades: its last session's range, never today's.
1077
+ [quote.delisting ? "Last Session" : "Day Range", quote.low != null || quote.high != null
1033
1078
  ? formatPriceRange(quote.low, quote.high, quote.currency, quoteOptions)
1034
1079
  : "—"],
1035
1080
  ["52W Range", quote.low52w != null || quote.high52w != null
@@ -1050,10 +1095,11 @@ export async function buildTickerReport({
1050
1095
  }
1051
1096
 
1052
1097
  const metrics = fundamentalsMetrics(quote, priced.fundamentals, profile, { text: marketCapText }, priceReturns, fundamentalsCurrency(financials),
1053
- { text: enterpriseValueText }, distributions, bondFund);
1098
+ { text: enterpriseValueText }, distributions, bondFund, codes);
1054
1099
  const beforeFundamentals = lines.length;
1055
1100
  appendMetricSection(lines, "Fundamentals", metricLines(metrics));
1056
1101
  for (const note of fundamentalsNotes(priced, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
1102
+ if (lines.length > beforeFundamentals) for (const note of conversionNotes(toBase, convertedCurrencies)) lines.push(cliStyles.muted(note));
1057
1103
  // Where a fund's facts would go: under its fundamentals, or in their place.
1058
1104
  const fund = fundFactsNote(quote?.symbol ?? symbol,
1059
1105
  quote?.tradingExchangeName || listingExchange || quote?.listingExchangeName || quote?.exchangeName || tickerFile?.metadata.exchange, financials, tickerFile);
@@ -1071,13 +1117,13 @@ export async function buildTickerReport({
1071
1117
  const latestAnnual = latestFinancialPeriod(financials.annualStatements, row => row.date);
1072
1118
  if (latestAnnual) {
1073
1119
  appendMetricSection(lines, `Latest Annual (${statementPeriodText(latestAnnual.date, statementFilings?.latestAnnual ?? null)})`,
1074
- buildStatementMetrics(latestAnnual, statementCurrency(latestAnnual)));
1120
+ buildStatementMetrics(latestAnnual, statementCurrency(latestAnnual), codes));
1075
1121
  }
1076
1122
 
1077
1123
  const latestQuarter = latestFinancialPeriod(financials.quarterlyStatements, row => row.date);
1078
1124
  if (latestQuarter) {
1079
1125
  appendMetricSection(lines, `Latest Quarter (${statementPeriodText(latestQuarter.date, statementFilings?.latestQuarter ?? null)})`,
1080
- buildStatementMetrics(latestQuarter, statementCurrency(latestQuarter)));
1126
+ buildStatementMetrics(latestQuarter, statementCurrency(latestQuarter), codes));
1081
1127
  }
1082
1128
  // The statements stop a quarter short of the results the company has released.
1083
1129
  if (statementFilings?.resultsRelease && (latestAnnual || latestQuarter)) lines.push(cliStyles.muted(resultsReleaseText(statementFilings.resultsRelease)));
@@ -1161,6 +1207,10 @@ function buildTickerStructuredData({
1161
1207
  name: quote.name,
1162
1208
  // Last and Change as the text report reads them, then the extended print from that close.
1163
1209
  ...getQuoteSessionFields(quote),
1210
+ // What Change is measured from ("vs Fri 16 Oct close"), and the quote's previous close with its exchange-local date.
1211
+ changeBasis: changeBasisJson(quoteChangeBasis(quote, { terms: futuresContract })),
1212
+ previousClose: quote.previousClose ?? null,
1213
+ previousCloseDate: quotePreviousCloseDate(quote),
1164
1214
  priceBasis: quote.priceBasis ?? null,
1165
1215
  currency: quote.currency,
1166
1216
  marketCap: priced.quote?.marketCap ?? null,
@@ -0,0 +1,27 @@
1
+ import { isVenueAbroad } from "../tickers/listing-fate";
2
+ import type { Quote } from "../types/financials";
3
+ import { isCurrencyCode } from "../utils/currency-pair";
4
+ import { canonicalExchange } from "../utils/exchanges";
5
+ import { UNLISTED_TYPES, type CliListing } from "./listing-arg";
6
+
7
+ // Quoted as dollars per unit (EUR/USD); every other currency as units per dollar (USD/MXN).
8
+ const DOLLARS_PER_UNIT = new Set(["EUR", "GBP", "AUD", "NZD"]);
9
+
10
+ /**
11
+ * "MXN is also a currency: fx MXN, quote USD/MXN" for a bare currency code
12
+ * that read as a listing abroad (MXN is mixi Inc on Stuttgart's exchange): the
13
+ * quote is that company's, and a currency was likely what was typed. Null for
14
+ * a code that is no currency, for the dollar (a US fund trades as USD, and
15
+ * there is no USD/USD), for a listing that named or saved its venue, and for
16
+ * one in the US.
17
+ */
18
+ export function currencyCodeNote(
19
+ listing: Pick<CliListing, "symbol" | "exchange" | "saved">,
20
+ quote: Pick<Quote, "listingExchangeName" | "exchangeName" | "instrumentType"> | null | undefined,
21
+ ): string | null {
22
+ const code = listing.symbol;
23
+ if (!quote || listing.exchange || listing.saved || code === "USD" || !/^[A-Z]{3}$/.test(code) || !isCurrencyCode(code)) return null;
24
+ if (UNLISTED_TYPES.has(quote.instrumentType?.trim().toUpperCase() ?? "")) return null;
25
+ if (!isVenueAbroad(canonicalExchange(quote.listingExchangeName || quote.exchangeName))) return null;
26
+ return `${code} is also a currency: fx ${code}, quote ${DOLLARS_PER_UNIT.has(code) ? `${code}/USD` : `USD/${code}`}`;
27
+ }
@@ -1,5 +1,6 @@
1
1
  import { currencyMinorDigits } from "../market-data/market/format";
2
2
  import { parseTypedNumber, readCurrencyAmount } from "../utils/decimal-comma";
3
+ import { currenciesNamed } from "../utils/currency-names";
3
4
 
4
5
  /**
5
6
  * What `gloomberb fx` is asked for. A bare code is one unit of it in the base
@@ -12,6 +13,8 @@ export interface FxRequest {
12
13
  currency: string;
13
14
  /** The currency the price is in: the base currency for a bare code, the quote of a pair. */
14
15
  baseCurrency: string;
16
+ /** Each currency typed as its name (`yen`) and the code it was read as, said beside the answer. */
17
+ readAs?: string[];
15
18
  }
16
19
 
17
20
  const CURRENCY_CODE = /^[A-Z]{3}$/;
@@ -30,19 +33,45 @@ function isKnownCurrency(code: string): boolean {
30
33
  return knownCurrencies.has(code);
31
34
  }
32
35
 
36
+ /**
37
+ * A currency typed as its name: the code a name that is one currency's reads
38
+ * as, or the sentence listing the currencies a name several share (`peso`),
39
+ * with `retry` the argument that asks for the first of them. Null for text
40
+ * that names none.
41
+ */
42
+ function readCurrencyName(typed: string, retry: (code: string) => string): { code: string } | { error: string } | null {
43
+ const named = currenciesNamed(typed);
44
+ if (named.length === 0) return null;
45
+ if (named.length === 1) return { code: named[0]!.code };
46
+ const choices = named.map(({ code, qualifier }) => (qualifier ? `${code} (${qualifier})` : code)).join(", ");
47
+ return { error: `${typed} names several currencies: ${choices}. Write one as its code, such as fx ${retry(named[0]!.code)}.` };
48
+ }
49
+
33
50
  /** The request, or the sentence saying what is wrong with the argument. */
34
51
  export function parseFxRequest(raw: string, baseCurrency: string): FxRequest | { error: string } {
52
+ const typed = raw.trim().split("/").map((part) => part.trim());
35
53
  const text = raw.trim().toUpperCase();
36
- const parts = text.split("/").map((part) => part.trim());
54
+ const parts = typed.map((part) => part.toUpperCase());
37
55
  if (parts.length > 2 || parts.some((part) => !part)) return { error: `"${raw}" is not a currency or a pair such as USD/NGN.` };
38
56
  // EURUSD is the pair typed without its slash: say how to write it.
39
57
  const joined = parts.length === 1 && /^[A-Z]{6}$/.test(text) && isKnownCurrency(text.slice(0, 3)) && isKnownCurrency(text.slice(3));
40
58
  if (joined) return { error: `${text} is not a currency code; write the pair as ${text.slice(0, 3)}/${text.slice(3)}.` };
59
+ // A name (peso, yen, Swiss franc) is a currency's code. Three letters that are a code stay one: only a name that is no code reads as one.
60
+ const readAs: string[] = [];
61
+ for (const [index, part] of parts.entries()) {
62
+ if (isKnownCurrency(part)) continue;
63
+ const named = readCurrencyName(typed[index]!, (code) => parts.map((other, at) => at === index ? code : other).join("/"));
64
+ if (!named) continue;
65
+ if ("error" in named) return named;
66
+ readAs.push(`Read ${typed[index]} as ${named.code}.`);
67
+ parts[index] = named.code;
68
+ }
41
69
  const bad = parts.filter((part) => !CURRENCY_CODE.test(part));
42
70
  if (bad.length > 0) return { error: `${bad.join(" and ")} ${bad.length > 1 ? "are not currency codes" : "is not a currency code"}; use three letters such as NGN.` };
43
- return parts.length === 2
71
+ const request = parts.length === 2
44
72
  ? { currency: parts[0]!, baseCurrency: parts[1]! }
45
73
  : { currency: parts[0]!, baseCurrency: baseCurrency.trim().toUpperCase() };
74
+ return readAs.length > 0 ? { ...request, readAs } : request;
46
75
  }
47
76
 
48
77
  /** What `gloomberb fx` is asked for, with the amount to convert when one comes first. */
@@ -55,7 +84,14 @@ export interface FxArgs {
55
84
  }
56
85
 
57
86
  /** Every form `fx` takes, for its usage line. */
58
- export const FX_USAGE = "fx <currency> | fx <base>/<quote> | fx <amount> <from>/<to> | fx <amount> <from> <to>";
87
+ export const FX_USAGE = "fx <currency> | fx <base>/<quote> | fx <amount> <from>/<to> | fx <amount> <from> <to> [--date <day>]";
88
+
89
+ /** A currency's name of several words (`hong kong dollar`, `100 swiss franc`) as the one argument it is. */
90
+ function joinNameWords(args: readonly string[]): readonly string[] {
91
+ const amount = args.length > 2 && parseTypedNumber(args[0]).kind !== "invalid" ? 1 : 0;
92
+ const name = args.slice(amount).join(" ");
93
+ return args.length - amount > 1 && currenciesNamed(name).length > 0 ? [...args.slice(0, amount), name] : args;
94
+ }
59
95
 
60
96
  /**
61
97
  * `fx` arguments: a currency or a pair (`EUR`, `USD/NGN`), or an amount and
@@ -66,7 +102,8 @@ export const FX_USAGE = "fx <currency> | fx <base>/<quote> | fx <amount> <from>/
66
102
  * refused naming both readings. An amount and one currency converts to USD
67
103
  * (`toUsdByDefault`). The error sentence otherwise.
68
104
  */
69
- export function parseFxArgs(args: readonly string[], baseCurrency: string): FxArgs | { error: string } {
105
+ export function parseFxArgs(words: readonly string[], baseCurrency: string): FxArgs | { error: string } {
106
+ const args = joinNameWords(words);
70
107
  if (args.length === 1) {
71
108
  const request = parseFxRequest(args[0]!, baseCurrency);
72
109
  if (!("error" in request)) return { request };