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.
- package/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +22 -9
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/api-client/types.ts +3 -1
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/index.ts +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +19 -2
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +33 -6
- package/src/plugins/builtin/futures/model.ts +69 -5
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/dom/styles.css +6 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +24 -5
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ScreenMetric, ScreenRow } from "../../../api-client/equity-screener";
|
|
1
|
+
import type { ScreenDefinition, ScreenMetric, ScreenRow } from "../../../api-client/equity-screener";
|
|
2
2
|
import { getExtendedSessionDisplay, type ExtendedSession, type ExtendedSessionDisplay } from "../../../market-data/market/status";
|
|
3
3
|
import { quoteTradingDay } from "../../../market-data/quotes/day-range";
|
|
4
4
|
import { getRegularSessionQuoteDisplay } from "../../../market-data/quotes/screener-live-quotes";
|
|
@@ -112,6 +112,32 @@ export function overlayScreenQuotes(rows: readonly ScreenRow[], quotes: Readonly
|
|
|
112
112
|
return overlayRows(rows, (key) => quotes.get(key));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** The fields a quote moves on a row (overlayLiveScreenRow). */
|
|
116
|
+
const LIVE_SORT_FIELDS: ReadonlySet<string> = new Set(["price", "changePercent", "volume", "marketCap"]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rows in the screen's order by the figures they show. A sort on a field the
|
|
120
|
+
* quotes move (price, change, volume, market cap) is redone on the overlaid
|
|
121
|
+
* values, so the column reads monotone: GRAB's snapshot cap, two closes old,
|
|
122
|
+
* kept it above MANH after its quote moved it below (GLO-754). Rows without a
|
|
123
|
+
* value stay last, and ties keep the snapshot's order. Any other sort is the
|
|
124
|
+
* snapshot's, which the overlay does not touch.
|
|
125
|
+
*/
|
|
126
|
+
export function sortLiveScreenRows<T extends ScreenRow>(rows: readonly T[], sort: ScreenDefinition["sort"]): T[] {
|
|
127
|
+
if (!LIVE_SORT_FIELDS.has(sort.field)) return [...rows];
|
|
128
|
+
const field = sort.field as "price" | "changePercent" | "volume" | "marketCap";
|
|
129
|
+
const sign = sort.direction === "desc" ? -1 : 1;
|
|
130
|
+
return rows
|
|
131
|
+
.map((row, index) => ({ row, index, value: row.metrics[field]?.value }))
|
|
132
|
+
.sort((a, b) => {
|
|
133
|
+
const aKnown = isFiniteNumber(a.value), bKnown = isFiniteNumber(b.value);
|
|
134
|
+
if (aKnown !== bKnown) return aKnown ? -1 : 1;
|
|
135
|
+
if (aKnown && bKnown && a.value !== b.value) return sign * (a.value! - b.value!);
|
|
136
|
+
return a.index - b.index;
|
|
137
|
+
})
|
|
138
|
+
.map(({ row }) => row);
|
|
139
|
+
}
|
|
140
|
+
|
|
115
141
|
/** The extended sessions some row has a print for, in the order their columns go. */
|
|
116
142
|
export function screenExtendedSessions(rows: readonly LiveScreenRow[]): ExtendedSession[] {
|
|
117
143
|
return (["PRE", "POST", "OVERNIGHT"] as const).filter((session) => rows.some((row) => row.extended?.session === session));
|
|
@@ -48,6 +48,7 @@ import { EXTENDED_SESSION_LABELS, extendedSessionStateLabel, type ExtendedSessio
|
|
|
48
48
|
import { useOpenDescription } from "../shared/open-description";
|
|
49
49
|
import { useResearchCloudSession } from "../shared/research-cloud-session";
|
|
50
50
|
import { INITIAL_STREAM_RANGE, streamWindowRows } from "../shared/use-quote-board";
|
|
51
|
+
import { useSampledValue } from "../../../state/hooks/live-ticker-financials";
|
|
51
52
|
import { usePlanAccess } from "../../../api-client/plan-access";
|
|
52
53
|
import { SignInWall } from "../cloud/auth-actions";
|
|
53
54
|
import { CriterionEditor } from "./criterion-editor";
|
|
@@ -71,12 +72,14 @@ import {
|
|
|
71
72
|
screenRowId,
|
|
72
73
|
} from "./model";
|
|
73
74
|
import { useScreenResults } from "./results";
|
|
74
|
-
import { overlayLiveScreenRows, screenExtendedMove, screenExtendedSessions, type LiveScreenRow } from "./live";
|
|
75
|
+
import { overlayLiveScreenRows, screenExtendedMove, screenExtendedSessions, sortLiveScreenRows, type LiveScreenRow } from "./live";
|
|
75
76
|
import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
|
|
76
77
|
import { buildScreenerQuoteTargets } from "../../../market-data/quotes/screener-live-quotes";
|
|
77
78
|
import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
|
|
78
79
|
import { getTableWidth } from "../../../components/ui/table-layout";
|
|
79
80
|
|
|
81
|
+
/** How often a live sort re-reads the figures it orders by. */
|
|
82
|
+
const LIVE_ORDER_SAMPLE_MS = 5_000;
|
|
80
83
|
const TABS = [
|
|
81
84
|
{ value: "results", label: "Results" },
|
|
82
85
|
{ value: "criteria", label: "Criteria" },
|
|
@@ -290,7 +293,23 @@ function EquityScreenView({
|
|
|
290
293
|
freshnessScopeKey: `equity-screener:${JSON.stringify(definition)}`,
|
|
291
294
|
liveStreaming,
|
|
292
295
|
});
|
|
293
|
-
const
|
|
296
|
+
const liveRows = useMemo(() => overlayLiveScreenRows(snapshotRows, liveEntries), [liveEntries, snapshotRows]);
|
|
297
|
+
// A sort on a field the quotes move is redone on the figures shown, so the
|
|
298
|
+
// column reads monotone. Re-sorting on every tick would reshuffle rows under
|
|
299
|
+
// the cursor; the order is taken from values sampled every few seconds,
|
|
300
|
+
// and at once when the screen or its pages change.
|
|
301
|
+
const orderRows = useSampledValue(
|
|
302
|
+
liveRows,
|
|
303
|
+
LIVE_ORDER_SAMPLE_MS,
|
|
304
|
+
`${data?.snapshot?.id ?? ""}:${JSON.stringify(definition.sort)}:${snapshotRows.length}`,
|
|
305
|
+
);
|
|
306
|
+
const rows = useMemo(() => {
|
|
307
|
+
const order = new Map(sortLiveScreenRows(orderRows, definition.sort).map((row, index) => [screenRowId(row), index]));
|
|
308
|
+
return liveRows
|
|
309
|
+
.map((row, index) => ({ row, index, place: order.get(screenRowId(row)) ?? Number.POSITIVE_INFINITY }))
|
|
310
|
+
.sort((a, b) => a.place - b.place || a.index - b.index)
|
|
311
|
+
.map(({ row }) => row);
|
|
312
|
+
}, [definition.sort, liveRows, orderRows]);
|
|
294
313
|
const opened = rows.find((row) => screenRowId(row) === openId);
|
|
295
314
|
const selected =
|
|
296
315
|
opened ?? rows.find((row) => screenRowId(row) === selectedId) ?? rows[0];
|
|
@@ -111,6 +111,7 @@ function SentimentPlot({
|
|
|
111
111
|
valueFormat,
|
|
112
112
|
updatedAt,
|
|
113
113
|
overlays,
|
|
114
|
+
focused = false,
|
|
114
115
|
}: {
|
|
115
116
|
rating: FearGreedRating;
|
|
116
117
|
points: FearGreedIndicator["points"];
|
|
@@ -118,6 +119,7 @@ function SentimentPlot({
|
|
|
118
119
|
valueFormat: FearGreedValueFormat;
|
|
119
120
|
updatedAt: Date | null;
|
|
120
121
|
overlays?: StaticChartOverlay[];
|
|
122
|
+
focused?: boolean;
|
|
121
123
|
}) {
|
|
122
124
|
const chartWidth = Math.max(24, width - 2);
|
|
123
125
|
const chartHeight = width >= 96 ? 12 : 10;
|
|
@@ -143,6 +145,7 @@ function SentimentPlot({
|
|
|
143
145
|
mode="line"
|
|
144
146
|
colors={palette}
|
|
145
147
|
overlays={overlays}
|
|
148
|
+
focused={focused}
|
|
146
149
|
showTimeAxis
|
|
147
150
|
timeAxisColor={colors.textDim}
|
|
148
151
|
yAxisColor={colors.textDim}
|
|
@@ -224,7 +227,7 @@ function ChartStats({
|
|
|
224
227
|
|
|
225
228
|
// The gauge above already shows the score, its rating and what the series is,
|
|
226
229
|
// so the history is the plot alone.
|
|
227
|
-
export function IndexHistoryChart({ data, width }: { data: FearGreedData; width: number }) {
|
|
230
|
+
export function IndexHistoryChart({ data, width, focused = false }: { data: FearGreedData; width: number; focused?: boolean }) {
|
|
228
231
|
return (
|
|
229
232
|
<Box flexDirection="column" paddingX={1}>
|
|
230
233
|
<SentimentPlot
|
|
@@ -233,6 +236,7 @@ export function IndexHistoryChart({ data, width }: { data: FearGreedData; width:
|
|
|
233
236
|
width={width}
|
|
234
237
|
valueFormat="score"
|
|
235
238
|
updatedAt={data.overall.updatedAt}
|
|
239
|
+
focused={focused}
|
|
236
240
|
/>
|
|
237
241
|
</Box>
|
|
238
242
|
);
|
|
@@ -91,7 +91,7 @@ export function FearGreedPane({ paneId, focused, width, height }: PaneProps) {
|
|
|
91
91
|
segments={FEAR_GREED_GAUGE_SEGMENTS}
|
|
92
92
|
minWidth={isDesktopWeb ? Math.min(34, Math.max(1, width - 2)) : undefined}
|
|
93
93
|
/>
|
|
94
|
-
<IndexHistoryChart data={data} width={width} />
|
|
94
|
+
<IndexHistoryChart data={data} width={width} focused={focused} />
|
|
95
95
|
<Box paddingX={1} marginTop={2}>
|
|
96
96
|
<SectionHeading title="Indicators" />
|
|
97
97
|
</Box>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
-
Button,
|
|
4
3
|
DataTableView,
|
|
5
4
|
PaneStatusBody,
|
|
6
5
|
QueryBar,
|
|
@@ -28,6 +27,7 @@ import { useLiveStreamingSetting } from "../../../state/hooks/live-streaming";
|
|
|
28
27
|
import { quoteObservation } from "../shared/report-freshness";
|
|
29
28
|
import {
|
|
30
29
|
boardErrorMessage,
|
|
30
|
+
boardHeadlineQuote,
|
|
31
31
|
quoteBoardFooterInfo,
|
|
32
32
|
quoteBoardStatus,
|
|
33
33
|
useQuoteBoard,
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
usesSessionText,
|
|
60
60
|
type FuturesColumn,
|
|
61
61
|
} from "./table";
|
|
62
|
+
import { useFuturesBoardTerms } from "./use-board-terms";
|
|
62
63
|
import { useFrontContractReturns } from "./use-front-returns";
|
|
63
64
|
|
|
64
65
|
const FUTURES_PANE_ID = "futures";
|
|
@@ -134,6 +135,8 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
|
|
|
134
135
|
const paneVisible = usePaneVisible();
|
|
135
136
|
const showsReturns = columns.some((column) => FUTURES_RETURN_COLUMNS[column.id]);
|
|
136
137
|
const returns = useFrontContractReturns(listedContracts, quotes, dataProvider, showsReturns && paneVisible);
|
|
138
|
+
// The unit a price counts and the month a symbol rolled past, as `quote` reads them.
|
|
139
|
+
const terms = useFuturesBoardTerms(listedContracts, !!dataProvider && paneVisible);
|
|
137
140
|
|
|
138
141
|
const visibleCollapsed = effectiveCollapsedSectors(collapsedSectors, searchQuery);
|
|
139
142
|
const rows = useMemo(
|
|
@@ -141,15 +144,38 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
|
|
|
141
144
|
query: searchQuery,
|
|
142
145
|
collapsed: collapsedSectors,
|
|
143
146
|
returns,
|
|
147
|
+
terms,
|
|
144
148
|
}),
|
|
145
|
-
[collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference],
|
|
149
|
+
[collapsedSectors, contractsBySector, quotes, returns, searchQuery, sortPreference, terms],
|
|
146
150
|
);
|
|
147
151
|
|
|
148
152
|
const sessionText = usesSessionText(width);
|
|
153
|
+
const quotesRef = useRef(quotes);
|
|
154
|
+
const returnsRef = useRef(returns);
|
|
155
|
+
const termsRef = useRef(terms);
|
|
156
|
+
quotesRef.current = quotes;
|
|
157
|
+
returnsRef.current = returns;
|
|
158
|
+
termsRef.current = terms;
|
|
149
159
|
const renderCell = useCallback((
|
|
150
160
|
row: FuturesTableRow,
|
|
151
161
|
column: FuturesColumn,
|
|
152
|
-
) => renderFuturesCell(row, column,
|
|
162
|
+
) => renderFuturesCell(row, column, quotesRef.current, { sessionText, returns: returnsRef.current, terms: termsRef.current }), [sessionText]);
|
|
163
|
+
// Everything a cell reads: the headline figures the board shows, the quote's
|
|
164
|
+
// other fields, whether the last good quote is held over, and the returns.
|
|
165
|
+
const getRowVersion = useCallback((row: FuturesTableRow) => {
|
|
166
|
+
if (row.type !== "row") return row.sector;
|
|
167
|
+
const state = quotes.get(row.contract.symbol);
|
|
168
|
+
const quote = state?.quote ? boardHeadlineQuote(state.quote) : null;
|
|
169
|
+
const values = returns.get(row.contract.symbol);
|
|
170
|
+
const contract = terms.get(row.contract.symbol);
|
|
171
|
+
return [
|
|
172
|
+
state?.loading, state?.stale,
|
|
173
|
+
quote?.price, quote?.change, quote?.changePercent, quote?.previousClose, quote?.volume, quote?.lastUpdated, quote?.marketState, quote?.symbol, quote?.name,
|
|
174
|
+
values?.loading, values?.values["1W"], values?.values["1M"], values?.values.YTD,
|
|
175
|
+
contract?.loading, contract?.terms?.spec?.unit, contract?.terms?.deliveryMonth, contract?.terms?.nearest?.contract,
|
|
176
|
+
contract?.terms?.nearest?.lastTrade,
|
|
177
|
+
].join(":");
|
|
178
|
+
}, [quotes, returns, terms]);
|
|
153
179
|
|
|
154
180
|
const toggleSector = useCallback((sector: FuturesSector) => {
|
|
155
181
|
setCollapsedSectors((current) => {
|
|
@@ -259,7 +285,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
|
|
|
259
285
|
height={height}
|
|
260
286
|
error={errorMessage ?? "No quotes returned."}
|
|
261
287
|
subject="Futures quotes"
|
|
262
|
-
|
|
288
|
+
retry={{ onRetry: refresh }}
|
|
263
289
|
/>
|
|
264
290
|
);
|
|
265
291
|
}
|
|
@@ -297,6 +323,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
|
|
|
297
323
|
onVisibleRangeChange={setVisibleRange}
|
|
298
324
|
renderSectionHeader={renderSectorHeader}
|
|
299
325
|
renderCell={renderCell}
|
|
326
|
+
getRowVersion={getRowVersion}
|
|
300
327
|
selectedTextOverridesCellColor
|
|
301
328
|
reportEveryRow
|
|
302
329
|
emptyStateTitle={searchQuery.trim()
|
|
@@ -361,7 +388,7 @@ export const futuresModule: PluginModule = {
|
|
|
361
388
|
paneId: FUTURES_PANE_ID,
|
|
362
389
|
label: "Futures Board",
|
|
363
390
|
description:
|
|
364
|
-
"
|
|
391
|
+
"Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
|
|
365
392
|
keywords: [
|
|
366
393
|
"futures",
|
|
367
394
|
"commodities",
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { FuturesContractTermsPayload } from "../../../api-client/futures-curve";
|
|
1
2
|
import type { Quote } from "../../../types/financials";
|
|
2
3
|
import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
|
|
3
4
|
import type { BoardQuoteMap } from "../shared/use-quote-board";
|
|
4
5
|
import { FUTURES_SECTOR_ORDER, type FuturesContract, type FuturesSector } from "../../../market-data/futures-board";
|
|
5
6
|
import type { FuturesReturnHorizon } from "./returns";
|
|
7
|
+
import type { FuturesTermsMap } from "./use-board-terms";
|
|
6
8
|
import type { FrontReturnsMap } from "./use-front-returns";
|
|
7
9
|
|
|
8
10
|
export type FuturesTableRow =
|
|
@@ -14,6 +16,7 @@ export type FuturesColumnId =
|
|
|
14
16
|
| "code"
|
|
15
17
|
| "name"
|
|
16
18
|
| "price"
|
|
19
|
+
| "unit"
|
|
17
20
|
| "change"
|
|
18
21
|
| "changePercent"
|
|
19
22
|
| "return1w"
|
|
@@ -37,7 +40,8 @@ export function futuresRowId(row: FuturesTableRow): string {
|
|
|
37
40
|
return row.type === "header" ? `header-${row.sector}` : row.contract.symbol;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
/** "Nov 26" or "Futures,Nov-2026"; a hyphen takes only a whole year, since "Futures,Nov-20" is 2026 cut short. */
|
|
44
|
+
const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?: (\d{4}|\d{2})|-(\d{4}))$/i;
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* The alias stays the row identity while its quoted contract rolls, so the
|
|
@@ -63,10 +67,64 @@ export function quotedContractMonth(
|
|
|
63
67
|
const match = CONTRACT_MONTH.exec(quote.name?.trim() ?? "");
|
|
64
68
|
if (!match) return null;
|
|
65
69
|
const month = MONTH_NAMES.findIndex((name) => name.toLowerCase() === match[1]!.toLowerCase());
|
|
66
|
-
const
|
|
70
|
+
const written = match[2] ?? match[3]!;
|
|
71
|
+
const year = written.length === 2 ? 2000 + Number(written) : Number(written);
|
|
67
72
|
return { year, month };
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
/** "2026-10-20" as "20 Oct". */
|
|
76
|
+
function dayMonth(date: string): string {
|
|
77
|
+
return `${Number(date.slice(8, 10))} ${MONTH_NAMES[Number(date.slice(5, 7)) - 1]}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The row's headline: the contract its quote prices, as futuresContractName
|
|
82
|
+
* words it. While the continuous symbol has rolled to the most active month
|
|
83
|
+
* and a nearer one still trades, the headline says so, with the nearer one's
|
|
84
|
+
* last trade: "WTI Crude Oil Dec 26, most active (Nov expires 20 Oct)". The
|
|
85
|
+
* terms only add that note when they name the month the quote prices, so a
|
|
86
|
+
* roll between the two loads never mixes months, and place the month when the
|
|
87
|
+
* quote's name carries none. A name column too narrow for the note keeps the
|
|
88
|
+
* plain name.
|
|
89
|
+
*/
|
|
90
|
+
export function futuresContractHeadline(
|
|
91
|
+
contract: FuturesContract,
|
|
92
|
+
quote?: Quote | null,
|
|
93
|
+
terms?: Pick<FuturesContractTermsPayload, "deliveryMonth" | "nearest"> | null,
|
|
94
|
+
width = Number.POSITIVE_INFINITY,
|
|
95
|
+
): string {
|
|
96
|
+
const quoted = quotedContractMonth(contract, quote);
|
|
97
|
+
const termsMonth = terms?.deliveryMonth && /^\d{4}-\d{2}$/.test(terms.deliveryMonth)
|
|
98
|
+
? { year: Number(terms.deliveryMonth.slice(0, 4)), month: Number(terms.deliveryMonth.slice(5, 7)) - 1 }
|
|
99
|
+
: null;
|
|
100
|
+
const nearest = terms?.nearest;
|
|
101
|
+
const agree = quoted == null || termsMonth == null || (quoted.year === termsMonth.year && quoted.month === termsMonth.month);
|
|
102
|
+
// A quote whose name carries no month (or a cut one) is placed by its terms.
|
|
103
|
+
const month = agree ? quoted ?? termsMonth : quoted;
|
|
104
|
+
if (!month) return contract.name;
|
|
105
|
+
const active = `${MONTH_NAMES[month.month]} ${String(month.year).slice(-2)}`;
|
|
106
|
+
const plain = `${contract.name} ${active}`;
|
|
107
|
+
if (!nearest || !agree || nearest.deliveryMonth >= `${month.year}-${String(month.month + 1).padStart(2, "0")}`) return plain;
|
|
108
|
+
const nearer = MONTH_NAMES[Number(nearest.deliveryMonth.slice(5, 7)) - 1];
|
|
109
|
+
const expiry = nearest.lastTrade ? `expires ${dayMonth(nearest.lastTrade)}` : "still trading";
|
|
110
|
+
// The month the quote prices leads, so a column that clips the tail still shows it; one too narrow for
|
|
111
|
+
// "most active" shows the plain name.
|
|
112
|
+
const note = `, most active (${nearer} ${expiry})`;
|
|
113
|
+
return width >= plain.length + MOST_ACTIVE_MIN.length ? `${plain}${note}` : plain;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The least of the note a name column must fit to carry it. */
|
|
117
|
+
const MOST_ACTIVE_MIN = ", most active";
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a row's price counts, as `quote` says it: the exchange's unit from the
|
|
121
|
+
* contract terms ("USD / barrel"), the catalog's where the server has none
|
|
122
|
+
* ("EUR / MWh"), else null.
|
|
123
|
+
*/
|
|
124
|
+
export function futuresUnitLabel(contract: FuturesContract, terms?: Pick<FuturesContractTermsPayload, "spec"> | null): string | null {
|
|
125
|
+
return terms?.spec?.unit ?? contract.unit ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
70
128
|
function matchesFuturesSearch(contract: FuturesContract, query: string, quote?: Quote | null): boolean {
|
|
71
129
|
const normalized = query.trim().toLowerCase();
|
|
72
130
|
if (!normalized) return true;
|
|
@@ -89,9 +147,12 @@ function getSortValue(
|
|
|
89
147
|
contract: FuturesContract,
|
|
90
148
|
quotes: BoardQuoteMap,
|
|
91
149
|
returns?: FrontReturnsMap,
|
|
150
|
+
terms?: FuturesTermsMap,
|
|
92
151
|
): string | number | null {
|
|
93
152
|
const quote = quotes.get(contract.symbol)?.quote;
|
|
94
153
|
switch (columnId) {
|
|
154
|
+
case "unit":
|
|
155
|
+
return futuresUnitLabel(contract, terms?.get(contract.symbol)?.terms);
|
|
95
156
|
case "status":
|
|
96
157
|
return quote?.marketState === "REGULAR" ? 0 : 1;
|
|
97
158
|
case "code":
|
|
@@ -122,12 +183,13 @@ function sortContracts(
|
|
|
122
183
|
sortPreference: FuturesSortPreference,
|
|
123
184
|
quotes: BoardQuoteMap,
|
|
124
185
|
returns?: FrontReturnsMap,
|
|
186
|
+
terms?: FuturesTermsMap,
|
|
125
187
|
): FuturesContract[] {
|
|
126
188
|
const columnId = sortPreference.columnId;
|
|
127
189
|
if (!columnId) return contracts;
|
|
128
190
|
return [...contracts].sort((left, right) => compareSortValues(
|
|
129
|
-
getSortValue(columnId, left, quotes, returns),
|
|
130
|
-
getSortValue(columnId, right, quotes, returns),
|
|
191
|
+
getSortValue(columnId, left, quotes, returns, terms),
|
|
192
|
+
getSortValue(columnId, right, quotes, returns, terms),
|
|
131
193
|
sortPreference.direction,
|
|
132
194
|
));
|
|
133
195
|
}
|
|
@@ -139,6 +201,8 @@ export interface BuildFuturesRowsOptions {
|
|
|
139
201
|
collapsed?: ReadonlySet<FuturesSector>;
|
|
140
202
|
/** Front-contract returns, for sorting on a returns column. */
|
|
141
203
|
returns?: FrontReturnsMap;
|
|
204
|
+
/** Each row's contract terms, for sorting on the unit. */
|
|
205
|
+
terms?: FuturesTermsMap;
|
|
142
206
|
}
|
|
143
207
|
|
|
144
208
|
const NO_COLLAPSED_SECTORS: ReadonlySet<FuturesSector> = new Set();
|
|
@@ -166,7 +230,7 @@ export function buildFuturesRows(
|
|
|
166
230
|
const query = options?.query ?? "";
|
|
167
231
|
const collapsed = effectiveCollapsedSectors(options?.collapsed, query);
|
|
168
232
|
for (const sector of FUTURES_SECTOR_ORDER) {
|
|
169
|
-
const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns)
|
|
233
|
+
const contracts = sortContracts(contractsBySector.get(sector) ?? [], sortPreference, quotes, options?.returns, options?.terms)
|
|
170
234
|
.filter((contract) => matchesFuturesSearch(contract, query, quotes.get(contract.symbol)?.quote));
|
|
171
235
|
if (contracts.length === 0) continue;
|
|
172
236
|
rows.push({ type: "header", sector });
|
|
@@ -9,8 +9,8 @@ import { FUTURES_CONTRACTS, FUTURES_SECTOR_LABELS, type FuturesContract } from "
|
|
|
9
9
|
const US_CENT_CONTRACTS: ReadonlySet<string> = new Set(["ZC", "ZS", "ZL", "ZW", "KE", "KC", "SB", "CT"]);
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* The unit the board prints, not a blanket dollar price. Index
|
|
13
|
-
* points, rates are points (the board prints decimals, not 32nds), cents
|
|
12
|
+
* The unit the board prints, not a blanket dollar price. Index and sector
|
|
13
|
+
* futures are points, rates are points (the board prints decimals, not 32nds), cents
|
|
14
14
|
* contracts are USX, and a declared quote unit wins over USD. Currency
|
|
15
15
|
* futures are dollars per unit of the foreign currency.
|
|
16
16
|
*/
|
|
@@ -18,7 +18,7 @@ function futuresSeriesUnit(contract: FuturesContract): string {
|
|
|
18
18
|
if (contract.unit?.startsWith("US cents") || US_CENT_CONTRACTS.has(contract.code)) return "USX";
|
|
19
19
|
if (contract.unit?.startsWith("EUR")) return "EUR";
|
|
20
20
|
if (contract.unit?.startsWith("USD")) return "USD";
|
|
21
|
-
if (contract.sector === "equity-index" || contract.sector === "rates") return "points";
|
|
21
|
+
if (contract.sector === "equity-index" || contract.sector === "equity-sectors" || contract.sector === "rates") return "points";
|
|
22
22
|
return "USD";
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -5,7 +5,8 @@ import { TextAttributes } from "../../../ui";
|
|
|
5
5
|
import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
6
6
|
import { isBoardRowLoading, renderQuoteBoardCell, type BoardQuoteMap } from "../shared/use-quote-board";
|
|
7
7
|
import { tickDecimals, type FuturesContract } from "../../../market-data/futures-board";
|
|
8
|
-
import { FUTURES_RETURN_COLUMNS,
|
|
8
|
+
import { FUTURES_RETURN_COLUMNS, futuresContractHeadline, futuresUnitLabel, type FuturesColumnId, type FuturesTableRow } from "./model";
|
|
9
|
+
import type { FuturesTermsMap } from "./use-board-terms";
|
|
9
10
|
import type { FrontReturnsMap } from "./use-front-returns";
|
|
10
11
|
|
|
11
12
|
export type FuturesColumn = DataTableColumn & { id: FuturesColumnId };
|
|
@@ -21,6 +22,7 @@ export const FUTURES_COLUMN_DEFS: readonly FuturesColumnDef[] = [
|
|
|
21
22
|
{ id: "code", label: "Symbol", description: "Exchange contract code." },
|
|
22
23
|
{ id: "name", label: "Contract", description: "Contract name." },
|
|
23
24
|
{ id: "price", label: "Last", description: "Last traded price." },
|
|
25
|
+
{ id: "unit", label: "Unit", description: "What a price counts, as the exchange quotes it (USD / barrel)." },
|
|
24
26
|
{ id: "change", label: "Change", description: "Change on the session." },
|
|
25
27
|
{ id: "changePercent", label: "Change %", description: "Percent change on the session." },
|
|
26
28
|
{ id: "return1w", label: "1W", description: "Front contract's return over one week." },
|
|
@@ -35,22 +37,32 @@ const DEFAULT_FUTURES_COLUMN_IDS = FUTURES_COLUMN_DEFS.map((column) => column.id
|
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Extra columns only earn their width once the board is wide enough to keep a
|
|
38
|
-
* readable contract name; a narrow pane drops them instead
|
|
40
|
+
* readable contract name (24 cells or more); a narrow pane drops them instead
|
|
41
|
+
* of clipping. The unit a price counts outranks its session change in points,
|
|
42
|
+
* which the change in percent still gives. The previous close (the last price
|
|
43
|
+
* less the change) goes first, then each row's quote time (the status line
|
|
44
|
+
* dates the newest), which leaves a 1280-pixel shot room for "WTI Crude Oil
|
|
45
|
+
* Dec 26, most active (Nov expires 20 Oct)".
|
|
39
46
|
*/
|
|
40
47
|
const COLUMN_MIN_PANE_WIDTH: Partial<Record<FuturesColumnId, number>> = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
time:
|
|
48
|
+
change: 100,
|
|
49
|
+
return1w: 108,
|
|
50
|
+
return1m: 117,
|
|
51
|
+
returnYtd: 126,
|
|
52
|
+
volume: 136,
|
|
53
|
+
time: 170,
|
|
54
|
+
prevClose: 200,
|
|
47
55
|
};
|
|
56
|
+
/** From this width the unit column fits lumber's "USD / 1,000 board ft" whole. */
|
|
57
|
+
const WIDE_UNIT_MIN_WIDTH = 200;
|
|
48
58
|
const SESSION_TEXT_MIN_WIDTH = 100;
|
|
49
59
|
|
|
50
60
|
const COLUMN_WIDTHS: Record<Exclude<FuturesColumnId, "name">, number> = {
|
|
51
61
|
status: 1,
|
|
52
62
|
code: 5,
|
|
53
63
|
price: 12,
|
|
64
|
+
// "US cents / bushel"; lumber's longer unit fits from WIDE_UNIT_MIN_WIDTH.
|
|
65
|
+
unit: 17,
|
|
54
66
|
change: 10,
|
|
55
67
|
changePercent: 9,
|
|
56
68
|
return1w: 8,
|
|
@@ -70,6 +82,7 @@ export function usesSessionText(width: number): boolean {
|
|
|
70
82
|
|
|
71
83
|
function columnWidth(id: Exclude<FuturesColumnId, "name">, paneWidth: number): number {
|
|
72
84
|
if (id === "status") return usesSessionText(paneWidth) ? 9 : 1;
|
|
85
|
+
if (id === "unit" && paneWidth >= WIDE_UNIT_MIN_WIDTH) return 20;
|
|
73
86
|
return COLUMN_WIDTHS[id];
|
|
74
87
|
}
|
|
75
88
|
|
|
@@ -95,7 +108,7 @@ export function createFuturesColumns(width: number, visibleIds?: readonly string
|
|
|
95
108
|
// TIME is left-aligned like the text columns: the shared table trims a few
|
|
96
109
|
// cells off the right edge of a floating pane, and a right-aligned value
|
|
97
110
|
// there would lose digits.
|
|
98
|
-
align: id === "code" || id === "status" || id === "time" ? "left" : "right",
|
|
111
|
+
align: id === "code" || id === "status" || id === "unit" || id === "time" ? "left" : "right",
|
|
99
112
|
};
|
|
100
113
|
});
|
|
101
114
|
}
|
|
@@ -105,6 +118,7 @@ const FUTURES_HEADER_LABELS: Record<FuturesColumnId, string> = {
|
|
|
105
118
|
code: "SYM",
|
|
106
119
|
name: "CONTRACT",
|
|
107
120
|
price: "LAST",
|
|
121
|
+
unit: "UNIT",
|
|
108
122
|
change: "CHG",
|
|
109
123
|
changePercent: "CHG%",
|
|
110
124
|
return1w: "1W",
|
|
@@ -200,7 +214,7 @@ export function renderFuturesCell(
|
|
|
200
214
|
row: FuturesTableRow,
|
|
201
215
|
column: FuturesColumn,
|
|
202
216
|
quotes: BoardQuoteMap,
|
|
203
|
-
options?: { sessionText?: boolean; returns?: FrontReturnsMap },
|
|
217
|
+
options?: { sessionText?: boolean; returns?: FrontReturnsMap; terms?: FuturesTermsMap },
|
|
204
218
|
): DataTableCell {
|
|
205
219
|
if (row.type === "header") return { text: "" };
|
|
206
220
|
|
|
@@ -216,7 +230,13 @@ export function renderFuturesCell(
|
|
|
216
230
|
attributes: TextAttributes.BOLD,
|
|
217
231
|
};
|
|
218
232
|
case "name":
|
|
219
|
-
return { text:
|
|
233
|
+
return { text: futuresContractHeadline(contract, quote, options?.terms?.get(contract.symbol)?.terms, column.width) };
|
|
234
|
+
case "unit": {
|
|
235
|
+
const terms = options?.terms?.get(contract.symbol);
|
|
236
|
+
const unit = futuresUnitLabel(contract, terms?.terms);
|
|
237
|
+
if (unit) return { text: unit, color: colors.textDim };
|
|
238
|
+
return terms?.loading ? { text: "…", color: colors.textDim } : { text: "—", color: colors.textDim };
|
|
239
|
+
}
|
|
220
240
|
case "volume":
|
|
221
241
|
if (isBoardRowLoading(state)) return { text: "…", color: colors.textDim };
|
|
222
242
|
if (!quote || quote.volume == null || !Number.isFinite(quote.volume)) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { FuturesContractTermsPayload } from "../../../api-client/futures-curve";
|
|
3
|
+
import type { FuturesContract } from "../../../market-data/futures-board";
|
|
4
|
+
import { loadFuturesContractTerms } from "../../../market-data/futures-contract";
|
|
5
|
+
|
|
6
|
+
interface FuturesTermsState {
|
|
7
|
+
/** The row's terms are still being asked for. */
|
|
8
|
+
loading: boolean;
|
|
9
|
+
/** Null when the server has no terms for the symbol, or could not answer. */
|
|
10
|
+
terms: FuturesContractTermsPayload | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type FuturesTermsMap = ReadonlyMap<string, FuturesTermsState>;
|
|
14
|
+
|
|
15
|
+
/** The terms change with a roll, and a roll is decided once a day. */
|
|
16
|
+
const TERMS_TTL_MS = 30 * 60_000;
|
|
17
|
+
/** Forty-odd rows open at once; a few requests at a time keep the board's quotes first. */
|
|
18
|
+
const TERMS_CONCURRENCY = 4;
|
|
19
|
+
|
|
20
|
+
interface CachedTerms {
|
|
21
|
+
at: number;
|
|
22
|
+
/** Null when the server has no terms for the symbol, or could not answer. */
|
|
23
|
+
terms: FuturesContractTermsPayload | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const termsCache = new Map<string, CachedTerms>();
|
|
27
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
28
|
+
|
|
29
|
+
function fresh(symbol: string, now: number): CachedTerms | undefined {
|
|
30
|
+
const cached = termsCache.get(symbol);
|
|
31
|
+
return cached && now - cached.at < TERMS_TTL_MS ? cached : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadTerms(symbol: string): Promise<void> {
|
|
35
|
+
const pending = inFlight.get(symbol);
|
|
36
|
+
if (pending) return pending;
|
|
37
|
+
const request = loadFuturesContractTerms(symbol)
|
|
38
|
+
.then((terms) => { termsCache.set(symbol, { at: Date.now(), terms }); })
|
|
39
|
+
.catch(() => { termsCache.set(symbol, { at: Date.now(), terms: null }); })
|
|
40
|
+
.finally(() => { inFlight.delete(symbol); });
|
|
41
|
+
inFlight.set(symbol, request);
|
|
42
|
+
return request;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Each listed row's contract terms, as `quote` reads them: the unit its price
|
|
47
|
+
* counts, and the nearer month still trading when its continuous symbol has
|
|
48
|
+
* rolled to the most active one. Only the rows the board lists are asked for,
|
|
49
|
+
* and a row whose terms have not arrived (or that has none) renders without them.
|
|
50
|
+
*/
|
|
51
|
+
export function useFuturesBoardTerms(contracts: readonly FuturesContract[], enabled: boolean): FuturesTermsMap {
|
|
52
|
+
const symbolKey = contracts.map((contract) => contract.symbol).join(",");
|
|
53
|
+
const [version, setVersion] = useState(0);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!enabled || !symbolKey) return;
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const queue = symbolKey.split(",").filter((symbol) => !fresh(symbol, now));
|
|
59
|
+
if (queue.length === 0) return;
|
|
60
|
+
let cancelled = false;
|
|
61
|
+
const worker = async () => {
|
|
62
|
+
for (let symbol = queue.shift(); symbol; symbol = queue.shift()) {
|
|
63
|
+
await loadTerms(symbol);
|
|
64
|
+
if (!cancelled) setVersion((current) => current + 1);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
void Promise.all(Array.from({ length: Math.min(TERMS_CONCURRENCY, queue.length) }, worker));
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
queue.length = 0;
|
|
71
|
+
};
|
|
72
|
+
}, [enabled, symbolKey]);
|
|
73
|
+
|
|
74
|
+
return useMemo(() => {
|
|
75
|
+
const result = new Map<string, FuturesTermsState>();
|
|
76
|
+
if (!enabled) return result;
|
|
77
|
+
for (const symbol of symbolKey ? symbolKey.split(",") : []) {
|
|
78
|
+
const cached = termsCache.get(symbol);
|
|
79
|
+
result.set(symbol, { loading: !cached, terms: cached?.terms ?? null });
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
// `version` re-reads the cache as loads land.
|
|
83
|
+
}, [enabled, symbolKey, version]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Forgets every cached row's terms (tests). */
|
|
87
|
+
export function resetFuturesBoardTerms(): void {
|
|
88
|
+
termsCache.clear();
|
|
89
|
+
}
|
|
@@ -73,7 +73,12 @@ export function HoldersView({ focused, width, height }: { focused: boolean; widt
|
|
|
73
73
|
} : null, [dataProvider, exchange, symbol]);
|
|
74
74
|
// Another ticker's holders stay up until its own answer, and a failed
|
|
75
75
|
// refresh keeps the last holders; the failure goes to the footer.
|
|
76
|
-
const { data, loading, error, reload } = useAsyncResource<HolderData>(loadHolders, {
|
|
76
|
+
const { data, loading, error, reload } = useAsyncResource<HolderData>(loadHolders, {
|
|
77
|
+
initialData: () => symbol && dataProvider?.getCachedQuery
|
|
78
|
+
? dataProvider.getCachedQuery("getHolders", [symbol, exchange]).getSnapshot().result?.value ?? null
|
|
79
|
+
: null,
|
|
80
|
+
keepPreviousData: true,
|
|
81
|
+
});
|
|
77
82
|
|
|
78
83
|
// The values' own currency: dollars from the 13F filings on a London line, not its pence.
|
|
79
84
|
const currency = holderValueCurrency(data, ticker?.metadata.currency || "USD")!;
|