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
|
@@ -6,6 +6,7 @@ import { TestDialogProvider, testRender } from "../../../../renderers/opentui/te
|
|
|
6
6
|
import { appReducer, createInitialState } from "../../../../state/app/context";
|
|
7
7
|
import { createTestPaneConfig, createTestTicker, TestPaneProvider } from "../../../../test-support/pane";
|
|
8
8
|
import { createTestPluginRuntime } from "../../../../test-support/plugin-runtime";
|
|
9
|
+
import type { PluginPersistence } from "../../../../types/plugin";
|
|
9
10
|
import { Box, Text } from "../../../../ui";
|
|
10
11
|
import { ThesisBoardPane } from "./board-pane";
|
|
11
12
|
import { thesisStore } from "./store";
|
|
@@ -193,6 +194,30 @@ describe("ThesisBoardPane", () => {
|
|
|
193
194
|
expect(frame).toContain("Weights");
|
|
194
195
|
});
|
|
195
196
|
|
|
197
|
+
test("the on-device copy is only called offline once the refresh fails", async () => {
|
|
198
|
+
let failList: (error: Error) => void = () => {};
|
|
199
|
+
setCloudApiFetchTransport(async (url, init) => {
|
|
200
|
+
const parsed = new URL(url);
|
|
201
|
+
if (parsed.pathname === "/theses") return new Promise<Response>((_, reject) => { failList = reject; });
|
|
202
|
+
const { status, body } = respond(parsed.pathname, init?.method ?? "GET");
|
|
203
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
204
|
+
});
|
|
205
|
+
const saved = new Map<string, unknown>([["thesis-cache", [nvda, asml]]]);
|
|
206
|
+
thesisStore.attach({ getState: (key: string) => saved.get(key) ?? null, setState: (key: string, value: unknown) => { saved.set(key, value); } } as unknown as PluginPersistence);
|
|
207
|
+
thesisStore.start();
|
|
208
|
+
await act(async () => {
|
|
209
|
+
setup = await testRender(<Harness />, { width: 110, height: 30 });
|
|
210
|
+
});
|
|
211
|
+
await flush(null);
|
|
212
|
+
const loading = setup!.captureCharFrame();
|
|
213
|
+
expect(loading).toContain("ASML");
|
|
214
|
+
expect(loading).toContain("footer: loading");
|
|
215
|
+
expect(loading).not.toContain("offline copy");
|
|
216
|
+
await act(async () => { failList(new Error("network down")); });
|
|
217
|
+
await flush(null);
|
|
218
|
+
expect(setup!.captureCharFrame()).toContain("offline copy");
|
|
219
|
+
});
|
|
220
|
+
|
|
196
221
|
test("enter opens the thesis: pillars, kill conditions, catalysts, and the open signal with its source", async () => {
|
|
197
222
|
thesisStore.start();
|
|
198
223
|
await act(async () => {
|
|
@@ -17,7 +17,7 @@ import { colors } from "../../../../theme/colors";
|
|
|
17
17
|
import type { PaneProps } from "../../../../types/plugin";
|
|
18
18
|
import { Box, TextAttributes, type InputRenderable } from "../../../../ui";
|
|
19
19
|
import { useDialog } from "../../../../ui/dialog";
|
|
20
|
-
import {
|
|
20
|
+
import { formatCompactAmount } from "../../../../utils/format";
|
|
21
21
|
import { isPlainKey } from "../../../../utils/keyboard";
|
|
22
22
|
import { stopSearchFocusNavigation } from "../../../../utils/search-focus-navigation";
|
|
23
23
|
import { usePluginAppActions } from "../../../runtime";
|
|
@@ -344,7 +344,8 @@ export function ThesisBoardPane({ focused, width, height }: PaneProps) {
|
|
|
344
344
|
case "weight":
|
|
345
345
|
return { text: `${pct(row.weight)}${row.hasOptions ? "*" : ""}`, color: selectedColor ?? colors.text };
|
|
346
346
|
case "value":
|
|
347
|
-
|
|
347
|
+
// A thesis can be worth under 1000, where formatCompactCurrency would trim the cents as prices move.
|
|
348
|
+
return { text: `${formatCompactAmount(row.value)} ${exposure.baseCurrency}`, color: selectedColor ?? colors.textDim };
|
|
348
349
|
case "gap": {
|
|
349
350
|
const text = row.gap > 0 ? "under-sized" : row.gap < 0 ? "grew big" : "in line";
|
|
350
351
|
const color = row.gap > 0 ? colors.warning : row.gap < 0 ? colors.negative : colors.textDim;
|
|
@@ -8,7 +8,7 @@ interface ThesisStoreSnapshot {
|
|
|
8
8
|
loaded: boolean;
|
|
9
9
|
loading: boolean;
|
|
10
10
|
error: string | null;
|
|
11
|
-
/** Set
|
|
11
|
+
/** Set when the last refresh failed and the list shown is the on-device copy. */
|
|
12
12
|
offline: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -89,7 +89,7 @@ class ThesisStore {
|
|
|
89
89
|
this.persistence = persistence;
|
|
90
90
|
const cached = persistence.getState<CloudThesis[]>(CACHE_STATE_KEY);
|
|
91
91
|
if (Array.isArray(cached) && cached.length > 0) {
|
|
92
|
-
this.update({ theses: cached
|
|
92
|
+
this.update({ theses: cached });
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -80,7 +80,7 @@ export function TradeDetail({
|
|
|
80
80
|
{/* The detail title already carries the member and ticker. */}
|
|
81
81
|
<DetailLine label="chamber" value={`${trade.chamber === "senate" ? "Senate" : "House"}${trade.stateDistrict ? ` · ${trade.stateDistrict}` : ""}`} tone="muted" />
|
|
82
82
|
<DetailLine label="party" value={trade.party ?? "--"} />
|
|
83
|
-
<DetailLine label="side" value={trade.transactionType} tone={trade.side === "BUY" ? "positive" : trade.side === "SELL" ? "negative" : "
|
|
83
|
+
<DetailLine label="side" value={trade.transactionType} tone={trade.side === "BUY" ? "positive" : trade.side === "SELL" ? "negative" : trade.side === "OTHER" ? "muted" : undefined} />
|
|
84
84
|
<DetailLine label="asset" value={truncate(trade.assetName, Math.max(10, lineWidth - 16))} />
|
|
85
85
|
<DetailLine label="amount" value={trade.amount} tone="value" />
|
|
86
86
|
<DetailLine label="owner" value={trade.owner} />
|
|
@@ -21,7 +21,7 @@ export function sideColor(side: CloudCongressTradePayload["side"], selected: boo
|
|
|
21
21
|
if (selected) return colors.selectedText;
|
|
22
22
|
if (side === "BUY") return colors.positive;
|
|
23
23
|
if (side === "SELL") return colors.negative;
|
|
24
|
-
if (side === "EXCHANGE") return colors.
|
|
24
|
+
if (side === "EXCHANGE") return colors.text;
|
|
25
25
|
return colors.textDim;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -84,7 +84,8 @@ export interface CotDetailData { payload: CotContractPayload; price: PricePoint[
|
|
|
84
84
|
export async function loadCotDetail(contractCode: string, family: CotFamily, client: Pick<typeof apiClient, "getCloudCotContract" | "getCloudHistory"> = apiClient): Promise<CotDetailData> {
|
|
85
85
|
const payload = await fetchCotContract(contractCode, family, client);
|
|
86
86
|
const mapping = cotPriceMapping(contractCode);
|
|
87
|
-
|
|
87
|
+
// An unmapped market simply has no price panel; that is not a data failure.
|
|
88
|
+
if (!mapping?.priceSymbol) return { payload, price: [], priceSymbol: null, priceAsOf: null, priceWarning: null };
|
|
88
89
|
try {
|
|
89
90
|
const result = await client.getCloudHistory(mapping.priceSymbol, mapping.exchange, { interval: "1day", rangeKey: "5Y", outputsize: 1500 });
|
|
90
91
|
if (!Array.isArray(result.data)) throw new Error("Front-price history unavailable");
|
|
@@ -56,6 +56,15 @@ test("price-source failure leaves report history readable and never substitutes
|
|
|
56
56
|
const resultVx = await loadCotDetail("1170E1", "legacy", { getCloudCotContract: async () => vx, getCloudHistory: async () => { priceRequested = true; throw new Error("must not substitute cash VIX"); } });
|
|
57
57
|
expect(priceRequested).toBe(false);
|
|
58
58
|
expect(resultVx.priceSymbol).toBeNull();
|
|
59
|
+
expect(resultVx.priceWarning).toBeNull();
|
|
60
|
+
const corn = payload(); corn.contract = { ...corn.contract!, contractCode: "002602", marketName: "CORN" };
|
|
61
|
+
const requested: string[] = [];
|
|
62
|
+
const resultCorn = await loadCotDetail("002602", "legacy", { getCloudCotContract: async () => corn,
|
|
63
|
+
getCloudHistory: async (symbol, exchange) => { requested.push(`${symbol} ${exchange}`); return { status: "success", data: [] } as never; } });
|
|
64
|
+
expect(requested).toEqual(["ZC=F CBT"]);
|
|
65
|
+
expect(resultCorn.priceSymbol).toBe("ZC=F");
|
|
66
|
+
// A market code is never an equity alias: Soybean Meal's ZM stays Zoom's.
|
|
67
|
+
expect(cotContractCode("ZM")).toBeNull();
|
|
59
68
|
});
|
|
60
69
|
|
|
61
70
|
test("missing migration gives unavailable state; access failures remain access failures", async () => {
|
|
@@ -19,6 +19,35 @@ const ROOTS: Record<string, { code: string; exchange: string; priceSymbol: strin
|
|
|
19
19
|
SR3: { code: "134741", exchange: "CME", priceSymbol: null },
|
|
20
20
|
ES: { code: "13874A", exchange: "CME", priceSymbol: "ES=F" },
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* Front-month price overlays for major markets without a root alias, each
|
|
24
|
+
* checked to return five years of daily history (2026-09-24). Keyed by CFTC
|
|
25
|
+
* code, never added to ROOTS: an alias would also claim the equity ticker under
|
|
26
|
+
* the cursor (ZM, ZS, PL). No cash index stands in, so the dollar index has none.
|
|
27
|
+
*/
|
|
28
|
+
const PRICE_OVERLAYS: Record<string, { exchange: string; priceSymbol: string }> = {
|
|
29
|
+
"209742": { exchange: "CME", priceSymbol: "NQ=F" }, "239742": { exchange: "CME", priceSymbol: "RTY=F" },
|
|
30
|
+
"124603": { exchange: "CBT", priceSymbol: "YM=F" },
|
|
31
|
+
"042601": { exchange: "CBT", priceSymbol: "ZT=F" }, "044601": { exchange: "CBT", priceSymbol: "ZF=F" },
|
|
32
|
+
"043607": { exchange: "CBT", priceSymbol: "TN=F" }, "020601": { exchange: "CBT", priceSymbol: "ZB=F" },
|
|
33
|
+
"020604": { exchange: "CBT", priceSymbol: "UB=F" },
|
|
34
|
+
"099741": { exchange: "CME", priceSymbol: "6E=F" }, "097741": { exchange: "CME", priceSymbol: "6J=F" },
|
|
35
|
+
"096742": { exchange: "CME", priceSymbol: "6B=F" }, "090741": { exchange: "CME", priceSymbol: "6C=F" },
|
|
36
|
+
"232741": { exchange: "CME", priceSymbol: "6A=F" }, "092741": { exchange: "CME", priceSymbol: "6S=F" },
|
|
37
|
+
"095741": { exchange: "CME", priceSymbol: "6M=F" },
|
|
38
|
+
"133741": { exchange: "CME", priceSymbol: "BTC=F" }, "146021": { exchange: "CME", priceSymbol: "ETH=F" },
|
|
39
|
+
"023651": { exchange: "NYM", priceSymbol: "NG=F" }, "111659": { exchange: "NYM", priceSymbol: "RB=F" },
|
|
40
|
+
"022651": { exchange: "NYM", priceSymbol: "HO=F" }, "06765T": { exchange: "NYM", priceSymbol: "BZ=F" },
|
|
41
|
+
"085692": { exchange: "CMX", priceSymbol: "HG=F" }, "076651": { exchange: "NYM", priceSymbol: "PL=F" },
|
|
42
|
+
"075651": { exchange: "NYM", priceSymbol: "PA=F" },
|
|
43
|
+
"002602": { exchange: "CBT", priceSymbol: "ZC=F" }, "005602": { exchange: "CBT", priceSymbol: "ZS=F" },
|
|
44
|
+
"001602": { exchange: "CBT", priceSymbol: "ZW=F" }, "001612": { exchange: "CBT", priceSymbol: "KE=F" },
|
|
45
|
+
"007601": { exchange: "CBT", priceSymbol: "ZL=F" }, "026603": { exchange: "CBT", priceSymbol: "ZM=F" },
|
|
46
|
+
"083731": { exchange: "NYB", priceSymbol: "KC=F" }, "080732": { exchange: "NYB", priceSymbol: "SB=F" },
|
|
47
|
+
"033661": { exchange: "NYB", priceSymbol: "CT=F" }, "073732": { exchange: "NYB", priceSymbol: "CC=F" },
|
|
48
|
+
"057642": { exchange: "CME", priceSymbol: "LE=F" }, "054642": { exchange: "CME", priceSymbol: "HE=F" },
|
|
49
|
+
"061641": { exchange: "CME", priceSymbol: "GF=F" },
|
|
50
|
+
};
|
|
22
51
|
/**
|
|
23
52
|
* The markets most readers mean by "positioning", verified against the live
|
|
24
53
|
* CFTC boards on 2026-09-22. A code missing from a report family simply does
|
|
@@ -55,7 +84,7 @@ export function cotContractCode(value: unknown): string | null {
|
|
|
55
84
|
export function cotRoot(code: string): string | null {
|
|
56
85
|
return Object.entries(ROOTS).find(([, row]) => row.code === code)?.[0] ?? null;
|
|
57
86
|
}
|
|
58
|
-
export function cotPriceMapping(code: string) { return Object.values(ROOTS).find((row) => row.code === code) ?? null; }
|
|
87
|
+
export function cotPriceMapping(code: string) { return Object.values(ROOTS).find((row) => row.code === code) ?? PRICE_OVERLAYS[code] ?? null; }
|
|
59
88
|
export function cotClass(family: CotFamily, value: unknown): CotClass {
|
|
60
89
|
return COT_CLASSES[family].find((row) => row.value === value)?.value ?? COT_CLASSES[family][0]!.value;
|
|
61
90
|
}
|
|
@@ -97,8 +97,7 @@ function CotBoard({ width, height, focused, family, initialCode }: PaneProps & {
|
|
|
97
97
|
] : [] }), [boardShown]);
|
|
98
98
|
usePaneNoticeFooter({ registrationId: "cot:board-notices", focused, enabled: !open, notices: data?.gaps ?? [] });
|
|
99
99
|
usePaneStatusFooter({ registrationId: "cot:board", enabled: !open, loading: resource.loading, error: resource.error,
|
|
100
|
-
info: data ? [{ id: "as-of", parts: [{ text: `CFTC futures only · ${data.asOf ?? "--"} · contracts`, tone: "muted" }] }
|
|
101
|
-
...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : [])] : [] });
|
|
100
|
+
info: data ? [{ id: "as-of", parts: [{ text: `CFTC futures only · ${data.asOf ?? "--"} · contracts`, tone: "muted" }] }] : [] });
|
|
102
101
|
if (!data && !open) return <PaneStatusBody loading={resource.loading} error={resource.error} empty={!resource.loading && !resource.error} subject="COT history" />;
|
|
103
102
|
return <DataTableStackView<CotBoardRow> columns={BOARD_COLUMNS} items={rows} focused={focused && !searching}
|
|
104
103
|
rootWidth={width} rootHeight={height} selection={{ kind: "id", selectedId: selected, getId: (row) => row.contractCode, onChange: setSelected }}
|
|
@@ -150,8 +149,7 @@ function CotDetail({ width, height, focused, code, family, traderClass, onClassC
|
|
|
150
149
|
});
|
|
151
150
|
usePaneNoticeFooter({ registrationId: "cot:detail-notices", focused, notices: [...(payload?.gaps ?? []), ...(data?.priceWarning ? [data.priceWarning] : []), ...partialRanks] });
|
|
152
151
|
usePaneStatusFooter({ registrationId: "cot:detail", loading: resource.loading, error: resource.error,
|
|
153
|
-
info: payload ? [{ id: "as-of", parts: [{ text: `CFTC ${payload.asOf ?? "--"} · contracts${data?.priceAsOf ? ` · ${data.priceSymbol} ${data.priceAsOf}` : ""}`, tone: "muted" }] }
|
|
154
|
-
...(payload.status !== "available" ? [{ id: "partial", parts: [{ text: payload.status, tone: "warning" as const }] }] : [])] : [] });
|
|
152
|
+
info: payload ? [{ id: "as-of", parts: [{ text: `CFTC ${payload.asOf ?? "--"} · contracts${data?.priceAsOf ? ` · ${data.priceSymbol} ${data.priceAsOf}` : ""}`, tone: "muted" }] }] : [] });
|
|
155
153
|
return <PaneStatusBody loading={resource.loading && !data} error={!data ? resource.error : null} empty={!!payload && !payload.contract} subject="COT contract">
|
|
156
154
|
{payload && data ? <Box width={width} height={height} flexDirection="column">
|
|
157
155
|
<CompositeChart series={series} panels={data.price.length ? [{ id: "price", height: 2 }, { id: "net", height: 1 }] : [{ id: "net" }]}
|
|
@@ -45,7 +45,8 @@ export function getCachedCreditConditions(): CreditConditionsLoadResult | null {
|
|
|
45
45
|
const cached = getCachedFredSeries(requestFor(definition.seriesId), { allowExpired: true });
|
|
46
46
|
if (!cached) continue;
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
// The pane revalidates this copy on mount; only the source or a failed refresh makes it stale.
|
|
49
|
+
rows.push(normalizeCreditSeries(definition, cached.data, cached.data.stale === true));
|
|
49
50
|
} catch (error) {
|
|
50
51
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
51
52
|
}
|
|
@@ -105,7 +105,8 @@ export function cachedCryptoMarkets(): CryptoMarketsResource | null {
|
|
|
105
105
|
const cached = cryptoMarketsCache.get("usd", { allowExpired: true });
|
|
106
106
|
if (!cached) return null;
|
|
107
107
|
try {
|
|
108
|
-
|
|
108
|
+
// The pane revalidates this copy on mount; only a failed refresh makes it stale.
|
|
109
|
+
return { payload: validateCryptoMarkets(cached.data), stale: false, refreshError: null };
|
|
109
110
|
} catch {
|
|
110
111
|
return null;
|
|
111
112
|
}
|
|
@@ -52,6 +52,15 @@ test("a live quote without a change percent is restated from the previous close"
|
|
|
52
52
|
expect(row!.changePercent).toBeCloseTo(10, 6);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test("a live price crossing a power of ten keeps the decimals of its previous close", () => {
|
|
56
|
+
const coin = { ...cryptoFixture().assets[0]!, price: 9.95, previousClose: 9.9 };
|
|
57
|
+
const rowAt = (price: number) => buildCryptoRows(
|
|
58
|
+
[coin], "coin", new Map([[cryptoQuoteKey(coin), entry({ price, lastUpdated: CRYPTO_FIXTURE_NOW })]]), CRYPTO_FIXTURE_NOW,
|
|
59
|
+
)[0]!.priceText;
|
|
60
|
+
expect(rowAt(9.998)).toBe("9.998");
|
|
61
|
+
expect(rowAt(10.02)).toBe("10.020");
|
|
62
|
+
});
|
|
63
|
+
|
|
55
64
|
test("tabs split coins from stablecoins and keep market-cap rank order", () => {
|
|
56
65
|
const assets = cryptoFixture().assets;
|
|
57
66
|
expect(buildCryptoRows(assets, "coin", new Map()).map((row) => row.code)).toEqual(["BTC", "HYPE"]);
|
|
@@ -41,6 +41,8 @@ export const cryptoQuoteKey = (asset: Pick<CryptoMarketAsset, "symbol">) =>
|
|
|
41
41
|
const finite = (value: number | null | undefined): value is number =>
|
|
42
42
|
typeof value === "number" && Number.isFinite(value);
|
|
43
43
|
|
|
44
|
+
const positive = (value: number | null | undefined): value is number => finite(value) && value > 0;
|
|
45
|
+
|
|
44
46
|
const utcDay = (ms: number) => Math.floor(ms / DAY_MS);
|
|
45
47
|
|
|
46
48
|
/** The close `days` UTC days before today, the basis the day change also uses. */
|
|
@@ -102,6 +104,9 @@ export function buildCryptoRow(
|
|
|
102
104
|
? [...closes, { date: new Date(Math.max(now, start + closes.length * DAY_MS)), close: price }]
|
|
103
105
|
: closes;
|
|
104
106
|
const updatedAt = quote?.lastUpdated ?? (asset.quoteTime ? Date.parse(asset.quoteTime) : null);
|
|
107
|
+
// The digits follow a price that holds still for the day, so a tick across a
|
|
108
|
+
// power of ten (0.9998 to 1.0002) keeps the row's decimals.
|
|
109
|
+
const referencePrice = [asset.previousClose, quote?.previousClose, quote?.open].find(positive) ?? asset.price;
|
|
105
110
|
return {
|
|
106
111
|
asset,
|
|
107
112
|
id: asset.symbol,
|
|
@@ -109,7 +114,7 @@ export function buildCryptoRow(
|
|
|
109
114
|
code: asset.code,
|
|
110
115
|
name: asset.name,
|
|
111
116
|
price,
|
|
112
|
-
priceText: formatCryptoPrice(price, asset.kind === "stablecoin" ? 4 : 2),
|
|
117
|
+
priceText: formatCryptoPrice(price, asset.kind === "stablecoin" ? 4 : 2, referencePrice),
|
|
113
118
|
changePercent,
|
|
114
119
|
return7d: percentChange(price, closeDaysAgo(asset, 7, now)),
|
|
115
120
|
return30d: percentChange(price, closeDaysAgo(asset, 30, now)),
|
|
@@ -149,10 +154,13 @@ export function buildCryptoRows(
|
|
|
149
154
|
* Four significant digits and never fewer than two decimals, with trailing
|
|
150
155
|
* zeros kept, so a column running from 84,399.24 to 0.000005660 reads evenly.
|
|
151
156
|
* Stablecoins ask for four decimals so 1.0001 and 0.9998 show the same peg detail.
|
|
157
|
+
* The digits are counted at `referencePrice` (a session-fixed price such as the
|
|
158
|
+
* previous close) when there is one, so a live price crossing a power of ten
|
|
159
|
+
* keeps its decimals.
|
|
152
160
|
*/
|
|
153
|
-
export function formatCryptoPrice(value: number | null, minimumDecimals = 2): string {
|
|
161
|
+
export function formatCryptoPrice(value: number | null, minimumDecimals = 2, referencePrice?: number | null): string {
|
|
154
162
|
if (value == null || !Number.isFinite(value) || value <= 0) return "—";
|
|
155
|
-
const magnitude = Math.floor(Math.log10(value));
|
|
163
|
+
const magnitude = Math.floor(Math.log10(positive(referencePrice) ? referencePrice : value));
|
|
156
164
|
const decimals = Math.min(12, Math.max(minimumDecimals, 3 - magnitude));
|
|
157
165
|
return value.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
158
166
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import type { Quote } from "../../../types/financials";
|
|
3
|
-
import { liveViewColumns, overlayViewRow, viewStreamTargets } from "./live-quotes";
|
|
3
|
+
import { liveViewColumns, liveViewDecimalFloors, overlayViewRow, viewStreamTargets } from "./live-quotes";
|
|
4
4
|
|
|
5
5
|
const quote: Quote = { symbol: "NVDA", price: 181.5, change: 3, changePercent: 1.68, volume: 12_000_000, currency: "USD", lastUpdated: 1 };
|
|
6
6
|
|
|
@@ -24,6 +24,11 @@ test("only a listing's own last price, day change and volume stream into a view"
|
|
|
24
24
|
expect(overlayViewRow(row, quote, fields)).toEqual({ ...row, price: 181.5, changePercent: 1.68, volume: 12_000_000 });
|
|
25
25
|
expect(overlayViewRow(row, { ...quote, currency: "EUR" }, fields)).toBe(row);
|
|
26
26
|
expect(overlayViewRow(row, { ...quote, stale: true }, fields)).toBe(row);
|
|
27
|
+
|
|
28
|
+
// A live price keeps cents whatever the tick, and none for a yen-only view.
|
|
29
|
+
expect(liveViewDecimalFloors([row], fields)).toEqual(new Map([["price", 2], ["changePercent", 2]]));
|
|
30
|
+
expect(liveViewDecimalFloors([{ ...row, currency: "JPY" }], fields).get("price")).toBe(0);
|
|
31
|
+
expect(liveViewDecimalFloors([{ ...row, currency: "JPY" }, row], fields).get("price")).toBe(2);
|
|
27
32
|
});
|
|
28
33
|
|
|
29
34
|
test("a view streams the rows on screen fast and one screen either side in the background", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveEntryValue } from "../../../market-data/coordinator";
|
|
2
|
+
import { currencyMinorDigits } from "../../../market-data/market/format";
|
|
2
3
|
import type { QueryEntry } from "../../../market-data/result-types";
|
|
3
4
|
import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
|
|
4
5
|
import type { Quote } from "../../../types/financials";
|
|
@@ -41,6 +42,31 @@ export function liveViewColumns(columns: readonly HeadlessPaneColumn[]): Map<str
|
|
|
41
42
|
return fields;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The fewest decimals a live column shows, however a tick lands: a price its
|
|
47
|
+
* currency's minor unit (cents, none for yen), a percent change two.
|
|
48
|
+
*/
|
|
49
|
+
export function liveViewDecimalFloors(rows: readonly ViewRow[], fields: ReadonlyMap<string, LiveViewField>): Map<string, number> {
|
|
50
|
+
const floors = new Map<string, number>();
|
|
51
|
+
let priceDigits: number | undefined;
|
|
52
|
+
for (const [key, field] of fields) {
|
|
53
|
+
if (field === "changePercent") {
|
|
54
|
+
floors.set(key, 2);
|
|
55
|
+
} else if (field === "price") {
|
|
56
|
+
priceDigits ??= rowsMinorDigits(rows);
|
|
57
|
+
floors.set(key, priceDigits);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return floors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cents unless every row trades in a currency without them. */
|
|
64
|
+
function rowsMinorDigits(rows: readonly ViewRow[]): number {
|
|
65
|
+
const currencies = new Set(rows.map((row) => (typeof row.currency === "string" ? row.currency.trim() : "")));
|
|
66
|
+
if (currencies.size === 0) return 2;
|
|
67
|
+
return Math.max(...[...currencies].map((currency) => Math.min(2, currencyMinorDigits(currency || undefined))));
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
export function viewRowSymbol(row: ViewRow, symbolKey: string | null): string | null {
|
|
45
71
|
if (!symbolKey) return null;
|
|
46
72
|
const value = row[symbolKey];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { apiClient, TeamRevisionConflictError } from "../../../api-client";
|
|
3
3
|
import { DataTableView } from "../../../components/data-table/view";
|
|
4
4
|
import { ChoiceDialog } from "../../../components/ui/choice-dialog";
|
|
@@ -29,6 +29,7 @@ import type { QuoteSubscriptionTarget } from "../../../types/data-provider";
|
|
|
29
29
|
import { normalizeSymbol } from "../../../utils/exchanges";
|
|
30
30
|
import {
|
|
31
31
|
liveViewColumns,
|
|
32
|
+
liveViewDecimalFloors,
|
|
32
33
|
overlayViewRow,
|
|
33
34
|
viewRowQuote,
|
|
34
35
|
viewRowSymbol,
|
|
@@ -39,8 +40,8 @@ import { loadViewSource, type LoadedView } from "./loader";
|
|
|
39
40
|
import {
|
|
40
41
|
applyViewProjection,
|
|
41
42
|
formatViewValue,
|
|
43
|
+
growViewColumnDecimals,
|
|
42
44
|
parseViewSpecOr,
|
|
43
|
-
viewColumnDecimals,
|
|
44
45
|
type ViewColumn,
|
|
45
46
|
type ViewRow,
|
|
46
47
|
type ViewSort,
|
|
@@ -56,6 +57,7 @@ const DEFAULT_COLUMN_WIDTH = 14;
|
|
|
56
57
|
const NO_ROWS: ViewRow[] = [];
|
|
57
58
|
const NO_SYMBOLS: readonly (string | null)[] = [];
|
|
58
59
|
const NO_TARGETS: QuoteSubscriptionTarget[] = [];
|
|
60
|
+
const NO_DECIMALS: ReadonlyMap<string, number> = new Map();
|
|
59
61
|
/** Live values move every tick; filters and sort follow them at most this often. */
|
|
60
62
|
const LIVE_VIEW_ORDER_SAMPLE_MS = 5_000;
|
|
61
63
|
/** Header and border rows above the table body. */
|
|
@@ -185,11 +187,20 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
185
187
|
}
|
|
186
188
|
return bases;
|
|
187
189
|
}, [columns, rows]);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
const decimalFloors = useMemo(() => liveViewDecimalFloors(sourceRows, liveFields), [liveFields, sourceRows]);
|
|
191
|
+
// A column's decimals only grow while this spec is shown, so a tick that
|
|
192
|
+
// crosses 100 or lands on a whole number never reflows the column.
|
|
193
|
+
const heldDecimals = useRef<{ spec: ViewSpec | null; decimals: ReadonlyMap<string, number> }>({ spec: null, decimals: NO_DECIMALS });
|
|
194
|
+
const decimalsByColumn = useMemo(() => {
|
|
195
|
+
const held = heldDecimals.current.spec === spec ? heldDecimals.current.decimals : NO_DECIMALS;
|
|
196
|
+
const decimals = growViewColumnDecimals(held, rows, columns.flatMap((column) => (
|
|
197
|
+
column.transform === "percent" || column.transform === "index100"
|
|
198
|
+
? []
|
|
199
|
+
: [{ key: column.key, minimum: decimalFloors.get(column.key) }]
|
|
200
|
+
)));
|
|
201
|
+
heldDecimals.current = { spec, decimals };
|
|
202
|
+
return decimals;
|
|
203
|
+
}, [columns, decimalFloors, rows, spec]);
|
|
193
204
|
const activeSort = sort === undefined ? spec?.projection.sort ?? null : sort;
|
|
194
205
|
// Memoized so the table's row memo holds while the selection moves.
|
|
195
206
|
const rowKey = useCallback((row: ViewRow, index: number) => (
|
|
@@ -2,12 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
applyViewProjection,
|
|
4
4
|
formatViewValue,
|
|
5
|
+
growViewColumnDecimals,
|
|
5
6
|
normalizeViewSpec,
|
|
6
7
|
parseViewSpec,
|
|
7
8
|
parseViewSpecOr,
|
|
8
9
|
serializeViewSpec,
|
|
9
10
|
validateViewSpec,
|
|
10
|
-
|
|
11
|
+
type ViewRow,
|
|
11
12
|
} from "./view-spec";
|
|
12
13
|
|
|
13
14
|
const good = {
|
|
@@ -98,19 +99,36 @@ describe("applyViewProjection", () => {
|
|
|
98
99
|
describe("formatViewValue", () => {
|
|
99
100
|
test("applies transforms", () => {
|
|
100
101
|
expect(formatViewValue(0.1234, "percent")).toBe("12.34%");
|
|
101
|
-
expect(formatViewValue(1_234_567, "compact")).toBe("1.
|
|
102
|
+
expect(formatViewValue(1_234_567, "compact")).toBe("1.23M");
|
|
103
|
+
expect(formatViewValue(12_300_000, "compact")).toBe("12.30M");
|
|
102
104
|
expect(formatViewValue(-3, "abs")).toBe("3");
|
|
103
105
|
expect(formatViewValue(110, "index100", 100)).toBe("110.0");
|
|
104
106
|
expect(formatViewValue(3.14159, undefined)).toBe("3.14");
|
|
107
|
+
expect(formatViewValue(-0.001, undefined, undefined, 2)).toBe("0.00");
|
|
105
108
|
expect(formatViewValue("text", "percent")).toBe("text");
|
|
106
109
|
expect(formatViewValue(null, "compact")).toBe("");
|
|
107
110
|
});
|
|
108
111
|
|
|
109
|
-
test("reads midnight ISO stamps as dates
|
|
112
|
+
test("reads midnight ISO stamps as dates", () => {
|
|
110
113
|
expect(formatViewValue("2026-09-01T00:00:00.000Z", undefined)).toBe("2026-09-01");
|
|
111
114
|
expect(formatViewValue("2026-09-01T14:30:00Z", undefined)).toBe("2026-09-01 14:30 UTC");
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a numeric column keeps one decimal count that only grows while the view is shown", () => {
|
|
118
|
+
const columns = [{ key: "close" }];
|
|
119
|
+
const format = (rows: ViewRow[], decimals: ReadonlyMap<string, number>) => (
|
|
120
|
+
rows.map((row) => formatViewValue(row.close, undefined, undefined, decimals.get("close")))
|
|
121
|
+
);
|
|
122
|
+
const loaded = [{ close: 236 }, { close: 232.1 }, { close: null }];
|
|
123
|
+
const held = growViewColumnDecimals(new Map(), loaded, columns);
|
|
124
|
+
expect(format(loaded, held)).toEqual(["236.0", "232.1", ""]);
|
|
125
|
+
// A tick lands on whole numbers: the column keeps its decimal rather than reflowing.
|
|
126
|
+
const whole = [{ close: 236 }, { close: 232 }];
|
|
127
|
+
expect(growViewColumnDecimals(held, whole, columns)).toBe(held);
|
|
128
|
+
// One row under 100 grows the whole column, and it stays grown.
|
|
129
|
+
const grown = growViewColumnDecimals(held, [{ close: 99.5 }], columns);
|
|
130
|
+
expect(format(whole, growViewColumnDecimals(grown, whole, columns))).toEqual(["236.00", "232.00"]);
|
|
131
|
+
// A live price keeps its floor on a whole-number tick.
|
|
132
|
+
expect(growViewColumnDecimals(new Map(), whole, [{ key: "close", minimum: 2 }]).get("close")).toBe(2);
|
|
115
133
|
});
|
|
116
134
|
});
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* version here never waits on a deploy. Modeled on `chart-composer/chart-spec`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { formatCompact, formatCompactAmount } from "../../../utils/format";
|
|
9
|
+
|
|
8
10
|
export const VIEW_SPEC_VERSION = 1;
|
|
9
11
|
export const MAX_VIEW_COLUMNS = 24;
|
|
10
12
|
export const MAX_VIEW_FILTERS = 12;
|
|
@@ -337,48 +339,78 @@ function naturalDecimals(value: number): number {
|
|
|
337
339
|
return Number.isInteger(value) ? 0 : Math.abs(value) >= 100 ? 1 : 2;
|
|
338
340
|
}
|
|
339
341
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
export function viewColumnDecimals(rows: readonly ViewRow[], key: string): number | undefined {
|
|
342
|
+
const MAX_NATURAL_DECIMALS = 2;
|
|
343
|
+
|
|
344
|
+
/** The most decimals any of a column's values would show on its own. */
|
|
345
|
+
function viewColumnDecimals(rows: readonly ViewRow[], key: string): number | undefined {
|
|
345
346
|
let decimals: number | undefined;
|
|
346
347
|
for (const row of rows) {
|
|
347
348
|
const value = row[key];
|
|
348
349
|
if (typeof value !== "number" || !Number.isFinite(value)) continue;
|
|
349
350
|
decimals = Math.max(decimals ?? 0, naturalDecimals(value));
|
|
350
|
-
if (decimals ===
|
|
351
|
+
if (decimals === MAX_NATURAL_DECIMALS) break;
|
|
351
352
|
}
|
|
352
353
|
return decimals;
|
|
353
354
|
}
|
|
354
355
|
|
|
356
|
+
export interface ViewDecimalColumn {
|
|
357
|
+
key: string;
|
|
358
|
+
/** Decimals the column never shows fewer of, such as cents on a price. */
|
|
359
|
+
minimum?: number;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* One decimal count per numeric column, so a column never mixes "236" with
|
|
364
|
+
* "232.1", grown with the rows now shown and never fewer than `held`. Live
|
|
365
|
+
* rows tick several times a second, and a count read from each tick flips the
|
|
366
|
+
* whole column when one value crosses 100 or lands on a whole number. Returns
|
|
367
|
+
* `held` itself when no count grew.
|
|
368
|
+
*/
|
|
369
|
+
export function growViewColumnDecimals(
|
|
370
|
+
held: ReadonlyMap<string, number>,
|
|
371
|
+
rows: readonly ViewRow[],
|
|
372
|
+
columns: readonly ViewDecimalColumn[],
|
|
373
|
+
): ReadonlyMap<string, number> {
|
|
374
|
+
let grown: Map<string, number> | null = null;
|
|
375
|
+
for (const { key, minimum = 0 } of columns) {
|
|
376
|
+
const current = held.get(key);
|
|
377
|
+
if (current !== undefined && current >= Math.max(minimum, MAX_NATURAL_DECIMALS)) continue;
|
|
378
|
+
const seen = viewColumnDecimals(rows, key);
|
|
379
|
+
if (seen === undefined && current === undefined) continue;
|
|
380
|
+
const decimals = Math.max(current ?? 0, seen ?? 0, minimum);
|
|
381
|
+
if (decimals === current) continue;
|
|
382
|
+
grown ??= new Map(held);
|
|
383
|
+
grown.set(key, decimals);
|
|
384
|
+
}
|
|
385
|
+
return grown ?? held;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A value that rounds to zero is unsigned: 0.00, never -0.00. */
|
|
389
|
+
function unsignedZero(text: string): string {
|
|
390
|
+
return text.startsWith("-") && !/[1-9]/.test(text) ? text.slice(1) : text;
|
|
391
|
+
}
|
|
392
|
+
|
|
355
393
|
/**
|
|
356
394
|
* A transform's display text, given the raw value, (for index100) the column's
|
|
357
|
-
* first value, and (for plain numbers) the column's decimal count.
|
|
395
|
+
* first value, and (for plain, abs and compact numbers) the column's decimal count.
|
|
358
396
|
*/
|
|
359
397
|
export function formatViewValue(value: unknown, transform: ViewTransform | undefined, base?: number, decimals?: number): string {
|
|
360
398
|
if (value === null || value === undefined) return "";
|
|
361
399
|
if (typeof value !== "number") return typeof value === "string" ? formatViewString(value) : JSON.stringify(value);
|
|
362
400
|
if (!Number.isFinite(value)) return "";
|
|
401
|
+
const columnDecimals = decimals ?? naturalDecimals(value);
|
|
363
402
|
switch (transform) {
|
|
364
403
|
case "percent":
|
|
365
|
-
return `${(value * 100).toFixed(2)}
|
|
404
|
+
return unsignedZero(`${(value * 100).toFixed(2)}%`);
|
|
366
405
|
case "compact":
|
|
367
|
-
|
|
406
|
+
// A whole-number column (counts, volume) stays whole below 1,000; any
|
|
407
|
+
// other keeps two decimals there. Scaled values keep their zeros either way.
|
|
408
|
+
return columnDecimals === 0 ? formatCompact(value, { fixedDecimals: true }) : formatCompactAmount(value);
|
|
368
409
|
case "abs":
|
|
369
|
-
return
|
|
410
|
+
return Math.abs(value).toFixed(columnDecimals);
|
|
370
411
|
case "index100":
|
|
371
|
-
return base && Number.isFinite(base) && base !== 0 ? (value / base * 100).toFixed(1) : "";
|
|
412
|
+
return base && Number.isFinite(base) && base !== 0 ? unsignedZero((value / base * 100).toFixed(1)) : "";
|
|
372
413
|
default:
|
|
373
|
-
return value.toFixed(
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function compactNumber(value: number): string {
|
|
378
|
-
const abs = Math.abs(value);
|
|
379
|
-
const units: Array<[number, string]> = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]];
|
|
380
|
-
for (const [threshold, suffix] of units) {
|
|
381
|
-
if (abs >= threshold) return `${(value / threshold).toFixed(abs / threshold >= 100 ? 0 : 1)}${suffix}`;
|
|
414
|
+
return unsignedZero(value.toFixed(columnDecimals));
|
|
382
415
|
}
|
|
383
|
-
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
384
416
|
}
|
|
@@ -21,6 +21,15 @@ export const BUCKET_IDS = [
|
|
|
21
21
|
"InYearFive",
|
|
22
22
|
"AfterYearFive",
|
|
23
23
|
] as const;
|
|
24
|
+
/** Rolling-period concepts some filers (JPM from FY2023) use for the same buckets. */
|
|
25
|
+
const ROLLING_BUCKET_TAGS = [
|
|
26
|
+
"InNextRollingTwelveMonths",
|
|
27
|
+
"InRollingYearTwo",
|
|
28
|
+
"InRollingYearThree",
|
|
29
|
+
"InRollingYearFour",
|
|
30
|
+
"InRollingYearFive",
|
|
31
|
+
"InRollingAfterYearFive",
|
|
32
|
+
] as const;
|
|
24
33
|
const number = (value: unknown): value is number =>
|
|
25
34
|
typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
26
35
|
const nullableNumber = (value: unknown) => value === null || number(value);
|
|
@@ -177,8 +186,10 @@ export function validateDebtMaturities(
|
|
|
177
186
|
: !sameFiling(bucket.fact) ||
|
|
178
187
|
bucket.fact.end !== latest.asOf ||
|
|
179
188
|
bucket.fact.start !== null ||
|
|
180
|
-
bucket.fact.tag !==
|
|
181
|
-
`LongTermDebtMaturitiesRepaymentsOfPrincipal${bucket.id}`
|
|
189
|
+
(bucket.fact.tag !==
|
|
190
|
+
`LongTermDebtMaturitiesRepaymentsOfPrincipal${bucket.id}` &&
|
|
191
|
+
bucket.fact.tag !==
|
|
192
|
+
`LongTermDebtMaturitiesRepaymentsOfPrincipal${ROLLING_BUCKET_TAGS[index]}`) ||
|
|
182
193
|
bucket.fact.value !== bucket.value)
|
|
183
194
|
)
|
|
184
195
|
return invalid();
|
|
@@ -336,13 +347,15 @@ export interface DebtResource {
|
|
|
336
347
|
stale: boolean;
|
|
337
348
|
refreshError: string | null;
|
|
338
349
|
}
|
|
350
|
+
// Seeds the first paint while the mount load runs; cache age is not staleness,
|
|
351
|
+
// and a failed refresh reports its own.
|
|
339
352
|
export function cachedDebtMaturities(symbol: string): DebtResource | null {
|
|
340
353
|
const cached = debtMaturitiesCache.get(symbol, { allowExpired: true });
|
|
341
354
|
if (!cached) return null;
|
|
342
355
|
try {
|
|
343
356
|
return {
|
|
344
357
|
payload: validateDebtMaturities(cached.data, symbol),
|
|
345
|
-
stale:
|
|
358
|
+
stale: false,
|
|
346
359
|
refreshError: null,
|
|
347
360
|
};
|
|
348
361
|
} catch {
|
|
@@ -214,6 +214,14 @@ describe("debt maturity Cloud boundary", () => {
|
|
|
214
214
|
}
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
test("accepts a bucket filed under its rolling-period concept, not another bucket's", () => {
|
|
218
|
+
const data = fixture();
|
|
219
|
+
data.latest!.buckets[1]!.fact!.tag = "LongTermDebtMaturitiesRepaymentsOfPrincipalInRollingYearTwo";
|
|
220
|
+
expect(validateDebtMaturities(data, "TEST").latest?.buckets[1]?.value).toBe(10);
|
|
221
|
+
data.latest!.buckets[1]!.fact!.tag = "LongTermDebtMaturitiesRepaymentsOfPrincipalInRollingYearThree";
|
|
222
|
+
expect(() => validateDebtMaturities(data, "TEST")).toThrow();
|
|
223
|
+
});
|
|
224
|
+
|
|
217
225
|
test("missing bucket stays a chart gap and prevents a total and concentration denominator", () => {
|
|
218
226
|
const data = fixture(),
|
|
219
227
|
latest = data.latest!;
|