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,21 +1,153 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
|
2
2
|
import { t, tf } from "../../i18n";
|
|
3
3
|
import { useThemeColors } from "../../theme/theme-context";
|
|
4
4
|
import { Box, Text, useUiCapabilities } from "../../ui";
|
|
5
|
+
import { formatRelativeAge } from "../../utils/datetime-format";
|
|
5
6
|
import { Spinner } from "./loading";
|
|
6
7
|
import { ButtonActionScope } from "./action-scope";
|
|
8
|
+
import { Button } from "./button";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Asking again from an empty or failed state. The state draws the button
|
|
12
|
+
* (first, so it answers Enter), keeps it busy while the check runs, and when
|
|
13
|
+
* the answer comes back the same, says when it checked: otherwise a press that
|
|
14
|
+
* changes nothing on screen looks like a press that did nothing.
|
|
15
|
+
*/
|
|
16
|
+
export interface StatusRetry {
|
|
17
|
+
/** Asks again. A returned promise keeps the button busy until it settles. */
|
|
18
|
+
onRetry: () => unknown;
|
|
19
|
+
/** "Retry", unless the check has a better verb, such as "Look again". */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** The label while it runs: "Retrying..." for the default label. */
|
|
22
|
+
pendingLabel?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The same check running from anywhere (the button, `r`, a timer), such as
|
|
25
|
+
* a resource's `loading`. Needed when the old answer stays on screen while
|
|
26
|
+
* it runs, as an uncovered company does.
|
|
27
|
+
*/
|
|
28
|
+
pending?: boolean;
|
|
29
|
+
/** Said after "Checked just now." when an empty state comes back the same, such as "Still no careers system." */
|
|
30
|
+
unchanged?: string;
|
|
31
|
+
}
|
|
7
32
|
|
|
8
33
|
export interface EmptyStateProps {
|
|
9
34
|
title: string;
|
|
10
35
|
message?: string;
|
|
11
36
|
hint?: string;
|
|
12
37
|
actions?: ReactNode;
|
|
38
|
+
retry?: StatusRetry;
|
|
13
39
|
status?: "empty" | "error";
|
|
14
40
|
}
|
|
15
41
|
|
|
16
|
-
|
|
42
|
+
type ShownStatus = "empty" | "error" | null;
|
|
43
|
+
|
|
44
|
+
interface RetryFeedback {
|
|
45
|
+
pending: boolean;
|
|
46
|
+
press: () => void;
|
|
47
|
+
/** When a check ended on the state it started from; null once anything else is shown. */
|
|
48
|
+
checkedAt: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
52
|
+
return !!value && typeof (value as PromiseLike<unknown>).then === "function";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A check is the button's own promise, `retry.pending`, or the body showing
|
|
57
|
+
* its spinner (`shown === "loading"`) once a retry has been offered. When one
|
|
58
|
+
* ends on the empty or failed state it started from, the answer did not change.
|
|
59
|
+
*/
|
|
60
|
+
function useRetryFeedback(retry: StatusRetry | undefined, shown: ShownStatus | "loading"): RetryFeedback {
|
|
61
|
+
const [, rerender] = useReducer((count: number) => count + 1, 0);
|
|
62
|
+
const [checkedAt, setCheckedAt] = useState<number | null>(null);
|
|
63
|
+
const inFlight = useRef(0);
|
|
64
|
+
const mounted = useRef(true);
|
|
65
|
+
const track = useRef<{ offered: boolean; checking: boolean; from: ShownStatus; last: ShownStatus }>({
|
|
66
|
+
offered: false, checking: false, from: null, last: null,
|
|
67
|
+
});
|
|
68
|
+
if (retry) track.current.offered = true;
|
|
69
|
+
const pending = inFlight.current > 0 || retry?.pending === true;
|
|
70
|
+
const checking = pending || shown === "loading";
|
|
71
|
+
const begin = () => {
|
|
72
|
+
const state = track.current;
|
|
73
|
+
if (state.checking) return;
|
|
74
|
+
state.checking = true;
|
|
75
|
+
state.from = state.last;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
mounted.current = true;
|
|
80
|
+
return () => { mounted.current = false; };
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// After every commit: a check that just ended either confirms the state on
|
|
84
|
+
// screen or clears the line, and anything else shown clears it. A body that
|
|
85
|
+
// never offered a retry skips all of it.
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const state = track.current;
|
|
88
|
+
if (!state.offered) return;
|
|
89
|
+
if (checking) {
|
|
90
|
+
begin();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// `shown` is not "loading" here: that counts as checking.
|
|
94
|
+
if (state.checking) {
|
|
95
|
+
state.checking = false;
|
|
96
|
+
setCheckedAt(shown !== null && shown === state.from ? Date.now() : null);
|
|
97
|
+
} else if (shown !== state.last) {
|
|
98
|
+
setCheckedAt(null);
|
|
99
|
+
}
|
|
100
|
+
state.last = shown;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// "just now" turns into "2m ago" while the line stays up.
|
|
104
|
+
const shownCheckedAt = checking ? null : checkedAt;
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (shownCheckedAt === null) return;
|
|
107
|
+
const timer = setInterval(rerender, 30_000);
|
|
108
|
+
return () => clearInterval(timer);
|
|
109
|
+
}, [shownCheckedAt]);
|
|
110
|
+
|
|
111
|
+
const press = () => {
|
|
112
|
+
if (!retry || pending) return;
|
|
113
|
+
const result = retry.onRetry();
|
|
114
|
+
if (!isThenable(result)) return;
|
|
115
|
+
begin();
|
|
116
|
+
inFlight.current += 1;
|
|
117
|
+
rerender();
|
|
118
|
+
const done = () => {
|
|
119
|
+
inFlight.current -= 1;
|
|
120
|
+
if (mounted.current) rerender();
|
|
121
|
+
};
|
|
122
|
+
result.then(done, done);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return { pending, press, checkedAt: shownCheckedAt };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function retryNote(retry: StatusRetry, status: "empty" | "error", checkedAt: number): string {
|
|
129
|
+
const age = formatRelativeAge(checkedAt, Math.max(checkedAt, Date.now()));
|
|
130
|
+
if (status === "error") return tf("Tried again {age}.", { age });
|
|
131
|
+
const checked = tf("Checked {age}.", { age });
|
|
132
|
+
return retry.unchanged ? `${checked} ${t(retry.unchanged)}` : checked;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function EmptyState(props: EmptyStateProps) {
|
|
136
|
+
const feedback = useRetryFeedback(props.retry, props.retry ? props.status ?? "empty" : null);
|
|
137
|
+
return <EmptyStateBody {...props} feedback={feedback} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function EmptyStateBody({ title, message, hint, actions, retry, status = "empty", feedback }: EmptyStateProps & { feedback: RetryFeedback }) {
|
|
17
141
|
const colors = useThemeColors();
|
|
18
142
|
const { nativePaneChrome } = useUiCapabilities();
|
|
143
|
+
const retryButton = retry ? (
|
|
144
|
+
<Button
|
|
145
|
+
label={retry.label ?? "Retry"}
|
|
146
|
+
pendingLabel={retry.pendingLabel ?? (retry.label ? undefined : "Retrying...")}
|
|
147
|
+
pending={feedback.pending}
|
|
148
|
+
onPress={feedback.press}
|
|
149
|
+
/>
|
|
150
|
+
) : null;
|
|
19
151
|
// Provider messages must wrap at narrow pane widths rather than lose their tail.
|
|
20
152
|
return (
|
|
21
153
|
<Box flexDirection="column" data-gloom-status={status} data-gloom-ui="empty-state">
|
|
@@ -23,11 +155,14 @@ export function EmptyState({ title, message, hint, actions, status = "empty" }:
|
|
|
23
155
|
{message && <Box><Text fg={colors.textMuted} wrapText>{t(message)}</Text></Box>}
|
|
24
156
|
{hint && <Box><Text fg={colors.textMuted} wrapText>{t(hint)}</Text></Box>}
|
|
25
157
|
{/* Wraps in a narrow pane rather than clipping the second action; a terminal wraps without a blank row. */}
|
|
26
|
-
{actions && (
|
|
158
|
+
{(retryButton || actions) && (
|
|
27
159
|
<Box flexDirection="row" flexWrap="wrap" gap={1} rowGap={nativePaneChrome ? undefined : 0} marginTop={1}>
|
|
28
|
-
<ButtonActionScope>{actions}</ButtonActionScope>
|
|
160
|
+
<ButtonActionScope>{retryButton}{actions}</ButtonActionScope>
|
|
29
161
|
</Box>
|
|
30
162
|
)}
|
|
163
|
+
{retry && feedback.checkedAt !== null && (
|
|
164
|
+
<Box><Text fg={colors.textMuted} wrapText>{retryNote(retry, status, feedback.checkedAt)}</Text></Box>
|
|
165
|
+
)}
|
|
31
166
|
</Box>
|
|
32
167
|
);
|
|
33
168
|
}
|
|
@@ -68,6 +203,8 @@ export interface PaneStatusBodyProps {
|
|
|
68
203
|
emptyTitle?: string;
|
|
69
204
|
emptyMessage?: string;
|
|
70
205
|
actions?: ReactNode;
|
|
206
|
+
/** Asking again from the error or empty state; see StatusRetry. */
|
|
207
|
+
retry?: StatusRetry;
|
|
71
208
|
align?: "start" | "center";
|
|
72
209
|
width?: number;
|
|
73
210
|
height?: number;
|
|
@@ -85,12 +222,16 @@ export function PaneStatusBody({
|
|
|
85
222
|
emptyTitle,
|
|
86
223
|
emptyMessage,
|
|
87
224
|
actions,
|
|
225
|
+
retry,
|
|
88
226
|
align = "start",
|
|
89
227
|
width,
|
|
90
228
|
height,
|
|
91
229
|
children,
|
|
92
230
|
}: PaneStatusBodyProps) {
|
|
93
231
|
const status = error ? "error" : loading ? "loading" : empty ? "empty" : null;
|
|
232
|
+
// Here rather than in the state, so a retry that shows the spinner while it
|
|
233
|
+
// runs still says it tried once the same failure is back.
|
|
234
|
+
const feedback = useRetryFeedback(retry, status);
|
|
94
235
|
if (!status) return <>{children}</>;
|
|
95
236
|
// Keyed so content that starts with a bare Box never reuses this padded node.
|
|
96
237
|
return (
|
|
@@ -107,11 +248,13 @@ export function PaneStatusBody({
|
|
|
107
248
|
data-gloom-ui="pane-status"
|
|
108
249
|
>
|
|
109
250
|
{status === "loading" ? <Spinner label={loadingLabel ?? loadingText(subject)} /> : (
|
|
110
|
-
<
|
|
251
|
+
<EmptyStateBody
|
|
111
252
|
status={status}
|
|
112
253
|
title={status === "error" ? errorTitle ?? (subject ? unavailableText(subject) : error!) : emptyTitle ?? t("Nothing to show yet.")}
|
|
113
254
|
message={status === "error" ? (errorTitle || subject ? error! : undefined) : emptyMessage}
|
|
114
255
|
actions={actions}
|
|
256
|
+
retry={retry}
|
|
257
|
+
feedback={feedback}
|
|
115
258
|
/>
|
|
116
259
|
)}
|
|
117
260
|
</Box>
|
|
@@ -193,7 +193,7 @@ function DiscoverStatus({ controller }: { controller: LayoutGalleryController })
|
|
|
193
193
|
<EmptyState
|
|
194
194
|
title={discover.state.error}
|
|
195
195
|
status="error"
|
|
196
|
-
|
|
196
|
+
retry={{ onRetry: discover.refresh }}
|
|
197
197
|
/>
|
|
198
198
|
</SidebarState>
|
|
199
199
|
);
|
|
@@ -219,7 +219,7 @@ function TeamStatus({ controller }: { controller: LayoutGalleryController }) {
|
|
|
219
219
|
<EmptyState
|
|
220
220
|
title={state.error}
|
|
221
221
|
status="error"
|
|
222
|
-
|
|
222
|
+
retry={{ onRetry: refresh }}
|
|
223
223
|
/>
|
|
224
224
|
</SidebarState>
|
|
225
225
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type FuturesSector =
|
|
2
2
|
| "equity-index"
|
|
3
|
+
| "equity-sectors"
|
|
3
4
|
| "rates"
|
|
4
5
|
| "energy"
|
|
5
6
|
| "metals"
|
|
@@ -38,7 +39,10 @@ export interface FuturesContract {
|
|
|
38
39
|
* unset: the security keeps the code and the future is its `=F` symbol.
|
|
39
40
|
* Checked with `gloomberb quote <code>` signed out on 2026-10-11: each of
|
|
40
41
|
* these was "Not a ticker", or for ZT and ZF a quote last updated in 2023 and
|
|
41
|
-
* 2021 that search no longer lists.
|
|
42
|
+
* 2021 that search no longer lists. The sector, index and energy rows were
|
|
43
|
+
* checked the same way on 2026-10-14: XAR is an ETF, XAU Goldmoney, SXI
|
|
44
|
+
* Standex, SXT Sensient, RX BioSyent, EMD a closed-end fund, SOX the PHLX
|
|
45
|
+
* index, and RS1, MES, MYM, M2K, MCL, MNG and HH are listed abroad.
|
|
42
46
|
*/
|
|
43
47
|
bareCode?: true;
|
|
44
48
|
}
|
|
@@ -58,6 +62,37 @@ export const FUTURES_CONTRACTS: FuturesContract[] = [
|
|
|
58
62
|
{ symbol: "NQ=F", code: "NQ", name: "E-Mini Nasdaq 100", sector: "equity-index", venue: "CME", tick: 0.25, bareCode: true },
|
|
59
63
|
{ symbol: "YM=F", code: "YM", name: "E-Mini Dow", sector: "equity-index", venue: "CBT", tick: 1, bareCode: true },
|
|
60
64
|
{ symbol: "RTY=F", code: "RTY", name: "E-Mini Russell 2000", sector: "equity-index", venue: "CME", tick: 0.1, bareCode: true },
|
|
65
|
+
{ symbol: "EMD=F", code: "EMD", name: "E-Mini S&P MidCap 400", sector: "equity-index", venue: "CME", tick: 0.1 },
|
|
66
|
+
{ symbol: "RS1=F", code: "RS1", name: "E-Mini Russell 1000", sector: "equity-index", venue: "CME", tick: 0.1 },
|
|
67
|
+
{ symbol: "NKD=F", code: "NKD", name: "Nikkei 225 (USD)", sector: "equity-index", venue: "CME", tick: 5, bareCode: true },
|
|
68
|
+
// Quoted in yen: the same index points, each worth 500 yen, so the server has no dollar terms for it.
|
|
69
|
+
{ symbol: "NIY=F", code: "NIY", name: "Nikkei 225 (Yen)", sector: "equity-index", venue: "CME", tick: 5, unit: "index points", bareCode: true },
|
|
70
|
+
{ symbol: "MES=F", code: "MES", name: "Micro E-Mini S&P 500", sector: "equity-index", venue: "CME", tick: 0.25 },
|
|
71
|
+
{ symbol: "MNQ=F", code: "MNQ", name: "Micro E-Mini Nasdaq 100", sector: "equity-index", venue: "CME", tick: 0.25, bareCode: true },
|
|
72
|
+
{ symbol: "MYM=F", code: "MYM", name: "Micro E-Mini Dow", sector: "equity-index", venue: "CBT", tick: 1 },
|
|
73
|
+
{ symbol: "M2K=F", code: "M2K", name: "Micro E-Mini Russell 2000", sector: "equity-index", venue: "CME", tick: 0.1 },
|
|
74
|
+
|
|
75
|
+
// CME's E-mini S&P Select Sector futures (XA*) and Select Industry futures
|
|
76
|
+
// (SX*), named by their S&P index: each sector followed by its industries in
|
|
77
|
+
// GICS order, then the Dow Jones U.S. Real Estate future on CBOT.
|
|
78
|
+
{ symbol: "XAE=F", code: "XAE", name: "S&P Energy", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
79
|
+
{ symbol: "SXO=F", code: "SXO", name: "S&P Oil & Gas E&P", sector: "equity-sectors", venue: "CME", tick: 0.5, bareCode: true },
|
|
80
|
+
{ symbol: "XAB=F", code: "XAB", name: "S&P Materials", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
81
|
+
{ symbol: "XAI=F", code: "XAI", name: "S&P Industrial", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
82
|
+
{ symbol: "XAY=F", code: "XAY", name: "S&P Consumer Discretionary", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
83
|
+
{ symbol: "SXR=F", code: "SXR", name: "S&P Retail", sector: "equity-sectors", venue: "CME", tick: 1, bareCode: true },
|
|
84
|
+
{ symbol: "XAP=F", code: "XAP", name: "S&P Consumer Staples", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
85
|
+
{ symbol: "XAV=F", code: "XAV", name: "S&P Health Care", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
86
|
+
{ symbol: "SXT=F", code: "SXT", name: "S&P Biotechnology", sector: "equity-sectors", venue: "CME", tick: 1 },
|
|
87
|
+
{ symbol: "XAF=F", code: "XAF", name: "S&P Financial", sector: "equity-sectors", venue: "CME", tick: 0.05, bareCode: true },
|
|
88
|
+
{ symbol: "SXB=F", code: "SXB", name: "S&P Regional Banks", sector: "equity-sectors", venue: "CME", tick: 0.25, bareCode: true },
|
|
89
|
+
{ symbol: "SXI=F", code: "SXI", name: "S&P Insurance", sector: "equity-sectors", venue: "CME", tick: 0.5 },
|
|
90
|
+
{ symbol: "XAK=F", code: "XAK", name: "S&P Technology", sector: "equity-sectors", venue: "CME", tick: 0.1, bareCode: true },
|
|
91
|
+
{ symbol: "SOX=F", code: "SOX", name: "PHLX Semiconductor", sector: "equity-sectors", venue: "CME", tick: 0.5 },
|
|
92
|
+
{ symbol: "XAZ=F", code: "XAZ", name: "S&P Communication Services", sector: "equity-sectors", venue: "CME", tick: 0.05, bareCode: true },
|
|
93
|
+
{ symbol: "XAU=F", code: "XAU", name: "S&P Utilities", sector: "equity-sectors", venue: "CME", tick: 0.1 },
|
|
94
|
+
{ symbol: "XAR=F", code: "XAR", name: "S&P Real Estate", sector: "equity-sectors", venue: "CME", tick: 0.05 },
|
|
95
|
+
{ symbol: "RX=F", code: "RX", name: "Dow Jones U.S. Real Estate", sector: "equity-sectors", venue: "CBT", tick: 0.1 },
|
|
61
96
|
|
|
62
97
|
{ symbol: "ZT=F", code: "ZT", name: "2-Year T-Note", sector: "rates", venue: "CBT", bareCode: true },
|
|
63
98
|
{ symbol: "ZF=F", code: "ZF", name: "5-Year T-Note", sector: "rates", venue: "CBT", bareCode: true },
|
|
@@ -72,6 +107,12 @@ export const FUTURES_CONTRACTS: FuturesContract[] = [
|
|
|
72
107
|
{ symbol: "TTF=F", code: "TTF", name: "Dutch TTF Gas", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.001, unit: "EUR / MWh", bareCode: true },
|
|
73
108
|
{ symbol: "RB=F", code: "RB", name: "RBOB Gasoline", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.0001 },
|
|
74
109
|
{ symbol: "HO=F", code: "HO", name: "Heating Oil", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.0001 },
|
|
110
|
+
// Cash settled on the CL and NG final settlements.
|
|
111
|
+
{ symbol: "QM=F", code: "QM", name: "E-Mini Crude Oil", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.025, bareCode: true },
|
|
112
|
+
{ symbol: "MCL=F", code: "MCL", name: "Micro WTI Crude Oil", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.01 },
|
|
113
|
+
{ symbol: "QG=F", code: "QG", name: "E-Mini Natural Gas", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.005, bareCode: true },
|
|
114
|
+
{ symbol: "MNG=F", code: "MNG", name: "Micro Henry Hub Natural Gas", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.001 },
|
|
115
|
+
{ symbol: "HH=F", code: "HH", name: "Henry Hub Last Day Financial", sector: "energy", venue: "NYM", everyMonth: true, tick: 0.001 },
|
|
75
116
|
|
|
76
117
|
{ symbol: "GC=F", code: "GC", name: "Gold", sector: "metals", venue: "CMX", tick: 0.1 },
|
|
77
118
|
{ symbol: "SI=F", code: "SI", name: "Silver", sector: "metals", venue: "CMX", tick: 0.005 },
|
|
@@ -109,6 +150,7 @@ export const FUTURES_CONTRACTS: FuturesContract[] = [
|
|
|
109
150
|
|
|
110
151
|
export const FUTURES_SECTOR_LABELS: Record<FuturesSector, string> = {
|
|
111
152
|
"equity-index": "Equity Index",
|
|
153
|
+
"equity-sectors": "Equity Sectors",
|
|
112
154
|
rates: "Rates",
|
|
113
155
|
energy: "Energy",
|
|
114
156
|
metals: "Metals",
|
|
@@ -119,6 +161,7 @@ export const FUTURES_SECTOR_LABELS: Record<FuturesSector, string> = {
|
|
|
119
161
|
|
|
120
162
|
export const FUTURES_SECTOR_ORDER: FuturesSector[] = [
|
|
121
163
|
"equity-index",
|
|
164
|
+
"equity-sectors",
|
|
122
165
|
"rates",
|
|
123
166
|
"energy",
|
|
124
167
|
"metals",
|
|
@@ -165,7 +165,7 @@ export function CalendarAccountTab({ width, sessionMarker }: { width: number; se
|
|
|
165
165
|
<EmptyState
|
|
166
166
|
status="error"
|
|
167
167
|
title={loadError}
|
|
168
|
-
|
|
168
|
+
retry={{ onRetry: () => load(), pending: loading }}
|
|
169
169
|
/>
|
|
170
170
|
);
|
|
171
171
|
}
|
|
@@ -2,12 +2,14 @@ import { withConfigData, withMarketData } from "../../../cli/scoped-context";
|
|
|
2
2
|
import { CLI_COMMAND_GROUPS } from "../../../cli/help";
|
|
3
3
|
import { dryRunNote } from "../../../cli/helpers";
|
|
4
4
|
import { requireArg, takeOption } from "../../../cli/commands/command-utils";
|
|
5
|
-
import { EXCHANGE_OPTION, listingReadNotes, requireListingArg } from "../../../cli/listing-arg";
|
|
5
|
+
import { EXCHANGE_OPTION, listingReadNotes, requireListingArg, type ListingArg } from "../../../cli/listing-arg";
|
|
6
6
|
import { saveConfig } from "../../../data/config/store";
|
|
7
7
|
import type { AppConfig } from "../../../types/config";
|
|
8
|
-
import type { CliCommandDef } from "../../../types/plugin";
|
|
8
|
+
import type { CliCommandContext, CliCommandDef } from "../../../types/plugin";
|
|
9
|
+
import { getActiveQuoteDisplay } from "../../../market-data/market/status";
|
|
10
|
+
import { settleWithin } from "../../../utils/async-deadline";
|
|
9
11
|
import { cliStyles } from "../../../utils/cli-output";
|
|
10
|
-
import { ambiguousNumberMessage, parsePastedNumber } from "../../../utils/decimal-comma";
|
|
12
|
+
import { ambiguousNumberMessage, meantThousandsDot, parsePastedNumber, thousandsDotReadings, type ThousandsDotReadings } from "../../../utils/decimal-comma";
|
|
11
13
|
import { newYorkToday } from "../earnings/board-model";
|
|
12
14
|
import { createAlert, deserializeAlerts, rearmAlert, serializeAlerts } from "./alert-engine";
|
|
13
15
|
import { ALERTS_KEY } from "./constants";
|
|
@@ -43,20 +45,28 @@ const DAYS_RULE = `--days takes a whole number of days from 1 to ${MAX_LEAD_DAYS
|
|
|
43
45
|
|
|
44
46
|
/** What `alerts add` was asked for, before any listing or date is looked up. */
|
|
45
47
|
export type AlertAddRequest =
|
|
46
|
-
| {
|
|
48
|
+
| {
|
|
49
|
+
kind: "price"; symbol: string; condition: AlertCondition; price: number;
|
|
50
|
+
/** A price typed as 81.000, which the last price may show was 81000 with a dot between thousands. */
|
|
51
|
+
thousandsDot?: ThousandsDotReadings & { text: string };
|
|
52
|
+
}
|
|
47
53
|
| { kind: "earnings"; symbol: string; leadDays: number }
|
|
48
54
|
| { kind: "macro"; event: MacroAlertEvent; leadDays: number };
|
|
49
55
|
|
|
50
56
|
const CONDITIONS: readonly AlertCondition[] = ["above", "below", "crosses"];
|
|
51
57
|
|
|
58
|
+
/** The first word of a price typed with a space between thousands and no quotes (`81 000`): one to three digits. */
|
|
59
|
+
const LEADING_GROUP = /^[+-]?\d{1,3}$/;
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Reads `alerts add` arguments: `<symbol> above 250`, `<symbol> earnings`, or
|
|
54
63
|
* `CPI` / `FOMC`, with `--days` (already taken out of `args`, `null` when the
|
|
55
|
-
* flag came without a value) for the event forms only
|
|
56
|
-
* names what the command
|
|
64
|
+
* flag came without a value) for the event forms only, and `--exchange`, which
|
|
65
|
+
* a command to type again keeps. Throws a message that names what the command
|
|
66
|
+
* takes.
|
|
57
67
|
*/
|
|
58
|
-
export function parseAlertAddArgs(args: readonly string[], days: string | null | undefined): AlertAddRequest {
|
|
59
|
-
const [first, second, third] = args;
|
|
68
|
+
export function parseAlertAddArgs(args: readonly string[], days: string | null | undefined, exchange?: string | null): AlertAddRequest {
|
|
69
|
+
const [first, second, third, ...rest] = args;
|
|
60
70
|
if (!first) throw new Error(ADD_USAGE);
|
|
61
71
|
const leadDays = () => {
|
|
62
72
|
const parsed = days === null ? null : parseLeadDays(days);
|
|
@@ -83,11 +93,57 @@ export function parseAlertAddArgs(args: readonly string[], days: string | null |
|
|
|
83
93
|
}
|
|
84
94
|
if (days !== undefined) throw new Error("--days is for earnings and CPI or FOMC alerts, not price alerts.");
|
|
85
95
|
if (third === undefined) throw new Error(ADD_USAGE);
|
|
96
|
+
const retype = (price: number) => retypeCommand(first, condition, price, exchange);
|
|
97
|
+
// `81 000` without quotes arrives as 81 and a stray 000; neither reading is taken for granted.
|
|
98
|
+
const spaced = rest.length > 0 && LEADING_GROUP.test(third) ? parsePastedNumber([third, ...rest].join(" ")) : null;
|
|
99
|
+
if (spaced?.kind === "number") {
|
|
100
|
+
const typed = Number(third);
|
|
101
|
+
throw new Error(`${[third, ...rest].join(" ")} could be ${spaced.value} (thousands) or ${typed} and a stray ${rest.join(" ")}; type ${retype(spaced.value)} or ${retype(typed)}`);
|
|
102
|
+
}
|
|
86
103
|
// A price pasted from a sheet (1.350,5, 1,350.5, 1 350,5) reads as it is written; `1,350` could be either, so it asks.
|
|
87
104
|
const pasted = parsePastedNumber(third);
|
|
88
105
|
if (pasted.kind === "ambiguous") throw new Error(ambiguousNumberMessage(third, pasted));
|
|
89
106
|
if (pasted.kind === "invalid") throw new Error("Alert price must be a finite number.");
|
|
90
|
-
|
|
107
|
+
if (rest.length > 0) throw new Error(`A price alert takes one price, not "${rest.join(" ")}" after ${third}; type ${retype(pasted.value)}`);
|
|
108
|
+
const thousandsDot = thousandsDotReadings(third);
|
|
109
|
+
return { kind: "price", symbol: first, condition, price: pasted.value, ...(thousandsDot ? { thousandsDot: { ...thousandsDot, text: third.trim() } } : {}) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `alerts add` as typed, with another price: what an error tells someone to type. */
|
|
113
|
+
function retypeCommand(symbol: string, condition: AlertCondition, price: number, exchange: string | null | undefined): string {
|
|
114
|
+
return `gloomberb alerts add ${symbol} ${condition} ${price}${exchange ? ` --exchange ${exchange}` : ""}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const LAST_PRICE_TIMEOUT_MS = 10_000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The listing's last price, as the alert will check it, for telling 81.000
|
|
121
|
+
* typed for 81000 from 81; null when there is none, and the price is then
|
|
122
|
+
* read as written.
|
|
123
|
+
*/
|
|
124
|
+
async function lastPrice(listing: ListingArg, ctx: Pick<CliCommandContext, "initMarketData">): Promise<number | null> {
|
|
125
|
+
const quote = await settleWithin(
|
|
126
|
+
withMarketData(ctx, (market) => market.dataProvider.getQuote(listing.key.toUpperCase(), listing.exchange)),
|
|
127
|
+
LAST_PRICE_TIMEOUT_MS,
|
|
128
|
+
);
|
|
129
|
+
const price = quote ? getActiveQuoteDisplay(quote)?.price ?? quote.price : null;
|
|
130
|
+
return price != null && Number.isFinite(price) && price !== 0 ? price : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Asks which price `81.000` meant when the last price shows it was a dot
|
|
135
|
+
* between thousands: both readings, and the command to type for each.
|
|
136
|
+
*/
|
|
137
|
+
export function thousandsDotMessage(
|
|
138
|
+
request: Extract<AlertAddRequest, { kind: "price" }>,
|
|
139
|
+
listing: string,
|
|
140
|
+
last: number | null,
|
|
141
|
+
exchange?: string | null,
|
|
142
|
+
): string | null {
|
|
143
|
+
const dot = request.thousandsDot;
|
|
144
|
+
if (!dot || !meantThousandsDot(dot, last)) return null;
|
|
145
|
+
const retype = (price: number) => retypeCommand(request.symbol, request.condition, price, exchange);
|
|
146
|
+
return `${dot.text} could be ${dot.thousands} (thousands) or ${dot.decimal} (decimal point), and ${listing.toUpperCase()} last traded at ${Number(last!.toFixed(2))}; type ${retype(dot.thousands)} or ${retype(dot.decimal)}`;
|
|
91
147
|
}
|
|
92
148
|
|
|
93
149
|
/** One row of `alerts list`: a price alert as stored, or an event alert with its next date. */
|
|
@@ -222,13 +278,17 @@ export const alertsCliCommand: CliCommandDef = {
|
|
|
222
278
|
|
|
223
279
|
const parseAdd = (): AlertAddRequest => {
|
|
224
280
|
try {
|
|
225
|
-
return parseAlertAddArgs(args.slice(1), days);
|
|
281
|
+
return parseAlertAddArgs(args.slice(1), days, exchangeOption);
|
|
226
282
|
} catch (error) {
|
|
227
283
|
return ctx.fail(error instanceof Error ? error.message : String(error));
|
|
228
284
|
}
|
|
229
285
|
};
|
|
230
286
|
const request = action === "add" ? parseAdd() : null;
|
|
231
287
|
const priceListing = request?.kind === "price" ? await requireListingArg(request.symbol, exchangeOption, ctx) : null;
|
|
288
|
+
if (request?.kind === "price" && request.thousandsDot && priceListing) {
|
|
289
|
+
const question = thousandsDotMessage(request, priceListing.key, await lastPrice(priceListing, ctx), exchangeOption);
|
|
290
|
+
if (question) ctx.fail(question);
|
|
291
|
+
}
|
|
232
292
|
// What a typed listing was read as (7203:TSE as 7203:JPX), said beside what was added.
|
|
233
293
|
let read = priceListing ? listingReadNotes(priceListing) : [];
|
|
234
294
|
let newEventRule: EventAlertRule | null = null;
|
|
@@ -309,7 +369,8 @@ export const alertsCliCommand: CliCommandDef = {
|
|
|
309
369
|
const alert = createAlert(symbol, condition, price, priceListing?.exchange);
|
|
310
370
|
await save({ alerts: [...alerts, alert] });
|
|
311
371
|
ctx.printResult({ data: { changed: !ctx.cliOptions.dryRun, dryRun: ctx.cliOptions.dryRun, alert }, ...(read.length > 0 ? { warnings: read } : {}) }, {
|
|
312
|
-
|
|
372
|
+
// No full stop after the price, which would read as part of it (below 81.).
|
|
373
|
+
text: (data) => `Added alert ${data.alert.id}: ${symbol} ${condition} ${price}${dryRunNote(data.dryRun)}`,
|
|
313
374
|
});
|
|
314
375
|
return;
|
|
315
376
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AwardFilter, AwardRow, AwardType } from "../../../api-client/awards";
|
|
2
2
|
import { notATickerSymbol } from "../../../sources/provider-errors";
|
|
3
3
|
import type { HeadlessBundleSection, HeadlessPaneColumn, HeadlessPaneContext, HeadlessPaneDefinition, HeadlessPaneRow } from "../../../types/headless";
|
|
4
|
+
import { isoDateOption } from "../../../utils/calendar-date";
|
|
4
5
|
import { resolveHeadlessInstrument } from "../shared/headless-market-data";
|
|
5
6
|
import { fetchAward, fetchAwards } from "./client";
|
|
6
7
|
import { AWARD_TABS, awardScope, awardsPreviewNote, awardTypeLabel, futureAwardDatesNote, money } from "./model";
|
|
@@ -85,7 +86,10 @@ export const awardsHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
85
86
|
options: [
|
|
86
87
|
{ key: "tab", type: "enum", settingKey: "tab", defaultValue: "feed", values: [...AWARD_TABS.map(({ value }) => ({ value })), { value: "all" }], description: "Feed, company history, agencies, sectors, events, or all sections." },
|
|
87
88
|
{ key: "view", type: "enum", settingKey: "leaderView", defaultValue: "companies", values: [{ value: "companies" }, { value: "sectors" }], description: "Sectors tab: company leaders or sector totals." },
|
|
88
|
-
...
|
|
89
|
+
...FILTER_KEYS.map((key) => key === "from" || key === "to"
|
|
90
|
+
? { key, type: "string" as const, settingKey: key, placeholder: "YYYY-MM-DD", description: `${key === "from" ? "Start" : "End"} of the date range. Dates use YYYY-MM-DD.`,
|
|
91
|
+
normalize: (value: string) => isoDateOption(value, key) }
|
|
92
|
+
: { key, type: "string" as const, settingKey: key, description: `Filter by ${key}.` }),
|
|
89
93
|
{ key: "type", type: "enum", settingKey: "awardType", defaultValue: "prime", values: [{ value: "prime" }, { value: "subaward" }, { value: "notice" }, { value: "modification" }], description: "Keep record types separate." },
|
|
90
94
|
{ key: "cursor", type: "string", description: "Continue after the previous response's nextCursor." },
|
|
91
95
|
{ key: "limit", type: "integer", minimum: 1, maximum: 200, defaultValue: 100, description: "Maximum feed rows in this response." },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CATALYST_TYPES } from "../../../api-client/catalysts";
|
|
2
2
|
import type { HeadlessPaneDefinition } from "../../../types/plugin";
|
|
3
|
+
import { isoDateOption } from "../../../utils/calendar-date";
|
|
3
4
|
import { fetchCatalystDetail, fetchCatalysts } from "./client";
|
|
4
5
|
import { catalystCell, catalystChangeText, catalystDate, catalystQuery, catalystTickers } from "./model";
|
|
5
6
|
|
|
@@ -11,7 +12,10 @@ function definition(litigation: boolean): HeadlessPaneDefinition<"bundle"> {
|
|
|
11
12
|
options: [
|
|
12
13
|
{ key: "tab", type: "enum", values: [{ value: "calendar" }, { value: "changes" }], defaultValue: "calendar", description: "Dated events or observed revisions." },
|
|
13
14
|
{ key: "type", type: "enum", values: CATALYST_TYPES.map((value) => ({ value })), description: "Event type." },
|
|
14
|
-
...["agency", "country", "sector", "status"
|
|
15
|
+
...["agency", "country", "sector", "status"].map((key) => ({ key, type: "string" as const, description: `Filter by ${key}.` })),
|
|
16
|
+
{ key: "search", type: "string", description: "Filter by text." },
|
|
17
|
+
{ key: "from", type: "string", placeholder: "YYYY-MM-DD", description: "Start of the calendar window. Dates use YYYY-MM-DD.", normalize: (value) => isoDateOption(value, "from") },
|
|
18
|
+
{ key: "to", type: "string", placeholder: "YYYY-MM-DD", description: "End of the calendar window. Dates use YYYY-MM-DD.", normalize: (value) => isoDateOption(value, "to") },
|
|
15
19
|
{ key: "dateField", type: "enum", values: ["any", "announced", "effective", "deadline", "observed"].map((value) => ({ value })), defaultValue: "any", description: "Date field for the calendar window." },
|
|
16
20
|
{ key: "upcoming", type: "boolean", defaultValue: false, description: "Only future effective dates and deadlines." },
|
|
17
21
|
{ key: "offset", type: "integer", defaultValue: 0, description: "Zero-based event offset." },
|
|
@@ -21,6 +21,21 @@ export function getSafetyRefreshChannelIds(channelStates: Iterable<[string, Chan
|
|
|
21
21
|
return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The channels read live, each marked to read its latest page in full on its
|
|
26
|
+
* next refresh: after the socket was down, what changed on messages already
|
|
27
|
+
* held (reactions, edits, removals) does not come with newer messages.
|
|
28
|
+
*/
|
|
29
|
+
export function markConnectedChannelsForBackfill(channelStates: Iterable<[string, ChannelRuntimeState]>): string[] {
|
|
30
|
+
const channelIds: string[] = [];
|
|
31
|
+
for (const [channelId, channel] of channelStates) {
|
|
32
|
+
if (!channel.wsConnected) continue;
|
|
33
|
+
channel.backfilled = false;
|
|
34
|
+
channelIds.push(channelId);
|
|
35
|
+
}
|
|
36
|
+
return channelIds;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export function getOpenConnectionChannelIds(channelStates: Iterable<[string, ChannelRuntimeState]>): Set<string> {
|
|
25
40
|
const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
|
|
26
41
|
for (const [channelId, channel] of channelStates) {
|
|
@@ -76,6 +91,9 @@ export function ensureChatChannelConnection({
|
|
|
76
91
|
if (channel.wsConnected) return;
|
|
77
92
|
channel.wsConnected = true;
|
|
78
93
|
|
|
94
|
+
// Reactions, edits and removals on messages already held while the channel
|
|
95
|
+
// was not read live come only with a full page, not with newer messages.
|
|
96
|
+
channel.backfilled = false;
|
|
79
97
|
void refreshMessages().catch(() => {});
|
|
80
98
|
|
|
81
99
|
channel.wsConnection = connectChannel(
|
|
@@ -35,8 +35,11 @@ export async function fetchLatestChannelMessages(
|
|
|
35
35
|
limit: MESSAGE_PAGE_SIZE,
|
|
36
36
|
after: backfillGaps ? undefined : channel.lastCursor ?? undefined,
|
|
37
37
|
});
|
|
38
|
-
|
|
39
|
-
if (backfillGaps)
|
|
38
|
+
// A full page asked for while this read was out still happens next time.
|
|
39
|
+
if (backfillGaps) {
|
|
40
|
+
channel.backfilled = true;
|
|
41
|
+
channel.backfilledAs = readerId;
|
|
42
|
+
}
|
|
40
43
|
if ((!hasIncrementalCursor || backfillGaps) && messages.length < MESSAGE_PAGE_SIZE) {
|
|
41
44
|
channel.reachedOldestMessage = true;
|
|
42
45
|
}
|