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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
DataTableView,
|
|
4
4
|
usePaneFooter,
|
|
@@ -22,7 +22,7 @@ import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
|
|
|
22
22
|
import { useAutoRefresh, useUpdatedAgo } from "../shared/auto-refresh";
|
|
23
23
|
import { useLiveStreamingSetting } from "../shared/live-streaming";
|
|
24
24
|
import { isStreamCarryingQuote } from "../shared/use-quote-board";
|
|
25
|
-
import { fxLegQuoteKey, fxLegTargets, fxLegs, liveFxLegEntry } from "./live-legs";
|
|
25
|
+
import { fxLegQuoteKey, fxLegReferenceRate, fxLegTargets, fxLegs, liveFxLegEntry } from "./live-legs";
|
|
26
26
|
import { MAJOR_CURRENCIES, formatRate, resolveCurrencies, type MajorCurrency } from "./pairs";
|
|
27
27
|
import { createFxExportMetadata } from "./export";
|
|
28
28
|
|
|
@@ -64,6 +64,22 @@ function FxMatrixPane({ focused, width, height }: PaneProps) {
|
|
|
64
64
|
for (const [currency, entry] of liveEntries) merged.set(currency, entry.data!);
|
|
65
65
|
return merged;
|
|
66
66
|
}, [liveEntries, snapshotRates]);
|
|
67
|
+
// A cell's decimals are counted at a rate that holds still, the pair's
|
|
68
|
+
// previous close or else the first rate this pane drew, never at the tick.
|
|
69
|
+
// The snapshot rates are no reference: they follow the stream too.
|
|
70
|
+
const firstRates = useRef(new Map<string, number>());
|
|
71
|
+
const referenceRates = useMemo(() => {
|
|
72
|
+
const references = new Map<string, number>([["USD", 1]]);
|
|
73
|
+
for (const leg of legs) {
|
|
74
|
+
const rate = rates.get(leg.currency);
|
|
75
|
+
if (!firstRates.current.has(leg.currency) && rate != null && Number.isFinite(rate) && rate > 0) {
|
|
76
|
+
firstRates.current.set(leg.currency, rate);
|
|
77
|
+
}
|
|
78
|
+
const reference = fxLegReferenceRate(leg, legEntries.get(fxLegQuoteKey(leg))) ?? firstRates.current.get(leg.currency);
|
|
79
|
+
if (reference != null) references.set(leg.currency, reference);
|
|
80
|
+
}
|
|
81
|
+
return references;
|
|
82
|
+
}, [legEntries, legs, rates]);
|
|
67
83
|
// Export the provenance of this render's rates, even if a provider response
|
|
68
84
|
// arrives before React commits the next render and the user exports now.
|
|
69
85
|
const rateEntries = new Map(currencies.map((currency) => {
|
|
@@ -137,8 +153,9 @@ function FxMatrixPane({ focused, width, height }: PaneProps) {
|
|
|
137
153
|
const pending = status.loading > 0;
|
|
138
154
|
return { text: pending ? "…" : "—", color: dimmed };
|
|
139
155
|
}
|
|
140
|
-
|
|
141
|
-
|
|
156
|
+
const reference = (referenceRates.get(row) ?? base) / (referenceRates.get(quoteCurrency) ?? quote);
|
|
157
|
+
return { text: formatRate(base / quote, quoteCurrency, reference), color: selectedColor ?? colors.text };
|
|
158
|
+
}, [rates, referenceRates, status.loading]);
|
|
142
159
|
|
|
143
160
|
const handleKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
144
161
|
if (!isPlainKey(event, "r")) return false;
|
|
@@ -68,3 +68,10 @@ export function liveFxLegEntry(
|
|
|
68
68
|
attempts: [],
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/** USD per unit at the pair's previous close: a rate that holds still for the session. */
|
|
73
|
+
export function fxLegReferenceRate(leg: FxLeg, entry: QueryEntry<Quote> | undefined): number | null {
|
|
74
|
+
const close = resolveEntryData(entry)?.previousClose;
|
|
75
|
+
if (close == null || !Number.isFinite(close) || close <= 0) return null;
|
|
76
|
+
return leg.inverted ? 1 / close : close;
|
|
77
|
+
}
|
|
@@ -9,3 +9,8 @@ test("inverse yen rates retain meaningful precision instead of rounding a materi
|
|
|
9
9
|
expect(formatRate(Number.POSITIVE_INFINITY, "USD")).toBe("—");
|
|
10
10
|
expect(formatRate(0, "USD")).toBe("—");
|
|
11
11
|
});
|
|
12
|
+
|
|
13
|
+
test("a cross ticking across a power of ten keeps the decimals of its reference rate", () => {
|
|
14
|
+
expect(formatRate(0.00999, "AUD", 0.01001)).toBe("0.009990");
|
|
15
|
+
expect(formatRate(0.01001, "AUD", 0.00999)).toBe("0.0100100");
|
|
16
|
+
});
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
export const MAJOR_CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD"] as const;
|
|
3
3
|
export type MajorCurrency = typeof MAJOR_CURRENCIES[number];
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Preserve conventional quote precision and meaningful digits on inverse JPY
|
|
7
|
+
* crosses. The digits are counted at `referenceRate` (the cross at a rate that
|
|
8
|
+
* holds still) when there is one, so a live rate ticking across a power of ten,
|
|
9
|
+
* such as JPY/AUD near 0.0100, keeps its decimals.
|
|
10
|
+
*/
|
|
11
|
+
export function formatRate(rate: number, toCurrency: string, referenceRate = rate): string {
|
|
7
12
|
if (!Number.isFinite(rate) || rate <= 0) return "—";
|
|
8
|
-
const
|
|
13
|
+
const basis = Number.isFinite(referenceRate) && referenceRate > 0 ? referenceRate : rate;
|
|
14
|
+
const decimals = basis < 0.1 ? Math.min(8, 4 - Math.floor(Math.log10(basis))) : toCurrency === "JPY" ? 2 : 4;
|
|
9
15
|
return rate.toFixed(decimals);
|
|
10
16
|
}
|
|
11
17
|
|
|
@@ -42,6 +42,9 @@ import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
|
|
|
42
42
|
export { insiderHeadless } from "./headless";
|
|
43
43
|
|
|
44
44
|
const FORM4_PAGE_SIZE = 20;
|
|
45
|
+
// The first page grows to every filing inside the 90-day window, up to this
|
|
46
|
+
// many, so the totals do not depend on how far the list is scrolled.
|
|
47
|
+
const INSIDER_WINDOW_CAP = 120;
|
|
45
48
|
// Recent EDGAR dumps cap at 1,000 mixed forms. Older archives are fetched
|
|
46
49
|
// until this many filings or company history ends.
|
|
47
50
|
const SEC_FILING_SCAN_LIMIT = 20_000;
|
|
@@ -142,16 +145,22 @@ function InsiderView({ width, height, focused }: { width: number; height: number
|
|
|
142
145
|
() => allFilings.filter((f) => isInsiderForm(f.form)),
|
|
143
146
|
[allFilings],
|
|
144
147
|
);
|
|
148
|
+
const windowCount = useMemo(() => {
|
|
149
|
+
const cutoff = insiderSummaryCutoff();
|
|
150
|
+
return form4Filings.findLastIndex((filing) => new Date(filing.filingDate).getTime() >= cutoff) + 1;
|
|
151
|
+
}, [form4Filings]);
|
|
152
|
+
const windowShown = Math.min(windowCount, INSIDER_WINDOW_CAP);
|
|
145
153
|
const [visibleCount, setVisibleCount] = useState(FORM4_PAGE_SIZE);
|
|
154
|
+
const shownCount = Math.max(visibleCount, windowShown);
|
|
146
155
|
const visibleForm4Filings = useMemo(
|
|
147
|
-
() => form4Filings.slice(0,
|
|
148
|
-
[form4Filings,
|
|
156
|
+
() => form4Filings.slice(0, shownCount),
|
|
157
|
+
[form4Filings, shownCount],
|
|
149
158
|
);
|
|
150
159
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
151
160
|
const loadMore = useTableLoadMore(
|
|
152
161
|
scrollRef,
|
|
153
|
-
|
|
154
|
-
() => setVisibleCount((current) => Math.min(form4Filings.length, current + FORM4_PAGE_SIZE)),
|
|
162
|
+
shownCount < form4Filings.length,
|
|
163
|
+
() => setVisibleCount((current) => Math.min(form4Filings.length, Math.max(current, windowShown) + FORM4_PAGE_SIZE)),
|
|
155
164
|
);
|
|
156
165
|
useEffect(() => {
|
|
157
166
|
setVisibleCount(FORM4_PAGE_SIZE);
|
|
@@ -202,14 +211,8 @@ function InsiderView({ width, height, focused }: { width: number; height: number
|
|
|
202
211
|
if (summary) lastSummaryRef.current = { scope: summaryScope, figures: summary };
|
|
203
212
|
const shownSummary = summary ?? (lastSummaryRef.current?.scope === summaryScope ? lastSummaryRef.current.figures : null);
|
|
204
213
|
const statItems = useMemo(() => shownSummary ? summaryItems(shownSummary) : [], [shownSummary]);
|
|
205
|
-
// The totals
|
|
206
|
-
|
|
207
|
-
const oldestLoaded = visibleForm4Filings.reduce<number | null>((oldest, filing) => {
|
|
208
|
-
const time = new Date(filing.filingDate).getTime();
|
|
209
|
-
return Number.isFinite(time) && (oldest == null || time < oldest) ? time : oldest;
|
|
210
|
-
}, null);
|
|
211
|
-
const windowPartial = visibleCount < form4Filings.length
|
|
212
|
-
&& oldestLoaded != null && oldestLoaded >= insiderSummaryCutoff();
|
|
214
|
+
// The totals miss window filings only when the window passes the cap.
|
|
215
|
+
const windowPartial = shownCount < windowCount;
|
|
213
216
|
const insiderOptions = useMemo(() => {
|
|
214
217
|
const names = new Set<string>();
|
|
215
218
|
for (const entry of allParsed) {
|
|
@@ -44,6 +44,16 @@ async function mount(width: number, explanationOnly = false) {
|
|
|
44
44
|
await act(async () => { setup = await testRender(<Harness width={width} />, { width, height: 30 }); });
|
|
45
45
|
await settle();
|
|
46
46
|
}
|
|
47
|
+
async function mountWindow(inWindow: number) {
|
|
48
|
+
const day = 24 * 60 * 60 * 1000;
|
|
49
|
+
const isoDate = (time: number) => new Date(time).toISOString().slice(0, 10);
|
|
50
|
+
const filings = Array.from({ length: inWindow + 1 }, (_, index) => ({ accessionNumber: `f${index}`, form: "4",
|
|
51
|
+
filingDate: new Date(Date.now() - (index < inWindow ? 1 + index % 60 : 120) * day), cik: "999", filingUrl: `https://www.sec.gov/f${index}` }));
|
|
52
|
+
setSharedMarketDataCoordinator(new MarketDataCoordinator(createTestDataProvider({ getSecFilings: async () => filings,
|
|
53
|
+
getSecFilingContent: async (filing) => `<ownershipDocument><documentType>4</documentType><reportingOwner><reportingOwnerId><rptOwnerName>A. OFFICER</rptOwnerName><rptOwnerCik>111</rptOwnerCik></reportingOwnerId></reportingOwner><nonDerivativeTransaction><securityTitle><value>Class A</value></securityTitle><transactionDate><value>${isoDate(new Date(filing.filingDate).getTime())}</value></transactionDate><transactionCoding><transactionCode>P</transactionCode></transactionCoding><transactionAmounts><transactionShares><value>100</value></transactionShares><transactionPricePerShare><value>10</value></transactionPricePerShare></transactionAmounts></nonDerivativeTransaction></ownershipDocument>` })));
|
|
54
|
+
await act(async () => { setup = await testRender(<Harness width={80} />, { width: 80, height: 30 }); });
|
|
55
|
+
for (let i = 0; i < 40 && (i < 8 || setup!.captureCharFrame().includes("loading")); i++) await settle();
|
|
56
|
+
}
|
|
47
57
|
afterEach(async () => {
|
|
48
58
|
if (setup) await act(async () => { setup!.renderer.destroy(); });
|
|
49
59
|
setup = undefined;
|
|
@@ -86,3 +96,17 @@ test("owner filtering keeps explanation-only amendments and clears amendment sta
|
|
|
86
96
|
expect(setup!.captureCharFrame()).not.toContain("A. OFFICER");
|
|
87
97
|
expect(setup!.captureCharFrame()).not.toContain("⚠");
|
|
88
98
|
});
|
|
99
|
+
|
|
100
|
+
test("the 90-day totals load every filing in the window, past the first page", async () => {
|
|
101
|
+
await mountWindow(25);
|
|
102
|
+
const frame = setup!.captureCharFrame();
|
|
103
|
+
expect(frame).toContain("2.5k shares");
|
|
104
|
+
expect(frame).not.toContain("⚠");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a window larger than the cap still says the totals are partial", async () => {
|
|
108
|
+
await mountWindow(121);
|
|
109
|
+
const frame = setup!.captureCharFrame();
|
|
110
|
+
expect(frame).toContain("12k shares");
|
|
111
|
+
expect(frame).toContain("⚠");
|
|
112
|
+
});
|
|
@@ -5,13 +5,13 @@ import type {
|
|
|
5
5
|
} from "../../../types/plugin";
|
|
6
6
|
import { formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
7
7
|
import { loadMarketMoverTab, type MarketMoverTabResult } from "./client";
|
|
8
|
-
import { createRows, fiftyTwoWeekPositionPercent, formatMoverPrice, type TabId } from "./model";
|
|
8
|
+
import { createRows, fiftyTwoWeekPositionPercent, formatMoverPrice, moverReferencePrice, type MarketMoverRow, type TabId } from "./model";
|
|
9
9
|
|
|
10
10
|
const COLUMNS = [
|
|
11
11
|
{ key: "rank", header: "Rank", align: "right" as const },
|
|
12
12
|
{ key: "symbol", header: "Symbol" },
|
|
13
13
|
{ key: "name", header: "Name" },
|
|
14
|
-
{ key: "price", header: "Last", align: "right" as const, format: (value: unknown, row: Record<string, unknown>) => formatMoverPrice(typeof value === "number" ? value : null, typeof row.currency === "string" ? row.currency : "") },
|
|
14
|
+
{ key: "price", header: "Last", align: "right" as const, format: (value: unknown, row: Record<string, unknown>) => formatMoverPrice(typeof value === "number" ? value : null, typeof row.currency === "string" ? row.currency : "", moverReferencePrice(row as unknown as MarketMoverRow)) },
|
|
15
15
|
{ key: "changePercent", header: "Change %", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatPercentRaw(Number(value)) },
|
|
16
16
|
{ key: "volume", header: "Volume", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatCompact(Number(value), { fixedDecimals: true }) },
|
|
17
17
|
{ key: "volumeRatio", header: "Vol / Avg", align: "right" as const, format: (value: unknown) => value == null ? "—" : formatNumber(Number(value), 1) },
|
|
@@ -3,7 +3,7 @@ import { overlayScreenerQuoteEntries } from "../shared/screener-live-quotes";
|
|
|
3
3
|
import type { Quote } from "../../../types/financials";
|
|
4
4
|
import type { QueryEntry } from "../../../market-data/result-types";
|
|
5
5
|
import { formatNumber } from "../../../utils/format";
|
|
6
|
-
import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
|
|
6
|
+
import { formatMarketPriceWithCurrency, stablePriceFractionDigits } from "../../../market-data/market/format";
|
|
7
7
|
import type { DataTableColumn } from "../../../components";
|
|
8
8
|
import { compareSortValues, type SortDirection } from "../../../utils/sort-values";
|
|
9
9
|
import { MARKET_SUMMARY_SYMBOLS, convertScreenerPriceUnit, screenerNumber, screenerVolume, screenerVolumeRatio, type MarketSummaryQuote, type ScreenerCategory, type ScreenerQuote } from "./screener";
|
|
@@ -156,7 +156,7 @@ export function summaryQuoteFromQuote(
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string; exchangeName?: string; listingExchangeName?: string; lastUpdated?: number }): ScreenerQuote {
|
|
159
|
+
export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string; exchangeName?: string; listingExchangeName?: string; lastUpdated?: number; previousClose?: number }): ScreenerQuote {
|
|
160
160
|
return {
|
|
161
161
|
symbol,
|
|
162
162
|
name: quote.name ?? symbol,
|
|
@@ -174,27 +174,39 @@ export function screenerQuoteFromQuote(symbol: string, quote: { name?: string; p
|
|
|
174
174
|
dayLow: undefined,
|
|
175
175
|
exchange: quote.listingExchangeName ?? quote.exchangeName ?? "",
|
|
176
176
|
lastUpdated: quote.lastUpdated,
|
|
177
|
+
previousClose: screenerNumber(quote.previousClose) ?? undefined,
|
|
177
178
|
};
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
/** The session-fixed price a mover's decimals are read from: its previous
|
|
182
|
+
* close, else price less change (the same close), and only without either the
|
|
183
|
+
* price itself. */
|
|
184
|
+
export function moverReferencePrice(row: Pick<ScreenerQuote, "price" | "change" | "previousClose">): number | undefined {
|
|
185
|
+
if (row.previousClose != null && Number.isFinite(row.previousClose) && row.previousClose > 0) return row.previousClose;
|
|
186
|
+
if (row.price == null) return undefined;
|
|
187
|
+
// Rounded so float residue on a close of exactly $1 cannot flip the decimals between ticks.
|
|
188
|
+
const close = row.change == null ? Number.NaN : Number((row.price - row.change).toPrecision(12));
|
|
189
|
+
return Number.isFinite(close) && close > 0 ? close : row.price;
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
/** Listing currency at its minor unit (¥ none, $ two),
|
|
187
|
-
|
|
192
|
+
/** Listing currency at its minor unit (¥ none, $ two), four decimals under one
|
|
193
|
+
* unit and a sub-cent coin's digits. A missing listing currency cannot be
|
|
194
|
+
* represented by a USD symbol. The decimals come from `referencePrice`, so a
|
|
195
|
+
* streamed tick landing on $0.50 or crossing $1 keeps the column's digits. */
|
|
196
|
+
export function formatMoverPrice(price: number | null, currency: string, referencePrice: number | null | undefined = price): string {
|
|
188
197
|
if (!currency) return formatNumber(price ?? undefined);
|
|
189
198
|
const unit = resolveCurrencyUnit(currency);
|
|
199
|
+
const reference = referencePrice == null ? undefined : referencePrice / unit.divisor;
|
|
200
|
+
// Movers are priced as stocks: the currency's minor unit, not a fixed two, sets the digits above one unit.
|
|
201
|
+
const fixedFractionDigits = stablePriceFractionDigits({ assetCategory: "EQUITY", currency: unit.currency, referencePrice: reference });
|
|
190
202
|
return formatMarketPriceWithCurrency(price == null ? undefined : price / unit.divisor, unit.currency,
|
|
191
|
-
{
|
|
203
|
+
{ fixedFractionDigits, referencePrice: reference });
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
/** Keyed by the overlaid row, which the shared overlay keeps while its quote holds. */
|
|
195
207
|
const convertedOverlays = new WeakMap<ScreenerQuote, ScreenerQuote>();
|
|
196
208
|
|
|
197
|
-
/** Range endpoints belong to the original screener price denomination. */
|
|
209
|
+
/** Range endpoints and the previous close belong to the original screener price denomination. */
|
|
198
210
|
export function overlayMarketMoverQuotes(
|
|
199
211
|
rows: readonly ScreenerQuote[],
|
|
200
212
|
entries: ReadonlyMap<string, QueryEntry<Quote>>,
|
|
@@ -211,6 +223,7 @@ export function overlayMarketMoverQuotes(
|
|
|
211
223
|
fiftyTwoWeekHigh: convert(original.fiftyTwoWeekHigh),
|
|
212
224
|
dayLow: convert(original.dayLow),
|
|
213
225
|
dayHigh: convert(original.dayHigh),
|
|
226
|
+
previousClose: row.previousClose ?? convert(original.previousClose),
|
|
214
227
|
};
|
|
215
228
|
convertedOverlays.set(row, converted);
|
|
216
229
|
return converted;
|
|
@@ -321,9 +321,13 @@ describe("fetchScreener", () => {
|
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
test("mover prices keep the currency's minor unit and a sub-cent coin's digits", async () => {
|
|
324
|
-
const { formatMoverPrice } = await import("./model");
|
|
324
|
+
const { formatMoverPrice, moverReferencePrice } = await import("./model");
|
|
325
325
|
expect(formatMoverPrice(0.00001234, "USD")).toBe("$0.00001234");
|
|
326
326
|
expect(formatMoverPrice(33.4, "USD")).toBe("$33.40");
|
|
327
327
|
expect(formatMoverPrice(45678, "JPY")).toBe("¥45,678");
|
|
328
328
|
expect(formatMoverPrice(1234.5, "GBp")).toBe("£12.35");
|
|
329
|
+
// Decimals come from the session's close, never the tick: a sub-dollar stock
|
|
330
|
+
// landing on $0.50 or crossing $1 keeps its four.
|
|
331
|
+
expect(formatMoverPrice(0.5, "USD", moverReferencePrice({ price: 0.5, change: -0.0123 }))).toBe("$0.5000");
|
|
332
|
+
expect(formatMoverPrice(1.2, "USD", moverReferencePrice({ price: 1.2, change: 0.4 }))).toBe("$1.2000");
|
|
329
333
|
});
|
|
@@ -177,6 +177,8 @@ export interface ScreenerQuote {
|
|
|
177
177
|
dayLow: number | undefined;
|
|
178
178
|
exchange: string;
|
|
179
179
|
lastUpdated?: number;
|
|
180
|
+
/** Session-fixed, so the price column reads its decimals from it rather than from each tick. */
|
|
181
|
+
previousClose?: number;
|
|
180
182
|
}
|
|
181
183
|
|
|
182
184
|
export type MarketMoversDataSource = "cloud" | "yahoo";
|
|
@@ -260,6 +262,7 @@ export function parseScreenerResponse(data: any): ScreenerQuote[] {
|
|
|
260
262
|
fiftyTwoWeekLow: screenerNumber(q.fiftyTwoWeekLow) ?? undefined,
|
|
261
263
|
dayHigh: screenerNumber(q.regularMarketDayHigh) ?? undefined,
|
|
262
264
|
dayLow: screenerNumber(q.regularMarketDayLow) ?? undefined,
|
|
265
|
+
previousClose: screenerNumber(q.regularMarketPreviousClose) ?? undefined,
|
|
263
266
|
exchange: typeof (q.fullExchangeName ?? q.exchange) === "string" ? q.fullExchangeName ?? q.exchange : "",
|
|
264
267
|
lastUpdated: typeof q.regularMarketTime === "number" && Number.isFinite(q.regularMarketTime) && q.regularMarketTime > 0
|
|
265
268
|
? q.regularMarketTime * 1000 : undefined,
|
|
@@ -3,7 +3,7 @@ import { TextAttributes } from "../../../ui";
|
|
|
3
3
|
import { colors, priceColor } from "../../../theme/colors";
|
|
4
4
|
import { formatCompact, formatPercentRaw } from "../../../utils/format";
|
|
5
5
|
import type { MarketMoverColumn, MarketMoverRow } from "./model";
|
|
6
|
-
import { fiftyTwoWeekPositionPercent, formatMoverPrice } from "./model";
|
|
6
|
+
import { fiftyTwoWeekPositionPercent, formatMoverPrice, moverReferencePrice } from "./model";
|
|
7
7
|
|
|
8
8
|
function formatVolRatio(ratio: number | null): string {
|
|
9
9
|
if (ratio == null || !Number.isFinite(ratio) || ratio < 0) return "—";
|
|
@@ -68,7 +68,7 @@ export function renderMarketMoverCell(
|
|
|
68
68
|
case "name":
|
|
69
69
|
return { text: row.name, color: selectedColor };
|
|
70
70
|
case "price":
|
|
71
|
-
return { text: formatMoverPrice(row.price, row.currency), color: selectedColor };
|
|
71
|
+
return { text: formatMoverPrice(row.price, row.currency, moverReferencePrice(row)), color: selectedColor };
|
|
72
72
|
case "changePercent":
|
|
73
73
|
return {
|
|
74
74
|
text: formatPercentRaw(row.changePercent ?? undefined),
|
|
@@ -232,12 +232,15 @@ test("refresh bypasses fresh caches, shares pending Shiller work and retains fai
|
|
|
232
232
|
expect(calls).toBe(3);
|
|
233
233
|
expect(cached.errors).toEqual(failed.errors);
|
|
234
234
|
expect(getCachedValuationBundle(indicators)!.errors).toEqual(failed.errors);
|
|
235
|
+
expect(getCachedValuationBundle(indicators)!.builds[0]!.sourceStale).toBe(true);
|
|
235
236
|
now += 1000; failing = false; cape = 50;
|
|
236
237
|
const recovered = await load(true);
|
|
237
238
|
expect(calls).toBe(4);
|
|
238
239
|
expect(recovered.errors).toEqual([]);
|
|
239
240
|
expect(recovered.sources!.SHILLER_CAPE).toMatchObject({ stale: false, source: "network", fetchedAt: now });
|
|
240
241
|
now += 6 * 60 * 60 * 1000 + 1; cape = 60;
|
|
242
|
+
// A cache past its refresh time is revalidated, not late at the source.
|
|
243
|
+
expect(getCachedValuationBundle(indicators)!.builds[0]!.sourceStale).toBe(false);
|
|
241
244
|
const expired = await load();
|
|
242
245
|
expect(calls).toBe(5);
|
|
243
246
|
expect(expired.builds[0]!.series.points.at(-1)!.ratio).toBe(60);
|
|
@@ -87,7 +87,12 @@ function buildIndicators(
|
|
|
87
87
|
builds.push({
|
|
88
88
|
indicator,
|
|
89
89
|
series,
|
|
90
|
-
|
|
90
|
+
// A cached leg past its refresh time is not late at the source; a cached
|
|
91
|
+
// first paint is revalidated at once, and a failed refresh keeps its error.
|
|
92
|
+
sourceStale: indicatorSeries(indicator).some((def) => {
|
|
93
|
+
const leg = legs.get(def.key);
|
|
94
|
+
return leg?.provider?.stale === true || !!leg?.refreshError;
|
|
95
|
+
}),
|
|
91
96
|
trend: fitIndicatorTrend(indicator, series.points),
|
|
92
97
|
});
|
|
93
98
|
} catch (error) {
|
|
@@ -56,7 +56,8 @@ export async function fetchMoneyMarkets(client: Pick<typeof apiClient, "getCloud
|
|
|
56
56
|
export interface MoneyMarketsResource { payload: MoneyMarketsPayload; stale: boolean; refreshError: string | null }
|
|
57
57
|
export function getCachedMoneyMarkets(): MoneyMarketsResource | null {
|
|
58
58
|
const cached = moneyMarketsCache.get("usd", { allowExpired: true });
|
|
59
|
-
|
|
59
|
+
// The pane revalidates this copy on mount; only a failed refresh makes it stale.
|
|
60
|
+
return cached ? { payload: cached.data, stale: false, refreshError: null } : null;
|
|
60
61
|
}
|
|
61
62
|
export async function loadMoneyMarkets(force = false): Promise<MoneyMarketsResource> {
|
|
62
63
|
const result = await moneyMarketsCache.load("usd", () => fetchMoneyMarkets(), { force });
|
|
@@ -86,7 +86,6 @@ export function MoneyMarketsPane({ width, height, focused }: PaneProps) {
|
|
|
86
86
|
info: data ? [
|
|
87
87
|
...(updatedAgo ? [{ id: "updated", parts: [{ text: updatedAgo, tone: "muted" as const }] }] : []),
|
|
88
88
|
...(resource.data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
|
|
89
|
-
...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
|
|
90
89
|
] : [],
|
|
91
90
|
});
|
|
92
91
|
return <Box width={width} height={height} flexDirection="column">
|
|
@@ -203,7 +203,7 @@ test.each([48, 80, 120])("retains a chosen expiry across unchanged, reordered an
|
|
|
203
203
|
const result = await f.capture(label);
|
|
204
204
|
expect(result.launch?.marketReference?.expiration).toBe(EXPIRIES[1]);
|
|
205
205
|
expect(result.launch?.marketPrice).toBe(20);
|
|
206
|
-
expect(result.csv).toContain("19,21,10.0%,20");
|
|
206
|
+
expect(result.csv).toContain("19.00,21.00,10.0%,20.00");
|
|
207
207
|
expect(result.frame).not.toContain("AAPL261120");
|
|
208
208
|
}
|
|
209
209
|
});
|
|
@@ -38,8 +38,8 @@ describe("selected contract status", () => {
|
|
|
38
38
|
optionContractFooterSegments(...args).map((segment) => segment.parts.map((part) => part.text).join(" "));
|
|
39
39
|
|
|
40
40
|
test("yields the spread to a visible SPRD column, but never the reason one is missing", () => {
|
|
41
|
-
expect(texts(reference(10.05, 10.15), false, NOW)).toContain("spread 0.
|
|
42
|
-
expect(texts(reference(10.05, 10.15), true, NOW)).not.toContain("spread 0.
|
|
41
|
+
expect(texts(reference(10.05, 10.15), false, NOW)).toContain("spread 0.10 (1.0% of mid)");
|
|
42
|
+
expect(texts(reference(10.05, 10.15), true, NOW)).not.toContain("spread 0.10 (1.0% of mid)");
|
|
43
43
|
// The column renders every degenerate quote as one dash, so the status bar
|
|
44
44
|
// keeps naming them whether or not it is shown.
|
|
45
45
|
for (const visible of [false, true]) {
|
|
@@ -7,7 +7,7 @@ import { useCloudAccessFooter } from "../shared/cloud-upgrade";
|
|
|
7
7
|
import { usePaneStatusFooter } from "../shared/pane-footer";
|
|
8
8
|
import { CLOUD_QUOTE_DELAY_MINUTES } from "../shared/plan-access";
|
|
9
9
|
import type { OptionQuoteCoverage, OptionQuoteCoverageStatus } from "./live-quotes";
|
|
10
|
-
import { optionSpread, type OptionMarketReference } from "./market-reference";
|
|
10
|
+
import { formatOptionPrice, optionSpread, type OptionMarketReference } from "./market-reference";
|
|
11
11
|
|
|
12
12
|
export interface OptionsCoverageState {
|
|
13
13
|
text: string;
|
|
@@ -57,7 +57,7 @@ export function optionContractFooterSegments(
|
|
|
57
57
|
id: "options-spread",
|
|
58
58
|
parts: [
|
|
59
59
|
{ text: "spread", tone: "label" },
|
|
60
|
-
{ text: `${spread.spread} (${spread.percentOfMid.toFixed(1)}% of mid)`, tone: "value" },
|
|
60
|
+
{ text: `${formatOptionPrice(spread.spread)} (${spread.percentOfMid.toFixed(1)}% of mid)`, tone: "value" },
|
|
61
61
|
],
|
|
62
62
|
};
|
|
63
63
|
return [
|
|
@@ -9,7 +9,7 @@ const recorded = {
|
|
|
9
9
|
test("keeps historical trades distinct and never substitutes retrieval time for a quote clock", () => {
|
|
10
10
|
const reference = optionMarketReference({ ...recorded, asOf: "2026-09-11T14:51:16Z" })!;
|
|
11
11
|
const lines = optionMarketReferenceLines(reference).join("\n");
|
|
12
|
-
expect(lines).toContain("spread 5.75 (4.89% of mid)");
|
|
12
|
+
expect(lines).toContain("Bid 114.80 · Ask 120.55 · spread 5.75 (4.89% of mid)");
|
|
13
13
|
expect(lines).toContain("Quote time unavailable");
|
|
14
14
|
expect(lines).toContain("Last 72.55 · trade 2026-08-13 14:19:55 UTC");
|
|
15
15
|
expect(lines).not.toContain("14:51:16");
|
|
@@ -25,7 +25,7 @@ test("does not fabricate a midpoint for one-sided, crossed, missing or malformed
|
|
|
25
25
|
expect(lines).not.toContain("% of mid");
|
|
26
26
|
expect(lines).toContain("Last — · trade time unavailable");
|
|
27
27
|
}
|
|
28
|
-
expect(optionMarketReferenceLines({ ...recorded, bid: 1, ask: 1 }).join("\n")).toContain("spread 0 (0.00% of mid)");
|
|
28
|
+
expect(optionMarketReferenceLines({ ...recorded, bid: 1, ask: 1 }).join("\n")).toContain("spread 0.00 (0.00% of mid)");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test("round-trips only valid optional reference metadata across saved pane params", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OptionContract } from "../../../types/financials";
|
|
2
|
+
import { formatMarketPrice, stablePriceFractionDigits } from "../../../market-data/market/format";
|
|
2
3
|
import { formatExpDate } from "../../../utils/options";
|
|
3
4
|
|
|
4
5
|
/** A saved contract observation, never a claim that its prices are executable. */
|
|
@@ -33,7 +34,18 @@ function timestamp(milliseconds: number | undefined): string | null {
|
|
|
33
34
|
return Number.isFinite(date.getTime()) ? date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, " UTC") : null;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
const
|
|
37
|
+
const OPTION_PRICE_DIGITS = stablePriceFractionDigits({ assetCategory: "OPT" });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A premium at the contract's fixed decimals whatever the quote, so a bid on a
|
|
41
|
+
* whole dime prints 1.20 rather than 1.2 and streamed prices hold their width.
|
|
42
|
+
* A cell too narrow drops grouping first, then decimals.
|
|
43
|
+
*/
|
|
44
|
+
export function formatOptionPrice(value: number, maxWidth?: number): string {
|
|
45
|
+
return formatMarketPrice(value, { assetCategory: "OPT", fixedFractionDigits: OPTION_PRICE_DIGITS, maxWidth });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const price = (value: number) => Number.isFinite(value) && value > 0 ? formatOptionPrice(value) : "—";
|
|
37
49
|
|
|
38
50
|
/**
|
|
39
51
|
* The spread is the one quote fact the chain table cannot show: its columns
|
|
@@ -67,7 +79,7 @@ export function optionMarketReferenceLines(reference: OptionMarketReference): st
|
|
|
67
79
|
const { bid, ask } = reference;
|
|
68
80
|
const spread = optionSpread(reference);
|
|
69
81
|
const market = spread.kind === "two-sided"
|
|
70
|
-
? `spread ${spread.spread} (${spread.percentOfMid.toFixed(2)}% of mid)`
|
|
82
|
+
? `spread ${formatOptionPrice(spread.spread)} (${spread.percentOfMid.toFixed(2)}% of mid)`
|
|
71
83
|
: spread.kind === "crossed" ? "crossed quote; no midpoint"
|
|
72
84
|
: spread.kind === "one-sided" ? "one-sided quote; no midpoint" : "bid/ask unavailable";
|
|
73
85
|
const quoteTime = timestamp(reference.lastUpdated);
|
|
@@ -4,8 +4,7 @@ import type { OptionContract, OptionsChain } from "../../../types/financials";
|
|
|
4
4
|
import { blendHex, colors } from "../../../theme/colors";
|
|
5
5
|
import { blendForContrast, blendForSeparation, contrastRatio } from "../../../theme/color-utils";
|
|
6
6
|
import { formatCompact } from "../../../utils/format";
|
|
7
|
-
import {
|
|
8
|
-
import { optionQuoteSide, optionSpread } from "./market-reference";
|
|
7
|
+
import { formatOptionPrice, optionQuoteSide, optionSpread } from "./market-reference";
|
|
9
8
|
import type {
|
|
10
9
|
OptionColumn,
|
|
11
10
|
OptionFieldId,
|
|
@@ -250,20 +249,18 @@ function formatOptionContractCell(
|
|
|
250
249
|
const greeks = optionGreeksForColumn(row, column);
|
|
251
250
|
switch (column.field) {
|
|
252
251
|
case "last":
|
|
253
|
-
return contract.lastPrice > 0
|
|
254
|
-
? formatMarketPrice(contract.lastPrice, { assetCategory: "OPT", maxWidth: column.width })
|
|
255
|
-
: "—";
|
|
252
|
+
return contract.lastPrice > 0 ? formatOptionPrice(contract.lastPrice, column.width) : "—";
|
|
256
253
|
case "bid":
|
|
257
254
|
case "ask": {
|
|
258
255
|
const quote = optionQuoteSide(contract, column.field);
|
|
259
|
-
return quote == null ? "—" :
|
|
256
|
+
return quote == null ? "—" : formatOptionPrice(quote, column.width);
|
|
260
257
|
}
|
|
261
258
|
case "spread":
|
|
262
259
|
return formatSpreadPercent(contract);
|
|
263
260
|
case "volume":
|
|
264
|
-
return formatCompact(contract.volume);
|
|
261
|
+
return formatCompact(contract.volume, { fixedDecimals: true });
|
|
265
262
|
case "openInterest":
|
|
266
|
-
return formatCompact(contract.openInterest);
|
|
263
|
+
return formatCompact(contract.openInterest, { fixedDecimals: true });
|
|
267
264
|
case "iv":
|
|
268
265
|
return formatIv(row.impliedVolatility);
|
|
269
266
|
case "delta":
|
|
@@ -254,7 +254,7 @@ test("shows volatility statistics and mirrored default Greeks", async () => {
|
|
|
254
254
|
const frame = testSetup!.captureCharFrame();
|
|
255
255
|
expect(frame).toMatch(/ATM IV\s+90\.1%/);
|
|
256
256
|
expect(frame).toMatch(/HV30\s+--/);
|
|
257
|
-
expect(frame).toMatch(/Volume\s+
|
|
257
|
+
expect(frame).toMatch(/Volume\s+4\.0k/);
|
|
258
258
|
expect(frame).toContain("C Δ");
|
|
259
259
|
expect(frame).toContain("C Γ");
|
|
260
260
|
expect(frame).toContain("P Γ");
|
|
@@ -540,7 +540,7 @@ test("starts at a held contract's expiry and preserves a researcher-selected rol
|
|
|
540
540
|
expect(testSetup!.captureCharFrame()).toContain("Loading options chain");
|
|
541
541
|
await act(async () => { selectTicker(ticker); });
|
|
542
542
|
await renderSettled();
|
|
543
|
-
expect(testSetup!.captureCharFrame()).toMatch(/34\.05\s+0\.3%\s+34\s+.*340/);
|
|
543
|
+
expect(testSetup!.captureCharFrame()).toMatch(/34\.05\s+0\.3%\s+34\.00\s+.*340/);
|
|
544
544
|
});
|
|
545
545
|
|
|
546
546
|
test("keeps the selected chain visible when its refresh fails", async () => {
|
|
@@ -117,7 +117,7 @@ function optionsSummaryItems({ summary, enrichment, currency, ivRank }: {
|
|
|
117
117
|
{ id: "sigma", label: "1σ fit", ...move(enrichment?.expectedMove.sigma, enrichment?.expectedMove.sigmaPercent) },
|
|
118
118
|
{ id: "skew", label: "Skew", value: skew == null ? "--" : `${skew} pp`, detail: "25d P-C" },
|
|
119
119
|
{ id: "slope", label: "Slope", value: slope == null ? "--" : `${slope} ${slopeAnnualized ? "pp/y" : "pts"}`, detail: slopeTo },
|
|
120
|
-
{ id: "volume", label: "Volume", value: summary?.expirationVolume == null ? "--" : formatCompact(summary.expirationVolume) },
|
|
120
|
+
{ id: "volume", label: "Volume", value: summary?.expirationVolume == null ? "--" : formatCompact(summary.expirationVolume, { fixedDecimals: true }) },
|
|
121
121
|
{ id: "pc-volume", label: "P/C vol", value: formatRatio(summary?.putCallVolumeRatio) },
|
|
122
122
|
{ id: "pc-oi", label: "P/C OI", value: formatRatio(summary?.putCallOpenInterestRatio) },
|
|
123
123
|
];
|
|
@@ -122,17 +122,17 @@ test("a chain-seeded calculator follows the contract and underlying until the us
|
|
|
122
122
|
await act(async () => { emit!(underlying, quote("COST", { price: 910 })); });
|
|
123
123
|
await settle();
|
|
124
124
|
let frame = testSetup!.captureCharFrame();
|
|
125
|
-
expect(frame).toMatch(/Spot\s+900/);
|
|
125
|
+
expect(frame).toMatch(/Spot\s+900\.00/);
|
|
126
126
|
expect(frame).toContain("Snapshot:");
|
|
127
127
|
expect(frame).not.toContain("real-time market");
|
|
128
128
|
await act(async () => { emit!(contract, quote(contract.symbol, { price: 24, bid: 23.9, ask: 24.1 })); });
|
|
129
129
|
await settle();
|
|
130
130
|
frame = testSetup!.captureCharFrame();
|
|
131
|
-
expect(frame).toMatch(/Mid\s+24
|
|
132
|
-
expect(frame).toMatch(/Spot\s+910/);
|
|
131
|
+
expect(frame).toMatch(/Mid\s+24\.000/);
|
|
132
|
+
expect(frame).toMatch(/Spot\s+910\.00/);
|
|
133
133
|
// The contract context is the live quote now, not a saved snapshot.
|
|
134
134
|
expect(frame).not.toContain("Snapshot:");
|
|
135
|
-
expect(frame).toContain("Bid 23.
|
|
135
|
+
expect(frame).toContain("Bid 23.90 · Ask 24.10");
|
|
136
136
|
const liveIv = frame.match(/Implied IV\s+([\d.]+)%/)![1];
|
|
137
137
|
const expected = valueOption({ ...draftFromParams({}), side: "call", spot: 910, strike: 905, rate: 0.04,
|
|
138
138
|
daysToExpiry: daysToExpiryFrom(expiration, Date.now()), volatility: Number(liveIv) / 100, dividendYield: 0 }).price;
|
|
@@ -146,8 +146,8 @@ test("a chain-seeded calculator follows the contract and underlying until the us
|
|
|
146
146
|
await act(async () => { emit!(underlying, quote("COST", { price: 915 })); });
|
|
147
147
|
await settle();
|
|
148
148
|
frame = testSetup!.captureCharFrame();
|
|
149
|
-
expect(frame).toMatch(/Spot\s+905/);
|
|
150
|
-
expect(frame).toMatch(/Mid\s+24
|
|
149
|
+
expect(frame).toMatch(/Spot\s+905(?!\.)/);
|
|
150
|
+
expect(frame).toMatch(/Mid\s+24\.000/);
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
test("narrow results keep contract context reachable while scrolling Greeks and editing fixed inputs", async () => {
|