gloomberb 0.17.0 → 0.17.1

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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/data.ts +5 -1
  3. package/src/api-client/doe.ts +19 -0
  4. package/src/api-client/index.ts +1 -0
  5. package/src/app/runtime/update.ts +16 -0
  6. package/src/architecture/dom-cell-metrics-allowlist.json +100 -26
  7. package/src/cli/commands/market.ts +57 -5
  8. package/src/cli/commands/options-chain.ts +75 -1
  9. package/src/cli/commands/ticker.ts +18 -9
  10. package/src/cli/glossary.ts +1 -1
  11. package/src/cli/index.ts +14 -6
  12. package/src/cli/pane-functions/catalog.ts +39 -7
  13. package/src/cli/pane-functions/dom.ts +19 -8
  14. package/src/cli/pane-functions/function-help.ts +3 -1
  15. package/src/cli/pane-functions/headless.ts +12 -1
  16. package/src/cli/pane-functions/index.ts +39 -0
  17. package/src/cli/pane-functions/lock-note.ts +56 -0
  18. package/src/components/command-bar/list/view.tsx +2 -1
  19. package/src/components/index.ts +3 -2
  20. package/src/components/layout/header.tsx +58 -14
  21. package/src/components/layout/shell/chrome.ts +10 -4
  22. package/src/components/ui/column-grid.tsx +206 -0
  23. package/src/components/ui/display.tsx +40 -3
  24. package/src/components/ui/fill.ts +8 -0
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/market-data/quotes/screener-live-quotes.ts +31 -7
  27. package/src/plugins/builtin/account-management/form-components.tsx +4 -7
  28. package/src/plugins/builtin/account-management/password-dialog.tsx +3 -2
  29. package/src/plugins/builtin/backtest/pane.tsx +9 -3
  30. package/src/plugins/builtin/buildout/detail/company.tsx +4 -3
  31. package/src/plugins/builtin/buildout/detail/ui.tsx +9 -12
  32. package/src/plugins/builtin/cloud/askg/pane.tsx +2 -1
  33. package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +3 -2
  34. package/src/plugins/builtin/cloud/thesis/detail.tsx +11 -9
  35. package/src/plugins/builtin/congress-trades/detail.tsx +8 -4
  36. package/src/plugins/builtin/connections/pane.tsx +16 -5
  37. package/src/plugins/builtin/cpi/pane.tsx +5 -4
  38. package/src/plugins/builtin/doe/index.tsx +11 -4
  39. package/src/plugins/builtin/doe/pane.tsx +5 -4
  40. package/src/plugins/builtin/doe/prices.ts +84 -0
  41. package/src/plugins/builtin/doe/series.ts +4 -1
  42. package/src/plugins/builtin/earnings-calls/headless.ts +7 -21
  43. package/src/plugins/builtin/earnings-calls/index.tsx +2 -0
  44. package/src/plugins/builtin/earnings-calls/lock-note.ts +17 -0
  45. package/src/plugins/builtin/equity-screener/headless.ts +59 -1
  46. package/src/plugins/builtin/equity-screener/live.ts +82 -31
  47. package/src/plugins/builtin/equity-screener/pane.tsx +60 -23
  48. package/src/plugins/builtin/estimate-revisions/client.ts +1 -1
  49. package/src/plugins/builtin/estimate-revisions/headless.ts +9 -2
  50. package/src/plugins/builtin/estimate-revisions/index.tsx +3 -0
  51. package/src/plugins/builtin/estimate-revisions/lock-note.ts +23 -0
  52. package/src/plugins/builtin/executives/index.tsx +18 -0
  53. package/src/plugins/builtin/exposure/charts.tsx +4 -3
  54. package/src/plugins/builtin/filing-events/index.tsx +8 -10
  55. package/src/plugins/builtin/fixed-income/pane.tsx +2 -1
  56. package/src/plugins/builtin/gpu/equities.tsx +9 -6
  57. package/src/plugins/builtin/iv-history/format.ts +3 -0
  58. package/src/plugins/builtin/iv-history/headless.ts +3 -1
  59. package/src/plugins/builtin/iv-history/index.tsx +3 -1
  60. package/src/plugins/builtin/iv-history/model.ts +3 -1
  61. package/src/plugins/builtin/iv-history/screen-pane.tsx +3 -1
  62. package/src/plugins/builtin/iv-history/universe.ts +1 -1
  63. package/src/plugins/builtin/jobs/headless.ts +6 -20
  64. package/src/plugins/builtin/jobs/index.tsx +2 -0
  65. package/src/plugins/builtin/jobs/lock-note.ts +16 -0
  66. package/src/plugins/builtin/options/index.tsx +2 -1
  67. package/src/plugins/builtin/options/model-spot.ts +94 -0
  68. package/src/plugins/builtin/options/open-interest-as-of.ts +63 -0
  69. package/src/plugins/builtin/options/view.tsx +55 -9
  70. package/src/plugins/builtin/options-scenario/client.ts +33 -9
  71. package/src/plugins/builtin/options-scenario/csp.ts +168 -0
  72. package/src/plugins/builtin/options-scenario/evidence.ts +3 -0
  73. package/src/plugins/builtin/options-scenario/headless.ts +24 -8
  74. package/src/plugins/builtin/options-scenario/index.tsx +3 -1
  75. package/src/plugins/builtin/options-scenario/pane.tsx +32 -6
  76. package/src/plugins/builtin/risk-factors/index.tsx +18 -0
  77. package/src/plugins/builtin/risk-factors/pane.tsx +2 -1
  78. package/src/plugins/builtin/scanner/headless.ts +9 -2
  79. package/src/plugins/builtin/scanner/index.ts +3 -0
  80. package/src/plugins/builtin/scanner/lock-note.ts +17 -0
  81. package/src/plugins/builtin/series-inventory.ts +1 -1
  82. package/src/plugins/builtin/shared/cloud-denied.ts +39 -8
  83. package/src/plugins/builtin/shared/pro-lock-note.ts +58 -0
  84. package/src/plugins/builtin/shared/series-list-detail.tsx +10 -5
  85. package/src/plugins/builtin/social-mentions/client.ts +3 -3
  86. package/src/plugins/builtin/social-mentions/headless.ts +6 -2
  87. package/src/plugins/builtin/social-mentions/index.tsx +2 -1
  88. package/src/plugins/builtin/social-mentions/lock-note.ts +15 -0
  89. package/src/plugins/builtin/supply-chain/flow.tsx +15 -13
  90. package/src/plugins/builtin/supply-chain/graph.tsx +6 -4
  91. package/src/plugins/builtin/supply-chain/pane.tsx +8 -5
  92. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +14 -1
  93. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +9 -7
  94. package/src/plugins/builtin/ticker-detail/headless.ts +4 -1
  95. package/src/plugins/builtin/ticker-detail/overview/components.tsx +105 -104
  96. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -2
  97. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +34 -8
  98. package/src/plugins/builtin/vol-surface/pane.tsx +6 -3
  99. package/src/public/__snapshots__/public-api.test.ts.snap +2 -0
  100. package/src/renderers/dom/data-table/index.tsx +48 -15
  101. package/src/renderers/dom/data-table/row.tsx +1 -1
  102. package/src/renderers/dom/desktop/popover.tsx +6 -15
  103. package/src/renderers/dom/host/input.tsx +27 -3
  104. package/src/renderers/dom/host/surface-3d.tsx +6 -4
  105. package/src/renderers/dom/styles.css +22 -4
  106. package/src/renderers/electrobun/view/app-services.ts +2 -22
  107. package/src/renderers/electrobun/view/news-source.ts +42 -0
  108. package/src/series/validate.ts +5 -0
  109. package/src/shares/dialog.tsx +2 -1
  110. package/src/sources/provider-router/financials.ts +5 -0
  111. package/src/types/financials.ts +22 -0
  112. package/src/types/plugin.ts +17 -4
  113. package/src/utils/financial-statements.ts +25 -0
  114. package/src/utils/fundamentals.ts +41 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -38,7 +38,7 @@ import type { BeneficialOwnersPayload, CloudBeneficialOwnersParams } from "./ben
38
38
  import type { SocialMentionDayPosts, SocialMentionsPayload, SocialMentionsRange } from "./social-mentions";
39
39
  import type { FuturesContractTermsPayload, FuturesCurveAsOfPayload, FuturesCurvePayload } from "./futures-curve";
40
40
  import type { CotBoardPayload, CotContractPayload, CotFamily, CotClass } from "./cot";
41
- import type { DoeBoardPayload } from "./doe";
41
+ import type { DoeBoardPayload, DoePricesPayload } from "./doe";
42
42
  import type { CarbonBoard, CarbonHistory, EuropePowerBoard, EuropePowerHistory, GasStorageBoard, GasStorageHistory, GasStorageSeasonal } from "./europe-energy";
43
43
  import type { GpuBoardPayload, GpuEventsPayload, GpuHistoryPayload, GpuHistoryQuery } from "./gpu";
44
44
  import type { CpiBoardPayload } from "./cpi";
@@ -586,6 +586,10 @@ export class CloudDataApi {
586
586
  return this.request<DoeBoardPayload>("/cloud/doe/board");
587
587
  }
588
588
 
589
+ async getCloudDoePrices(): Promise<DoePricesPayload> {
590
+ return this.request<DoePricesPayload>("/cloud/doe/prices");
591
+ }
592
+
589
593
  async getCloudAttention(window: AttentionWindow = "now", symbol?: string): Promise<AttentionPayload> {
590
594
  const path = symbol ? `/cloud/attention/${encodeURIComponent(symbol)}` : "/cloud/attention";
591
595
  return this.request<AttentionPayload>(`${path}?window=${window}`);
@@ -73,3 +73,22 @@ export interface DoeBoardPayload {
73
73
  reports: DoeReportStatus[];
74
74
  series: DoeSeriesRow[];
75
75
  }
76
+
77
+ /** One EIA weekly US retail price series, as GET /cloud/doe/prices serves it. */
78
+ interface DoePriceSeries {
79
+ id: string;
80
+ /** The EIA source key, which a report cites. */
81
+ key: string;
82
+ label: string;
83
+ unit: "usd-per-gallon";
84
+ /** [Monday the EIA surveyed, dollars per gallon], oldest first, the last five years. */
85
+ points: Array<[string, number]>;
86
+ }
87
+
88
+ export interface DoePricesPayload {
89
+ source: "EIA";
90
+ generatedAt: string;
91
+ status: "available" | "partial" | "unavailable";
92
+ gaps: string[];
93
+ series: DoePriceSeries[];
94
+ }
@@ -812,6 +812,7 @@ class GloomApiClient {
812
812
  getCloudAwards = this.data.getCloudAwards.bind(this.data);
813
813
  getCloudAward = this.data.getCloudAward.bind(this.data);
814
814
  getCloudDoeBoard = this.data.getCloudDoeBoard.bind(this.data);
815
+ getCloudDoePrices = this.data.getCloudDoePrices.bind(this.data);
815
816
  getCloudAttention = this.data.getCloudAttention.bind(this.data);
816
817
  getCloudPowerBoard = this.data.getCloudPowerBoard.bind(this.data);
817
818
  getCloudPowerHistory = this.data.getCloudPowerHistory.bind(this.data);
@@ -10,13 +10,25 @@ import {
10
10
  performUpdate,
11
11
  type ReleaseInfo,
12
12
  type UpdateCheckResult,
13
+ type UpdateProgress,
13
14
  } from "../../updater";
14
15
  import { VERSION } from "../../version";
16
+ import { debugLog } from "../../utils/debug-log";
15
17
  import { reportCrash } from "../../telemetry/crash-reports";
16
18
  import { runAutomated } from "../../telemetry/usage-counts";
17
19
 
18
20
  const UPDATE_CHECK_INTERVAL_MS = 60 * 60_000; // hourly
19
21
 
22
+ const updateLog = debugLog.createLogger("update");
23
+
24
+ /**
25
+ * The header and the command bar show a failed update's message, and the header
26
+ * cuts it to its room: the log keeps what the updater actually said.
27
+ */
28
+ function logUpdateFailure(progress: UpdateProgress): void {
29
+ if (progress.phase === "error") updateLog.error("update failed", { error: progress.error });
30
+ }
31
+
20
32
  /** An update is being downloaded, applied, or is downloaded and waiting for a restart. */
21
33
  function updateInFlight(progress: AppState["updateProgress"]): boolean {
22
34
  return progress?.phase === "downloading" || progress?.phase === "replacing" || progress?.phase === "ready";
@@ -53,6 +65,7 @@ export function useAppUpdateRuntime({
53
65
  const startUpdate = useCallback((release: ReleaseInfo) => {
54
66
  dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "downloading", percent: 0 } });
55
67
  void performUpdate(release, (progress) => {
68
+ logUpdateFailure(progress);
56
69
  dispatch({ type: "SET_UPDATE_PROGRESS", progress });
57
70
  });
58
71
  }, [dispatch]);
@@ -66,6 +79,7 @@ export function useAppUpdateRuntime({
66
79
  dispatch({ type: "SET_UPDATE_PROGRESS", progress: { phase: "replacing" } });
67
80
  void applyUpdate((progress) => {
68
81
  if (progress.phase === "error") restartingRef.current = false;
82
+ logUpdateFailure(progress);
69
83
  dispatch({ type: "SET_UPDATE_PROGRESS", progress });
70
84
  });
71
85
  }, [dispatch, enabled, isDetachedWindow, stateRef]);
@@ -91,6 +105,8 @@ export function useAppUpdateRuntime({
91
105
  return { kind: "error", error: message || "Update check failed" };
92
106
  });
93
107
 
108
+ if (result.kind === "error") updateLog.warn("update check failed", { error: result.error });
109
+
94
110
  if (!manual) {
95
111
  if (result.kind === "available") {
96
112
  dispatch({ type: "SET_UPDATE_AVAILABLE", release: result.release });
@@ -1,28 +1,102 @@
1
1
  {
2
- "src/components/layout/pane/header.tsx": 2,
3
- "src/components/layout/pane/sizing.ts": 3,
4
- "src/components/layout/shell/drag/slide.ts": 4,
5
- "src/components/layout/shell/pane/layers.tsx": 6,
6
- "src/components/ui/select-button.tsx": 2,
7
- "src/renderers/dom/data-table/dom.ts": 26,
8
- "src/renderers/dom/data-table/index.tsx": 21,
9
- "src/renderers/dom/data-table/row.tsx": 11,
10
- "src/renderers/dom/desktop/controls.tsx": 6,
11
- "src/renderers/dom/desktop/list-view.tsx": 2,
12
- "src/renderers/dom/desktop/popover.tsx": 6,
13
- "src/renderers/dom/desktop/query-bar.tsx": 1,
14
- "src/renderers/dom/desktop/select-field.tsx": 2,
15
- "src/renderers/dom/dom-ui-host.tsx": 6,
16
- "src/renderers/dom/host/input.tsx": 5,
17
- "src/renderers/dom/host/live-frame.ts": 8,
18
- "src/renderers/dom/host/mouse.ts": 10,
19
- "src/renderers/dom/host/scroll-box.tsx": 22,
20
- "src/renderers/dom/host/size-observer.ts": 6,
21
- "src/renderers/dom/host/style.ts": 27,
22
- "src/renderers/dom/host/surface-fit.ts": 4,
23
- "src/renderers/dom/host/tabs.tsx": 2,
24
- "src/renderers/dom/host/text.tsx": 2,
25
- "src/renderers/dom/input-host.tsx": 4,
26
- "src/renderers/dom/native-renderer.ts": 4,
27
- "src/renderers/dom/styles.css": 17
2
+ "src/components/layout/pane/header.tsx": {
3
+ "uses": 2,
4
+ "why": "The title bar's inset for the window controls comes from the shell's header geometry, which the terminal shares, in cells."
5
+ },
6
+ "src/components/layout/pane/sizing.ts": {
7
+ "uses": 3,
8
+ "why": "Panes receive their size in cells (PaneProps); this gives the desktop's taller chrome row in those cells."
9
+ },
10
+ "src/components/layout/shell/drag/slide.ts": {
11
+ "uses": 4,
12
+ "why": "A dragged pane moves by offsets of the saved layout, which is kept in cells."
13
+ },
14
+ "src/components/layout/shell/pane/layers.tsx": {
15
+ "uses": 6,
16
+ "why": "Pane rectangles in the saved layout are in cells; this is where they become pixels."
17
+ },
18
+ "src/components/ui/select-button.tsx": {
19
+ "uses": 2,
20
+ "why": "A select's width is given in cells by the pane, as its terminal button's is."
21
+ },
22
+ "src/renderers/dom/data-table/dom.ts": {
23
+ "uses": 26,
24
+ "why": "The table's scroll and pointer API speaks rows and columns to callers shared with the terminal (j/k, paging, visible range); a row of text is one line tall."
25
+ },
26
+ "src/renderers/dom/data-table/index.tsx": {
27
+ "uses": 19,
28
+ "why": "Every pane gives its columns in cells (DataTableColumn.width) and rows are one line tall: the horizontal extent, the overflow decision, virtualization and paging follow from them."
29
+ },
30
+ "src/renderers/dom/data-table/row.tsx": {
31
+ "uses": 10,
32
+ "why": "Column widths and gaps are cells in the column model every pane uses; a row is one line tall."
33
+ },
34
+ "src/renderers/dom/desktop/controls.tsx": {
35
+ "uses": 6,
36
+ "why": "Callers give a button's size in cells; a framed field keeps its text one cell off its frame, as the terminal's does."
37
+ },
38
+ "src/renderers/dom/desktop/list-view.tsx": {
39
+ "uses": 2,
40
+ "why": "The caller gives a list's height in rows."
41
+ },
42
+ "src/renderers/dom/desktop/query-bar.tsx": {
43
+ "uses": 1,
44
+ "why": "The pane gives its search field's width in cells."
45
+ },
46
+ "src/renderers/dom/desktop/select-field.tsx": {
47
+ "uses": 2,
48
+ "why": "An inline select is one row tall, the row it sits in."
49
+ },
50
+ "src/renderers/dom/dom-ui-host.tsx": {
51
+ "uses": 6,
52
+ "why": "Exposes the cell size to components that still size in cells (chart bitmaps, the company logo)."
53
+ },
54
+ "src/renderers/dom/host/input.tsx": {
55
+ "uses": 3,
56
+ "why": "Callers give a field's width and height in cells; a one-line field is one row tall."
57
+ },
58
+ "src/renderers/dom/host/live-frame.ts": {
59
+ "uses": 8,
60
+ "why": "A resize's live frame is in cells, the pane's unit, until the pane renders at the new size."
61
+ },
62
+ "src/renderers/dom/host/mouse.ts": {
63
+ "uses": 10,
64
+ "why": "Pointer handlers receive cell coordinates, the event API every renderer shares."
65
+ },
66
+ "src/renderers/dom/host/scroll-box.tsx": {
67
+ "uses": 22,
68
+ "why": "The scroller's API (scrollTop, scrollBy, viewport) is in rows and columns for callers shared with the terminal; content insets are given in cells."
69
+ },
70
+ "src/renderers/dom/host/size-observer.ts": {
71
+ "uses": 6,
72
+ "why": "Observed sizes are reported in cells, as callers expect."
73
+ },
74
+ "src/renderers/dom/host/style.ts": {
75
+ "uses": 27,
76
+ "why": "Maps the shared Box and Text props that components give in cells (width, height, padding, margin, gap, position) to pixels."
77
+ },
78
+ "src/renderers/dom/host/surface-fit.ts": {
79
+ "uses": 4,
80
+ "why": "A chart takes its box when its cells come within a cell of it, because panes still count their space in cells."
81
+ },
82
+ "src/renderers/dom/host/tabs.tsx": {
83
+ "uses": 2,
84
+ "why": "A tab strip is one row tall, the row every caller reserves for it."
85
+ },
86
+ "src/renderers/dom/host/text.tsx": {
87
+ "uses": 2,
88
+ "why": "The ASCII wordmark draws one glyph per cell."
89
+ },
90
+ "src/renderers/dom/input-host.tsx": {
91
+ "uses": 4,
92
+ "why": "The viewport is reported in cells (useViewport), the unit every renderer shares."
93
+ },
94
+ "src/renderers/dom/native-renderer.ts": {
95
+ "uses": 4,
96
+ "why": "The renderer's width and height are reported in cells, the unit every renderer shares."
97
+ },
98
+ "src/renderers/dom/styles.css": {
99
+ "uses": 14,
100
+ "why": "Defines the cell size and the base line height; status, footer and hint lines are one row; a few insets are one cell, as the terminal's (the floating pane's two-cell grip, a query bar segment)."
101
+ }
28
102
  }
@@ -31,6 +31,7 @@ import {
31
31
  formatPriceRange,
32
32
  } from "../helpers";
33
33
  import { cliStyles, colorBySign, renderSection, renderStats } from "../../utils/cli-output";
34
+ import { statementHistoryNotes } from "../../utils/financial-statements";
34
35
  import { formatPerShareNumber } from "../../utils/reported-money";
35
36
  import {
36
37
  analystTargetCurrency,
@@ -48,9 +49,9 @@ import type { FuturesContractTermsPayload } from "../../api-client/futures-curve
48
49
  import { futuresSettleText, isFuturesInstrument } from "../../market-data/futures-contract";
49
50
  import {
50
51
  chainHasExpiry,
51
- chainWithModelFigures,
52
52
  formatExpiryList,
53
53
  loadOptionModelInputs,
54
+ loadOptionsChainOpenInterest,
54
55
  formatOptionDeltaCell,
55
56
  formatOptionIvCell,
56
57
  formatOptionQuoteCell,
@@ -58,7 +59,12 @@ import {
58
59
  OPTION_MODEL_RATE,
59
60
  OPTIONS_USAGE,
60
61
  optionRows,
62
+ optionsBasisFields,
63
+ optionsBasisFootnote,
61
64
  parseOptionExpiration,
65
+ requestListedOpenInterest,
66
+ valueChainAtModelSpot,
67
+ type OptionsChainBasis,
62
68
  } from "./options-chain";
63
69
  import { DEFAULT_LEAPS_CRITERIA, LEAPS_USAGE, parseLeapsCriteria, runLeapsScreen, takeLeapsOptions } from "./options-leaps";
64
70
  import type { CliResultColumn } from "../result";
@@ -950,11 +956,13 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
950
956
  const freshness = rowsFreshness(rows, {
951
957
  ...REPORTED_DATA, basis: "financial statements", observedKey: "date", oldest: null,
952
958
  });
959
+ // The rows are the annual years served; older ones are not, and the closing lines say so.
960
+ const historyNotes = statementHistoryNotes("annual", rows.map((row) => row.date), listing.key);
953
961
  // NDJSON keeps the raw statement rows it always wrote; CSV gets the table and its closing lines.
954
962
  const tables = tabular
955
963
  ? selectTables({
956
964
  tables: [exportRowsTable("Annual statements", STATEMENT_COLUMNS, rows)],
957
- footer: reportFooterLines({ freshness }),
965
+ footer: reportFooterLines({ freshness, notes: historyNotes }),
958
966
  })
959
967
  : undefined;
960
968
  ctx.printResult({
@@ -966,6 +974,7 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
966
974
  quarterlyStatements: financials.quarterlyStatements.length,
967
975
  fundamentals: exportedFundamentals(financials.fundamentals),
968
976
  profile: financials.profile,
977
+ ...(historyNotes.length > 0 ? { notes: historyNotes } : {}),
969
978
  },
970
979
  freshness,
971
980
  ...(choice.length > 0 ? { warnings: choice } : {}),
@@ -973,6 +982,7 @@ async function runFinancials(rawArgs: string[], ctx: Parameters<CliCommandDef["e
973
982
  heading: listingHeading(identity),
974
983
  rows: () => rows,
975
984
  columns: STATEMENT_COLUMNS,
985
+ ...(historyNotes.length > 0 ? { footnote: historyNotes.join("\n"), exportNotes: historyNotes } : {}),
976
986
  ...(tables && ctx.cliOptions.format === "csv" ? { tables } : {}),
977
987
  // Annual statements in the company's reporting currency.
978
988
  exportIdentity: { symbolOrSeries: listing.key, currencyOrUnit: rows.find((row) => row.currency)?.currency ?? null, interval: "annual" },
@@ -1495,6 +1505,8 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
1495
1505
  const quotePromise = loadListingQuote(market.dataProvider, listing);
1496
1506
  const fate = listingFateLoader(listing, market, quotePromise);
1497
1507
  const inputsPromise = loadOptionModelInputs(market.dataProvider, listing, quotePromise, refresh);
1508
+ // OPX's listing dates the chain's open interest; asked for alongside the chain when the expiry is known.
1509
+ const listedOpenInterest = expirationDate != null ? requestListedOpenInterest(symbol, expirationDate) : null;
1498
1510
  const { result, chain: loaded } = await loadForListing(listing, market, ctx, async () => {
1499
1511
  const cached = await market.dataProvider.getCachedQuery?.("getOptionsChain", [symbol, exchange, expirationDate, undefined])
1500
1512
  .load({ force: refresh });
@@ -1524,7 +1536,13 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
1524
1536
  }
1525
1537
  const quote = await quotePromise;
1526
1538
  const identity = listingIdentity(listing, quote);
1527
- const chain = chainWithModelFigures(loaded, inputs);
1539
+ // IV and delta are valued at one spot and instant, named under the table and on every exported row.
1540
+ const { chain, spot: modelSpot } = valueChainAtModelSpot(loaded, inputs, Date.now(), identity.exchange);
1541
+ const basis: OptionsChainBasis = { spot: modelSpot, dividendYield: inputs.dividendYield ?? null,
1542
+ openInterest: await loadOptionsChainOpenInterest(symbol, chain, listedOpenInterest) };
1543
+ const basisFields = optionsBasisFields(basis);
1544
+ const currency = [...chain.calls, ...chain.puts].find((contract) => contract.currency?.trim())?.currency.trim().toUpperCase()
1545
+ ?? quote?.currency ?? null;
1528
1546
  // A failed refresh falls back to the stored chain, which can be days old.
1529
1547
  const refreshWarning = result?.refreshError == null ? null
1530
1548
  : `Options refresh failed; showing the chain stored ${new Date(result.fetchedAt).toISOString()}`
@@ -1546,23 +1564,54 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
1546
1564
  ...(chain.delayMinutes != null ? { delayMinutes: chain.delayMinutes } : {}),
1547
1565
  stale: refreshWarning != null,
1548
1566
  });
1567
+ const contractCount = chain.calls.length + chain.puts.length;
1549
1568
  ctx.printResult({
1550
1569
  data: chain,
1551
1570
  metadata: {
1552
1571
  ...listingMetadata(identity),
1553
1572
  expirations: chain.expirationDates,
1573
+ currency,
1574
+ interval: OPTIONS_EXPORT_INTERVAL,
1575
+ // `iv` is annualised volatility and `delta` the option's change per 1.00 move in the underlying, both decimals.
1576
+ units: { iv: "annualized decimal", delta: "decimal per 1.00 move in the underlying" },
1554
1577
  // What `iv` and `delta` on each contract are valued from; the provider's own impliedVolatility is left as sent.
1555
- model: { spot: inputs.spot ?? null, dividendYield: inputs.dividendYield ?? null, rate: OPTION_MODEL_RATE },
1578
+ model: {
1579
+ spot: basisFields.spot, spotBasis: basisFields.spotBasis, spotAsOf: basisFields.spotAsOf,
1580
+ valuationTime: modelSpot ? new Date(modelSpot.valuationTime).toISOString() : null,
1581
+ dividendYield: inputs.dividendYield ?? null, rate: OPTION_MODEL_RATE,
1582
+ },
1583
+ openInterest: { asOf: basisFields.openInterestAsOf, listedAsOf: basis.openInterest?.listedDate ?? null,
1584
+ compared: basis.openInterest?.compared ?? 0, matched: basis.openInterest?.matched ?? 0 },
1556
1585
  },
1557
1586
  ...(warnings.length > 0 ? { warnings } : {}),
1558
1587
  freshness,
1559
1588
  }, {
1560
1589
  heading: listingHeading(identity),
1561
- rows: optionRows,
1590
+ rows: (data: OptionsChain) => optionRows(data).map((row) => ({ ...row, ...basisFields })),
1562
1591
  // Choosing an expiry needs the list; once one is chosen the table is that expiry.
1563
1592
  summary: (data: OptionsChain) => expirationDate == null ? formatExpiryList(data.expirationDates) : "",
1564
1593
  empty: `No option contracts for ${listingTitle(identity)}.`,
1594
+ ...(contractCount > 0 ? { footnote: optionsBasisFootnote(basis) } : {}),
1595
+ exportIdentity: { symbolOrSeries: listing.key, currencyOrUnit: currency, interval: OPTIONS_EXPORT_INTERVAL },
1596
+ // Exports state each decimal's unit in its header and end every row with the spot and dates it is as of.
1565
1597
  columns: [
1598
+ { key: "side", header: "Side" },
1599
+ { key: "contract", header: "Contract" },
1600
+ { key: "expiration", header: "Expiry" },
1601
+ { key: "strike", header: "Strike" },
1602
+ { key: "last", header: "Last" },
1603
+ { key: "bid", header: "Bid" },
1604
+ { key: "ask", header: "Ask" },
1605
+ { key: "iv", header: "IV (annualized decimal)" },
1606
+ { key: "delta", header: "Delta (decimal)" },
1607
+ { key: "volume", header: "Vol" },
1608
+ { key: "openInterest", header: "OI" },
1609
+ { key: "spot", header: "Spot" },
1610
+ { key: "spotBasis", header: "Spot basis" },
1611
+ { key: "spotAsOf", header: "Spot as of" },
1612
+ { key: "openInterestAsOf", header: "OI as of" },
1613
+ ],
1614
+ textColumns: [
1566
1615
  { key: "side", header: "Side" },
1567
1616
  { key: "contract", header: "Contract", shrink: false },
1568
1617
  // The contract symbol already carries the date, so this goes first when the table is narrow.
@@ -1580,6 +1629,9 @@ async function runOptions(rawArgs: string[], ctx: Parameters<CliCommandDef["exec
1580
1629
  });
1581
1630
  }
1582
1631
 
1632
+ /** What an options chain export's `interval` says: one quote snapshot, not bars. */
1633
+ const OPTIONS_EXPORT_INTERVAL = "snapshot";
1634
+
1583
1635
  const FX_USAGE = "fx <currency> | fx <base>/<quote>";
1584
1636
 
1585
1637
  async function runFx(rawArgs: string[], ctx: Parameters<CliCommandDef["execute"]>[1]) {
@@ -5,6 +5,12 @@ import { cliStyles, cliTerminalWidth, wrapText } from "../../utils/cli-output";
5
5
  import { isFiniteNumber } from "../../utils/guards";
6
6
  import { calculateOptionGreeks, solveChainVolatilities } from "../../plugins/builtin/options/analytics";
7
7
  import { optionQuoteSide } from "../../plugins/builtin/options/market-reference";
8
+ import {
9
+ formatModelSpotPrice, modelSpotLabel, optionModelSpot, type OptionModelSpot,
10
+ } from "../../plugins/builtin/options/model-spot";
11
+ import {
12
+ chainOpenInterestAsOf, loadListedOpenInterest, openInterestAsOfLabel, type OpenInterestAsOf,
13
+ } from "../../plugins/builtin/options/open-interest-as-of";
8
14
  import { DEFAULT_OPTION_CALC_DRAFT } from "../../plugins/builtin/options-calculator/model";
9
15
  import { expiryIsoDate, missingExpiryText, parseOptionExpiration } from "../../utils/option-expiry";
10
16
 
@@ -61,6 +67,8 @@ export function formatExpiryList(expirations: readonly number[], width = cliTerm
61
67
  export interface OptionModelInputs {
62
68
  /** The underlying's current price; absent when the quote is missing or stale. */
63
69
  spot?: number;
70
+ /** The quote `spot` is read from, for the spot the chain is valued at (`optionModelSpot`). */
71
+ quote?: Quote;
64
72
  dividendYield?: number;
65
73
  now?: number;
66
74
  }
@@ -119,7 +127,7 @@ export async function loadOptionModelInputs(
119
127
  .find((candidate) => candidate && candidate.stale !== true && isFiniteNumber(candidate.price) && candidate.price > 0);
120
128
  const dividendYield = financials?.fundamentals?.dividendYield;
121
129
  return {
122
- ...(current ? { spot: current.price } : {}),
130
+ ...(current ? { spot: current.price, quote: current } : {}),
123
131
  ...(isFiniteNumber(dividendYield) && dividendYield >= 0 ? { dividendYield } : {}),
124
132
  };
125
133
  }
@@ -131,6 +139,72 @@ export function chainWithModelFigures(chain: OptionsChain, inputs: OptionModelIn
131
139
  return { ...chain, calls: chain.calls.map(add), puts: chain.puts.map(add) };
132
140
  }
133
141
 
142
+ /**
143
+ * What a chain's IV, delta and open interest are as of: the spot and instant
144
+ * they are valued at, and the session the open interest counts. The rows and
145
+ * the closing lines of every format say it.
146
+ */
147
+ export interface OptionsChainBasis {
148
+ spot: OptionModelSpot | null;
149
+ dividendYield: number | null;
150
+ openInterest: OpenInterestAsOf | null;
151
+ }
152
+
153
+ /** The chain with `iv` and `delta` valued at the spot `optionModelSpot` picks for it, and that spot. */
154
+ export function valueChainAtModelSpot(
155
+ chain: OptionsChain, inputs: OptionModelInputs, now: number, exchange?: string,
156
+ ): { chain: OptionsChain; spot: OptionModelSpot | null } {
157
+ const spot = optionModelSpot(inputs.quote, chain, now, exchange);
158
+ return {
159
+ chain: chainWithModelFigures(chain, spot ? { spot: spot.price, dividendYield: inputs.dividendYield, now: spot.valuationTime } : inputs),
160
+ spot,
161
+ };
162
+ }
163
+
164
+ /** The open-interest date of the chain's expiry, from OPX's listing of it; null when the listing cannot be read. */
165
+ export async function loadOptionsChainOpenInterest(
166
+ symbol: string, chain: OptionsChain, listed?: ReturnType<typeof loadListedOpenInterest> | null,
167
+ ): Promise<OpenInterestAsOf | null> {
168
+ const expiration = [...chain.calls, ...chain.puts][0]?.expiration;
169
+ if (expiration == null) return null;
170
+ const payload = await (listed ?? loadListedOpenInterest(symbol, expiryIsoDate(expiration)));
171
+ return payload ? chainOpenInterestAsOf(chain, payload) : null;
172
+ }
173
+
174
+ /** OPX's listing of an expiry, requested while the chain loads. */
175
+ export function requestListedOpenInterest(symbol: string, expiration: number) {
176
+ return loadListedOpenInterest(symbol, expiryIsoDate(expiration));
177
+ }
178
+
179
+ /**
180
+ * The line under the chain: `Greeks at spot 89.79 (close Mon 12 Oct 20:00 UTC), dividend yield 2.34%, rate 4% · OI as of Fri 9 Oct`.
181
+ * An open interest whose date cannot be established says why.
182
+ */
183
+ export function optionsBasisFootnote(basis: OptionsChainBasis): string {
184
+ const parts: string[] = [];
185
+ if (basis.spot) {
186
+ parts.push(`Greeks at spot ${formatModelSpotPrice(basis.spot.price)} (${modelSpotLabel(basis.spot)}), `
187
+ + `dividend yield ${basis.dividendYield == null ? "0%" : `${Number((basis.dividendYield * 100).toFixed(2))}%`}, `
188
+ + `rate ${Number((OPTION_MODEL_RATE * 100).toFixed(2))}%`);
189
+ }
190
+ const oi = basis.openInterest;
191
+ parts.push(oi?.date ? openInterestAsOfLabel(oi)
192
+ : oi?.listedDate && oi.compared > 0
193
+ ? `OI date not published: the counts differ from OPX's ${oi.listedDate} counts on ${oi.compared - oi.matched} of ${oi.compared} contracts`
194
+ : openInterestAsOfLabel(null));
195
+ return parts.join(" · ");
196
+ }
197
+
198
+ /** The spot and open-interest fields every exported row carries. */
199
+ export function optionsBasisFields(basis: OptionsChainBasis) {
200
+ return {
201
+ spot: basis.spot?.price ?? null,
202
+ spotBasis: basis.spot ? basis.spot.basis === "close" ? "close" : basis.spot.session ?? "last" : null,
203
+ spotAsOf: basis.spot ? new Date(basis.spot.observedAt).toISOString() : null,
204
+ openInterestAsOf: basis.openInterest?.date ?? null,
205
+ };
206
+ }
207
+
134
208
  export function optionRows(chain: OptionsChain) {
135
209
  const contracts = [
136
210
  ...chain.calls.map((contract) => ({ side: "call", contract })),
@@ -1,6 +1,6 @@
1
1
  import { formatShortDate, parseDisplayDate } from "../../utils/datetime-format";
2
2
  import { formatReportedMoney } from "../../utils/reported-money";
3
- import { convertedPriceEarningsNote, epsConversionNote, fundamentalsCurrency, reportedEnterpriseValue, ttmConversionNote } from "../../utils/fundamentals";
3
+ import { convertedPriceEarningsNote, enterpriseValueLabel, enterpriseValueReconciliationNote, epsConversionNote, fundamentalsCurrency, reportedEnterpriseValue, ttmConversionNote } from "../../utils/fundamentals";
4
4
  import { latestFinancialPeriod } from "../../utils/latest-financial-period";
5
5
  import { exportedFundamentals, formatPriceEarnings, priceEarningsOnEarnings } from "../../utils/price-earnings";
6
6
  import { describeFundamentalMarketCap, selectMarketCapitalization } from "../../utils/market-capitalization";
@@ -336,6 +336,13 @@ function reportedMoney(value: number | undefined, currency: string | undefined,
336
336
  return { text: formatReportedMoney(value, currency, perShare), value, unit: currency?.trim() || "ccy?" };
337
337
  }
338
338
 
339
+ /** The EV line: labelled "(provider)" when it is more than 5% from market cap + debt - cash, "unavailable" when withdrawn. */
340
+ function enterpriseValueFigure(figure: MetricFigure, fundamentals: TickerFinancials["fundamentals"]): MetricFigure {
341
+ const label = enterpriseValueLabel(fundamentals);
342
+ if (label === "unavailable") return { text: "unavailable", value: null, unit: figure.unit };
343
+ return label === "provider" && figure.text !== "—" ? { ...figure, text: `${figure.text} (provider)` } : figure;
344
+ }
345
+
339
346
  function percentFigure(fraction: number | null | undefined, format: (value: number) => string): MetricFigure {
340
347
  return fraction != null ? { text: format(fraction), value: fraction * 100, unit: "%" } : { text: "—" };
341
348
  }
@@ -366,7 +373,7 @@ function fundamentalsMetrics(
366
373
  const receipt = fundamentals?.sharesOutstanding != null && sharesOutstandingInReceipts(quote, fundamentals, profile?.description);
367
374
  return [
368
375
  { label: "Market Cap", ...marketCap },
369
- { label: "Enterprise Value", ...enterpriseValue },
376
+ { label: "Enterprise Value", ...enterpriseValueFigure(enterpriseValue, fundamentals) },
370
377
  // A multiple over a loss is N/M, even beside a positive one the source served from an older period.
371
378
  { label: "P/E (TTM)", ...multipleFigure(priceEarningsOnEarnings(fundamentals?.trailingPE, fundamentals?.eps)) },
372
379
  { label: "Forward P/E", ...multipleFigure(priceEarningsOnEarnings(fundamentals?.forwardPE, fundamentals?.forwardEps)) },
@@ -404,10 +411,11 @@ function metricLines(metrics: readonly FundamentalsMetric[]): Array<[string, str
404
411
  const TRAILING_MONEY_METRICS = new Set(["EPS (TTM)", "Revenue (TTM)", "Net Income (TTM)", "Operating Cash Flow (TTM)", "Free Cash Flow (TTM)"]);
405
412
 
406
413
  /**
407
- * The unit notes under shown fundamentals: what a converted TTM block was converted from, an EPS in another
408
- * currency than the amounts beside it, and what a P/E on such an EPS divides.
414
+ * The notes under shown fundamentals: how a labelled or withdrawn EV compares with market cap + debt - cash, what a
415
+ * converted TTM block was converted from, an EPS in another currency than the amounts beside it, and what a P/E on
416
+ * such an EPS divides.
409
417
  */
410
- function trailingConversionNotes(
418
+ function fundamentalsNotes(
411
419
  fundamentals: TickerFinancials["fundamentals"],
412
420
  metrics: readonly FundamentalsMetric[],
413
421
  quoteCurrency: string | undefined,
@@ -418,7 +426,8 @@ function trailingConversionNotes(
418
426
  const priceEarnings = metrics.some((metric) => metric.label === "P/E (TTM)" && typeof metric.value === "number")
419
427
  ? convertedPriceEarningsNote(fundamentals, quoteCurrency)
420
428
  : undefined;
421
- return [conversion, eps, priceEarnings].filter((note): note is string => !!note);
429
+ const enterpriseValue = shown("Enterprise Value") ? enterpriseValueReconciliationNote(fundamentals) : undefined;
430
+ return [enterpriseValue, conversion, eps, priceEarnings].filter((note): note is string => !!note);
422
431
  }
423
432
 
424
433
  const VALUATION_METRICS = new Set(["Market Cap", "Enterprise Value", "P/E (TTM)", "Forward P/E", "PEG", "EPS (TTM)"]);
@@ -523,7 +532,7 @@ export function renderFundamentalsReport(
523
532
  const fund = fundFactsNote(symbol, financials.exchange || quote?.exchangeName, financials);
524
533
  if (lines.length === before) lines.push("", cliStyles.muted([`No ${view} reported for ${financials.symbol}.`, fund].filter(Boolean).join(" ")));
525
534
  else if (fund) lines.push(cliStyles.muted(fund));
526
- for (const note of trailingConversionNotes(financials.fundamentals, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
535
+ for (const note of fundamentalsNotes(financials.fundamentals, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
527
536
  if (view === "fundamentals") appendTextSection(lines, "Description", profile?.description);
528
537
  return lines.join("\n");
529
538
  }
@@ -563,7 +572,7 @@ export function fundamentalsReportTables(
563
572
  notes: [
564
573
  ...metrics.length === 0
565
574
  ? [`No ${view} reported for ${financials.symbol}.`]
566
- : trailingConversionNotes(financials.fundamentals, metrics, financials.quote?.currency),
575
+ : fundamentalsNotes(financials.fundamentals, metrics, financials.quote?.currency),
567
576
  ...fund ? [fund] : [],
568
577
  ],
569
578
  }),
@@ -726,7 +735,7 @@ export async function buildTickerReport({
726
735
  const metrics = fundamentalsMetrics(quote, priced.fundamentals, profile, { text: marketCapText }, priceReturns, fundamentalsCurrency(financials), { text: enterpriseValueText });
727
736
  const beforeFundamentals = lines.length;
728
737
  appendMetricSection(lines, "Fundamentals", metricLines(metrics));
729
- for (const note of trailingConversionNotes(fundamentals, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
738
+ for (const note of fundamentalsNotes(fundamentals, metrics, quote?.currency)) lines.push(cliStyles.muted(note));
730
739
  // Where a fund's facts would go: under its fundamentals, or in their place.
731
740
  const fund = fundFactsNote(quote?.symbol ?? symbol,
732
741
  quote?.tradingExchangeName || listingExchange || quote?.listingExchangeName || quote?.exchangeName || tickerFile?.metadata.exchange, financials, tickerFile);
@@ -351,7 +351,7 @@ export const GLOSSARY: readonly GlossaryEntry[] = [
351
351
  term: "TTM", aliases: ["trailing twelve months", "trailing 12 months", "trailing four quarters", "ltm", "last twelve months"],
352
352
  functions: ["FA"],
353
353
  definition: "Trailing twelve months: the four latest quarters added up, a year that ends with the latest quarter rather than the fiscal year. "
354
- + "FA's quarterly leverage ratios read their flows over it, and show < 4 quarters when fewer are on file.",
354
+ + "FA's quarterly leverage and valuation ratios (P/E, EV / EBITDA, P/S, FCF yield) read their flows over it, and show < 4 quarters when fewer are on file.",
355
355
  },
356
356
  // Funds (ETF). Named in what ETF says it cannot show yet.
357
357
  {