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
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
REALIZED_VOLATILITY_WINDOWS, realizedVolatilityCadenceIssue, realizedVolatilityResult, rollingRealizedVolatility, volatilityCone,
|
|
4
4
|
type RealizedVolatilityEstimator, type RollingRealizedVolatilityPoint, type VolatilityConeStatistics,
|
|
5
5
|
} from "../shared/volatility";
|
|
6
|
-
import type { SurfaceSnapshot } from "../vol-surface/model";
|
|
6
|
+
import type { SurfaceExpiry, SurfaceSnapshot } from "../vol-surface/model";
|
|
7
7
|
|
|
8
8
|
export interface RealizedVolatilityModel {
|
|
9
9
|
symbol: string;
|
|
@@ -99,32 +99,47 @@ export interface CurrentAtmIvSnapshot {
|
|
|
99
99
|
reference: CurrentAtmIvReference | null;
|
|
100
100
|
error: string | null;
|
|
101
101
|
warnings: string[];
|
|
102
|
+
/** The underlying has no option chain in the sources, so there is no IV to show and nothing failed. */
|
|
103
|
+
noOptionChain?: boolean;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** A LEAPS ATM IV is not comparable with a 10 to 260 session realized cone. */
|
|
105
107
|
export const MAX_CURRENT_ATM_IV_DAYS = 90;
|
|
106
108
|
|
|
107
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Use a listed expiry nearest 30 days, preserving its own observation date and tenor. Warnings and
|
|
111
|
+
* failures are the chosen slice's and the surface's own, not those of expiries the reference does not use.
|
|
112
|
+
*/
|
|
108
113
|
export function projectCurrentAtmIv(surface: SurfaceSnapshot): CurrentAtmIvSnapshot {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
if (surface.catalogue.length === 0 && surface.failures.some((failure) => failure.expiration == null && failure.reasonCode === "NO_DATA")) {
|
|
115
|
+
return { reference: null, error: null, warnings: [], noOptionChain: true };
|
|
116
|
+
}
|
|
117
|
+
const eligible = surface.expiries.filter((expiry) => expiry.years > 0 && expiry.years * 365 <= MAX_CURRENT_ATM_IV_DAYS);
|
|
118
|
+
const usable = (expiry: SurfaceExpiry) => expiry.atmIV != null && Number.isFinite(expiry.atmIV) && expiry.atmIV > 0;
|
|
119
|
+
const distance = (expiry: SurfaceExpiry) => Math.abs(expiry.years * 365 - 30);
|
|
120
|
+
const warnings: string[] = [];
|
|
121
|
+
const candidates = eligible.filter(usable);
|
|
112
122
|
const fresh = candidates.filter((expiry) => !expiry.stale && !expiry.error);
|
|
113
123
|
if (fresh.length < candidates.length) warnings.push("Stale or failed ATM IV slices excluded from the current reference");
|
|
114
124
|
const dated = fresh.filter((expiry) => expiry.asOf != null && Number.isFinite(Date.parse(expiry.asOf)));
|
|
115
125
|
if (dated.length < fresh.length) warnings.push("Some ATM IV observations have no valid source date");
|
|
116
|
-
dated.sort((a, b) =>
|
|
126
|
+
dated.sort((a, b) => distance(a) - distance(b) || a.expiration - b.expiration);
|
|
117
127
|
const expiry = dated[0];
|
|
118
|
-
const errors = [...new Set(surface.failures.map((failure) => failure.message))];
|
|
119
128
|
if (!expiry) {
|
|
120
|
-
|
|
129
|
+
const errors = [...new Set(surface.failures.map((failure) => failure.message))];
|
|
130
|
+
return { reference: null, error: errors.join("; ") || "Current ATM IV unavailable from cleaned option quotes",
|
|
131
|
+
warnings: [...new Set([...eligible.flatMap((entry) => entry.warnings), ...warnings])] };
|
|
121
132
|
}
|
|
122
133
|
const daysToExpiry = expiry.years * 365;
|
|
134
|
+
if (eligible.some((entry) => !usable(entry) && distance(entry) < distance(expiry))) {
|
|
135
|
+
warnings.push(`Expiries nearer 30 days have no usable ATM quotes; reference uses the ${Math.round(daysToExpiry)}d expiry`);
|
|
136
|
+
}
|
|
123
137
|
if (surface.spotAsOf == null) warnings.push("Underlying quote observation date unavailable");
|
|
138
|
+
const errors = [...new Set(surface.failures.filter((failure) => failure.expiration == null).map((failure) => failure.message))];
|
|
124
139
|
return { reference: {
|
|
125
140
|
value: expiry.atmIV!, date: new Date(expiry.asOf!),
|
|
126
141
|
label: `ATM IV ${Math.round(daysToExpiry)}d`,
|
|
127
142
|
daysToExpiry, expiration: expiry.expiration, source: expiry.source, stale: expiry.stale,
|
|
128
143
|
ivSource: surface.settings.ivSource, spot: surface.spot, spotAsOf: surface.spotAsOf ?? null,
|
|
129
|
-
}, error: errors.join("; ") || null, warnings: [...new Set(warnings)] };
|
|
144
|
+
}, error: errors.join("; ") || null, warnings: [...new Set([...expiry.warnings, ...warnings])] };
|
|
130
145
|
}
|
|
@@ -186,7 +186,8 @@ export function cachedRotation(
|
|
|
186
186
|
return value
|
|
187
187
|
? {
|
|
188
188
|
payload: value.data,
|
|
189
|
-
stale
|
|
189
|
+
// The pane revalidates this copy on mount; only a failed refresh makes it stale.
|
|
190
|
+
stale: false,
|
|
190
191
|
refreshError: null as string | null,
|
|
191
192
|
}
|
|
192
193
|
: null;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { formatPriceEarnings } from "../../../utils/price-earnings";
|
|
2
|
-
import { describeFundamentalMarketCap } from "../../../utils/market-capitalization";
|
|
3
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
3
|
import { TextAttributes } from "../../../ui";
|
|
5
4
|
import {
|
|
@@ -210,17 +209,11 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
210
209
|
const rowErrors = rows.filter((row) => row.error).map((row) => `${row.symbol}: ${row.error}`);
|
|
211
210
|
const status = [error, ...rowErrors, staleSymbols.length ? `Stale quotes: ${staleSymbols.join(", ")}` : null,
|
|
212
211
|
missingFx ? "Market-cap FX unavailable" : null].filter(Boolean).join(" · ") || null;
|
|
213
|
-
const selectedRow = sortedRows[selectedIdx];
|
|
214
|
-
const selectedCapNotice = selectedRow?.marketCapProvenance?.kind === "fundamentals"
|
|
215
|
-
? `${selectedRow.symbol} cap: ${describeFundamentalMarketCap(selectedRow.marketCapProvenance)}.` : undefined;
|
|
216
212
|
|
|
217
213
|
usePaneNoticeFooter({
|
|
218
214
|
registrationId: "relative-valuation-notices",
|
|
219
|
-
notices:
|
|
220
|
-
|
|
221
|
-
.map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
|
|
222
|
-
...(selectedCapNotice ? [selectedCapNotice] : []),
|
|
223
|
-
],
|
|
215
|
+
notices: rows.filter((row) => row.fundamentalsProvenance?.stale)
|
|
216
|
+
.map((row) => `${row.symbol}: ${RELATIVE_VALUATION_STALE_FUNDAMENTALS_NOTICE}.`),
|
|
224
217
|
focused,
|
|
225
218
|
});
|
|
226
219
|
|
|
@@ -236,7 +229,7 @@ export function RelativeValuationPane({ focused, width, height }: PaneProps) {
|
|
|
236
229
|
case "changePercent":
|
|
237
230
|
return { text: row.changePercent != null ? formatPercentRaw(row.changePercent) : "-", color: selectedColor ?? priceColor(row.changePercent ?? 0) };
|
|
238
231
|
case "marketCap":
|
|
239
|
-
return { text: formatCompact(row.marketCap ?? undefined), color: selectedColor ?? colors.textDim };
|
|
232
|
+
return { text: formatCompact(row.marketCap ?? undefined, { fixedDecimals: true }), color: selectedColor ?? colors.textDim };
|
|
240
233
|
case "trailingPE":
|
|
241
234
|
return { text: formatPriceEarnings(row.reportedMultiples.trailingPE), color: selectedColor ?? colors.text };
|
|
242
235
|
case "forwardPE":
|
|
@@ -25,6 +25,13 @@ for(const status of [401,403,404])test(`report ${status} cannot fall back to cac
|
|
|
25
25
|
await expect(loadRiskReportWithClient(api,"CONTROL",2025)).rejects.toMatchObject({status});expect(store.getResource("report","CONTROL:2025",{...options,allowExpired:true})).toBeNull();
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test("a list 404 is an empty cached list",async()=>{
|
|
29
|
+
const store=new MemoryPluginPersistence();attachRiskFactorsPersistence(store);const api=client();let calls=0;
|
|
30
|
+
api.getRiskReports=async()=>{calls++;throw new ApiRequestError("No risk factor reports for this ticker",404);};
|
|
31
|
+
await expect(loadRiskReportsWithClient(api,"CONTROL")).resolves.toMatchObject({reports:[],stale:false});
|
|
32
|
+
await loadRiskReportsWithClient(api,"CONTROL");expect(calls).toBe(1);expect(store.getResource("reports","CONTROL",options)?.value).toMatchObject({reports:[]});
|
|
33
|
+
});
|
|
34
|
+
|
|
28
35
|
test("transient historical refresh retains immutable source dates and expired content",async()=>{
|
|
29
36
|
const store=new MemoryPluginPersistence();attachRiskFactorsPersistence(store);store.seedResource("report","CONTROL:2025",report(2025),{...options,stale:true,expired:true});
|
|
30
37
|
const before=store.getResource("report","CONTROL:2025",{...options,allowExpired:true})!;const api=client();api.getRiskReport=async()=>{throw new ApiRequestError("Temporary outage",503);};
|
|
@@ -96,7 +96,14 @@ function loadCached<T extends object>(
|
|
|
96
96
|
export function loadRiskReportsWithClient(client: RiskApiClient, ticker: string, options?: { force?: boolean }): Promise<RiskReportsResult> {
|
|
97
97
|
const key = ticker.trim().toUpperCase();
|
|
98
98
|
return loadCached(client, LIST_KIND, key, activeListFetches, LIST_CACHE_POLICY, options?.force ?? false, async () => {
|
|
99
|
-
|
|
99
|
+
let payload: CloudRiskReportListPayload;
|
|
100
|
+
try {
|
|
101
|
+
payload = await client.getRiskReports(key);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// The list answers 404 when no 10-K risk report is on file (20-F filers, funds).
|
|
104
|
+
if (error instanceof ApiRequestError && error.status === 404) return { company: null, reports: [] };
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
100
107
|
if (!payload || !Array.isArray(payload.reports)) throw new Error(`Risk report list unavailable for ${key}.`);
|
|
101
108
|
return payload;
|
|
102
109
|
});
|
|
@@ -44,6 +44,9 @@ test("historical year loading and failure cannot display the previous report or
|
|
|
44
44
|
for(const status of [503,404])test(`initial report ${status} stops loading and exposes the cause`,async()=>{
|
|
45
45
|
transport(path=>path.endsWith("/CONTROL")?Response.json(list([2026])):new Response("Detail unavailable",{status}));await mount();const failed=capture(`initial-detail-${status}`);expect(failed).toContain("Detail unavailable");expect(failed).not.toContain("Loading...");expect(failed).not.toContain("[o]pen filing");cases.push({case:`initial-detail-${status}`,requests:[...requests]});
|
|
46
46
|
});
|
|
47
|
+
test("a ticker without a 10-K report shows the empty state, not a load error",async()=>{
|
|
48
|
+
transport(()=>Response.json({message:"No risk factor reports for this ticker"},{status:404}));await mount();const empty=capture("list-404");expect(empty).toContain("No 10-K risk factors on file for CONTROL.");expect(empty).not.toContain("Report list:");expect(empty).not.toContain("Could not load risk reports");expect(requests).toEqual(["/public/risks/CONTROL"]);
|
|
49
|
+
});
|
|
47
50
|
test("changing ticker while a historical report is pending cannot adopt the old security or year",async()=>{
|
|
48
51
|
const pending=deferred<Response>();transport(path=>path.endsWith("/CONTROL")?Response.json(list([2026,2025])):path.endsWith("/CONTROL/2025")?pending.promise:path.endsWith("/CONTROL/2026")?Response.json(report(2026)):path.endsWith("/OTHER")?Response.json({...list([2024]),company:{...list([2024]).company,ticker:"OTHER"}}):Response.json({...report(2024),ticker:"OTHER",docUrl:"https://www.sec.gov/Archives/other-2024.htm",overview:"Only OTHER 2024 risk analysis"}));
|
|
49
52
|
await mount();await click("2025 10-K");await act(async()=>selectTicker("OTHER"));await settle();const next=capture("ticker-changed");expect(next).toContain("Only OTHER 2024 risk analysis");expect(requests).not.toContain("/public/risks/OTHER/2025");pending.resolve(Response.json(report(2025)));await settle();const completed=capture("ticker-old-completion");expect(completed).toContain("Only OTHER 2024 risk analysis");expect(completed).not.toContain("Only 2025 risk analysis");await click("[o]pen filing");expect(opened.at(-1)).toBe("https://www.sec.gov/Archives/other-2024.htm");cases.push({case:"ticker-ownership",requests:[...requests],opened:[...opened]});
|
|
@@ -97,14 +97,14 @@ describe("screener live quotes", () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
test("replacement snapshots clear unavailable fields and select only the qualified listing", () => {
|
|
100
|
+
test("replacement snapshots clear unavailable fields, keep the listing currency and select only the qualified listing", () => {
|
|
101
101
|
const rows = [{ symbol: "ACME", exchange: "NASDAQ", name: "US", currency: "USD", price: 10, change: 1, changePercent: 10, volume: 200, lastUpdated: 100 },
|
|
102
102
|
{ symbol: "ACME", exchange: "LSE", name: "UK", currency: "GBP", price: 8, change: 0, changePercent: 0, volume: 0, lastUpdated: 100 }];
|
|
103
103
|
const targets = buildScreenerQuoteTargets(rows, "ACME:XLON");
|
|
104
104
|
expect(targets.map(target => target.selected)).toEqual([false, true]);
|
|
105
|
-
const entries = new Map([[buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "
|
|
105
|
+
const entries = new Map([[buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "", lastUpdated: 200 })]]);
|
|
106
106
|
expect(overlayScreenerQuoteEntries(rows, entries)).toEqual([
|
|
107
|
-
expect.objectContaining({ price: 12, change: null, changePercent: null, volume: null, lastUpdated: 200 }), rows[1]!,
|
|
107
|
+
expect.objectContaining({ price: 12, change: null, changePercent: null, volume: null, currency: "USD", lastUpdated: 200 }), rows[1]!,
|
|
108
108
|
]);
|
|
109
109
|
entries.set(buildQuoteKey(rows[0]!), readyEntry({ symbol: "ACME", price: 12, currency: "USD", lastUpdated: 50, change: 0, changePercent: 0, volume: 0 }));
|
|
110
110
|
expect(overlayScreenerQuoteEntries(rows, entries)).toEqual(rows);
|
|
@@ -3,6 +3,7 @@ import { publicTickerKey } from "../../../utils/exchanges";
|
|
|
3
3
|
import type { Quote } from "../../../types/financials";
|
|
4
4
|
import { buildQuoteKey, resolveEntryData } from "../../../market-data/selectors";
|
|
5
5
|
import type { QueryEntry } from "../../../market-data/result-types";
|
|
6
|
+
import { resolveCurrencyUnit } from "../../../utils/currency-units";
|
|
6
7
|
|
|
7
8
|
const STREAM_FRESHNESS_MS = 2 * 60_000;
|
|
8
9
|
const STREAM_CONNECTING_GRACE_MS = 15_000;
|
|
@@ -17,6 +18,7 @@ export interface ScreenerQuoteRow {
|
|
|
17
18
|
currency: string;
|
|
18
19
|
exchange: string;
|
|
19
20
|
lastUpdated?: number;
|
|
21
|
+
previousClose?: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface ScreenerQuoteFreshness {
|
|
@@ -48,6 +50,18 @@ function finite(value: number | undefined): value is number {
|
|
|
48
50
|
return typeof value === "number" && Number.isFinite(value);
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** Venues quote these in either the major or the minor unit (GBP or GBp). */
|
|
54
|
+
const TWO_UNIT_CURRENCIES = new Set(["GBP", "ILS", "ZAR"]);
|
|
55
|
+
|
|
56
|
+
/** A quote without a currency keeps the listing's, so a streamed price never
|
|
57
|
+
* loses its symbol, unless the listing's currency has two units and the
|
|
58
|
+
* quote's could be either. */
|
|
59
|
+
function overlayCurrency(row: ScreenerQuoteRow, quote: Quote): string {
|
|
60
|
+
const quoted = quote.currency?.trim();
|
|
61
|
+
if (quoted) return quoted;
|
|
62
|
+
return TWO_UNIT_CURRENCIES.has(resolveCurrencyUnit(row.currency).currency) ? "" : row.currency;
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
/**
|
|
52
66
|
* The last overlay of each source row. Every stream flush hands over a new
|
|
53
67
|
* entries map, but most rows' quotes did not move; returning the same row
|
|
@@ -87,7 +101,8 @@ function overlayQuote<T extends ScreenerQuoteRow>(row: T, quote: Quote): T {
|
|
|
87
101
|
? quote.changePercent
|
|
88
102
|
: null,
|
|
89
103
|
volume: finite(quote.volume) && quote.volume >= 0 ? quote.volume : null,
|
|
90
|
-
currency: quote
|
|
104
|
+
currency: overlayCurrency(row, quote),
|
|
105
|
+
previousClose: finite(quote.previousClose) ? quote.previousClose : undefined,
|
|
91
106
|
lastUpdated: quote.lastUpdated,
|
|
92
107
|
};
|
|
93
108
|
}
|
|
@@ -84,7 +84,8 @@ const cacheKey = (symbol: string, scope: ShortVolumeScope) => `${scope}:${symbol
|
|
|
84
84
|
export function cachedShortVolume(symbol: string, scope: ShortVolumeScope): ShortVolumeResource | null {
|
|
85
85
|
const cached = shortVolumeCache.get(cacheKey(symbol, scope), { allowExpired: true });
|
|
86
86
|
if (!cached) return null;
|
|
87
|
-
|
|
87
|
+
// The pane revalidates this copy on mount; only a failed refresh makes it stale.
|
|
88
|
+
try { return { payload: validateShortVolume(cached.data, symbol, scope), stale: false, refreshError: null }; }
|
|
88
89
|
catch { return null; }
|
|
89
90
|
}
|
|
90
91
|
export async function loadShortVolume(symbol: string, scope: ShortVolumeScope, force = false): Promise<ShortVolumeResource> {
|
|
@@ -74,7 +74,6 @@ export function ShortVolumePane({ width, height, focused }: Pick<PaneProps, "wid
|
|
|
74
74
|
info: data ? [
|
|
75
75
|
...(updatedAgo ? [{ id: "updated", parts: [{ text: updatedAgo, tone: "muted" as const }] }] : []),
|
|
76
76
|
...(resource.data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
|
|
77
|
-
...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
|
|
78
77
|
] : [],
|
|
79
78
|
});
|
|
80
79
|
if (!data && isCloudSessionRequired(resource.error)) return <SignInWall action="view daily short volume" needsVerification={session.needsVerification} />;
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
HeadlessPaneLoadArgs,
|
|
6
6
|
} from "../../../types/plugin";
|
|
7
7
|
import { normalizeCik } from "./api";
|
|
8
|
-
import { buildFundOverlap } from "./overlap";
|
|
8
|
+
import { buildFundOverlap, overlapPeriod } from "./overlap";
|
|
9
9
|
import { appendTickerHoldings, loadCrowding, loadTickerHoldings, type TickerHoldings } from "./signals";
|
|
10
10
|
import {
|
|
11
11
|
loadBrowserRows,
|
|
@@ -261,9 +261,9 @@ export function createThirteenFHeadless(
|
|
|
261
261
|
const fund = await resolveFund(query, args, ctx, dependencies);
|
|
262
262
|
const other = await resolveFund(String(args.options.compare ?? ""), args, ctx, dependencies);
|
|
263
263
|
const [first, second] = await Promise.all([dependencies.loadDetail(fund.cik, fund.name, args, ctx), dependencies.loadDetail(other.cik, other.name, args, ctx)]);
|
|
264
|
-
const
|
|
264
|
+
const comparedPeriod = overlapPeriod(first, second);
|
|
265
265
|
const rows = buildFundOverlap(first, second);
|
|
266
|
-
return { columns: [{ key: "ticker", header: "Ticker" }, { key: "type", header: "Type" }, { key: "issuer", header: "Issuer" }, { key: "weight", header: "First weight", align: "right", format: weight }, { key: "comparedWeight", header: "Second weight", align: "right", format: weight }], rows: rows.slice(0, limit).map(row => ({ ...row })), errors: [...(first.warnings ?? []), ...(second.warnings ?? []), ...(!
|
|
266
|
+
return { columns: [{ key: "ticker", header: "Ticker" }, { key: "type", header: "Type" }, { key: "issuer", header: "Issuer" }, { key: "weight", header: "First weight", align: "right", format: weight }, { key: "comparedWeight", header: "Second weight", align: "right", format: weight }], rows: rows.slice(0, limit).map(row => ({ ...row })), errors: [...(first.warnings ?? []), ...(second.warnings ?? []), ...(!comparedPeriod ? ["No reporting quarter is loaded for both funds."] : [])], metadata: { firstFund: first.name, secondFund: second.name, firstPeriod: first.latestForm?.periodOfReport ?? null, secondPeriod: second.latestForm?.periodOfReport ?? null, comparedPeriod, truncated: rows.length > limit, notices: comparedPeriod ? [`Quarter compared: ${comparedPeriod}`] : [] } };
|
|
267
267
|
}
|
|
268
268
|
if (view === "crowding") {
|
|
269
269
|
const { rows: sourceRows, ...metadata } = await loadCrowding(ctx.signal);
|
|
@@ -165,6 +165,15 @@ export function buildBrowserRows(options: {
|
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
export type ThirteenFAmendmentKind = "restatement" | "new-holdings" | "unknown";
|
|
169
|
+
|
|
170
|
+
/** How a 13F-HR/A combines with its period's report; null for an original filing. */
|
|
171
|
+
export function amendmentKind(form: Pick<ThirteenFFormSummary, "isAmendment" | "amendmentType">): ThirteenFAmendmentKind | null {
|
|
172
|
+
if (!form.isAmendment) return null;
|
|
173
|
+
const type = form.amendmentType?.trim().toUpperCase();
|
|
174
|
+
return type === "RESTATEMENT" ? "restatement" : type === "NEW HOLDINGS" ? "new-holdings" : "unknown";
|
|
175
|
+
}
|
|
176
|
+
|
|
168
177
|
function browserReportTotal(
|
|
169
178
|
form: ThirteenFFormSummary | undefined,
|
|
170
179
|
report: ThirteenFPeriodReport | undefined,
|
|
@@ -172,7 +181,8 @@ function browserReportTotal(
|
|
|
172
181
|
): number | null | undefined {
|
|
173
182
|
if (!form) return undefined;
|
|
174
183
|
if (report?.periodOfReport === form.periodOfReport) return report.complete ? report[key] : null;
|
|
175
|
-
|
|
184
|
+
const kind = amendmentKind(form);
|
|
185
|
+
return kind == null || kind === "restatement" ? form[key] : null;
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
export function dedupeLatestForms(forms: ThirteenFFormSummary[]): ThirteenFFormSummary[] {
|
|
@@ -194,11 +204,11 @@ export function buildPeriodReports(forms: ThirteenFFormSummary[]): ThirteenFPeri
|
|
|
194
204
|
const unique = [...new Map(forms.map((form) => [form.accessionNumber, form])).values()];
|
|
195
205
|
for (const form of unique.sort(compareFormRecency)) {
|
|
196
206
|
let report = byPeriod.get(form.periodOfReport);
|
|
197
|
-
const
|
|
198
|
-
if (
|
|
207
|
+
const kind = amendmentKind(form);
|
|
208
|
+
if (kind == null || kind === "restatement") {
|
|
199
209
|
report = { periodOfReport: form.periodOfReport, filings: [form], complete: true,
|
|
200
210
|
tableValueTotal: form.tableValueTotal, tableEntryTotal: form.tableEntryTotal };
|
|
201
|
-
} else if (
|
|
211
|
+
} else if (kind === "new-holdings") {
|
|
202
212
|
report = report ?? { periodOfReport: form.periodOfReport, filings: [], complete: false,
|
|
203
213
|
tableValueTotal: null, tableEntryTotal: null };
|
|
204
214
|
report.filings.push(form);
|
|
@@ -12,9 +12,15 @@ import { normalizeCik, searchThirteenFFunds } from "./api";
|
|
|
12
12
|
import { loadFundDetail } from "./data";
|
|
13
13
|
import { formatWeightMaybe } from "./format";
|
|
14
14
|
import { isCikQuery } from "./model";
|
|
15
|
-
import { buildFundOverlap, type FundOverlapRow } from "./overlap";
|
|
15
|
+
import { buildFundOverlap, overlapPeriod, type FundOverlapRow } from "./overlap";
|
|
16
16
|
import type { FundDetailData, ThirteenFFund } from "./types";
|
|
17
17
|
|
|
18
|
+
/** The quarter compared, and the fund's newer latest quarter when it has one. */
|
|
19
|
+
function periodDetail(data: FundDetailData | null, period: string | null): string | undefined {
|
|
20
|
+
const latest = data?.latestForm?.periodOfReport;
|
|
21
|
+
return period && latest && latest !== period ? `${period} · latest ${latest}` : latest;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export function FundOverlapView({ data, focused, width }: { data: FundDetailData; focused: boolean; width: number }) {
|
|
19
25
|
const [query, setQuery] = usePluginPaneState<string>("overlap:query", "");
|
|
20
26
|
const [target, setTarget] = usePluginPaneState<ThirteenFFund | null>("overlap:target", null);
|
|
@@ -74,16 +80,17 @@ export function FundOverlapView({ data, focused, width }: { data: FundDetailData
|
|
|
74
80
|
if (isPlainKey(event, "/") && !target) { event.preventDefault?.(); focusSearch(); }
|
|
75
81
|
if (isPlainKey(event, "m") && target) { event.preventDefault?.(); setMineOnly(value => !value); }
|
|
76
82
|
});
|
|
77
|
-
const
|
|
83
|
+
const period = peer ? overlapPeriod(data, peer) : null;
|
|
84
|
+
const mismatch = peer && !period;
|
|
78
85
|
usePaneStatusFooter({ registrationId: "13f-overlap", loading: loading || loadingMore, error, hints: target ? [{ id: "mine", key: "m", label: mineOnly ? "all tickers" : "mine", title: mineOnly ? "All Tickers" : "Mine Only", onPress: () => setMineOnly(value => !value) }] : [{ id: "search", key: "/", label: "search", onPress: focusSearch }] });
|
|
79
|
-
usePaneNoticeFooter({ registrationId: "13f-overlap-notice", focused, notices: [...(peer?.warnings ?? []), ...(mismatch ? ["
|
|
86
|
+
usePaneNoticeFooter({ registrationId: "13f-overlap-notice", focused, notices: [...(peer?.warnings ?? []), ...(mismatch ? ["No reporting quarter is loaded for both funds; overlap is unavailable."] : [])] });
|
|
80
87
|
const rows = useMemo(() => (peer ? buildFundOverlap(data, peer) : []).filter(row => !mineOnly || mine.has(row.ticker)).sort((a, b) => ((a[sort.id] ?? -1) - (b[sort.id] ?? -1)) * (sort.desc ? -1 : 1)), [peer, data, mineOnly, mine, sort]);
|
|
81
88
|
const positionColumns: DataTableColumn[] = [{ id: "mine", label: "MINE", width: 5, align: "left" }, { id: "ticker", label: "TICKER", width: 10, align: "left" }, { id: "type", label: "TYPE", width: 6, align: "left" }, { id: "issuer", label: "ISSUER", width: Math.max(18, width - 58), align: "left" }, { id: "weight", label: "FIRST %", width: 12, align: "right" }, { id: "comparedWeight", label: "SECOND %", width: 12, align: "right" }];
|
|
82
89
|
return <DataTableStackView<ThirteenFFund, DataTableColumn>
|
|
83
90
|
focused={focused && !searchFocused} detailOpen={!!target} onBack={() => setTarget(null)} detailTitle={target?.name}
|
|
84
91
|
detailContent={<Box flexDirection="column" flexGrow={1}>
|
|
85
|
-
<KeyValueRow label="First" value={data.name} detail={data
|
|
86
|
-
<KeyValueRow label="Second" value={peer?.name ?? target?.name ?? ""} detail={peer
|
|
92
|
+
<KeyValueRow label="First" value={data.name} detail={periodDetail(data, period)} />
|
|
93
|
+
<KeyValueRow label="Second" value={peer?.name ?? target?.name ?? ""} detail={periodDetail(peer, period)} />
|
|
87
94
|
<QueryBar width={width} filters={[{ id: "mine", kind: "toggle", label: "Mine", value: mineOnly, onChange: setMineOnly }]} />
|
|
88
95
|
<DataTableView<FundOverlapRow, DataTableColumn> focused={focused} columns={positionColumns} items={rows} getItemKey={row => row.id}
|
|
89
96
|
selection={{ kind: "id", selectedId: selectedPositionId, getId: row => row.id, onChange: setSelectedPositionId }}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { buildFundOverlap } from "./overlap";
|
|
2
|
+
import { buildFundOverlap, overlapPeriod } from "./overlap";
|
|
3
3
|
import type { FundDetailData, ThirteenFFormSummary, ThirteenFHoldingRecord } from "./types";
|
|
4
4
|
const form: ThirteenFFormSummary = { url: "", accessionNumber: "current", submissionType: "13F-HR", periodOfReport: "2026-06-30", filedAsOfDate: "2026-08-14", cik: "1", companyName: "Fund", tableValueTotal: 100, tableEntryTotal: 1, isAmendment: false };
|
|
5
5
|
const holding = (overrides: Partial<ThirteenFHoldingRecord> = {}): ThirteenFHoldingRecord => ({ accessionNumber: "current", cik: "1", cusip: "one", ticker: "AAA", issuer: "A", titleOfClass: "COM", putCall: "", shareType: "SH", value: 20, shares: 2, investmentDiscretion: "SOLE", votingAuthoritySole: 2, votingAuthorityShared: 0, votingAuthorityNone: 0, ...overrides });
|
|
@@ -15,3 +15,15 @@ test("overlap cannot mix quarter ends and incomplete reports do not acquire weig
|
|
|
15
15
|
const partial = detail([holding()], { latestReport: { periodOfReport: form.periodOfReport, filings: [form], complete: false, tableValueTotal: null, tableEntryTotal: null } });
|
|
16
16
|
expect(buildFundOverlap(first, partial)[0]?.comparedWeight).toBeNull();
|
|
17
17
|
});
|
|
18
|
+
test("a fund one quarter ahead compares at the other fund's latest quarter, weighted against that report", () => {
|
|
19
|
+
const previousForm = { ...form, accessionNumber: "prior", periodOfReport: "2026-03-31", tableValueTotal: 50 };
|
|
20
|
+
const report = (source: ThirteenFFormSummary, complete = true) => ({ periodOfReport: source.periodOfReport, filings: [source], complete, tableValueTotal: source.tableValueTotal, tableEntryTotal: 1 });
|
|
21
|
+
const ahead = detail([holding({ cusip: "new" })], { previousForm, previousHoldings: [holding({ value: 10 })], latestReport: report(form), previousReport: report(previousForm) });
|
|
22
|
+
const behind = detail([holding()], { latestForm: previousForm, latestReport: report(previousForm) });
|
|
23
|
+
expect(overlapPeriod(ahead, behind)).toBe("2026-03-31");
|
|
24
|
+
expect(buildFundOverlap(ahead, behind)).toEqual([{ id: "ONE||SH", ticker: "AAA", issuer: "A", type: "SH", weight: 0.2, comparedWeight: 0.4 }]);
|
|
25
|
+
const failed = { ...ahead, previousReport: report(previousForm, false), previousHoldings: [] };
|
|
26
|
+
expect(overlapPeriod(failed, behind)).toBeNull();
|
|
27
|
+
expect(buildFundOverlap(failed, behind)).toEqual([]);
|
|
28
|
+
expect(overlapPeriod(ahead, { ...ahead, cik: "2" })).toBe("2026-06-30");
|
|
29
|
+
});
|
|
@@ -9,11 +9,39 @@ export interface FundOverlapRow {
|
|
|
9
9
|
weight: number | null;
|
|
10
10
|
comparedWeight: number | null;
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** Quarter ends a fund can be compared at: its latest report, and the previous one when it loaded. */
|
|
14
|
+
function comparablePeriods(data: FundDetailData): string[] {
|
|
15
|
+
return [
|
|
16
|
+
data.latestForm?.periodOfReport,
|
|
17
|
+
data.previousReport?.complete !== false ? data.previousForm?.periodOfReport : undefined,
|
|
18
|
+
].filter((period): period is string => !!period);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The latest quarter end both funds reported among their two most recent
|
|
23
|
+
* reports, so a fund that has not filed the newest quarter yet still compares.
|
|
24
|
+
*/
|
|
25
|
+
export function overlapPeriod(first: FundDetailData, second: FundDetailData): string | null {
|
|
26
|
+
const periods = new Set(comparablePeriods(second));
|
|
27
|
+
return comparablePeriods(first).filter(period => periods.has(period)).sort().at(-1) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The fund's report for a period, as if it were the latest, with no prior quarter to show exits against. */
|
|
31
|
+
function atPeriod(data: FundDetailData, period: string): FundDetailData | null {
|
|
32
|
+
if (data.latestForm?.periodOfReport === period) return data;
|
|
33
|
+
if (data.previousForm?.periodOfReport !== period || data.previousReport?.complete === false) return null;
|
|
34
|
+
return { ...data, latestForm: data.previousForm, latestReport: data.previousReport, latestHoldings: data.previousHoldings, previousForm: null, previousReport: undefined, previousHoldings: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export function buildFundOverlap(first: FundDetailData, second: FundDetailData): FundOverlapRow[] {
|
|
13
|
-
|
|
38
|
+
const period = overlapPeriod(first, second);
|
|
39
|
+
const left = period ? atPeriod(first, period) : null;
|
|
40
|
+
const right = period ? atPeriod(second, period) : null;
|
|
41
|
+
if (!left || !right) return [];
|
|
14
42
|
// Rows are keyed by CUSIP, option side and share type, including when ticker aliases change.
|
|
15
|
-
const peer = new Map(buildFundHoldingRows(
|
|
16
|
-
return buildFundHoldingRows(
|
|
43
|
+
const peer = new Map(buildFundHoldingRows(right).filter(row => row.action !== "exit").map(row => [row.id, row]));
|
|
44
|
+
return buildFundHoldingRows(left).filter(row => row.action !== "exit" && peer.has(row.id)).map(row => ({
|
|
17
45
|
id: row.id, ticker: row.ticker, issuer: row.issuer, type: row.putCall || row.shareType,
|
|
18
46
|
weight: row.weight, comparedWeight: peer.get(row.id)!.weight,
|
|
19
47
|
})).sort((a, b) => (b.weight ?? -1) - (a.weight ?? -1));
|
|
@@ -248,6 +248,7 @@ describe("ThirteenFPane", () => {
|
|
|
248
248
|
|
|
249
249
|
const holdingsFrame = testSetup!.captureCharFrame();
|
|
250
250
|
expect(holdingsFrame).toContain("Holdings");
|
|
251
|
+
expect(holdingsFrame).toContain("filed 2026-05-15, restated");
|
|
251
252
|
expect(holdingsFrame).toContain("AAPL");
|
|
252
253
|
expect(holdingsFrame).not.toContain("Accession");
|
|
253
254
|
});
|
|
@@ -271,7 +272,7 @@ describe("ThirteenFPane", () => {
|
|
|
271
272
|
const detailFrame = testSetup!.captureCharFrame();
|
|
272
273
|
expect(detailFrame).toContain("Accession");
|
|
273
274
|
expect(detailFrame).toContain("0000000001-26-000001");
|
|
274
|
-
expect(detailFrame).toContain("
|
|
275
|
+
expect(detailFrame).toContain("Restatement");
|
|
275
276
|
expect(detailFrame).toContain("Alpha Capital");
|
|
276
277
|
expect(detailFrame).toContain("Apple Inc.");
|
|
277
278
|
expect(detailFrame).toContain("CALL");
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
DEFAULT_TIMELINE_SORT,
|
|
35
35
|
FUND_DETAIL_TABS,
|
|
36
36
|
THIRTEENF_PANE_ID,
|
|
37
|
+
amendmentKind,
|
|
37
38
|
hasComparable13FQuarter,
|
|
38
39
|
browserSortFor,
|
|
39
40
|
buildBrowserColumns,
|
|
@@ -520,7 +521,6 @@ export function FundDetailView({
|
|
|
520
521
|
const currentSourceUrl = activeTab === "filings"
|
|
521
522
|
? openFiling?.url ?? selectedFiling?.url
|
|
522
523
|
: selectedHoldingFiling?.url ?? latestForm?.url;
|
|
523
|
-
const statusFiling = openFiling ?? latestForm;
|
|
524
524
|
useEffect(() => {
|
|
525
525
|
if (holdingSelectedId && visibleHoldingRows.some((row) => row.id === holdingSelectedId)) return;
|
|
526
526
|
setHoldingSelectedId(visibleHoldingRows[0]?.id ?? null);
|
|
@@ -610,7 +610,8 @@ export function FundDetailView({
|
|
|
610
610
|
});
|
|
611
611
|
|
|
612
612
|
const holdingStats: StatItem[] = [
|
|
613
|
-
{ id: "reported", label: "Reported", value: data?.latestForm?.periodOfReport ?? "--",
|
|
613
|
+
{ id: "reported", label: "Reported", value: data?.latestForm?.periodOfReport ?? "--",
|
|
614
|
+
detail: `filed ${data?.latestForm?.filedAsOfDate || "--"}${data?.latestForm && amendmentKind(data.latestForm) === "restatement" ? ", restated" : ""}` },
|
|
614
615
|
data && hasComparable13FQuarter(data)
|
|
615
616
|
? { id: "compared", label: "Compared with", value: data.previousForm!.periodOfReport }
|
|
616
617
|
: { id: "compared", label: "Compared with", value: "No prior quarter", tone: "muted" },
|
|
@@ -632,11 +633,6 @@ export function FundDetailView({
|
|
|
632
633
|
const detailHints = useMemo<PaneHint[]>(() => hasHoldingFiling
|
|
633
634
|
? [{ id: "filing", key: "f", label: "iling", title: "Open Filing", onPress: openSelectedFilingInPane }]
|
|
634
635
|
: [], [hasHoldingFiling, openSelectedFilingInPane]);
|
|
635
|
-
const detailStatusInfo = useMemo<PaneFooterSegment[]>(() => (
|
|
636
|
-
statusFiling?.isAmendment
|
|
637
|
-
? [{ id: "amended", parts: [{ text: "amended", tone: "warning" }] }]
|
|
638
|
-
: []
|
|
639
|
-
), [statusFiling?.isAmendment]);
|
|
640
636
|
usePaneStatusLinkFooter({
|
|
641
637
|
registrationId: "thirteenf-detail",
|
|
642
638
|
focused,
|
|
@@ -644,7 +640,6 @@ export function FundDetailView({
|
|
|
644
640
|
source: openFiling ? "SEC 13F" : null,
|
|
645
641
|
loading: activeTab !== "overlap" && status === "loading",
|
|
646
642
|
error: activeTab === "overlap" ? null : error,
|
|
647
|
-
info: activeTab === "overlap" ? [] : detailStatusInfo,
|
|
648
643
|
showOpenHint: true,
|
|
649
644
|
hints: detailHints,
|
|
650
645
|
});
|
|
@@ -873,11 +868,19 @@ function FilingDetailView({
|
|
|
873
868
|
|
|
874
869
|
usePaneNoticeFooter({ registrationId: "thirteenf-filing-notices", notices: [...new Set([...sourceWarnings, ...warnings])], focused });
|
|
875
870
|
|
|
871
|
+
// An untyped amendment may replace or add to the report, so only it is a caveat.
|
|
872
|
+
const kind = amendmentKind(filing);
|
|
873
|
+
const amendmentStat: StatItem | null = kind && {
|
|
874
|
+
id: "amendment",
|
|
875
|
+
label: "Amendment",
|
|
876
|
+
value: kind === "restatement" ? "Restatement" : kind === "new-holdings" ? "New holdings" : "Unknown type",
|
|
877
|
+
tone: kind === "unknown" ? "warning" : undefined,
|
|
878
|
+
};
|
|
876
879
|
// The fund names the stack and `o` opens the filing, so neither repeats here.
|
|
877
880
|
const summaryItems: StatItem[] = [
|
|
878
881
|
{ id: "filed", label: "Filed", value: filing.filedAsOfDate || "--" },
|
|
879
882
|
{ id: "form", label: "Form", value: filing.submissionType || "--" },
|
|
880
|
-
...(
|
|
883
|
+
...(amendmentStat ? [amendmentStat] : []),
|
|
881
884
|
{ id: "cik", label: "CIK", value: filing.cik },
|
|
882
885
|
{ id: "accession", label: "Accession", value: filing.accessionNumber },
|
|
883
886
|
];
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
formatShares,
|
|
14
14
|
formatShortDate,
|
|
15
15
|
} from "./format";
|
|
16
|
-
import { positionType } from "./model";
|
|
16
|
+
import { amendmentKind, positionType } from "./model";
|
|
17
17
|
import type {
|
|
18
18
|
FilingPositionColumn,
|
|
19
19
|
FilingPositionRow,
|
|
@@ -157,6 +157,12 @@ export function renderFilingPositionCell(
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
function amendmentLabel(row: FundTimelineRow): string | null {
|
|
161
|
+
const kind = amendmentKind(row);
|
|
162
|
+
if (!kind) return null;
|
|
163
|
+
return kind === "restatement" ? "Restatement" : kind === "new-holdings" ? "New holdings" : "Amendment (unknown)";
|
|
164
|
+
}
|
|
165
|
+
|
|
160
166
|
export function renderTimelineCell(
|
|
161
167
|
row: FundTimelineRow,
|
|
162
168
|
column: FundTimelineColumn,
|
|
@@ -184,8 +190,8 @@ export function renderTimelineCell(
|
|
|
184
190
|
};
|
|
185
191
|
case "form":
|
|
186
192
|
return {
|
|
187
|
-
text:
|
|
188
|
-
color: selectedColor ?? (row
|
|
193
|
+
text: amendmentLabel(row) ?? row.submissionType,
|
|
194
|
+
color: selectedColor ?? (amendmentKind(row) === "unknown" ? colors.warning : colors.textDim),
|
|
189
195
|
};
|
|
190
196
|
}
|
|
191
197
|
}
|
|
@@ -200,12 +200,6 @@ export function financialStatementCurrency(
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
export function financialStatementLimitations(financials: TickerFinancials | null | undefined): string[] {
|
|
203
|
-
const industry = financials?.profile?.industry ?? "";
|
|
204
|
-
const records = [financials?.fundamentals, ...(financials?.annualStatements ?? []), ...(financials?.quarterlyStatements ?? [])];
|
|
205
|
-
const hasMetric = (keys: string[]) => records.some((record) => keys.some((key) => {
|
|
206
|
-
const value = (record as Record<string, unknown> | undefined)?.[key];
|
|
207
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
208
|
-
}));
|
|
209
203
|
const limitations: string[] = [];
|
|
210
204
|
const operatingNotice = financialOperatingSourceNotice(financials);
|
|
211
205
|
if (operatingNotice) limitations.push(operatingNotice);
|
|
@@ -215,12 +209,6 @@ export function financialStatementLimitations(financials: TickerFinancials | nul
|
|
|
215
209
|
if ([...(financials?.annualStatements ?? []), ...(financials?.quarterlyStatements ?? [])].some(row => row.unavailableFields?.includes("netIncome"))) {
|
|
216
210
|
limitations.push("Parent net income is unavailable for some reported periods.");
|
|
217
211
|
}
|
|
218
|
-
if (/\breit\b/i.test(industry) && !hasMetric(["fundsFromOperations", "adjustedFundsFromOperations", "ffo", "affo"])) {
|
|
219
|
-
limitations.push("FFO/AFFO are unavailable.");
|
|
220
|
-
}
|
|
221
|
-
if (/\bbanks?\b/i.test(industry) && !hasMetric(["commonEquityTier1Ratio", "cet1Ratio", "riskWeightedAssets"])) {
|
|
222
|
-
limitations.push("Bank capital measures, including CET1 and risk-weighted assets, are unavailable.");
|
|
223
|
-
}
|
|
224
212
|
return limitations;
|
|
225
213
|
}
|
|
226
214
|
|
|
@@ -24,7 +24,10 @@ export const paneSchemas = {
|
|
|
24
24
|
discovery: {
|
|
25
25
|
id: "financial-statements",
|
|
26
26
|
aliases: ["financials", "financial statement", "income statement", "balance sheet", "cash flow statement"],
|
|
27
|
-
limitations: [
|
|
27
|
+
limitations: [
|
|
28
|
+
"One company per invocation; use GF for cross-company metric comparisons.",
|
|
29
|
+
"Excludes bank regulatory capital (CET1, risk-weighted assets) and REIT FFO/AFFO.",
|
|
30
|
+
],
|
|
28
31
|
screenshotReadiness: "ready",
|
|
29
32
|
},
|
|
30
33
|
},
|
|
@@ -770,7 +770,7 @@ describe("TickerResearchPane", () => {
|
|
|
770
770
|
|
|
771
771
|
const frame = testSetup.captureCharFrame();
|
|
772
772
|
expect(frame).toContain("€125");
|
|
773
|
-
expect(frame).toContain("2.
|
|
773
|
+
expect(frame).toContain("2.20B USD");
|
|
774
774
|
expect(frame).toContain("Account");
|
|
775
775
|
expect(frame).toContain("Qty");
|
|
776
776
|
expect(frame).toContain("Avg");
|