gloomberb 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/gloomberb +9 -1
- 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/cli/native-smoke.ts +2 -0
- 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/components/ui/index.ts +2 -1
- package/src/components/ui/multi-select/dialog.tsx +46 -4
- package/src/components/ui/number-prompt-dialog.tsx +71 -0
- 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 +50 -3
- package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
- package/src/plugins/builtin/chart-composer/presets.ts +48 -0
- package/src/plugins/builtin/chart-composer/settings.ts +42 -11
- 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/renderers/opentui/render-timing-smoke.ts +20 -0
- 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
|
@@ -10,7 +10,7 @@ import { useQuoteEntry, useResolvedEntryValue } from "../../market-data/hooks";
|
|
|
10
10
|
import { useQuoteStreaming } from "../../state/hooks/quote-streaming";
|
|
11
11
|
import type { QuoteSubscriptionTarget } from "../../types/data-provider";
|
|
12
12
|
import { formatPercentRaw } from "../../utils/format";
|
|
13
|
-
import { formatMarketPrice } from "../../market-data/market/format";
|
|
13
|
+
import { formatMarketPrice, liveQuoteFormatOptions } from "../../market-data/market/format";
|
|
14
14
|
import { getActiveQuoteDisplay, marketStateColor, marketStateCountdown, marketStateLabel } from "../../market-data/market/status";
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -115,8 +115,10 @@ export function useMarketSummary(): MarketSummary {
|
|
|
115
115
|
|
|
116
116
|
const activeSpyQuote = getActiveQuoteDisplay(spyQuote);
|
|
117
117
|
const spyColor = activeSpyQuote?.change != null ? priceColor(activeSpyQuote.change, colors) : colors.textDim;
|
|
118
|
+
// Fixed decimals and a percent slot as wide as "+0.53%", so a streamed tick
|
|
119
|
+
// never slides the market-state label or re-fits the cluster.
|
|
118
120
|
const spyText = activeSpyQuote
|
|
119
|
-
? `SPY ${formatMarketPrice(activeSpyQuote.price,
|
|
121
|
+
? `SPY ${formatMarketPrice(activeSpyQuote.price, liveQuoteFormatOptions(spyQuote, spyQuote?.currency, "ETF"))} ${formatPercentRaw(activeSpyQuote.changePercent).padStart(6)}`
|
|
120
122
|
: "SPY —";
|
|
121
123
|
|
|
122
124
|
const mktCountdown = mktState ? marketStateCountdown(mktState, now) : null;
|
|
@@ -16,10 +16,10 @@ const quote: Quote = {
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
describe("formatTickerBadgeChange", () => {
|
|
19
|
-
test("keeps one decimal and loses a pointless minus on zero", () => {
|
|
19
|
+
test("keeps one decimal on whole moves and loses a pointless minus on zero", () => {
|
|
20
20
|
expect(formatTickerBadgeChange(1.24)).toBe("+1.2%");
|
|
21
|
-
expect(formatTickerBadgeChange(-5)).toBe("-5%");
|
|
22
|
-
expect(formatTickerBadgeChange(-0.01)).toBe("0%");
|
|
21
|
+
expect(formatTickerBadgeChange(-5)).toBe("-5.0%");
|
|
22
|
+
expect(formatTickerBadgeChange(-0.01)).toBe("0.0%");
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -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 () => {
|
|
@@ -22,7 +22,8 @@ export { Menu, MenuPopover } from "./menu";
|
|
|
22
22
|
export type { MenuItem, MenuProps, MenuPopoverProps } from "./menu";
|
|
23
23
|
|
|
24
24
|
export { MultiSelectDialogButton, MultiSelectDialogContent } from "./multi-select/dialog";
|
|
25
|
-
export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint } from "./multi-select/dialog";
|
|
25
|
+
export type { MultiSelectDialogButtonHandle, MultiSelectPopoverAnchorPoint, MultiSelectRowAction } from "./multi-select/dialog";
|
|
26
|
+
export { NumberPromptDialog } from "./number-prompt-dialog";
|
|
26
27
|
|
|
27
28
|
export { SelectButton } from "./select-button";
|
|
28
29
|
export type { SelectButtonOption, SelectButtonProps, SelectControl } from "./select-button";
|
|
@@ -37,6 +37,19 @@ import {
|
|
|
37
37
|
type MultiSelectOption,
|
|
38
38
|
} from "./index";
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* One action on the highlighted row, shown beside Done: editing a value the
|
|
42
|
+
* option carries, such as an indicator's period.
|
|
43
|
+
*/
|
|
44
|
+
export interface MultiSelectRowAction {
|
|
45
|
+
label: string;
|
|
46
|
+
shortcut: string;
|
|
47
|
+
/** Whether the highlighted option offers it; `selected` is its checkbox. */
|
|
48
|
+
appliesTo(value: string, selected: boolean): boolean;
|
|
49
|
+
/** A returned label replaces the row's label while the dialog stays open. */
|
|
50
|
+
run(value: string): Promise<string | void> | string | void;
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
export interface MultiSelectDialogContentProps extends AlertContext {
|
|
41
54
|
title: string;
|
|
42
55
|
options: MultiSelectOption[];
|
|
@@ -45,6 +58,7 @@ export interface MultiSelectDialogContentProps extends AlertContext {
|
|
|
45
58
|
ordered?: boolean;
|
|
46
59
|
emptyLabel?: string;
|
|
47
60
|
idPrefix?: string;
|
|
61
|
+
rowAction?: MultiSelectRowAction;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
export interface MultiSelectDialogButtonProps {
|
|
@@ -61,6 +75,8 @@ export interface MultiSelectDialogButtonProps {
|
|
|
61
75
|
shortcutKey?: string | string[];
|
|
62
76
|
shortcutActive?: boolean;
|
|
63
77
|
onOpenChange?: (open: boolean) => void;
|
|
78
|
+
/** Opens the dialog on desktop too, since the popover menu has no row actions. */
|
|
79
|
+
rowAction?: MultiSelectRowAction;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
export interface MultiSelectPopoverAnchorPoint {
|
|
@@ -197,6 +213,7 @@ export function MultiSelectDialogContent({
|
|
|
197
213
|
onChange,
|
|
198
214
|
ordered = false,
|
|
199
215
|
idPrefix,
|
|
216
|
+
rowAction,
|
|
200
217
|
}: MultiSelectDialogContentProps) {
|
|
201
218
|
const isDesktopWeb = useUiHost().kind === "desktop-web";
|
|
202
219
|
const optionByValue = useMemo(() => new Map(options.map((option) => [option.value, option])), [options]);
|
|
@@ -216,6 +233,11 @@ export function MultiSelectDialogContent({
|
|
|
216
233
|
const canMoveDown = ordered
|
|
217
234
|
&& selectedValueOrder >= 0
|
|
218
235
|
&& selectedValueOrder < knownSelectedValues.length - 1;
|
|
236
|
+
const [labelOverrides, setLabelOverrides] = useState<Record<string, string>>({});
|
|
237
|
+
const canRunRowAction = !!rowAction
|
|
238
|
+
&& !!selectedOption
|
|
239
|
+
&& !selectedOption.disabled
|
|
240
|
+
&& rowAction.appliesTo(selectedOption.value, selectedValues.includes(selectedOption.value));
|
|
219
241
|
|
|
220
242
|
useEffect(() => {
|
|
221
243
|
setSelectedValues((values) => normalizeDialogSelectedValues(options, values, ordered));
|
|
@@ -235,7 +257,7 @@ export function MultiSelectDialogContent({
|
|
|
235
257
|
|
|
236
258
|
return {
|
|
237
259
|
id: option.value,
|
|
238
|
-
label: option.label,
|
|
260
|
+
label: labelOverrides[option.value] ?? option.label,
|
|
239
261
|
disabled: option.disabled,
|
|
240
262
|
enabled: selectedValues.includes(option.value),
|
|
241
263
|
description: [option.description, orderDescription].filter((entry): entry is string => !!entry).join(" "),
|
|
@@ -278,6 +300,13 @@ export function MultiSelectDialogContent({
|
|
|
278
300
|
);
|
|
279
301
|
};
|
|
280
302
|
|
|
303
|
+
const runRowAction = async () => {
|
|
304
|
+
if (!rowAction || !canRunRowAction || !selectedOption) return;
|
|
305
|
+
const value = selectedOption.value;
|
|
306
|
+
const label = await rowAction.run(value);
|
|
307
|
+
if (typeof label === "string") setLabelOverrides((current) => ({ ...current, [value]: label }));
|
|
308
|
+
};
|
|
309
|
+
|
|
281
310
|
useDialogKeyboard((event) => {
|
|
282
311
|
event.stopPropagation();
|
|
283
312
|
const move = listCursorMove(event, pageSize);
|
|
@@ -290,6 +319,8 @@ export function MultiSelectDialogContent({
|
|
|
290
319
|
void moveOption("up").catch(() => {});
|
|
291
320
|
} else if (event.name === "]" && ordered) {
|
|
292
321
|
void moveOption("down").catch(() => {});
|
|
322
|
+
} else if (rowAction && event.name === rowAction.shortcut && !event.ctrl && !event.meta && !event.alt) {
|
|
323
|
+
void runRowAction().catch(() => {});
|
|
293
324
|
} else if (event.name === "enter" || event.name === "return" || event.name === "escape" || isDetailBackNavigationKey(event)) {
|
|
294
325
|
dismiss();
|
|
295
326
|
}
|
|
@@ -331,6 +362,15 @@ export function MultiSelectDialogContent({
|
|
|
331
362
|
<Button label="Move Down" shortcut="]" variant="ghost" disabled={!canMoveDown} onPress={() => { void moveOption("down").catch(() => {}); }} />
|
|
332
363
|
</>
|
|
333
364
|
)}
|
|
365
|
+
{rowAction && (
|
|
366
|
+
<Button
|
|
367
|
+
label={rowAction.label}
|
|
368
|
+
shortcut={rowAction.shortcut}
|
|
369
|
+
variant="ghost"
|
|
370
|
+
disabled={!canRunRowAction}
|
|
371
|
+
onPress={() => { void runRowAction().catch(() => {}); }}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
334
374
|
<Button label="Done" shortcut="Enter" variant="primary" onPress={dismiss} />
|
|
335
375
|
</Box>
|
|
336
376
|
</Box>
|
|
@@ -352,6 +392,7 @@ function MultiSelectDialogButtonInner({
|
|
|
352
392
|
shortcutKey,
|
|
353
393
|
shortcutActive = false,
|
|
354
394
|
onOpenChange,
|
|
395
|
+
rowAction,
|
|
355
396
|
}: MultiSelectDialogButtonProps, ref: ForwardedRef<MultiSelectDialogButtonHandle>) {
|
|
356
397
|
const isDesktopWeb = useUiHost().kind === "desktop-web";
|
|
357
398
|
const dialog = useDialog();
|
|
@@ -364,7 +405,7 @@ function MultiSelectDialogButtonInner({
|
|
|
364
405
|
const openDialog = useCallback((event?: DialogTriggerEvent, anchorPoint?: MultiSelectPopoverAnchorPoint) => {
|
|
365
406
|
stopMouseEvent(event);
|
|
366
407
|
if (disabled) return;
|
|
367
|
-
if (isDesktopWeb && !ordered) {
|
|
408
|
+
if (isDesktopWeb && !ordered && !rowAction) {
|
|
368
409
|
setPopoverAnchorPoint(anchorPoint ?? null);
|
|
369
410
|
setPopoverOpen(true);
|
|
370
411
|
onOpenChange?.(true);
|
|
@@ -383,10 +424,11 @@ function MultiSelectDialogButtonInner({
|
|
|
383
424
|
ordered={ordered}
|
|
384
425
|
emptyLabel={emptyLabel}
|
|
385
426
|
idPrefix={idPrefix}
|
|
427
|
+
rowAction={rowAction}
|
|
386
428
|
/>
|
|
387
429
|
),
|
|
388
430
|
}).catch(() => {}).finally(() => onOpenChange?.(false));
|
|
389
|
-
}, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, selectedValues, title]);
|
|
431
|
+
}, [dialog, disabled, emptyLabel, idPrefix, isDesktopWeb, label, onChange, onOpenChange, options, ordered, rowAction, selectedValues, title]);
|
|
390
432
|
|
|
391
433
|
const closePopover = useCallback(() => {
|
|
392
434
|
setPopoverOpen(false);
|
|
@@ -469,7 +511,7 @@ function MultiSelectDialogButtonInner({
|
|
|
469
511
|
);
|
|
470
512
|
}
|
|
471
513
|
|
|
472
|
-
if (isDesktopWeb && !ordered) {
|
|
514
|
+
if (isDesktopWeb && !ordered && !rowAction) {
|
|
473
515
|
return (
|
|
474
516
|
<Popover
|
|
475
517
|
open={popoverOpen}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, type InputRenderable } from "../../ui";
|
|
3
|
+
import type { PromptContext } from "../../ui/dialog";
|
|
4
|
+
import { useThemeColors } from "../../theme/theme-context";
|
|
5
|
+
import { DialogFrame } from "./frame";
|
|
6
|
+
import { TextField } from "./fields";
|
|
7
|
+
|
|
8
|
+
/** A whole number within [min, max], or null for anything else. */
|
|
9
|
+
export function parseWholeNumber(text: string, min: number, max: number): number | null {
|
|
10
|
+
const trimmed = text.trim();
|
|
11
|
+
if (!/^\d+$/.test(trimmed)) return null;
|
|
12
|
+
const value = Number(trimmed);
|
|
13
|
+
return value >= min && value <= max ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NumberPromptDialogProps extends PromptContext<number> {
|
|
17
|
+
title: string;
|
|
18
|
+
initialValue: number;
|
|
19
|
+
min: number;
|
|
20
|
+
max: number;
|
|
21
|
+
/** Shown under the field when Enter is pressed on something out of range. */
|
|
22
|
+
invalidMessage: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One prefilled number field. Enter keeps a valid value, Esc closes without
|
|
27
|
+
* one (the dialog host resolves the prompt with undefined).
|
|
28
|
+
*/
|
|
29
|
+
export function NumberPromptDialog({
|
|
30
|
+
resolve,
|
|
31
|
+
title,
|
|
32
|
+
initialValue,
|
|
33
|
+
min,
|
|
34
|
+
max,
|
|
35
|
+
invalidMessage,
|
|
36
|
+
}: NumberPromptDialogProps) {
|
|
37
|
+
const colors = useThemeColors();
|
|
38
|
+
const inputRef = useRef<InputRenderable>(null);
|
|
39
|
+
const [value, setValue] = useState(String(initialValue));
|
|
40
|
+
const [invalid, setInvalid] = useState(false);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
inputRef.current?.focus?.();
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<DialogFrame title={title}>
|
|
48
|
+
<Box flexDirection="column" width={32}>
|
|
49
|
+
<TextField
|
|
50
|
+
inputRef={inputRef}
|
|
51
|
+
value={value}
|
|
52
|
+
focused
|
|
53
|
+
onChange={(next) => {
|
|
54
|
+
setValue(next);
|
|
55
|
+
setInvalid(false);
|
|
56
|
+
}}
|
|
57
|
+
onSubmit={(submitted) => {
|
|
58
|
+
const parsed = parseWholeNumber(submitted, min, max);
|
|
59
|
+
if (parsed === null) setInvalid(true);
|
|
60
|
+
else resolve(parsed);
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
{invalid && (
|
|
64
|
+
<Box height={1}>
|
|
65
|
+
<Text fg={colors.negative}>{invalidMessage}</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
)}
|
|
68
|
+
</Box>
|
|
69
|
+
</DialogFrame>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -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
|
+
});
|