gloomberb 0.16.2 → 0.16.3
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 +1 -1
- package/src/brokers/account-cache.ts +45 -0
- package/src/cli/command-options.ts +74 -0
- package/src/cli/commands/command-utils.ts +29 -0
- package/src/cli/commands/market.ts +170 -54
- package/src/cli/commands/overview.ts +62 -19
- package/src/cli/commands/ticker.ts +138 -43
- package/src/cli/desktop-pane-shot.ts +3 -0
- package/src/cli/help.ts +26 -6
- package/src/cli/history-rows.ts +187 -15
- package/src/cli/index.ts +12 -7
- package/src/cli/options.ts +5 -1
- package/src/cli/pane-functions/capabilities.ts +5 -0
- package/src/cli/pane-functions/dom.ts +69 -16
- package/src/cli/pane-functions/function-help.ts +4 -2
- package/src/cli/pane-functions/headless.ts +163 -31
- package/src/cli/pane-functions/index.ts +9 -2
- package/src/cli/pane-functions/report.ts +8 -1
- package/src/cli/pane-functions/resolver.ts +28 -2
- package/src/cli/pane-functions/screenshot.ts +4 -0
- package/src/cli/registry.ts +6 -3
- package/src/cli/report-tables.ts +460 -0
- package/src/cli/result.ts +13 -3
- package/src/components/data-table/export.ts +6 -51
- package/src/components/data-table/view.tsx +3 -1
- package/src/components/layout/shell/active-drag.ts +67 -35
- package/src/components/layout/shell/cursor-occlusion.ts +6 -5
- package/src/components/layout/shell/drag/index.ts +12 -7
- package/src/components/layout/shell/drag/live.ts +122 -0
- package/src/components/layout/shell/drag/overlays.tsx +179 -35
- package/src/components/layout/shell/drag/runtime.ts +62 -50
- package/src/components/layout/shell/drag/slide.ts +10 -0
- package/src/components/layout/shell/index.tsx +37 -51
- package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
- package/src/components/layout/shell/native/surfaces.ts +30 -14
- package/src/components/layout/shell/pane/layers.tsx +419 -179
- package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
- package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
- package/src/data/json-persistence.ts +17 -3
- package/src/data/json-storage.ts +2 -0
- package/src/data/memory-resource-store.ts +46 -0
- package/src/market-data/market/freshness.ts +45 -0
- package/src/market-data/quotes/freshness.ts +5 -0
- package/src/plugins/builtin/analytics/pane-model.ts +10 -6
- package/src/plugins/builtin/attention/pane.tsx +1 -1
- package/src/plugins/builtin/catalysts/detail.tsx +1 -1
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/holders/client.ts +3 -6
- package/src/plugins/builtin/holders/format.ts +31 -1
- package/src/plugins/builtin/holders/headless.ts +26 -5
- package/src/plugins/builtin/holders/pane.tsx +22 -13
- package/src/plugins/builtin/holders/report-header.ts +46 -5
- package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
- package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
- package/src/plugins/builtin/members/pane.tsx +10 -0
- package/src/plugins/builtin/perps/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
- package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
- package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
- package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
- package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
- package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
- package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
- package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
- package/src/plugins/builtin/shared/members-menu.tsx +260 -0
- package/src/plugins/builtin/short-volume/pane.tsx +1 -1
- package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
- package/src/plugins/builtin/themes/pane.tsx +3 -0
- package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
- package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
- package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
- package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
- package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
- package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
- package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
- package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
- package/src/plugins/builtin/world-venue-map/index.ts +5 -5
- package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
- package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
- package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
- package/src/plugins/builtin/world-venue-map/model.ts +16 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
- package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
- package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
- package/src/renderers/browser/app-services.ts +3 -1
- package/src/renderers/dom/data-table/dom.ts +31 -0
- package/src/renderers/dom/data-table/index.tsx +5 -0
- package/src/renderers/dom/host/box.tsx +113 -17
- package/src/renderers/dom/host/drag-shield.ts +24 -0
- package/src/renderers/dom/host/live-frame.ts +54 -0
- package/src/renderers/dom/host/mouse.ts +8 -1
- package/src/renderers/dom/host/scroll-box.tsx +6 -5
- package/src/renderers/dom/host/tabs.tsx +3 -2
- package/src/renderers/dom/styles.css +13 -5
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
- package/src/renderers/electrobun/shared/protocol.ts +10 -1
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
- package/src/renderers/electrobun/view/host/native.ts +0 -10
- package/src/renderers/electrobun/view/host/window-move.ts +28 -0
- package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
- package/src/renderers/electrobun/view/ui-host.tsx +2 -5
- package/src/renderers/opentui/ui-host.tsx +7 -1
- package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
- package/src/sources/provider-router/batches.ts +3 -3
- package/src/sources/provider-router/financial-routes.ts +2 -2
- package/src/sources/provider-router/financials.ts +70 -7
- package/src/sources/provider-router/primary.ts +2 -2
- package/src/state/pane-scroll-registry.tsx +67 -35
- package/src/test-support/margin-account.ts +108 -0
- package/src/time-series/resolution.ts +15 -0
- package/src/types/financials.ts +32 -3
- package/src/ui/host.tsx +12 -0
- package/src/ui/index.tsx +8 -3
- package/src/ui/scroll-box-axis.ts +26 -0
- package/src/utils/currency-units.ts +8 -3
- package/src/utils/depositary-receipt.ts +54 -10
- package/src/utils/display-number.ts +63 -0
- package/src/utils/fundamentals.ts +17 -1
- package/src/utils/price-history-integrity.ts +19 -6
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports as flat tables, for `--csv` and `--ndjson`: the columns the text
|
|
3
|
+
* view shows, one row per row it shows, and values a spreadsheet can use.
|
|
4
|
+
* Numbers drop the thousands commas, compact scales and units the text draws
|
|
5
|
+
* (the unit moves into the header, as table CSV exports do), times are ISO,
|
|
6
|
+
* and nothing is a JSON blob. CSV writes a report with several tables as
|
|
7
|
+
* stacked blocks under `# section:` lines and ends with `#` lines for the
|
|
8
|
+
* source, as-of, notes and errors.
|
|
9
|
+
*/
|
|
10
|
+
import { serializeCsv } from "../utils/csv";
|
|
11
|
+
import {
|
|
12
|
+
BLANK_DISPLAY,
|
|
13
|
+
DISPLAY_PLACEHOLDER,
|
|
14
|
+
labelWithUnit,
|
|
15
|
+
parseDisplayNumber,
|
|
16
|
+
type DisplayNumber,
|
|
17
|
+
} from "../utils/display-number";
|
|
18
|
+
import { isEpochMilliseconds, parseReportTime } from "../utils/utc-time";
|
|
19
|
+
import { formatFreshnessLine, type ReportFreshness } from "./pane-functions/freshness";
|
|
20
|
+
|
|
21
|
+
type CliReportValue = string | number;
|
|
22
|
+
|
|
23
|
+
/** One table of a report, as CSV and NDJSON write it. */
|
|
24
|
+
export interface CliReportTable {
|
|
25
|
+
/** The section title; CSV names it in a `# section:` line when it writes several tables. */
|
|
26
|
+
title: string;
|
|
27
|
+
/** The column names the text view shows, with the unit the cells dropped. */
|
|
28
|
+
columns: string[];
|
|
29
|
+
/** Numbers, ISO times and text; an empty string for a missing value. */
|
|
30
|
+
rows: CliReportValue[][];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CliReportTables {
|
|
34
|
+
tables: CliReportTable[];
|
|
35
|
+
/** What CSV writes after the data as `#` lines: source and as-of, completeness, errors, notes. */
|
|
36
|
+
footer: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A column as a report declares it: the text view draws `format(value, row)`, or the value itself. */
|
|
40
|
+
export interface CliReportColumn<Row = Record<string, unknown>> {
|
|
41
|
+
key: string;
|
|
42
|
+
/** The header as the text view prints it. */
|
|
43
|
+
header: string;
|
|
44
|
+
align?: "left" | "right" | "center";
|
|
45
|
+
format?: (value: unknown, row: Row) => string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A label/value line of a report. `unit` names the unit of a numeric `value` when `formatted` cannot say it. */
|
|
49
|
+
export interface CliReportEntry {
|
|
50
|
+
label: string;
|
|
51
|
+
value: unknown;
|
|
52
|
+
formatted?: string;
|
|
53
|
+
key?: string;
|
|
54
|
+
unit?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Cell =
|
|
58
|
+
| { kind: "blank" }
|
|
59
|
+
| { kind: "text"; text: string; time?: boolean }
|
|
60
|
+
| { kind: "number"; value: number; unit: string }
|
|
61
|
+
/** Text that may read as a number, once the whole column is known. */
|
|
62
|
+
| { kind: "textual"; text: string };
|
|
63
|
+
|
|
64
|
+
type ResolvedCell = Exclude<Cell, { kind: "textual" }>;
|
|
65
|
+
|
|
66
|
+
interface ResolvedColumn {
|
|
67
|
+
header: string;
|
|
68
|
+
cells: ResolvedCell[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const BLANK = { kind: "blank" } as const;
|
|
72
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
73
|
+
// Keys whose epoch-millisecond numbers are times rather than counts or prices.
|
|
74
|
+
const TIME_KEY = /(?:At|Time|Timestamp|Updated|AsOf|Date)$|^(?:asOf|date|time|timestamp|updated)$/;
|
|
75
|
+
// A value drawn with a change beside it, "416.2 +6.4%" or "1.20 (3%)": the value is the first figure.
|
|
76
|
+
const CHANGE_NOTE = /^\(?[+\-\u2212]?[\d.,]+%?\)?$/;
|
|
77
|
+
/**
|
|
78
|
+
* How a value relates to the number drawn for it: as is, as a percent of a
|
|
79
|
+
* fraction, or in thousands, millions or billions named by the header.
|
|
80
|
+
*/
|
|
81
|
+
const DISPLAY_SCALES = [1, 100, 0.01, 1e-3, 1e-6, 1e-9, 1e-12, 1e3, 1e6, 1e4];
|
|
82
|
+
// "3M" is as often a tenor as three million.
|
|
83
|
+
const BARE_SCALED = /^[+-]?\d+[kKMBT]$/;
|
|
84
|
+
// Beside "1Y" or "6W", "3M" is three months.
|
|
85
|
+
const TENOR = /^\d+\s*[DWQY]$/i;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Some feeds store prices as float32, so 338.93 arrives as 338.929992676. A value
|
|
89
|
+
* that is exactly a float32 reads as the shortest decimal that rounds back to it,
|
|
90
|
+
* which leaves genuine doubles (never exactly float32 unless short) untouched.
|
|
91
|
+
*/
|
|
92
|
+
export function float32Shortest(value: number): number | null {
|
|
93
|
+
if (Math.fround(value) !== value) return null;
|
|
94
|
+
for (let digits = 1; digits <= 9; digits += 1) {
|
|
95
|
+
const candidate = Number(value.toPrecision(digits));
|
|
96
|
+
if (Math.fround(candidate) === value) return candidate;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A number without binary floating-point noise (47.350000000000364, 0.0615 * 100),
|
|
103
|
+
* as the text view drops it: twelve significant digits, whole numbers and
|
|
104
|
+
* amounts of a billion or more as they are.
|
|
105
|
+
*/
|
|
106
|
+
export function exportNumber(value: number): number {
|
|
107
|
+
if (Number.isInteger(value) || Math.abs(value) >= 1e9) return Object.is(value, -0) ? 0 : value;
|
|
108
|
+
const rounded = float32Shortest(value) ?? Number(value.toPrecision(12));
|
|
109
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isoInstant(time: number): string {
|
|
113
|
+
return new Date(time).toISOString().replace(".000Z", "Z");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function text(value: string): Cell {
|
|
117
|
+
return { kind: "text", text: value };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function time(value: string): Cell {
|
|
121
|
+
return { kind: "text", text: value, time: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A drawn number read back. Leading zeros make a code ("0700", "000001"), not a number. */
|
|
125
|
+
function readDisplay(value: string): DisplayNumber | typeof BLANK_DISPLAY | null {
|
|
126
|
+
const trimmed = value.trim();
|
|
127
|
+
if (/^[+-]?0\d/.test(trimmed)) return null;
|
|
128
|
+
return parseDisplayNumber(trimmed);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The value in the units the text drew, so a fraction drawn as 6.15% exports 6.15, or null when it is not what was drawn. */
|
|
132
|
+
function valueAsDrawn(value: number, drawn: DisplayNumber): number | null {
|
|
133
|
+
const target = Number(drawn.number);
|
|
134
|
+
const tolerance = drawn.step * (1 + 1e-9) + Number.EPSILON;
|
|
135
|
+
for (const scale of DISPLAY_SCALES) {
|
|
136
|
+
const scaled = value * scale;
|
|
137
|
+
if (Math.abs(scaled - target) <= tolerance) return exportNumber(scaled);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function timeCell(instant: number, dateOnly: boolean, shown: string | null, raw?: string): Cell {
|
|
143
|
+
if (!Number.isFinite(instant)) return shown ? { kind: "textual", text: shown } : BLANK;
|
|
144
|
+
// A date the text drew from an instant (a monthly bar) stays the date it shows.
|
|
145
|
+
if (shown != null && DATE_ONLY.test(shown)) return time(shown);
|
|
146
|
+
if (raw != null && shown == null) return time(raw);
|
|
147
|
+
return time(dateOnly ? new Date(instant).toISOString().slice(0, 10) : isoInstant(instant));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function numberCell(value: number, shown: string | null, key: string | undefined): Cell {
|
|
151
|
+
if (!Number.isFinite(value)) return shown ? { kind: "textual", text: shown } : BLANK;
|
|
152
|
+
if (key != null && TIME_KEY.test(key) && isEpochMilliseconds(value)) return timeCell(value, false, shown);
|
|
153
|
+
if (shown == null) return { kind: "number", value: exportNumber(value), unit: "" };
|
|
154
|
+
const drawn = readDisplay(shown);
|
|
155
|
+
if (drawn === BLANK_DISPLAY) return BLANK;
|
|
156
|
+
// "3M" drawn from 3 is a tenor or a code, not three million.
|
|
157
|
+
if (drawn && BARE_SCALED.test(shown) && value === Number(shown.replace(/[kKMBT]$/, ""))) return text(shown);
|
|
158
|
+
if (drawn) return { kind: "number", value: valueAsDrawn(value, drawn) ?? Number(drawn.number), unit: drawn.unit };
|
|
159
|
+
// A time drawn as a date or a clock.
|
|
160
|
+
if (isEpochMilliseconds(value)) return timeCell(value, false, shown);
|
|
161
|
+
const [first, ...rest] = shown.split(/\s+/);
|
|
162
|
+
const lead = rest.length > 0 && CHANGE_NOTE.test(rest.join(" ")) ? readDisplay(first!) : null;
|
|
163
|
+
const scaled = lead && lead !== BLANK_DISPLAY ? valueAsDrawn(value, lead) : null;
|
|
164
|
+
if (lead && lead !== BLANK_DISPLAY && scaled != null) return { kind: "number", value: scaled, unit: lead.unit };
|
|
165
|
+
return text(shown);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Text for a nested value, never JSON: `a, b` for a list, `Key: value` pairs for a record. */
|
|
169
|
+
function flattenValue(value: unknown, depth = 0): string {
|
|
170
|
+
if (value == null) return "";
|
|
171
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? "" : isoInstant(value.getTime());
|
|
172
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(exportNumber(value)) : "";
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
return value.map((item) => flattenValue(item, depth + 1)).filter(Boolean).join(depth === 0 ? "; " : ", ");
|
|
175
|
+
}
|
|
176
|
+
if (typeof value === "object") {
|
|
177
|
+
return Object.entries(value)
|
|
178
|
+
.map(([key, entry]) => [key, flattenValue(entry, depth + 1)] as const)
|
|
179
|
+
.filter(([, entry]) => entry !== "")
|
|
180
|
+
.map(([key, entry]) => `${key}: ${entry}`)
|
|
181
|
+
.join(", ");
|
|
182
|
+
}
|
|
183
|
+
return String(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** JSON text a row or a format handed over, as the flat text of what it holds; null for any other text. */
|
|
187
|
+
function flattenJsonText(value: string): string | null {
|
|
188
|
+
if (!/^\s*[{[]/.test(value)) return null;
|
|
189
|
+
try {
|
|
190
|
+
const parsed: unknown = JSON.parse(value);
|
|
191
|
+
return parsed != null && typeof parsed === "object" ? flattenValue(parsed) : null;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A cell from its value and, when the column formats it, the text it draws. */
|
|
198
|
+
function exportCell(raw: unknown, display: string | null, key?: string): Cell {
|
|
199
|
+
const shown = display?.trim() ?? null;
|
|
200
|
+
if (shown != null && (shown === "" || DISPLAY_PLACEHOLDER.test(shown))) return BLANK;
|
|
201
|
+
const json = flattenJsonText(shown ?? (typeof raw === "string" ? raw : ""));
|
|
202
|
+
if (json != null) return json ? text(json) : BLANK;
|
|
203
|
+
if (raw instanceof Date) return timeCell(raw.getTime(), false, shown);
|
|
204
|
+
if (typeof raw === "number") return numberCell(raw, shown, key);
|
|
205
|
+
if (typeof raw === "string") {
|
|
206
|
+
const value = raw.trim();
|
|
207
|
+
const reportTime = parseReportTime(value);
|
|
208
|
+
if (reportTime) return timeCell(reportTime.time, reportTime.dateOnly, shown, value);
|
|
209
|
+
const drawn = shown ?? value;
|
|
210
|
+
return drawn === "" || DISPLAY_PLACEHOLDER.test(drawn) ? BLANK : { kind: "textual", text: drawn };
|
|
211
|
+
}
|
|
212
|
+
if (typeof raw === "boolean") return text(shown ?? String(raw));
|
|
213
|
+
if (raw == null) return shown == null ? BLANK : { kind: "textual", text: shown };
|
|
214
|
+
if (shown != null) return { kind: "textual", text: shown };
|
|
215
|
+
const flat = flattenValue(raw);
|
|
216
|
+
return flat ? text(flat) : BLANK;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Text that reads as a number becomes one; text that does not, such as a
|
|
221
|
+
* range "3.75-4.00%" or "N/M", stays as drawn. A bare scaled count ("-106B")
|
|
222
|
+
* becomes a number only beside other figures, never in a column of tenors
|
|
223
|
+
* ("3M, 6M, 1Y"). Identifier columns keep their text.
|
|
224
|
+
*/
|
|
225
|
+
function resolveColumn(header: string, cells: Cell[], parseText: boolean): ResolvedColumn {
|
|
226
|
+
const textual = cells.flatMap((cell) => cell.kind === "textual" ? [cell] : []);
|
|
227
|
+
const read = parseText ? textual.map((cell) => readDisplay(cell.text)) : [];
|
|
228
|
+
const numeric = read.filter((entry): entry is DisplayNumber => entry != null && entry !== BLANK_DISPLAY);
|
|
229
|
+
const bare = textual.filter((cell, index) => read[index] != null && read[index] !== BLANK_DISPLAY && BARE_SCALED.test(cell.text.trim()));
|
|
230
|
+
const otherFigures = numeric.length > bare.length || cells.some((cell) => cell.kind === "number");
|
|
231
|
+
const convertBare = otherFigures && !textual.some((cell) => TENOR.test(cell.text.trim()));
|
|
232
|
+
let textualIndex = 0;
|
|
233
|
+
return {
|
|
234
|
+
header,
|
|
235
|
+
cells: cells.map((cell): ResolvedCell => {
|
|
236
|
+
if (cell.kind !== "textual") return cell;
|
|
237
|
+
const entry = parseText ? read[textualIndex] : null;
|
|
238
|
+
textualIndex += 1;
|
|
239
|
+
if (entry === BLANK_DISPLAY) return BLANK;
|
|
240
|
+
if (entry == null || (!convertBare && BARE_SCALED.test(cell.text.trim()))) return { kind: "text", text: cell.text };
|
|
241
|
+
return { kind: "number", value: Number(entry.number), unit: entry.unit };
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function numberUnits(cells: readonly ResolvedCell[]): Set<string> {
|
|
247
|
+
return new Set(cells.flatMap((cell) => cell.kind === "number" ? [cell.unit] : []));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cellValue(cell: ResolvedCell): CliReportValue {
|
|
251
|
+
return cell.kind === "blank" ? "" : cell.kind === "number" ? cell.value : cell.text;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const RANGE = /^[+-]?\d[\d,]*(?:\.\d+)?\s*[-\u2013]\s*[+-]?\d[\d,]*(?:\.\d+)?$/;
|
|
255
|
+
|
|
256
|
+
/** A range drawn in the unit the header now names ("3.75-4.00%" under "Rate (%)") drops it too. */
|
|
257
|
+
function withoutUnit(cell: ResolvedCell, unit: string): CliReportValue {
|
|
258
|
+
if (!unit || cell.kind !== "text") return cellValue(cell);
|
|
259
|
+
const drawn = cell.text.trim();
|
|
260
|
+
const bare = drawn.endsWith(unit) ? drawn.slice(0, -unit.length).trim()
|
|
261
|
+
: drawn.startsWith(unit) ? drawn.slice(unit.length).trim()
|
|
262
|
+
: null;
|
|
263
|
+
return bare != null && RANGE.test(bare) ? bare.replace(/,/g, "") : cell.text;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The first column of a table of metrics, one per row: `Metric, 2025, 2024` or `Indicator, Latest, Previous`.
|
|
267
|
+
const METRIC_LABEL = /^(?:metric|indicator|ratio|measure|statistic|line item)s?$/i;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* In a table of metrics, the columns that mix units keep one per row, and the
|
|
271
|
+
* row's label names it. A symbol or a date in the first column is not a label.
|
|
272
|
+
*/
|
|
273
|
+
function rowUnits(columns: readonly ResolvedColumn[], mixed: readonly boolean[], rowCount: number): string[] | null {
|
|
274
|
+
const label = columns[0];
|
|
275
|
+
if (!label || mixed[0] || !METRIC_LABEL.test(label.header.trim())) return null;
|
|
276
|
+
if (!label.cells.some((cell) => cell.kind === "text")) return null;
|
|
277
|
+
if (label.cells.some((cell) => cell.kind === "number" || (cell.kind === "text" && cell.time))) return null;
|
|
278
|
+
const units: string[] = [];
|
|
279
|
+
for (let row = 0; row < rowCount; row += 1) {
|
|
280
|
+
const unitsInRow = numberUnits(columns.flatMap((column, index) => mixed[index] ? [column.cells[row] ?? BLANK] : []));
|
|
281
|
+
if (unitsInRow.size > 1) return null;
|
|
282
|
+
units.push([...unitsInRow][0] ?? "");
|
|
283
|
+
}
|
|
284
|
+
return units;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Numbers are bare, so their unit goes where a spreadsheet reader looks for
|
|
289
|
+
* it: the header when the column has one unit, the row's label in a table of
|
|
290
|
+
* metrics whose rows each have one, and otherwise a `<header> unit` column
|
|
291
|
+
* beside a column that mixes them (a rate in % and a spread in bp).
|
|
292
|
+
*/
|
|
293
|
+
function assembleTable(title: string, columns: readonly ResolvedColumn[], rowCount: number): CliReportTable {
|
|
294
|
+
const units = columns.map((column) => numberUnits(column.cells));
|
|
295
|
+
const mixed = units.map((columnUnits) => columnUnits.size > 1);
|
|
296
|
+
const byRow = mixed.some(Boolean) ? rowUnits(columns, mixed, rowCount) : null;
|
|
297
|
+
const layout = columns.flatMap((column, index) => {
|
|
298
|
+
const columnUnits = units[index]!;
|
|
299
|
+
const cell = (row: number) => cellValue(column.cells[row] ?? BLANK);
|
|
300
|
+
if (index === 0 && byRow) {
|
|
301
|
+
return [{ header: column.header, value: (row: number) => byRow[row] ? labelWithUnit(String(cell(row)), byRow[row]!).trim() : cell(row) }];
|
|
302
|
+
}
|
|
303
|
+
if (!mixed[index]) {
|
|
304
|
+
const unit = [...columnUnits][0] ?? "";
|
|
305
|
+
return [{ header: labelWithUnit(column.header, unit), value: (row: number) => withoutUnit(column.cells[row] ?? BLANK, unit) }];
|
|
306
|
+
}
|
|
307
|
+
if (byRow) return [{ header: column.header, value: cell }];
|
|
308
|
+
return [
|
|
309
|
+
{ header: column.header, value: cell },
|
|
310
|
+
{
|
|
311
|
+
header: `${column.header} unit`,
|
|
312
|
+
value: (row: number) => {
|
|
313
|
+
const resolved = column.cells[row];
|
|
314
|
+
return resolved?.kind === "number" ? resolved.unit : "";
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
title,
|
|
321
|
+
columns: layout.map((column) => column.header),
|
|
322
|
+
rows: Array.from({ length: rowCount }, (_, row) => layout.map((column) => column.value(row))),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Codes a spreadsheet must not turn into numbers, even when they are digits.
|
|
327
|
+
const IDENTIFIER_KEY = /^(?:id|cik|code|cusip|figi|isin|symbol|ticker)$|(?:Id|Cik|Code|Cusip|Figi|Isin|Symbol|Ticker)$/;
|
|
328
|
+
|
|
329
|
+
/** A table of rows under the columns the text view draws. */
|
|
330
|
+
export function exportRowsTable<Row extends Record<string, unknown>>(
|
|
331
|
+
title: string,
|
|
332
|
+
columns: readonly CliReportColumn<Row>[],
|
|
333
|
+
rows: readonly Row[],
|
|
334
|
+
): CliReportTable {
|
|
335
|
+
return assembleTable(title, columns.map((column) => resolveColumn(
|
|
336
|
+
column.header,
|
|
337
|
+
rows.map((row) => {
|
|
338
|
+
const value = (row as Record<string, unknown> | undefined)?.[column.key];
|
|
339
|
+
return exportCell(value, column.format ? column.format(value, row) : null, column.key);
|
|
340
|
+
}),
|
|
341
|
+
!IDENTIFIER_KEY.test(column.key),
|
|
342
|
+
)), rows.length);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Label/value lines as `Metric,Value`; a number's unit moves into its label. */
|
|
346
|
+
export function exportEntriesTable(title: string, entries: readonly CliReportEntry[]): CliReportTable {
|
|
347
|
+
return {
|
|
348
|
+
title,
|
|
349
|
+
columns: ["Metric", "Value"],
|
|
350
|
+
rows: entries.map((entry) => {
|
|
351
|
+
if (entry.unit != null && typeof entry.value === "number" && Number.isFinite(entry.value)) {
|
|
352
|
+
return [labelWithUnit(entry.label, entry.unit), exportNumber(entry.value)];
|
|
353
|
+
}
|
|
354
|
+
const [cell] = resolveColumn(entry.label, [exportCell(entry.value, entry.formatted ?? null, entry.key)], true).cells;
|
|
355
|
+
return cell?.kind === "number"
|
|
356
|
+
? [labelWithUnit(entry.label, cell.unit), cell.value]
|
|
357
|
+
: [entry.label, cellValue(cell ?? BLANK)];
|
|
358
|
+
}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** A table known only by the text it shows, such as a rendered pane; `instant` is the time behind a shortened one. */
|
|
363
|
+
export function exportTextTable(
|
|
364
|
+
title: string,
|
|
365
|
+
headers: readonly string[],
|
|
366
|
+
rows: ReadonlyArray<ReadonlyArray<{ text: string; instant?: string } | undefined>>,
|
|
367
|
+
): CliReportTable {
|
|
368
|
+
return assembleTable(title, headers.map((header, index) => resolveColumn(
|
|
369
|
+
header,
|
|
370
|
+
rows.map((row) => {
|
|
371
|
+
const cell = row[index];
|
|
372
|
+
if (!cell) return BLANK;
|
|
373
|
+
return cell.instant ? exportCell(cell.instant, null) : exportCell(null, cell.text);
|
|
374
|
+
}),
|
|
375
|
+
true,
|
|
376
|
+
)), rows.length);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The closing `#` lines: the text view's source and as-of line, then whatever kept the report from being whole. */
|
|
380
|
+
export function reportFooterLines(input: {
|
|
381
|
+
freshness?: ReportFreshness;
|
|
382
|
+
/** `true`, or what is missing, such as "19 of 20 available". */
|
|
383
|
+
incomplete?: boolean | string;
|
|
384
|
+
errors?: readonly string[];
|
|
385
|
+
notes?: readonly string[];
|
|
386
|
+
}): string[] {
|
|
387
|
+
return [
|
|
388
|
+
...(input.freshness ? [formatFreshnessLine(input.freshness)] : []),
|
|
389
|
+
...(input.incomplete ? [typeof input.incomplete === "string" ? `incomplete: ${input.incomplete}` : "incomplete"] : []),
|
|
390
|
+
...(input.errors ?? []).map((error) => `error: ${error}`),
|
|
391
|
+
...(input.notes ?? []).map((note) => `note: ${note}`),
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function sectionList(tables: readonly CliReportTable[]): string {
|
|
396
|
+
const titles = tables.map((table) => table.title);
|
|
397
|
+
return tables.length === 1
|
|
398
|
+
? `${titles[0]} (or 1)`
|
|
399
|
+
: `${titles.join(", ")}, or a number from 1 to ${tables.length}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `--section`: one table, by its title (any case) or its 1-based position.
|
|
404
|
+
* Without a selector the report keeps every table.
|
|
405
|
+
*/
|
|
406
|
+
export function selectReportTables(report: CliReportTables, selector: string | true | undefined): CliReportTables {
|
|
407
|
+
if (selector === undefined) return report;
|
|
408
|
+
if (selector === true || !selector.trim()) throw new Error("--section needs a section title or number.");
|
|
409
|
+
const wanted = selector.trim();
|
|
410
|
+
if (report.tables.length === 0) throw new Error(`No section "${wanted}": this report has no tables.`);
|
|
411
|
+
const index = /^\d+$/.test(wanted)
|
|
412
|
+
? Number(wanted) - 1
|
|
413
|
+
: report.tables.findIndex((table) => table.title.trim().toLowerCase() === wanted.toLowerCase());
|
|
414
|
+
const table = report.tables[index];
|
|
415
|
+
if (!table) throw new Error(`No section "${wanted}". Sections: ${sectionList(report.tables)}.`);
|
|
416
|
+
return { ...report, tables: [table] };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function commentLine(value: string): string {
|
|
420
|
+
return `# ${value.replace(/\s*[\r\n]+\s*/g, " ").trim()}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A row whose first cell starts with `#` (a rank column) quotes it, so no reader takes the row for a comment. */
|
|
424
|
+
function csvLine(row: readonly unknown[]): string {
|
|
425
|
+
const line = serializeCsv([row]);
|
|
426
|
+
if (!line.startsWith("#")) return line;
|
|
427
|
+
const first = String(row[0]);
|
|
428
|
+
return `"${first}"${line.slice(first.length)}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One table writes as a plain CSV; several write as blocks, each under a
|
|
433
|
+
* `# section: <title>` line, with a blank line between. The footer follows
|
|
434
|
+
* the data as `#` lines after a blank line.
|
|
435
|
+
*/
|
|
436
|
+
export function renderReportCsv(report: CliReportTables): string {
|
|
437
|
+
const marked = report.tables.length > 1;
|
|
438
|
+
const blocks = report.tables.map((table) => [
|
|
439
|
+
...(marked ? [commentLine(`section: ${table.title}`)] : []),
|
|
440
|
+
...(table.columns.length > 0 ? [[table.columns, ...table.rows].map(csvLine).join("\n")] : []),
|
|
441
|
+
].join("\n"));
|
|
442
|
+
const footer = report.footer.map(commentLine).join("\n");
|
|
443
|
+
return [blocks.join("\n\n"), footer].filter(Boolean).join("\n\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** One JSON object per row, keyed by column; rows of a report with several tables name their section. */
|
|
447
|
+
export function renderReportNdjson(report: CliReportTables): string {
|
|
448
|
+
const tagged = report.tables.length > 1;
|
|
449
|
+
return report.tables.flatMap((table) => {
|
|
450
|
+
const sectionKey = table.columns.some((column) => column.toLowerCase() === "section") ? "_section" : "section";
|
|
451
|
+
return table.rows.map((row) => {
|
|
452
|
+
const object: Record<string, CliReportValue | null> = tagged ? { [sectionKey]: table.title } : {};
|
|
453
|
+
table.columns.forEach((column, index) => {
|
|
454
|
+
const value = row[index];
|
|
455
|
+
object[column] = value === undefined || value === "" ? null : value;
|
|
456
|
+
});
|
|
457
|
+
return JSON.stringify(object);
|
|
458
|
+
});
|
|
459
|
+
}).join("\n");
|
|
460
|
+
}
|
package/src/cli/result.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { serializeCsv } from "../utils/csv";
|
|
14
14
|
import { formatUtcTime, isEpochMilliseconds, isZonedIsoDateTime } from "../utils/utc-time";
|
|
15
15
|
import { formatFreshnessLine, type ReportFreshness } from "./pane-functions/freshness";
|
|
16
|
+
import { renderReportCsv, renderReportNdjson, type CliReportTables } from "./report-tables";
|
|
16
17
|
|
|
17
18
|
export interface CliResult<T = unknown> {
|
|
18
19
|
data: T;
|
|
@@ -21,7 +22,8 @@ export interface CliResult<T = unknown> {
|
|
|
21
22
|
/**
|
|
22
23
|
* Source, as-of and status of market data, as every `fn` report states them
|
|
23
24
|
* (docs/usage.md#how-current-a-report-is): the last line of the text output
|
|
24
|
-
* and `metadata.freshness` in JSON. CSV and NDJSON stay rows only
|
|
25
|
+
* and `metadata.freshness` in JSON. CSV and NDJSON stay rows only, unless
|
|
26
|
+
* the command passes `tables`, whose footer carries it.
|
|
25
27
|
*/
|
|
26
28
|
freshness?: ReportFreshness;
|
|
27
29
|
}
|
|
@@ -57,6 +59,11 @@ export interface CliResultRenderOptions<T = unknown, Row = Record<string, unknow
|
|
|
57
59
|
summary?: (data: T) => string;
|
|
58
60
|
/** Text-mode line printed first, naming what the result is about, such as the listing a symbol resolved to. */
|
|
59
61
|
heading?: string;
|
|
62
|
+
/**
|
|
63
|
+
* What CSV and NDJSON write instead of the rows: a report's tables as the
|
|
64
|
+
* text view shows them, CSV with its `# section:` and closing `#` lines.
|
|
65
|
+
*/
|
|
66
|
+
tables?: CliReportTables;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
interface CliResultJsonEnvelope<T> extends Omit<CliResult<T>, "freshness"> {
|
|
@@ -333,6 +340,8 @@ function serializeCliRows<T, Row extends Record<string, unknown>>(
|
|
|
333
340
|
options: CliGlobalOptions,
|
|
334
341
|
renderOptions: CliResultRenderOptions<T, Row>,
|
|
335
342
|
): string {
|
|
343
|
+
if (renderOptions.tables && options.format === "csv") return renderReportCsv(renderOptions.tables);
|
|
344
|
+
if (renderOptions.tables && options.format === "ndjson") return renderReportNdjson(renderOptions.tables);
|
|
336
345
|
const rows = asRows(result.data, options.limit, renderOptions.rows);
|
|
337
346
|
if (options.format === "ndjson") {
|
|
338
347
|
return rows.map((row) => JSON.stringify(row)).join("\n");
|
|
@@ -379,8 +388,9 @@ export function printCliResult<T, Row extends Record<string, unknown> = Record<s
|
|
|
379
388
|
// Bun's console writer can truncate a large pipe write after stdout has been
|
|
380
389
|
// initialized. The stream queues the remaining bytes until the reader drains.
|
|
381
390
|
if (output) process.stdout.write(`${output}\n`);
|
|
382
|
-
//
|
|
383
|
-
|
|
391
|
+
// JSON carries warnings in its envelope. Text, CSV and NDJSON are only the
|
|
392
|
+
// rows, so a warning goes to stderr, out of a file the rows are piped into.
|
|
393
|
+
if (options.format !== "json" && !options.quiet) {
|
|
384
394
|
for (const warning of result.warnings ?? []) {
|
|
385
395
|
console.error(`${cliStyles.warning("warning:")} ${warning}`);
|
|
386
396
|
}
|
|
@@ -1,56 +1,11 @@
|
|
|
1
1
|
import type { DataTableCell, DataTableColumn, DataTableProps } from "../ui";
|
|
2
2
|
import { serializeCsv } from "../../utils/csv";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* decimals, a compact scale (12.3k, 1.20B) and a unit (%, x, bp, USD).
|
|
10
|
-
*/
|
|
11
|
-
const DISPLAY_NUMBER = /^([+-]?)([A-Z]{0,3}[$\u20ac\u00a3\u00a5\u20b9\u20a9\u20bd\u20ba\u20aa\u20ab\u0e3f])?(?=\.?\d)(\d{1,3}(?:,\d{3})+|\d*)(?:\.(\d+))?([kKMBT])?(%|x| ?bps?| [A-Z]{3})?$/;
|
|
12
|
-
|
|
13
|
-
const SCALE_DIGITS: Record<string, number> = { k: 3, K: 3, M: 6, B: 9, T: 12 };
|
|
14
|
-
|
|
15
|
-
interface DisplayNumber {
|
|
16
|
-
/** Plain decimal text: no sign for positives, no commas, no scale letter. */
|
|
17
|
-
number: string;
|
|
18
|
-
/** What the column header has to name once the cells are bare numbers. */
|
|
19
|
-
unit: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const BLANK = "blank";
|
|
23
|
-
|
|
24
|
-
/** Moves the decimal point right without going through a float, so 1.23B is exactly 1230000000. */
|
|
25
|
-
function scaleDecimal(integer: string, fraction: string, digits: number): string {
|
|
26
|
-
const whole = `${integer}${fraction.padEnd(digits, "0").slice(0, digits)}`.replace(/^0+(?=\d)/, "") || "0";
|
|
27
|
-
const rest = fraction.slice(digits);
|
|
28
|
-
return rest ? `${whole}.${rest}` : whole;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function parseDisplayNumber(text: string): DisplayNumber | typeof BLANK | null {
|
|
32
|
-
// A direction arrow beside a signed move repeats the sign.
|
|
33
|
-
const trimmed = text.trim().replace(/\u2212/g, "-").replace(/\s*[\u2191\u2193]$/, "");
|
|
34
|
-
if (!trimmed || PLACEHOLDER.test(trimmed)) return BLANK;
|
|
35
|
-
const match = DISPLAY_NUMBER.exec(trimmed);
|
|
36
|
-
if (!match) return null;
|
|
37
|
-
const [, sign, currency = "", grouped, fraction = "", scale, unitText = ""] = match;
|
|
38
|
-
const integer = grouped!.replace(/,/g, "") || "0";
|
|
39
|
-
const digits = scale ? SCALE_DIGITS[scale]! : 0;
|
|
40
|
-
const magnitude = digits ? scaleDecimal(integer, fraction, digits) : fraction ? `${integer}.${fraction}` : integer;
|
|
41
|
-
const unit = unitText.trim().replace(/^bps$/, "bp");
|
|
42
|
-
return { number: `${sign === "-" ? "-" : ""}${magnitude}`, unit: [currency, unit].filter(Boolean).join(" ") };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Adds the unit the cells dropped, unless the header already names it. */
|
|
46
|
-
function labelWithUnit(label: string, unit: string): string {
|
|
47
|
-
if (!unit) return label;
|
|
48
|
-
// A word unit has to stand alone: "Max" does not name x.
|
|
49
|
-
const named = unit === "bp" ? /\bbps?\b/i.test(label)
|
|
50
|
-
: /^[A-Za-z]+$/.test(unit) ? new RegExp(`\\b${unit}\\b`, "i").test(label)
|
|
51
|
-
: label.includes(unit);
|
|
52
|
-
return named ? label : `${label} (${unit})`;
|
|
53
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
BLANK_DISPLAY as BLANK,
|
|
5
|
+
DISPLAY_PLACEHOLDER as PLACEHOLDER,
|
|
6
|
+
labelWithUnit,
|
|
7
|
+
parseDisplayNumber,
|
|
8
|
+
} from "../../utils/display-number";
|
|
54
9
|
|
|
55
10
|
function rawExportValue(value: NonNullable<DataTableCell["value"]> | null): unknown {
|
|
56
11
|
if (value == null) return "";
|
|
@@ -672,7 +672,9 @@ export function DataTableView<
|
|
|
672
672
|
event.preventDefault();
|
|
673
673
|
return;
|
|
674
674
|
}
|
|
675
|
-
|
|
675
|
+
// Without a cursor there is nothing for these keys to move, so they stay
|
|
676
|
+
// free for the pane scroll keys to scroll the rows.
|
|
677
|
+
if (tableProps.items.length === 0 || selection.kind === "none") return;
|
|
676
678
|
|
|
677
679
|
if (isPlainKey(event, "j", "down")) {
|
|
678
680
|
stopTableKey(event);
|