gloomberb 0.15.0 → 0.15.1
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/api-client/central-bank-rates.ts +3 -0
- package/src/api-client/types.ts +1 -1
- package/src/app/pane-runtime/index.tsx +7 -5
- package/src/app/runtime/broker-import.test.tsx +89 -0
- package/src/app/runtime/broker-import.ts +29 -8
- package/src/brokers/sync-broker-instance.ts +67 -0
- package/src/cli/commands/overview.ts +6 -2
- package/src/components/chart/composite/composite-chart.tsx +21 -1
- package/src/components/chart/composite/format.ts +71 -8
- package/src/components/chart/composite/fx-precision.test.ts +21 -3
- package/src/components/chart/composite/scene.ts +13 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/layout/market-summary.ts +4 -2
- package/src/components/ticker/badge/format.test.ts +3 -3
- package/src/components/ticker/badge/format.ts +11 -9
- package/src/components/ticker/badge/text.test.tsx +6 -6
- package/src/core/state/app/state.ts +8 -2
- package/src/market-data/coordinator/events.ts +5 -0
- package/src/market-data/coordinator/index.ts +44 -10
- package/src/market-data/market/format.test.ts +54 -0
- package/src/market-data/market/format.ts +164 -5
- package/src/market-data/query-store.test.ts +87 -0
- package/src/market-data/query-store.ts +66 -9
- package/src/plugins/builtin/alerts/format.ts +4 -3
- package/src/plugins/builtin/analytics/display.ts +3 -3
- package/src/plugins/builtin/analytics/index.test.tsx +22 -22
- package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
- package/src/plugins/builtin/analytics/pane-model.ts +8 -8
- package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
- package/src/plugins/builtin/analytics/risk-client.ts +6 -8
- package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
- package/src/plugins/builtin/analytics/risk-model.ts +8 -0
- package/src/plugins/builtin/analytics/view.tsx +2 -2
- package/src/plugins/builtin/backtest/engine.test.ts +0 -1
- package/src/plugins/builtin/backtest/engine.ts +1 -4
- package/src/plugins/builtin/backtest/pane.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
- package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
- package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
- package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
- package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
- package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +2 -0
- package/src/plugins/builtin/chat/chat.test.tsx +2 -2
- package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
- package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
- package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
- package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
- package/src/plugins/builtin/congress-trades/table.tsx +1 -1
- package/src/plugins/builtin/cot/client.ts +2 -1
- package/src/plugins/builtin/cot/model.test.ts +9 -0
- package/src/plugins/builtin/cot/model.ts +30 -1
- package/src/plugins/builtin/cot/pane.tsx +2 -4
- package/src/plugins/builtin/credit-conditions/client.ts +2 -1
- package/src/plugins/builtin/crypto-board/client.ts +2 -1
- package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
- package/src/plugins/builtin/crypto-board/model.ts +11 -3
- package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
- package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
- package/src/plugins/builtin/custom-view/pane.tsx +18 -7
- package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
- package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
- package/src/plugins/builtin/debt-maturities/client.ts +16 -3
- package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
- package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
- package/src/plugins/builtin/debt-maturities/model.ts +2 -1
- package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
- package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
- package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
- package/src/plugins/builtin/econ/index.tsx +4 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
- package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
- package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
- package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
- package/src/plugins/builtin/equity-screener/headless.ts +18 -10
- package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
- package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
- package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
- package/src/plugins/builtin/futures/table.tsx +4 -3
- package/src/plugins/builtin/futures-curve/client.ts +2 -1
- package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
- package/src/plugins/builtin/futures-curve/model.ts +18 -14
- package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
- package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
- package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
- package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
- package/src/plugins/builtin/insider/index.tsx +15 -12
- package/src/plugins/builtin/insider/pane.test.tsx +24 -0
- package/src/plugins/builtin/market-movers/headless.ts +2 -2
- package/src/plugins/builtin/market-movers/model.ts +23 -10
- package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
- package/src/plugins/builtin/market-movers/screener.ts +3 -0
- package/src/plugins/builtin/market-movers/table.ts +2 -2
- package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
- package/src/plugins/builtin/market-valuation/client.ts +6 -1
- package/src/plugins/builtin/money-markets/client.ts +2 -1
- package/src/plugins/builtin/money-markets/pane.tsx +0 -1
- package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
- package/src/plugins/builtin/options/footer.test.ts +2 -2
- package/src/plugins/builtin/options/footer.ts +2 -2
- package/src/plugins/builtin/options/market-reference.test.ts +2 -2
- package/src/plugins/builtin/options/market-reference.ts +14 -2
- package/src/plugins/builtin/options/table.ts +5 -8
- package/src/plugins/builtin/options/view.test.tsx +2 -2
- package/src/plugins/builtin/options/view.tsx +1 -1
- package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
- package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
- package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
- package/src/plugins/builtin/options-calculator/surface.ts +13 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
- package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
- package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
- package/src/plugins/builtin/rate-path/client.ts +2 -1
- package/src/plugins/builtin/rate-path/headless.ts +1 -1
- package/src/plugins/builtin/rate-path/pane.tsx +0 -1
- package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
- package/src/plugins/builtin/realized-vol/headless.ts +1 -1
- package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
- package/src/plugins/builtin/realized-vol/model.ts +24 -9
- package/src/plugins/builtin/relative-rotation/client.ts +2 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
- package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
- package/src/plugins/builtin/risk-factors/data.ts +8 -1
- package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
- package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
- package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
- package/src/plugins/builtin/short-volume/client.ts +2 -1
- package/src/plugins/builtin/short-volume/pane.tsx +0 -1
- package/src/plugins/builtin/thirteenf/headless.ts +3 -3
- package/src/plugins/builtin/thirteenf/model.ts +14 -4
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
- package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
- package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
- package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
- package/src/plugins/builtin/thirteenf/table.ts +9 -3
- package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
- package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
- package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
- package/src/plugins/builtin/time-sales/model.test.ts +17 -1
- package/src/plugins/builtin/time-sales/model.ts +14 -3
- package/src/plugins/builtin/time-sales/pane.tsx +8 -4
- package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
- package/src/plugins/builtin/vol-surface/client.ts +4 -1
- package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
- package/src/plugins/builtin/vol-surface/model.ts +14 -2
- package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
- package/src/plugins/builtin/volatility/client.test.ts +13 -8
- package/src/plugins/builtin/volatility/client.ts +3 -2
- package/src/plugins/builtin/volatility/headless.test.ts +2 -2
- package/src/plugins/builtin/volatility/model.test.ts +4 -0
- package/src/plugins/builtin/volatility/model.ts +6 -7
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
- package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
- package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
- package/src/renderers/electrobun/bun/index.ts +2 -1
- package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
- package/src/sources/yahoo-finance.test.ts +5 -1
- package/src/sources/yahoo-finance.ts +2 -1
- package/src/time-series/forward-valuation.ts +6 -0
- package/src/time-series/resolve.ts +5 -1
- package/src/time-series/valuation-currency.test.ts +2 -0
- package/src/time-series/valuation-currency.ts +3 -1
- package/src/utils/exchange-rate-snapshot.test.ts +12 -1
- package/src/utils/exchange-rate-snapshot.ts +3 -2
- package/src/utils/format.ts +55 -20
- package/src/utils/fx-market-hours.test.ts +28 -0
- package/src/utils/fx-market-hours.ts +42 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
|
|
1
|
+
import { formatMarketPriceWithCurrency, liveQuoteFormatOptions } from "../../../market-data/market/format";
|
|
2
2
|
import type { Quote } from "../../../types/financials";
|
|
3
3
|
import { displayWidth } from "../../../utils/format";
|
|
4
4
|
|
|
@@ -12,13 +12,11 @@ export type TickerBadgeStatus = "loading" | "ready" | "ambiguous";
|
|
|
12
12
|
/** Padding inside the chip plus the gap that separates it from the next one. */
|
|
13
13
|
export const TICKER_BADGE_CHROME_WIDTH = 3;
|
|
14
14
|
|
|
15
|
+
/** One decimal on every tick, so a chip does not narrow when the move lands on a whole percent. */
|
|
15
16
|
export function formatTickerBadgeChange(changePercent: number): string {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const abs = Math.abs(normalized);
|
|
20
|
-
const body = Number.isInteger(abs) ? abs.toFixed(0) : abs.toFixed(1);
|
|
21
|
-
return `${normalized > 0 ? "+" : "-"}${body}%`;
|
|
17
|
+
const body = Math.abs(changePercent).toFixed(1);
|
|
18
|
+
if (!/[1-9]/.test(body)) return `${body}%`;
|
|
19
|
+
return `${changePercent > 0 ? "+" : "-"}${body}%`;
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
export interface TickerBadgeTextOptions {
|
|
@@ -48,10 +46,14 @@ function tickerBadgeTextCandidates({
|
|
|
48
46
|
const quoteForDisplay = liveQuote ? quote : null;
|
|
49
47
|
const candidates: string[] = [];
|
|
50
48
|
if (hovered && quoteForDisplay) {
|
|
51
|
-
candidates.push(`${symbol} ${formatMarketPriceWithCurrency(
|
|
49
|
+
candidates.push(`${symbol} ${formatMarketPriceWithCurrency(
|
|
50
|
+
quoteForDisplay.price,
|
|
51
|
+
quoteForDisplay.currency,
|
|
52
|
+
liveQuoteFormatOptions(quoteForDisplay, quoteForDisplay.currency),
|
|
53
|
+
)}`);
|
|
52
54
|
}
|
|
53
55
|
if (liveQuote) {
|
|
54
|
-
if (status === "ready" && quoteForDisplay) {
|
|
56
|
+
if (status === "ready" && quoteForDisplay && Number.isFinite(quoteForDisplay.changePercent)) {
|
|
55
57
|
candidates.push(`${symbol} ${formatTickerBadgeChange(quoteForDisplay.changePercent)}`);
|
|
56
58
|
} else if (status === "loading") {
|
|
57
59
|
candidates.push(`${symbol} \u2026`);
|
|
@@ -44,7 +44,7 @@ describe("TickerBadgeText", () => {
|
|
|
44
44
|
|
|
45
45
|
await testSetup.renderOnce();
|
|
46
46
|
|
|
47
|
-
expect(testSetup.captureCharFrame()).toContain("TSLA -5%");
|
|
47
|
+
expect(testSetup.captureCharFrame()).toContain("TSLA -5.0%");
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
test("falls back to the raw token when resolution failed", async () => {
|
|
@@ -63,7 +63,7 @@ describe("TickerBadgeText", () => {
|
|
|
63
63
|
|
|
64
64
|
const frame = testSetup.captureCharFrame();
|
|
65
65
|
expect(frame).toContain("$TSLA");
|
|
66
|
-
expect(frame).not.toContain("TSLA -5%");
|
|
66
|
+
expect(frame).not.toContain("TSLA -5.0%");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
test("opens the Ticker Research pane when a badge is clicked", async () => {
|
|
@@ -82,8 +82,8 @@ describe("TickerBadgeText", () => {
|
|
|
82
82
|
await testSetup.renderOnce();
|
|
83
83
|
|
|
84
84
|
const lines = testSetup.captureCharFrame().split("\n");
|
|
85
|
-
const row = lines.findIndex((line) => line.includes("TSLA -5%"));
|
|
86
|
-
const col = lines[row]?.indexOf("TSLA -5%") ?? -1;
|
|
85
|
+
const row = lines.findIndex((line) => line.includes("TSLA -5.0%"));
|
|
86
|
+
const col = lines[row]?.indexOf("TSLA -5.0%") ?? -1;
|
|
87
87
|
|
|
88
88
|
expect(row).toBeGreaterThanOrEqual(0);
|
|
89
89
|
expect(col).toBeGreaterThanOrEqual(0);
|
|
@@ -112,7 +112,7 @@ describe("TickerBadgeText", () => {
|
|
|
112
112
|
|
|
113
113
|
const frame = testSetup.captureCharFrame();
|
|
114
114
|
expect(frame).toContain("https://example.com");
|
|
115
|
-
expect(frame).toContain("TSLA -5%");
|
|
115
|
+
expect(frame).toContain("TSLA -5.0%");
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
test("renders usernames as clickable tags when a username opener is provided", async () => {
|
|
@@ -186,7 +186,7 @@ describe("TickerBadgeText", () => {
|
|
|
186
186
|
|
|
187
187
|
expect(firstRow).toBeGreaterThanOrEqual(0);
|
|
188
188
|
expect(secondRow).toBeGreaterThan(firstRow);
|
|
189
|
-
expect(lines[firstRow]).toContain("TSLA -5%");
|
|
189
|
+
expect(lines[firstRow]).toContain("TSLA -5.0%");
|
|
190
190
|
});
|
|
191
191
|
|
|
192
192
|
test("opens detected links without trailing punctuation when clicked", async () => {
|
|
@@ -41,9 +41,14 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
41
41
|
if (layoutState) return layoutState;
|
|
42
42
|
|
|
43
43
|
switch (action.type) {
|
|
44
|
-
case "SET_CONFIG":
|
|
44
|
+
case "SET_CONFIG": {
|
|
45
|
+
// A config that leaves the layouts alone (a broker sync, a setting) keeps
|
|
46
|
+
// the layout undo history; one that replaces them starts it over.
|
|
47
|
+
const sameLayouts = action.config.layout === state.config.layout
|
|
48
|
+
&& action.config.layouts === state.config.layouts
|
|
49
|
+
&& action.config.activeLayoutIndex === state.config.activeLayoutIndex;
|
|
45
50
|
return withFocusedPane(
|
|
46
|
-
{ ...state, themePreview: null, layoutHistory: {} },
|
|
51
|
+
{ ...state, themePreview: null, layoutHistory: sameLayouts ? state.layoutHistory : {} },
|
|
47
52
|
action.config,
|
|
48
53
|
{
|
|
49
54
|
paneState: {
|
|
@@ -52,6 +57,7 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
52
57
|
},
|
|
53
58
|
},
|
|
54
59
|
);
|
|
60
|
+
}
|
|
55
61
|
|
|
56
62
|
case "SET_ONBOARDING_STATE":
|
|
57
63
|
return {
|
|
@@ -49,6 +49,11 @@ export class MarketDataCoordinatorEvents {
|
|
|
49
49
|
return this.version;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** True while a mounted pane is subscribed to this key. */
|
|
53
|
+
hasKeyListeners(key: string): boolean {
|
|
54
|
+
return (this.keyListeners.get(key)?.size ?? 0) > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
52
57
|
getKeysVersion(keys: readonly string[]): number {
|
|
53
58
|
let version = 0;
|
|
54
59
|
for (const key of new Set(keys)) {
|
|
@@ -2,7 +2,7 @@ import type { CachedAssetArgs, CachedAssetMethod, CachedAssetValue, DataProvider
|
|
|
2
2
|
import type { OptionsChain, PricePoint, Quote, TickerFinancials } from "../../types/financials";
|
|
3
3
|
import { fetchHistoryResult, type HistoryResultRequest } from "../../sources/history-result";
|
|
4
4
|
import type { ChartRequest, InstrumentRef, OptionsRequest, SecFilingsRequest } from "../request-types";
|
|
5
|
-
import { QueryStore } from "../query-store";
|
|
5
|
+
import { QueryStore, type QueryStoreRetention } from "../query-store";
|
|
6
6
|
import type { QueryEntry } from "../result-types";
|
|
7
7
|
import type { CachedQueryHandle } from "../../data/cached-query";
|
|
8
8
|
import {
|
|
@@ -23,6 +23,7 @@ import { resolveTickerFinancialsQuoteState } from "../quotes/resolution";
|
|
|
23
23
|
import { measurePerf } from "../../utils/perf-marks";
|
|
24
24
|
import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
|
|
25
25
|
import { hasUsablePriceHistory } from "../../utils/price-history";
|
|
26
|
+
import { fxFreshUntil } from "../../utils/fx-market-hours";
|
|
26
27
|
import {
|
|
27
28
|
createBaselineChartRequest,
|
|
28
29
|
createChartLoadingEntry,
|
|
@@ -90,6 +91,23 @@ const STREAM_QUOTE_BASELINE_TTL_MS = 60_000;
|
|
|
90
91
|
const STREAM_RECEIPT_REFRESH_MS = 5_000;
|
|
91
92
|
/** Keeps a long session of browsing from holding a baseline per visited symbol. */
|
|
92
93
|
const STREAM_QUOTE_BASELINE_LIMIT = 256;
|
|
94
|
+
/**
|
|
95
|
+
* Per-store ceiling for entries nothing on screen is watching. Quotes and FX
|
|
96
|
+
* rates stay uncapped: they are a few fields each, and a large portfolio
|
|
97
|
+
* legitimately holds thousands. The capped stores are the heavy ones, where a
|
|
98
|
+
* single entry is a full price history, statement set or filing document.
|
|
99
|
+
*
|
|
100
|
+
* A key a pane is watching is never evicted, so this only governs entries left
|
|
101
|
+
* behind by navigation. 64 keeps a deep back-and-forth instant while bounding
|
|
102
|
+
* what browsing can retain. For scale: the refresh plan in #452 asked for 17
|
|
103
|
+
* financials on a 1934-ticker install, and a session there measured 2.33 MB of
|
|
104
|
+
* resident memory per distinct ticker visited.
|
|
105
|
+
*
|
|
106
|
+
* ponytail: one number for every capped store. Filing text is the heaviest
|
|
107
|
+
* entry of the group by some margin, so give secContentStore its own smaller
|
|
108
|
+
* cap if this ceiling turns out to be too generous for it.
|
|
109
|
+
*/
|
|
110
|
+
const RETAINED_ENTRIES_PER_STORE = 64;
|
|
93
111
|
|
|
94
112
|
// A store hands out a fresh idle entry for every key it has never seen, so
|
|
95
113
|
// two idle reads of the same key must count as the same entry.
|
|
@@ -112,14 +130,30 @@ export class MarketDataCoordinator {
|
|
|
112
130
|
private readonly cachedQueries = new Map<string, { query: CachedQueryHandle<unknown>; dispose: () => void }>();
|
|
113
131
|
private readonly streamQuoteBaselines = new Map<string, { baseline: TickerFinancials | null; readAt: number }>();
|
|
114
132
|
|
|
133
|
+
private readonly retained: QueryStoreRetention = {
|
|
134
|
+
maxRetainedEntries: RETAINED_ENTRIES_PER_STORE,
|
|
135
|
+
isWatched: (key) => this.events.hasKeyListeners(key),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The merged financials view below is derived from a snapshot entry and keyed
|
|
140
|
+
* the same way, so it has to go when that entry does. Left behind it would
|
|
141
|
+
* hold its own normalized copy of the price history, which is the larger half
|
|
142
|
+
* of what the snapshot key costs.
|
|
143
|
+
*/
|
|
144
|
+
private readonly retainedSnapshots: QueryStoreRetention = {
|
|
145
|
+
...this.retained,
|
|
146
|
+
onEvict: (key) => { this.financialsSnapshotCache.delete(key); },
|
|
147
|
+
};
|
|
148
|
+
|
|
115
149
|
private readonly quoteStore = new QueryStore<Quote>((key) => this.events.bump(key));
|
|
116
|
-
private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.events.bump(key));
|
|
117
|
-
private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.events.bump(key));
|
|
118
|
-
private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.events.bump(key));
|
|
119
|
-
private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.events.bump(key));
|
|
120
|
-
private readonly secDocumentsStore = new QueryStore<SecFilingDocument[]>((key) => this.events.bump(key));
|
|
121
|
-
private readonly secContentStore = new QueryStore<string | null>((key) => this.events.bump(key));
|
|
122
|
-
private readonly articleSummaryStore = new QueryStore<string | null>((key) => this.events.bump(key));
|
|
150
|
+
private readonly snapshotStore = new QueryStore<TickerFinancials>((key) => this.events.bump(key), this.retainedSnapshots);
|
|
151
|
+
private readonly chartStore = new QueryStore<PricePoint[]>((key) => this.events.bump(key), this.retained);
|
|
152
|
+
private readonly optionsStore = new QueryStore<OptionsChain>((key) => this.events.bump(key), this.retained);
|
|
153
|
+
private readonly secFilingsStore = new QueryStore<SecFilingItem[]>((key) => this.events.bump(key), this.retained);
|
|
154
|
+
private readonly secDocumentsStore = new QueryStore<SecFilingDocument[]>((key) => this.events.bump(key), this.retained);
|
|
155
|
+
private readonly secContentStore = new QueryStore<string | null>((key) => this.events.bump(key), this.retained);
|
|
156
|
+
private readonly articleSummaryStore = new QueryStore<string | null>((key) => this.events.bump(key), this.retained);
|
|
123
157
|
private readonly liveFxRates = new Map<string, { rate: number; observedAt: number; receivedAt: number }>();
|
|
124
158
|
/**
|
|
125
159
|
* The last rate a request loaded per currency; a streamed rate must stay
|
|
@@ -135,7 +169,7 @@ export class MarketDataCoordinator {
|
|
|
135
169
|
private readonly fxLegsByCurrency = new Map<string, FxLeg>();
|
|
136
170
|
private readonly fxStore = new QueryStore<number>(
|
|
137
171
|
(key) => this.events.bump(key),
|
|
138
|
-
(key, entry) => this.projectLiveFxRate(key, entry),
|
|
172
|
+
{ project: (key, entry) => this.projectLiveFxRate(key, entry) },
|
|
139
173
|
);
|
|
140
174
|
private readonly financialCacheStores: FinancialCacheStores = {
|
|
141
175
|
quoteStore: this.quoteStore,
|
|
@@ -635,7 +669,7 @@ export class MarketDataCoordinator {
|
|
|
635
669
|
error: null,
|
|
636
670
|
asOf: live.observedAt,
|
|
637
671
|
fetchedAt: Math.max(entry.fetchedAt ?? 0, live.receivedAt),
|
|
638
|
-
staleAt: live.observedAt
|
|
672
|
+
staleAt: fxFreshUntil(live.observedAt, FX_LIVE_RATE_STALE_MS),
|
|
639
673
|
};
|
|
640
674
|
}
|
|
641
675
|
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
formatMarketPriceWithCurrency,
|
|
12
12
|
formatMarketQuantity,
|
|
13
13
|
formatSignedMarketPrice,
|
|
14
|
+
liveQuoteFormatOptions,
|
|
14
15
|
resolveAssetDisplayKind,
|
|
16
|
+
stablePriceFractionDigits,
|
|
15
17
|
} from "./format";
|
|
16
18
|
|
|
17
19
|
describe("resolveAssetDisplayKind", () => {
|
|
@@ -228,3 +230,55 @@ test("money prices pad to the currency's minor unit without cutting crypto, FX o
|
|
|
228
230
|
expect(pad(3025, "JPY", { assetCategory: "EQUITY" })).toBe("¥3,025");
|
|
229
231
|
expect(pad(87, "USD", { assetCategory: "BOND", priceBasis: "percent-of-par" })).toBe("87% par");
|
|
230
232
|
});
|
|
233
|
+
|
|
234
|
+
// Streamed quotes re-render several times a second. Decimals chosen from each
|
|
235
|
+
// tick made 150.10 print as 150.1 and a coin crossing $100 drop two digits, so
|
|
236
|
+
// the numbers visibly jumped.
|
|
237
|
+
describe("live quote precision", () => {
|
|
238
|
+
const live = (values: number[], quote: Parameters<typeof liveQuoteFormatOptions>[0], currency = "USD") =>
|
|
239
|
+
values.map((value) => formatMarketPrice(value, liveQuoteFormatOptions(quote, currency)));
|
|
240
|
+
|
|
241
|
+
test("keeps one decimal count per instrument whatever the tick", () => {
|
|
242
|
+
expect(live([150.1, 150.12, 150], { instrumentType: "EQUITY", previousClose: 149.99 }))
|
|
243
|
+
.toEqual(["150.10", "150.12", "150.00"]);
|
|
244
|
+
expect(live([0.9987, 1.0012, 1.01], { instrumentType: "EQUITY", previousClose: 0.9987 }))
|
|
245
|
+
.toEqual(["0.9987", "1.0012", "1.0100"]);
|
|
246
|
+
expect(live([99.9912, 100.01, 100], { instrumentType: "CRYPTOCURRENCY", previousClose: 101 }))
|
|
247
|
+
.toEqual(["99.99", "100.01", "100.00"]);
|
|
248
|
+
expect(live([0.16234, 0.16], { instrumentType: "CRYPTOCURRENCY", previousClose: 0.16234 }))
|
|
249
|
+
.toEqual(["0.16234", "0.16000"]);
|
|
250
|
+
expect(live([1.17364, 1.17], { instrumentType: "CURRENCY", previousClose: 1.17364 }))
|
|
251
|
+
.toEqual(["1.17364", "1.17000"]);
|
|
252
|
+
expect(live([153.554, 153.5], { instrumentType: "CURRENCY", previousClose: 153.5540008544922 }, "JPY"))
|
|
253
|
+
.toEqual(["153.554", "153.500"]);
|
|
254
|
+
expect(live([6012.25, 6012.5, 6013], { instrumentType: "FUTURE", previousClose: 6010.75 }))
|
|
255
|
+
.toEqual(["6,012.25", "6,012.50", "6,013.00"]);
|
|
256
|
+
expect(live([2.35, 2.4], { instrumentType: "OPTION", previousClose: 2.35 })).toEqual(["2.35", "2.40"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("takes decimals from the currency's minor unit and from session prices, never from the tick", () => {
|
|
260
|
+
expect(live([3025, 3026], { instrumentType: "EQUITY", previousClose: 3025 }, "JPY")).toEqual(["3,025", "3,026"]);
|
|
261
|
+
expect(live([2710, 2710.5], { instrumentType: "EQUITY", previousClose: 2710, open: 2708.5 }, "JPY"))
|
|
262
|
+
.toEqual(["2,710.0", "2,710.5"]);
|
|
263
|
+
expect(live([35.365, 35.1], { instrumentType: "EQUITY", previousClose: 35.32, providerPriceDivisor: 100 }, "GBP"))
|
|
264
|
+
.toEqual(["35.3650", "35.1000"]);
|
|
265
|
+
// A provider's float32 close does not ask for its binary tail.
|
|
266
|
+
expect(stablePriceFractionDigits({ assetCategory: "FUTURE", referencePrice: 157.8800048828125, sessionPrices: [157.8800048828125] }))
|
|
267
|
+
.toBe(2);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a tick finer than the session prices keeps its digits, and a tiny move never prints as zero", () => {
|
|
271
|
+
// A half-yen print after whole-yen closes is not rounded to a price that never traded.
|
|
272
|
+
expect(live([1850.5], { instrumentType: "EQUITY", previousClose: 1850, open: 1851 }, "JPY")).toEqual(["1,850.5"]);
|
|
273
|
+
const shib = liveQuoteFormatOptions({ instrumentType: "CRYPTOCURRENCY", previousClose: 0.00001234 }, "USD");
|
|
274
|
+
expect(formatSignedMarketPrice(-0.00000036, { ...shib, maxWidth: 9 })).toBe("-3.6e-7");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("changes use the price's decimals, and a move that rounds to zero is unsigned", () => {
|
|
278
|
+
const options = liveQuoteFormatOptions({ instrumentType: "EQUITY", previousClose: 150 }, "USD");
|
|
279
|
+
expect([0.135, 1.2, 0, -0.004, -0.5].map((change) => formatSignedMarketPrice(change, options)))
|
|
280
|
+
.toEqual(["+0.14", "+1.20", "0.00", "0.00", "-0.50"]);
|
|
281
|
+
expect(formatMarketChangeWithCurrency(-0.004, "USD", options)).toBe("$0.00");
|
|
282
|
+
expect(formatMarketPriceWithCurrency(-0.001, "USD", options)).toBe("$0.00");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -22,6 +22,10 @@ export interface MarketFormatOptions extends AssetDisplayContext {
|
|
|
22
22
|
fixedFractionDigits?: number;
|
|
23
23
|
/** Source units per displayed currency unit; a price quoted in pence keeps its pence decimals in pounds. */
|
|
24
24
|
quotedUnitDivisor?: number;
|
|
25
|
+
/** A price that holds still for the session, such as the previous close. With
|
|
26
|
+
* `fixedFractionDigits`, the kind's decimal ceiling is read at this price
|
|
27
|
+
* instead of at the value, so a live price keeps its decimals as it moves. */
|
|
28
|
+
referencePrice?: number;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/** Current price fields use the quote's source metadata; stored cost/mark
|
|
@@ -117,6 +121,11 @@ function formatVariableNumber(
|
|
|
117
121
|
if (fitsWidth(formatted, maxWidth)) return formatted;
|
|
118
122
|
}
|
|
119
123
|
}
|
|
124
|
+
// A cell too narrow for the minimum gives up decimals one at a time.
|
|
125
|
+
for (let decimals = minimumFractionDigits - 1; decimals > 0; decimals -= 1) {
|
|
126
|
+
const formatted = getNumberFormatter(decimals, decimals, false).format(value);
|
|
127
|
+
if (fitsWidth(formatted, maxWidth)) return formatted;
|
|
128
|
+
}
|
|
120
129
|
|
|
121
130
|
return getNumberFormatter(0, 0, false).format(value);
|
|
122
131
|
}
|
|
@@ -194,7 +203,11 @@ function formatPriceNumber(value: number, decimals: number, maxWidth: number | u
|
|
|
194
203
|
? "0"
|
|
195
204
|
: formatVariableNumber(value, Math.max(decimals, tinyDecimals), tinyDecimals > 0 ? undefined : maxWidth, minimumDecimals);
|
|
196
205
|
if (value === 0 || !Number.isFinite(value) || (Number(rendered.replaceAll(",", "")) !== 0 && fitsWidth(rendered, maxWidth))) return rendered;
|
|
197
|
-
|
|
206
|
+
return scientificPrice(value, maxWidth);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A constrained cell must not imply a worthless asset or unchanged price. */
|
|
210
|
+
function scientificPrice(value: number, maxWidth: number | undefined): string {
|
|
198
211
|
for (let precision = 4; precision >= 1; precision -= 1) {
|
|
199
212
|
const scientific = Number(value.toPrecision(precision)).toExponential();
|
|
200
213
|
if (fitsWidth(scientific, maxWidth)) return scientific;
|
|
@@ -310,11 +323,21 @@ export function formatMarketPrice(value: number | undefined, options: MarketForm
|
|
|
310
323
|
const quotedUnitDigits = kind === "equity" || kind === "other" ? quotedUnitFractionDigits(options.quotedUnitDivisor) : 0;
|
|
311
324
|
const fixedFractionDigits = options.fixedFractionDigits;
|
|
312
325
|
if (fixedFractionDigits !== undefined) {
|
|
326
|
+
const reference = positiveMagnitude(options.referencePrice);
|
|
313
327
|
const maxFractionDigits = kind === "other" && options.priceRange !== undefined
|
|
314
328
|
? 6
|
|
315
|
-
:
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
: reference === undefined
|
|
330
|
+
? getBasePriceMaxFractionDigits(kind, value) + quotedUnitDigits
|
|
331
|
+
: marketPriceFractionDigitCeiling(reference, options) + quotedUnitDigits;
|
|
332
|
+
let digits = Math.max(0, Math.min(fixedFractionDigits, maxFractionDigits));
|
|
333
|
+
// A live price keeps its instrument's decimals, but a trade finer than the
|
|
334
|
+
// session prices showed (a half-yen print after whole-yen closes) is not
|
|
335
|
+
// rounded to a price that never traded.
|
|
336
|
+
if (reference !== undefined) digits = Math.max(digits, writtenFractionDigits(Math.abs(value), maxFractionDigits));
|
|
337
|
+
const fitted = formatVariableNumber(value, digits, options.maxWidth, digits);
|
|
338
|
+
return hasNonZeroDigit(fitted) || Number(Math.abs(value).toFixed(digits)) === 0
|
|
339
|
+
? fitted
|
|
340
|
+
: scientificPrice(value, options.maxWidth);
|
|
318
341
|
}
|
|
319
342
|
const minimumFractionDigits = Math.max(
|
|
320
343
|
0,
|
|
@@ -344,6 +367,13 @@ export function formatMarketCost(value: number | undefined, options: MarketForma
|
|
|
344
367
|
export function formatSignedMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
|
|
345
368
|
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
346
369
|
if (resolvePriceBasis(options.priceBasis, options.assetCategory) === null) return "—";
|
|
370
|
+
if (options.fixedFractionDigits !== undefined) {
|
|
371
|
+
// A move at the instrument's fixed decimals: the sign always has its column,
|
|
372
|
+
// and a move that rounds to zero is unsigned (0.00, never +0.00 or -0.00).
|
|
373
|
+
const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
|
|
374
|
+
const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth });
|
|
375
|
+
return hasNonZeroDigit(body) ? `${value > 0 ? "+" : "-"}${body}` : body;
|
|
376
|
+
}
|
|
347
377
|
if (value > 0) {
|
|
348
378
|
const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
|
|
349
379
|
return `+${formatMarketPrice(value, { ...options, maxWidth })}`;
|
|
@@ -363,6 +393,11 @@ export function formatMarketChangeWithCurrency(
|
|
|
363
393
|
): string {
|
|
364
394
|
if (value == null || !Number.isFinite(value)) return "—";
|
|
365
395
|
if (resolvePriceBasis(options.priceBasis, options.assetCategory) !== "per-unit") return formatSignedMarketPrice(value, options);
|
|
396
|
+
if (options.fixedFractionDigits !== undefined) {
|
|
397
|
+
const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
|
|
398
|
+
const body = formatMarketPriceWithCurrency(Math.abs(value), currency, { ...options, maxWidth });
|
|
399
|
+
return hasNonZeroDigit(body) ? `${value > 0 ? "+" : "-"}${body}` : body;
|
|
400
|
+
}
|
|
366
401
|
if (resolveAssetDisplayKind(options) === "contract" || quotedUnitFractionDigits(options.quotedUnitDivisor) > 0) {
|
|
367
402
|
return `${value > 0 ? "+" : ""}${formatMarketPriceWithCurrency(value, currency, {
|
|
368
403
|
...options, minimumFractionDigits: Math.max(2, options.minimumFractionDigits ?? 0),
|
|
@@ -396,6 +431,130 @@ export function withCurrencyMinorDigits(options: MarketFormatOptions, currency:
|
|
|
396
431
|
return { ...options, minimumFractionDigits: Math.max(options.minimumFractionDigits ?? 0, Math.min(2, currencyMinorDigits(currency))) };
|
|
397
432
|
}
|
|
398
433
|
|
|
434
|
+
function positiveMagnitude(value: number | null | undefined): number | undefined {
|
|
435
|
+
return value != null && Number.isFinite(value) && value !== 0 ? Math.abs(value) : undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function hasNonZeroDigit(text: string): boolean {
|
|
439
|
+
return /[1-9]/.test(text);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Decimals a price was written with, ignoring binary noise: 6012.25 is 2, so
|
|
443
|
+
* is a provider's float32 157.8800048828125, and a change of
|
|
444
|
+
* 0.00004999999999988347 is 5. */
|
|
445
|
+
function writtenFractionDigits(value: number, cap: number): number {
|
|
446
|
+
const float32 = Math.fround(value) === value;
|
|
447
|
+
for (let digits = 0; digits < cap; digits += 1) {
|
|
448
|
+
const written = Number(value.toFixed(digits));
|
|
449
|
+
if (Math.abs(written - value) <= 1e-9 * value) return digits;
|
|
450
|
+
if (float32 && Math.fround(written) === value) return digits;
|
|
451
|
+
}
|
|
452
|
+
return cap;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const OPTION_CONTRACT_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FOP"]);
|
|
456
|
+
|
|
457
|
+
export interface StablePriceContext extends AssetDisplayContext {
|
|
458
|
+
/** The price's currency; its minor unit sets an equity's decimals (USD 2, JPY 0). */
|
|
459
|
+
currency?: string;
|
|
460
|
+
quotedUnitDivisor?: number;
|
|
461
|
+
/** A price that holds still for the session, such as the previous close.
|
|
462
|
+
* Never the live price: a precision read from each tick is what makes the
|
|
463
|
+
* digits jump. */
|
|
464
|
+
referencePrice?: number;
|
|
465
|
+
/** Session-fixed prices as the feed wrote them (previous close, open).
|
|
466
|
+
* Their decimals can ask for more than the default, such as a half-yen
|
|
467
|
+
* Tokyo line. */
|
|
468
|
+
sessionPrices?: readonly (number | null | undefined)[];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The decimals a live price keeps for its instrument, whatever the tick: an
|
|
473
|
+
* equity above $1 always shows cents (150.10, not 150.1), and a coin, pair or
|
|
474
|
+
* contract takes its count from the reference price, never from the latest
|
|
475
|
+
* trade, so crossing $100 or 1.0000 on a tick changes nothing. A floor per kind
|
|
476
|
+
* is raised to the decimals the reference and session prices were written
|
|
477
|
+
* with (a half-yen close, a 5-decimal FX feed), up to the kind's ceiling.
|
|
478
|
+
* Changes, bid, ask and ranges of the same instrument use the same count.
|
|
479
|
+
*/
|
|
480
|
+
export function stablePriceFractionDigits(context: StablePriceContext): number {
|
|
481
|
+
const reference = positiveMagnitude(context.referencePrice);
|
|
482
|
+
const kind = resolveAssetDisplayKind(context);
|
|
483
|
+
const unitDigits = kind === "equity" || kind === "other" ? quotedUnitFractionDigits(context.quotedUnitDivisor) : 0;
|
|
484
|
+
const ceiling = reference === undefined ? 8 : marketPriceFractionDigitCeiling(reference, context) + unitDigits;
|
|
485
|
+
const below = (limit: number) => reference !== undefined && reference < limit;
|
|
486
|
+
// Four significant digits for a sub-unit price.
|
|
487
|
+
const subUnitDigits = reference === undefined ? 4 : Math.min(8, Math.max(4, 3 - Math.floor(Math.log10(reference))));
|
|
488
|
+
let digits: number;
|
|
489
|
+
if (resolvePriceBasis(context.priceBasis, context.assetCategory) === "percent-of-par") {
|
|
490
|
+
digits = 2;
|
|
491
|
+
} else if (kind === "equity" || kind === "other") {
|
|
492
|
+
// A sub-unit price keeps four decimals of the major unit, pence lines
|
|
493
|
+
// included; above one unit the currency's minor unit (and pence) apply.
|
|
494
|
+
digits = below(1) ? 4 : Math.min(2, currencyMinorDigits(context.currency)) + unitDigits;
|
|
495
|
+
} else if (kind === "cash") {
|
|
496
|
+
digits = below(10) ? 4 : 2;
|
|
497
|
+
} else if (kind === "crypto") {
|
|
498
|
+
digits = below(1) ? subUnitDigits : below(100) ? 4 : 2;
|
|
499
|
+
} else if (kind === "contract") {
|
|
500
|
+
const type = normalizeType(context.contractSecType || context.assetCategory);
|
|
501
|
+
digits = OPTION_CONTRACT_TYPES.has(type) || !CONTRACT_TYPES.has(type) || !below(1) ? 2 : subUnitDigits;
|
|
502
|
+
} else {
|
|
503
|
+
digits = 2;
|
|
504
|
+
}
|
|
505
|
+
if (kind !== "crypto" && reference !== undefined) digits = Math.max(digits, tinyPriceFractionDigits(reference));
|
|
506
|
+
for (const price of context.sessionPrices ?? []) {
|
|
507
|
+
const magnitude = positiveMagnitude(price);
|
|
508
|
+
if (magnitude !== undefined) digits = Math.max(digits, writtenFractionDigits(magnitude, ceiling));
|
|
509
|
+
}
|
|
510
|
+
return digits;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
type LiveQuotePrices = Pick<Quote, "price" | "previousClose" | "regularClose" | "open">;
|
|
514
|
+
|
|
515
|
+
/** The session-fixed price a quote's decimals are chosen from. */
|
|
516
|
+
export function quoteReferencePrice(quote: Partial<LiveQuotePrices> | null | undefined): number | undefined {
|
|
517
|
+
return positiveMagnitude(quote?.previousClose)
|
|
518
|
+
?? positiveMagnitude(quote?.regularClose)
|
|
519
|
+
?? positiveMagnitude(quote?.open)
|
|
520
|
+
?? positiveMagnitude(quote?.price);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Pins a live price's options to its instrument's decimals (see
|
|
525
|
+
* stablePriceFractionDigits). `prices` supplies the session-fixed prices, from
|
|
526
|
+
* the quote when there is one or a stored mark when there is not.
|
|
527
|
+
*/
|
|
528
|
+
export function withStablePriceDigits(
|
|
529
|
+
options: MarketFormatOptions,
|
|
530
|
+
currency: string | undefined,
|
|
531
|
+
prices: Partial<LiveQuotePrices> | null | undefined,
|
|
532
|
+
): MarketFormatOptions {
|
|
533
|
+
if (resolvePriceBasis(options.priceBasis, options.assetCategory) === null) return options;
|
|
534
|
+
// Without any session price, the live price can only give the magnitude.
|
|
535
|
+
const referencePrice = quoteReferencePrice(prices);
|
|
536
|
+
return {
|
|
537
|
+
...options,
|
|
538
|
+
referencePrice,
|
|
539
|
+
fixedFractionDigits: stablePriceFractionDigits({
|
|
540
|
+
...options,
|
|
541
|
+
currency,
|
|
542
|
+
referencePrice,
|
|
543
|
+
sessionPrices: [prices?.previousClose, prices?.regularClose, prices?.open],
|
|
544
|
+
}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Options for a quote's live prices and changes: its instrument's fixed decimals, whatever the tick. */
|
|
549
|
+
export function liveQuoteFormatOptions(
|
|
550
|
+
quote: (Pick<Quote, "instrumentType" | "priceBasis" | "providerPriceDivisor"> & Partial<LiveQuotePrices>) | null | undefined,
|
|
551
|
+
currency: string | undefined,
|
|
552
|
+
fallbackAssetCategory?: string,
|
|
553
|
+
metadataInstrumentType?: string,
|
|
554
|
+
): MarketFormatOptions {
|
|
555
|
+
return withStablePriceDigits(quoteFormatOptions(quote, fallbackAssetCategory, metadataInstrumentType), currency, quote);
|
|
556
|
+
}
|
|
557
|
+
|
|
399
558
|
export function formatMarketPriceWithCurrency(
|
|
400
559
|
value: number | undefined,
|
|
401
560
|
currency = "USD",
|
|
@@ -410,7 +569,7 @@ export function formatMarketPriceWithCurrency(
|
|
|
410
569
|
? undefined
|
|
411
570
|
: Math.max(1, options.maxWidth - sign.length - symbol.length);
|
|
412
571
|
const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth: numericWidth });
|
|
413
|
-
return `${sign}${symbol}${body}`;
|
|
572
|
+
return `${hasNonZeroDigit(body) ? sign : ""}${symbol}${body}`;
|
|
414
573
|
}
|
|
415
574
|
|
|
416
575
|
export function formatMarketCostWithCurrency(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { QueryStore } from "./query-store";
|
|
3
|
+
import type { QueryEntry } from "./result-types";
|
|
4
|
+
|
|
5
|
+
function ready(value: string): QueryEntry<string> {
|
|
6
|
+
return {
|
|
7
|
+
phase: "ready",
|
|
8
|
+
data: value,
|
|
9
|
+
lastGoodData: value,
|
|
10
|
+
source: "test",
|
|
11
|
+
fetchedAt: 0,
|
|
12
|
+
staleAt: null,
|
|
13
|
+
error: null,
|
|
14
|
+
attempts: [],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("QueryStore retention", () => {
|
|
19
|
+
it("keeps every entry when no ceiling is configured", () => {
|
|
20
|
+
const store = new QueryStore<string>(() => {});
|
|
21
|
+
for (let index = 0; index < 500; index += 1) store.set(`k${index}`, ready(`v${index}`));
|
|
22
|
+
expect(store.size).toBe(500);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops the oldest unwatched entries once past the ceiling", () => {
|
|
26
|
+
const store = new QueryStore<string>(() => {}, { maxRetainedEntries: 3 });
|
|
27
|
+
for (const key of ["a", "b", "c", "d", "e"]) store.set(key, ready(key));
|
|
28
|
+
|
|
29
|
+
expect(store.size).toBe(3);
|
|
30
|
+
expect(store.get("a").phase).toBe("idle");
|
|
31
|
+
expect(store.get("b").phase).toBe("idle");
|
|
32
|
+
expect(store.get("e").data).toBe("e");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never evicts a key a pane is watching, even past the ceiling", () => {
|
|
36
|
+
const watched = new Set(["a", "b", "c", "d"]);
|
|
37
|
+
const store = new QueryStore<string>(() => {}, {
|
|
38
|
+
maxRetainedEntries: 2,
|
|
39
|
+
isWatched: (key) => watched.has(key),
|
|
40
|
+
});
|
|
41
|
+
for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
|
|
42
|
+
|
|
43
|
+
// Four visible panes outrank a ceiling of two: blanking one would be worse
|
|
44
|
+
// than holding the memory.
|
|
45
|
+
expect(store.size).toBe(4);
|
|
46
|
+
|
|
47
|
+
// Once they unmount, the next write collects them.
|
|
48
|
+
watched.clear();
|
|
49
|
+
store.set("e", ready("e"));
|
|
50
|
+
expect(store.size).toBe(2);
|
|
51
|
+
expect(store.get("e").data).toBe("e");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("evicts around watched keys rather than stopping at the first one", () => {
|
|
55
|
+
const store = new QueryStore<string>(() => {}, {
|
|
56
|
+
maxRetainedEntries: 2,
|
|
57
|
+
isWatched: (key) => key === "a",
|
|
58
|
+
});
|
|
59
|
+
for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
|
|
60
|
+
|
|
61
|
+
expect(store.size).toBe(2);
|
|
62
|
+
expect(store.get("a").data).toBe("a");
|
|
63
|
+
expect(store.get("d").data).toBe("d");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reports every evicted key so derived caches can drop it too", () => {
|
|
67
|
+
const evicted: string[] = [];
|
|
68
|
+
const store = new QueryStore<string>(() => {}, {
|
|
69
|
+
maxRetainedEntries: 2,
|
|
70
|
+
onEvict: (key) => evicted.push(key),
|
|
71
|
+
});
|
|
72
|
+
for (const key of ["a", "b", "c", "d"]) store.set(key, ready(key));
|
|
73
|
+
|
|
74
|
+
expect(evicted).toEqual(["a", "b"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns an evicted key to idle so the next read refetches", () => {
|
|
78
|
+
const store = new QueryStore<string>(() => {}, { maxRetainedEntries: 1 });
|
|
79
|
+
store.set("a", ready("a"));
|
|
80
|
+
store.set("b", ready("b"));
|
|
81
|
+
|
|
82
|
+
const evicted = store.get("a");
|
|
83
|
+
expect(evicted.phase).toBe("idle");
|
|
84
|
+
expect(evicted.data).toBeNull();
|
|
85
|
+
expect(evicted.lastGoodData).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|