gloomberb 0.17.3 → 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/index.ts +4 -1
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/components/index.ts +1 -1
- 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/ui/button.tsx +24 -9
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- 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/cot/model.ts +20 -0
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/docs/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +2 -3
- package/src/plugins/builtin/futures/model.ts +4 -2
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -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 +12 -4
- package/src/time-series/resolve.ts +5 -3
- 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/futures-generic.ts +25 -4
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Box, useUiCapabilities } from "../../../ui";
|
|
3
3
|
import {
|
|
4
|
-
buildHeatTreemapScene,
|
|
5
4
|
findMetricTreemapNeighbor,
|
|
6
5
|
formatHeatTileMove,
|
|
7
|
-
heatTreemapCanvas,
|
|
8
6
|
HeatTreemapSurface,
|
|
9
7
|
settleTreemapLayoutItems,
|
|
8
|
+
useHeatTreemapScene,
|
|
10
9
|
} from "../../../components/metric-treemap";
|
|
11
10
|
import {
|
|
12
11
|
loadingText,
|
|
@@ -262,7 +261,7 @@ function useSettledLayoutItems<T>(items: readonly MetricTreemapItem<T>[], tolera
|
|
|
262
261
|
function MarketHeatmapPane({ focused, width, height }: PaneProps) {
|
|
263
262
|
const { pinTicker } = usePluginTickerActions();
|
|
264
263
|
const liveStreaming = useLiveStreamingSetting();
|
|
265
|
-
const {
|
|
264
|
+
const { nativePaneChrome } = useUiCapabilities();
|
|
266
265
|
// A pane setting, not private pane state, so the settings dialog can show it.
|
|
267
266
|
const [universeSetting, setActiveUniverse] = usePaneSettingValue<string>("universe", "us-equity");
|
|
268
267
|
const [linkPortfolio] = usePaneSettingValue<boolean>("linkPortfolio", false);
|
|
@@ -370,10 +369,6 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
|
|
|
370
369
|
});
|
|
371
370
|
|
|
372
371
|
const chartHeight = Math.max(1, height - (tabsInHeader ? 0 : 1));
|
|
373
|
-
const canvas = useMemo(
|
|
374
|
-
() => heatTreemapCanvas(width, chartHeight, { nativePaneChrome, cellWidthPx, cellHeightPx }),
|
|
375
|
-
[cellHeightPx, cellWidthPx, chartHeight, nativePaneChrome, width],
|
|
376
|
-
);
|
|
377
372
|
|
|
378
373
|
// Stream weights by market-cap rank, fixed for this load of the board.
|
|
379
374
|
const quoteWeightsRef = useRef<{ tab: string; weights: Map<string, number> } | null>(null);
|
|
@@ -430,14 +425,9 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
|
|
|
430
425
|
useMemo(() => buildLayoutItems(boardAssets, layoutAssets, grouping, sizeBy), [boardAssets, grouping, layoutAssets, sizeBy]),
|
|
431
426
|
byMove ? DAY_MOVE_LAYOUT_TOLERANCE : LAYOUT_TOLERANCE,
|
|
432
427
|
);
|
|
433
|
-
// The board on screen seeds the next layout's order, so a refresh nudges
|
|
434
|
-
|
|
435
|
-
const scene =
|
|
436
|
-
const previous = sceneRef.current?.tab === activeUniverse ? sceneRef.current.scene : null;
|
|
437
|
-
const next = buildHeatTreemapScene(layoutItems, canvas, previous);
|
|
438
|
-
sceneRef.current = { tab: activeUniverse, scene: next };
|
|
439
|
-
return next;
|
|
440
|
-
}, [activeUniverse, canvas, layoutItems]);
|
|
428
|
+
// The board on screen seeds the next layout's order, so a refresh nudges
|
|
429
|
+
// tiles instead of reshuffling them; a resize lays it out once it settles.
|
|
430
|
+
const { scene, resizing } = useHeatTreemapScene(layoutItems, width, chartHeight, activeUniverse);
|
|
441
431
|
// The terminal repaints whole cells: colours there move at most four times a second.
|
|
442
432
|
const paintAssets = useThrottledMemo(
|
|
443
433
|
() => resolvedAssets,
|
|
@@ -726,6 +716,7 @@ function MarketHeatmapPane({ focused, width, height }: PaneProps) {
|
|
|
726
716
|
>
|
|
727
717
|
<HeatTreemapSurface
|
|
728
718
|
scene={scene}
|
|
719
|
+
resizing={resizing}
|
|
729
720
|
items={paintItems}
|
|
730
721
|
width={width}
|
|
731
722
|
height={chartHeight}
|
|
@@ -11,13 +11,15 @@ import {
|
|
|
11
11
|
mapCloudNewsArticle,
|
|
12
12
|
} from "../../../sources/gloomberb-cloud/news";
|
|
13
13
|
import { parsePublicTickerKey } from "../../../utils/exchanges";
|
|
14
|
+
import { formatUtcSeconds } from "../../../utils/utc-time";
|
|
14
15
|
import { cloudNewsFreshness, cloudRealtimeAccess } from "../shared/report-freshness";
|
|
15
16
|
|
|
17
|
+
// Stories carry their time to the second, so a story published on a whole minute prints its seconds too.
|
|
16
18
|
const NEWS_COLUMNS = [
|
|
17
|
-
{ key: "publishedAt", header: "Published" },
|
|
19
|
+
{ key: "publishedAt", header: "Published", textFormat: formatUtcSeconds },
|
|
18
20
|
{ key: "source", header: "Source" },
|
|
19
21
|
{ key: "headline", header: "Headline" },
|
|
20
|
-
{ key: "tickers", header: "Tickers" },
|
|
22
|
+
{ key: "tickers", header: "Tickers", textFormat: (value: unknown) => Array.isArray(value) ? value.join(", ") : String(value ?? "") },
|
|
21
23
|
{ key: "sentiment", header: "Sentiment" },
|
|
22
24
|
{ key: "importance", header: "Score", align: "right" as const },
|
|
23
25
|
];
|
|
@@ -886,7 +886,7 @@ export function PluginMarketplacePane({ focused, width, height }: PaneProps) {
|
|
|
886
886
|
<EmptyState
|
|
887
887
|
title="Plugin catalog unavailable."
|
|
888
888
|
status="error"
|
|
889
|
-
|
|
889
|
+
retry={{ onRetry: () => refresh(true) }}
|
|
890
890
|
/>
|
|
891
891
|
) : query || category ? (
|
|
892
892
|
<EmptyState title="No plugins match." />
|
|
@@ -571,10 +571,9 @@ export function EquityDiagnosticView({ focused, width, height }: {
|
|
|
571
571
|
return <Box paddingX={1} paddingY={1}><DiagnosticLoading step={loadingStep} /></Box>;
|
|
572
572
|
}
|
|
573
573
|
if (!report) {
|
|
574
|
-
const retryAction = <Button label="Retry" variant="secondary" onPress={retry} />;
|
|
575
574
|
return failure
|
|
576
|
-
? <PaneStatusBody error={failureText(failure)}
|
|
577
|
-
: <PaneStatusBody empty emptyTitle={t("No diagnostic available yet.")}
|
|
575
|
+
? <PaneStatusBody error={failureText(failure)} retry={{ onRetry: retry }} />
|
|
576
|
+
: <PaneStatusBody empty emptyTitle={t("No diagnostic available yet.")} retry={{ onRetry: retry }} />;
|
|
578
577
|
}
|
|
579
578
|
|
|
580
579
|
return (
|
|
@@ -25,6 +25,18 @@ interface ViewBundleOptions {
|
|
|
25
25
|
aliasRules?: AliasRule[];
|
|
26
26
|
failureMessage: string;
|
|
27
27
|
missingEntryMessage: string;
|
|
28
|
+
/**
|
|
29
|
+
* Leave identifiers unminified (whitespace and syntax still are), so the
|
|
30
|
+
* components keep their names and a page audit can say which one drew an
|
|
31
|
+
* element (`scripts/pane-chrome/ui-audit.ts`). Bun's `keepNames` alone did
|
|
32
|
+
* not keep them.
|
|
33
|
+
*/
|
|
34
|
+
keepNames?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bun's minify setting: everything, or everything but identifiers when names are kept. */
|
|
38
|
+
function minifyOption(keepNames: boolean | undefined) {
|
|
39
|
+
return keepNames ? { whitespace: true, syntax: true, identifiers: false } : true;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
// From this file rather than the working directory, so a build started from
|
|
@@ -60,6 +72,7 @@ export async function buildViewBundle({
|
|
|
60
72
|
aliasRules = [],
|
|
61
73
|
failureMessage,
|
|
62
74
|
missingEntryMessage,
|
|
75
|
+
keepNames,
|
|
63
76
|
}: ViewBundleOptions): Promise<{ script: string; stylesheet: string | null }> {
|
|
64
77
|
const chunks = new Map<string, string>();
|
|
65
78
|
const result = await Bun.build({
|
|
@@ -69,7 +82,7 @@ export async function buildViewBundle({
|
|
|
69
82
|
format: "esm",
|
|
70
83
|
splitting: false,
|
|
71
84
|
sourcemap,
|
|
72
|
-
minify:
|
|
85
|
+
minify: minifyOption(keepNames),
|
|
73
86
|
define: {
|
|
74
87
|
"process.env.NODE_ENV": "\"production\"",
|
|
75
88
|
__GLOOM_WORLD_MAP_DATA__: JSON.stringify(WORLD_MAP_DATA_SPECIFIER),
|
|
@@ -88,7 +101,7 @@ export async function buildViewBundle({
|
|
|
88
101
|
|
|
89
102
|
const script = result.outputs.find((output) => output.kind === "entry-point" && output.path.endsWith(".js"));
|
|
90
103
|
if (!script) throw new Error(missingEntryMessage);
|
|
91
|
-
await buildLazyChunks(outdir, chunks, failureMessage);
|
|
104
|
+
await buildLazyChunks(outdir, chunks, failureMessage, keepNames);
|
|
92
105
|
|
|
93
106
|
const stylesheet = result.outputs.find((output) => output.path.endsWith(".css"))?.path ?? null;
|
|
94
107
|
if (stylesheet) await writeFile(stylesheet, withTitlebarOverlayHeight(await readFile(stylesheet, "utf8")));
|
|
@@ -181,7 +194,7 @@ function lazyChunkPlugin(rootOutdir: string, emitDir: string, chunks: Map<string
|
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
/** Builds each lazy chunk on its own, and the chunks those load in turn. */
|
|
184
|
-
async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>, failureMessage: string): Promise<void> {
|
|
197
|
+
async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>, failureMessage: string, keepNames?: boolean): Promise<void> {
|
|
185
198
|
const built = new Set<string>();
|
|
186
199
|
let pending = [...chunks.entries()];
|
|
187
200
|
while (pending.length) {
|
|
@@ -197,7 +210,7 @@ async function buildLazyChunks(rootOutdir: string, chunks: Map<string, string>,
|
|
|
197
210
|
format: "esm",
|
|
198
211
|
splitting: false,
|
|
199
212
|
sourcemap: "none",
|
|
200
|
-
minify:
|
|
213
|
+
minify: minifyOption(keepNames),
|
|
201
214
|
plugins: [lazyChunkPlugin(rootOutdir, dirname(output), nested)],
|
|
202
215
|
});
|
|
203
216
|
if (!result.success) {
|
|
@@ -41,6 +41,7 @@ export function WebButton({
|
|
|
41
41
|
onPress,
|
|
42
42
|
variant = "secondary",
|
|
43
43
|
disabled = false,
|
|
44
|
+
pending = false,
|
|
44
45
|
active = false,
|
|
45
46
|
shortcut,
|
|
46
47
|
width,
|
|
@@ -61,6 +62,7 @@ export function WebButton({
|
|
|
61
62
|
aria-keyshortcuts={ariaKeyShortcut(shortcut)}
|
|
62
63
|
title={title}
|
|
63
64
|
aria-expanded={expanded}
|
|
65
|
+
aria-busy={pending || undefined}
|
|
64
66
|
disabled={disabled}
|
|
65
67
|
onMouseDown={(event) => {
|
|
66
68
|
if (stopPropagation) event.stopPropagation();
|
|
@@ -15,14 +15,15 @@ export function setMacosDockBadge(label: string): void {
|
|
|
15
15
|
const dockTile = objc.send(app, objc.selector("dockTile"));
|
|
16
16
|
if (!dockTile) return;
|
|
17
17
|
const text = label
|
|
18
|
-
? objc.
|
|
18
|
+
? objc.sendCString(
|
|
19
19
|
objc.send(objc.getClass("NSString"), objc.selector("alloc")),
|
|
20
20
|
objc.selector("initWithUTF8String:"),
|
|
21
21
|
cString(label),
|
|
22
22
|
)
|
|
23
|
-
:
|
|
23
|
+
: 0n;
|
|
24
24
|
// AppKit only takes this on the main thread, and Bun runs on another one.
|
|
25
|
-
// The call retains the string until it has run;
|
|
25
|
+
// The call retains the string until it has run; no label is nil. A label as
|
|
26
|
+
// short as a count is a tagged pointer, which is why objects travel as ids.
|
|
26
27
|
objc.sendOnMainThread(
|
|
27
28
|
dockTile,
|
|
28
29
|
objc.selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { Pointer } from "bun:ffi";
|
|
2
1
|
import { appIconColors, appIconSvg } from "./app-icon";
|
|
3
2
|
import { getTheme } from "../../../../theme/themes";
|
|
4
|
-
import { objcOrNull, type Objc } from "./macos-objc";
|
|
3
|
+
import { objcOrNull, type Objc, type ObjcId } from "./macos-objc";
|
|
5
4
|
|
|
6
5
|
let appliedThemeId: string | null = null;
|
|
7
6
|
|
|
8
|
-
function createImage(objc: Objc, svg: string):
|
|
7
|
+
function createImage(objc: Objc, svg: string): ObjcId | null {
|
|
9
8
|
const bytes = Buffer.from(svg);
|
|
10
9
|
const data = objc.sendBytes(
|
|
11
10
|
objc.send(objc.getClass("NSData"), objc.selector("alloc")),
|
|
@@ -4,28 +4,39 @@ const LIBOBJC = "/usr/lib/libobjc.A.dylib";
|
|
|
4
4
|
|
|
5
5
|
export type Objc = ReturnType<typeof loadObjc>;
|
|
6
6
|
|
|
7
|
+
/** An Objective-C object or class as its address, `0n` for nil. */
|
|
8
|
+
export type ObjcId = bigint;
|
|
9
|
+
|
|
7
10
|
let cachedObjc: Objc | null | undefined;
|
|
8
11
|
|
|
9
12
|
export const cString = (value: string) => Buffer.from(`${value}\0`);
|
|
10
13
|
|
|
14
|
+
// Objects cross the FFI as u64, never as ptr. Bun hands a ptr result to
|
|
15
|
+
// JavaScript as a double, exact only up to 2**53, and Foundation keeps short
|
|
16
|
+
// strings and small numbers (a Dock badge label such as "3") in tagged
|
|
17
|
+
// pointers whose data sits in the top bits. Through a ptr they come back
|
|
18
|
+
// rounded, and the next message to one crashes the process.
|
|
19
|
+
const ID = FFIType.u64;
|
|
20
|
+
|
|
11
21
|
// objc_msgSend has to be called with each method's exact C signature, so it is
|
|
12
22
|
// bound once per call shape.
|
|
13
23
|
function loadObjc() {
|
|
14
24
|
const runtime = dlopen(LIBOBJC, {
|
|
15
|
-
objc_getClass: { args: [FFIType.ptr], returns:
|
|
25
|
+
objc_getClass: { args: [FFIType.ptr], returns: ID },
|
|
16
26
|
sel_registerName: { args: [FFIType.ptr], returns: FFIType.ptr },
|
|
17
27
|
}).symbols;
|
|
18
28
|
const msgSend = <const Args extends FFIType[], Returns extends FFIType>(args: Args, returns: Returns) =>
|
|
19
|
-
dlopen(LIBOBJC, { objc_msgSend: { args: [
|
|
29
|
+
dlopen(LIBOBJC, { objc_msgSend: { args: [ID, FFIType.ptr, ...args], returns } }).symbols.objc_msgSend;
|
|
20
30
|
|
|
21
31
|
return {
|
|
22
32
|
getClass: (name: string) => runtime.objc_getClass(cString(name)),
|
|
23
33
|
selector: (name: string) => runtime.sel_registerName(cString(name)),
|
|
24
|
-
send: msgSend([],
|
|
34
|
+
send: msgSend([], ID),
|
|
25
35
|
sendVoid: msgSend([], FFIType.void),
|
|
26
|
-
sendObject: msgSend([
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
sendObject: msgSend([ID], ID),
|
|
37
|
+
sendCString: msgSend([FFIType.ptr], ID),
|
|
38
|
+
sendBytes: msgSend([FFIType.ptr, FFIType.u64], ID),
|
|
39
|
+
sendOnMainThread: msgSend([FFIType.ptr, ID, FFIType.bool], FFIType.void),
|
|
29
40
|
};
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { futuresListingPriceBasis } from "../../utils/futures-generic";
|
|
2
2
|
import type { TimeRange } from "../../time-series/range";
|
|
3
3
|
import { RANGE_HISTORY_RESOLUTION } from "../../time-series/resolution";
|
|
4
4
|
import type {
|
|
@@ -55,6 +55,12 @@ function isImpossiblePreviousClose(
|
|
|
55
55
|
&& previousClose * IMPOSSIBLE_CLOSE_FACTOR < low52w;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** A quote that says it is something other than a future (an equity, a fund) keeps its own price basis. */
|
|
59
|
+
function isNonFutureType(instrumentType: string | undefined): boolean {
|
|
60
|
+
const type = instrumentType?.trim().toUpperCase();
|
|
61
|
+
return !!type && type !== "FUTURE" && type !== "FUTURES" && type !== "FUT";
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
export function mapQuote(
|
|
59
65
|
quote: CloudQuotePayload,
|
|
60
66
|
providerMeta?: CloudProviderMeta,
|
|
@@ -76,12 +82,14 @@ export function mapQuote(
|
|
|
76
82
|
const changePercent = !impossibleClose && typeof quote.changePercent === "number" && Number.isFinite(quote.changePercent)
|
|
77
83
|
? quote.changePercent
|
|
78
84
|
: Number.NaN;
|
|
79
|
-
// A generic future (VX1, TY1) reads in points or 32nds,
|
|
80
|
-
|
|
85
|
+
// A generic future (VX1, TY1) reads in points or 32nds, and an index future's
|
|
86
|
+
// continuous symbol or contract (ES=F, SXOZ26.CME) in points, not dollars.
|
|
87
|
+
const futuresBasis = isNonFutureType(quote.instrumentType) ? null
|
|
88
|
+
: futuresListingPriceBasis(quote.symbol, quote.listingExchangeName ?? quote.exchangeName);
|
|
81
89
|
const priceObservation = quote.priceBasis === "nav" ? "nav" : undefined;
|
|
82
90
|
const priceBasis = quote.priceBasis === "nav"
|
|
83
91
|
? "per-unit"
|
|
84
|
-
:
|
|
92
|
+
: futuresBasis ?? quote.priceBasis;
|
|
85
93
|
return withoutUndefinedFields(reconcileQuoteDayRange({
|
|
86
94
|
...quote,
|
|
87
95
|
priceBasis,
|
|
@@ -83,7 +83,7 @@ import { getPricePointTimestamp, isPriceHistoryStaleForCurrentWindow } from "../
|
|
|
83
83
|
import { timeZoneLabel, zonedDateKey, zonedWallClockMs } from "../utils/zoned-date-time";
|
|
84
84
|
import { formatShortDate } from "../utils/datetime-format";
|
|
85
85
|
import { latestSessionStart, latestTradingSessionOpen, listingTimeZone, priorSessionClose } from "../market-data/market/trading-sessions";
|
|
86
|
-
import { futuresGenericCaption, futuresGenericListing,
|
|
86
|
+
import { futuresGenericCaption, futuresGenericListing, futuresListingPriceBasis } from "../utils/futures-generic";
|
|
87
87
|
import { isOhlcSeriesStyle } from "./spec";
|
|
88
88
|
import { SERIES_COLORS } from "../theme/series-colors";
|
|
89
89
|
import type {
|
|
@@ -1166,8 +1166,10 @@ function baseSecuritySeries(
|
|
|
1166
1166
|
: field.unit;
|
|
1167
1167
|
const generic = field.unitGroup === "price"
|
|
1168
1168
|
? futuresGenericListing(spec.source.instrument.symbol, spec.source.instrument.exchange) : null;
|
|
1169
|
-
// A generic future on an index, VIX or Treasuries reads in points or 32nds,
|
|
1170
|
-
|
|
1169
|
+
// A generic future on an index, VIX or Treasuries reads in points or 32nds, and an index
|
|
1170
|
+
// future's continuous symbol or contract in points, not dollars, as its quote does.
|
|
1171
|
+
const genericBasis = field.unitGroup === "price" && isMarketFieldId(field.id)
|
|
1172
|
+
? futuresListingPriceBasis(spec.source.instrument.symbol, spec.source.instrument.exchange) : null;
|
|
1171
1173
|
const genericUnit = genericBasis === "points" ? "points" : genericBasis === "thirty-seconds" ? "32nds" : null;
|
|
1172
1174
|
const unit = field.id === "market.volume" ? volumeUnit ?? ""
|
|
1173
1175
|
: genericUnit ?? (unitTemplate.startsWith("currency") && currency ? unitTemplate.replace("currency", currency) : unitTemplate);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { isLiveResizing, onLiveResizeEnd } from "./live-resize";
|
|
3
|
+
import { LivePaneFrameContext, type CellSize } from "./live-stretch";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long a box's size has to hold still before what is laid out in it
|
|
7
|
+
* settles at that size. A resize's content catches up every 100 ms
|
|
8
|
+
* (live-resize.ts), so a pane under the pointer stays resizing between its
|
|
9
|
+
* catch-ups.
|
|
10
|
+
*/
|
|
11
|
+
export const RESIZE_SETTLE_MS = 120;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How far a box may stretch from the size its layout was made for, on either
|
|
15
|
+
* axis, before the layout is made again mid-resize: past this, tiles and
|
|
16
|
+
* blocks drawn in percentages of the old size would look squashed or pulled.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_HELD_STRETCH = 1.5;
|
|
19
|
+
|
|
20
|
+
export interface ResizeHold {
|
|
21
|
+
/** The box's size as it last rendered. */
|
|
22
|
+
size: CellSize;
|
|
23
|
+
/** While the box is resizing, the size its layout holds to; null at rest. */
|
|
24
|
+
held: CellSize | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stretch(from: number, to: number): number {
|
|
28
|
+
return from > 0 && to > 0 ? Math.max(from / to, to / from) : Infinity;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The box rendered at `size`: the first change of a resize holds the layout at
|
|
33
|
+
* the size it had, later ones keep it there, and a stretch past
|
|
34
|
+
* MAX_HELD_STRETCH lays it out again at the new size (still resizing).
|
|
35
|
+
*/
|
|
36
|
+
export function nextResizeHold(state: ResizeHold, size: CellSize, maxStretch = MAX_HELD_STRETCH): ResizeHold {
|
|
37
|
+
if (state.size.width === size.width && state.size.height === size.height) return state;
|
|
38
|
+
const held = state.held ?? state.size;
|
|
39
|
+
const far = Math.max(stretch(held.width, size.width), stretch(held.height, size.height)) > maxStretch;
|
|
40
|
+
return { size, held: far ? size : held };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** At rest: the layout takes the box's size. */
|
|
44
|
+
export function settleResizeHold(state: ResizeHold): ResizeHold {
|
|
45
|
+
return state.held ? { size: state.size, held: null } : state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SettledLayoutSize extends CellSize {
|
|
49
|
+
/**
|
|
50
|
+
* The box is being resized: from the first move of a resize that has its
|
|
51
|
+
* pane's frame, or its first change of size, until it has held still for
|
|
52
|
+
* RESIZE_SETTLE_MS with the pointer let go. Nothing laid out in it should
|
|
53
|
+
* animate meanwhile.
|
|
54
|
+
*/
|
|
55
|
+
resizing: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Desktop: the size to lay out a surface at whose content is placed in
|
|
60
|
+
* percentages of its box (a heat map's tiles), and whether that box is being
|
|
61
|
+
* resized. A pane resize, a divider, a maximise or restore and a window
|
|
62
|
+
* resize all change the box's size, often once a frame; laying the content
|
|
63
|
+
* out again at every one of them costs a full restyle and moves every tile
|
|
64
|
+
* each time. Instead, while the size changes the layout holds to the size it
|
|
65
|
+
* had and the box stretches it by CSS, and once the size has held still it is
|
|
66
|
+
* laid out once, at the size the box settled on. A stretch too far from the
|
|
67
|
+
* held size lays it out again on the way.
|
|
68
|
+
*
|
|
69
|
+
* `resizing` is the shared signal for anything that animates when its layout
|
|
70
|
+
* changes (a glide, a fade, a pulse): none of it while the size is changing.
|
|
71
|
+
* Off (`enabled: false`, the terminal), the size is the box's at once.
|
|
72
|
+
*/
|
|
73
|
+
export function useSettledLayoutSize(
|
|
74
|
+
width: number,
|
|
75
|
+
height: number,
|
|
76
|
+
{ enabled = true }: { enabled?: boolean } = {},
|
|
77
|
+
): SettledLayoutSize {
|
|
78
|
+
const [state, setState] = useState<ResizeHold>(() => ({ size: { width, height }, held: null }));
|
|
79
|
+
let current = state;
|
|
80
|
+
if (state.size.width !== width || state.size.height !== height) {
|
|
81
|
+
// Stored while rendering, so the render that has the new size already lays out at the held one.
|
|
82
|
+
current = enabled ? nextResizeHold(state, { width, height }) : { size: { width, height }, held: null };
|
|
83
|
+
setState(current);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A resize that has this pane's frame: it holds still from the first move, before its content catches up.
|
|
87
|
+
const frame = useContext(LivePaneFrameContext);
|
|
88
|
+
const [framed, setFramed] = useState(false);
|
|
89
|
+
const framedRef = useRef(false);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
// Called on every move: only a resize starting or ending is news.
|
|
92
|
+
const follow = () => {
|
|
93
|
+
const next = enabled && frame !== null && frame.live() !== null;
|
|
94
|
+
if (next === framedRef.current) return;
|
|
95
|
+
framedRef.current = next;
|
|
96
|
+
setFramed(next);
|
|
97
|
+
};
|
|
98
|
+
follow();
|
|
99
|
+
return enabled ? frame?.subscribe(follow) : undefined;
|
|
100
|
+
}, [enabled, frame]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!state.held || framed) return;
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
105
|
+
let stopWaiting: (() => void) | null = null;
|
|
106
|
+
const settle = () => {
|
|
107
|
+
timer = null;
|
|
108
|
+
// A box still under the pointer (a sidebar, a split) settles when it is let go, not when the pointer pauses.
|
|
109
|
+
if (isLiveResizing()) {
|
|
110
|
+
stopWaiting = onLiveResizeEnd(() => {
|
|
111
|
+
stopWaiting?.();
|
|
112
|
+
stopWaiting = null;
|
|
113
|
+
timer = setTimeout(settle, RESIZE_SETTLE_MS);
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setState(settleResizeHold);
|
|
118
|
+
};
|
|
119
|
+
timer = setTimeout(settle, RESIZE_SETTLE_MS);
|
|
120
|
+
return () => {
|
|
121
|
+
if (timer !== null) clearTimeout(timer);
|
|
122
|
+
stopWaiting?.();
|
|
123
|
+
};
|
|
124
|
+
}, [framed, state]);
|
|
125
|
+
|
|
126
|
+
const layout = current.held ?? current.size;
|
|
127
|
+
return { width: layout.width, height: layout.height, resizing: enabled && (current.held !== null || framed) };
|
|
128
|
+
}
|
|
@@ -29,3 +29,59 @@ export function isoDateDaysAgo(days: number, now = new Date()): string {
|
|
|
29
29
|
export function isoDateYearsAgo(years: number, now = new Date()): string {
|
|
30
30
|
return isoDateBefore(now, years, 0);
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** YYYY-MM-DD for a day the calendar has; null for a month or day out of range. */
|
|
34
|
+
function calendarDay(year: number, month: number, day: number): string | null {
|
|
35
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
36
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
37
|
+
? date.toISOString().slice(0, 10)
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
42
|
+
/** A day and a month in either order, then a four-digit year, with one separator throughout: 14/10/2026, 14.10.2026, 14-10-2026. */
|
|
43
|
+
const YEAR_LAST = /^(\d{1,2})([/.-])(\d{1,2})\2(\d{4})$/;
|
|
44
|
+
/** A four-digit year first, written another way than YYYY-MM-DD: 2026/10/14, 2026.10.14, 2026-10-4. */
|
|
45
|
+
const YEAR_FIRST = /^(\d{4})([/.-])(\d{1,2})\2(\d{1,2})$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The YYYY-MM-DD date that a date written another way names: 14/10/2026 and
|
|
49
|
+
* 10/14/2026 are 2026-10-14, since a part over 12 can only be the day. When
|
|
50
|
+
* both parts could be the month (03/04/2026), only dots are read, day first,
|
|
51
|
+
* as dotted dates are written (`assumedDayFirst`); a slash or a dash is
|
|
52
|
+
* written both ways round, so that is null. Null as well for text that is not
|
|
53
|
+
* a date of the calendar.
|
|
54
|
+
*/
|
|
55
|
+
function isoDateReading(value: string): { date: string; assumedDayFirst: boolean } | null {
|
|
56
|
+
const text = value.trim();
|
|
57
|
+
const yearFirst = YEAR_FIRST.exec(text);
|
|
58
|
+
if (yearFirst) {
|
|
59
|
+
const date = calendarDay(Number(yearFirst[1]), Number(yearFirst[3]), Number(yearFirst[4]));
|
|
60
|
+
return date && date !== text ? { date, assumedDayFirst: false } : null;
|
|
61
|
+
}
|
|
62
|
+
const yearLast = YEAR_LAST.exec(text);
|
|
63
|
+
if (!yearLast) return null;
|
|
64
|
+
const [first, second, year] = [Number(yearLast[1]), Number(yearLast[3]), Number(yearLast[4])];
|
|
65
|
+
const monthFirst = first <= 12 && second > 12;
|
|
66
|
+
const either = first <= 12 && second <= 12 && first !== second;
|
|
67
|
+
if (either && yearLast[2] !== ".") return null;
|
|
68
|
+
const date = monthFirst ? calendarDay(year, first, second) : calendarDay(year, second, first);
|
|
69
|
+
return date ? { date, assumedDayFirst: either } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A date option's value, checked before any request is made: YYYY-MM-DD as
|
|
74
|
+
* typed, or an error that names the format. A date written another way gets
|
|
75
|
+
* the option to type instead (`--from 2026-10-14` for 14/10/2026); one whose
|
|
76
|
+
* day and month could be either way round gets an example of the format. An
|
|
77
|
+
* empty value is no date.
|
|
78
|
+
*/
|
|
79
|
+
export function isoDateOption(value: string, flag: string, now = new Date()): string {
|
|
80
|
+
const text = value.trim();
|
|
81
|
+
const iso = ISO_DATE.exec(text);
|
|
82
|
+
if (!text || (iso && calendarDay(Number(iso[1]), Number(iso[2]), Number(iso[3])))) return text;
|
|
83
|
+
const format = `Use a --${flag} date in YYYY-MM-DD format`;
|
|
84
|
+
const reading = isoDateReading(text);
|
|
85
|
+
if (reading) throw new Error(`${format}: type --${flag} ${reading.date} for ${text}${reading.assumedDayFirst ? " (day first)" : ""}.`);
|
|
86
|
+
throw new Error(`${format}, such as --${flag} ${isoDateToday(now)}.`);
|
|
87
|
+
}
|
|
@@ -92,3 +92,34 @@ export function parsePastedNumber(value: unknown): PastedNumber {
|
|
|
92
92
|
export function ambiguousNumberMessage(text: string, reading: { thousands: number; decimal: number }): string {
|
|
93
93
|
return `${text.trim()} could be ${reading.thousands} (thousands) or ${reading.decimal} (decimal comma); type ${reading.thousands} or ${reading.decimal}`;
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/** What a dot and exactly three digits after one to three (`81.000`, `81.250`) could mean. */
|
|
97
|
+
export interface ThousandsDotReadings {
|
|
98
|
+
/** The number as written, the dot a decimal point: 81. */
|
|
99
|
+
decimal: number;
|
|
100
|
+
/** The dot as many locales write it, between thousands: 81000. */
|
|
101
|
+
thousands: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const THOUSANDS_DOT = /^([+-]?)([1-9]\d{0,2})\.(\d{3})$/;
|
|
105
|
+
|
|
106
|
+
/** Both readings of `81.000`; null for any other text, which has one. */
|
|
107
|
+
export function thousandsDotReadings(value: unknown): ThousandsDotReadings | null {
|
|
108
|
+
const match = THOUSANDS_DOT.exec(typeof value === "string" ? value.trim() : "");
|
|
109
|
+
if (!match) return null;
|
|
110
|
+
const sign = match[1] === "-" ? -1 : 1;
|
|
111
|
+
return { decimal: sign * Number(`${match[2]}.${match[3]}`), thousands: sign * Number(`${match[2]}${match[3]}`) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a dot and three digits was meant as a thousands separator, beside a
|
|
116
|
+
* price it must be near (the last price): the number as written is more than
|
|
117
|
+
* 90% away from it, while the thousands reading lands within 10%. A plain
|
|
118
|
+
* dotted number otherwise keeps its meaning, and without a reference it is
|
|
119
|
+
* read as written.
|
|
120
|
+
*/
|
|
121
|
+
export function meantThousandsDot(readings: ThousandsDotReadings, reference: number | null | undefined): boolean {
|
|
122
|
+
if (reference == null || !Number.isFinite(reference) || reference === 0) return false;
|
|
123
|
+
const scale = Math.abs(reference);
|
|
124
|
+
return Math.abs(readings.decimal - reference) * 10 > scale * 9 && Math.abs(readings.thousands - reference) * 10 <= scale;
|
|
125
|
+
}
|
|
@@ -28,9 +28,11 @@ export interface FuturesGeneric {
|
|
|
28
28
|
|
|
29
29
|
/** Roots Gloom Cloud archives daily, by the venue that lists them. */
|
|
30
30
|
const ROOT_VENUES: Readonly<Record<string, string>> = Object.fromEntries(([
|
|
31
|
-
["CME", ["ES", "NQ", "RTY", "
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
["CME", ["ES", "NQ", "RTY", "EMD", "RS1", "NKD", "NIY", "MES", "MNQ", "M2K",
|
|
32
|
+
"XAE", "XAB", "XAI", "XAY", "XAP", "XAV", "XAF", "XAK", "XAZ", "XAU", "XAR", "SXO", "SXR", "SXT", "SXB", "SXI", "SOX",
|
|
33
|
+
"6E", "6J", "6B", "6A", "6C", "6S", "SR3", "LE", "GF", "HE", "LBR", "DC", "CSC", "GD", "BTC", "ETH", "SOL", "XRP"]],
|
|
34
|
+
["CBT", ["YM", "MYM", "RX", "ZT", "ZF", "ZN", "ZB", "UB", "ZQ", "ZC", "ZS", "ZW", "ZM", "ZL", "KE", "ZO", "ZR"]],
|
|
35
|
+
["NYM", ["CL", "QM", "MCL", "BZ", "NG", "QG", "MNG", "HH", "RB", "HO", "PL", "PA", "B0", "TTF"]],
|
|
34
36
|
["CMX", ["GC", "SI", "HG", "ALI", "HRC", "UX"]],
|
|
35
37
|
["NYB", ["KC", "SB", "CC", "CT", "OJ"]],
|
|
36
38
|
["CFE", ["VX"]],
|
|
@@ -128,16 +130,35 @@ function futuresGenericOrdinal(position: number): string {
|
|
|
128
130
|
return `${position}${tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][position % 10] ?? "th"}`;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/** Equity index, sector and industry futures, the S&P GSCI and VIX: their prices are index points. */
|
|
134
|
+
const POINT_ROOTS: ReadonlySet<string> = new Set(["ES", "NQ", "RTY", "YM", "GD", "VX", "EMD", "RS1", "NKD", "NIY", "MES", "MNQ", "M2K", "MYM", "RX",
|
|
135
|
+
"XAE", "XAB", "XAI", "XAY", "XAP", "XAV", "XAF", "XAK", "XAZ", "XAU", "XAR", "SXO", "SXR", "SXT", "SXB", "SXI", "SOX"]);
|
|
136
|
+
|
|
131
137
|
/**
|
|
132
138
|
* How a generic's price reads: index and VIX futures in points (not dollars),
|
|
133
139
|
* Treasury futures in 32nds of par; everything else in its quote currency.
|
|
134
140
|
*/
|
|
135
141
|
export function futuresGenericPriceBasis(generic: Pick<FuturesGeneric, "root">): "points" | "thirty-seconds" | null {
|
|
136
|
-
if (
|
|
142
|
+
if (POINT_ROOTS.has(generic.root)) return "points";
|
|
137
143
|
if (["ZT", "ZF", "ZN", "ZB", "UB"].includes(generic.root)) return "thirty-seconds";
|
|
138
144
|
return null;
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
/**
|
|
148
|
+
* How a futures listing's price reads, whichever form it takes: a generic by
|
|
149
|
+
* its root (VX1 points, TY1 32nds), and a continuous (`ES=F`, `SXO=F`) or
|
|
150
|
+
* listed contract (`SXOZ26.CME`) of an index, sector or industry root in index
|
|
151
|
+
* points, on the root's own venue or with none. The quote keeps its currency
|
|
152
|
+
* for what a tick is worth. Null for every other listing, a security that
|
|
153
|
+
* shares a root's code on another venue (XAU on Toronto, the XAR ETF) included.
|
|
154
|
+
*/
|
|
155
|
+
export function futuresListingPriceBasis(symbol: string, exchange?: string | null): "points" | "thirty-seconds" | null {
|
|
156
|
+
const generic = futuresGenericListing(symbol, exchange);
|
|
157
|
+
if (generic) return futuresGenericPriceBasis(generic);
|
|
158
|
+
const listing = futuresListingVenue(symbol, exchange);
|
|
159
|
+
return listing?.root && ROOT_VENUES[listing.root] === listing.venue && POINT_ROOTS.has(listing.root) ? "points" : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
/** Whether a root's date rules count back from first notice or from last trade. */
|
|
142
163
|
export function futuresGenericNotice(root: string): "first notice" | "last trade" {
|
|
143
164
|
return FIRST_NOTICE_ROOTS.has(root) ? "first notice" : "last trade";
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hosts": [
|
|
3
|
+
"api.crypto.com",
|
|
3
4
|
"api.elections.kalshi.com",
|
|
4
5
|
"api.fda.gov",
|
|
5
6
|
"api.fiscaldata.treasury.gov",
|
|
7
|
+
"api.gemini.com",
|
|
8
|
+
"api.limitless.exchange",
|
|
6
9
|
"api.publisher.jodidata.org",
|
|
7
10
|
"api.votehub.com",
|
|
8
11
|
"apps.fas.usda.gov",
|
|
9
12
|
"clinicaltrials.gov",
|
|
10
13
|
"clob.polymarket.com",
|
|
11
14
|
"data-api.polymarket.com",
|
|
15
|
+
"data.pascal.trade",
|
|
12
16
|
"efts.sec.gov",
|
|
13
17
|
"gamma-api.polymarket.com",
|
|
18
|
+
"gateway.polymarket.us",
|
|
14
19
|
"markets.newyorkfed.org",
|
|
15
20
|
"nasdaqtrader.com",
|
|
16
21
|
"production.dataviz.cnn.io",
|