gloomberb 0.17.4 → 0.17.5
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 +1 -1
- package/patches/electrobun@1.18.1.patch +80 -0
- package/src/api-client/data.ts +4 -1
- package/src/api-client/research-activity.ts +10 -3
- package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
- package/src/app/runtime/update.ts +50 -3
- package/src/app.tsx +2 -0
- package/src/ask-gloom/pane-context.ts +11 -1
- package/src/cli/commands/overview.ts +2 -2
- package/src/cli/commands/system.ts +19 -1
- package/src/cli/glossary.ts +34 -4
- package/src/cli/index.ts +6 -0
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/market-session.ts +18 -3
- package/src/components/chart/composite/format.ts +7 -0
- package/src/components/chart/composite/legend.tsx +8 -11
- package/src/components/command-bar/commands/direct/index.ts +47 -0
- package/src/components/command-bar/commands/registry.ts +8 -0
- package/src/components/command-bar/panel/prompt-binding.ts +8 -0
- package/src/components/command-bar/routes/root/command-items.ts +16 -1
- package/src/components/command-bar/surface/index.tsx +2 -1
- package/src/components/command-bar/workflow/ops.ts +4 -3
- package/src/components/form-modal/confirm.tsx +1 -0
- package/src/components/form-modal/request.ts +2 -0
- package/src/components/layout/detached-pane-shell.tsx +6 -1
- package/src/components/layout/pane/sidebar.tsx +24 -11
- package/src/components/layout/shell/index.tsx +5 -1
- package/src/components/layout/shell/menu.ts +8 -0
- package/src/components/layout/shell/pane/capture.ts +29 -12
- package/src/components/ui/confirm-dialog.tsx +30 -4
- package/src/components/ui/fields.tsx +5 -0
- package/src/data/config/store/normalize.ts +8 -0
- package/src/i18n/es.ts +2 -0
- package/src/i18n/ja.ts +2 -0
- package/src/i18n/ko.ts +2 -0
- package/src/i18n/zh-cn.ts +2 -0
- package/src/i18n/zh-tw.ts +2 -0
- package/src/market-data/coordinator/events.ts +5 -1
- package/src/market-data/coordinator/index.ts +4 -0
- package/src/market-data/coordinator/quotes.ts +4 -0
- package/src/market-data/hooks.tsx +168 -29
- package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
- package/src/plugins/builtin/broker-manager/model.ts +2 -2
- package/src/plugins/builtin/chart-composer/headless.ts +2 -1
- package/src/plugins/builtin/chart-composer/pane.tsx +20 -2
- package/src/plugins/builtin/chat/content/footer.tsx +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +20 -0
- package/src/plugins/builtin/chat/controller/messages.ts +14 -2
- package/src/plugins/builtin/chat/controller/state.ts +15 -0
- package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
- package/src/plugins/builtin/chat/controller/view.ts +2 -0
- package/src/plugins/builtin/chat/index.tsx +1 -1
- package/src/plugins/builtin/chat/share/flow.tsx +18 -9
- package/src/plugins/builtin/chat/unread-badge.ts +38 -7
- package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
- package/src/plugins/builtin/futures-curve/headless.ts +13 -6
- package/src/plugins/builtin/futures-curve/index.tsx +3 -3
- package/src/plugins/builtin/futures-curve/model.ts +64 -4
- package/src/plugins/builtin/futures-curve/pane.tsx +11 -4
- package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
- package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
- package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
- package/src/plugins/builtin/macro-day/release-table.ts +3 -3
- package/src/plugins/builtin/mna/model.ts +2 -1
- package/src/plugins/builtin/options-positioning/model.ts +14 -0
- package/src/plugins/builtin/options-positioning/pane.tsx +17 -10
- package/src/plugins/builtin/perps/client.ts +77 -6
- package/src/plugins/builtin/perps/headless.ts +25 -14
- package/src/plugins/builtin/perps/index.tsx +1 -1
- package/src/plugins/builtin/perps/model.ts +32 -0
- package/src/plugins/builtin/perps/pane.tsx +5 -4
- package/src/plugins/builtin/rate-path/model.ts +1 -1
- package/src/plugins/builtin/rate-path/pane.tsx +2 -1
- package/src/plugins/builtin/research/event-sources.ts +1 -1
- package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
- package/src/plugins/builtin/sectors/client.ts +46 -19
- package/src/plugins/builtin/sectors/sector-model.ts +22 -10
- package/src/plugins/builtin/thirteenf/api.ts +60 -43
- package/src/plugins/builtin/thirteenf/data.ts +76 -35
- package/src/plugins/builtin/thirteenf/headless.ts +2 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
- package/src/plugins/builtin/yield-curve/curves.ts +15 -1
- package/src/plugins/builtin/yield-curve/headless.ts +17 -7
- package/src/plugins/builtin/yield-curve/history.ts +2 -0
- package/src/plugins/builtin/yield-curve/index.tsx +23 -18
- package/src/plugins/builtin/yield-curve/series.ts +19 -9
- package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
- package/src/plugins/builtin/yield-curve/world.ts +4 -0
- package/src/plugins/registry/host-actions.ts +3 -0
- package/src/renderers/browser/app-services.ts +7 -1
- package/src/renderers/browser/config-host.ts +55 -2
- package/src/renderers/browser/first-screen.ts +193 -0
- package/src/renderers/browser/main.tsx +59 -7
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -0
- package/src/renderers/electrobun/bun/desktop/update.ts +115 -12
- package/src/renderers/electrobun/shared/protocol.ts +3 -2
- package/src/renderers/electrobun/view/update-host.ts +2 -2
- package/src/shares/bug-report-dialog.tsx +162 -0
- package/src/shares/bug-report-flow.tsx +84 -0
- package/src/shares/bug-report.ts +42 -0
- package/src/shares/capture-flow.ts +19 -0
- package/src/shares/capture-preview.tsx +129 -0
- package/src/shares/chat.ts +10 -1
- package/src/shares/dialog.tsx +16 -91
- package/src/shares/flow.tsx +12 -16
- package/src/shares/personal-pane.ts +35 -1
- package/src/telemetry/usage-counts.ts +16 -0
- package/src/tickers/open-target.ts +61 -11
- package/src/time-series/resolve.ts +21 -6
- package/src/time-series/spec.ts +8 -0
- package/src/types/config.ts +14 -0
- package/src/updater/channel.ts +51 -0
- package/src/updater/github-releases.ts +85 -16
- package/src/updater/index.ts +125 -16
- package/src/utils/datetime-format.ts +7 -1
- package/src/utils/semver.ts +47 -2
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { apiClient, type ChatChannel, type ChatMessage } from "../../../../api-client";
|
|
2
|
+
import type { ChannelRuntimeState, ChannelUnreadPeople } from "./state";
|
|
3
|
+
import { chatMessageMentionsUsername, normalizeChatUsername } from "./utils";
|
|
4
|
+
|
|
5
|
+
/** The most messages the server returns for one request. */
|
|
6
|
+
const PAGE_SIZE = 100;
|
|
7
|
+
/**
|
|
8
|
+
* A channel reads at most this many pages of its unread messages, the newest
|
|
9
|
+
* ones: 300. A mention older than that in a channel with more than 300 unread
|
|
10
|
+
* is not counted, which keeps a busy channel left unread for weeks from
|
|
11
|
+
* costing hundreds of requests at every launch.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_PAGES = 3;
|
|
14
|
+
const RETRY_AFTER_FAILURE_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
interface Reader {
|
|
17
|
+
id: string;
|
|
18
|
+
username: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the app badge needs of one unread message: who sent it, and whether it mentions you. */
|
|
22
|
+
interface UnreadFact {
|
|
23
|
+
senderId: string;
|
|
24
|
+
mentionsYou: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unreadFact(message: ChatMessage, reader: Reader): UnreadFact {
|
|
28
|
+
return {
|
|
29
|
+
senderId: message.user.id,
|
|
30
|
+
mentionsYou: !!reader.username && chatMessageMentionsUsername(message.content, reader.username),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function peopleOf(facts: Iterable<UnreadFact>): ChannelUnreadPeople {
|
|
35
|
+
const senders = new Set<string>();
|
|
36
|
+
const mentioners = new Set<string>();
|
|
37
|
+
for (const fact of facts) {
|
|
38
|
+
senders.add(fact.senderId);
|
|
39
|
+
if (fact.mentionsYou) mentioners.add(fact.senderId);
|
|
40
|
+
}
|
|
41
|
+
return { senders: [...senders], mentioners: [...mentioners] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The people behind unread messages: who sent them and who mentioned
|
|
46
|
+
* `reader`. Your own messages are never unread.
|
|
47
|
+
*/
|
|
48
|
+
export function summarizeUnreadMessages(
|
|
49
|
+
messages: readonly ChatMessage[],
|
|
50
|
+
reader: { id: string; username: string | null },
|
|
51
|
+
): ChannelUnreadPeople {
|
|
52
|
+
return peopleOf(messages.filter((message) => message.user.id !== reader.id).map((message) => unreadFact(message, reader)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Messages read from the server for a channel whose unread messages are not all on this device. */
|
|
56
|
+
interface LoadedUnread {
|
|
57
|
+
userId: string;
|
|
58
|
+
username: string | null;
|
|
59
|
+
/** The read marker they follow; a new marker makes them stale. */
|
|
60
|
+
markerId: string;
|
|
61
|
+
facts: Map<string, UnreadFact>;
|
|
62
|
+
/** The newest message read, where the next load picks up. */
|
|
63
|
+
newestId: string | null;
|
|
64
|
+
/** The unread count the load was asked for, so a count that never matches is not asked for again. */
|
|
65
|
+
loadedFor: number;
|
|
66
|
+
failedAt: number | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface UnreadPeopleOptions {
|
|
70
|
+
getChannels: () => ChatChannel[];
|
|
71
|
+
getChannelStates: () => Iterable<[string, ChannelRuntimeState]>;
|
|
72
|
+
getUser: () => { id: string; username: string; emailVerified: boolean } | null;
|
|
73
|
+
/** A load finished: the people changed. */
|
|
74
|
+
onChange: () => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Who each channel's unread messages are from, for the app badge. The server
|
|
79
|
+
* keeps a count per channel and a read marker, not the senders, so they come
|
|
80
|
+
* from the messages after the marker: the ones this device holds, and for a
|
|
81
|
+
* channel whose unread messages are not all here, the newest 300 of the rest
|
|
82
|
+
* read from the server once (then only what is new). Nothing is guessed: a
|
|
83
|
+
* channel that has not loaded yet reports the unread messages it does hold.
|
|
84
|
+
*
|
|
85
|
+
* Only a host that keeps an app badge needs this, so it does nothing, and
|
|
86
|
+
* makes no request, until something asks to {@link track} it.
|
|
87
|
+
*/
|
|
88
|
+
export class ChatControllerUnreadPeople {
|
|
89
|
+
private readonly loaded = new Map<string, LoadedUnread>();
|
|
90
|
+
private readonly inFlight = new Set<string>();
|
|
91
|
+
private generation = 0;
|
|
92
|
+
private trackers = 0;
|
|
93
|
+
|
|
94
|
+
constructor(private readonly options: UnreadPeopleOptions) {}
|
|
95
|
+
|
|
96
|
+
/** Starts keeping the people current for as long as the returned stop has not been called. */
|
|
97
|
+
track(): () => void {
|
|
98
|
+
this.trackers += 1;
|
|
99
|
+
if (this.trackers === 1) this.options.onChange();
|
|
100
|
+
let stopped = false;
|
|
101
|
+
return () => {
|
|
102
|
+
if (stopped) return;
|
|
103
|
+
stopped = true;
|
|
104
|
+
this.trackers -= 1;
|
|
105
|
+
if (this.trackers === 0) this.clear();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
snapshot(): Record<string, ChannelUnreadPeople> {
|
|
110
|
+
const reader = this.trackers > 0 ? this.reader() : null;
|
|
111
|
+
if (!reader) return {};
|
|
112
|
+
const channels = new Map(this.options.getChannels().map((channel) => [channel.id, channel]));
|
|
113
|
+
const people: Record<string, ChannelUnreadPeople> = {};
|
|
114
|
+
for (const [channelId, state] of this.options.getChannelStates()) {
|
|
115
|
+
if (state.unreadCount <= 0) continue;
|
|
116
|
+
const otherPerson = directPerson(channels.get(channelId));
|
|
117
|
+
// Everything unread in a DM is from the one person on the other side.
|
|
118
|
+
people[channelId] = otherPerson
|
|
119
|
+
? { senders: [otherPerson], mentioners: [] }
|
|
120
|
+
: peopleOf(this.knownFacts(channelId, state, reader).values());
|
|
121
|
+
}
|
|
122
|
+
return people;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Starts reading what is missing for channels whose unread messages are not all here. Cheap when nothing is. */
|
|
126
|
+
sync(): void {
|
|
127
|
+
const reader = this.trackers > 0 ? this.reader() : null;
|
|
128
|
+
if (!reader || !this.options.getUser()?.emailVerified) return;
|
|
129
|
+
const channels = new Map(this.options.getChannels().map((channel) => [channel.id, channel]));
|
|
130
|
+
for (const [channelId, state] of this.options.getChannelStates()) {
|
|
131
|
+
const marker = state.lastViewedMessageId;
|
|
132
|
+
const channel = channels.get(channelId);
|
|
133
|
+
if (state.unreadCount <= 0 || !marker || !channel || directPerson(channel) || this.inFlight.has(channelId)) continue;
|
|
134
|
+
if (this.knownFacts(channelId, state, reader).size >= state.unreadCount) continue;
|
|
135
|
+
const loaded = this.currentLoad(channelId, marker, reader);
|
|
136
|
+
if (loaded && loaded.loadedFor >= state.unreadCount) continue;
|
|
137
|
+
if (loaded?.failedAt && Date.now() - loaded.failedAt < RETRY_AFTER_FAILURE_MS) continue;
|
|
138
|
+
void this.load(channelId, marker, reader, state.unreadCount);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Drops what was read and lets late answers fall away, as on sign-out. */
|
|
143
|
+
clear(): void {
|
|
144
|
+
this.generation += 1;
|
|
145
|
+
this.loaded.clear();
|
|
146
|
+
this.inFlight.clear();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private reader(): Reader | null {
|
|
150
|
+
const user = this.options.getUser();
|
|
151
|
+
return user ? { id: user.id, username: normalizeChatUsername(user.username) } : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private currentLoad(channelId: string, marker: string, reader: Reader): LoadedUnread | null {
|
|
155
|
+
const loaded = this.loaded.get(channelId);
|
|
156
|
+
return loaded && loaded.markerId === marker && loaded.userId === reader.id && loaded.username === reader.username
|
|
157
|
+
? loaded
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The unread messages known for certain: those read from the server after
|
|
163
|
+
* the read marker, and the cached ones after it. Without the marker nothing
|
|
164
|
+
* can be placed before or after it, so none is claimed (the server counts
|
|
165
|
+
* no unread without one either).
|
|
166
|
+
*/
|
|
167
|
+
private knownFacts(channelId: string, state: ChannelRuntimeState, reader: Reader): Map<string, UnreadFact> {
|
|
168
|
+
const facts = new Map<string, UnreadFact>();
|
|
169
|
+
const marker = state.lastViewedMessageId;
|
|
170
|
+
if (!marker) return facts;
|
|
171
|
+
for (const [id, fact] of this.currentLoad(channelId, marker, reader)?.facts ?? []) facts.set(id, fact);
|
|
172
|
+
const markerIndex = state.messages.findIndex((message) => message.id === marker);
|
|
173
|
+
if (markerIndex < 0) return facts;
|
|
174
|
+
for (const message of state.messages.slice(markerIndex + 1)) {
|
|
175
|
+
if (message.user.id === reader.id || message.clientStatus) continue;
|
|
176
|
+
facts.set(message.id, unreadFact(message, reader));
|
|
177
|
+
}
|
|
178
|
+
return facts;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async load(channelId: string, marker: string, reader: Reader, wanted: number): Promise<void> {
|
|
182
|
+
const generation = this.generation;
|
|
183
|
+
this.inFlight.add(channelId);
|
|
184
|
+
const previous = this.currentLoad(channelId, marker, reader);
|
|
185
|
+
try {
|
|
186
|
+
const messages = await readAfter(channelId, previous?.newestId ?? marker);
|
|
187
|
+
if (generation !== this.generation) return;
|
|
188
|
+
const facts = new Map(previous?.facts);
|
|
189
|
+
for (const message of messages) {
|
|
190
|
+
if (message.user.id !== reader.id) facts.set(message.id, unreadFact(message, reader));
|
|
191
|
+
}
|
|
192
|
+
this.loaded.set(channelId, {
|
|
193
|
+
userId: reader.id,
|
|
194
|
+
username: reader.username,
|
|
195
|
+
markerId: marker,
|
|
196
|
+
facts,
|
|
197
|
+
newestId: messages.at(-1)?.id ?? previous?.newestId ?? null,
|
|
198
|
+
loadedFor: wanted,
|
|
199
|
+
failedAt: null,
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
if (generation !== this.generation) return;
|
|
203
|
+
this.loaded.set(channelId, {
|
|
204
|
+
userId: reader.id,
|
|
205
|
+
username: reader.username,
|
|
206
|
+
markerId: marker,
|
|
207
|
+
facts: previous?.facts ?? new Map(),
|
|
208
|
+
newestId: previous?.newestId ?? null,
|
|
209
|
+
loadedFor: previous?.loadedFor ?? 0,
|
|
210
|
+
failedAt: Date.now(),
|
|
211
|
+
});
|
|
212
|
+
} finally {
|
|
213
|
+
if (generation === this.generation) this.inFlight.delete(channelId);
|
|
214
|
+
}
|
|
215
|
+
this.options.onChange();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The one other person in a direct message, when the channel says who. */
|
|
220
|
+
function directPerson(channel: ChatChannel | undefined): string | null {
|
|
221
|
+
return channel?.kind === "direct" ? channel.dmUser?.id ?? null : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Every message after `after`, oldest first. The server answers a request
|
|
226
|
+
* with the newest ones, so a long run reads back from the newest page to the
|
|
227
|
+
* one that reaches `after`.
|
|
228
|
+
*/
|
|
229
|
+
async function readAfter(channelId: string, after: string): Promise<ChatMessage[]> {
|
|
230
|
+
const pages: ChatMessage[][] = [];
|
|
231
|
+
let before: string | undefined;
|
|
232
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
233
|
+
const messages = await apiClient.getMessages(channelId, { after, before, limit: PAGE_SIZE });
|
|
234
|
+
if (messages.length === 0) break;
|
|
235
|
+
pages.unshift(messages);
|
|
236
|
+
if (messages.length < PAGE_SIZE) break;
|
|
237
|
+
before = messages[0]!.id;
|
|
238
|
+
}
|
|
239
|
+
return pages.flat();
|
|
240
|
+
}
|
|
@@ -23,6 +23,7 @@ interface ChatControllerViewOptions {
|
|
|
23
23
|
getListenerSnapshot: (channelId: string) => ChatControllerSnapshot;
|
|
24
24
|
getVisibleMessages: (channelId: string) => ChatMessage[];
|
|
25
25
|
getUnreadMentionCount: (channelId: string) => number;
|
|
26
|
+
getUnreadPeople: () => ChatControllerSnapshot["unreadPeople"];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface ChannelListenerEntry {
|
|
@@ -78,6 +79,7 @@ export class ChatControllerView {
|
|
|
78
79
|
draftAttachments: channel.draftAttachments.map(publicDraftAttachment),
|
|
79
80
|
replyToId: channel.replyToId,
|
|
80
81
|
unreadMentionCount: this.options.getUnreadMentionCount(normalizedChannelId),
|
|
82
|
+
unreadPeople: this.options.getUnreadPeople(),
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -157,7 +157,7 @@ export const chatModule: PluginModule = {
|
|
|
157
157
|
chatController.attachPersistence(ctx.persistence, ctx.resume);
|
|
158
158
|
chatSidebarStore.attach(ctx.persistence);
|
|
159
159
|
chatHandlePromptStore.attach(ctx.persistence);
|
|
160
|
-
// The desktop app's Dock badge
|
|
160
|
+
// The desktop app's Dock badge counts the people who wrote to you or tagged you, plus the missed notices; other hosts have no badge.
|
|
161
161
|
disposeUnreadBadge?.();
|
|
162
162
|
const badgeHost = getAppBadgeHost();
|
|
163
163
|
disposeUnreadBadge = badgeHost
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../../../api-client";
|
|
2
2
|
import { pngDataUrl } from "../../../../shares/pane-image";
|
|
3
|
-
import { registerPaneChatSharing } from "../../../../shares/chat";
|
|
3
|
+
import { registerPaneChatSharing, type ChatPostPurpose } from "../../../../shares/chat";
|
|
4
4
|
import type { GloomPluginContext } from "../../../../types/plugin";
|
|
5
5
|
import { requestAuthDialog } from "../../../../account/auth-dialog";
|
|
6
6
|
import { MAX_CHAT_IMAGE_BYTES } from "../attachments/model";
|
|
@@ -11,6 +11,11 @@ import { defaultShareTarget, shareTargets } from "./targets";
|
|
|
11
11
|
|
|
12
12
|
type ShareContext = Pick<GloomPluginContext, "notify" | "createPaneFromTemplate" | "configState">;
|
|
13
13
|
const INCLUDE_LINK_KEY = "share.includeLink";
|
|
14
|
+
/** How the sign-in and verify prompts finish "Sign in to ...". */
|
|
15
|
+
const PURPOSE_TEXT: Record<ChatPostPurpose, string> = {
|
|
16
|
+
share: "share to chat",
|
|
17
|
+
"bug-report": "report a bug",
|
|
18
|
+
};
|
|
14
19
|
|
|
15
20
|
function errorText(error: unknown, fallback: string): string {
|
|
16
21
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
@@ -18,12 +23,13 @@ function errorText(error: unknown, fallback: string): string {
|
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Whether the account can post, saying in one line what is missing when it
|
|
21
|
-
* cannot: a sign-in (which then carries on with the share
|
|
26
|
+
* cannot: a sign-in (which then carries on with the share or the bug report)
|
|
27
|
+
* or a verified email.
|
|
22
28
|
*/
|
|
23
|
-
function readyToPost(ctx: ShareContext, retry: () => void): boolean {
|
|
29
|
+
function readyToPost(ctx: ShareContext, retry: () => void, purpose: ChatPostPurpose): boolean {
|
|
24
30
|
if (!apiClient.isSignedIn()) {
|
|
25
31
|
ctx.notify({
|
|
26
|
-
body:
|
|
32
|
+
body: `Sign in to ${PURPOSE_TEXT[purpose]}.`,
|
|
27
33
|
type: "info",
|
|
28
34
|
action: { label: "Sign in", onClick: () => { requestAuthDialog({ onSignedIn: retry }); } },
|
|
29
35
|
});
|
|
@@ -31,7 +37,7 @@ function readyToPost(ctx: ShareContext, retry: () => void): boolean {
|
|
|
31
37
|
}
|
|
32
38
|
if (!apiClient.isVerified()) {
|
|
33
39
|
ctx.notify({
|
|
34
|
-
body:
|
|
40
|
+
body: `Verify your email to ${PURPOSE_TEXT[purpose]}.`,
|
|
35
41
|
type: "info",
|
|
36
42
|
action: {
|
|
37
43
|
label: "Resend link",
|
|
@@ -55,10 +61,14 @@ async function loadTargets() {
|
|
|
55
61
|
return shareTargets(list ? buildChatConversations(list.states, list.userId) : []);
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
function openMessage(ctx: ShareContext, channelId: string, messageId: string): void {
|
|
65
|
+
ctx.createPaneFromTemplate("new-chat-pane", { arg: channelId, values: { messageId } });
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
/** Chat contributes a destination, never another menu entry, command or key. */
|
|
59
69
|
export function installPaneChatSharing(ctx: ShareContext): () => void {
|
|
60
70
|
return registerPaneChatSharing({
|
|
61
|
-
ready: (retry) => readyToPost(ctx, retry),
|
|
71
|
+
ready: (retry, purpose = "share") => readyToPost(ctx, retry, purpose),
|
|
62
72
|
targets: loadTargets,
|
|
63
73
|
defaultChannel: (targets) => defaultShareTarget(targets, ctx.configState.get<string>(LAST_VISITED_CHAT_CHANNEL_KEY)),
|
|
64
74
|
includeLink: () => ctx.configState.get<boolean>(INCLUDE_LINK_KEY) !== false,
|
|
@@ -78,10 +88,9 @@ export function installPaneChatSharing(ctx: ShareContext): () => void {
|
|
|
78
88
|
ctx.notify({
|
|
79
89
|
body: `Posted to ${label}`,
|
|
80
90
|
type: "success",
|
|
81
|
-
action: { label: "Open", onClick: () => {
|
|
82
|
-
ctx.createPaneFromTemplate("new-chat-pane", { arg: result.channelId, values: { messageId: result.messageId } });
|
|
83
|
-
} },
|
|
91
|
+
action: { label: "Open", onClick: () => { openMessage(ctx, result.channelId, result.messageId); } },
|
|
84
92
|
});
|
|
85
93
|
},
|
|
94
|
+
openMessage: (channelId, messageId) => openMessage(ctx, channelId, messageId),
|
|
86
95
|
});
|
|
87
96
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import type { ChatChannel } from "../../../api-client";
|
|
1
2
|
import { appBadgeLabel } from "../../../notifications/app-badge";
|
|
2
3
|
import type { NotificationLogEntry } from "../../../notifications/notification-log";
|
|
3
4
|
import { unreadNotificationLogCount } from "../../../notifications/notification-log";
|
|
4
5
|
import type { ChatControllerSnapshot } from "./controller/state";
|
|
5
6
|
|
|
6
7
|
type UnreadSnapshot = Pick<ChatControllerSnapshot, "channelStates" | "hasSavedSession" | "user">;
|
|
8
|
+
type BadgeSnapshot = UnreadSnapshot & Pick<ChatControllerSnapshot, "channels" | "unreadPeople">;
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* What the Unread list holds as unread, and what the status bar counts: the
|
|
@@ -16,9 +18,34 @@ export function unreadTotal(snapshot: UnreadSnapshot, notices: readonly Notifica
|
|
|
16
18
|
return chat + unreadNotificationLogCount(notices);
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
/** DMs and group chats: what only the people in them read, and every message in them is for you. */
|
|
22
|
+
function isPeopleConversation(channel: ChatChannel | undefined): boolean {
|
|
23
|
+
return channel?.kind === "direct" || channel?.kind === "group";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the app badge counts: people, not messages. Everyone who sent you an
|
|
28
|
+
* unread DM or group message, and everyone whose unread message in any other
|
|
29
|
+
* channel mentions you, each once however many messages they sent or where;
|
|
30
|
+
* then every missed notice not yet read. Untagged channel messages add
|
|
31
|
+
* nothing. Zero signed out. The status bar keeps counting every unread
|
|
32
|
+
* message ({@link unreadTotal}).
|
|
33
|
+
*/
|
|
34
|
+
export function unreadBadgeCount(snapshot: BadgeSnapshot, notices: readonly NotificationLogEntry[]): number {
|
|
35
|
+
if (!snapshot.user && !snapshot.hasSavedSession) return 0;
|
|
36
|
+
const channels = new Map(snapshot.channels.map((channel) => [channel.id, channel]));
|
|
37
|
+
const people = new Set<string>();
|
|
38
|
+
for (const [channelId, who] of Object.entries(snapshot.unreadPeople)) {
|
|
39
|
+
for (const id of isPeopleConversation(channels.get(channelId)) ? who.senders : who.mentioners) people.add(id);
|
|
40
|
+
}
|
|
41
|
+
return people.size + unreadNotificationLogCount(notices);
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
interface UnreadBadgeOptions {
|
|
20
45
|
controller: {
|
|
21
|
-
subscribe(listener: (snapshot:
|
|
46
|
+
subscribe(listener: (snapshot: BadgeSnapshot) => void): () => void;
|
|
47
|
+
/** Starts reading who the unread messages are from; the returned stop ends it. */
|
|
48
|
+
trackUnreadPeople(): () => void;
|
|
22
49
|
};
|
|
23
50
|
log: {
|
|
24
51
|
get(): readonly NotificationLogEntry[];
|
|
@@ -28,14 +55,16 @@ interface UnreadBadgeOptions {
|
|
|
28
55
|
}
|
|
29
56
|
|
|
30
57
|
/**
|
|
31
|
-
* Keeps the app badge on
|
|
32
|
-
* label, none while it stays the same (past 99 it reads `99+` however
|
|
33
|
-
* more arrive), and a clear when stopped, as when the plugin is turned
|
|
34
|
-
* Signing out zeroes the count, which clears it.
|
|
58
|
+
* Keeps the app badge on {@link unreadBadgeCount}: one `setBadge` per change
|
|
59
|
+
* of the label, none while it stays the same (past 99 it reads `99+` however
|
|
60
|
+
* many more arrive), and a clear when stopped, as when the plugin is turned
|
|
61
|
+
* off. Signing out zeroes the count, which clears it. While it runs the
|
|
62
|
+
* controller reads who the unread messages are from; a host with no badge
|
|
63
|
+
* never starts it, so reads nothing extra. Returns the stop.
|
|
35
64
|
*/
|
|
36
65
|
export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptions): () => void {
|
|
37
66
|
let applied = "";
|
|
38
|
-
let snapshot:
|
|
67
|
+
let snapshot: BadgeSnapshot | null = null;
|
|
39
68
|
|
|
40
69
|
const apply = (label: string) => {
|
|
41
70
|
if (label === applied) return;
|
|
@@ -47,9 +76,10 @@ export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptio
|
|
|
47
76
|
}
|
|
48
77
|
};
|
|
49
78
|
const update = () => {
|
|
50
|
-
if (snapshot) apply(appBadgeLabel(
|
|
79
|
+
if (snapshot) apply(appBadgeLabel(unreadBadgeCount(snapshot, log.get())));
|
|
51
80
|
};
|
|
52
81
|
|
|
82
|
+
const stopTracking = controller.trackUnreadPeople();
|
|
53
83
|
const stopController = controller.subscribe((next) => {
|
|
54
84
|
snapshot = next;
|
|
55
85
|
update();
|
|
@@ -59,6 +89,7 @@ export function watchUnreadBadge({ controller, log, setBadge }: UnreadBadgeOptio
|
|
|
59
89
|
return () => {
|
|
60
90
|
stopController();
|
|
61
91
|
stopLog();
|
|
92
|
+
stopTracking();
|
|
62
93
|
snapshot = null;
|
|
63
94
|
apply("");
|
|
64
95
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { paneFunctionLabel } from "../../../../ask-gloom/pane-context";
|
|
1
2
|
import type { AskGloomPaneAttachment } from "../../../../ask-gloom/pane-request";
|
|
2
3
|
import type { ASKGSessionContext, JsonValue } from "./protocol";
|
|
3
4
|
|
|
@@ -38,9 +39,7 @@ function sharesPaneContents(attached: ASKGAttachedPane): boolean {
|
|
|
38
39
|
|
|
39
40
|
/** What the chip says: the function and what it shows, such as `DES AAPL`. */
|
|
40
41
|
export function attachedPaneLabel(pane: AskGloomPaneAttachment): string {
|
|
41
|
-
|
|
42
|
-
if (!pane.code) return title;
|
|
43
|
-
return title.split(/\s+/)[0]?.toUpperCase() === pane.code ? title : `${pane.code} ${title}`;
|
|
42
|
+
return paneFunctionLabel(pane);
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
/** Drops whole lines from the end, then characters, until `fits` holds. */
|
|
@@ -3,7 +3,7 @@ import { futuresSpecLines } from "../../../market-data/futures-contract";
|
|
|
3
3
|
import type { HeadlessPaneColumn, HeadlessPaneDefinition, HeadlessPaneEntry, HeadlessPaneFreshness } from "../../../types/plugin";
|
|
4
4
|
import { newestReportTime, oldestReportTime } from "../../../utils/utc-time";
|
|
5
5
|
import { fetchFuturesCurve, loadCurveSpot, loadFuturesCurveAsOf } from "./client";
|
|
6
|
-
import { basisSpotSymbol,
|
|
6
|
+
import { basisSpotSymbol, BASIS_CONVENTION, blankBasisNote, catalogueCurveUnit, curveAsOfDate, curveBasisPercent, curveBasisRows, curveChangeText, curveContractCode, curvePrice, curveRoundedPrice, curveSpotLabel, curveSpotSource, curveSpreadLabel, curveTimestamp, curveUnitLabel, normalizeCurveRoot, shortDatedBasisNote, thinContractCount, unsupportedCurveRootMessage, type ContractBasis } from "./model";
|
|
7
7
|
|
|
8
8
|
/** The root a report shows when none is given. */
|
|
9
9
|
const DEFAULT_ROOT = "ES";
|
|
@@ -73,7 +73,9 @@ function specEntries(data: FuturesCurvePayload): HeadlessPaneEntry[] {
|
|
|
73
73
|
|
|
74
74
|
export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
75
75
|
discovery: { aliases: ["CTM"], dataRequirements: ["Gloom Cloud futures curve endpoint"],
|
|
76
|
-
limitations: ["Listed-contract catalogues can be incomplete.", "Ghosts and percentiles use the same listed contracts.", "Cboe VIX is daily settlement."
|
|
76
|
+
limitations: ["Listed-contract catalogues can be incomplete.", "Ghosts and percentiles use the same listed contracts.", "Cboe VIX is daily settlement.",
|
|
77
|
+
"Ann Basis is simple actual/365 on the latest trade against the spot quote, and noisy under 30 days to expiry."],
|
|
78
|
+
intents: ["futures basis trade", "cash and carry", "basis against spot"] },
|
|
77
79
|
shape: "bundle", argument: { kind: "free-text", optional: true, placeholder: "root", description: "FUT root such as CL, ES, ZN or VX, or a CME crypto root (BTC, ETH, SOL, XRP). Defaults to ES." },
|
|
78
80
|
// Only the pane reads the tab; the report always carries both.
|
|
79
81
|
options: [{ key: "tab", description: "Curve or the full contract table.", type: "enum",
|
|
@@ -93,11 +95,15 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
93
95
|
date ? loadFuturesCurveAsOf(root, date, ctx.apiClient) : fetchFuturesCurve(root, ctx.apiClient),
|
|
94
96
|
spotSymbol ? loadCurveSpot(spotSymbol, ctx.marketData, now) : null,
|
|
95
97
|
]);
|
|
96
|
-
const bases = spot ? data.contracts
|
|
98
|
+
const bases = spot ? curveBasisRows(data.contracts, spot) : null;
|
|
97
99
|
const unit = curveUnitLabel(data);
|
|
98
100
|
const terms = specEntries(data);
|
|
101
|
+
const source = spot ? curveSpotSource(spot) : null;
|
|
99
102
|
const notices = [...args.argument ? [] : [`Showing ${DEFAULT_ROOT}. Try fn CTM ${EXAMPLE_ROOT}.`],
|
|
100
|
-
...spot ? [spot.status === "ok" ? `Basis against ${curveSpotLabel(spot, root, now)}.` : `Basis blank: ${spot.reason}.`] : []];
|
|
103
|
+
...spot ? [spot.status === "ok" ? `Basis against ${curveSpotLabel(spot, root, now)}${source ? ` (${source})` : ""}; not the CME reference rate.` : `Basis blank: ${spot.reason}.`] : []];
|
|
104
|
+
// The footnotes under the table: the convention, why a cell is blank, and where a short-dated rate is not to be trusted.
|
|
105
|
+
// Against a spot that is not usable every cell is blank for the spot's reason, which the notice above already gives.
|
|
106
|
+
const notes = spot && bases ? [BASIS_CONVENTION, ...spot.status === "ok" ? [blankBasisNote(data.contracts, bases), shortDatedBasisNote(data.contracts, bases, spot)].filter((note): note is string => !!note) : []] : [];
|
|
101
107
|
// Dated by the contracts' own quotes; the same-contract history rows are a year of context, not observations.
|
|
102
108
|
const quoted = data.contracts.map((row) => row.asOf);
|
|
103
109
|
const freshness: HeadlessPaneFreshness = date
|
|
@@ -109,7 +115,7 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
109
115
|
sections: [
|
|
110
116
|
{ title: "Contracts", columns: contractColumns(root, data.settlementDate ?? null,
|
|
111
117
|
{ basis: !!spot, last: data.source !== "cboe", notice: data.contracts.some((row) => row.firstNotice) }),
|
|
112
|
-
rows: data.contracts.map((row
|
|
118
|
+
rows: data.contracts.map((row) => contractRow(row, data, root, !!date, bases?.get(row.symbol))) },
|
|
113
119
|
{ title: "Front spread", rows: [{ label: curveSpreadLabel(data.slope), ...data.slope, value: curveRoundedPrice(data.slope.value, root),
|
|
114
120
|
annualizedRollYield: data.slope.annualizedRollYield == null ? null : Number(data.slope.annualizedRollYield.toFixed(2)),
|
|
115
121
|
percentile: rank(data.slope.percentile), rollPercentile: rank(data.slope.rollPercentile) }] },
|
|
@@ -117,7 +123,8 @@ export const futuresCurveHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
|
117
123
|
...data.ghosts.map((ghost) => ({ title: `${ghost.label} same-contract history`, rows: ghost.points.map((point) => ({ ...point })) })),
|
|
118
124
|
],
|
|
119
125
|
errors: data.gaps,
|
|
120
|
-
|
|
126
|
+
...(notes.length ? { notes } : {}),
|
|
127
|
+
metadata: { ...data, quoteUnit: unit, ...(spot && bases ? { spot, basisBlankedThin: thinContractCount(bases.values()) } : {}), ...(args.argument ? {} : { defaultArgument: DEFAULT_ROOT }),
|
|
121
128
|
...(notices.length ? { notices } : {}),
|
|
122
129
|
complete: data.status === "available", percentileBasis: "Same-contract observations within one year; actual sample start/end retained" },
|
|
123
130
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { PluginModule } from "../plugin-module";
|
|
2
2
|
import { futuresCurveCache } from "./client";
|
|
3
3
|
import { futuresCurveHeadless } from "./headless";
|
|
4
|
-
import { CURVE_HORIZONS, CURVE_ROOTS, curveRootForTicker, DEFAULT_CURVE_HORIZON, normalizeCurveRoot } from "./model";
|
|
4
|
+
import { BASIS_FORMULA, CURVE_HORIZONS, CURVE_ROOTS, curveRootForTicker, DEFAULT_CURVE_HORIZON, normalizeCurveRoot } from "./model";
|
|
5
5
|
import { FuturesCurvePane } from "./pane";
|
|
6
6
|
|
|
7
7
|
export const futuresCurveModule: PluginModule = {
|
|
@@ -17,8 +17,8 @@ export const futuresCurveModule: PluginModule = {
|
|
|
17
17
|
}),
|
|
18
18
|
}],
|
|
19
19
|
paneTemplates: [{ id: "futures-curve-pane", paneId: "futures-curve", label: "Futures Curve",
|
|
20
|
-
description:
|
|
21
|
-
keywords: ["ctm", "futures", "curve", "term structure", "contango", "backwardation", "roll", "vix", "crude oil", "natural gas"],
|
|
20
|
+
description: `Listed futures contracts, historical curves, roll yield and open interest including VIX and CME crypto futures, which add basis against spot: Ann Basis = ${BASIS_FORMULA}, simple actual/365.`,
|
|
21
|
+
keywords: ["ctm", "futures", "curve", "term structure", "contango", "backwardation", "roll", "basis", "basis trade", "carry", "annualised basis", "vix", "crude oil", "natural gas"],
|
|
22
22
|
shortcut: { prefix: "CTM", aliases: ["CT"], argKind: "text", argPlaceholder: "root", argOptional: true,
|
|
23
23
|
argOptions: () => CURVE_ROOTS },
|
|
24
24
|
headless: futuresCurveHeadless,
|
|
@@ -282,6 +282,10 @@ export interface CurveSpot {
|
|
|
282
282
|
asOf: string | null;
|
|
283
283
|
status: "ok" | "stale" | "missing";
|
|
284
284
|
reason: string | null;
|
|
285
|
+
/** The listing the quote path reports for the pair (`CCC`), when it reports one. */
|
|
286
|
+
venue?: string;
|
|
287
|
+
/** Whether the quote is real-time or held back, as the quote path reports it. */
|
|
288
|
+
feed?: "real-time" | "delayed";
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
export interface SpotQuote {
|
|
@@ -289,6 +293,8 @@ export interface SpotQuote {
|
|
|
289
293
|
lastUpdated: number;
|
|
290
294
|
stale?: boolean;
|
|
291
295
|
dataSource?: string;
|
|
296
|
+
listingExchangeName?: string;
|
|
297
|
+
exchangeName?: string;
|
|
292
298
|
}
|
|
293
299
|
|
|
294
300
|
const positive = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
@@ -303,16 +309,20 @@ function quoteAge(ms: number): string {
|
|
|
303
309
|
/** Judges one spot quote at `now`: usable, older than the rule allows, or absent. */
|
|
304
310
|
export function curveSpot(symbol: string, quote: SpotQuote | null | undefined, now: number): CurveSpot {
|
|
305
311
|
if (!quote || !positive(quote.price)) return { symbol, price: null, asOf: null, status: "missing", reason: `no ${symbol} quote` };
|
|
312
|
+
// What the quote path says about the quote itself, kept beside the price so the footer can name it.
|
|
313
|
+
const venue = (quote.listingExchangeName || quote.exchangeName)?.trim();
|
|
314
|
+
const feed: CurveSpot["feed"] = quote.dataSource === "live" ? "real-time" : quote.dataSource === "delayed" ? "delayed" : undefined;
|
|
315
|
+
const source = { ...venue ? { venue } : {}, ...feed ? { feed } : {} };
|
|
306
316
|
const time = quote.lastUpdated;
|
|
307
317
|
if (!Number.isFinite(time) || time <= 0) {
|
|
308
|
-
return { symbol, price: quote.price, asOf: null, status: "stale", reason: `${symbol} quote has no time
|
|
318
|
+
return { symbol, price: quote.price, asOf: null, status: "stale", reason: `${symbol} quote has no time`, ...source };
|
|
309
319
|
}
|
|
310
320
|
const asOf = new Date(time).toISOString().replace(".000Z", "Z");
|
|
311
321
|
const age = now - time;
|
|
312
322
|
const limit = SPOT_STALE_MS + (quote.dataSource === "delayed" ? DELAYED_SPOT_MS : 0);
|
|
313
|
-
if (age > limit) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is ${quoteAge(age)} old
|
|
314
|
-
if (quote.stale === true) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is stale
|
|
315
|
-
return { symbol, price: quote.price, asOf, status: "ok", reason: null };
|
|
323
|
+
if (age > limit) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is ${quoteAge(age)} old`, ...source };
|
|
324
|
+
if (quote.stale === true) return { symbol, price: quote.price, asOf, status: "stale", reason: `${symbol} quote is stale`, ...source };
|
|
325
|
+
return { symbol, price: quote.price, asOf, status: "ok", reason: null, ...source };
|
|
316
326
|
}
|
|
317
327
|
|
|
318
328
|
/** How far the contract trades over (positive) or under spot, in percent. */
|
|
@@ -400,6 +410,56 @@ export function thinContractsNotice(count: number): string {
|
|
|
400
410
|
return `Basis blank on ${count} thin contract${count === 1 ? "" : "s"} (last print over 1h before spot)`;
|
|
401
411
|
}
|
|
402
412
|
|
|
413
|
+
/** What the quote path reports of the spot itself, as the footer names it: `CCC, real-time`. Null when it reports neither. */
|
|
414
|
+
export function curveSpotSource(spot: Pick<CurveSpot, "venue" | "feed">): string | null {
|
|
415
|
+
const parts = [spot.venue, spot.feed].filter((part): part is string => !!part);
|
|
416
|
+
return parts.length ? parts.join(", ") : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** What Ann Basis is: Vs Spot is the same ratio before it is annualised. */
|
|
420
|
+
export const BASIS_FORMULA = "(Last / spot - 1) × 365 / days to expiry";
|
|
421
|
+
/** The two basis columns' convention in one line. */
|
|
422
|
+
export const BASIS_CONVENTION = `Ann Basis = ${BASIS_FORMULA}: simple actual/365, the latest trade against the spot quote.`;
|
|
423
|
+
|
|
424
|
+
/** Why one priced-or-not contract has no basis against a usable spot, in the order `contractBasis` decides it. */
|
|
425
|
+
function blankReason(row: Pick<FuturesContract, "price" | "stale">, basis: ContractBasis): string | null {
|
|
426
|
+
if (basis.vsSpotPct != null) return null;
|
|
427
|
+
if (!positive(row.price)) return "no price";
|
|
428
|
+
if (row.stale) return "price over two sessions old";
|
|
429
|
+
return basis.thin ? "last trade over 1h before the spot quote" : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The contracts whose basis cells are blank against a good spot, grouped by cause and named, so a blank is never
|
|
434
|
+
* unexplained: the far months of a curve print rarely, and a print an hour before the spot is not read against it.
|
|
435
|
+
*/
|
|
436
|
+
export function blankBasisNote(rows: readonly Pick<FuturesContract, "symbol" | "price" | "stale">[], basis: CurveBasisRows): string | null {
|
|
437
|
+
const groups = new Map<string, string[]>();
|
|
438
|
+
for (const row of rows) {
|
|
439
|
+
const cell = basis.get(row.symbol);
|
|
440
|
+
const reason = cell ? blankReason(row, cell) : null;
|
|
441
|
+
if (reason) groups.set(reason, [...groups.get(reason) ?? [], curveContractCode(row.symbol)]);
|
|
442
|
+
}
|
|
443
|
+
return groups.size ? `Basis blank for ${[...groups].map(([reason, codes]) => `${codes.join(", ")} (${reason})`).join("; ")}.` : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Under this many days to expiry a few basis points of spot move the annualised rate by whole points. */
|
|
447
|
+
const SHORT_DATED_DAYS = 30;
|
|
448
|
+
/** The noise the note sizes the swing against: ten basis points of spot, a quote's usual gap to the contract's print. */
|
|
449
|
+
const SPOT_NOISE_PCT = 0.1;
|
|
450
|
+
|
|
451
|
+
/** The note for contracts showing an annualised basis inside `SHORT_DATED_DAYS`: what 10 bp of spot noise does to each. */
|
|
452
|
+
export function shortDatedBasisNote(rows: readonly Pick<FuturesContract, "symbol" | "expiration">[], basis: CurveBasisRows, spot: CurveSpot | null): string | null {
|
|
453
|
+
if (!spot || spot.status !== "ok" || spot.asOf == null) return null;
|
|
454
|
+
const spotTime = Date.parse(spot.asOf);
|
|
455
|
+
const near = rows.flatMap((row) => {
|
|
456
|
+
if (basis.get(row.symbol)?.annualisedBasisPct == null) return [];
|
|
457
|
+
const days = daysToExpiry(spotTime, row.expiration);
|
|
458
|
+
return days != null && days > 0 && days < SHORT_DATED_DAYS ? [`${curveContractCode(row.symbol)} (${days}d) by ±${(SPOT_NOISE_PCT * 365 / days).toFixed(1)} points`] : [];
|
|
459
|
+
});
|
|
460
|
+
return near.length ? `Ann Basis is noisy under ${SHORT_DATED_DAYS} days to expiry: 10 bp of spot noise moves ${near.join(", ")}.` : null;
|
|
461
|
+
}
|
|
462
|
+
|
|
403
463
|
/** A past date for the as-of view: empty (or "latest") for the live curve, else YYYY-MM-DD no later than today. */
|
|
404
464
|
export function curveAsOfDate(value: unknown, now = new Date()): string {
|
|
405
465
|
const date = typeof value === "string" ? value.trim() : "";
|