gloomberb 0.16.2 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
@@ -2,6 +2,7 @@ import { RGBA, StyledText as OpenTuiStyledText, SyntaxStyle, TextAttributes as O
2
2
  import { createElement, forwardRef, useEffect, useRef, useState, type ReactNode } from "react";
3
3
  import { TextAttributes, type UiHost, type TextProps } from "../../ui/host";
4
4
  import { renderAsciiText } from "../../ui/ascii-font";
5
+ import { scrollBoxScrollsVertically } from "../../ui/scroll-box-axis";
5
6
  import { OpenTuiImageSurface } from "./image/surface";
6
7
  import { OpenTuiChartSurface } from "./chart-surface";
7
8
 
@@ -141,7 +142,12 @@ function mapTextContent(content: unknown): unknown {
141
142
  }
142
143
 
143
144
  const OpenTuiBox = createOpenTuiPrimitive("box");
144
- const OpenTuiScrollBox = createOpenTuiPrimitive("scrollbox");
145
+ const OpenTuiScrollBoxPrimitive = createOpenTuiPrimitive("scrollbox");
146
+ // The native box scrolls vertically unless scrollY is false; the shared rule
147
+ // also keeps the one-row horizontal strips from scrolling on that axis.
148
+ const OpenTuiScrollBox = forwardRef<unknown, OpenTuiPrimitiveProps>(function OpenTuiScrollBox(props, ref) {
149
+ return createElement(OpenTuiScrollBoxPrimitive, { ...props, scrollY: scrollBoxScrollsVertically(props), ref });
150
+ });
145
151
  const OpenTuiInput = createOpenTuiPrimitive("input");
146
152
  const OpenTuiTextarea = createOpenTuiPrimitive("textarea");
147
153
  const OpenTuiMediaSurface = createOpenTuiPrimitive("box");
@@ -1,5 +1,6 @@
1
1
  import { futuresGenericListing, futuresGenericPriceBasis } from "../../utils/futures-generic";
2
2
  import type { TimeRange } from "../../time-series/range";
3
+ import { RANGE_HISTORY_RESOLUTION } from "../../time-series/resolution";
3
4
  import type {
4
5
  FinancialStatement,
5
6
  Fundamentals,
@@ -370,29 +371,24 @@ export function formatCloudDateTime(
370
371
  return `${year}-${month}-${day}`;
371
372
  }
372
373
 
374
+ const HISTORY_OUTPUT_SIZE: Record<TimeRange, number> = {
375
+ "1D": 24 * 12,
376
+ "1W": 7 * 24,
377
+ "1M": 31,
378
+ "3M": 93,
379
+ "6M": 186,
380
+ "1Y": 366,
381
+ "5Y": 261,
382
+ "ALL": 600,
383
+ };
384
+
385
+ /** The bar size comes from the shared range table, which `gloomberb help history` also lists. */
373
386
  export function toHistoryRequest(range: TimeRange): {
374
387
  interval: string;
375
388
  outputsize: number;
376
389
  rangeKey: TimeRange;
377
390
  } {
378
- switch (range) {
379
- case "1D":
380
- return { interval: "5min", outputsize: 24 * 12, rangeKey: range };
381
- case "1W":
382
- return { interval: "1h", outputsize: 7 * 24, rangeKey: range };
383
- case "1M":
384
- return { interval: "1day", outputsize: 31, rangeKey: range };
385
- case "3M":
386
- return { interval: "1day", outputsize: 93, rangeKey: range };
387
- case "6M":
388
- return { interval: "1day", outputsize: 186, rangeKey: range };
389
- case "1Y":
390
- return { interval: "1day", outputsize: 366, rangeKey: range };
391
- case "5Y":
392
- return { interval: "1week", outputsize: 261, rangeKey: range };
393
- case "ALL":
394
- return { interval: "1month", outputsize: 600, rangeKey: range };
395
- default:
396
- return { interval: "1day", outputsize: 366, rangeKey: range };
397
- }
391
+ const resolution = RANGE_HISTORY_RESOLUTION[range];
392
+ if (!resolution) return { interval: "1day", outputsize: 366, rangeKey: range };
393
+ return { interval: toCloudInterval(resolution), outputsize: HISTORY_OUTPUT_SIZE[range], rangeKey: range };
398
394
  }
@@ -66,7 +66,7 @@ export class ProviderRouterBatchRoutes {
66
66
  const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
67
67
  const cached = rawCached && (brokerSourceKeys.includes(rawCached.sourceKey)
68
68
  ? !isQuoteStaleForCurrentSession(quoteWithFreshnessExchange(rawCached.value, target.exchange))
69
- : isProviderQuoteUsableForCurrentSession(rawCached.value, target.exchange, target.symbol))
69
+ : isProviderQuoteUsableForCurrentSession(rawCached.value, target.exchange, target.symbol, { recentAnswer: !rawCached.stale }))
70
70
  ? rawCached
71
71
  : null;
72
72
  if (cached && !forceRefresh && !cached.stale) {
@@ -93,7 +93,7 @@ export class ProviderRouterBatchRoutes {
93
93
  const uniqueTargets = [...providerIndexes.values()].map((bucket) => bucket[0]!.target);
94
94
  const batchResults = await batchProvider.getQuotesBatch!(uniqueTargets, options).catch(() => []);
95
95
  for (const item of batchResults) {
96
- if (!isProviderQuoteUsableForCurrentSession(item.quote, item.target.exchange, item.target.symbol)) continue;
96
+ if (!isProviderQuoteUsableForCurrentSession(item.quote, item.target.exchange, item.target.symbol, { recentAnswer: true })) continue;
97
97
  const key = this.quoteBatchKey(item.target);
98
98
  const sourceKey = this.deps.providerSourceKey(batchProvider);
99
99
  for (const entry of providerIndexes.get(key) ?? []) {
@@ -162,7 +162,7 @@ export class ProviderRouterBatchRoutes {
162
162
  const key = this.cachedFinancialsBatchKey(item.target);
163
163
  let value = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(item.financials));
164
164
  if (value && !item.target.instrument && !providerFinancialsMatchTarget(value, item.target.symbol, item.target.exchange)) continue;
165
- if (value) value = dropUnusableProviderQuote(value, item.target.exchange);
165
+ if (value) value = dropUnusableProviderQuote(value, item.target.exchange, { recentAnswer: true });
166
166
  if (!value) continue;
167
167
  const sourceKey = this.deps.providerSourceKey(batchProvider);
168
168
  for (const entry of providerIndexes.get(key) ?? []) {
@@ -200,7 +200,7 @@ export class ProviderRouterFinancialRoutes {
200
200
  const rawCached = selectCachedResource<Quote>(this.deps.resources, "quote", entityKey, variantKeys, sourceKeys, false);
201
201
  const cachedIsStale = rawCached && (brokerSourceKeys.includes(rawCached.sourceKey)
202
202
  ? isQuoteContributionStaleForCurrentSession(quoteWithFreshnessExchange(rawCached.value, exchange))
203
- : !isProviderQuoteUsableForCurrentSession(rawCached.value, exchange, ticker));
203
+ : !isProviderQuoteUsableForCurrentSession(rawCached.value, exchange, ticker, { recentAnswer: !rawCached.stale }));
204
204
  const cached = rawCached && !cachedIsStale
205
205
  ? rawCached
206
206
  : null;
@@ -259,7 +259,7 @@ export class ProviderRouterFinancialRoutes {
259
259
  if (
260
260
  record
261
261
  && (includeStale || !record.stale)
262
- && isProviderQuoteUsableForCurrentSession(record.value, exchange, ticker)
262
+ && isProviderQuoteUsableForCurrentSession(record.value, exchange, ticker, { recentAnswer: !record.stale })
263
263
  ) {
264
264
  return record.value;
265
265
  }
@@ -7,7 +7,16 @@ import { redactUnavailableFundamentals, RETRACTABLE_VALUATION_FIELDS } from "../
7
7
  import { isExtendedHoursExchange, isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
8
8
  import { mergeQuoteMetadata, quoteMetadataFromQuote, quoteMetadataMatchesTarget } from "../../market-data/quotes/metadata";
9
9
  import { parsePublicTickerKey } from "../../utils/exchanges";
10
- import { activeUsMarketSession, delayedFeedLagMs, isMiddayBreakPrint, isUsPriorSessionPremarketQuote } from "../../market-data/market/freshness";
10
+ import {
11
+ activeUsMarketSession,
12
+ delayedFeedLagMs,
13
+ isMiddayBreakPrint,
14
+ isRegularSessionTime,
15
+ isUsPostSessionPrint,
16
+ isUsPriorSessionPremarketQuote,
17
+ isUsRegularCloseQuoteInPostSession,
18
+ latestRegularSessionOpen,
19
+ } from "../../market-data/market/freshness";
11
20
  import {
12
21
  mergeQuoteContributionMaps,
13
22
  isQuoteContributionStaleForCurrentSession,
@@ -122,7 +131,42 @@ function isQuoteInActiveSession(quote: Quote, now: number): boolean {
122
131
  return false;
123
132
  }
124
133
 
125
- function isActiveProviderQuoteTooOld(quote: Quote, now = Date.now()): boolean {
134
+ // An opening auction can print before the continuous open (Hong Kong's at 09:20).
135
+ const OPENING_AUCTION_MS = 15 * 60_000;
136
+
137
+ /**
138
+ * Whether Gloom Cloud vouches that a delayed quote is current, however long
139
+ * ago its last trade was. The service judges more than that trade's age: it
140
+ * knows when it asked for the quote and how often the listing trades, so a
141
+ * thinly traded listing's hour-old trade can be the current price while its
142
+ * venue trades. Its verdict stands in for the age bound when:
143
+ * - the service answered on this request, or a cache entry still inside its
144
+ * TTL holds that answer (`recentAnswer`); a copy past the TTL is not vouched for;
145
+ * - the answer says `stale: false`. One without the flag vouches for nothing,
146
+ * and one flagged stale never gets this far;
147
+ * - the quote is delayed. The service bounds a delayed quote's feed age itself
148
+ * but not a real-time one's, so real-time quotes keep the age bound;
149
+ * - it reports the regular session, the venue's session is in progress as
150
+ * the delayed feed shows it, and the trade belongs to that session; or it
151
+ * reports a US after-hours price, the after-hours session is in progress and
152
+ * the trade belongs to it.
153
+ * The answer says nothing about how often a listing trades, so a liquid
154
+ * listing the service wrongly vouches for is accepted too. The service's own
155
+ * bound for liquid listings, about twenty minutes, is tighter than this one.
156
+ */
157
+ function isVouchedCurrentByService(quote: Quote, exchange: string | undefined, now: number): boolean {
158
+ if (quote.providerId !== "gloomberb-cloud" || quote.stale !== false || quote.dataSource !== "delayed") return false;
159
+ if (quote.marketState === "POST") {
160
+ return finitePositiveNumber(quote.postMarketPrice) && isUsPostSessionPrint(quote.lastUpdated, exchange, now);
161
+ }
162
+ if (quote.marketState !== "REGULAR") return false;
163
+ const feedTime = now - delayedFeedLagMs(exchange);
164
+ if (isRegularSessionTime(exchange, feedTime) !== true) return false;
165
+ const open = latestRegularSessionOpen(exchange, feedTime);
166
+ return open != null && quote.lastUpdated >= open - OPENING_AUCTION_MS;
167
+ }
168
+
169
+ function isActiveProviderQuoteTooOld(quote: Quote, now = Date.now(), recentAnswer = false): boolean {
126
170
  // The shared freshness predicate already validates and dates a daily NAV.
127
171
  if (quote.priceObservation === "nav") return false;
128
172
  if (!isQuoteInActiveSession(quote, now)) return false;
@@ -132,12 +176,17 @@ function isActiveProviderQuoteTooOld(quote: Quote, now = Date.now()): boolean {
132
176
  if (isUsPriorSessionPremarketQuote(quote.lastUpdated, exchange, quote.marketState, now)) {
133
177
  return false;
134
178
  }
179
+ // Likewise today's regular close before any after-hours trade.
180
+ if (quote.postMarketPrice == null && isUsRegularCloseQuoteInPostSession(quote.lastUpdated, exchange, quote.marketState, now)) {
181
+ return false;
182
+ }
135
183
  if (!Number.isFinite(quote.lastUpdated)) return false;
136
184
  const delayed = quote.dataSource === "delayed";
137
185
  const maxAge = delayed ? ACTIVE_DELAYED_PROVIDER_QUOTE_MAX_AGE_MS : ACTIVE_PROVIDER_QUOTE_MAX_AGE_MS;
138
186
  if (now - quote.lastUpdated <= maxAge) return false;
139
187
  // While the venue pauses at midday, the morning's last print is the current price.
140
- return !isMiddayBreakPrint(quote.lastUpdated, exchange, now, delayed ? delayedFeedLagMs(exchange) : 0);
188
+ if (isMiddayBreakPrint(quote.lastUpdated, exchange, now, delayed ? delayedFeedLagMs(exchange) : 0)) return false;
189
+ return !(recentAnswer && isVouchedCurrentByService(quote, exchange, now));
141
190
  }
142
191
 
143
192
  export function providerQuoteMatchesTarget(quote: Quote | null | undefined, symbol?: string, exchange?: string): quote is Quote {
@@ -157,11 +206,21 @@ export function providerQuoteMatchesTarget(quote: Quote | null | undefined, symb
157
206
  return true;
158
207
  }
159
208
 
160
- export function isProviderQuoteUsableForCurrentSession(quote: Quote | null | undefined, exchange?: string, symbol?: string): quote is Quote {
209
+ /**
210
+ * `recentAnswer`: the source answered with this quote on this request, or a
211
+ * cache entry still inside its TTL holds it. Only then can the source's own
212
+ * verdict on a delayed quote stand in for the age bound.
213
+ */
214
+ export function isProviderQuoteUsableForCurrentSession(
215
+ quote: Quote | null | undefined,
216
+ exchange?: string,
217
+ symbol?: string,
218
+ options: { recentAnswer?: boolean } = {},
219
+ ): quote is Quote {
161
220
  if (!providerQuoteMatchesTarget(quote, symbol, exchange)) return false;
162
221
  const normalized = quoteWithFreshnessExchange(quote, exchange);
163
222
  if (isQuoteStaleForCurrentSession(normalized)) return false;
164
- if (isActiveProviderQuoteTooOld(normalized)) return false;
223
+ if (isActiveProviderQuoteTooOld(normalized, Date.now(), options.recentAnswer === true)) return false;
165
224
  // Futures may trade or settle at zero or negative prices. Require explicit
166
225
  // source type metadata; an alias alone cannot establish the price domain.
167
226
  const futures = ["FUT", "FUTURE", "FUTURES"].includes(normalized.instrumentType?.trim().toUpperCase() ?? "");
@@ -188,8 +247,12 @@ export function providerFinancialsMatchTarget(value: TickerFinancials, symbol: s
188
247
  return quoteMetadataMatchesTarget(metadata, hasListing ? symbol : parsePublicTickerKey(symbol).symbol, hasListing ? exchange : undefined);
189
248
  }
190
249
 
191
- export function dropUnusableProviderQuote(value: TickerFinancials, exchange?: string): TickerFinancials {
192
- if (!value.quote || isProviderQuoteUsableForCurrentSession(value.quote, exchange)) {
250
+ export function dropUnusableProviderQuote(
251
+ value: TickerFinancials,
252
+ exchange?: string,
253
+ options: { recentAnswer?: boolean } = {},
254
+ ): TickerFinancials {
255
+ if (!value.quote || isProviderQuoteUsableForCurrentSession(value.quote, exchange, undefined, options)) {
193
256
  return value;
194
257
  }
195
258
 
@@ -73,7 +73,7 @@ export class ProviderRouterPrimaryRoutes {
73
73
  if (rawValue && !context?.instrument && !providerFinancialsMatchTarget(rawValue, ticker, exchange)) continue;
74
74
  const resolvedValue = resolveTickerFinancialsQuoteState(normalizeTickerFinancialsPriceHistory(rawValue));
75
75
  if (resolvedValue && !context?.instrument && !providerFinancialsMatchTarget(resolvedValue, ticker, exchange)) continue;
76
- let value = resolvedValue ? dropUnusableProviderQuote(resolvedValue, exchange) : null;
76
+ let value = resolvedValue ? dropUnusableProviderQuote(resolvedValue, exchange, { recentAnswer: true }) : null;
77
77
  if (!value) continue;
78
78
  const sourceKey = this.options.providerSourceKey(provider);
79
79
  if (context?.statementHistory === "extended" && !hasReusableExtendedHistory(value)) {
@@ -187,7 +187,7 @@ export class ProviderRouterPrimaryRoutes {
187
187
  for (const provider of this.options.providersInPriorityOrder()) {
188
188
  try {
189
189
  const quote = await provider.getQuote(ticker, exchange, context);
190
- if (!isProviderQuoteUsableForCurrentSession(quote, exchange, ticker)) continue;
190
+ if (!isProviderQuoteUsableForCurrentSession(quote, exchange, ticker, { recentAnswer: true })) continue;
191
191
  const sourceKey = this.options.providerSourceKey(provider);
192
192
  this.options.cacheResource(
193
193
  "quote",
@@ -5,9 +5,12 @@ import { useAppSelector, useOptionalPaneInstanceId } from "./app/context";
5
5
 
6
6
  type ScrollDirection = "up" | "down";
7
7
 
8
+ /** A scroll position: rows (or cells), or device pixels where the host measures in them. */
9
+ type ScrollTarget = { cells: number } | { pixels: number };
10
+
8
11
  interface PaneScrollAction {
9
12
  direction: ScrollDirection;
10
- resolveTarget(scrollBox: ScrollBoxRenderable): number | null;
13
+ resolveTarget(scrollBox: ScrollBoxRenderable): ScrollTarget | null;
11
14
  }
12
15
 
13
16
  interface PaneScrollEntry {
@@ -57,17 +60,58 @@ function isVisibleScrollBox(scrollBox: ScrollBoxRenderable): boolean {
57
60
  return !hasHiddenAncestor(scrollBox) && !hasCollapsedBounds(scrollBox);
58
61
  }
59
62
 
60
- function clampScrollTop(scrollBox: ScrollBoxRenderable, target: number): number | null {
63
+ function maxScrollTop(scrollBox: ScrollBoxRenderable): number {
61
64
  const viewportHeight = Math.max(0, scrollBox.viewport?.height ?? 0);
62
- if (viewportHeight <= 0) return null;
63
- const maxScrollTop = Math.max(0, scrollBox.scrollHeight - viewportHeight);
64
- if (maxScrollTop <= 0) return null;
65
- return Math.max(0, Math.min(maxScrollTop, target));
65
+ if (viewportHeight <= 0) return 0;
66
+ return Math.max(0, scrollBox.scrollHeight - viewportHeight);
67
+ }
68
+
69
+ /**
70
+ * The desktop host rounds its viewport and content to whole rows, which can
71
+ * leave up to half a row of content past the last scrollable row. Where it
72
+ * also reports pixels, the ends are measured in them.
73
+ */
74
+ function pixelRange(scrollBox: ScrollBoxRenderable): { top: number; max: number } | null {
75
+ const { scrollTopPx, scrollHeightPx, viewportPx } = scrollBox;
76
+ if (!scrollBox.scrollToPixels || typeof scrollTopPx !== "number" || typeof scrollHeightPx !== "number" || !viewportPx) {
77
+ return null;
78
+ }
79
+ return { top: scrollTopPx, max: Math.max(0, scrollHeightPx - viewportPx.height) };
66
80
  }
67
81
 
68
- function scrollTargetByDelta(scrollBox: ScrollBoxRenderable, delta: number): number | null {
69
- const target = clampScrollTop(scrollBox, scrollBox.scrollTop + delta);
70
- return target == null || target === scrollBox.scrollTop ? null : target;
82
+ function topTarget(scrollBox: ScrollBoxRenderable): ScrollTarget | null {
83
+ if ((scrollBox.viewport?.height ?? 0) <= 0) return null;
84
+ const pixels = pixelRange(scrollBox);
85
+ if (pixels) return pixels.top >= 1 ? { pixels: 0 } : null;
86
+ return scrollBox.scrollTop > 0 ? { cells: 0 } : null;
87
+ }
88
+
89
+ function bottomTarget(scrollBox: ScrollBoxRenderable): ScrollTarget | null {
90
+ const max = maxScrollTop(scrollBox);
91
+ // A body that overflows by less than a row is not worth capturing the keys for.
92
+ if (max <= 0) return null;
93
+ const pixels = pixelRange(scrollBox);
94
+ if (pixels) return pixels.max - pixels.top >= 1 ? { pixels: pixels.max } : null;
95
+ return max !== scrollBox.scrollTop ? { cells: max } : null;
96
+ }
97
+
98
+ function scrollTargetByDelta(scrollBox: ScrollBoxRenderable, delta: number): ScrollTarget | null {
99
+ if ((scrollBox.viewport?.height ?? 0) <= 0) return null;
100
+ const wanted = scrollBox.scrollTop + delta;
101
+ if (delta < 0 && wanted <= 0) return topTarget(scrollBox);
102
+ if (delta > 0 && wanted >= maxScrollTop(scrollBox)) return bottomTarget(scrollBox);
103
+ return delta === 0 ? null : { cells: wanted };
104
+ }
105
+
106
+ function applyScrollTarget(scrollBox: ScrollBoxRenderable, target: ScrollTarget): void {
107
+ if ("pixels" in target) {
108
+ scrollBox.scrollToPixels?.(target.pixels);
109
+ return;
110
+ }
111
+ scrollBox.scrollTo(target.cells);
112
+ if (scrollBox.scrollTop !== target.cells) {
113
+ scrollBox.scrollTop = target.cells;
114
+ }
71
115
  }
72
116
 
73
117
  /**
@@ -76,7 +120,7 @@ function scrollTargetByDelta(scrollBox: ScrollBoxRenderable, delta: number): num
76
120
  */
77
121
  export function scrollByLines(scrollBox: ScrollBoxRenderable, delta: number): void {
78
122
  const target = scrollTargetByDelta(scrollBox, delta);
79
- if (target != null) scrollBox.scrollTop = target;
123
+ if (target) applyScrollTarget(scrollBox, target);
80
124
  }
81
125
 
82
126
  function arrowScrollLines(scrollBox: ScrollBoxRenderable): number {
@@ -117,20 +161,9 @@ function resolveKeyScrollAction(event: PaneScrollKeyEvent): PaneScrollAction | n
117
161
  ),
118
162
  };
119
163
  case "home":
120
- return {
121
- direction: "up",
122
- resolveTarget: (scrollBox) => (
123
- scrollBox.scrollTop > 0 ? 0 : null
124
- ),
125
- };
164
+ return { direction: "up", resolveTarget: topTarget };
126
165
  case "end":
127
- return {
128
- direction: "down",
129
- resolveTarget: (scrollBox) => {
130
- const target = clampScrollTop(scrollBox, Number.MAX_SAFE_INTEGER);
131
- return target == null || target === scrollBox.scrollTop ? null : target;
132
- },
133
- };
166
+ return { direction: "down", resolveTarget: bottomTarget };
134
167
  default:
135
168
  return null;
136
169
  }
@@ -145,12 +178,12 @@ function getEntryScore(entry: PaneScrollEntry, scrollBox: ScrollBoxRenderable):
145
178
  function findScrollableEntry(paneId: string, action: PaneScrollAction): {
146
179
  entry: PaneScrollEntry;
147
180
  scrollBox: ScrollBoxRenderable;
148
- target: number;
181
+ target: ScrollTarget;
149
182
  } | null {
150
183
  const entries = paneScrollBoxes.get(paneId);
151
184
  if (!entries) return null;
152
185
 
153
- let best: { entry: PaneScrollEntry; scrollBox: ScrollBoxRenderable; target: number; score: number } | null = null;
186
+ let best: { entry: PaneScrollEntry; scrollBox: ScrollBoxRenderable; target: ScrollTarget; score: number } | null = null;
154
187
  for (const entry of entries.values()) {
155
188
  const scrollBox = entry.ref.current;
156
189
  if (!scrollBox) continue;
@@ -176,17 +209,16 @@ function scrollPaneByKey(paneId: string, event: PaneScrollKeyEvent): boolean {
176
209
  const match = findScrollableEntry(paneId, action);
177
210
  if (!match) return false;
178
211
 
179
- const previousTop = match.scrollBox.scrollTop;
180
- match.scrollBox.scrollTo(match.target);
181
- if (match.scrollBox.scrollTop !== match.target) {
182
- match.scrollBox.scrollTop = match.target;
183
- }
212
+ const { scrollBox } = match;
213
+ const position = () => scrollBox.scrollTopPx ?? scrollBox.scrollTop;
214
+ const previousTop = scrollBox.scrollTop;
215
+ const previousPosition = position();
216
+ applyScrollTarget(scrollBox, match.target);
184
217
 
185
- const delta = Math.abs(match.scrollBox.scrollTop - previousTop);
186
- if (delta > 0) {
187
- match.entry.onScrollActivity?.({ scroll: { direction: action.direction, delta } });
188
- }
189
- return delta > 0;
218
+ if (position() === previousPosition) return false;
219
+ const delta = Math.max(1, Math.abs(scrollBox.scrollTop - previousTop));
220
+ match.entry.onScrollActivity?.({ scroll: { direction: action.direction, delta } });
221
+ return true;
190
222
  }
191
223
 
192
224
  export function useForwardedScrollBoxRef<T>(
@@ -0,0 +1,108 @@
1
+ import type { Quote, TickerFinancials } from "../types/financials";
2
+ import type { Portfolio, TickerRecord } from "../types/ticker";
3
+ import type { BrokerAccount } from "../types/trading";
4
+ import { createTestTicker } from "./ticker";
5
+
6
+ /**
7
+ * An invented margin account: 1.0M of equity carrying 1.6M of positions, the
8
+ * difference borrowed in three currencies. It holds a US stock, a listing
9
+ * quoted in euros and an option, which no quote prices, so it stays at its
10
+ * broker mark. Every figure is made up.
11
+ */
12
+ export const MARGIN_PORTFOLIO: Portfolio = {
13
+ id: "broker:margin-test:M1",
14
+ name: "Margin test",
15
+ currency: "USD",
16
+ brokerId: "test",
17
+ brokerInstanceId: "margin-test",
18
+ brokerAccountId: "M1",
19
+ };
20
+
21
+ /** Dollars per unit of each currency. */
22
+ export const MARGIN_EXCHANGE_RATES = new Map([["USD", 1], ["EUR", 1.2], ["JPY", 0.008]]);
23
+
24
+ const OPTION = "CCC 280121C00050000";
25
+
26
+ function holding(symbol: string, details: {
27
+ exchange: string;
28
+ currency: string;
29
+ shares: number;
30
+ mark: number;
31
+ multiplier?: number;
32
+ assetCategory?: string;
33
+ }): TickerRecord {
34
+ return createTestTicker(symbol, symbol, {
35
+ exchange: details.exchange,
36
+ currency: details.currency,
37
+ assetCategory: details.assetCategory,
38
+ portfolios: [MARGIN_PORTFOLIO.id],
39
+ positions: [{
40
+ portfolio: MARGIN_PORTFOLIO.id,
41
+ shares: details.shares,
42
+ avgCost: details.mark,
43
+ markPrice: details.mark,
44
+ marketValue: details.shares * details.mark * (details.multiplier ?? 1),
45
+ unrealizedPnl: 0,
46
+ multiplier: details.multiplier,
47
+ currency: details.currency,
48
+ side: "long",
49
+ broker: "test",
50
+ brokerInstanceId: "margin-test",
51
+ brokerAccountId: "M1",
52
+ }],
53
+ });
54
+ }
55
+
56
+ export const MARGIN_TICKERS: TickerRecord[] = [
57
+ holding("AAA", { exchange: "NASDAQ", currency: "USD", shares: 4_000, mark: 200 }),
58
+ holding("BBB", { exchange: "XETRA", currency: "EUR", shares: 5_000, mark: 100 }),
59
+ holding(OPTION, { exchange: "", currency: "USD", shares: 20, mark: 100, multiplier: 100, assetCategory: "OPT" }),
60
+ ];
61
+
62
+ export function createMarginAccount(overrides: Partial<BrokerAccount> = {}): BrokerAccount {
63
+ return {
64
+ accountId: "M1",
65
+ name: "Margin test",
66
+ currency: "USD",
67
+ source: "cloud",
68
+ updatedAt: Date.now() - 60_000,
69
+ netLiquidation: 1_000_000,
70
+ grossPositionValue: 1_600_000,
71
+ totalCashValue: -600_000,
72
+ cashBalances: [
73
+ { currency: "EUR", quantity: -250_000, baseValue: -300_000, baseCurrency: "USD" },
74
+ { currency: "JPY", quantity: -25_000_000, baseValue: -200_000, baseCurrency: "USD" },
75
+ { currency: "USD", quantity: -100_000, baseValue: -100_000, baseCurrency: "USD" },
76
+ ],
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ function liveQuote(symbol: string, price: number, previousClose: number, currency: string, exchange: string): TickerFinancials {
82
+ const quote: Quote = {
83
+ symbol,
84
+ price,
85
+ change: price - previousClose,
86
+ changePercent: (price / previousClose - 1) * 100,
87
+ previousClose,
88
+ currency,
89
+ lastUpdated: Date.now() - 1_000,
90
+ listingExchangeName: exchange,
91
+ marketState: "REGULAR",
92
+ dataSource: "live",
93
+ };
94
+ return { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote };
95
+ }
96
+
97
+ /**
98
+ * Real-time quotes for the stock and the euro listing, the stock `move`
99
+ * dollars above its mark. With `option`, the option is quoted at its mark too,
100
+ * so every position has a live price.
101
+ */
102
+ export function marginQuotes({ move = 0, option = false }: { move?: number; option?: boolean } = {}): Map<string, TickerFinancials> {
103
+ return new Map([
104
+ ["AAA", liveQuote("AAA", 200 + move, 200, "USD", "NASDAQ")],
105
+ ["BBB", liveQuote("BBB", 100, 100, "EUR", "XETRA")],
106
+ ...(option ? [[OPTION, liveQuote(OPTION, 100, 100, "USD", "CBOE")] as const] : []),
107
+ ]);
108
+ }
@@ -37,6 +37,21 @@ const RANGE_PRESET_RESOLUTION: Record<TimeRange, ManualChartResolution> = {
37
37
  "ALL": "1mo",
38
38
  };
39
39
 
40
+ /**
41
+ * The bar size a price history for a range is served in, unlike the chart
42
+ * presets above: what `gloomberb history` and the historical prices pane get.
43
+ */
44
+ export const RANGE_HISTORY_RESOLUTION: Readonly<Record<TimeRange, ManualChartResolution>> = {
45
+ "1D": "5m",
46
+ "1W": "1h",
47
+ "1M": "1d",
48
+ "3M": "1d",
49
+ "6M": "1d",
50
+ "1Y": "1d",
51
+ "5Y": "1wk",
52
+ "ALL": "1mo",
53
+ };
54
+
40
55
  const RANGE_PRELOAD_BUFFER: Record<TimeRange, TimeRange> = {
41
56
  "1D": "1W",
42
57
  "1W": "1M",
@@ -38,7 +38,21 @@ export interface QuoteMetadata {
38
38
  fieldSources?: Partial<Record<"currency" | "instrumentType", QuoteMetadataSource>>;
39
39
  }
40
40
 
41
- export interface Quote {
41
+ /**
42
+ * What a record's share counts count: depositary receipts or the ordinary
43
+ * shares behind them. Gloom Cloud states it only when it established it.
44
+ */
45
+ export type ShareBasis = "depositary_receipt" | "ordinary";
46
+
47
+ /** How a depositary receipt relates to the ordinary shares, as Gloom Cloud states it. */
48
+ interface DepositaryReceiptFacts {
49
+ /** Whether the listing is a depositary receipt; absent when unknown. */
50
+ isDepositaryReceipt?: boolean;
51
+ /** Ordinary shares per receipt, on a receipt's own records. */
52
+ adrRatio?: number;
53
+ }
54
+
55
+ export interface Quote extends DepositaryReceiptFacts {
42
56
  /** Applies to this price observation; a stored position cannot supply it. */
43
57
  priceBasis?: PriceBasis;
44
58
  /** Daily fund net asset value; independent of the units used to format its price. */
@@ -109,7 +123,11 @@ export interface QuoteContribution extends Quote {
109
123
 
110
124
  export type QuoteContributionMap = Record<string, QuoteContribution>;
111
125
 
112
- export interface Fundamentals {
126
+ export interface Fundamentals extends DepositaryReceiptFacts {
127
+ /** Every share count here is on this basis, the basis of the listing's price and market cap; absent when unproven. */
128
+ shareBasis?: ShareBasis;
129
+ /** On a receipt, the ordinary shares behind `sharesOutstanding`. */
130
+ underlyingOrdinaryShares?: number;
113
131
  source?: "gloom";
114
132
  fetchedAt?: string;
115
133
  stale?: boolean;
@@ -180,6 +198,8 @@ export interface HolderRecord {
180
198
  percentHeld?: number;
181
199
  changeShares?: number;
182
200
  changePercent?: number;
201
+ /** Set when the position's filing ties it to the receipts or to the ordinary shares. */
202
+ shareBasis?: ShareBasis;
183
203
  }
184
204
 
185
205
  interface HolderSummary {
@@ -189,11 +209,18 @@ interface HolderSummary {
189
209
  institutionsCount?: number;
190
210
  }
191
211
 
192
- export interface HolderData {
212
+ export interface HolderData extends DepositaryReceiptFacts {
193
213
  providerId?: string;
194
214
  symbol: string;
195
215
  name?: string;
216
+ /** The listing's price currency. */
196
217
  currency?: string;
218
+ /** The currency of `value`, which can differ from the listing's (USD on a London line priced from 13F filings). */
219
+ valueCurrency?: string;
220
+ /** Reported shares times the latest price, or times the price at the report date. */
221
+ valueBasis?: "latest_price" | "period_end_price";
222
+ /** Set when every row is on one basis. */
223
+ shareBasis?: ShareBasis;
197
224
  exchange?: string;
198
225
  asOf?: string;
199
226
  summary?: HolderSummary;
@@ -430,6 +457,8 @@ export interface ReportedEarningsCohort {
430
457
  export interface EarningsResultProvenance { version: 1; reported: ReportedEarningsCohort }
431
458
 
432
459
  export interface FinancialStatement {
460
+ /** The basis of this row's share counts and per-share earnings; absent when unproven. */
461
+ shareBasis?: ShareBasis;
433
462
  /** @deprecated No longer populated. */
434
463
  earningsResult?: EarningsResultProvenance;
435
464
  /** EPS lines unavailable for this period, for example because they disagree with the row's own income and shares. */
package/src/ui/host.tsx CHANGED
@@ -108,6 +108,12 @@ export interface SyntaxStyleLike {
108
108
  }): number;
109
109
  }
110
110
 
111
+ /** Where a drag has a box this frame: an offset in cells from where it is laid out. */
112
+ export interface LiveBoxFrame {
113
+ dx: number;
114
+ dy: number;
115
+ }
116
+
111
117
  export interface BoxRenderable {
112
118
  x?: number;
113
119
  y?: number;
@@ -117,6 +123,12 @@ export interface BoxRenderable {
117
123
  absoluteY?: number;
118
124
  absoluteBounds?: { x: number; y: number; width: number; height: number };
119
125
  getBoundingClientRect?: () => { x: number; y: number; width: number; height: number };
126
+ /**
127
+ * Desktop only: draws the box at `frame` by restyling its own element, with
128
+ * no render, until it is called with null. The offset rides the compositor,
129
+ * so it costs no layout or repaint.
130
+ */
131
+ setLiveFrame?: (frame: LiveBoxFrame | null) => void;
120
132
  [key: string]: unknown;
121
133
  }
122
134