gloomberb 0.7.3 → 0.7.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/src/app.tsx +59 -30
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +26 -2
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.ts +72 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -127,6 +127,23 @@ describe("MarketDataCoordinator", () => {
|
|
|
127
127
|
expect(calls).toBe(2);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("serves USD exchange rates without hitting the provider", async () => {
|
|
131
|
+
let calls = 0;
|
|
132
|
+
const provider = createProvider({
|
|
133
|
+
getExchangeRate: async () => {
|
|
134
|
+
calls += 1;
|
|
135
|
+
return 1;
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
139
|
+
|
|
140
|
+
const entry = await coordinator.loadFxRate("usd");
|
|
141
|
+
|
|
142
|
+
expect(entry.data).toBe(1);
|
|
143
|
+
expect(entry.source).toBe("static");
|
|
144
|
+
expect(calls).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
130
147
|
it("reuses fresh empty tab query results instead of refetching on reopen", async () => {
|
|
131
148
|
let newsCalls = 0;
|
|
132
149
|
let optionsCalls = 0;
|
|
@@ -553,6 +570,50 @@ describe("MarketDataCoordinator", () => {
|
|
|
553
570
|
}
|
|
554
571
|
});
|
|
555
572
|
|
|
573
|
+
it("drops a stale stream quote when there is no fresh quote to preserve", () => {
|
|
574
|
+
const fixedNow = Date.parse("2026-05-13T21:00:00Z");
|
|
575
|
+
const realDateNow = Date.now;
|
|
576
|
+
Date.now = () => fixedNow;
|
|
577
|
+
try {
|
|
578
|
+
let streamed: ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null = null;
|
|
579
|
+
const provider = createProvider({
|
|
580
|
+
subscribeQuotes: (_targets, onQuote) => {
|
|
581
|
+
streamed = onQuote as typeof streamed;
|
|
582
|
+
return () => {};
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
586
|
+
const instrument = { symbol: "2337", exchange: "TWSE" };
|
|
587
|
+
|
|
588
|
+
coordinator.subscribeQuotes([{ instrument }]);
|
|
589
|
+
const onStreamed = streamed as ((target: QuoteSubscriptionTarget, quote: Quote) => void) | null;
|
|
590
|
+
if (!onStreamed) throw new Error("expected streaming callback");
|
|
591
|
+
onStreamed(
|
|
592
|
+
{ symbol: "2337", exchange: "TWSE" },
|
|
593
|
+
{
|
|
594
|
+
symbol: "2337",
|
|
595
|
+
providerId: "gloomberb-cloud",
|
|
596
|
+
dataSource: "delayed",
|
|
597
|
+
price: 150,
|
|
598
|
+
currency: "TWD",
|
|
599
|
+
change: 0,
|
|
600
|
+
changePercent: 0,
|
|
601
|
+
lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
|
|
602
|
+
listingExchangeName: "TWSE",
|
|
603
|
+
marketState: "CLOSED",
|
|
604
|
+
sessionConfidence: "explicit",
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
const entry = coordinator.getQuoteEntry(instrument);
|
|
609
|
+
expect(entry.data).toBeNull();
|
|
610
|
+
expect(entry.lastGoodData).toBeNull();
|
|
611
|
+
expect(coordinator.getTickerFinancialsSync(instrument)?.quote).toBeUndefined();
|
|
612
|
+
} finally {
|
|
613
|
+
Date.now = realDateNow;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
556
617
|
it("hydrates ticker financials synchronously from primed cached data", () => {
|
|
557
618
|
const coordinator = new MarketDataCoordinator(createProvider());
|
|
558
619
|
const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
|
|
@@ -642,7 +703,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
642
703
|
currency: "USD",
|
|
643
704
|
change: 0,
|
|
644
705
|
changePercent: 0,
|
|
645
|
-
lastUpdated:
|
|
706
|
+
lastUpdated: Date.now(),
|
|
646
707
|
}),
|
|
647
708
|
getQuotesBatch: async (targets) => {
|
|
648
709
|
batchTargets.push(targets);
|
|
@@ -654,7 +715,7 @@ describe("MarketDataCoordinator", () => {
|
|
|
654
715
|
currency: "USD",
|
|
655
716
|
change: 0,
|
|
656
717
|
changePercent: 0,
|
|
657
|
-
lastUpdated:
|
|
718
|
+
lastUpdated: Date.now(),
|
|
658
719
|
},
|
|
659
720
|
}));
|
|
660
721
|
},
|
|
@@ -672,6 +733,65 @@ describe("MarketDataCoordinator", () => {
|
|
|
672
733
|
expect(coordinator.getQuoteEntry(msft).data?.price).toBe(200);
|
|
673
734
|
});
|
|
674
735
|
|
|
736
|
+
it("refreshes primed quote cache when the quote is stale for the current session", async () => {
|
|
737
|
+
const fixedNow = Date.parse("2026-05-13T21:00:00Z");
|
|
738
|
+
const realDateNow = Date.now;
|
|
739
|
+
Date.now = () => fixedNow;
|
|
740
|
+
try {
|
|
741
|
+
const batchTargets: QuoteSubscriptionTarget[][] = [];
|
|
742
|
+
const provider = createProvider({
|
|
743
|
+
getQuotesBatch: async (targets) => {
|
|
744
|
+
batchTargets.push(targets);
|
|
745
|
+
return targets.map((target) => ({
|
|
746
|
+
target,
|
|
747
|
+
quote: {
|
|
748
|
+
symbol: target.symbol,
|
|
749
|
+
price: 6970,
|
|
750
|
+
currency: "JPY",
|
|
751
|
+
change: 370,
|
|
752
|
+
changePercent: 5.61,
|
|
753
|
+
lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
|
|
754
|
+
marketState: "CLOSED",
|
|
755
|
+
listingExchangeName: "JPX",
|
|
756
|
+
providerId: "yahoo",
|
|
757
|
+
},
|
|
758
|
+
}));
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
const coordinator = new MarketDataCoordinator(provider);
|
|
762
|
+
const instrument = { symbol: "6324.T", exchange: "JPX" };
|
|
763
|
+
|
|
764
|
+
coordinator.primeCachedFinancials([{
|
|
765
|
+
instrument,
|
|
766
|
+
financials: {
|
|
767
|
+
quote: {
|
|
768
|
+
symbol: "6324.T",
|
|
769
|
+
price: 6230,
|
|
770
|
+
currency: "JPY",
|
|
771
|
+
change: 520,
|
|
772
|
+
changePercent: 9.11,
|
|
773
|
+
lastUpdated: Date.parse("2026-05-08T06:24:00Z"),
|
|
774
|
+
marketState: "CLOSED",
|
|
775
|
+
listingExchangeName: "JPX",
|
|
776
|
+
providerId: "gloomberb-cloud",
|
|
777
|
+
},
|
|
778
|
+
annualStatements: [],
|
|
779
|
+
quarterlyStatements: [],
|
|
780
|
+
priceHistory: [],
|
|
781
|
+
},
|
|
782
|
+
}]);
|
|
783
|
+
|
|
784
|
+
await coordinator.loadQuotesBatch([instrument]);
|
|
785
|
+
|
|
786
|
+
expect(batchTargets).toHaveLength(1);
|
|
787
|
+
expect(batchTargets[0]?.map((target) => target.symbol)).toEqual(["6324.T"]);
|
|
788
|
+
expect(coordinator.getQuoteEntry(instrument).data?.price).toBe(6970);
|
|
789
|
+
expect(coordinator.getQuoteEntry(instrument).data?.changePercent).toBe(5.61);
|
|
790
|
+
} finally {
|
|
791
|
+
Date.now = realDateNow;
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
675
795
|
it("batch loads snapshots through provider batch support", async () => {
|
|
676
796
|
const batchSymbols: string[][] = [];
|
|
677
797
|
const provider = createProvider({
|
|
@@ -23,9 +23,9 @@ import {
|
|
|
23
23
|
toMarketDataContext,
|
|
24
24
|
} from "./selectors";
|
|
25
25
|
import { traceMarketData } from "./trace";
|
|
26
|
-
import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
26
|
+
import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../utils/price-history";
|
|
27
27
|
import { hasFreshQuoteForCurrentSession, isQuoteStaleForCurrentSession } from "../utils/quote-freshness";
|
|
28
|
-
import { TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
|
|
28
|
+
import { isIntradayResolution, TIME_RANGE_ORDER } from "../components/chart/chart-resolution";
|
|
29
29
|
import { measurePerf } from "../utils/perf-marks";
|
|
30
30
|
|
|
31
31
|
const EMPTY_MESSAGE = "No data available";
|
|
@@ -57,6 +57,29 @@ function getTimeRangeIndex(range: ChartRequest["bufferRange"]): number {
|
|
|
57
57
|
return TIME_RANGE_INDEX.get(range) ?? 0;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function isCurrentHistoryWindow(endDate?: Date): boolean {
|
|
61
|
+
if (!endDate) return true;
|
|
62
|
+
const endMs = endDate.getTime();
|
|
63
|
+
return Number.isFinite(endMs) && Date.now() - endMs < 60 * 60_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isIntradayChartRequest(request: ChartRequest): boolean {
|
|
67
|
+
const granularity = getChartGranularity(request);
|
|
68
|
+
if (granularity === "detail") return isCurrentHistoryWindow(request.endDate);
|
|
69
|
+
if (granularity === "resolution") {
|
|
70
|
+
return request.resolution ? isIntradayResolution(request.resolution) : false;
|
|
71
|
+
}
|
|
72
|
+
return request.bufferRange === "1D" || request.bufferRange === "1W" || request.bufferRange === "1M" || request.bufferRange === "3M";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeFreshChartData(points: PricePoint[] | null | undefined, request: ChartRequest): PricePoint[] {
|
|
76
|
+
const normalized = normalizePriceHistory(points ?? []);
|
|
77
|
+
if (isIntradayChartRequest(request) && isPriceHistoryStaleForCurrentWindow(normalized)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
function isSeedableChartRequest(
|
|
61
84
|
target: ChartRequest,
|
|
62
85
|
candidate: ChartRequest,
|
|
@@ -88,6 +111,20 @@ function hasFreshReadyEntry<T>(entry: QueryEntry<T>, ttlMs: number, now = Date.n
|
|
|
88
111
|
return entry.phase === "ready" && entry.fetchedAt != null && now - entry.fetchedAt < ttlMs;
|
|
89
112
|
}
|
|
90
113
|
|
|
114
|
+
function hasFreshQuoteEntry(
|
|
115
|
+
entry: QueryEntry<Quote>,
|
|
116
|
+
instrument: InstrumentRef,
|
|
117
|
+
ttlMs: number,
|
|
118
|
+
now = Date.now(),
|
|
119
|
+
): boolean {
|
|
120
|
+
const quote = resolveEntryData(entry);
|
|
121
|
+
if (!quote || entry.fetchedAt == null || now - entry.fetchedAt >= ttlMs) return false;
|
|
122
|
+
const quoteForFreshness = instrument.exchange && !quote.listingExchangeName && !quote.exchangeName
|
|
123
|
+
? { ...quote, listingExchangeName: instrument.exchange }
|
|
124
|
+
: quote;
|
|
125
|
+
return !isQuoteStaleForCurrentSession(quoteForFreshness, now);
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
function createAttempt(
|
|
92
129
|
providerId: string,
|
|
93
130
|
startedAt: number,
|
|
@@ -142,11 +179,9 @@ function readyQuoteEntry(
|
|
|
142
179
|
source: string,
|
|
143
180
|
attempts: ProviderAttempt[],
|
|
144
181
|
): QueryEntry<Quote> {
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
) {
|
|
149
|
-
return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: true });
|
|
182
|
+
if (isQuoteStaleForCurrentSession(quote)) {
|
|
183
|
+
const keepFreshQuote = hasFreshQuoteForCurrentSession([current.data, current.lastGoodData]);
|
|
184
|
+
return readyEntry(current, null, current.source ?? source, attempts, { keepLastGoodOnEmpty: keepFreshQuote });
|
|
150
185
|
}
|
|
151
186
|
return readyEntry(current, quote, source, attempts, { keepLastGoodOnEmpty: true });
|
|
152
187
|
}
|
|
@@ -189,16 +224,12 @@ const STREAM_QUOTE_FIELDS: Array<keyof Quote> = [
|
|
|
189
224
|
"dataSource",
|
|
190
225
|
];
|
|
191
226
|
|
|
192
|
-
function quoteTimestampMinute(quote: Quote): number | null {
|
|
193
|
-
return Number.isFinite(quote.lastUpdated) ? Math.floor(quote.lastUpdated / 60_000) : null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
227
|
function areStreamQuotesEquivalent(current: Quote | null | undefined, next: Quote): boolean {
|
|
197
228
|
if (!current) return false;
|
|
198
229
|
for (const field of STREAM_QUOTE_FIELDS) {
|
|
199
230
|
if (current[field] !== next[field]) return false;
|
|
200
231
|
}
|
|
201
|
-
return
|
|
232
|
+
return current.lastUpdated === next.lastUpdated
|
|
202
233
|
&& JSON.stringify(current.provenance ?? null) === JSON.stringify(next.provenance ?? null);
|
|
203
234
|
}
|
|
204
235
|
|
|
@@ -209,6 +240,53 @@ function hasCachedSnapshotData(financials: TickerFinancials): boolean {
|
|
|
209
240
|
|| financials.quarterlyStatements.length > 0;
|
|
210
241
|
}
|
|
211
242
|
|
|
243
|
+
type QuoteSubscriptionPriority = Pick<QuoteSubscriptionTarget, "surface" | "visible" | "selected" | "weight">;
|
|
244
|
+
|
|
245
|
+
interface QuoteSubscriptionEntry {
|
|
246
|
+
target: QuoteSubscriptionTarget;
|
|
247
|
+
targets: Map<number, QuoteSubscriptionTarget>;
|
|
248
|
+
removeTimer: ReturnType<typeof setTimeout> | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function quoteSubscriptionPriorityScore(target: QuoteSubscriptionTarget): number {
|
|
252
|
+
let score = Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0;
|
|
253
|
+
if (target.selected) score += 10_000;
|
|
254
|
+
if (target.visible) score += 5_000;
|
|
255
|
+
if (target.surface === "detail" || target.surface === "monitor") score += 4_000;
|
|
256
|
+
if (target.surface === "portfolio" || target.surface === "watchlist") score += 1_000;
|
|
257
|
+
if (target.surface === "screener") score += 700;
|
|
258
|
+
if (target.surface === "inline") score += 200;
|
|
259
|
+
return score;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function mergeQuoteSubscriptionTargets(targets: Iterable<QuoteSubscriptionTarget>): QuoteSubscriptionTarget | null {
|
|
263
|
+
let selectedTarget: QuoteSubscriptionTarget | null = null;
|
|
264
|
+
let selectedScore = -1;
|
|
265
|
+
let visible = false;
|
|
266
|
+
let selected = false;
|
|
267
|
+
let weight = 0;
|
|
268
|
+
|
|
269
|
+
for (const target of targets) {
|
|
270
|
+
const score = quoteSubscriptionPriorityScore(target);
|
|
271
|
+
if (!selectedTarget || score > selectedScore) {
|
|
272
|
+
selectedTarget = target;
|
|
273
|
+
selectedScore = score;
|
|
274
|
+
}
|
|
275
|
+
visible ||= target.visible === true;
|
|
276
|
+
selected ||= target.selected === true;
|
|
277
|
+
weight = Math.max(weight, Number.isFinite(target.weight) ? Math.max(0, target.weight ?? 0) : 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return selectedTarget
|
|
281
|
+
? {
|
|
282
|
+
...selectedTarget,
|
|
283
|
+
visible,
|
|
284
|
+
selected,
|
|
285
|
+
weight,
|
|
286
|
+
}
|
|
287
|
+
: null;
|
|
288
|
+
}
|
|
289
|
+
|
|
212
290
|
function errorEntry<T>(current: QueryEntry<T>, attempt: ProviderAttempt): QueryEntry<T> {
|
|
213
291
|
return {
|
|
214
292
|
...current,
|
|
@@ -231,12 +309,10 @@ export class MarketDataCoordinator {
|
|
|
231
309
|
private readonly keyVersions = new Map<string, number>();
|
|
232
310
|
private readonly inFlight = new Map<string, Promise<unknown>>();
|
|
233
311
|
private readonly chartRequests = new Map<string, ChartRequest>();
|
|
234
|
-
private readonly quoteSubscriptions = new Map<string,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
removeTimer: ReturnType<typeof setTimeout> | null;
|
|
239
|
-
}>();
|
|
312
|
+
private readonly quoteSubscriptions = new Map<string, QuoteSubscriptionEntry>();
|
|
313
|
+
private nextQuoteSubscriptionId = 1;
|
|
314
|
+
private quoteSubscriptionDispose: (() => void) | null = null;
|
|
315
|
+
private quoteSubscriptionSignature = "";
|
|
240
316
|
private pendingQuoteSubscriptionKeys = new Set<string>();
|
|
241
317
|
|
|
242
318
|
private readonly quoteStore = new QueryStore<Quote>((key) => this.bump(key));
|
|
@@ -452,11 +528,15 @@ export class MarketDataCoordinator {
|
|
|
452
528
|
};
|
|
453
529
|
}
|
|
454
530
|
|
|
455
|
-
private quoteTargetFromInstrument(
|
|
531
|
+
private quoteTargetFromInstrument(
|
|
532
|
+
instrument: InstrumentRef,
|
|
533
|
+
priority: QuoteSubscriptionPriority = {},
|
|
534
|
+
): QuoteSubscriptionTarget {
|
|
456
535
|
return {
|
|
457
536
|
symbol: instrument.symbol,
|
|
458
537
|
exchange: instrument.exchange ?? "",
|
|
459
538
|
context: toMarketDataContext(instrument),
|
|
539
|
+
...priority,
|
|
460
540
|
};
|
|
461
541
|
}
|
|
462
542
|
|
|
@@ -523,19 +603,37 @@ export class MarketDataCoordinator {
|
|
|
523
603
|
request: ChartRequest,
|
|
524
604
|
current: QueryEntry<PricePoint[]>,
|
|
525
605
|
): QueryEntry<PricePoint[]> {
|
|
526
|
-
|
|
527
|
-
|
|
606
|
+
const currentData = normalizeFreshChartData(resolveEntryData(current), request);
|
|
607
|
+
if (currentData.length) {
|
|
608
|
+
return loadingEntry({
|
|
609
|
+
...current,
|
|
610
|
+
data: currentData,
|
|
611
|
+
lastGoodData: currentData,
|
|
612
|
+
});
|
|
528
613
|
}
|
|
529
614
|
|
|
530
615
|
const seed = this.findChartSeedEntry(key, request);
|
|
531
616
|
if (!seed) {
|
|
532
|
-
return loadingEntry(
|
|
617
|
+
return loadingEntry({
|
|
618
|
+
...current,
|
|
619
|
+
data: null,
|
|
620
|
+
lastGoodData: null,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const seedData = normalizeFreshChartData(seed.data, request);
|
|
625
|
+
if (!seedData.length) {
|
|
626
|
+
return loadingEntry({
|
|
627
|
+
...current,
|
|
628
|
+
data: null,
|
|
629
|
+
lastGoodData: null,
|
|
630
|
+
});
|
|
533
631
|
}
|
|
534
632
|
|
|
535
633
|
return loadingEntry({
|
|
536
634
|
...current,
|
|
537
|
-
data:
|
|
538
|
-
lastGoodData:
|
|
635
|
+
data: seedData,
|
|
636
|
+
lastGoodData: seedData,
|
|
539
637
|
source: seed.entry.source,
|
|
540
638
|
fetchedAt: seed.entry.fetchedAt,
|
|
541
639
|
staleAt: seed.entry.staleAt,
|
|
@@ -668,7 +766,7 @@ export class MarketDataCoordinator {
|
|
|
668
766
|
for (const instrument of uniqueInstruments) {
|
|
669
767
|
const key = buildQuoteKey(instrument);
|
|
670
768
|
const current = this.quoteStore.get(key);
|
|
671
|
-
if (!options.forceRefresh &&
|
|
769
|
+
if (!options.forceRefresh && hasFreshQuoteEntry(current, instrument, SNAPSHOT_CACHE_TTL_MS)) {
|
|
672
770
|
results.set(key, current);
|
|
673
771
|
} else {
|
|
674
772
|
misses.push(instrument);
|
|
@@ -706,7 +804,13 @@ export class MarketDataCoordinator {
|
|
|
706
804
|
const key = buildChartKey(request);
|
|
707
805
|
this.chartRequests.set(key, request);
|
|
708
806
|
const current = this.chartStore.get(key);
|
|
709
|
-
|
|
807
|
+
const currentData = normalizeFreshChartData(resolveEntryData(current), request);
|
|
808
|
+
if (!options.forceRefresh && currentData.length > 0 && hasFreshEntryData(current, CHART_CACHE_TTL_MS)) {
|
|
809
|
+
if (currentData !== resolveEntryData(current)) {
|
|
810
|
+
return this.chartStore.update(key, (entry) =>
|
|
811
|
+
readyEntry(entry, currentData, entry.source ?? this.dataProvider.id, entry.attempts, { keepLastGoodOnEmpty: true })
|
|
812
|
+
);
|
|
813
|
+
}
|
|
710
814
|
return current;
|
|
711
815
|
}
|
|
712
816
|
const flightKey = options.forceRefresh ? `${key}|refresh` : key;
|
|
@@ -905,16 +1009,22 @@ export class MarketDataCoordinator {
|
|
|
905
1009
|
}
|
|
906
1010
|
|
|
907
1011
|
async loadFxRate(currency: string): Promise<QueryEntry<number>> {
|
|
908
|
-
const
|
|
1012
|
+
const normalizedCurrency = currency.trim().toUpperCase();
|
|
1013
|
+
const key = buildFxKey(normalizedCurrency);
|
|
909
1014
|
const current = this.fxStore.get(key);
|
|
910
1015
|
if (hasFreshEntryData(current, FX_CACHE_TTL_MS)) {
|
|
911
1016
|
return current;
|
|
912
1017
|
}
|
|
1018
|
+
if (normalizedCurrency === "USD") {
|
|
1019
|
+
const startedAt = Date.now();
|
|
1020
|
+
const attempts = [createAttempt("static", startedAt, "success")];
|
|
1021
|
+
return this.fxStore.update(key, (current) => readyEntry(current, 1, "static", attempts, { keepLastGoodOnEmpty: true }));
|
|
1022
|
+
}
|
|
913
1023
|
return this.runSingleFlight(key, async () => {
|
|
914
1024
|
this.fxStore.update(key, loadingEntry);
|
|
915
1025
|
const startedAt = Date.now();
|
|
916
1026
|
try {
|
|
917
|
-
const rate = await this.dataProvider.getExchangeRate(
|
|
1027
|
+
const rate = await this.dataProvider.getExchangeRate(normalizedCurrency);
|
|
918
1028
|
const attempts = [createAttempt(this.dataProvider.id, startedAt, "success")];
|
|
919
1029
|
return this.fxStore.update(key, (current) => readyEntry(current, rate, this.dataProvider.id, attempts, { keepLastGoodOnEmpty: true }));
|
|
920
1030
|
} catch (error) {
|
|
@@ -925,18 +1035,22 @@ export class MarketDataCoordinator {
|
|
|
925
1035
|
});
|
|
926
1036
|
}
|
|
927
1037
|
|
|
928
|
-
subscribeQuotes(targets: Array<{ instrument: InstrumentRef }>): () => void {
|
|
1038
|
+
subscribeQuotes(targets: Array<{ instrument: InstrumentRef; priority?: QuoteSubscriptionPriority }>): () => void {
|
|
929
1039
|
if (!this.dataProvider.subscribeQuotes || targets.length === 0) {
|
|
930
1040
|
return () => {};
|
|
931
1041
|
}
|
|
932
1042
|
|
|
1043
|
+
const subscriptionId = this.nextQuoteSubscriptionId++;
|
|
933
1044
|
const subscribedKeys = new Set<string>();
|
|
934
|
-
for (const { instrument } of targets) {
|
|
1045
|
+
for (const { instrument, priority } of targets) {
|
|
935
1046
|
const key = buildQuoteKey(instrument);
|
|
936
1047
|
subscribedKeys.add(key);
|
|
1048
|
+
const target = this.quoteTargetFromInstrument(instrument, priority);
|
|
937
1049
|
const existing = this.quoteSubscriptions.get(key);
|
|
938
1050
|
if (existing) {
|
|
939
|
-
existing.
|
|
1051
|
+
existing.targets.set(subscriptionId, target);
|
|
1052
|
+
existing.target = mergeQuoteSubscriptionTargets(existing.targets.values()) ?? target;
|
|
1053
|
+
this.pendingQuoteSubscriptionKeys.add(key);
|
|
940
1054
|
if (existing.removeTimer) {
|
|
941
1055
|
clearTimeout(existing.removeTimer);
|
|
942
1056
|
existing.removeTimer = null;
|
|
@@ -944,9 +1058,8 @@ export class MarketDataCoordinator {
|
|
|
944
1058
|
continue;
|
|
945
1059
|
}
|
|
946
1060
|
this.quoteSubscriptions.set(key, {
|
|
947
|
-
target
|
|
948
|
-
|
|
949
|
-
unsubscribe: null,
|
|
1061
|
+
target,
|
|
1062
|
+
targets: new Map([[subscriptionId, target]]),
|
|
950
1063
|
removeTimer: null,
|
|
951
1064
|
});
|
|
952
1065
|
this.pendingQuoteSubscriptionKeys.add(key);
|
|
@@ -957,44 +1070,68 @@ export class MarketDataCoordinator {
|
|
|
957
1070
|
for (const key of subscribedKeys) {
|
|
958
1071
|
const existing = this.quoteSubscriptions.get(key);
|
|
959
1072
|
if (!existing) continue;
|
|
960
|
-
existing.
|
|
961
|
-
|
|
1073
|
+
existing.targets.delete(subscriptionId);
|
|
1074
|
+
const mergedTarget = mergeQuoteSubscriptionTargets(existing.targets.values());
|
|
1075
|
+
if (mergedTarget) {
|
|
1076
|
+
existing.target = mergedTarget;
|
|
1077
|
+
this.pendingQuoteSubscriptionKeys.add(key);
|
|
1078
|
+
this.flushQuoteSubscriptions();
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (existing.removeTimer) continue;
|
|
962
1082
|
existing.removeTimer = setTimeout(() => {
|
|
963
1083
|
const current = this.quoteSubscriptions.get(key);
|
|
964
|
-
if (!current || current.
|
|
965
|
-
current.unsubscribe?.();
|
|
1084
|
+
if (!current || current.targets.size > 0) return;
|
|
966
1085
|
this.quoteSubscriptions.delete(key);
|
|
967
1086
|
this.pendingQuoteSubscriptionKeys.delete(key);
|
|
1087
|
+
this.flushQuoteSubscriptions();
|
|
968
1088
|
}, QUOTE_SUBSCRIPTION_REMOVE_GRACE_MS);
|
|
969
1089
|
}
|
|
970
1090
|
};
|
|
971
1091
|
}
|
|
972
1092
|
|
|
973
1093
|
private flushQuoteSubscriptions(): void {
|
|
974
|
-
|
|
1094
|
+
if (!this.dataProvider.subscribeQuotes) return;
|
|
975
1095
|
this.pendingQuoteSubscriptionKeys = new Set();
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1096
|
+
|
|
1097
|
+
const activeEntries = [...this.quoteSubscriptions.entries()]
|
|
1098
|
+
.filter(([, entry]) => entry.targets.size > 0 || entry.removeTimer)
|
|
1099
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
1100
|
+
const nextSignature = activeEntries.map(([key, entry]) => [
|
|
1101
|
+
key,
|
|
1102
|
+
entry.target.surface ?? "",
|
|
1103
|
+
entry.target.visible ? "visible" : "",
|
|
1104
|
+
entry.target.selected ? "selected" : "",
|
|
1105
|
+
Number.isFinite(entry.target.weight) ? entry.target.weight : "",
|
|
1106
|
+
].join(":")).join("|");
|
|
1107
|
+
if (nextSignature === this.quoteSubscriptionSignature) return;
|
|
1108
|
+
|
|
1109
|
+
this.quoteSubscriptionDispose?.();
|
|
1110
|
+
this.quoteSubscriptionDispose = null;
|
|
1111
|
+
this.quoteSubscriptionSignature = nextSignature;
|
|
1112
|
+
|
|
1113
|
+
const targets = activeEntries.map(([, entry]) => entry.target);
|
|
1114
|
+
if (targets.length === 0) return;
|
|
1115
|
+
|
|
1116
|
+
this.quoteSubscriptionDispose = this.dataProvider.subscribeQuotes(targets, (target, quote) => {
|
|
1117
|
+
const instrument: InstrumentRef = {
|
|
1118
|
+
symbol: target.symbol,
|
|
1119
|
+
exchange: target.exchange ?? "",
|
|
1120
|
+
brokerId: target.context?.brokerId,
|
|
1121
|
+
brokerInstanceId: target.context?.brokerInstanceId,
|
|
1122
|
+
instrument: target.context?.instrument ?? null,
|
|
1123
|
+
};
|
|
1124
|
+
this.applyStreamQuote(instrument, quote);
|
|
1125
|
+
});
|
|
990
1126
|
}
|
|
991
1127
|
|
|
992
1128
|
private applyStreamQuote(instrument: InstrumentRef, quote: Quote): void {
|
|
993
1129
|
const key = buildQuoteKey(instrument);
|
|
994
1130
|
const current = this.quoteStore.get(key);
|
|
995
1131
|
if (areStreamQuotesEquivalent(current.data ?? current.lastGoodData, quote)) return;
|
|
996
|
-
const
|
|
997
|
-
|
|
1132
|
+
const receivedAt = Date.now();
|
|
1133
|
+
const attempts = [createAttempt(quote.providerId ?? this.dataProvider.id, receivedAt, "success")];
|
|
1134
|
+
this.quoteStore.set(key, readyQuoteEntry(current, { ...quote, receivedAt }, quote.providerId ?? this.dataProvider.id, attempts));
|
|
998
1135
|
}
|
|
999
1136
|
}
|
|
1000
1137
|
|
|
@@ -73,6 +73,12 @@ function HooksHarness() {
|
|
|
73
73
|
return <text>{String(tick)}</text>;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
function FxRatesHarness() {
|
|
77
|
+
latestFxRates = useFxRatesMap(["usd", "EUR", null]);
|
|
78
|
+
|
|
79
|
+
return <text>fx</text>;
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
function ChartQueriesHarness({
|
|
77
83
|
initialRequests,
|
|
78
84
|
debounceMs,
|
|
@@ -147,6 +153,31 @@ describe("market-data hooks", () => {
|
|
|
147
153
|
expect(latestFinancialsMap).toBe(initialFinancialsMap);
|
|
148
154
|
});
|
|
149
155
|
|
|
156
|
+
test("does not load USD exchange rates from providers", async () => {
|
|
157
|
+
const loadedFxCurrencies: string[] = [];
|
|
158
|
+
const coordinator = {
|
|
159
|
+
subscribe: () => () => {},
|
|
160
|
+
getVersion: () => 1,
|
|
161
|
+
getFxEntry: (currency: string) => (currency === "EUR" ? readyEurEntry : readyUsdEntry),
|
|
162
|
+
loadFxRate: async (currency: string) => {
|
|
163
|
+
loadedFxCurrencies.push(currency);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
167
|
+
|
|
168
|
+
testSetup = await testRender(<FxRatesHarness />, {
|
|
169
|
+
width: 20,
|
|
170
|
+
height: 1,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
await testSetup!.renderOnce();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(latestFxRates?.get("USD")).toBe(1);
|
|
178
|
+
expect(loadedFxCurrencies).toEqual(["EUR"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
150
181
|
test("debounces chart query batches and cancels superseded schedules", async () => {
|
|
151
182
|
const loadedRanges: string[] = [];
|
|
152
183
|
const idleChartEntry = createIdleEntry<PricePoint[]>();
|
|
@@ -283,6 +283,7 @@ export function useFxRatesMap(currencies: Array<string | null | undefined>): Map
|
|
|
283
283
|
const coordinator = getSharedMarketDataCoordinator();
|
|
284
284
|
if (!coordinator) return;
|
|
285
285
|
for (const currency of normalizedCurrencies) {
|
|
286
|
+
if (currency === "USD") continue;
|
|
286
287
|
void coordinator.loadFxRate(currency);
|
|
287
288
|
}
|
|
288
289
|
}, [normalizedCurrencyKey]);
|
|
@@ -432,6 +432,9 @@ export function AiScreenerPane({ focused, width, height }: PaneProps) {
|
|
|
432
432
|
const quoteTargets = useMemo(() => (
|
|
433
433
|
sortedTickers
|
|
434
434
|
.map((ticker) => quoteSubscriptionTargetFromTicker(ticker, ticker.metadata.ticker))
|
|
435
|
+
.map((target) => target
|
|
436
|
+
? { ...target, surface: "screener" as const, visible: true, weight: 70 }
|
|
437
|
+
: null)
|
|
435
438
|
.filter((target): target is NonNullable<ReturnType<typeof quoteSubscriptionTargetFromTicker>> => target != null)
|
|
436
439
|
), [sortedTickers]);
|
|
437
440
|
|
|
@@ -752,24 +752,24 @@ export class ChatController {
|
|
|
752
752
|
this.notifiedMessageIds.clear();
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
-
send(content: string, replyToId?: string):
|
|
756
|
-
this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
|
|
755
|
+
send(content: string, replyToId?: string): boolean {
|
|
756
|
+
return this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
-
sendToChannel(channelId: string, content: string, replyToId?: string):
|
|
759
|
+
sendToChannel(channelId: string, content: string, replyToId?: string): boolean {
|
|
760
760
|
const normalizedChannelId = normalizeChannelId(channelId);
|
|
761
761
|
const channel = this.ensureChannelState(normalizedChannelId);
|
|
762
762
|
const messageContent = content.trim();
|
|
763
|
-
if (!messageContent) return;
|
|
764
|
-
if (!this.user?.emailVerified || !this.sessionToken) return;
|
|
765
|
-
if (this.hasPendingSend(channel, messageContent, replyToId)) return;
|
|
763
|
+
if (!messageContent) return false;
|
|
764
|
+
if (!this.user?.emailVerified || !this.sessionToken) return false;
|
|
765
|
+
if (this.hasPendingSend(channel, messageContent, replyToId)) return true;
|
|
766
766
|
if (!channel.wsConnection && this.user?.emailVerified && this.sessionToken) {
|
|
767
767
|
this.ensureConnection(normalizedChannelId);
|
|
768
768
|
}
|
|
769
769
|
const connection = channel.wsConnection;
|
|
770
770
|
if (!connection) {
|
|
771
771
|
this.notifyFn({ body: "Unable to send message right now.", type: "error" });
|
|
772
|
-
return;
|
|
772
|
+
return false;
|
|
773
773
|
}
|
|
774
774
|
|
|
775
775
|
const clientMessageId = channel.draftClientMessageId ?? createClientMessageId();
|
|
@@ -794,6 +794,7 @@ export class ChatController {
|
|
|
794
794
|
this.emit(normalizedChannelId);
|
|
795
795
|
this.notifyFn({ body: errorMessage, type: "error" });
|
|
796
796
|
});
|
|
797
|
+
return true;
|
|
797
798
|
}
|
|
798
799
|
|
|
799
800
|
private hasPendingSend(channel: ChannelRuntimeState, content: string, replyToId?: string): boolean {
|