gloomberb 0.19.1 → 0.19.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/package.json +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +9 -4
- package/src/cli/commands/overview.ts +5 -6
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/context.ts +1 -0
- package/src/cli/glossary.ts +2 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +17 -1
- package/src/cli/quote-answer.ts +29 -6
- package/src/components/command-bar/routes/root/runtime.ts +27 -5
- package/src/components/command-bar/selection-runtime.ts +12 -29
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +33 -1
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +2 -1
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ui/display.tsx +16 -5
- package/src/components/ui/icon.tsx +8 -2
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +15 -0
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +19 -11
- package/src/plugins/builtin/earnings-calls/pane.tsx +27 -14
- package/src/plugins/builtin/econ/detail-view.tsx +16 -2
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/holders/beneficial-view.tsx +1 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/market-movers/client.ts +1 -0
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +23 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/risk-factors/data.ts +8 -4
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/shared/cloud-resource.ts +13 -8
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +11 -16
- package/src/plugins/builtin/thirteenf/data.ts +34 -6
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +3 -1
- package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +6 -2
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +4 -6
- package/src/plugins/installer.ts +50 -39
- package/src/renderers/dom/desktop/icons.tsx +3 -2
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +2 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/view/ui-host.tsx +9 -2
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +5 -3
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +59 -29
- package/src/tickers/company-name.ts +65 -1
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +1 -1
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/financials.ts +18 -3
- package/src/ui/host.tsx +6 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +17 -5
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { canonicalExchange } from "../../utils/exchanges";
|
|
2
|
-
import { companyNameExtraWords,
|
|
1
|
+
import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
|
|
2
|
+
import { companyLines, companyNameExtraWords, namedByService, type CompanyLineFacts } from "../company-name";
|
|
3
3
|
import { answersOnlyByKeyword, isQualifiedTickerQuery } from "./ranking";
|
|
4
4
|
import type { TickerSearchCandidate } from "./types";
|
|
5
5
|
|
|
@@ -29,6 +29,69 @@ export function lineDistance(type: string, exchange: string, symbol: string): 0
|
|
|
29
29
|
return SECONDARY_VENUES.has(exchange) || /\d/.test(symbol) && /[A-Za-z]/.test(symbol) ? 1 : 0;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** How the primary-listing choice weighs one of a company's lines. */
|
|
33
|
+
export interface WeighedCompanyLine {
|
|
34
|
+
/** `listingDistance`: 0 its own line, 1 a line quoting shares listed elsewhere, 2 a receipt. */
|
|
35
|
+
distance: number;
|
|
36
|
+
/** The search score the service gives the listings people look up most; null when it gives none. */
|
|
37
|
+
score: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The line a company's search lists as its primary listing instead of `lead`,
|
|
42
|
+
* the line search put first, often the one-off cross-listing whose short name
|
|
43
|
+
* matched the query (LUFTHANSA on Budapest): its lines go by how far each is
|
|
44
|
+
* from being its own line, then by the search score. The lead gives way only
|
|
45
|
+
* to a closer line, or to one searched at least twice as often, or any scored
|
|
46
|
+
* line when it has no score. With `named` (the service marked the lead with
|
|
47
|
+
* the name as typed) no score outweighs the mark: only a closer line does.
|
|
48
|
+
* `lines` holds the lead. `search` and a company's name typed for a listing
|
|
49
|
+
* (`findListingOfCompanyName`) choose by this one rule, so both open the same
|
|
50
|
+
* listing.
|
|
51
|
+
*/
|
|
52
|
+
export function primaryCompanyLine<T>(
|
|
53
|
+
lead: T,
|
|
54
|
+
lines: readonly T[],
|
|
55
|
+
weigh: (line: T) => WeighedCompanyLine,
|
|
56
|
+
options: { named?: boolean } = {},
|
|
57
|
+
): T {
|
|
58
|
+
const order = (line: T) => lines.indexOf(line);
|
|
59
|
+
const [home] = [...lines].sort((a, b) => weigh(a).distance - weigh(b).distance
|
|
60
|
+
|| (weigh(b).score ?? -1) - (weigh(a).score ?? -1) || order(a) - order(b));
|
|
61
|
+
if (!home || home === lead) return lead;
|
|
62
|
+
const [best, first] = [weigh(home), weigh(lead)];
|
|
63
|
+
const closer = best.distance < first.distance;
|
|
64
|
+
const moreSearched = !options.named && best.distance === first.distance && best.score != null
|
|
65
|
+
&& (first.score == null || best.score >= first.score * 2);
|
|
66
|
+
return closer || moreSearched ? home : lead;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A row as `companyLines` weighs it. */
|
|
70
|
+
function lineFacts(candidate: TickerSearchCandidate): CompanyLineFacts {
|
|
71
|
+
return {
|
|
72
|
+
name: candidateName(candidate),
|
|
73
|
+
exchange: candidateExchange(candidate),
|
|
74
|
+
receipt: listingDistance(candidate) === 2,
|
|
75
|
+
home: candidate.result?.homeListing === true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The rows of `lead`'s company among `items` (`companyLines`): never a line of another business its name is in (Siemens Energy for Siemens). */
|
|
80
|
+
function companyRows<T extends TickerSearchCandidate>(lead: T, items: readonly T[]): T[] {
|
|
81
|
+
return companyLines(lead, items.filter((item) => item === lead || isListedEquity(item)), lineFacts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `primaryCompanyLine` among `items` of `start`'s company, for the command bar's rows. */
|
|
85
|
+
function primaryLineOf<T extends TickerSearchCandidate>(start: T, items: readonly T[], query: string): T {
|
|
86
|
+
const name = candidateName(start);
|
|
87
|
+
const named = !!start.result && namedByService(query, start.result);
|
|
88
|
+
const lines = companyRows(start, items).filter((item) => item === start
|
|
89
|
+
|| !named || companyNameExtraWords(candidateName(item), name) != null);
|
|
90
|
+
return primaryCompanyLine(start, lines, (item) => ({
|
|
91
|
+
distance: listingDistance(item), score: item.result?.popularity ?? item.popularity ?? null,
|
|
92
|
+
}), { named });
|
|
93
|
+
}
|
|
94
|
+
|
|
32
95
|
function candidateName(candidate: TickerSearchCandidate): string {
|
|
33
96
|
return candidate.result?.name || candidate.detail.split(" | ")[0] || "";
|
|
34
97
|
}
|
|
@@ -51,24 +114,46 @@ function isListedEquity(candidate: TickerSearchCandidate): boolean {
|
|
|
51
114
|
* service marks as the company's home (`homeListing`) takes the lead from
|
|
52
115
|
* the company's first row. Without that mark, a receipt in the lead gives
|
|
53
116
|
* way to the company's own shares on an exchange (`listingDistance` 0, not
|
|
54
|
-
* an OTC or regional line), the name nearest the receipt's first.
|
|
55
|
-
*
|
|
117
|
+
* an OTC or regional line), the name nearest the receipt's first. Then the
|
|
118
|
+
* line `search` calls the company's primary listing (`primaryCompanyLine`)
|
|
119
|
+
* takes the lead, so a name opens what `search` lists first: LHA on XETRA
|
|
120
|
+
* for "lufthansa", not LUFTHANSA on Budapest. A company's lines are never
|
|
121
|
+
* another business's its name is in (`companyLines`: Siemens Energy AG for
|
|
122
|
+
* Siemens AG). The row the service marks with the word as typed is the
|
|
123
|
+
* company the word means, ahead of a line abroad whose symbol is the word:
|
|
124
|
+
* "siemens" is SIE on XETRA, not SIEMENS on NSE (Siemens Ltd.); a US line
|
|
125
|
+
* keeps its ticker. A query that names a venue (LVMH:TSX, 7203.T) asked for
|
|
126
|
+
* that line, and a saved row keeps the lead.
|
|
56
127
|
*/
|
|
57
128
|
export function withHomeListingLead<T extends TickerSearchCandidate>(items: T[], query: string): T[] {
|
|
58
129
|
if (isQualifiedTickerQuery(query)) return items;
|
|
59
130
|
const lead = items.find((item) => item.instrumentClass !== "fund" && item.instrumentClass !== "derivative"
|
|
60
131
|
&& !answersOnlyByKeyword(item, query));
|
|
61
132
|
if (!lead || !isListedEquity(lead)) return items;
|
|
133
|
+
const typedSymbol = lead.symbol.toUpperCase() === query.trim().toUpperCase();
|
|
134
|
+
const meant = typedSymbol && !isUsListingExchange(candidateExchange(lead)) && !(lead.result && namedByService(query, lead.result))
|
|
135
|
+
// A company's shares under another symbol: an index the code also names (SPX) or the word's own line elsewhere (SAP) is not it.
|
|
136
|
+
? items.find((item) => item !== lead && isListedEquity(item) && item.instrumentClass === "equity" && !!item.result
|
|
137
|
+
&& item.symbol.toUpperCase() !== lead.symbol.toUpperCase() && namedByService(query, item.result))
|
|
138
|
+
: undefined;
|
|
139
|
+
if (meant) return moveAhead(items, meant, lead);
|
|
62
140
|
const name = candidateName(lead);
|
|
63
|
-
const lines = items.filter((item) => item !== lead
|
|
141
|
+
const lines = companyRows(lead, items).filter((item) => item !== lead);
|
|
64
142
|
const nearest = (candidates: T[]) => [...candidates].sort((a, b) => nameGap(name, candidateName(a)) - nameGap(name, candidateName(b))
|
|
65
143
|
|| items.indexOf(a) - items.indexOf(b))[0];
|
|
66
144
|
const marked = lines.filter((item) => item.result?.homeListing === true);
|
|
145
|
+
const own = listingDistance(lead) === 2 ? nearest(lines.filter((item) => listingDistance(item) === 0)) : undefined;
|
|
67
146
|
const home = lead.result?.homeListing === true ? undefined
|
|
68
147
|
: marked.length ? nearest(marked)
|
|
69
|
-
|
|
70
|
-
:
|
|
71
|
-
|
|
148
|
+
// A listing searched by its own symbol is that listing, as `search` keeps it.
|
|
149
|
+
: typedSymbol ? own
|
|
150
|
+
: primaryLineOf(own ?? lead, [lead, ...lines], query);
|
|
151
|
+
if (!home || home === lead) return items;
|
|
152
|
+
return moveAhead(items, home, lead);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `items` with `home` moved just ahead of `lead`. */
|
|
156
|
+
function moveAhead<T>(items: readonly T[], home: T, lead: T): T[] {
|
|
72
157
|
const rest = items.filter((item) => item !== home);
|
|
73
158
|
const at = rest.indexOf(lead);
|
|
74
159
|
return [...rest.slice(0, at), home, ...rest.slice(at)];
|
|
@@ -54,7 +54,7 @@ export {
|
|
|
54
54
|
rankTickerSearchItems,
|
|
55
55
|
} from "./ranking";
|
|
56
56
|
export { upsertTickerFromSearchResult } from "./upsert";
|
|
57
|
-
export { candidateExchange, listingDistance } from "./home-listing";
|
|
57
|
+
export { candidateExchange, listingDistance, primaryCompanyLine } from "./home-listing";
|
|
58
58
|
|
|
59
59
|
const MAX_NAMED_LISTINGS = 6;
|
|
60
60
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
2
|
-
import { pricePointIntegrity, pricePointValues, priceHistoryIntegrityNotice, mergePriceHistoryIntegrity, type PriceHistoryIntegrity } from "../utils/price-history-integrity";
|
|
2
|
+
import { pricePointIntegrity, pricePointValues, pricePointWicks, priceHistoryIntegrityNotice, mergePriceHistoryIntegrity, type PriceHistoryIntegrity } from "../utils/price-history-integrity";
|
|
3
3
|
import { canonicalTimeSeriesFieldId, isFundamentalFieldId, isMarketFieldId } from "./field-catalog";
|
|
4
4
|
import { extractFundamentalSeries } from "./fundamentals";
|
|
5
5
|
import type { ChartSeriesPriceHistoryIntegrity, ResolvedSeries, SecuritySeriesSource, SeriesPeriod, TimeSeriesPoint } from "./types";
|
|
@@ -73,8 +73,10 @@ function aggregatePriceHistory(
|
|
|
73
73
|
for (const point of sorted) {
|
|
74
74
|
const key = periodKey(point.date, period);
|
|
75
75
|
const open = isFiniteNumber(point.open) ? point.open : point.close;
|
|
76
|
-
|
|
77
|
-
const
|
|
76
|
+
// A wick too far from its bar's body to have traded does not set the week's or month's range.
|
|
77
|
+
const wicks = pricePointWicks(point);
|
|
78
|
+
const high = isFiniteNumber(wicks.high) ? wicks.high : isFiniteNumber(point.high) ? Math.max(open, point.close) : point.close;
|
|
79
|
+
const low = isFiniteNumber(wicks.low) ? wicks.low : isFiniteNumber(point.low) ? Math.min(open, point.close) : point.close;
|
|
78
80
|
const current = buckets.get(key);
|
|
79
81
|
if (!current) {
|
|
80
82
|
buckets.set(key, {
|
|
@@ -63,7 +63,24 @@ export function observationDate(time: number, series: Pick<ResolvedSeries, "time
|
|
|
63
63
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The day the weekly or monthly bar holding `date` is dated by: its week's
|
|
68
|
+
* Monday or its month's 1st, as `history --from` keeps the week or month its
|
|
69
|
+
* first date falls in. A daily bar is its own day.
|
|
70
|
+
*/
|
|
71
|
+
function windowBarStart(date: string, resolution: "1d" | "1wk" | "1mo"): string {
|
|
72
|
+
if (resolution === "1mo") return `${date.slice(0, 7)}-01`;
|
|
73
|
+
if (resolution === "1d") return date;
|
|
74
|
+
const day = Date.parse(`${date}T00:00:00Z`);
|
|
75
|
+
return new Date(day - ((new Date(day).getUTCDay() + 6) % 7) * 86_400_000).toISOString().slice(0, 10);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Date-only research windows select local sessions; runtime timestamps remain
|
|
80
|
+
* exact. Weekly and monthly bars keep the bar the window starts in: a window
|
|
81
|
+
* from 2019-12-31 in monthly bars starts with December 2019's, whose close is
|
|
82
|
+
* that day's, so a comparison is measured from it.
|
|
83
|
+
*/
|
|
67
84
|
export function priceObservationWindowFilter(
|
|
68
85
|
series: ResolvedSeries,
|
|
69
86
|
bounds: { start: number | null; end: number | null },
|
|
@@ -74,11 +91,12 @@ export function priceObservationWindowFilter(
|
|
|
74
91
|
const calendarWindow = calendarBars && dateWindow
|
|
75
92
|
&& /^\d{4}-\d{2}-\d{2}$/.test(dateWindow.start)
|
|
76
93
|
&& /^\d{4}-\d{2}-\d{2}$/.test(dateWindow.end) ? dateWindow : null;
|
|
94
|
+
const start = calendarWindow ? windowBarStart(calendarWindow.start, resolution as "1d" | "1wk" | "1mo") : null;
|
|
77
95
|
return (time) => {
|
|
78
96
|
if (!Number.isFinite(time)) return false;
|
|
79
97
|
const key = calendarWindow ? observationDate(time, series) : null;
|
|
80
98
|
return key !== null
|
|
81
|
-
? key >=
|
|
99
|
+
? key >= start! && key <= calendarWindow!.end
|
|
82
100
|
: (bounds.start === null || time >= bounds.start) && (bounds.end === null || time <= bounds.end);
|
|
83
101
|
};
|
|
84
102
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logFailure } from "../utils/log-failure";
|
|
2
|
+
import { servedFromSavedCopy } from "../data/saved-copies";
|
|
2
3
|
import { hasValidQuoteObservationTime } from "../market-data/quotes/freshness";
|
|
3
4
|
import { delistingLabel } from "../market-data/quotes/delisting";
|
|
4
5
|
import { financialPeriodCoverage, financialPeriodCoverageWarnings, limitSeriesObservations } from "./financial-period-coverage";
|
|
@@ -827,7 +828,7 @@ async function resolveChartSpecPass(
|
|
|
827
828
|
cache.capabilitySeriesByRequest.set(key, pending);
|
|
828
829
|
cache.revision += 1;
|
|
829
830
|
}
|
|
830
|
-
return baseCapabilitySeries(seriesSpec, await pending, index);
|
|
831
|
+
return asSavedCopy(baseCapabilitySeries(seriesSpec, await pending, index));
|
|
831
832
|
}
|
|
832
833
|
if (seriesSpec.source.kind === "economic") {
|
|
833
834
|
const request: FredSeriesRequest = {
|
|
@@ -845,6 +846,8 @@ async function resolveChartSpecPass(
|
|
|
845
846
|
priorityWarnings.push(`${result.label}: ${coverageNotice}`);
|
|
846
847
|
coverageNotices.push(`${result.label}: ${coverageNotice}`);
|
|
847
848
|
}
|
|
849
|
+
// A saved copy that answered for a failed refresh is dated in the footer, as every pane's is.
|
|
850
|
+
if (result && fred.source === "stale-fallback") return asSavedCopy({ ...result, savedAt: fred.fetchedAt });
|
|
848
851
|
const freshnessWarning = staleFredWarning(fred);
|
|
849
852
|
if (result && freshnessWarning) priorityWarnings.push(`${result.label}: ${freshnessWarning}`);
|
|
850
853
|
return result;
|
|
@@ -1180,6 +1183,7 @@ async function resolveChartSpecPass(
|
|
|
1180
1183
|
loading: false,
|
|
1181
1184
|
errors,
|
|
1182
1185
|
warnings: [...new Set([...priorityWarnings, ...warnings])],
|
|
1186
|
+
...savedCopyOf(rawSeries.filter((entry) => visibleSeriesIds.has(entry.id))),
|
|
1183
1187
|
...(coverageNotices.length ? { coverageNotices: [...new Set(coverageNotices)] } : {}),
|
|
1184
1188
|
...(returnBasisNotice ? { returnBasisNotice } : {}),
|
|
1185
1189
|
...(delistings.size ? { delistings: [...delistings.values()] } : {}),
|
|
@@ -1188,3 +1192,18 @@ async function resolveChartSpecPass(
|
|
|
1188
1192
|
...(builtBarSize && rawSeries.some((entry) => entry.barSize === builtBarSize) ? { barSize: builtBarSize } : {}),
|
|
1189
1193
|
};
|
|
1190
1194
|
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* A series drawn from a copy a failed refresh left answering (`savedAt`),
|
|
1198
|
+
* marked as one, so a command charting it ends with "couldn't refresh; saved
|
|
1199
|
+
* copy from …" as the app's footer says.
|
|
1200
|
+
*/
|
|
1201
|
+
function asSavedCopy(series: ResolvedSeries): ResolvedSeries {
|
|
1202
|
+
return series.savedAt != null ? servedFromSavedCopy(series, series.savedAt) : series;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** The oldest saved copy among `series` that answered for a failed refresh, which the chart's footer dates. */
|
|
1206
|
+
function savedCopyOf(series: readonly ResolvedSeries[]): { savedAt?: number } {
|
|
1207
|
+
const saved = series.flatMap((entry) => entry.savedAt != null && Number.isFinite(entry.savedAt) ? [entry.savedAt] : []);
|
|
1208
|
+
return saved.length ? { savedAt: Math.min(...saved) } : {};
|
|
1209
|
+
}
|
package/src/time-series/types.ts
CHANGED
|
@@ -265,6 +265,8 @@ export interface ResolvedSeries {
|
|
|
265
265
|
/** Rejected valuation price inputs, retained independently of usable observations. */
|
|
266
266
|
valuationPriceIssues?: import("./valuation-price").ValuationPriceIssue[];
|
|
267
267
|
warning?: string;
|
|
268
|
+
/** When the copy the points came from was saved (ms), when its refresh failed and that copy answered. */
|
|
269
|
+
savedAt?: number;
|
|
268
270
|
/** Listed in the legend so it can be restored, but not drawn. */
|
|
269
271
|
hidden?: boolean;
|
|
270
272
|
/**
|
|
@@ -324,6 +326,11 @@ export interface ChartResolutionResult {
|
|
|
324
326
|
loading: boolean;
|
|
325
327
|
errors: string[];
|
|
326
328
|
warnings: string[];
|
|
329
|
+
/**
|
|
330
|
+
* The oldest saved copy a shown series answered from after its refresh
|
|
331
|
+
* failed (ms), which the chart's footer dates: "saved copy from …".
|
|
332
|
+
*/
|
|
333
|
+
savedAt?: number;
|
|
327
334
|
/**
|
|
328
335
|
* Where a series' source starts after the range asked for, and why (a
|
|
329
336
|
* publisher's cap on history), as `warnings` also carries it, so a report
|
package/src/types/financials.ts
CHANGED
|
@@ -268,7 +268,8 @@ export interface TtmConversion {
|
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
/**
|
|
271
|
-
* How a served enterprise value ties to market cap + total debt - cash from the latest balance sheet
|
|
271
|
+
* How a served enterprise value ties to market cap + total debt (+ leases, `leases`) - cash from the latest balance sheet
|
|
272
|
+
* served beside it.
|
|
272
273
|
* `matches` within 5%; `differs` beyond it (or beyond 2% on the other side of the market cap from net debt), within
|
|
273
274
|
* 0.7x to 1.5x, shown labelled as the provider's; `withdrawn` outside that band, with no EV or EV/revenue; `unchecked`
|
|
274
275
|
* without a recent balance sheet carrying both lines. `computed`: an insurer's or a holding company's EV, which is
|
|
@@ -284,15 +285,24 @@ export interface EnterpriseValueCheck {
|
|
|
284
285
|
/** The market cap the EV is measured on; absent only on a not-meaningful source EV served without one. */
|
|
285
286
|
marketCap?: number;
|
|
286
287
|
totalDebt?: number;
|
|
288
|
+
/** The balance sheet's lease liabilities the bridge adds to total debt, as FA's net debt does (utils/lease-liabilities); absent when none were added. */
|
|
289
|
+
leases?: number;
|
|
287
290
|
cash?: number;
|
|
288
291
|
/** YYYY-MM-DD of the balance sheet the debt and cash come from. */
|
|
289
292
|
balanceSheetDate?: string;
|
|
290
|
-
/** marketCap + totalDebt - cash. */
|
|
293
|
+
/** marketCap + totalDebt + leases - cash. */
|
|
291
294
|
computed?: number;
|
|
292
295
|
/** `cash` is cash and equivalents alone: an insurer's or a holding company's investments stay in its EV. */
|
|
293
296
|
investmentsKept?: true;
|
|
294
|
-
/** Why the EV is not meaningful (`not-meaningful`). */
|
|
297
|
+
/** Why the EV is not meaningful (`not-meaningful`), or why a withdrawn EV no balance sheet checked is withdrawn. */
|
|
295
298
|
reason?: string;
|
|
299
|
+
/** A balance sheet in another currency than the market cap: its currency and the rate at its date
|
|
300
|
+
* (`currency` per unit of `from`) that `totalDebt`, `leases` and `cash` were converted at. */
|
|
301
|
+
fx?: { from: string; rate: number };
|
|
302
|
+
/** The provider converted its EV from the statements' currency. */
|
|
303
|
+
convertedFrom?: string;
|
|
304
|
+
/** Withdrawn because the provider applied that conversion twice: its EV over `computed` is the conversion rate. */
|
|
305
|
+
convertedTwice?: true;
|
|
296
306
|
}
|
|
297
307
|
|
|
298
308
|
/** How a trailing P/E converts the EPS it divides by, when that EPS is in another currency than the price. */
|
|
@@ -784,6 +794,11 @@ export interface FinancialStatement {
|
|
|
784
794
|
date: string;
|
|
785
795
|
/** Source of the fiscal period date; does not establish metric publication dates. */
|
|
786
796
|
dateSource?: "sec" | "provider";
|
|
797
|
+
/**
|
|
798
|
+
* The accounting standard the issuer reports in, from the taxonomy of its latest annual report to the SEC (10-K,
|
|
799
|
+
* 20-F or 40-F): BABA's 20-F is US GAAP, NVO's IFRS. Absent for a company that files none with the SEC.
|
|
800
|
+
*/
|
|
801
|
+
accountingStandard?: "us-gaap" | "ifrs";
|
|
787
802
|
/** Original vendor period date when independent filing evidence changes it. */
|
|
788
803
|
providerDate?: string;
|
|
789
804
|
/** Filing evidence for the period identity only, not availability of every field. */
|
package/src/ui/host.tsx
CHANGED
|
@@ -709,7 +709,12 @@ export interface SaveTextFileRequest {
|
|
|
709
709
|
|
|
710
710
|
export interface RendererHost {
|
|
711
711
|
requestExit(): void;
|
|
712
|
-
|
|
712
|
+
/**
|
|
713
|
+
* From a press on a title bar. `press.detail` counts the clicks: where the
|
|
714
|
+
* system's title bars maximize on a double-click, the second press does that
|
|
715
|
+
* instead of moving the window.
|
|
716
|
+
*/
|
|
717
|
+
startWindowDrag?(press?: { detail?: number }): Promise<void> | void;
|
|
713
718
|
controlWindow?(action: "minimize" | "toggle-maximize" | "close"): Promise<void> | void;
|
|
714
719
|
/**
|
|
715
720
|
* Opens a link in the person's browser. Resolves false when no browser could
|
|
@@ -1,31 +1,88 @@
|
|
|
1
|
-
import type { FinancialStatement, StatementFill, StatementFillField } from "../types/financials";
|
|
1
|
+
import type { FinancialStatement, StatementFill, StatementFillField, TickerFinancials } from "../types/financials";
|
|
2
|
+
import { settleWithin } from "./async-deadline";
|
|
3
|
+
import { logFailure } from "./log-failure";
|
|
2
4
|
import { copyIncomeField, incomeFieldOwner, INCOME_STATEMENT_FIELDS, isIncomeStatementField } from "./income-statement";
|
|
3
5
|
import { mergeStatementGaps } from "./statement-gaps";
|
|
4
6
|
|
|
5
7
|
export const FINANCIAL_VINTAGE_NOTICE = "Latest available statements may include restatements. Historical as-of values are not reconstructed.";
|
|
6
8
|
|
|
9
|
+
/** The oldest dated period of a report's columns (a TTM column has no date), or null when none has one. */
|
|
10
|
+
function oldestPeriod(dates: readonly string[]): string | null {
|
|
11
|
+
return dates.filter((date) => /^\d{4}-\d{2}-\d{2}/.test(date)).map((date) => date.slice(0, 10)).sort()[0] ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* The closing lines of a statements report that say how far back it goes, from
|
|
9
|
-
* the oldest period it served: the report holds no older ones
|
|
10
|
-
*
|
|
11
|
-
*
|
|
16
|
+
* the oldest period it served: the report holds no older ones. `older` says
|
|
17
|
+
* whether `GF`, which reads one metric from the listing's extended history
|
|
18
|
+
* (`hasOlderStatements`), goes further back: it is pointed to only then, and
|
|
19
|
+
* when it does not, the listing has no older ones at all (IAG on LSE: its
|
|
20
|
+
* statements start in 2022, in GF too). Null when that could not be told in
|
|
21
|
+
* time: the report then says nothing of older periods. Empty too when no
|
|
22
|
+
* period has a date, so a report with nothing in it says nothing.
|
|
12
23
|
*/
|
|
13
24
|
export function statementHistoryNotes(
|
|
14
25
|
period: "annual" | "quarterly",
|
|
15
26
|
dates: readonly string[],
|
|
16
27
|
symbol: string,
|
|
28
|
+
older: boolean | null,
|
|
17
29
|
): string[] {
|
|
18
|
-
const oldest = dates
|
|
19
|
-
if (!oldest) return [];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const oldest = oldestPeriod(dates);
|
|
31
|
+
if (!oldest || older === null) return [];
|
|
32
|
+
const [from, unit] = period === "annual" ? [oldest.slice(0, 4), "years"] : [oldest, "quarters"];
|
|
33
|
+
if (older === false) return [`${period} history from ${from}; older ${unit} not available for this listing`];
|
|
34
|
+
return [
|
|
35
|
+
`${period} history from ${from}; older ${unit} not served`,
|
|
36
|
+
...older ? [`earlier ${unit}, one metric at a time: gloom fn GF ${symbol} --period ${period} --periods 40`] : [],
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How long a statements report waits on the extended history for its
|
|
42
|
+
* older-periods note, as a lock note waits on its routes' checks: a read still
|
|
43
|
+
* out then leaves the report on time, without the note. The read goes on and
|
|
44
|
+
* warms the cache for the next report.
|
|
45
|
+
*/
|
|
46
|
+
const OLDER_STATEMENTS_WAIT_MS = 3_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the listing's extended statement history, which `GF` reads, has a
|
|
50
|
+
* period older than the oldest of `dates`, a report's columns: a US filer's
|
|
51
|
+
* filings reach years back; a listing abroad has nothing past what the report
|
|
52
|
+
* served. Null when the history could not be read within `waitMs`.
|
|
53
|
+
*/
|
|
54
|
+
export async function hasOlderStatements(
|
|
55
|
+
loadExtended: () => Promise<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>,
|
|
56
|
+
period: "annual" | "quarterly",
|
|
57
|
+
dates: readonly string[],
|
|
58
|
+
waitMs = OLDER_STATEMENTS_WAIT_MS,
|
|
59
|
+
): Promise<boolean | null> {
|
|
60
|
+
const oldest = oldestPeriod(dates);
|
|
61
|
+
if (!oldest) return null;
|
|
62
|
+
const read = Promise.resolve().then(loadExtended).catch((error: unknown) => {
|
|
63
|
+
logFailure("utils.financial-statements", "Reading the extended statement history failed")(error);
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
const extended = await settleWithin(read, waitMs);
|
|
67
|
+
if (!extended) return null;
|
|
68
|
+
const rows = period === "annual" ? extended.annualStatements : extended.quarterlyStatements;
|
|
69
|
+
return rows.some((row) => /^\d{4}-\d{2}-\d{2}/.test(row.date) && row.date.slice(0, 10) < oldest);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Venues whose companies report half-yearly and yearly, with no quarterly statements: Australia's (ASX, and Cboe Australia, which trades ASX companies) and New Zealand's. */
|
|
73
|
+
const HALF_YEARLY_VENUES = new Set(["ASX", "CXA", "NZX"]);
|
|
74
|
+
const DAY_MS = 86_400_000;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a listing's company reports half-yearly rather than quarterly: it
|
|
78
|
+
* lists on a venue whose companies do (Qantas on ASX), or the periods of its
|
|
79
|
+
* "quarterly" statements are half a year apart.
|
|
80
|
+
*/
|
|
81
|
+
export function reportsHalfYearly(exchange: string | null | undefined, quarterly: readonly Pick<FinancialStatement, "date">[]): boolean {
|
|
82
|
+
if (exchange && HALF_YEARLY_VENUES.has(exchange.toUpperCase())) return true;
|
|
83
|
+
const times = quarterly.map(({ date }) => Date.parse(`${date.slice(0, 10)}T00:00:00Z`)).filter(Number.isFinite).sort((a, b) => a - b);
|
|
84
|
+
const gaps = times.slice(1).map((time, index) => (time - times[index]!) / DAY_MS);
|
|
85
|
+
return gaps.length > 0 && gaps.every((days) => days > 150 && days < 215);
|
|
29
86
|
}
|
|
30
87
|
|
|
31
88
|
/** How old a statement's latest period may be before a report says how old it is. */
|
|
@@ -106,7 +163,7 @@ function retainedFill(merged: FinancialStatement, rows: Array<FinancialStatement
|
|
|
106
163
|
|
|
107
164
|
export const SEC_EPS_BASIS_NOTICE = "SEC EPS uses corroborated split-adjusted share bases. Unverified bases are unavailable.";
|
|
108
165
|
|
|
109
|
-
const STATEMENT_METADATA_KEYS = new Set(["date", "dateSource", "providerDate", "dateEvidence", "currency", "availableAt", "fieldAvailability", "epsBasis", "fieldSources", "unavailableFields", "withdrawnObservations", "operatingResult", "operatingResultAggregation", "earningsResult", "unavailableEarnings", "filledFrom"]);
|
|
166
|
+
const STATEMENT_METADATA_KEYS = new Set(["date", "dateSource", "accountingStandard", "providerDate", "dateEvidence", "currency", "availableAt", "fieldAvailability", "epsBasis", "fieldSources", "unavailableFields", "withdrawnObservations", "operatingResult", "operatingResultAggregation", "earningsResult", "unavailableEarnings", "filledFrom"]);
|
|
110
167
|
const NEARBY_PERIOD_END_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
111
168
|
|
|
112
169
|
/** An explicit field map is authoritative: omitted fields have unknown availability. */
|
|
@@ -45,7 +45,10 @@ export function enterpriseValueCheck(
|
|
|
45
45
|
// Not meaningful needs no figure of its own: a source EV below zero may come with no cap or currency.
|
|
46
46
|
if (check.status === "not-meaningful") return withdrawn ? check : undefined;
|
|
47
47
|
if (!check.currency) return undefined;
|
|
48
|
-
|
|
48
|
+
// A withdrawn EV comes with the figure it was checked against, or with why no balance sheet checked it.
|
|
49
|
+
if (check.status === "withdrawn") {
|
|
50
|
+
return withdrawn && ((finiteNumber(check.computed) && finiteNumber(check.source)) || !!check.reason?.trim()) ? check : undefined;
|
|
51
|
+
}
|
|
49
52
|
if (check.status === "computed") {
|
|
50
53
|
return finiteNumber(check.computed) && fundamentals?.enterpriseValue === check.computed ? check : undefined;
|
|
51
54
|
}
|
|
@@ -87,8 +90,9 @@ export function enterpriseValueLabel(fundamentals: Parameters<typeof enterpriseV
|
|
|
87
90
|
/**
|
|
88
91
|
* The reconciliation line under a labelled, withdrawn or not meaningful EV: "EV 61.75B USD (provider) vs 63.75B
|
|
89
92
|
* computed from market cap + total debt - cash (balance sheet 2026-06-30).", "EV N/M: cash and short-term investments
|
|
90
|
-
* exceed market cap + total debt (balance sheet 2026-06-30)."
|
|
91
|
-
*
|
|
93
|
+
* exceed market cap + total debt (balance sheet 2026-06-30)." A balance sheet in another currency names the rate it
|
|
94
|
+
* was converted at ("balance sheet 2026-06-30, EUR at 0.8614 GBP"). A withdrawn EV no balance sheet checked gives
|
|
95
|
+
* Gloom Cloud's reason; one withdrawn without a check, that the source failed validation. Undefined when the EV matches, is unchecked or computed, or has no check. Read it from the
|
|
92
96
|
* fundamentals the rows show (atRegularSessionPrice moves the check with the EV and the market cap), so its figures
|
|
93
97
|
* are theirs.
|
|
94
98
|
*/
|
|
@@ -97,14 +101,22 @@ export function enterpriseValueReconciliationNote(fundamentals: Parameters<typeo
|
|
|
97
101
|
if (notMeaningful) return `EV N/M: ${notMeaningful.charAt(0).toLowerCase()}${notMeaningful.slice(1)}.`;
|
|
98
102
|
const check = enterpriseValueCheck(fundamentals);
|
|
99
103
|
if (!check && enterpriseValueWithdrawn(fundamentals)) return "EV unavailable: the provider's figure failed validation.";
|
|
104
|
+
const reason = check?.reason?.trim().replace(/\.$/, "");
|
|
105
|
+
if (check?.status === "withdrawn" && !finiteNumber(check.computed) && reason) {
|
|
106
|
+
return `EV unavailable: ${reason.charAt(0).toLowerCase()}${reason.slice(1)}.`;
|
|
107
|
+
}
|
|
100
108
|
if (!check || !finiteNumber(check.computed) || !finiteNumber(check.source)
|
|
101
109
|
|| (check.status !== "differs" && check.status !== "withdrawn")) return undefined;
|
|
102
110
|
const source = `${formatCompact(check.source)} ${check.currency}`;
|
|
103
111
|
const computed = formatCompact(check.computed);
|
|
104
|
-
const
|
|
112
|
+
const leases = finiteNumber(check.leases) && check.leases > 0 ? " + leases" : "";
|
|
113
|
+
const fx = check.fx && finiteNumber(check.fx.rate) ? `, ${check.fx.from} at ${check.fx.rate.toPrecision(4)} ${check.currency}` : "";
|
|
114
|
+
const sheet = check.balanceSheetDate ? ` (balance sheet ${check.balanceSheetDate}${fx})` : "";
|
|
115
|
+
const basis = `market cap + total debt${leases} - cash${sheet}`;
|
|
105
116
|
if (check.status === "differs") return `EV ${source} (provider) vs ${computed} computed from ${basis}.`;
|
|
106
117
|
const ratio = check.computed > 0 ? ` is ${(check.source / check.computed).toFixed(1)}x` : " is far from";
|
|
107
|
-
|
|
118
|
+
const twice = check.convertedTwice && check.convertedFrom ? `; it converts ${check.convertedFrom} to ${check.currency} twice` : "";
|
|
119
|
+
return `EV unavailable: the provider's ${source}${ratio} the ${computed} computed from ${basis}${twice}.`;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
/**
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FinancialStatement } from "../types/financials";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How net debt treats the lease liabilities a listing's balance sheets report (docs/financial-ratios.md#leases):
|
|
5
|
+
*
|
|
6
|
+
* - `included`: added to total debt. IFRS 16 puts every lessee lease on the balance sheet and reports EBITDA before
|
|
7
|
+
* lease costs, so leverage with the leases in is like for like (IAG's June 2026 balance sheet: 6.96bn of
|
|
8
|
+
* borrowings, 6.91bn of leases, 9.18bn of cash).
|
|
9
|
+
* - `excluded`: a company reporting under US GAAP, whatever form it files (a 10-K, or a 20-F as BABA, PDD and JD
|
|
10
|
+
* do). Its finance leases are already in total debt and its operating lease cost is in EBITDA, so its operating
|
|
11
|
+
* leases stay out.
|
|
12
|
+
*
|
|
13
|
+
* The standard is the one Gloom Cloud reads from the issuer's latest annual report to the SEC
|
|
14
|
+
* (`accountingStandard`), the signal its enterprise value check bridges by. Where no row states it, a row dated by
|
|
15
|
+
* a 10-K or 10-Q (`dateSource: "sec"`) is US GAAP, and a company that files none with the SEC is IFRS.
|
|
16
|
+
*/
|
|
17
|
+
export type LeaseBasis = "included" | "excluded";
|
|
18
|
+
|
|
19
|
+
type LeaseLines = Pick<FinancialStatement, "capitalLeaseObligations" | "dateSource" | "accountingStandard">;
|
|
20
|
+
|
|
21
|
+
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
|
22
|
+
|
|
23
|
+
/** The listing's lease basis, or undefined when no balance sheet on file reports lease liabilities. */
|
|
24
|
+
export function leaseBasis(statements: Iterable<LeaseLines>): LeaseBasis | undefined {
|
|
25
|
+
let leases = false;
|
|
26
|
+
let standard: FinancialStatement["accountingStandard"];
|
|
27
|
+
let secDated = false;
|
|
28
|
+
for (const statement of statements) {
|
|
29
|
+
if (finite(statement.capitalLeaseObligations) && statement.capitalLeaseObligations > 0) leases = true;
|
|
30
|
+
standard ??= statement.accountingStandard;
|
|
31
|
+
if (statement.dateSource === "sec") secDated = true;
|
|
32
|
+
}
|
|
33
|
+
if (!leases) return undefined;
|
|
34
|
+
return (standard ? standard === "us-gaap" : secDated) ? "excluded" : "included";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Amounts are whole units; some providers round a leg to the thousand. */
|
|
38
|
+
const close = (a: number, b: number) => Math.abs(a - b) <= Math.max(1_000, Math.abs(a) * 1e-6);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A balance sheet's total debt and lease liabilities. A total that already holds the lease line (long-term and
|
|
42
|
+
* current debt plus leases) is read as the borrowings beside it, so the leases count once. Undefined without both
|
|
43
|
+
* lines: a missing lease line is never zero.
|
|
44
|
+
*/
|
|
45
|
+
export function debtAndLeases(
|
|
46
|
+
statement: Pick<FinancialStatement, "totalDebt" | "capitalLeaseObligations" | "longTermDebt" | "currentDebt">,
|
|
47
|
+
): { totalDebt: number; leases: number } | undefined {
|
|
48
|
+
const total = statement.totalDebt;
|
|
49
|
+
const leases = statement.capitalLeaseObligations;
|
|
50
|
+
if (!finite(total) || !finite(leases) || leases < 0) return undefined;
|
|
51
|
+
const { longTermDebt: long, currentDebt: current } = statement;
|
|
52
|
+
const holds = leases > 0 && (finite(long) || finite(current)) && close(total, (long ?? 0) + (current ?? 0) + leases);
|
|
53
|
+
return { totalDebt: holds ? total - leases : total, leases };
|
|
54
|
+
}
|