gloomberb 0.10.2 → 0.10.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/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ChartResolutionSupport,
|
|
10
10
|
type ManualChartResolution,
|
|
11
11
|
} from "../../time-series/resolution";
|
|
12
|
+
import { repairIsolatedIntradayOhlcOutliers } from "../../time-series/history-quality";
|
|
12
13
|
import { canonicalExchange } from "../../utils/exchanges";
|
|
13
14
|
import { resolvePriceHistoryCurrencyUnit } from "../../utils/currency-units";
|
|
14
15
|
import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory } from "../../utils/price-history";
|
|
@@ -29,7 +30,7 @@ type PriceHistoryCachePolicyKey = Extract<
|
|
|
29
30
|
ProviderRouterCachePolicyKey,
|
|
30
31
|
"priceHistoryIntraday" | "priceHistoryDaily"
|
|
31
32
|
>;
|
|
32
|
-
const PRICE_HISTORY_CACHE_VERSION =
|
|
33
|
+
const PRICE_HISTORY_CACHE_VERSION = 4;
|
|
33
34
|
|
|
34
35
|
interface HistoryRequestDescriptor {
|
|
35
36
|
identity: RouterRequestIdentity;
|
|
@@ -83,6 +84,16 @@ function makeHistoryRequestIdentity(
|
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
function normalizeRequestHistory(
|
|
88
|
+
points: PricePoint[],
|
|
89
|
+
request: Pick<HistoryRequestDescriptor, "cachePolicyKey">,
|
|
90
|
+
): PricePoint[] {
|
|
91
|
+
const normalized = normalizePriceHistory(points);
|
|
92
|
+
return request.cachePolicyKey === "priceHistoryIntraday"
|
|
93
|
+
? repairIsolatedIntradayOhlcOutliers(normalized)
|
|
94
|
+
: normalized;
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
export class ProviderRouterHistoryRoutes {
|
|
87
98
|
constructor(private readonly deps: ProviderRouterCoreDeps) {}
|
|
88
99
|
|
|
@@ -282,7 +293,7 @@ export class ProviderRouterHistoryRoutes {
|
|
|
282
293
|
sourceKeys,
|
|
283
294
|
false,
|
|
284
295
|
);
|
|
285
|
-
const cachedValue = cached ?
|
|
296
|
+
const cachedValue = cached ? normalizeRequestHistory(cached.value, request) : [];
|
|
286
297
|
const cachedHistoryStale = request.isCachedValueStale(cachedValue);
|
|
287
298
|
const forceRefresh = request.context?.cacheMode === "refresh";
|
|
288
299
|
if (cachedValue.length > 0 && !forceRefresh && cached && !cached.stale && !cachedHistoryStale) {
|
|
@@ -308,7 +319,7 @@ export class ProviderRouterHistoryRoutes {
|
|
|
308
319
|
return this.firstBrokerResult(candidates, async (candidate) => {
|
|
309
320
|
const fetched = await request.fetchBroker(candidate);
|
|
310
321
|
if (fetched === null) return null;
|
|
311
|
-
const value =
|
|
322
|
+
const value = normalizeRequestHistory(fetched, request);
|
|
312
323
|
if (request.isFetchedValueStale(value)) return null;
|
|
313
324
|
this.deps.cacheResource(
|
|
314
325
|
request.identity.kind,
|
|
@@ -326,7 +337,7 @@ export class ProviderRouterHistoryRoutes {
|
|
|
326
337
|
return this.firstProviderArrayResult(async (provider) => {
|
|
327
338
|
const fetched = await request.fetchProvider(provider);
|
|
328
339
|
if (fetched === null) return null;
|
|
329
|
-
const value =
|
|
340
|
+
const value = normalizeRequestHistory(fetched, request);
|
|
330
341
|
if (request.isFetchedValueStale(value)) return null;
|
|
331
342
|
this.deps.cacheResource(
|
|
332
343
|
request.identity.kind,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { loadYahooPriceHistoryForResolution } from "./history";
|
|
3
|
+
|
|
4
|
+
describe("Yahoo chart history", () => {
|
|
5
|
+
test("repairs an isolated intraday wick without dropping the bar", async () => {
|
|
6
|
+
const history = await loadYahooPriceHistoryForResolution({
|
|
7
|
+
ticker: "AMD",
|
|
8
|
+
exchange: "NASDAQ",
|
|
9
|
+
bufferRange: "1M",
|
|
10
|
+
resolution: "15m",
|
|
11
|
+
fetchChart: async () => ({
|
|
12
|
+
meta: { currency: "USD" },
|
|
13
|
+
history: [
|
|
14
|
+
{
|
|
15
|
+
date: new Date("2026-07-06T14:00:00.000Z"),
|
|
16
|
+
open: 99.5,
|
|
17
|
+
high: 101,
|
|
18
|
+
low: 99,
|
|
19
|
+
close: 100,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
date: new Date("2026-07-06T14:15:00.000Z"),
|
|
23
|
+
open: 100,
|
|
24
|
+
high: 150,
|
|
25
|
+
low: 99,
|
|
26
|
+
close: 100.5,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
date: new Date("2026-07-06T14:30:00.000Z"),
|
|
30
|
+
open: 100.5,
|
|
31
|
+
high: 101,
|
|
32
|
+
low: 100,
|
|
33
|
+
close: 100.7,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(history).toHaveLength(3);
|
|
40
|
+
expect(history[1]).toMatchObject({
|
|
41
|
+
open: 100,
|
|
42
|
+
high: 100.5,
|
|
43
|
+
low: 99,
|
|
44
|
+
close: 100.5,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { TimeRange } from "../../time-series/range";
|
|
2
2
|
import {
|
|
3
|
+
isIntradayResolution,
|
|
3
4
|
normalizeChartResolutionSupport,
|
|
4
5
|
type ChartResolutionSupport,
|
|
5
6
|
type ManualChartResolution,
|
|
6
7
|
} from "../../time-series/resolution";
|
|
8
|
+
import { repairIsolatedIntradayOhlcOutliers } from "../../time-series/history-quality";
|
|
7
9
|
import type { PricePoint } from "../../types/financials";
|
|
8
10
|
import { normalizeSubUnitCurrency } from "./mappers";
|
|
9
11
|
import { getYahooSymbolsToTry } from "./symbols";
|
|
@@ -101,7 +103,9 @@ export async function loadYahooPriceHistoryForResolution({
|
|
|
101
103
|
}
|
|
102
104
|
}
|
|
103
105
|
|
|
104
|
-
return
|
|
106
|
+
return isIntradayResolution(resolution)
|
|
107
|
+
? repairIsolatedIntradayOhlcOutliers(history)
|
|
108
|
+
: history;
|
|
105
109
|
} catch (err) {
|
|
106
110
|
lastError = err;
|
|
107
111
|
}
|
|
@@ -2,10 +2,18 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { act, useState } from "react";
|
|
3
3
|
import { testRender } from "../../renderers/opentui/test-utils";
|
|
4
4
|
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
5
|
-
import {
|
|
5
|
+
import { createTestDataProvider } from "../../test-support/data-provider";
|
|
6
|
+
import { AppProvider, PaneInstanceProvider } from "../../state/app/context";
|
|
7
|
+
import { createDefaultConfig } from "../../types/config";
|
|
8
|
+
import { useLiveStreamingSetting } from "../../plugins/builtin/shared/live-streaming";
|
|
9
|
+
import { useLiveQuoteEntries, useQuoteStreaming, useQuoteUpdates } from "./quote-streaming";
|
|
6
10
|
|
|
7
11
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
8
12
|
let bumpHarness: (() => void) | null = null;
|
|
13
|
+
let togglePollingPriority: (() => void) | null = null;
|
|
14
|
+
let updateLiveTargets: ((symbol: string) => void) | null = null;
|
|
15
|
+
let updateFreshnessScope: ((scope: string) => void) | null = null;
|
|
16
|
+
let observedSubscriptionStartedAt = 0;
|
|
9
17
|
|
|
10
18
|
function QuoteStreamingHarness() {
|
|
11
19
|
const [tick, setTick] = useState(0);
|
|
@@ -19,6 +27,53 @@ function QuoteStreamingHarness() {
|
|
|
19
27
|
return <text>{String(tick)}</text>;
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
function QuotePollingHarness() {
|
|
31
|
+
useQuoteUpdates([{
|
|
32
|
+
symbol: "AAPL",
|
|
33
|
+
exchange: "NASDAQ",
|
|
34
|
+
}], {
|
|
35
|
+
liveStreaming: false,
|
|
36
|
+
pollIntervalMs: 30,
|
|
37
|
+
});
|
|
38
|
+
return <text>polling</text>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function QuotePollingPriorityHarness() {
|
|
42
|
+
const [selected, setSelected] = useState(false);
|
|
43
|
+
togglePollingPriority = () => setSelected((current) => !current);
|
|
44
|
+
useQuoteUpdates([{
|
|
45
|
+
symbol: "AAPL",
|
|
46
|
+
exchange: "NASDAQ",
|
|
47
|
+
selected,
|
|
48
|
+
weight: selected ? 100 : 10,
|
|
49
|
+
}], {
|
|
50
|
+
liveStreaming: false,
|
|
51
|
+
pollIntervalMs: 1_000,
|
|
52
|
+
});
|
|
53
|
+
return <text>{selected ? "selected" : "idle"}</text>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function FollowedQuoteStreamingHarness() {
|
|
57
|
+
const liveStreaming = useLiveStreamingSetting();
|
|
58
|
+
useQuoteUpdates([{
|
|
59
|
+
symbol: "AMD",
|
|
60
|
+
exchange: "NASDAQ",
|
|
61
|
+
}], { liveStreaming });
|
|
62
|
+
return <text>{liveStreaming ? "live" : "polling"}</text>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function LiveQuoteFreshnessHarness() {
|
|
66
|
+
const [symbol, setSymbol] = useState("AAPL");
|
|
67
|
+
const [freshnessScopeKey, setFreshnessScopeKey] = useState("expiration-1");
|
|
68
|
+
updateLiveTargets = setSymbol;
|
|
69
|
+
updateFreshnessScope = setFreshnessScopeKey;
|
|
70
|
+
observedSubscriptionStartedAt = useLiveQuoteEntries(
|
|
71
|
+
[{ symbol, exchange: "OPTIONS" }],
|
|
72
|
+
{ freshnessScopeKey },
|
|
73
|
+
).subscriptionStartedAt;
|
|
74
|
+
return <text>{symbol}</text>;
|
|
75
|
+
}
|
|
76
|
+
|
|
22
77
|
afterEach(async () => {
|
|
23
78
|
if (testSetup) {
|
|
24
79
|
await act(async () => {
|
|
@@ -27,6 +82,10 @@ afterEach(async () => {
|
|
|
27
82
|
testSetup = undefined;
|
|
28
83
|
}
|
|
29
84
|
bumpHarness = null;
|
|
85
|
+
togglePollingPriority = null;
|
|
86
|
+
updateLiveTargets = null;
|
|
87
|
+
updateFreshnessScope = null;
|
|
88
|
+
observedSubscriptionStartedAt = 0;
|
|
30
89
|
setSharedMarketDataCoordinator(null);
|
|
31
90
|
});
|
|
32
91
|
|
|
@@ -67,4 +126,125 @@ describe("useQuoteStreaming", () => {
|
|
|
67
126
|
expect(subscribeCalls).toBe(1);
|
|
68
127
|
expect(unsubscribeCalls).toBe(0);
|
|
69
128
|
});
|
|
129
|
+
|
|
130
|
+
test("uses forced polling instead of a subscription when live streaming is disabled", async () => {
|
|
131
|
+
let subscribeCalls = 0;
|
|
132
|
+
let loadCalls = 0;
|
|
133
|
+
const coordinator = {
|
|
134
|
+
subscribeQuotes: () => {
|
|
135
|
+
subscribeCalls += 1;
|
|
136
|
+
return () => {};
|
|
137
|
+
},
|
|
138
|
+
loadQuotesBatch: async (_instruments: unknown[], options: { forceRefresh?: boolean }) => {
|
|
139
|
+
expect(options.forceRefresh).toBe(true);
|
|
140
|
+
loadCalls += 1;
|
|
141
|
+
return [];
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
145
|
+
|
|
146
|
+
testSetup = await testRender(<QuotePollingHarness />, { width: 20, height: 1 });
|
|
147
|
+
await act(async () => testSetup!.renderOnce());
|
|
148
|
+
expect(subscribeCalls).toBe(0);
|
|
149
|
+
expect(loadCalls).toBe(1);
|
|
150
|
+
|
|
151
|
+
await act(async () => {
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
153
|
+
});
|
|
154
|
+
expect(loadCalls).toBeGreaterThanOrEqual(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("inherits disabled streaming from a followed portfolio pane", async () => {
|
|
158
|
+
let subscribeCalls = 0;
|
|
159
|
+
let loadCalls = 0;
|
|
160
|
+
const config = createDefaultConfig("/tmp/gloomberb-live-streaming-test");
|
|
161
|
+
const portfolioPane = config.layout.instances.find((pane) => pane.instanceId === "portfolio-list:main");
|
|
162
|
+
if (!portfolioPane) throw new Error("expected default portfolio pane");
|
|
163
|
+
portfolioPane.settings = { ...portfolioPane.settings, liveStreaming: false };
|
|
164
|
+
setSharedMarketDataCoordinator({
|
|
165
|
+
subscribeQuotes: () => {
|
|
166
|
+
subscribeCalls += 1;
|
|
167
|
+
return () => {};
|
|
168
|
+
},
|
|
169
|
+
loadQuotesBatch: async () => {
|
|
170
|
+
loadCalls += 1;
|
|
171
|
+
return [];
|
|
172
|
+
},
|
|
173
|
+
} as unknown as MarketDataCoordinator);
|
|
174
|
+
|
|
175
|
+
testSetup = await testRender(
|
|
176
|
+
<AppProvider config={config}>
|
|
177
|
+
<PaneInstanceProvider paneId="ticker-detail:main">
|
|
178
|
+
<FollowedQuoteStreamingHarness />
|
|
179
|
+
</PaneInstanceProvider>
|
|
180
|
+
</AppProvider>,
|
|
181
|
+
{ width: 20, height: 1 },
|
|
182
|
+
);
|
|
183
|
+
await act(async () => testSetup!.renderOnce());
|
|
184
|
+
|
|
185
|
+
expect(testSetup.captureCharFrame()).toContain("polling");
|
|
186
|
+
expect(subscribeCalls).toBe(0);
|
|
187
|
+
expect(loadCalls).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("does not restart polling when only subscription priority changes", async () => {
|
|
191
|
+
let loadCalls = 0;
|
|
192
|
+
const coordinator = {
|
|
193
|
+
subscribeQuotes: () => () => {},
|
|
194
|
+
loadQuotesBatch: async () => {
|
|
195
|
+
loadCalls += 1;
|
|
196
|
+
return [];
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
200
|
+
|
|
201
|
+
testSetup = await testRender(<QuotePollingPriorityHarness />, { width: 20, height: 1 });
|
|
202
|
+
await act(async () => testSetup!.renderOnce());
|
|
203
|
+
expect(loadCalls).toBe(1);
|
|
204
|
+
|
|
205
|
+
await act(async () => {
|
|
206
|
+
togglePollingPriority?.();
|
|
207
|
+
await Promise.resolve();
|
|
208
|
+
});
|
|
209
|
+
await act(async () => testSetup!.renderOnce());
|
|
210
|
+
expect(loadCalls).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("keeps quote freshness stable while targets move within one surface", async () => {
|
|
214
|
+
const originalDateNow = Date.now;
|
|
215
|
+
let now = 100;
|
|
216
|
+
Date.now = () => now;
|
|
217
|
+
setSharedMarketDataCoordinator(new MarketDataCoordinator(createTestDataProvider({
|
|
218
|
+
subscribeQuotes: () => () => {},
|
|
219
|
+
})));
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
testSetup = await testRender(<LiveQuoteFreshnessHarness />, {
|
|
223
|
+
width: 20,
|
|
224
|
+
height: 1,
|
|
225
|
+
});
|
|
226
|
+
await act(async () => {
|
|
227
|
+
await testSetup!.renderOnce();
|
|
228
|
+
});
|
|
229
|
+
expect(observedSubscriptionStartedAt).toBe(100);
|
|
230
|
+
|
|
231
|
+
now = 200;
|
|
232
|
+
await act(async () => {
|
|
233
|
+
updateLiveTargets?.("MSFT");
|
|
234
|
+
await Promise.resolve();
|
|
235
|
+
await testSetup!.renderOnce();
|
|
236
|
+
});
|
|
237
|
+
expect(observedSubscriptionStartedAt).toBe(100);
|
|
238
|
+
|
|
239
|
+
now = 300;
|
|
240
|
+
await act(async () => {
|
|
241
|
+
updateFreshnessScope?.("expiration-2");
|
|
242
|
+
await Promise.resolve();
|
|
243
|
+
await testSetup!.renderOnce();
|
|
244
|
+
});
|
|
245
|
+
expect(observedSubscriptionStartedAt).toBe(300);
|
|
246
|
+
} finally {
|
|
247
|
+
Date.now = originalDateNow;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
70
250
|
});
|
|
@@ -11,6 +11,17 @@ import type { QueryEntry } from "../../market-data/result-types";
|
|
|
11
11
|
import { buildQuoteKey } from "../../market-data/selectors";
|
|
12
12
|
|
|
13
13
|
const quoteStreamLog = debugLog.createLogger("quote-stream");
|
|
14
|
+
export const DEFAULT_QUOTE_POLL_INTERVAL_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
export interface QuoteStreamingOptions {
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QuoteUpdateOptions {
|
|
21
|
+
liveStreaming?: boolean;
|
|
22
|
+
pollIntervalMs?: number;
|
|
23
|
+
freshnessScopeKey?: string;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export function normalizeQuoteStreamSubscriptionTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
|
|
16
27
|
const symbol = normalizeSymbol(target.symbol);
|
|
@@ -42,7 +53,10 @@ export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget)
|
|
|
42
53
|
].join("|");
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
export function useQuoteStreaming(
|
|
56
|
+
export function useQuoteStreaming(
|
|
57
|
+
targets: QuoteSubscriptionTarget[],
|
|
58
|
+
{ enabled = true }: QuoteStreamingOptions = {},
|
|
59
|
+
): void {
|
|
46
60
|
const appActive = useAppActive();
|
|
47
61
|
const coordinator = getSharedMarketDataCoordinator();
|
|
48
62
|
|
|
@@ -58,9 +72,12 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
|
|
|
58
72
|
const subscriptionKey = sortedEntries.map(([key]) => key).join("|");
|
|
59
73
|
|
|
60
74
|
useEffect(() => {
|
|
61
|
-
if (!appActive) {
|
|
75
|
+
if (!enabled || !appActive) {
|
|
62
76
|
if (normalizedTargets.length > 0) {
|
|
63
|
-
quoteStreamLog.info("skipping subscription
|
|
77
|
+
quoteStreamLog.info("skipping subscription", {
|
|
78
|
+
reason: enabled ? "inactive" : "disabled",
|
|
79
|
+
targets: subscriptionKey,
|
|
80
|
+
});
|
|
64
81
|
}
|
|
65
82
|
return;
|
|
66
83
|
}
|
|
@@ -94,14 +111,81 @@ export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
|
|
|
94
111
|
});
|
|
95
112
|
unsubscribe?.();
|
|
96
113
|
};
|
|
97
|
-
}, [appActive, coordinator, subscriptionKey]);
|
|
114
|
+
}, [appActive, coordinator, enabled, subscriptionKey]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function useQuoteUpdates(
|
|
118
|
+
targets: QuoteSubscriptionTarget[],
|
|
119
|
+
{
|
|
120
|
+
liveStreaming = true,
|
|
121
|
+
pollIntervalMs = DEFAULT_QUOTE_POLL_INTERVAL_MS,
|
|
122
|
+
}: QuoteUpdateOptions = {},
|
|
123
|
+
): void {
|
|
124
|
+
const appActive = useAppActive();
|
|
125
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
126
|
+
const normalizedTargets = targets.flatMap((target) => {
|
|
127
|
+
const normalized = normalizeQuoteStreamSubscriptionTarget(target);
|
|
128
|
+
return normalized ? [normalized] : [];
|
|
129
|
+
});
|
|
130
|
+
const instrumentKey = normalizedTargets
|
|
131
|
+
.map((target) => buildQuoteKey({
|
|
132
|
+
symbol: target.symbol,
|
|
133
|
+
exchange: target.exchange,
|
|
134
|
+
brokerId: target.context?.brokerId,
|
|
135
|
+
brokerInstanceId: target.context?.brokerInstanceId,
|
|
136
|
+
instrument: target.context?.instrument ?? null,
|
|
137
|
+
}))
|
|
138
|
+
.sort()
|
|
139
|
+
.join("\u001f");
|
|
140
|
+
const instruments = useMemo(() => {
|
|
141
|
+
const unique = new Map<string, InstrumentRef>();
|
|
142
|
+
for (const target of normalizedTargets) {
|
|
143
|
+
const instrument: InstrumentRef = {
|
|
144
|
+
symbol: target.symbol,
|
|
145
|
+
exchange: target.exchange,
|
|
146
|
+
brokerId: target.context?.brokerId,
|
|
147
|
+
brokerInstanceId: target.context?.brokerInstanceId,
|
|
148
|
+
instrument: target.context?.instrument ?? null,
|
|
149
|
+
};
|
|
150
|
+
unique.set(buildQuoteKey(instrument), instrument);
|
|
151
|
+
}
|
|
152
|
+
return [...unique.values()];
|
|
153
|
+
}, [instrumentKey]);
|
|
154
|
+
|
|
155
|
+
useQuoteStreaming(targets, { enabled: liveStreaming });
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (liveStreaming || !appActive || !coordinator || instruments.length === 0) return;
|
|
159
|
+
let cancelled = false;
|
|
160
|
+
let inFlight = false;
|
|
161
|
+
const refresh = async () => {
|
|
162
|
+
if (cancelled || inFlight) return;
|
|
163
|
+
inFlight = true;
|
|
164
|
+
try {
|
|
165
|
+
await coordinator.loadQuotesBatch(instruments, { forceRefresh: true });
|
|
166
|
+
} catch {
|
|
167
|
+
// Polling is best effort and the coordinator retains the last good quote.
|
|
168
|
+
} finally {
|
|
169
|
+
inFlight = false;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
void refresh();
|
|
173
|
+
const intervalId = setInterval(() => void refresh(), pollIntervalMs);
|
|
174
|
+
return () => {
|
|
175
|
+
cancelled = true;
|
|
176
|
+
clearInterval(intervalId);
|
|
177
|
+
};
|
|
178
|
+
}, [appActive, coordinator, instrumentKey, instruments, liveStreaming, pollIntervalMs]);
|
|
98
179
|
}
|
|
99
180
|
|
|
100
181
|
/**
|
|
101
182
|
* Subscribe to a bounded target set and observe the coordinator entries filled
|
|
102
183
|
* by that stream. This intentionally avoids per-symbol HTTP quote loads.
|
|
103
184
|
*/
|
|
104
|
-
export function useLiveQuoteEntries(
|
|
185
|
+
export function useLiveQuoteEntries(
|
|
186
|
+
targets: QuoteSubscriptionTarget[],
|
|
187
|
+
options: QuoteUpdateOptions = {},
|
|
188
|
+
): {
|
|
105
189
|
entries: Map<string, QueryEntry<Quote>>;
|
|
106
190
|
freshnessNow: number;
|
|
107
191
|
subscriptionStartedAt: number;
|
|
@@ -115,7 +199,9 @@ export function useLiveQuoteEntries(targets: QuoteSubscriptionTarget[]): {
|
|
|
115
199
|
.map((target) => buildQuoteStreamSubscriptionKey(target))
|
|
116
200
|
.sort()
|
|
117
201
|
.join("\u001f");
|
|
118
|
-
const
|
|
202
|
+
const liveStreaming = options.liveStreaming !== false;
|
|
203
|
+
const freshnessKey = options.freshnessScopeKey ?? targetKey;
|
|
204
|
+
const subscriptionKey = `${appActive ? "active" : "inactive"}\u001f${liveStreaming ? "live" : "poll"}\u001f${freshnessKey}`;
|
|
119
205
|
const subscriptionRef = useRef({
|
|
120
206
|
key: subscriptionKey,
|
|
121
207
|
startedAt: Date.now(),
|
|
@@ -149,7 +235,7 @@ export function useLiveQuoteEntries(targets: QuoteSubscriptionTarget[]): {
|
|
|
149
235
|
return () => clearInterval(interval);
|
|
150
236
|
}, [appActive, targetKey]);
|
|
151
237
|
|
|
152
|
-
|
|
238
|
+
useQuoteUpdates(targets, options);
|
|
153
239
|
return {
|
|
154
240
|
entries: useQuoteEntries(instruments),
|
|
155
241
|
freshnessNow,
|
package/src/theme/colors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blendForContrast, blendHex, higherContrast } from "./color-utils";
|
|
1
|
+
import { blendForContrast, blendForContrastOnSurfaces, blendHex, higherContrast } from "./color-utils";
|
|
2
2
|
import { getTheme, DEFAULT_THEME, type Theme } from "./themes";
|
|
3
3
|
|
|
4
4
|
export type ThemeColors = Readonly<Omit<Theme, "name" | "description">>;
|
|
@@ -8,6 +8,9 @@ export function getThemeColors(id: string): ThemeColors {
|
|
|
8
8
|
return Object.freeze(palette);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Soft violet the AI section is tinted with, before per-theme contrast fixup. */
|
|
12
|
+
const ASSIST_ACCENT = "#a877ff";
|
|
13
|
+
|
|
11
14
|
let currentColors = getThemeColors(DEFAULT_THEME);
|
|
12
15
|
|
|
13
16
|
/** Compatibility facade for non-React formatting and rendering helpers. */
|
|
@@ -174,6 +177,22 @@ export function commandBarSubtleText(palette: ThemeColors = colors): string {
|
|
|
174
177
|
return blendForContrast(palette.textDim, base, fallback, 4.1);
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Accent for the command bar's AI section. No theme ships a violet token, so the
|
|
182
|
+
* hue is fixed — that is the point, it has to read as "not one of your results"
|
|
183
|
+
* on monochrome palettes too — and only darkened or lightened until it clears
|
|
184
|
+
* the subtle-text contrast floor on both command bar surfaces.
|
|
185
|
+
*/
|
|
186
|
+
export function commandBarAccentText(palette: ThemeColors = colors): string {
|
|
187
|
+
const surfaces = [commandBarBg(palette), commandBarPanelBg(palette)] as const;
|
|
188
|
+
return blendForContrastOnSurfaces(
|
|
189
|
+
ASSIST_ACCENT,
|
|
190
|
+
surfaces,
|
|
191
|
+
higherContrast("#000000", "#ffffff", surfaces[0]),
|
|
192
|
+
3.6,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
177
196
|
export function commandBarSelectedText(palette: ThemeColors = colors): string {
|
|
178
197
|
const base = commandBarSelectedBg(palette);
|
|
179
198
|
const preferred = higherContrast(palette.selectedText, palette.text, base);
|
package/src/theme/themes.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
applyTheme,
|
|
4
|
+
commandBarAccentText,
|
|
4
5
|
commandBarBg,
|
|
5
6
|
commandBarHeadingText,
|
|
6
7
|
commandBarPanelBg,
|
|
@@ -65,6 +66,8 @@ describe("theme contrast", () => {
|
|
|
65
66
|
assertMinContrast(id, "commandBarText/bg", commandBarText(), commandBarBg(), BODY_TEXT_MIN);
|
|
66
67
|
assertMinContrast(id, "commandBarHeadingText/bg", commandBarHeadingText(), commandBarBg(), SUBTLE_TEXT_MIN);
|
|
67
68
|
assertMinContrast(id, "commandBarSubtleText/panel", commandBarSubtleText(), commandBarPanelBg(), SUBTLE_TEXT_MIN);
|
|
69
|
+
assertMinContrast(id, "commandBarAccentText/bg", commandBarAccentText(), commandBarBg(), SUBTLE_TEXT_MIN);
|
|
70
|
+
assertMinContrast(id, "commandBarAccentText/panel", commandBarAccentText(), commandBarPanelBg(), SUBTLE_TEXT_MIN);
|
|
68
71
|
assertMinContrast(id, "commandBarSelectedText/selected", commandBarSelectedText(), commandBarSelectedBg(), BODY_TEXT_MIN);
|
|
69
72
|
}
|
|
70
73
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PricePoint } from "
|
|
2
|
-
import { getPricePointTimestamp } from "
|
|
1
|
+
import type { PricePoint } from "../types/financials";
|
|
2
|
+
import { getPricePointTimestamp } from "../utils/price-history";
|
|
3
3
|
|
|
4
4
|
const MAX_NEIGHBOR_GAP_MS = 60 * 60 * 1000;
|
|
5
5
|
const STABLE_NEIGHBOR_CLOSE_RATIO = 0.01;
|
|
@@ -34,7 +34,7 @@ function isIsolatedOhlcOutlier(
|
|
|
34
34
|
));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function
|
|
37
|
+
export function hasMalformedIntradayHistory(points: readonly PricePoint[]): boolean {
|
|
38
38
|
for (let index = 1; index < points.length - 1; index += 1) {
|
|
39
39
|
if (isIsolatedOhlcOutlier(points[index - 1]!, points[index]!, points[index + 1]!)) {
|
|
40
40
|
return true;
|
|
@@ -42,3 +42,27 @@ export function hasMalformedCloudIntradayHistory(points: readonly PricePoint[]):
|
|
|
42
42
|
}
|
|
43
43
|
return false;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export function repairIsolatedIntradayOhlcOutliers(
|
|
47
|
+
points: readonly PricePoint[],
|
|
48
|
+
): PricePoint[] {
|
|
49
|
+
return points.map((point, index) => {
|
|
50
|
+
const previous = points[index - 1];
|
|
51
|
+
const next = points[index + 1];
|
|
52
|
+
if (!previous || !next || !isIsolatedOhlcOutlier(previous, point, next)) return point;
|
|
53
|
+
|
|
54
|
+
const isOutlier = (value: number | undefined) => (
|
|
55
|
+
typeof value === "number"
|
|
56
|
+
&& Number.isFinite(value)
|
|
57
|
+
&& relativeDifference(value, point.close) > ISOLATED_OHLC_OUTLIER_RATIO
|
|
58
|
+
);
|
|
59
|
+
const open = isOutlier(point.open) ? previous.close : point.open;
|
|
60
|
+
const bodyOpen = open ?? point.close;
|
|
61
|
+
return {
|
|
62
|
+
...point,
|
|
63
|
+
open,
|
|
64
|
+
high: isOutlier(point.high) ? Math.max(bodyOpen, point.close) : point.high,
|
|
65
|
+
low: isOutlier(point.low) ? Math.min(bodyOpen, point.close) : point.low,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
getBestSupportedResolutionForVisibleWindow,
|
|
5
5
|
getExpandedBufferRange,
|
|
6
6
|
getPresetResolution,
|
|
7
|
+
getSupportedChartResolutionsForViewport,
|
|
7
8
|
isRangePresetSupported,
|
|
8
9
|
normalizeChartResolution,
|
|
9
10
|
sortChartResolutions,
|
|
10
11
|
} from "./resolution";
|
|
12
|
+
import { TIME_RANGES } from "./range";
|
|
11
13
|
|
|
12
14
|
describe("chart-resolution", () => {
|
|
13
15
|
test("maps range presets to their default manual resolutions", () => {
|
|
@@ -79,4 +81,53 @@ describe("chart-resolution", () => {
|
|
|
79
81
|
|
|
80
82
|
expect(CHART_RESOLUTION_STEP_MS["1h"]).toBe(60 * 60_000);
|
|
81
83
|
});
|
|
84
|
+
|
|
85
|
+
test("exposes only intervals that can cover and chart the authored viewport", () => {
|
|
86
|
+
const support = [
|
|
87
|
+
{ resolution: "1m", maxRange: "1W" },
|
|
88
|
+
{ resolution: "15m", maxRange: "3M" },
|
|
89
|
+
{ resolution: "30m", maxRange: "6M" },
|
|
90
|
+
{ resolution: "1h", maxRange: "1Y" },
|
|
91
|
+
{ resolution: "1d", maxRange: "ALL" },
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
expect(getSupportedChartResolutionsForViewport("1M", support))
|
|
95
|
+
.toEqual(["15m", "30m", "1h", "1d"]);
|
|
96
|
+
expect(getSupportedChartResolutionsForViewport("1Y", support))
|
|
97
|
+
.toEqual(["1h", "1d"]);
|
|
98
|
+
expect(getSupportedChartResolutionsForViewport("1M", support, {
|
|
99
|
+
start: "2024-01-01",
|
|
100
|
+
end: "2026-01-01",
|
|
101
|
+
})).toEqual(["1d"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("hides intervals too coarse to form a chart for the selected range", () => {
|
|
105
|
+
const support = (Object.keys(CHART_RESOLUTION_STEP_MS) as Array<
|
|
106
|
+
keyof typeof CHART_RESOLUTION_STEP_MS
|
|
107
|
+
>).map((resolution) => ({ resolution, maxRange: "ALL" as const }));
|
|
108
|
+
|
|
109
|
+
expect(getSupportedChartResolutionsForViewport("1D", support))
|
|
110
|
+
.toEqual(["1m", "5m", "15m", "30m", "45m", "1h"]);
|
|
111
|
+
expect(getSupportedChartResolutionsForViewport("1W", support))
|
|
112
|
+
.toEqual(["1m", "5m", "15m", "30m", "45m", "1h", "1d"]);
|
|
113
|
+
expect(getSupportedChartResolutionsForViewport("1M", support))
|
|
114
|
+
.toEqual(["1m", "5m", "15m", "30m", "45m", "1h", "1d", "1wk"]);
|
|
115
|
+
expect(getSupportedChartResolutionsForViewport("3M", support))
|
|
116
|
+
.toEqual(Object.keys(CHART_RESOLUTION_STEP_MS));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("evaluates every range and resolution capability boundary", () => {
|
|
120
|
+
const resolutions = ["1m", "5m", "15m", "30m", "45m", "1h"] as const;
|
|
121
|
+
const support = resolutions.map((resolution, index) => ({
|
|
122
|
+
resolution,
|
|
123
|
+
maxRange: TIME_RANGES[Math.min(index, TIME_RANGES.length - 1)]!,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
for (const [rangeIndex, range] of TIME_RANGES.entries()) {
|
|
127
|
+
const available = new Set(getSupportedChartResolutionsForViewport(range, support));
|
|
128
|
+
for (const [resolutionIndex, resolution] of resolutions.entries()) {
|
|
129
|
+
expect(available.has(resolution)).toBe(rangeIndex <= resolutionIndex);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
82
133
|
});
|