gloomberb 0.7.0 → 0.7.2
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/src/app.tsx +149 -51
- package/src/capabilities/factories.ts +3 -0
- package/src/components/chart/chart-renderer.ts +24 -12
- package/src/components/chart/chart.test.ts +43 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +15 -12
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +2 -0
- package/src/components/chart/stock-chart.tsx +31 -20
- package/src/components/chart/time-axis-label.tsx +78 -1
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +114 -31
- package/src/components/command-bar/command-bar.tsx +587 -335
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +1 -2
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -1
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +5 -2
- package/src/components/layout/header.tsx +2 -0
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +198 -0
- package/src/components/layout/shell.tsx +33 -2
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/speedometer-gauge.tsx +29 -25
- package/src/components/table-view-shared.tsx +85 -3
- package/src/components/ticker-list-table-view.tsx +16 -2
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/data-table.tsx +12 -9
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/table-layout.test.ts +27 -1
- package/src/components/ui/table-layout.ts +39 -1
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +68 -1
- package/src/core/state/app-state.ts +4 -14
- package/src/market-data/coordinator.test.ts +75 -0
- package/src/market-data/coordinator.ts +218 -48
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -0
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/chat-controller.test.ts +83 -1
- package/src/plugins/builtin/chat-controller.ts +491 -243
- package/src/plugins/builtin/chat.test.tsx +425 -7
- package/src/plugins/builtin/chat.tsx +1109 -211
- package/src/plugins/builtin/cloud-tweets.tsx +1 -0
- package/src/plugins/builtin/fear-greed/index.tsx +148 -39
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +54 -14
- package/src/plugins/builtin/market-movers/index.tsx +12 -18
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/notes.tsx +1 -0
- package/src/plugins/builtin/options.tsx +2 -16
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +159 -12
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/plugin-runtime.test.tsx +34 -0
- package/src/plugins/plugin-runtime.tsx +7 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/registry.ts +3 -3
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +1 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +111 -96
- package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
- package/src/renderers/electrobun/view/input-host.tsx +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +6 -8
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +101 -5
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -11
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +20 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.ts +13 -10
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +64 -3
- package/src/theme/theme-context.tsx +26 -0
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -1
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -1
- package/src/ui/host.tsx +14 -2
- package/src/ui/index.tsx +1 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
package/src/utils/api-client.ts
CHANGED
|
@@ -12,10 +12,12 @@ import type {
|
|
|
12
12
|
import type { InstrumentSearchResult } from "../types/instrument";
|
|
13
13
|
import { debugLog } from "./debug-log";
|
|
14
14
|
import { canonicalExchange, normalizeSymbol, publicTickerKey } from "./exchanges";
|
|
15
|
+
import { httpFetch } from "./http-transport";
|
|
15
16
|
import { normalizeTimestamp } from "./timestamp";
|
|
16
17
|
|
|
17
18
|
const DEFAULT_API_URL = "https://api.gloom.sh";
|
|
18
19
|
const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
|
|
20
|
+
const QUOTE_SUBSCRIPTION_FLUSH_MS = 25;
|
|
19
21
|
const cloudApiLog = debugLog.createLogger("cloud-api");
|
|
20
22
|
const HARD_SESSION_INVALID_PATTERNS = [
|
|
21
23
|
/\b(user|account)\b.*\b(not found|deleted|removed|disabled|deactivated|suspended)\b/i,
|
|
@@ -26,10 +28,10 @@ const HARD_SESSION_INVALID_PATTERNS = [
|
|
|
26
28
|
type CloudApiResponse = Pick<Response, "ok" | "status" | "headers" | "text">;
|
|
27
29
|
type CloudApiFetchTransport = (url: string, init?: RequestInit) => Promise<CloudApiResponse>;
|
|
28
30
|
|
|
29
|
-
let cloudApiFetchTransport: CloudApiFetchTransport =
|
|
31
|
+
let cloudApiFetchTransport: CloudApiFetchTransport = httpFetch;
|
|
30
32
|
|
|
31
33
|
export function setCloudApiFetchTransport(transport: CloudApiFetchTransport | null): void {
|
|
32
|
-
cloudApiFetchTransport = transport ??
|
|
34
|
+
cloudApiFetchTransport = transport ?? httpFetch;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
function getCloudApiBaseUrl(): string {
|
|
@@ -54,7 +56,6 @@ export interface ChatMessage {
|
|
|
54
56
|
export interface ChatChannel {
|
|
55
57
|
id: string;
|
|
56
58
|
name: string;
|
|
57
|
-
description: string | null;
|
|
58
59
|
created_at: string;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -306,6 +307,23 @@ export interface CloudMarketResponse<T> {
|
|
|
306
307
|
};
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
export interface CloudMarketBatchTarget {
|
|
311
|
+
symbol: string;
|
|
312
|
+
exchange?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface CloudMarketBatchItem<T> {
|
|
316
|
+
symbol: string;
|
|
317
|
+
exchange: string;
|
|
318
|
+
status: CloudMarketStatus;
|
|
319
|
+
data: T | null;
|
|
320
|
+
reasonCode?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface CloudMarketBatchPayload<T> {
|
|
324
|
+
items: Array<CloudMarketBatchItem<T>>;
|
|
325
|
+
}
|
|
326
|
+
|
|
309
327
|
export interface CloudVerificationResponse {
|
|
310
328
|
sent: boolean;
|
|
311
329
|
email?: string;
|
|
@@ -392,6 +410,9 @@ class GloomApiClient {
|
|
|
392
410
|
private readonly channelListeners = new Map<string, Set<ChannelListener>>();
|
|
393
411
|
private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
|
|
394
412
|
private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
|
|
413
|
+
private readonly pendingQuoteSubscribes = new Map<string, QuoteStreamTarget>();
|
|
414
|
+
private readonly pendingQuoteUnsubscribes = new Map<string, QuoteStreamTarget>();
|
|
415
|
+
private quoteSubscriptionFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
395
416
|
|
|
396
417
|
constructor() {
|
|
397
418
|
this.baseUrl = getCloudApiBaseUrl();
|
|
@@ -467,7 +488,7 @@ class GloomApiClient {
|
|
|
467
488
|
throw new Error(message);
|
|
468
489
|
}
|
|
469
490
|
|
|
470
|
-
private extractSessionCookie(res:
|
|
491
|
+
private extractSessionCookie(res: CloudApiResponse): void {
|
|
471
492
|
const setCookie = res.headers.getSetCookie?.() ?? [];
|
|
472
493
|
const fallbackHeader = res.headers.get("set-cookie");
|
|
473
494
|
if (fallbackHeader) {
|
|
@@ -654,6 +675,62 @@ class GloomApiClient {
|
|
|
654
675
|
}
|
|
655
676
|
}
|
|
656
677
|
|
|
678
|
+
private scheduleQuoteSubscriptionFlush(): void {
|
|
679
|
+
if (this.quoteSubscriptionFlushTimer) return;
|
|
680
|
+
this.quoteSubscriptionFlushTimer = setTimeout(() => {
|
|
681
|
+
this.quoteSubscriptionFlushTimer = null;
|
|
682
|
+
this.flushQueuedQuoteSubscriptions();
|
|
683
|
+
}, QUOTE_SUBSCRIPTION_FLUSH_MS);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private queueQuoteSubscribes(targets: QuoteStreamTarget[]): void {
|
|
687
|
+
for (const target of targets) {
|
|
688
|
+
const key = marketKey(target.symbol, target.exchange);
|
|
689
|
+
this.pendingQuoteUnsubscribes.delete(key);
|
|
690
|
+
this.pendingQuoteSubscribes.set(key, target);
|
|
691
|
+
}
|
|
692
|
+
this.scheduleQuoteSubscriptionFlush();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private queueQuoteUnsubscribes(targets: QuoteStreamTarget[]): void {
|
|
696
|
+
for (const target of targets) {
|
|
697
|
+
const key = marketKey(target.symbol, target.exchange);
|
|
698
|
+
if (this.pendingQuoteSubscribes.delete(key)) continue;
|
|
699
|
+
this.pendingQuoteUnsubscribes.set(key, target);
|
|
700
|
+
}
|
|
701
|
+
this.scheduleQuoteSubscriptionFlush();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private flushQueuedQuoteSubscriptions(): void {
|
|
705
|
+
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
706
|
+
this.pendingQuoteSubscribes.clear();
|
|
707
|
+
this.pendingQuoteUnsubscribes.clear();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const subscribes = [...this.pendingQuoteSubscribes.values()];
|
|
711
|
+
const unsubscribes = [...this.pendingQuoteUnsubscribes.values()];
|
|
712
|
+
this.pendingQuoteSubscribes.clear();
|
|
713
|
+
this.pendingQuoteUnsubscribes.clear();
|
|
714
|
+
if (subscribes.length > 0) {
|
|
715
|
+
this.sendSocketMessage({
|
|
716
|
+
type: "market.subscribe",
|
|
717
|
+
symbols: subscribes.map((target) => ({
|
|
718
|
+
symbol: target.symbol,
|
|
719
|
+
exchange: target.exchange ?? "",
|
|
720
|
+
})),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
if (unsubscribes.length > 0) {
|
|
724
|
+
this.sendSocketMessage({
|
|
725
|
+
type: "market.unsubscribe",
|
|
726
|
+
symbols: unsubscribes.map((target) => ({
|
|
727
|
+
symbol: target.symbol,
|
|
728
|
+
exchange: target.exchange ?? "",
|
|
729
|
+
})),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
657
734
|
private async handleSocketMessage(raw: string): Promise<void> {
|
|
658
735
|
let parsed: any;
|
|
659
736
|
try {
|
|
@@ -879,13 +956,7 @@ class GloomApiClient {
|
|
|
879
956
|
count: newSubscriptions.length,
|
|
880
957
|
symbols: newSubscriptions.map((target) => marketKey(target.symbol, target.exchange)),
|
|
881
958
|
});
|
|
882
|
-
this.
|
|
883
|
-
type: "market.subscribe",
|
|
884
|
-
symbols: newSubscriptions.map((target) => ({
|
|
885
|
-
symbol: target.symbol,
|
|
886
|
-
exchange: target.exchange ?? "",
|
|
887
|
-
})),
|
|
888
|
-
});
|
|
959
|
+
this.queueQuoteSubscribes(newSubscriptions);
|
|
889
960
|
}
|
|
890
961
|
|
|
891
962
|
return () => {
|
|
@@ -911,13 +982,7 @@ class GloomApiClient {
|
|
|
911
982
|
count: removedTargets.length,
|
|
912
983
|
symbols: removedTargets.map((target) => marketKey(target.symbol, target.exchange)),
|
|
913
984
|
});
|
|
914
|
-
this.
|
|
915
|
-
type: "market.unsubscribe",
|
|
916
|
-
symbols: removedTargets.map((target) => ({
|
|
917
|
-
symbol: target.symbol,
|
|
918
|
-
exchange: target.exchange ?? "",
|
|
919
|
-
})),
|
|
920
|
-
});
|
|
985
|
+
this.queueQuoteUnsubscribes(removedTargets);
|
|
921
986
|
}
|
|
922
987
|
|
|
923
988
|
if (!this.shouldKeepSocketOpen()) {
|
|
@@ -934,6 +999,12 @@ class GloomApiClient {
|
|
|
934
999
|
this.channelListeners.clear();
|
|
935
1000
|
this.quoteListeners.clear();
|
|
936
1001
|
this.quoteTargets.clear();
|
|
1002
|
+
this.pendingQuoteSubscribes.clear();
|
|
1003
|
+
this.pendingQuoteUnsubscribes.clear();
|
|
1004
|
+
if (this.quoteSubscriptionFlushTimer) {
|
|
1005
|
+
clearTimeout(this.quoteSubscriptionFlushTimer);
|
|
1006
|
+
this.quoteSubscriptionFlushTimer = null;
|
|
1007
|
+
}
|
|
937
1008
|
this.reconnectDelayMs = 1000;
|
|
938
1009
|
this.teardownSocket();
|
|
939
1010
|
}
|
|
@@ -953,6 +1024,16 @@ class GloomApiClient {
|
|
|
953
1024
|
return this.request<CloudMarketResponse<CloudQuotePayload>>(`/market/quote?${params.toString()}`);
|
|
954
1025
|
}
|
|
955
1026
|
|
|
1027
|
+
async getCloudQuotesBatch(
|
|
1028
|
+
targets: CloudMarketBatchTarget[],
|
|
1029
|
+
mode: "cache-first" | "refresh" = "cache-first",
|
|
1030
|
+
): Promise<CloudMarketResponse<CloudMarketBatchPayload<CloudQuotePayload>>> {
|
|
1031
|
+
return this.request<CloudMarketResponse<CloudMarketBatchPayload<CloudQuotePayload>>>("/market/quotes/batch", {
|
|
1032
|
+
method: "POST",
|
|
1033
|
+
body: JSON.stringify({ targets, mode }),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
|
|
956
1037
|
async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
|
|
957
1038
|
const params = new URLSearchParams({ symbol });
|
|
958
1039
|
if (exchange) params.set("exchange", exchange);
|
|
@@ -965,6 +1046,22 @@ class GloomApiClient {
|
|
|
965
1046
|
return this.request<CloudMarketResponse<CloudFundamentals>>(`/market/fundamentals?${params.toString()}`);
|
|
966
1047
|
}
|
|
967
1048
|
|
|
1049
|
+
async getCloudFinancials(symbol: string, exchange?: string): Promise<CloudMarketResponse<TickerFinancials>> {
|
|
1050
|
+
const params = new URLSearchParams({ symbol });
|
|
1051
|
+
if (exchange) params.set("exchange", exchange);
|
|
1052
|
+
return this.request<CloudMarketResponse<TickerFinancials>>(`/market/financials?${params.toString()}`);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async getCloudFinancialsBatch(
|
|
1056
|
+
targets: CloudMarketBatchTarget[],
|
|
1057
|
+
mode: "cache-first" | "refresh" = "cache-first",
|
|
1058
|
+
): Promise<CloudMarketResponse<CloudMarketBatchPayload<TickerFinancials>>> {
|
|
1059
|
+
return this.request<CloudMarketResponse<CloudMarketBatchPayload<TickerFinancials>>>("/market/financials/batch", {
|
|
1060
|
+
method: "POST",
|
|
1061
|
+
body: JSON.stringify({ targets, mode }),
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
|
|
968
1065
|
async getCloudHolders(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudHoldersPayload>> {
|
|
969
1066
|
const params = new URLSearchParams({ symbol });
|
|
970
1067
|
if (exchange) params.set("exchange", exchange);
|
|
@@ -1093,6 +1190,10 @@ class GloomApiClient {
|
|
|
1093
1190
|
return this.request<CloudNewsListResponse>(`/news${query ? `?${query}` : ""}`);
|
|
1094
1191
|
}
|
|
1095
1192
|
|
|
1193
|
+
async getCloudNewsStory(storyId: string): Promise<CloudNewsPayload> {
|
|
1194
|
+
return this.request<CloudNewsPayload>(`/news/${encodeURIComponent(storyId)}`);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1096
1197
|
async getCloudTickerTweets(params: {
|
|
1097
1198
|
ticker: string;
|
|
1098
1199
|
limit?: number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createDoubleEscapeCloseState,
|
|
4
|
+
recordDoubleEscapeClose,
|
|
5
|
+
resetDoubleEscapeClose,
|
|
6
|
+
} from "./double-escape-close";
|
|
7
|
+
|
|
8
|
+
describe("double escape close detector", () => {
|
|
9
|
+
test("matches a second escape for the same target inside the threshold", () => {
|
|
10
|
+
const state = createDoubleEscapeCloseState();
|
|
11
|
+
|
|
12
|
+
expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
|
|
13
|
+
expect(recordDoubleEscapeClose(state, "pane:a", 1200)).toBe(true);
|
|
14
|
+
expect(state.targetId).toBe(null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("requires the same target and a recent first escape", () => {
|
|
18
|
+
const state = createDoubleEscapeCloseState();
|
|
19
|
+
|
|
20
|
+
expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
|
|
21
|
+
expect(recordDoubleEscapeClose(state, "pane:b", 1100)).toBe(false);
|
|
22
|
+
expect(recordDoubleEscapeClose(state, "pane:b", 2000)).toBe(false);
|
|
23
|
+
expect(recordDoubleEscapeClose(state, "pane:b", 2100)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("can be reset by non-escape input", () => {
|
|
27
|
+
const state = createDoubleEscapeCloseState();
|
|
28
|
+
|
|
29
|
+
expect(recordDoubleEscapeClose(state, "pane:a", 1000)).toBe(false);
|
|
30
|
+
resetDoubleEscapeClose(state);
|
|
31
|
+
expect(recordDoubleEscapeClose(state, "pane:a", 1100)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const DOUBLE_ESCAPE_CLOSE_MS = 650;
|
|
2
|
+
|
|
3
|
+
export interface DoubleEscapeCloseState {
|
|
4
|
+
lastAt: number;
|
|
5
|
+
targetId: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createDoubleEscapeCloseState(): DoubleEscapeCloseState {
|
|
9
|
+
return {
|
|
10
|
+
lastAt: 0,
|
|
11
|
+
targetId: null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resetDoubleEscapeClose(state: DoubleEscapeCloseState) {
|
|
16
|
+
state.lastAt = 0;
|
|
17
|
+
state.targetId = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function recordDoubleEscapeClose(
|
|
21
|
+
state: DoubleEscapeCloseState,
|
|
22
|
+
targetId: string | null | undefined,
|
|
23
|
+
now: number,
|
|
24
|
+
thresholdMs = DOUBLE_ESCAPE_CLOSE_MS,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (!targetId) {
|
|
27
|
+
resetDoubleEscapeClose(state);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const matched = state.targetId === targetId && now - state.lastAt <= thresholdMs;
|
|
32
|
+
if (matched) {
|
|
33
|
+
resetDoubleEscapeClose(state);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
state.targetId = targetId;
|
|
38
|
+
state.lastAt = now;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
|
2
2
|
import { createThrottledFetch } from "./throttled-fetch";
|
|
3
|
+
import { setHttpFetchTransport } from "./http-transport";
|
|
3
4
|
|
|
4
5
|
// Mock global fetch
|
|
5
6
|
const originalFetch = globalThis.fetch;
|
|
6
7
|
let fetchMock: ReturnType<typeof mock>;
|
|
7
8
|
|
|
8
9
|
beforeEach(() => {
|
|
10
|
+
setHttpFetchTransport(null);
|
|
9
11
|
fetchMock = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
|
10
12
|
globalThis.fetch = fetchMock as any;
|
|
11
13
|
});
|
|
@@ -156,9 +158,29 @@ describe("createThrottledFetch", () => {
|
|
|
156
158
|
expect(transportMock).toHaveBeenCalledTimes(1);
|
|
157
159
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
test("uses the shared HTTP transport by default", async () => {
|
|
163
|
+
const transportMock = mock((url: string, init?: RequestInit) => {
|
|
164
|
+
expect(url).toBe("https://api.example.com/shared");
|
|
165
|
+
expect((init?.headers as Record<string, string>)["X-Shared"]).toBe("1");
|
|
166
|
+
return Promise.resolve(new Response("shared", { status: 203 }));
|
|
167
|
+
});
|
|
168
|
+
setHttpFetchTransport(transportMock);
|
|
169
|
+
|
|
170
|
+
const client = createThrottledFetch({
|
|
171
|
+
defaultHeaders: { "X-Shared": "1" },
|
|
172
|
+
});
|
|
173
|
+
const resp = await client.fetch("https://api.example.com/shared");
|
|
174
|
+
|
|
175
|
+
expect(resp.status).toBe(203);
|
|
176
|
+
expect(await resp.text()).toBe("shared");
|
|
177
|
+
expect(transportMock).toHaveBeenCalledTimes(1);
|
|
178
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
159
180
|
});
|
|
160
181
|
|
|
161
182
|
// Restore
|
|
162
183
|
afterAll(() => {
|
|
184
|
+
setHttpFetchTransport(null);
|
|
163
185
|
globalThis.fetch = originalFetch;
|
|
164
186
|
});
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* const data = await client.fetch("https://api.example.com/data");
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { httpFetch } from "./http-transport";
|
|
11
|
+
|
|
10
12
|
const DEFAULT_REQUESTS_PER_MINUTE = 30;
|
|
11
13
|
const DEFAULT_MAX_RETRIES = 2;
|
|
12
14
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -50,7 +52,7 @@ export function createThrottledFetch(
|
|
|
50
52
|
const defaultHeaders = options.defaultHeaders ?? {};
|
|
51
53
|
const dedupeGetRequests = options.dedupeGetRequests ?? true;
|
|
52
54
|
const fetchTransport =
|
|
53
|
-
options.transport ??
|
|
55
|
+
options.transport ?? httpFetch;
|
|
54
56
|
|
|
55
57
|
// Sliding window: track timestamps of recent requests per host
|
|
56
58
|
const hostTimestamps = new Map<string, number[]>();
|
|
@@ -3,12 +3,14 @@ import type { InstrumentSearchResult } from "../types/instrument";
|
|
|
3
3
|
import type { TickerRecord } from "../types/ticker";
|
|
4
4
|
import type { TickerMetadata } from "../types/ticker";
|
|
5
5
|
import type { TickerRepository } from "../data/ticker-repository";
|
|
6
|
+
import { parseOptionSymbol } from "./options";
|
|
6
7
|
|
|
7
8
|
export type TickerSearchInstrumentClass = "equity" | "fund" | "derivative" | "other";
|
|
8
9
|
export type TickerSearchCategory = "Saved" | "Primary Listing" | "Other Listings" | "Funds & Derivatives";
|
|
9
10
|
|
|
10
11
|
const FUND_TYPES = new Set(["ETF", "ETN", "ETP", "FUND", "MUTUALFUND", "CEF", "CLOSEDEND"]);
|
|
11
12
|
const DERIVATIVE_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FUT", "FUTURE", "FUTURES", "WARRANT", "WARRANTS", "RIGHT", "RIGHTS"]);
|
|
13
|
+
const OPTION_TYPES = new Set(["OPT", "OPTION", "OPTIONS"]);
|
|
12
14
|
const EQUITY_TYPES = new Set(["STK", "STOCK", "EQUITY", "COMMONSTOCK", "COMMON STOCK", "ADR", "ORDINARYSHARES", "ORDINARY SHARES"]);
|
|
13
15
|
|
|
14
16
|
const EXCHANGE_HINT_ALIASES: Record<string, string[]> = {
|
|
@@ -90,6 +92,10 @@ interface SearchQueryIntent {
|
|
|
90
92
|
assetPreference: TickerSearchInstrumentClass | null;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
interface TickerSearchCandidateOptions {
|
|
96
|
+
includeOptionContracts?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
export function normalizeTickerInput(activeTicker: string | null, arg?: string): string | null {
|
|
94
100
|
const explicitTicker = arg?.trim().toUpperCase();
|
|
95
101
|
if (explicitTicker) return explicitTicker;
|
|
@@ -99,11 +105,13 @@ export function normalizeTickerInput(activeTicker: string | null, arg?: string):
|
|
|
99
105
|
export function createLocalTickerSearchCandidates(
|
|
100
106
|
tickers: Iterable<TickerRecord>,
|
|
101
107
|
providerHints: ReadonlyMap<string, InstrumentSearchResult> = new Map(),
|
|
108
|
+
options: TickerSearchCandidateOptions = {},
|
|
102
109
|
): TickerSearchCandidate[] {
|
|
103
|
-
return Array.from(tickers).
|
|
110
|
+
return Array.from(tickers).flatMap((ticker) => {
|
|
111
|
+
if (options.includeOptionContracts === false && isOptionTickerRecord(ticker)) return [];
|
|
104
112
|
const symbol = normalizeTickerSymbol(ticker.metadata.ticker);
|
|
105
113
|
const hint = providerHints.get(symbol);
|
|
106
|
-
return {
|
|
114
|
+
return [{
|
|
107
115
|
id: `goto:${ticker.metadata.ticker}`,
|
|
108
116
|
label: ticker.metadata.ticker,
|
|
109
117
|
symbol,
|
|
@@ -118,18 +126,20 @@ export function createLocalTickerSearchCandidates(
|
|
|
118
126
|
searchAliases: buildSymbolAliases(symbol),
|
|
119
127
|
ticker,
|
|
120
128
|
result: hint,
|
|
121
|
-
};
|
|
129
|
+
}];
|
|
122
130
|
});
|
|
123
131
|
}
|
|
124
132
|
|
|
125
133
|
export function createProviderTickerSearchCandidates(
|
|
126
134
|
searchResults: InstrumentSearchResult[],
|
|
127
135
|
localTickers: ReadonlyMap<string, TickerRecord>,
|
|
136
|
+
options: TickerSearchCandidateOptions = {},
|
|
128
137
|
): TickerSearchCandidate[] {
|
|
129
|
-
return searchResults.
|
|
138
|
+
return searchResults.flatMap((result) => {
|
|
139
|
+
if (options.includeOptionContracts === false && isOptionSearchResult(result)) return [];
|
|
130
140
|
const symbol = getSearchResultSymbol(result);
|
|
131
141
|
const saved = localTickers.has(symbol);
|
|
132
|
-
return {
|
|
142
|
+
return [{
|
|
133
143
|
id: `search:${result.symbol}`,
|
|
134
144
|
label: symbol,
|
|
135
145
|
symbol,
|
|
@@ -143,7 +153,7 @@ export function createProviderTickerSearchCandidates(
|
|
|
143
153
|
instrumentClass: classifyInstrumentKind(result.brokerContract?.secType || result.type),
|
|
144
154
|
searchAliases: buildSearchResultAliases(result),
|
|
145
155
|
result,
|
|
146
|
-
};
|
|
156
|
+
}];
|
|
147
157
|
});
|
|
148
158
|
}
|
|
149
159
|
|
|
@@ -154,6 +164,7 @@ export async function searchTickerCandidates({
|
|
|
154
164
|
searchContext,
|
|
155
165
|
localLimit = 6,
|
|
156
166
|
totalLimit = 8,
|
|
167
|
+
includeOptionContracts = true,
|
|
157
168
|
}: {
|
|
158
169
|
query: string;
|
|
159
170
|
tickers: ReadonlyMap<string, TickerRecord>;
|
|
@@ -161,6 +172,7 @@ export async function searchTickerCandidates({
|
|
|
161
172
|
searchContext?: SearchRequestContext;
|
|
162
173
|
localLimit?: number;
|
|
163
174
|
totalLimit?: number;
|
|
175
|
+
includeOptionContracts?: boolean;
|
|
164
176
|
}): Promise<TickerSearchCandidate[]> {
|
|
165
177
|
return buildTickerSearchCandidates({
|
|
166
178
|
query,
|
|
@@ -168,6 +180,7 @@ export async function searchTickerCandidates({
|
|
|
168
180
|
providerResults: await searchProviderResults(dataProvider, query, searchContext),
|
|
169
181
|
localLimit,
|
|
170
182
|
totalLimit,
|
|
183
|
+
includeOptionContracts,
|
|
171
184
|
});
|
|
172
185
|
}
|
|
173
186
|
|
|
@@ -177,19 +190,25 @@ export function buildTickerSearchCandidates({
|
|
|
177
190
|
providerResults,
|
|
178
191
|
localLimit = 6,
|
|
179
192
|
totalLimit = 8,
|
|
193
|
+
includeOptionContracts = true,
|
|
180
194
|
}: {
|
|
181
195
|
query: string;
|
|
182
196
|
tickers: ReadonlyMap<string, TickerRecord>;
|
|
183
197
|
providerResults: InstrumentSearchResult[];
|
|
184
198
|
localLimit?: number;
|
|
185
199
|
totalLimit?: number;
|
|
200
|
+
includeOptionContracts?: boolean;
|
|
186
201
|
}): TickerSearchCandidate[] {
|
|
187
|
-
const
|
|
202
|
+
const candidateOptions = { includeOptionContracts };
|
|
203
|
+
const filteredProviderResults = includeOptionContracts
|
|
204
|
+
? providerResults
|
|
205
|
+
: providerResults.filter((result) => !isOptionSearchResult(result));
|
|
206
|
+
const providerHints = buildProviderHintMap(filteredProviderResults);
|
|
188
207
|
const localItems = rankTickerSearchItems(
|
|
189
|
-
createLocalTickerSearchCandidates(tickers.values(), providerHints),
|
|
208
|
+
createLocalTickerSearchCandidates(tickers.values(), providerHints, candidateOptions),
|
|
190
209
|
query,
|
|
191
210
|
);
|
|
192
|
-
const providerItems = createProviderTickerSearchCandidates(
|
|
211
|
+
const providerItems = createProviderTickerSearchCandidates(filteredProviderResults, tickers, candidateOptions);
|
|
193
212
|
const ranked = rankTickerSearchItems([...localItems, ...providerItems], query);
|
|
194
213
|
return limitTickerSearchCandidates(assignTickerSearchCategories(ranked), totalLimit, localLimit);
|
|
195
214
|
}
|
|
@@ -551,6 +570,31 @@ function shouldReplaceAssetCategory(currentCategory: string | undefined, nextCat
|
|
|
551
570
|
return currentClass === "equity" && nextClass !== "equity";
|
|
552
571
|
}
|
|
553
572
|
|
|
573
|
+
function isOptionTickerRecord(ticker: TickerRecord): boolean {
|
|
574
|
+
return isOptionType(ticker.metadata.assetCategory)
|
|
575
|
+
|| parseOptionSymbol(ticker.metadata.ticker) != null
|
|
576
|
+
|| (ticker.metadata.broker_contracts ?? []).some(isOptionBrokerContract);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function isOptionSearchResult(result: InstrumentSearchResult): boolean {
|
|
580
|
+
return isOptionType(result.type)
|
|
581
|
+
|| parseOptionSymbol(result.symbol) != null
|
|
582
|
+
|| isOptionBrokerContract(result.brokerContract);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function isOptionBrokerContract(contract: InstrumentSearchResult["brokerContract"]): boolean {
|
|
586
|
+
if (!contract) return false;
|
|
587
|
+
return isOptionType(contract.secType)
|
|
588
|
+
|| parseOptionSymbol(contract.localSymbol || "") != null
|
|
589
|
+
|| contract.right === "C"
|
|
590
|
+
|| contract.right === "P"
|
|
591
|
+
|| contract.strike != null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function isOptionType(rawType?: string): boolean {
|
|
595
|
+
return OPTION_TYPES.has(normalizeSearchText(rawType || ""));
|
|
596
|
+
}
|
|
597
|
+
|
|
554
598
|
function analyzeSearchQuery(query: string): SearchQueryIntent {
|
|
555
599
|
const normalizedQuery = normalizeSearchText(query);
|
|
556
600
|
const compactQuery = compactSearchText(query);
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.7.
|
|
1
|
+
export const VERSION = "0.7.2";
|