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
|
@@ -11,6 +11,7 @@ import { formatNumber } from "../../../utils/format";
|
|
|
11
11
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
12
12
|
import { OPTIONS_CALCULATOR_PANE_ID, daysToExpiryFrom, describeDraftProblem, draftFromParams, reconcileOptionCalcDraft, solveImpliedVolatility, updateOptionCalcDraft, valueOption, type OptionCalcDraft, type OptionSide } from "./model";
|
|
13
13
|
import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors";
|
|
14
|
+
import { liveQuoteFormatOptions } from "../../../market-data/market/format";
|
|
14
15
|
import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming";
|
|
15
16
|
import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
|
|
16
17
|
import { buildOptionQuoteKey, freshOptionQuote, OPTIONS_QUOTE_EXCHANGE } from "../options/live-quotes";
|
|
@@ -126,6 +127,13 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
|
|
|
126
127
|
// The contract's remaining time runs with the clock while its price is live.
|
|
127
128
|
daysToExpiry: liveInputs.price != null && reference ? daysToExpiryFrom(reference.expiration, Date.now()) : draft.daysToExpiry,
|
|
128
129
|
marketReference: liveInputs.reference }, [draft, liveInputs, reference]);
|
|
130
|
+
// A spot that follows the underlying keeps the underlying's decimals, so a
|
|
131
|
+
// tick on a whole dime shows 910.10 rather than 910.1. A typed spot shows as typed.
|
|
132
|
+
const underlyingQuote = spotLinked ? resolveEntryData(liveEntries.get(buildQuoteKey({ symbol: draft.symbol, exchange: "" }))) : null;
|
|
133
|
+
const spotText = spotLinked
|
|
134
|
+
? linkedDraft.spot.toFixed(liveQuoteFormatOptions(underlyingQuote, underlyingQuote?.currency ?? reference?.currency).fixedFractionDigits ?? 2)
|
|
135
|
+
: String(linkedDraft.spot);
|
|
136
|
+
const daysRunning = liveInputs?.price != null && !!reference;
|
|
129
137
|
const effectiveDraft = useMemo(() => ({ ...linkedDraft, dividends: dividendInput.dividends,
|
|
130
138
|
volatility: surfaceSource && surface?.volatility != null ? surface.volatility : linkedDraft.volatility }), [linkedDraft, dividendInput.dividends, surfaceSource, surface?.volatility]);
|
|
131
139
|
|
|
@@ -135,7 +143,7 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
|
|
|
135
143
|
}, [seed, setDraft]);
|
|
136
144
|
|
|
137
145
|
const fields = useMemo<GridField[]>(() => [
|
|
138
|
-
{ id: "spot", label: "Spot", value: linkedDraft.spot, valueText:
|
|
146
|
+
{ id: "spot", label: "Spot", value: linkedDraft.spot, valueText: spotText, onValue: (value) => updateDraft({ spot: value }) },
|
|
139
147
|
{ id: "strike", label: "Strike", value: draft.strike, valueText: String(draft.strike), onValue: (value) => updateDraft({ strike: value }) },
|
|
140
148
|
{
|
|
141
149
|
id: "days",
|
|
@@ -143,11 +151,14 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
|
|
|
143
151
|
value: linkedDraft.daysToExpiry,
|
|
144
152
|
// Keep intraday expiry visible; rounding six hours to "0 d" makes a
|
|
145
153
|
// live contract appear expired while its time value is still priced.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
// Days that run with the clock keep all four decimals from tick to tick.
|
|
155
|
+
valueText: linkedDraft.daysToExpiry > 0 && linkedDraft.daysToExpiry < 0.0001
|
|
156
|
+
? "<0.0001"
|
|
157
|
+
: daysRunning
|
|
158
|
+
? linkedDraft.daysToExpiry.toFixed(4)
|
|
159
|
+
: Number.isInteger(linkedDraft.daysToExpiry)
|
|
160
|
+
? String(linkedDraft.daysToExpiry)
|
|
161
|
+
: String(Number(linkedDraft.daysToExpiry.toFixed(4))),
|
|
151
162
|
suffix: "d",
|
|
152
163
|
onValue: (value) => updateDraft({ daysToExpiry: Math.max(0, value) }),
|
|
153
164
|
},
|
|
@@ -158,14 +169,18 @@ export function OptionsCalculatorPane({ focused, width, height }: PaneProps) {
|
|
|
158
169
|
id: "marketPrice",
|
|
159
170
|
label: draft.marketPriceSource === "mid" ? "Mid" : draft.marketPriceSource === "last" ? "Last" : draft.marketPrice > 0 ? "Input" : "Market",
|
|
160
171
|
value: linkedDraft.marketPrice,
|
|
161
|
-
|
|
172
|
+
// A linked mid or last streams, so it keeps fixed decimals; a mid of two
|
|
173
|
+
// penny quotes can land on a half cent, so it keeps three.
|
|
174
|
+
valueText: draft.marketPriceSource === "mid" ? linkedDraft.marketPrice.toFixed(3)
|
|
175
|
+
: draft.marketPriceSource === "last" ? linkedDraft.marketPrice.toFixed(2)
|
|
176
|
+
: String(Number(linkedDraft.marketPrice.toPrecision(12))),
|
|
162
177
|
// Clearing the field is how a standalone user says "no market price".
|
|
163
178
|
onValue: (value) => updateDraft({ marketPrice: Math.max(0, value), marketPriceSource: undefined }),
|
|
164
179
|
onClear: () => updateDraft({ marketPrice: 0, marketPriceSource: undefined }),
|
|
165
180
|
},
|
|
166
181
|
...(american ? [{ id: "steps", label: "Steps", value: draft.steps ?? 400, valueText: String(draft.steps ?? 400),
|
|
167
182
|
onValue: (value: number) => updateDraft({ steps: value }) }] : []),
|
|
168
|
-
], [draft, linkedDraft, updateDraft, american, surfaceSource, effectiveDraft.volatility, surface?.volatility]);
|
|
183
|
+
], [draft, linkedDraft, spotText, daysRunning, updateDraft, american, surfaceSource, effectiveDraft.volatility, surface?.volatility]);
|
|
169
184
|
|
|
170
185
|
const calculation = useMemo(() => {
|
|
171
186
|
const unavailable = seedError ?? (american ? dividendInput.error : null)
|
|
@@ -60,15 +60,30 @@ describe("calculator surface source", () => {
|
|
|
60
60
|
expect(projected.volatility).toBeCloseTo(month.cells[0]!.volatility!, 10);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("brackets quoted in the same New York session do not warn about differing dates", async () => {
|
|
64
|
+
const quoted = (asOf: Record<number, string>) => dependencies({ loadOptions: async (query) => {
|
|
65
|
+
const expiration = query.expirationDate ?? expirations[0]!;
|
|
66
|
+
return ready({ ...chain(expiration), asOf: asOf[expiration] ?? "2026-09-22T13:45:00Z" });
|
|
67
|
+
} });
|
|
68
|
+
const same = await loadCalculatorSurfaceVol(request, quoted({ [expirations[0]!]: "2026-09-22T13:45:00.004Z", [expirations[1]!]: "2026-09-22T13:44:00.001Z" }));
|
|
69
|
+
expect(same.error).toBeNull();
|
|
70
|
+
expect(same.asOf).toBe("2026-09-22T13:44:00.001Z");
|
|
71
|
+
expect(same.warnings.some((warning) => warning.startsWith("Surface quote dates differ"))).toBe(false);
|
|
72
|
+
const mixed = await loadCalculatorSurfaceVol(request, quoted({ [expirations[1]!]: "2026-09-21T19:59:59Z" }));
|
|
73
|
+
expect(mixed.warnings).toContain("Surface quote dates differ: 2026-09-21, 2026-09-22");
|
|
74
|
+
});
|
|
75
|
+
|
|
63
76
|
test("exact listed tenor needs one usable slice and does not require a neighboring smile", async () => {
|
|
64
77
|
const result = await loadCalculatorSurfaceVol({ ...request, daysToExpiry: days(expirations[1]!) }, dependencies({
|
|
65
78
|
loadOptions: async (query) => {
|
|
66
|
-
if (query.expirationDate
|
|
79
|
+
if (query.expirationDate != null && query.expirationDate !== expirations[1]) throw new Error("Unused neighboring expiry offline");
|
|
67
80
|
return ready(chain(query.expirationDate ?? expirations[0]!));
|
|
68
81
|
},
|
|
69
82
|
}));
|
|
70
83
|
expect(result.error).toBeNull();
|
|
71
84
|
expect(result.volatility).toBeCloseTo(.4, 4);
|
|
85
|
+
// The representative front expiry is loaded too, but its failure does not concern this price.
|
|
86
|
+
expect(result.warnings).toEqual([]);
|
|
72
87
|
});
|
|
73
88
|
|
|
74
89
|
test("pins actual adjacent listings with bounded requests and reuses the selected OMON cache", async () => {
|
|
@@ -6,7 +6,7 @@ import type { OptionsChain, Quote } from "../../../types/financials";
|
|
|
6
6
|
import type { DataProvider } from "../../../types/data-provider";
|
|
7
7
|
import { canonicalExchange, parsePublicTickerKey } from "../../../utils/exchanges";
|
|
8
8
|
import { loadVolatilitySurface, type SurfaceLoaderDependencies } from "../vol-surface/client";
|
|
9
|
-
import { evaluateSurfaceSmile, type SurfaceSnapshot } from "../vol-surface/model";
|
|
9
|
+
import { evaluateSurfaceSmile, surfaceCalendarWarnings, type SurfaceExpiry, type SurfaceSnapshot } from "../vol-surface/model";
|
|
10
10
|
import { interpolateTotalVariance, logForwardMoneyness } from "../shared/volatility";
|
|
11
11
|
import { daysToExpiryFrom } from "./model";
|
|
12
12
|
import type { YieldPoint } from "../yield-curve/treasury-data";
|
|
@@ -56,6 +56,8 @@ const positive = (value: unknown): value is number => typeof value === "number"
|
|
|
56
56
|
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
57
57
|
const message = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
58
58
|
const expiryLabel = (expiration: number): string => new Date(expiration * 1000).toISOString().slice(0, 10);
|
|
59
|
+
/** Brackets are fetched separately, so their quote instants never match; the New York session is what must agree. */
|
|
60
|
+
const quoteSession = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" });
|
|
59
61
|
const empty = (error: string, warnings: string[] = []): CalculatorSurfaceVol => ({
|
|
60
62
|
volatility: null, rate: null, dividendYield: null, asOf: null, sourceSpot: null, spotAsOf: null, rateAsOf: [],
|
|
61
63
|
source: "OVDV midpoint", warnings, error,
|
|
@@ -86,15 +88,19 @@ export function projectCalculatorSurfaceVol(
|
|
|
86
88
|
snapshot: SurfaceSnapshot,
|
|
87
89
|
request: Pick<CalculatorSurfaceRequest, "symbol" | "strike" | "daysToExpiry">,
|
|
88
90
|
): CalculatorSurfaceVol {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
// Only the brackets price the option: other loaded expiries' caveats do not apply to it.
|
|
92
|
+
const failures = (expirations: readonly number[]) => snapshot.failures
|
|
93
|
+
.filter((failure) => failure.expiration == null || expirations.includes(failure.expiration))
|
|
94
|
+
.map((failure) => `${failure.expiration == null ? "Surface" : expiryLabel(failure.expiration)}: ${failure.message}`);
|
|
91
95
|
if (snapshot.symbol !== parsePublicTickerKey(request.symbol).symbol || !positive(snapshot.spot)) {
|
|
92
|
-
return empty("Surface snapshot does not match the requested underlying mark",
|
|
96
|
+
return empty("Surface snapshot does not match the requested underlying mark", failures([]));
|
|
93
97
|
}
|
|
94
98
|
const years = request.daysToExpiry / 365;
|
|
95
99
|
const brackets = bracketingExpiries(snapshot.catalogue, years, snapshot.fetchedAt);
|
|
96
|
-
if (!brackets.length) return empty("Requested tenor is outside the listed surface range; extrapolation is unavailable",
|
|
100
|
+
if (!brackets.length) return empty("Requested tenor is outside the listed surface range; extrapolation is unavailable", failures([]));
|
|
97
101
|
const selected = brackets.map((expiration) => snapshot.expiries.find((expiry) => expiry.expiration === expiration));
|
|
102
|
+
const slices = selected.filter((expiry): expiry is SurfaceExpiry => expiry != null);
|
|
103
|
+
const warnings = [...new Set([...slices.flatMap((expiry) => expiry.warnings), ...surfaceCalendarWarnings(slices), ...failures(brackets)])];
|
|
98
104
|
for (let index = 0; index < selected.length; index += 1) {
|
|
99
105
|
const expiry = selected[index];
|
|
100
106
|
if (!expiry || expiry.stale || expiry.error || expiry.state !== "ready" || !expiry.fit
|
|
@@ -120,7 +126,8 @@ export function projectCalculatorSurfaceVol(
|
|
|
120
126
|
const dates = [...new Set(selected.flatMap((expiry) => expiry?.asOf && Number.isFinite(Date.parse(expiry.asOf)) ? [expiry.asOf] : []))];
|
|
121
127
|
const asOf = dates.length ? dates.toSorted((a, b) => Date.parse(a) - Date.parse(b))[0]! : null;
|
|
122
128
|
if (selected.some((expiry) => !expiry?.asOf || !Number.isFinite(Date.parse(expiry.asOf)))) warnings.push("Surface quote observation date unavailable");
|
|
123
|
-
|
|
129
|
+
const sessions = [...new Set(dates.map((date) => quoteSession.format(Date.parse(date))))].sort();
|
|
130
|
+
if (sessions.length > 1) warnings.push(`Surface quote dates differ: ${sessions.join(", ")}`);
|
|
124
131
|
const rateDates = [...new Set(selected.flatMap((expiry) => expiry!.rateAsOf))];
|
|
125
132
|
if (!rateDates.length) warnings.push("Treasury observation date unavailable");
|
|
126
133
|
const fits = selected.map((expiry) => `${expiryLabel(expiry!.expiration)} ${expiry!.fit!.method} (${expiry!.source ?? "source unavailable"})`);
|
|
@@ -6,7 +6,7 @@ import type { EarningsEvent } from "../../../types/data-provider";
|
|
|
6
6
|
import type { TickerRecord } from "../../../types/ticker";
|
|
7
7
|
import { priceColor } from "../../../theme/colors";
|
|
8
8
|
import { formatQuoteAgeWithSource, resolveQuoteAgeTimestamp } from "../../../market-data/quotes/time";
|
|
9
|
-
import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
9
|
+
import { convertCurrency, formatCompact, formatCompactAmount, formatNumber, formatPercentRaw } from "../../../utils/format";
|
|
10
10
|
import {
|
|
11
11
|
formatMarketCost,
|
|
12
12
|
quoteFormatOptions,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
formatMarketPriceWithCurrency,
|
|
15
15
|
formatMarketQuantity,
|
|
16
16
|
formatSignedMarketPrice,
|
|
17
|
+
withStablePriceDigits,
|
|
17
18
|
type MarketFormatOptions,
|
|
18
19
|
} from "../../../market-data/market/format";
|
|
19
20
|
import {
|
|
@@ -239,7 +240,14 @@ export function getColumnValue(
|
|
|
239
240
|
multiplier: multiplierHint,
|
|
240
241
|
priceBasis: positionMetrics.priceBasis,
|
|
241
242
|
};
|
|
242
|
-
|
|
243
|
+
// Streamed prices keep one decimal count per instrument, so a tick landing on
|
|
244
|
+
// a whole dime does not print 150.1 and jump the column's digits.
|
|
245
|
+
const currentQuoteOptions = withStablePriceDigits(
|
|
246
|
+
{ ...formatOptions, ...quoteFormatOptions(quote, ticker.metadata.assetCategory, financials?.quoteMetadata?.instrumentType) },
|
|
247
|
+
quoteCurrency,
|
|
248
|
+
quote,
|
|
249
|
+
);
|
|
250
|
+
const markOptions = withStablePriceDigits(formatOptions, positionCurrency || quoteCurrency, { price: brokerMarkPrice });
|
|
243
251
|
|
|
244
252
|
switch (col.id) {
|
|
245
253
|
case "ticker": {
|
|
@@ -272,7 +280,7 @@ export function getColumnValue(
|
|
|
272
280
|
case "tags":
|
|
273
281
|
return { text: ticker.metadata.tags.length > 0 ? ticker.metadata.tags.join(",") : "—" };
|
|
274
282
|
case "price":
|
|
275
|
-
return resolvePortfolioPriceValue(displayQuote, brokerMarkPrice, displayQuote ? currentQuoteOptions :
|
|
283
|
+
return resolvePortfolioPriceValue(displayQuote, brokerMarkPrice, displayQuote ? currentQuoteOptions : markOptions, col.width, quote?.marketState);
|
|
276
284
|
case "change":
|
|
277
285
|
if (!displayQuote) return { text: "—" };
|
|
278
286
|
return {
|
|
@@ -304,10 +312,10 @@ export function getColumnValue(
|
|
|
304
312
|
? { text: formatPercentRaw(displayQuote.changePercent), color: marketChangeColor(displayQuote.changePercent, quote?.marketState) }
|
|
305
313
|
: { text: quote ? formatPercentRaw(quote.changePercent) : "—", color: quote ? marketChangeColor(quote.changePercent, quote.marketState) : undefined };
|
|
306
314
|
case "volume":
|
|
307
|
-
return { text: finiteNumber(quote?.volume) ? formatCompact(quote.volume) : "—" };
|
|
315
|
+
return { text: finiteNumber(quote?.volume) ? formatCompact(quote.volume, { fixedDecimals: true }) : "—" };
|
|
308
316
|
case "dollar_volume": {
|
|
309
317
|
if (!displayQuote || !finiteNumber(quote?.volume)) return { text: "—" };
|
|
310
|
-
return { text: formatCompact(toBaseQuote(displayQuote.price * quote.volume)) };
|
|
318
|
+
return { text: formatCompact(toBaseQuote(displayQuote.price * quote.volume), { fixedDecimals: true }) };
|
|
311
319
|
}
|
|
312
320
|
case "range_52w": {
|
|
313
321
|
const position = fiftyTwoWeekPosition(displayQuote, quote);
|
|
@@ -316,7 +324,7 @@ export function getColumnValue(
|
|
|
316
324
|
case "market_cap": {
|
|
317
325
|
const cap = liveMarketCapitalization(quote, fundamentals);
|
|
318
326
|
const value = cap ? convertMarketCapitalization(cap.value, cap.currency, ctx.baseCurrency, ctx.exchangeRates) : null;
|
|
319
|
-
return { text: value == null ? "—" : formatCompact(value) };
|
|
327
|
+
return { text: value == null ? "—" : formatCompact(value, { fixedDecimals: true }) };
|
|
320
328
|
}
|
|
321
329
|
case "pe":
|
|
322
330
|
return { text: formatPriceEarnings(liveTrailingPE(quote, fundamentals)) };
|
|
@@ -349,7 +357,7 @@ export function getColumnValue(
|
|
|
349
357
|
if (baseMetrics.positionCount === 0 || !Number.isFinite(baseMetrics.totalCost)) return { text: "—" };
|
|
350
358
|
return { text: formatCompact(baseMetrics.totalCost) };
|
|
351
359
|
case "mkt_value":
|
|
352
|
-
return { text:
|
|
360
|
+
return { text: formatCompactAmount(resolvePortfolioMarketValue(baseMetrics, activeQuote ? toBaseQuote(activeQuote.price) : null)?.gross ?? Number.NaN) };
|
|
353
361
|
case "weight": {
|
|
354
362
|
const marketValue = getActiveMarketValue(activeQuote, baseMetrics, toBaseQuote);
|
|
355
363
|
if (marketValue == null || !ctx.portfolioTotalMarketValue) return { text: "—" };
|
|
@@ -358,13 +366,13 @@ export function getColumnValue(
|
|
|
358
366
|
case "day_pnl":
|
|
359
367
|
if (activeQuote && finiteNumber(activeQuote.change) && Number.isFinite(positionMetrics.grossPriceUnits) && positionMetrics.grossPriceUnits !== 0) {
|
|
360
368
|
const dayPnl = toBaseQuote(totalPriceUnits * activeQuote.change);
|
|
361
|
-
return { text:
|
|
369
|
+
return { text: formatCompactAmount(dayPnl, { signed: true }), color: priceColor(dayPnl) };
|
|
362
370
|
}
|
|
363
371
|
return { text: "—" };
|
|
364
372
|
case "pnl": {
|
|
365
373
|
const pnl = positionPnl.value;
|
|
366
374
|
return pnl === null ? { text: "—", pnlBasis: positionPnl.basis }
|
|
367
|
-
: { text:
|
|
375
|
+
: { text: formatCompactAmount(pnl, { signed: true }), color: priceColor(pnl), pnlBasis: positionPnl.basis };
|
|
368
376
|
}
|
|
369
377
|
case "pnl_pct": {
|
|
370
378
|
const percent = portfolioPnlPercent(positionPnl.value, baseMetrics.totalCost);
|
|
@@ -59,7 +59,7 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
59
59
|
const segments = fitSummarySegments(buildPortfolioSummarySegments({
|
|
60
60
|
totals,
|
|
61
61
|
accountState: { account, sourceLabel: "Live" },
|
|
62
|
-
}),
|
|
62
|
+
}), 26);
|
|
63
63
|
|
|
64
64
|
expect(segments.map((segment) => segment.id)).toEqual(["netliq", "val"]);
|
|
65
65
|
});
|
|
@@ -85,14 +85,14 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
85
85
|
accountState: { account, sourceLabel: "Live" },
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
expect(segments.find((segment) => segment.id === "val")?.parts[1]?.text).toBe("
|
|
88
|
+
expect(segments.find((segment) => segment.id === "val")?.parts[1]?.text).toBe("175.0k");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("drops low-priority broker segments before required ones", () => {
|
|
92
92
|
const segments = fitSummarySegments(buildPortfolioSummarySegments({
|
|
93
93
|
totals,
|
|
94
94
|
accountState: { account, sourceLabel: "Live" },
|
|
95
|
-
}),
|
|
95
|
+
}), 112);
|
|
96
96
|
|
|
97
97
|
expect(segments.map((segment) => segment.id)).toEqual([
|
|
98
98
|
"netliq",
|
|
@@ -107,12 +107,12 @@ describe("buildPortfolioSummarySegments", () => {
|
|
|
107
107
|
|
|
108
108
|
test("the open cash drawer continues where the header row ran out of room", () => {
|
|
109
109
|
const segments = buildPortfolioSummarySegments({ totals, accountState: { account, sourceLabel: "Live" } });
|
|
110
|
-
const layout = layoutPortfolioSummaryHeader(segments,
|
|
110
|
+
const layout = layoutPortfolioSummaryHeader(segments, 56, { cashDrawer: true, hideHeader: false });
|
|
111
111
|
const ids = (list: typeof segments) => list.map((segment) => segment.id);
|
|
112
112
|
|
|
113
113
|
expect(ids(layout.row)).toEqual(["netliq", "val", "cash"]);
|
|
114
114
|
expect(ids(layout.detail)).toEqual(["day", "pnl", "settled"]);
|
|
115
|
-
expect(ids(layoutPortfolioSummaryHeader(segments,
|
|
115
|
+
expect(ids(layoutPortfolioSummaryHeader(segments, 56, { cashDrawer: true, hideHeader: true }).detail))
|
|
116
116
|
.toEqual(["netliq", "val", "cash"]);
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -143,11 +143,11 @@ describe("portfolio-metrics", () => {
|
|
|
143
143
|
const mutedPositive = blendHex(colors.positive, colors.textDim, 0.55);
|
|
144
144
|
|
|
145
145
|
expect(getColumnValue(priceColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
146
|
-
text: "120",
|
|
146
|
+
text: "120.00",
|
|
147
147
|
color: colors.textDim,
|
|
148
148
|
});
|
|
149
149
|
expect(getColumnValue(changeColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
150
|
-
text: "+5",
|
|
150
|
+
text: "+5.00",
|
|
151
151
|
color: mutedPositive,
|
|
152
152
|
});
|
|
153
153
|
expect(getColumnValue(changePctColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
@@ -337,13 +337,13 @@ describe("portfolio-metrics", () => {
|
|
|
337
337
|
const latencyColumn: ColumnConfig = { id: "latency", label: "AGE", width: 6, align: "right" };
|
|
338
338
|
|
|
339
339
|
expect(getColumnValue(dayPnlColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
340
|
-
text: "+50",
|
|
340
|
+
text: "+50.00",
|
|
341
341
|
color: expect.any(String),
|
|
342
342
|
});
|
|
343
343
|
expect(getSortValue(dayPnlColumn, ticker, financials, defaultColumnContext)).toBe(50);
|
|
344
344
|
expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
345
345
|
pnlBasis: "quote-and-cost",
|
|
346
|
-
text: "+200",
|
|
346
|
+
text: "+200.00",
|
|
347
347
|
color: expect.any(String),
|
|
348
348
|
});
|
|
349
349
|
expect(getSortValue(pnlColumn, ticker, financials, defaultColumnContext)).toBe(200);
|
|
@@ -366,7 +366,7 @@ describe("portfolio-metrics", () => {
|
|
|
366
366
|
expect(getSortValue(pnlColumn, ticker, financials, defaultColumnContext)).toBe(200);
|
|
367
367
|
expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
|
|
368
368
|
pnlBasis: "quote-and-cost",
|
|
369
|
-
text: "+200",
|
|
369
|
+
text: "+200.00",
|
|
370
370
|
color: expect.any(String),
|
|
371
371
|
});
|
|
372
372
|
expect(calculatePortfolioSummaryTotals(
|
|
@@ -425,7 +425,7 @@ describe("portfolio-metrics", () => {
|
|
|
425
425
|
expect(getColumnValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
|
|
426
426
|
expect(getSortValue({ id: "weight", label: "WEIGHT", width: 8, align: "right" }, ticker, financials, context)).toBe(50);
|
|
427
427
|
expect(getColumnValue({ id: "range_52w", label: "52W%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+50.00%");
|
|
428
|
-
expect(getColumnValue({ id: "dollar_volume", label: "$VOL", width: 9, align: "right" }, ticker, financials, context).text).toBe("1.
|
|
428
|
+
expect(getColumnValue({ id: "dollar_volume", label: "$VOL", width: 9, align: "right" }, ticker, financials, context).text).toBe("1.50B");
|
|
429
429
|
expect(getColumnValue({ id: "spread_pct", label: "SPR%", width: 7, align: "right" }, ticker, financials, context).text).toBe("+0.83%");
|
|
430
430
|
expect(getColumnValue({ id: "bid_ask_size", label: "B/A SZ", width: 9, align: "right" }, ticker, financials, context).text).toBe("100/150");
|
|
431
431
|
expect(getColumnValue({ id: "mark_delta", label: "MARK%", width: 8, align: "right" }, ticker, financials, context).text).toBe("+1.00%");
|
|
@@ -492,8 +492,8 @@ describe("position aggregation across sides, currencies and broker coverage", ()
|
|
|
492
492
|
expect(getSortValue(column("pnl"), ticker, financials, defaultColumnContext)).toBe(1000);
|
|
493
493
|
expect(getSortValue(column("pnl_pct"), ticker, financials, defaultColumnContext)).toBeCloseTo(1000 / 21000 * 100);
|
|
494
494
|
expect(getColumnValue(column("shares"), ticker, financials, defaultColumnContext).text).toBe("0");
|
|
495
|
-
expect(getColumnValue(column("pnl"), ticker, financials, defaultColumnContext).text).toBe("+
|
|
496
|
-
expect(buildPortfolioSummarySegments({ totals, accountState: null }).map(segment => segment.parts.map(part => part.text).join(" ")).join(" ")).toContain("Gross
|
|
495
|
+
expect(getColumnValue(column("pnl"), ticker, financials, defaultColumnContext).text).toBe("+1.0k");
|
|
496
|
+
expect(buildPortfolioSummarySegments({ totals, accountState: null }).map(segment => segment.parts.map(part => part.text).join(" ")).join(" ")).toContain("Gross 24.0k Net 0.00");
|
|
497
497
|
});
|
|
498
498
|
|
|
499
499
|
test("converts each cost basis before summing and withholds averages across native currencies", () => {
|
|
@@ -524,7 +524,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
524
524
|
});
|
|
525
525
|
for (let index = 0; index < 6; index++) await flushFrame();
|
|
526
526
|
const frame = testSetup!.captureCharFrame();
|
|
527
|
-
expect(frame).toContain(source === "unknown currency" ? "Net Liq —" : "Net Liq
|
|
527
|
+
expect(frame).toContain(source === "unknown currency" ? "Net Liq —" : "Net Liq 15.0k");
|
|
528
528
|
expect(frame).toContain(source === "CAD" ? "Cash 13.5k" : "Cash —");
|
|
529
529
|
expect(frame).not.toContain("Cash 0");
|
|
530
530
|
if (source !== "unknown currency") expect(requested).toContain("CAD");
|
|
@@ -815,7 +815,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
815
815
|
await flushFrame();
|
|
816
816
|
|
|
817
817
|
const frame = testSetup.captureCharFrame();
|
|
818
|
-
expect(frame).toMatch(/AAPL\s+125\s+\+4\.17%/);
|
|
818
|
+
expect(frame).toMatch(/AAPL\s+125\.00\s+\+4\.17%/);
|
|
819
819
|
expect(frame).toContain("100");
|
|
820
820
|
expect(frame).toContain("1.4k");
|
|
821
821
|
expect(frame).toContain("+275");
|
|
@@ -838,7 +838,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
838
838
|
await flushFrame();
|
|
839
839
|
const before = testSetup.captureCharFrame();
|
|
840
840
|
expect(before).toContain("⚠");
|
|
841
|
-
expect(before).toMatch(/AAPL\s+—\s+1\.2k\s+\+200\s+—/);
|
|
841
|
+
expect(before).toMatch(/AAPL\s+—\s+1\.2k\s+\+200\.00\s+—/);
|
|
842
842
|
expect(before).not.toContain("NaN");
|
|
843
843
|
const corrected = { ...imported, metadata: { ...imported.metadata,
|
|
844
844
|
positions: [{ ...imported.metadata.positions[0]!, avgCost: 100 }],
|
|
@@ -847,7 +847,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
847
847
|
await flushFrame();
|
|
848
848
|
const after = testSetup.captureCharFrame();
|
|
849
849
|
expect(after).not.toContain("⚠");
|
|
850
|
-
expect(after).toMatch(/AAPL\s+100\s+1\.2k\s+\+200\s+\+20\.00%/);
|
|
850
|
+
expect(after).toMatch(/AAPL\s+100\s+1\.2k\s+\+200\.00\s+\+20\.00%/);
|
|
851
851
|
});
|
|
852
852
|
}
|
|
853
853
|
|
|
@@ -1055,8 +1055,8 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1055
1055
|
expect(frame).toContain("4.25");
|
|
1056
1056
|
expect(frame).toContain("5");
|
|
1057
1057
|
expect(frame).toContain("850");
|
|
1058
|
-
expect(frame).toContain("
|
|
1059
|
-
expect(frame).toContain("+150");
|
|
1058
|
+
expect(frame).toContain("1.0k");
|
|
1059
|
+
expect(frame).toContain("+150.00");
|
|
1060
1060
|
expect(frame).not.toContain("$4.25");
|
|
1061
1061
|
});
|
|
1062
1062
|
|
|
@@ -1093,8 +1093,8 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1093
1093
|
|
|
1094
1094
|
const frame = testSetup.captureCharFrame();
|
|
1095
1095
|
expect(frame).toContain("Cash & Margin");
|
|
1096
|
-
expect(frame.match(/Cash -
|
|
1097
|
-
expect(frame.match(/Net Liq
|
|
1096
|
+
expect(frame.match(/Cash -50.0k/g)).toHaveLength(1);
|
|
1097
|
+
expect(frame.match(/Net Liq 125.0k/g)).toHaveLength(1);
|
|
1098
1098
|
expect(frame).toContain("Flex Mar 27");
|
|
1099
1099
|
expect(frame).toContain("-351,957.025");
|
|
1100
1100
|
expect(frame).not.toContain("Avail");
|
|
@@ -1142,7 +1142,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1142
1142
|
{ width: 60, height: 8 },
|
|
1143
1143
|
);
|
|
1144
1144
|
await flushFrame();
|
|
1145
|
-
expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\s+2s/);
|
|
1145
|
+
expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\.00\s+2s/);
|
|
1146
1146
|
|
|
1147
1147
|
// A quote received since the clock last ticked reads its true age on the next tick.
|
|
1148
1148
|
clock += 5_000;
|
|
@@ -1150,7 +1150,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1150
1150
|
await Bun.sleep(AGE_CLOCK_MS + 50);
|
|
1151
1151
|
});
|
|
1152
1152
|
await flushFrame();
|
|
1153
|
-
expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\s+7s/);
|
|
1153
|
+
expect(testSetup.captureCharFrame()).toMatch(/AAPL\s+125\.00\s+7s/);
|
|
1154
1154
|
});
|
|
1155
1155
|
|
|
1156
1156
|
test("warms full financials for visible rows when only quote data is loaded", async () => {
|
|
@@ -1252,7 +1252,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1252
1252
|
|
|
1253
1253
|
const frame = testSetup.captureCharFrame();
|
|
1254
1254
|
expect(calls).toBeGreaterThan(0);
|
|
1255
|
-
expect(frame).toContain("
|
|
1255
|
+
expect(frame).toContain("2.00B");
|
|
1256
1256
|
expect(frame).toContain("25.0");
|
|
1257
1257
|
expect(frame).toContain("22.0");
|
|
1258
1258
|
});
|
|
@@ -1511,7 +1511,7 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
1511
1511
|
|
|
1512
1512
|
const frame = testSetup.captureCharFrame();
|
|
1513
1513
|
expect(liveCalls).toBe(0);
|
|
1514
|
-
expect(frame).toContain("
|
|
1514
|
+
expect(frame).toContain("2.00B");
|
|
1515
1515
|
expect(frame).toContain("25.0");
|
|
1516
1516
|
expect(frame).toContain("22.0");
|
|
1517
1517
|
|
|
@@ -54,7 +54,7 @@ test("a current mark cannot refresh broker profit without cost, and manual corre
|
|
|
54
54
|
const record = ticker(lot({ unrealizedPnl: 200, marketValue: 1150, markPrice: 115 }));
|
|
55
55
|
expect(totals(record)).toMatchObject({ totalMktValue: 1200, totalCostBasis: Number.NaN, unrealizedPnl: 200, unrealizedPnlPct: Number.NaN,
|
|
56
56
|
unrealizedPnlBasis: "broker-snapshot", unavailableCostSymbols: ["AAPL"], brokerPnlSymbols: ["AAPL"] });
|
|
57
|
-
expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+200", pnlBasis: "broker-snapshot" });
|
|
57
|
+
expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+200.00", pnlBasis: "broker-snapshot" });
|
|
58
58
|
expect(getSortValue(column("pnl"), record, financials, context)).toBe(200);
|
|
59
59
|
const summary = buildPortfolioSummarySegments({ totals: totals(record), accountState: null });
|
|
60
60
|
expect(summary.flatMap(segment => segment.parts.map(part => part.text)).join(" ")).toContain("Broker P&L");
|
|
@@ -71,7 +71,7 @@ test("mixed lots select each independently, preserve offsetting exposure, and wi
|
|
|
71
71
|
const record = ticker(lot({ avgCost: 100 }), lot({ shares: -10, unrealizedPnl: 50 }));
|
|
72
72
|
expect(totals(record)).toMatchObject({ netMktValue: 0, totalMktValue: 2400, unrealizedPnl: 250,
|
|
73
73
|
unrealizedPnlBasis: "mixed", totalCostBasis: Number.NaN, unrealizedPnlPct: Number.NaN });
|
|
74
|
-
expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+250", pnlBasis: "mixed" });
|
|
74
|
+
expect(getColumnValue(column("pnl"), record, financials, context)).toMatchObject({ text: "+250.00", pnlBasis: "mixed" });
|
|
75
75
|
expect(getSortValue(column("pnl"), record, financials, context)).toBe(250);
|
|
76
76
|
record.metadata.positions.push(lot({ shares: 1 }));
|
|
77
77
|
expect(totals(record).unrealizedPnl).toBeNaN();
|
|
@@ -7,7 +7,7 @@ import type { BrokerConnectionStatus } from "../../../../types/broker";
|
|
|
7
7
|
import type { TickerFinancials } from "../../../../types/financials";
|
|
8
8
|
import type { Portfolio, TickerRecord } from "../../../../types/ticker";
|
|
9
9
|
import type { BrokerAccount, BrokerCashBalance } from "../../../../types/trading";
|
|
10
|
-
import { displayWidth,
|
|
10
|
+
import { displayWidth, formatCompactAmount, formatPercentRaw } from "../../../../utils/format";
|
|
11
11
|
import { getBrokerInstance } from "../../../../utils/broker-instances";
|
|
12
12
|
import {
|
|
13
13
|
resolvePortfolioAccountMetrics,
|
|
@@ -59,8 +59,9 @@ function createSummarySegment(
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Totals move with every streamed price; fixed decimals keep the segments after them still.
|
|
62
63
|
function formatSignedCompact(value: number): string {
|
|
63
|
-
return
|
|
64
|
+
return formatCompactAmount(value, { signed: true });
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
function formatMonthDay(date: Date): string {
|
|
@@ -240,26 +241,26 @@ export function buildPortfolioSummarySegments({
|
|
|
240
241
|
const accountValue = (id: string, label: string, value: number | undefined) => value != null
|
|
241
242
|
? createSummarySegment(id, [
|
|
242
243
|
{ text: label, tone: "label" },
|
|
243
|
-
{ text:
|
|
244
|
+
{ text: formatCompactAmount(convertAccountValue(value)), tone: "value", bold: true },
|
|
244
245
|
])
|
|
245
246
|
: null;
|
|
246
247
|
|
|
247
248
|
if (netLiquidation != null) {
|
|
248
249
|
candidates.push(createSummarySegment("netliq", [
|
|
249
250
|
{ text: "Net Liq", tone: "label" },
|
|
250
|
-
{ text:
|
|
251
|
+
{ text: formatCompactAmount(netLiquidation), tone: "value", bold: true },
|
|
251
252
|
]));
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
candidates.push(createSummarySegment("val", [
|
|
255
256
|
{ text: totals.hasShorts ? "Gross" : "Val", tone: "label" },
|
|
256
|
-
{ text:
|
|
257
|
+
{ text: formatCompactAmount(totalMarketValue), tone: "value", bold: true },
|
|
257
258
|
]));
|
|
258
259
|
|
|
259
260
|
if (totals.hasShorts && totals.netMktValue != null) {
|
|
260
261
|
candidates.push(createSummarySegment("net-value", [
|
|
261
262
|
{ text: "Net", tone: "label" },
|
|
262
|
-
{ text:
|
|
263
|
+
{ text: formatCompactAmount(totals.netMktValue), tone: "value", bold: true },
|
|
263
264
|
]));
|
|
264
265
|
}
|
|
265
266
|
|
|
@@ -47,7 +47,8 @@ export async function fetchRatePath(client: Pick<typeof apiClient, "getCloudRate
|
|
|
47
47
|
|
|
48
48
|
export function getCachedRatePath(): RatePathPayload | null {
|
|
49
49
|
const cached = ratePathCache.get("usd", { allowExpired: true });
|
|
50
|
-
|
|
50
|
+
// The pane revalidates this copy on mount; only the source or a failed refresh makes it stale.
|
|
51
|
+
return cached?.data ?? null;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export async function loadRatePath(force = false): Promise<RatePathPayload> {
|
|
@@ -29,7 +29,7 @@ const contractColumns: HeadlessPaneColumn[] = [
|
|
|
29
29
|
|
|
30
30
|
export const ratePathHeadless: HeadlessPaneDefinition<"bundle"> = {
|
|
31
31
|
discovery: { aliases: ["WIRP", "FFIP"], dataRequirements: ["Gloom Cloud rate-path endpoint"],
|
|
32
|
-
limitations: ["Conditional two-outcome probabilities", "
|
|
32
|
+
limitations: ["Conditional two-outcome probabilities", "SR3 history from hourly trades", "Maintained FOMC schedule and SEP snapshot"] },
|
|
33
33
|
shape: "bundle", argument: { kind: "none" }, options: [], describe: "US rate path",
|
|
34
34
|
async load(_args, ctx) {
|
|
35
35
|
const data = await fetchRatePath(ctx.apiClient);
|
|
@@ -107,7 +107,6 @@ export function RatePathPane({ width, height, focused }: PaneProps) {
|
|
|
107
107
|
info: data ? [
|
|
108
108
|
{ id: "as-of", parts: [{ text: `as of ${timestamp(data.asOf)} UTC`, tone: "muted" }] },
|
|
109
109
|
...(data.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
|
|
110
|
-
...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
|
|
111
110
|
] : [],
|
|
112
111
|
});
|
|
113
112
|
const selection = { kind: "id" as const, selectedId: selected, getId: (row: RateMeeting) => row.date, onChange: setSelected };
|
|
@@ -173,6 +173,13 @@ describe("independent current-IV loading", () => {
|
|
|
173
173
|
expect(calls).toBe(1);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test("an underlying with no option chain has no IV line and no error", async () => {
|
|
177
|
+
const empty: OptionsChain = { underlyingSymbol: "7203.T", expirationDates: [], calls: [], puts: [] };
|
|
178
|
+
const result = await loadCurrentAtmIv({ instrument: { symbol: "7203.T" }, spot: 100 }, { now: () => now, loadYieldCurve: async () => [],
|
|
179
|
+
loadOptions: async () => ({ ...ready(empty), error: { reasonCode: "NO_DATA", message: "No data available" } }) });
|
|
180
|
+
expect(result).toEqual({ reference: null, error: null, warnings: [], noOptionChain: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
176
183
|
test("surface cancellation remains cancellation rather than a missing-IV result", async () => {
|
|
177
184
|
const gate = deferred<QueryEntry<OptionsChain>>();
|
|
178
185
|
const controller = new AbortController();
|
|
@@ -63,7 +63,7 @@ export function realizedVolHeadless(initialView: "graph" | "cone"): HeadlessPane
|
|
|
63
63
|
] },
|
|
64
64
|
],
|
|
65
65
|
complete: model.cone.every((row) => row.current != null) && model.warnings.length === 0 && !history.stale && errors.length === 0
|
|
66
|
-
&& (!args.options.showIv || iv?.reference != null),
|
|
66
|
+
&& (!args.options.showIv || iv?.reference != null || iv?.noOptionChain === true),
|
|
67
67
|
unavailableSymbols: model.history.length ? [] : [instrument.symbol], errors,
|
|
68
68
|
metadata: { unit: "decimal annualized volatility", annualization: 252, model, currentIv: iv,
|
|
69
69
|
source: history.source, stale: history.stale, fetchedAt: history.fetchedAt,
|
|
@@ -111,10 +111,12 @@ describe("realized-volatility projection", () => {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
describe("current ATM IV reference", () => {
|
|
114
|
-
test("selects the listed tenor nearest 30 days and
|
|
115
|
-
const selected = expiry(31, { atmIV: 0.27 });
|
|
116
|
-
const
|
|
114
|
+
test("selects the listed tenor nearest 30 days and keeps only its own and surface-wide caveats", () => {
|
|
115
|
+
const selected = expiry(31, { atmIV: 0.27, warnings: ["9 butterfly arbitrage warnings"] });
|
|
116
|
+
const leaps = expiry(800, { warnings: ["SVI did not converge"] });
|
|
117
|
+
const result = projectCurrentAtmIv(surface([expiry(7), selected, expiry(60), leaps], {
|
|
117
118
|
phase: "partial", failures: [{ expiration: 100, message: "unrelated expiry offline" }],
|
|
119
|
+
warnings: ["SVI did not converge", "9 butterfly arbitrage warnings", "2 calendar arbitrage warnings"],
|
|
118
120
|
}));
|
|
119
121
|
expect(result.reference!.value).toBe(0.27);
|
|
120
122
|
expect(result.reference!.expiration).toBe(selected.expiration);
|
|
@@ -122,7 +124,17 @@ describe("current ATM IV reference", () => {
|
|
|
122
124
|
expect(result.reference!.date.toISOString()).toBe(new Date(selected.asOf!).toISOString());
|
|
123
125
|
expect(result.reference!.source).toBe("test");
|
|
124
126
|
expect(result.reference!.ivSource).toBe("recomputed");
|
|
125
|
-
expect(result.error).
|
|
127
|
+
expect(result.error).toBeNull();
|
|
128
|
+
expect(result.warnings).toEqual(["9 butterfly arbitrage warnings"]);
|
|
129
|
+
const offline = projectCurrentAtmIv(surface([selected], { failures: [{ expiration: null, message: "Treasury: offline" }] }));
|
|
130
|
+
expect(offline.error).toBe("Treasury: offline");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("says when nearer expiries without usable quotes pushed the reference to a farther tenor", () => {
|
|
134
|
+
const result = projectCurrentAtmIv(surface([expiry(1), expiry(29, { atmIV: null, state: "empty" }), expiry(60, { atmIV: null, state: "error" })]));
|
|
135
|
+
expect(result.reference!.daysToExpiry).toBeCloseTo(1, 12);
|
|
136
|
+
expect(result.warnings).toContain("Expiries nearer 30 days have no usable ATM quotes; reference uses the 1d expiry");
|
|
137
|
+
expect(projectCurrentAtmIv(surface([expiry(29), expiry(60, { atmIV: null, state: "empty" })])).warnings).toEqual([]);
|
|
126
138
|
});
|
|
127
139
|
|
|
128
140
|
test("withholds stale, failed and undated observations and chooses the next eligible expiry", () => {
|