gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -53,6 +53,64 @@ function formatPercent(value: number | null): string {
53
53
  return value == null ? "-" : `${formatNumber(value, 2)}%`;
54
54
  }
55
55
 
56
+ const finiteNumber = (value: number | null | undefined): value is number => typeof value === "number" && Number.isFinite(value);
57
+
58
+ /**
59
+ * Shares short over the float, in percent. Short interest above the float is
60
+ * possible (shares lent twice), twice it is not: such a float is another
61
+ * class's (BRK.B's is Berkshire's Class A), so it is not used. The bound
62
+ * Gloom Cloud's statistics use for their short % of float.
63
+ */
64
+ function percentOfFloat(sharesShort: number, floatShares: number | null | undefined): number | null {
65
+ return finiteNumber(floatShares) && floatShares > 0 && sharesShort <= floatShares * 2 ? (sharesShort / floatShares) * 100 : null;
66
+ }
67
+
68
+ /**
69
+ * The statistics' short % of float (a fraction, null when not served) and the
70
+ * settlement it is of: Gloom Cloud serves it on the float it serves, from the
71
+ * settlement SI and SIW show, as the overview and `fundamentals` read it.
72
+ */
73
+ export interface SettlementFloatStatistics {
74
+ settlementDate: string;
75
+ shortPercentOfFloat: number | null;
76
+ }
77
+
78
+ /**
79
+ * A settlement's percent of float: the statistics' own when they are of the
80
+ * same settlement, then a percentage the source states, then the shares short
81
+ * over the current float.
82
+ */
83
+ export function settlementPercentFloat(
84
+ record: ShortInterestRecord,
85
+ floatShares: number | null | undefined,
86
+ statistics?: SettlementFloatStatistics | null,
87
+ ): number | null {
88
+ if (statistics && statistics.settlementDate === record.settlementDate.toISOString().slice(0, 10)) {
89
+ return finiteNumber(statistics.shortPercentOfFloat) ? statistics.shortPercentOfFloat * 100 : null;
90
+ }
91
+ return record.shortPercentFloat ?? percentOfFloat(record.sharesShort, floatShares);
92
+ }
93
+
94
+ /**
95
+ * The newest settlement's percent of float where the source gives none, read
96
+ * against the current float as SIW reads it (settlementPercentFloat). The
97
+ * float is undated, so the settlements before it stay blank rather than take
98
+ * today's float.
99
+ */
100
+ export function withLatestPercentFloat(
101
+ records: readonly ShortInterestRecord[],
102
+ floatShares: number | null | undefined,
103
+ statistics?: SettlementFloatStatistics | null,
104
+ ): ShortInterestRecord[] {
105
+ const latest = records.reduce<ShortInterestRecord | null>(
106
+ (best, record) => (!best || record.settlementDate.getTime() > best.settlementDate.getTime() ? record : best),
107
+ null,
108
+ );
109
+ return records.map((record) => (record === latest
110
+ ? { ...record, shortPercentFloat: settlementPercentFloat(record, floatShares, statistics) }
111
+ : record));
112
+ }
113
+
56
114
  export function buildRows(records: ShortInterestRecord[]): ShortInterestRow[] {
57
115
  return records.map((record, index) => ({
58
116
  key: `${record.settlementDate.toISOString()}:${index}`,
@@ -89,9 +147,10 @@ export function sortRows(rows: ShortInterestRow[], preference: SortPreference):
89
147
  /**
90
148
  * The columns sit together rather than spread across the pane, so the figures
91
149
  * of one settlement read as a row, and fit a 60-column pane without a
92
- * horizontal scrollbar. FINRA settlement history carries no float, so percent of float is null for
93
- * every row on that route. Keeping the column drew a header over a column of
94
- * dashes, which reads as missing data rather than data the source never had.
150
+ * horizontal scrollbar. FINRA settlement history carries no float, so percent of float
151
+ * is null for every row unless the latest one is read against the current float
152
+ * (`withLatestPercentFloat`). Keeping the column with nothing in it drew a header over a
153
+ * column of dashes, which reads as missing data rather than data the source never had.
95
154
  */
96
155
  export function buildColumns(
97
156
  records: readonly ShortInterestRecord[] = [],
@@ -19,7 +19,6 @@ import { Box, TextAttributes } from "../../../ui";
19
19
  import { isKnownNonUsEquityTicker } from "../../../utils/sec";
20
20
  import { nextHeaderSort } from "../../../utils/sort-values";
21
21
  import { usePluginPaneState } from "../../runtime";
22
- import { loadShortInterest } from "./client";
23
22
  import {
24
23
  DEFAULT_SORT,
25
24
  buildColumns,
@@ -34,6 +33,7 @@ import {
34
33
  type SortPreference,
35
34
  } from "./model";
36
35
  import type { ShortInterestRecord } from "./types";
36
+ import { loadShortInterestWithFloat } from "./watch-client";
37
37
  import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
38
38
 
39
39
  const EMPTY_RECORDS: ShortInterestRecord[] = [];
@@ -45,7 +45,7 @@ function ShortInterestView({ width, height, focused }: { width: number; height:
45
45
  const symbol = ticker?.metadata.ticker ?? null;
46
46
 
47
47
  const skipNonUs = isKnownNonUsEquityTicker(ticker);
48
- const request = useCallback(() => loadShortInterest(symbol!), [symbol]);
48
+ const request = useCallback(() => loadShortInterestWithFloat(symbol!), [symbol]);
49
49
  const resource = useAsyncResource(symbol && !skipNonUs ? request : null, { clearOnError: true });
50
50
  const { error, updatedAt, reload: refresh } = resource;
51
51
  const records = resource.data?.records ?? EMPTY_RECORDS;
@@ -0,0 +1,107 @@
1
+ import { getPublishedUsEquityCalendarDay } from "../../../market-data/published-us-sessions";
2
+
3
+ /**
4
+ * When a short interest settlement reaches readers. FINRA's calendar follows a
5
+ * fixed rule (Rule 4560): positions are struck on the 15th of the month, or the
6
+ * business day before when it is not one, and on the month's last business day;
7
+ * firms report two business days later and FINRA publishes seven business days
8
+ * after the settlement. The dataset carries only the settlement date, so the
9
+ * rest is read from that rule, against the US exchange holidays.
10
+ */
11
+ const PUBLICATION_LAG_BUSINESS_DAYS = 7;
12
+ const MID_MONTH_DAY = 15;
13
+ const DAY_MS = 86_400_000;
14
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
15
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
16
+
17
+ export interface ShortInterestSchedule {
18
+ /** The settlement the schedule is read from, `YYYY-MM-DD`. */
19
+ settlement: string;
20
+ /** When FINRA published that settlement. */
21
+ published: string;
22
+ /** The first settlement after it. */
23
+ nextSettlement: string;
24
+ /** When FINRA publishes that one. */
25
+ nextPublished: string;
26
+ }
27
+
28
+ const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
29
+ const parseDay = (date: string) => Date.parse(`${date}T00:00:00Z`);
30
+
31
+ /** A US business day: a weekday the exchanges are open. Years the calendar does not cover read as weekdays only. */
32
+ function isBusinessDay(ms: number): boolean {
33
+ const weekday = new Date(ms).getUTCDay();
34
+ if (weekday === 0 || weekday === 6) return false;
35
+ return getPublishedUsEquityCalendarDay("NYSE", isoDay(ms)) !== "closed";
36
+ }
37
+
38
+ function addBusinessDays(ms: number, count: number): number {
39
+ let day = ms;
40
+ for (let left = count; left > 0;) {
41
+ day += DAY_MS;
42
+ if (isBusinessDay(day)) left -= 1;
43
+ }
44
+ return day;
45
+ }
46
+
47
+ /** The business day on or before `ms`. */
48
+ function businessDayOnOrBefore(ms: number): number {
49
+ let day = ms;
50
+ while (!isBusinessDay(day)) day -= DAY_MS;
51
+ return day;
52
+ }
53
+
54
+ /** The two settlements of a month, in order: the 15th and the last business day. */
55
+ function monthSettlements(year: number, month: number): [number, number] {
56
+ return [
57
+ businessDayOnOrBefore(Date.UTC(year, month, MID_MONTH_DAY)),
58
+ // Day 0 of the next month is this month's last day.
59
+ businessDayOnOrBefore(Date.UTC(year, month + 1, 0)),
60
+ ];
61
+ }
62
+
63
+ /** The first settlement strictly after `ms`. */
64
+ function settlementAfter(ms: number): number {
65
+ const from = new Date(ms);
66
+ for (let step = 0; step < 3; step += 1) {
67
+ const found = monthSettlements(from.getUTCFullYear(), from.getUTCMonth() + step).find((day) => day > ms);
68
+ if (found != null) return found;
69
+ }
70
+ throw new Error(`No short interest settlement follows ${isoDay(ms)}.`);
71
+ }
72
+
73
+ /** The publication dates of a settlement and of the one after it; null for a date that is not `YYYY-MM-DD`. */
74
+ export function shortInterestSchedule(settlement: string): ShortInterestSchedule | null {
75
+ const ms = parseDay(settlement);
76
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(settlement) || !Number.isFinite(ms) || isoDay(ms) !== settlement) return null;
77
+ const next = settlementAfter(ms);
78
+ return {
79
+ settlement,
80
+ published: isoDay(addBusinessDays(ms, PUBLICATION_LAG_BUSINESS_DAYS)),
81
+ nextSettlement: isoDay(next),
82
+ nextPublished: isoDay(addBusinessDays(next, PUBLICATION_LAG_BUSINESS_DAYS)),
83
+ };
84
+ }
85
+
86
+ /** `Fri 9 Oct`, with its year when it is not the settlement's: a reader sees the year once, in the settlement's own date. */
87
+ function dayText(date: string, settlement: string): string {
88
+ const ms = parseDay(date);
89
+ const day = new Date(ms);
90
+ const year = date.slice(0, 4) === settlement.slice(0, 4) ? "" : ` ${date.slice(0, 4)}`;
91
+ return `${WEEKDAYS[day.getUTCDay()]} ${day.getUTCDate()} ${MONTHS[day.getUTCMonth()]}${year}`;
92
+ }
93
+
94
+ /**
95
+ * `published Fri 9 Oct, next settlement Thu 15 Oct, published Mon 26 Oct`: what a
96
+ * trader could see on a given day, beside the settlement date the report cites.
97
+ */
98
+ export function shortInterestScheduleText(schedule: ShortInterestSchedule): string {
99
+ const day = (date: string) => dayText(date, schedule.settlement);
100
+ return `published ${day(schedule.published)}, next settlement ${day(schedule.nextSettlement)}, published ${day(schedule.nextPublished)}`;
101
+ }
102
+
103
+ /** The report's freshness for the newest settlement it shows; nothing when no settlement is dated. */
104
+ export function shortInterestFreshness(latestSettlement: string | null | undefined): { schedule: string; publishedAt: string } | undefined {
105
+ const schedule = latestSettlement ? shortInterestSchedule(latestSettlement) : null;
106
+ return schedule ? { schedule: shortInterestScheduleText(schedule), publishedAt: schedule.published } : undefined;
107
+ }
@@ -5,7 +5,8 @@ import type { QueryEntry } from "../../../market-data/result-types";
5
5
  import type { DataProvider } from "../../../types/data-provider";
6
6
  import type { PricePoint, TickerFinancials } from "../../../types/financials";
7
7
  import { errorMessage } from "../../../utils/errors";
8
- import { loadShortInterest } from "./client";
8
+ import { loadShortInterest, type ShortInterestResult } from "./client";
9
+ import { type SettlementFloatStatistics, withLatestPercentFloat } from "./model";
9
10
  import { buildShortWatchRow, type ShortWatchRow } from "./watch-model";
10
11
  import type { ShortInterestRecord } from "./types";
11
12
 
@@ -30,10 +31,55 @@ export function createShortWatchDependencies(
30
31
  };
31
32
  }
32
33
 
34
+ /** A symbol's current float and the statistics' short % of float with its settlement, from its snapshot. */
35
+ interface FloatSnapshot {
36
+ floatShares: number | null;
37
+ statistics: SettlementFloatStatistics | null;
38
+ }
39
+
40
+ /** Each symbol's float and statistics, in order: empty where its snapshot is missing or carries none. */
41
+ async function loadFloats(
42
+ symbols: readonly string[],
43
+ loadSnapshots: ShortWatchDependencies["loadSnapshots"],
44
+ options: { forceRefresh?: boolean } = {},
45
+ ): Promise<FloatSnapshot[]> {
46
+ const entries = await Promise.resolve()
47
+ .then(() => loadSnapshots(symbols.map((symbol) => ({ symbol, exchange: "" })), options))
48
+ .catch(() => []);
49
+ return symbols.map((_symbol, index) => {
50
+ const entry = entries[index];
51
+ const fundamentals = (entry ? resolveEntryValue(entry) : null)?.fundamentals;
52
+ return {
53
+ floatShares: fundamentals?.floatShares ?? null,
54
+ statistics: fundamentals?.shortInterestDate
55
+ ? { settlementDate: fundamentals.shortInterestDate, shortPercentOfFloat: fundamentals.shortPercentOfFloat ?? null }
56
+ : null,
57
+ };
58
+ });
59
+ }
60
+
61
+ /**
62
+ * A ticker's settlements with the newest one's percent of float, as SIW reads
63
+ * it (settlementPercentFloat). A snapshot that fails leaves the settlements as
64
+ * they are.
65
+ */
66
+ export async function loadShortInterestWithFloat(
67
+ symbol: string,
68
+ { marketData, client = apiClient, loadSnapshots = createShortWatchDependencies(marketData, client).loadSnapshots }: {
69
+ marketData?: DataProvider;
70
+ client?: Pick<typeof apiClient, "getCloudShortInterest">;
71
+ loadSnapshots?: ShortWatchDependencies["loadSnapshots"];
72
+ } = {},
73
+ ): Promise<ShortInterestResult> {
74
+ const [result, [snapshot]] = await Promise.all([loadShortInterest(symbol, client), loadFloats([symbol], loadSnapshots)]);
75
+ return { ...result, records: withLatestPercentFloat(result.records, snapshot?.floatShares, snapshot?.statistics) };
76
+ }
77
+
33
78
  /**
34
- * Every name's settlements, float and daily closes. The floats come in one
35
- * batched snapshot; settlements and closes are per symbol, six at a time. A
36
- * name whose closes fail keeps its short interest without a price move.
79
+ * Every name's settlements, float and daily closes. The floats, with the
80
+ * statistics' short % of float and its settlement, come in one batched
81
+ * snapshot; settlements and closes are per symbol, six at a time. A name
82
+ * whose closes fail keeps its short interest without a price move.
37
83
  */
38
84
  export async function loadShortWatch(
39
85
  symbols: readonly string[],
@@ -41,7 +87,7 @@ export async function loadShortWatch(
41
87
  dependencies: ShortWatchDependencies = createShortWatchDependencies(),
42
88
  ): Promise<ShortWatchRow[]> {
43
89
  const instruments = symbols.map((symbol) => ({ symbol, exchange: "" }));
44
- const snapshots = dependencies.loadSnapshots(instruments, { forceRefresh: options.forceRefresh }).catch(() => []);
90
+ const floats = loadFloats(symbols, dependencies.loadSnapshots, { forceRefresh: options.forceRefresh });
45
91
  const rows = new Array<ShortWatchRow>(symbols.length);
46
92
  let next = 0;
47
93
  const worker = async () => {
@@ -53,12 +99,12 @@ export async function loadShortWatch(
53
99
  dependencies.loadChart({ instrument, bufferRange: "3M", granularity: "resolution", resolution: "1d" },
54
100
  { forceRefresh: options.forceRefresh }).then((entry) => resolveEntryValue(entry) ?? []),
55
101
  ]);
56
- const entry = (await snapshots)[index];
57
- const floatShares = (entry ? resolveEntryValue(entry) : null)?.fundamentals?.floatShares ?? null;
102
+ const snapshot = (await floats)[index];
58
103
  rows[index] = buildShortWatchRow({
59
104
  symbol: instrument.symbol,
60
105
  records: records.status === "fulfilled" ? records.value : null,
61
- floatShares,
106
+ floatShares: snapshot?.floatShares ?? null,
107
+ statistics: snapshot?.statistics ?? null,
62
108
  history: history.status === "fulfilled" ? history.value : null,
63
109
  error: records.status === "rejected" ? errorMessage(records.reason) : null,
64
110
  });
@@ -1,5 +1,6 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { formatCompact, formatNumber } from "../../../utils/format";
3
+ import { shortInterestFreshness } from "./schedule";
3
4
  import { createShortWatchDependencies, loadShortWatch } from "./watch-client";
4
5
  import { customUniverse, type ShortWatchSetup, setupLabel, SHORT_WATCH_LIMIT, sortShortWatchRows } from "./watch-model";
5
6
 
@@ -13,33 +14,37 @@ export const shortWatchHeadless: HeadlessPaneDefinition<"rows"> = {
13
14
  freshness: { source: "FINRA", status: "not-a-feed", basis: "twice-monthly settlement", observedKey: "settlementDate", maxAgeMinutes: 30 * 24 * 60 },
14
15
  argument: { kind: "symbol-list", maximum: SHORT_WATCH_LIMIT, description: "US tickers; the pane defaults to your portfolios and watchlists." },
15
16
  usListingsOnly: "FINRA short interest",
17
+ failsWithoutData: true,
16
18
  options: [],
17
19
  describe: "Short squeeze watch",
18
20
  discovery: {
19
21
  aliases: ["SIW"],
20
22
  screenshotReadiness: "live-dom",
21
23
  dataRequirements: ["FINRA short interest settlements", "Float", "Daily closes"],
22
- limitations: ["Percent of float uses the current float against the settlement's shares short.",
24
+ limitations: ["Borrow fee, utilization and locate availability are not available: FINRA short interest counts shares short, not what they cost to borrow.",
25
+ "Percent of float uses the current float against the settlement's shares short.",
23
26
  "Crowded is a fixed threshold, not a squeeze forecast."],
24
27
  },
25
28
  columns: [
26
29
  { key: "symbol", header: "Symbol" },
27
30
  { key: "setup", header: "Setup", format: (value) => setupLabel(value as ShortWatchSetup | null) || "--" },
28
31
  { key: "percentFloat", header: "% Float", align: "right", format: number(2, "%") },
29
- { key: "daysToCover", header: "Days to Cover", align: "right", format: number(2) },
32
+ { key: "daysToCover", header: "Days to cover", align: "right", format: number(2) },
30
33
  { key: "changePercent", header: "SI Chg %", align: "right", format: signedPercent },
31
34
  { key: "return1M", header: "1M %", align: "right", format: signedPercent },
32
35
  { key: "sharesShort", header: "Shares Short", align: "right", format: (value) => typeof value === "number" ? formatCompact(value) : "--" },
33
36
  { key: "settlementDate", header: "Settled" },
34
- { key: "closeDate", header: "Close" },
37
+ { key: "closeDate", header: "Close date" },
35
38
  ],
36
39
  async load(args, ctx) {
37
40
  const universe = customUniverse(args.symbols.join(","));
38
41
  const rows = sortShortWatchRows(await loadShortWatch(universe.symbols, { signal: ctx.signal, forceRefresh: ctx.refresh },
39
42
  createShortWatchDependencies(ctx.marketData, ctx.apiClient)), "setup", "desc");
40
43
  const missing = rows.filter((row) => !row.settlementDate);
44
+ const freshness = shortInterestFreshness(rows.map((row) => row.settlementDate).filter((date): date is string => !!date).sort().at(-1));
41
45
  return {
42
46
  rows: rows.map(({ error: _error, ...row }) => row),
47
+ ...(freshness ? { freshness } : {}),
43
48
  complete: !missing.length && !universe.notice && rows.every((row) => row.return1M != null),
44
49
  symbols: universe.symbols,
45
50
  unavailableSymbols: missing.map((row) => row.symbol),
@@ -3,7 +3,9 @@ import type { PricePoint } from "../../../types/financials";
3
3
  import type { TickerRecord } from "../../../types/ticker";
4
4
  import { isKnownNonUsListing, listingAbroadVenue } from "../../../utils/sec";
5
5
  import { parsePublicTickerKey } from "../../../utils/exchanges";
6
+ import { splitSymbolArgs } from "../../../cli/symbol-args";
6
7
  import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
8
+ import { type SettlementFloatStatistics, settlementPercentFloat } from "./model";
7
9
  import type { ShortInterestRecord } from "./types";
8
10
 
9
11
  /** Short interest is published per settlement for each US listing, so a list fetches one symbol at a time. */
@@ -51,6 +53,8 @@ export interface ShortWatchInputs {
51
53
  symbol: string;
52
54
  records: readonly ShortInterestRecord[] | null;
53
55
  floatShares: number | null;
56
+ /** The statistics' short % of float and its settlement (settlementPercentFloat). */
57
+ statistics?: SettlementFloatStatistics | null;
54
58
  history: readonly PricePoint[] | null;
55
59
  error?: string | null;
56
60
  }
@@ -101,19 +105,16 @@ function shortWatchSetup(row: Pick<ShortWatchRow, "percentFloat" | "daysToCover"
101
105
 
102
106
  /**
103
107
  * One name's latest settlement, its change from the settlement before, its
104
- * shares short over the float, and its month's price move. A settlement that
105
- * carries its own percent of float keeps it; otherwise the float is the
106
- * snapshot's current float.
108
+ * shares short over the float, and its month's price move. The percent of
109
+ * float is the statistics' own when they are of the same settlement, so it
110
+ * reads as the overview and `fundamentals` do; otherwise the settlement's
111
+ * shares short over the snapshot's float.
107
112
  */
108
113
  export function buildShortWatchRow(inputs: ShortWatchInputs): ShortWatchRow {
109
114
  const records = [...(inputs.records ?? [])].sort((left, right) => left.settlementDate.getTime() - right.settlementDate.getTime());
110
115
  const latest = records.at(-1);
111
116
  const prior = records.at(-2);
112
- // A float smaller than the shares short is another class's float (BRK.B reports Berkshire's Class A), not a short over 100%.
113
- const percentFloat = latest
114
- ? latest.shortPercentFloat ?? (finite(inputs.floatShares) && inputs.floatShares >= latest.sharesShort && inputs.floatShares > 0
115
- ? latest.sharesShort / inputs.floatShares * 100 : null)
116
- : null;
117
+ const percentFloat = latest ? settlementPercentFloat(latest, inputs.floatShares, inputs.statistics) : null;
117
118
  const move = inputs.history ? oneMonthReturn(inputs.history) : null;
118
119
  const row = {
119
120
  symbol: inputs.symbol,
@@ -163,10 +164,20 @@ export function sharedDate(rows: readonly ShortWatchRow[], key: "settlementDate"
163
164
 
164
165
  export interface ShortWatchUniverse { symbols: string[]; notice: string | null }
165
166
 
166
- /** US listings only: short interest settlements cover US-traded shares. */
167
- function listed(ticker: Pick<TickerRecord, "metadata">): boolean {
167
+ const NOT_US_LISTED = "not US-listed";
168
+ const NOT_A_US_TICKER = "not a US ticker symbol";
169
+ const NOT_A_STOCK_OR_FUND = "not a US-listed stock or fund";
170
+
171
+ /** A US-listed stock or fund; otherwise why this name has no short interest. */
172
+ function listedReason(ticker: Pick<TickerRecord, "metadata">): string | null {
168
173
  const category = (ticker.metadata.assetCategory ?? "").toUpperCase().replace(/[\s_-]/g, "");
169
- return LISTED_TYPES.has(category) && !isKnownNonUsListing(ticker as TickerRecord);
174
+ if (!LISTED_TYPES.has(category)) return NOT_A_STOCK_OR_FUND;
175
+ return isKnownNonUsListing(ticker as TickerRecord) ? NOT_US_LISTED : null;
176
+ }
177
+
178
+ /** "Left out: ABC.L: not US-listed; XYZ: not a US ticker symbol.", or null when every name was read. */
179
+ function leftOut(skipped: readonly { name: string; reason: string }[]): string | null {
180
+ return skipped.length ? `Left out: ${skipped.map(({ name, reason }) => `${name}: ${reason}`).join("; ")}.` : null;
170
181
  }
171
182
 
172
183
  function cap(symbols: string[], skipped: string | null): ShortWatchUniverse {
@@ -177,22 +188,40 @@ function cap(symbols: string[], skipped: string | null): ShortWatchUniverse {
177
188
  };
178
189
  }
179
190
 
180
- const plural = (count: number) => `${count} name${count === 1 ? "" : "s"}`;
181
-
182
191
  /** Every name in the user's portfolios and watchlists. */
183
192
  export function ownNamesUniverse(tickers: readonly Pick<TickerRecord, "metadata">[]): ShortWatchUniverse {
184
193
  const members = tickers.filter((ticker) => ticker.metadata.portfolios.length > 0 || ticker.metadata.watchlists.length > 0);
185
- const symbols = members.filter(listed).map((ticker) => ticker.metadata.ticker.toUpperCase()).filter((symbol) => SYMBOL.test(symbol));
186
- const skipped = members.length - symbols.length;
187
- return cap(symbols.sort(), skipped ? `${plural(skipped)} left out: short interest covers US-listed shares.` : null);
194
+ const symbols: string[] = [];
195
+ const skipped: { name: string; reason: string }[] = [];
196
+ for (const ticker of members) {
197
+ const symbol = ticker.metadata.ticker.toUpperCase();
198
+ const reason = listedReason(ticker) ?? (SYMBOL.test(symbol) ? null : NOT_A_US_TICKER);
199
+ if (reason) skipped.push({ name: symbol, reason });
200
+ else symbols.push(symbol);
201
+ }
202
+ return cap(symbols.sort(), leftOut(skipped.sort((left, right) => left.name.localeCompare(right.name))));
188
203
  }
189
204
 
205
+ /** A share class written with a dash (BRK-B, BF-B) is the dotted symbol (BRK.B, BF.B) short interest is filed under. */
206
+ const DASHED_CLASS_SHARE = /^([A-Z]{1,5})-([A-Z])$/;
207
+
208
+ /**
209
+ * The names typed or pasted: split by commas, semicolons or blanks, each
210
+ * without the quote marks or the `$` around it. A name this cannot read as a US
211
+ * symbol is left out and named with its reason.
212
+ */
190
213
  export function customUniverse(text: string): ShortWatchUniverse {
191
- const entries = text.split(/[\s,]+/).map((value) => value.toUpperCase()).filter(Boolean);
192
- // A listing abroad (SAN:EPA) is not its symbol's US line, which can be another company's.
193
- const valid = entries.filter((entry) => listingAbroadVenue(entry) == null)
194
- .map((entry) => parsePublicTickerKey(entry).symbol)
195
- .filter((symbol) => SYMBOL.test(symbol));
196
- const skipped = entries.length - valid.length;
197
- return cap(valid, skipped ? `${plural(skipped)} left out: short interest covers US-listed shares.` : null);
214
+ const symbols: string[] = [];
215
+ const skipped: { name: string; reason: string }[] = [];
216
+ for (const entry of splitSymbolArgs([text]).map((value) => value.replace(/^\$+/, "").toUpperCase())) {
217
+ // A listing abroad (SAN:EPA) is not its symbol's US line, which can be another company's.
218
+ if (listingAbroadVenue(entry) != null) {
219
+ skipped.push({ name: entry, reason: NOT_US_LISTED });
220
+ continue;
221
+ }
222
+ const symbol = parsePublicTickerKey(entry).symbol.replace(DASHED_CLASS_SHARE, "$1.$2");
223
+ if (SYMBOL.test(symbol)) symbols.push(symbol);
224
+ else skipped.push({ name: entry, reason: NOT_A_US_TICKER });
225
+ }
226
+ return cap(symbols, leftOut(skipped));
198
227
  }
@@ -50,7 +50,7 @@ const COLUMNS: WatchColumn[] = [
50
50
  { id: "return1M", label: "1M%", width: 8, align: "right" },
51
51
  { id: "sharesShort", label: "Short", width: 8, align: "right" },
52
52
  { id: "settlementDate", label: "Settled", width: 8, align: "right" },
53
- { id: "closeDate", label: "Close", width: 8, align: "right" },
53
+ { id: "closeDate", label: "Close date", width: 10, align: "right" },
54
54
  ];
55
55
  const DEFAULT_SORT = { columnId: "setup" as ShortWatchSortId, direction: "desc" as SortDirection };
56
56
  const rowKey = (row: ShortWatchRow) => row.symbol;
@@ -183,9 +183,10 @@ export function SupplyFlow({ rows, symbol, focusName, focusId, width, height, fo
183
183
  const hasBand = (band: "suppliers" | "customers") => nodes.some((node) => node.band === band);
184
184
  return <Box width={width} height={height} flexGrow={1} flexBasis={0} minHeight={0} position="relative" overflow="hidden">
185
185
  <ChartSurface ref={surfaceRef} width={width} height={height} position="absolute" left={0} top={0}
186
- vectors={desktop ? vectors : undefined} bitmap={bitmap} flexDirection="column" aria-label="Supply chain flow"
186
+ vectors={desktop ? vectors : undefined} bitmap={bitmap} flexDirection="column"
187
187
  onMouseMove={hoverAt} onMouseOut={() => setHovered(null)}
188
- onMouseDown={() => { const node = hovered ? nodes.find((entry) => entry.id === hovered) : null; if (node) selectNode(node); }}>
188
+ onMouseDown={() => { const node = hovered ? nodes.find((entry) => entry.id === hovered) : null; if (node) selectNode(node); }}
189
+ aria-label={hovered ? flowNodeName(nodes.find((entry) => entry.id === hovered)!) : "Supply chain flow"}>
189
190
  {fallback ? fallback.map((spans, y) => <Text key={y}>{spans.map((span, i) => <Span key={i} fg={span.color}>{span.text}</Span>)}</Text>) : null}
190
191
  </ChartSurface>
191
192
  {header("suppliers", 1)}
@@ -217,6 +218,13 @@ export function SupplyFlow({ rows, symbol, focusName, focusId, width, height, fo
217
218
  </Box>;
218
219
  }
219
220
 
221
+ function flowNodeName(node: PositionedNode): string {
222
+ if (node.more) return `Show ${node.more} more ${node.role ? ROLE_PLURAL[node.role].toLowerCase() : node.band}`;
223
+ const name = node.row ? counterpartyLabel(node.row) : node.label;
224
+ const role = node.role ? roleLabel(node.role) : node.band;
225
+ return `Open ${name}, ${role}`;
226
+ }
227
+
220
228
  function tooltipAnchor(node: PositionedNode, ribbons: readonly FlowRibbon[], width: number) {
221
229
  const ribbon = ribbons.find((entry) => entry.id === node.id);
222
230
  const x = ribbon ? (ribbon.x0 + ribbon.x1) / 2 : node.x + node.width / 2;
@@ -275,7 +283,8 @@ function FlowCard({ node, focusId, desktop, active, onPress, onHover }: {
275
283
  backgroundColor={node.more ? undefined : active ? desktop ? blendHex(colors.bg, accent, 0.2) : colors.selected : blendHex(colors.bg, accent, desktop ? 0.09 : 0.12)}
276
284
  style={desktop ? { borderLeft: `3px ${node.more ? "dotted" : "solid"} ${node.more ? blendHex(colors.bg, accent, 0.5) : accent}`, borderRadius: 3, paddingLeft: 6, cursor: "pointer",
277
285
  boxShadow: active ? `inset 0 0 0 1px ${blendHex(colors.bg, accent, 0.6)}` : undefined } : undefined}
278
- onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-node">
286
+ onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-node"
287
+ role="button" aria-label={flowNodeName(node)}>
279
288
  {/* The ticker shows only beside the whole name. On the desktop that is CSS: the row wraps, one line shows, and a ticker that does not fit goes to the hidden line. */}
280
289
  <Box flexDirection="row" height={1} overflow="hidden" style={desktop ? { flexWrap: "wrap" } : undefined}>
281
290
  {!desktop ? <Text fg={node.more ? colors.textMuted : accent}>▍</Text> : null}
@@ -295,7 +304,8 @@ function FlowChip({ node, desktop, active, onPress, onHover }: { node: Positione
295
304
  const background = active ? blendHex(colors.bg, accent, desktop ? 0.32 : 0.45) : node.more ? undefined : blendHex(colors.bg, accent, 0.14);
296
305
  const text = <ClippedText fg={node.more ? colors.textDim : active ? colors.textBright : accent} text={flowChipLabel(node)} width={node.width - 2} />;
297
306
  return <Box position="absolute" left={node.x} top={node.y} width={node.width} height={1} justifyContent={desktop ? "center" : undefined}
298
- onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-chip">
307
+ onMouseDown={onPress} onMouseOver={() => onHover(true)} onMouseOut={() => onHover(false)} data-gloom-role="supply-flow-chip"
308
+ role="button" aria-label={flowNodeName(node)}>
299
309
  {/* The desktop chip leaves a hairline of row above and below, like ticker chips. */}
300
310
  {desktop ? <Box paddingX={1} minWidth={0} backgroundColor={background} justifyContent="center" style={{ height: "calc(100% - 4px)", borderRadius: 3, cursor: "pointer" }}>{text}</Box>
301
311
  : <Box paddingX={1} height={1} backgroundColor={background}>{text}</Box>}
@@ -72,7 +72,7 @@ export const themesHeadless: HeadlessPaneDefinition<"rows"> = {
72
72
  const argument = Array.isArray(args.argument) ? args.argument.join(" ") : args.argument;
73
73
  if (argument) {
74
74
  const theme = matchTheme(board.themes, argument);
75
- if (!theme) throw new Error(`No theme matches ${argument}`);
75
+ if (!theme) throw new Error(`No theme matches ${argument}. Themes: ${board.themes.map((row) => row.name).join(", ")}.`);
76
76
  const data = await fetchThemeMembers(theme.id, context.apiClient);
77
77
  const observation = boardObservation(data);
78
78
  // Price and 1D are the regular session's; an extended print gets a column only when a member has one.