gloomberb 0.11.2 → 0.11.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.
Files changed (68) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/api-client/data.ts +5 -0
  4. package/src/api-client/index.ts +5 -0
  5. package/src/api-client/types.ts +24 -0
  6. package/src/capabilities/chart-series.ts +3 -2
  7. package/src/cli/commands/market.ts +2 -2
  8. package/src/cli/pane-functions/capabilities.ts +3 -3
  9. package/src/cli/registry.ts +0 -22
  10. package/src/cli/result.ts +7 -10
  11. package/src/components/data-table/export.test.ts +21 -0
  12. package/src/components/data-table/export.ts +21 -0
  13. package/src/components/ui/data-table/index.tsx +19 -2
  14. package/src/data/config/chart-settings.ts +4 -4
  15. package/src/layout-marketplace/gallery-terminal.tsx +126 -96
  16. package/src/layout-marketplace/gallery.test.tsx +65 -1
  17. package/src/plugins/builtin/chart-composer/cli-options.ts +12 -9
  18. package/src/plugins/builtin/chart-composer/settings.ts +2 -13
  19. package/src/plugins/builtin/composite-plugins.ts +2 -0
  20. package/src/plugins/builtin/earnings/index.tsx +1 -0
  21. package/src/plugins/builtin/econ/index.tsx +1 -0
  22. package/src/plugins/builtin/futures/index.tsx +1 -0
  23. package/src/plugins/builtin/fx-matrix/index.tsx +1 -0
  24. package/src/plugins/builtin/ipo-calendar/index.tsx +1 -0
  25. package/src/plugins/builtin/market-halts/index.tsx +1 -0
  26. package/src/plugins/builtin/market-movers/index.tsx +1 -0
  27. package/src/plugins/builtin/options/analytics.test.ts +83 -0
  28. package/src/plugins/builtin/options/analytics.ts +110 -0
  29. package/src/plugins/builtin/options/index.tsx +17 -2
  30. package/src/plugins/builtin/options/table.ts +121 -51
  31. package/src/plugins/builtin/options/types.ts +22 -15
  32. package/src/plugins/builtin/options/view.test.tsx +24 -0
  33. package/src/plugins/builtin/options/view.tsx +91 -19
  34. package/src/plugins/builtin/plugin-module.test.ts +2 -4
  35. package/src/plugins/builtin/plugin-module.ts +0 -7
  36. package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
  37. package/src/plugins/builtin/scanner/index.ts +2 -0
  38. package/src/plugins/builtin/sectors/index.tsx +1 -0
  39. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +3 -5
  40. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  41. package/src/plugins/builtin/treasury-auctions/index.tsx +1 -0
  42. package/src/plugins/builtin/world-indices/index.tsx +1 -0
  43. package/src/plugins/builtin/world-venue-map/index.ts +27 -0
  44. package/src/plugins/builtin/world-venue-map/map.tsx +337 -0
  45. package/src/plugins/builtin/world-venue-map/model.test.ts +53 -0
  46. package/src/plugins/builtin/world-venue-map/model.ts +147 -0
  47. package/src/plugins/builtin/world-venue-map/pane.tsx +300 -0
  48. package/src/plugins/builtin/world-venue-map/world-outlines.ts +2 -0
  49. package/src/plugins/catalog-backend.ts +6 -44
  50. package/src/plugins/catalog-browser.ts +2 -0
  51. package/src/plugins/registry/pane-settings.test.ts +53 -0
  52. package/src/plugins/registry/pane-settings.ts +36 -3
  53. package/src/renderers/browser/ui-host.tsx +11 -0
  54. package/src/renderers/electrobun/bun/desktop/host-requests.ts +9 -2
  55. package/src/renderers/electrobun/bun/index.ts +1 -0
  56. package/src/renderers/electrobun/shared/protocol.ts +4 -0
  57. package/src/renderers/electrobun/view/ui-host.tsx +3 -0
  58. package/src/renderers/opentui/host.tsx +4 -0
  59. package/src/state/pane-table-export-registry.ts +31 -0
  60. package/src/time-series/range.ts +12 -0
  61. package/src/time-series/resolution.ts +3 -15
  62. package/src/time-series/spec.ts +5 -5
  63. package/src/types/plugin.ts +2 -20
  64. package/src/ui/host.tsx +7 -0
  65. package/src/utils/csv.test.ts +13 -0
  66. package/src/utils/csv.ts +37 -0
  67. package/src/utils/save-text-file.test.ts +24 -0
  68. package/src/utils/save-text-file.ts +36 -0
@@ -1,32 +1,81 @@
1
1
  import { TextAttributes } from "../../../ui";
2
2
  import type { DataTableCell } from "../../../components";
3
3
  import type { OptionContract, OptionsChain } from "../../../types/financials";
4
- import { blendHex, colors, hoverBg } from "../../../theme/colors";
4
+ import { blendHex, colors } from "../../../theme/colors";
5
5
  import { blendForContrast, contrastRatio } from "../../../theme/color-utils";
6
6
  import { formatCompact, formatNumber } from "../../../utils/format";
7
7
  import { formatMarketPrice } from "../../../market-data/market/format";
8
- import type { OptionColumn, OptionColumnId, OptionTableRow } from "./types";
8
+ import type {
9
+ OptionColumn,
10
+ OptionFieldId,
11
+ OptionTableRow,
12
+ } from "./types";
13
+ import type { OptionSide, OptionValuation } from "../options-calculator/model";
9
14
 
10
15
  type OptionColorRole = "call" | "put" | "price" | "activity" | "iv" | "strike";
11
16
 
17
+ type OptionFieldDef = {
18
+ id: OptionFieldId;
19
+ label: string;
20
+ header: string;
21
+ width: number;
22
+ description: string;
23
+ };
24
+
12
25
  const OPTION_TEXT_MIN_CONTRAST = 4.5;
13
26
 
14
- export const OPTION_COLUMNS: Array<Omit<OptionColumn, "headerColor">> = [
15
- { id: "callOpenInterest", label: "C OI", width: 6, align: "right" },
16
- { id: "callVolume", label: "C VOL", width: 6, align: "right" },
17
- { id: "callLast", label: "C LAST", width: 7, align: "right" },
18
- { id: "callIv", label: "C IV", width: 6, align: "right" },
19
- { id: "callBid", label: "C BID", width: 7, align: "right" },
20
- { id: "callAsk", label: "C ASK", width: 7, align: "right" },
21
- { id: "strike", label: "STRIKE", width: 9, align: "right" },
22
- { id: "putBid", label: "P BID", width: 7, align: "right" },
23
- { id: "putAsk", label: "P ASK", width: 7, align: "right" },
24
- { id: "putIv", label: "P IV", width: 6, align: "right" },
25
- { id: "putLast", label: "P LAST", width: 7, align: "right" },
26
- { id: "putVolume", label: "P VOL", width: 6, align: "right" },
27
- { id: "putOpenInterest", label: "P OI", width: 6, align: "right" },
27
+ export const OPTION_FIELD_DEFS: OptionFieldDef[] = [
28
+ { id: "bid", label: "Bid", header: "BID", width: 7, description: "Best bid price." },
29
+ { id: "ask", label: "Ask", header: "ASK", width: 7, description: "Best ask price." },
30
+ { id: "last", label: "Last", header: "LAST", width: 7, description: "Last traded price." },
31
+ { id: "delta", label: "Delta", header: "Δ", width: 6, description: "Price sensitivity to a $1 move in the underlying." },
32
+ { id: "gamma", label: "Gamma", header: "Γ", width: 7, description: "Delta sensitivity to a $1 move in the underlying." },
33
+ { id: "theta", label: "Theta", header: "Θ", width: 7, description: "Estimated value decay per calendar day." },
34
+ { id: "vega", label: "Vega", header: "VEGA", width: 7, description: "Price sensitivity to one volatility point." },
35
+ { id: "rho", label: "Rho", header: "RHO", width: 7, description: "Price sensitivity to one interest-rate point." },
36
+ { id: "iv", label: "Implied volatility", header: "IV", width: 6, description: "Volatility implied by the contract price." },
37
+ { id: "volume", label: "Volume", header: "VOL", width: 6, description: "Contracts traded in the current session." },
38
+ { id: "openInterest", label: "Open interest", header: "OI", width: 6, description: "Outstanding open contracts." },
28
39
  ];
29
40
 
41
+ export const DEFAULT_OPTION_FIELD_IDS: OptionFieldId[] = ["bid", "ask", "last", "delta", "gamma"];
42
+
43
+ const OPTION_FIELDS_BY_ID = new Map(OPTION_FIELD_DEFS.map((field) => [field.id, field]));
44
+
45
+ export function resolveOptionFieldIds(value: unknown): OptionFieldId[] {
46
+ if (!Array.isArray(value)) return [...DEFAULT_OPTION_FIELD_IDS];
47
+ const seen = new Set<OptionFieldId>();
48
+ const fields = value.filter((id): id is OptionFieldId => {
49
+ if (typeof id !== "string" || !OPTION_FIELDS_BY_ID.has(id as OptionFieldId) || seen.has(id as OptionFieldId)) {
50
+ return false;
51
+ }
52
+ seen.add(id as OptionFieldId);
53
+ return true;
54
+ });
55
+ return fields.length > 0 ? fields : [...DEFAULT_OPTION_FIELD_IDS];
56
+ }
57
+
58
+ function sideColumn(side: OptionSide, field: OptionFieldId): Omit<OptionColumn, "headerColor"> {
59
+ const definition = OPTION_FIELDS_BY_ID.get(field)!;
60
+ return {
61
+ id: `${side}${field[0]!.toUpperCase()}${field.slice(1)}` as OptionColumn["id"],
62
+ field,
63
+ side,
64
+ label: `${side === "call" ? "C" : "P"} ${definition.header}`,
65
+ width: definition.width,
66
+ align: "right",
67
+ };
68
+ }
69
+
70
+ export function createOptionColumns(fieldIds: readonly OptionFieldId[]): Array<Omit<OptionColumn, "headerColor">> {
71
+ const fields = resolveOptionFieldIds(fieldIds);
72
+ return [
73
+ ...fields.map((field) => sideColumn("call", field)),
74
+ { id: "strike", field: "strike", side: null, label: "STRIKE", width: 9, align: "right" },
75
+ ...[...fields].reverse().map((field) => sideColumn("put", field)),
76
+ ];
77
+ }
78
+
30
79
  export function buildStrikeList(chain: OptionsChain): number[] {
31
80
  const set = new Set<number>();
32
81
  for (const c of chain.calls) set.add(c.strike);
@@ -68,20 +117,32 @@ export function formatIv(value: number | undefined): string {
68
117
  return `${(value * 100).toFixed(1)}%`;
69
118
  }
70
119
 
71
- export function optionColumnColor(columnId: OptionColumnId, surface = colors.bg): string {
72
- return optionRoleColor(optionColumnRole(columnId), surface);
120
+ function formatGreek(value: number | undefined): string {
121
+ if (value == null || !Number.isFinite(value)) return "\u2014";
122
+ return value.toFixed(3).replace(/^(-?)0\./, "$1.");
123
+ }
124
+
125
+ export function optionColumnColor(
126
+ column: Pick<OptionColumn, "field" | "side">,
127
+ surface = colors.bg,
128
+ ): string {
129
+ return optionRoleColor(optionColumnRole(column), surface);
130
+ }
131
+
132
+ function optionContractForColumn(row: OptionTableRow, column: OptionColumn): OptionContract | undefined {
133
+ return column.side === "call" ? row.call : row.put;
73
134
  }
74
135
 
75
- function optionContractForColumn(row: OptionTableRow, columnId: OptionColumnId): OptionContract | undefined {
76
- return columnId.startsWith("call") ? row.call : row.put;
136
+ function optionGreeksForColumn(row: OptionTableRow, column: OptionColumn): OptionValuation | undefined {
137
+ return column.side === "call" ? row.callGreeks : row.putGreeks;
77
138
  }
78
139
 
79
- function optionColumnRole(columnId: OptionColumnId): OptionColorRole {
80
- if (columnId === "strike") return "strike";
81
- if (columnId.endsWith("Iv")) return "iv";
82
- if (columnId.endsWith("Volume") || columnId.endsWith("OpenInterest")) return "activity";
83
- if (columnId.endsWith("Bid") || columnId.endsWith("Ask")) return "price";
84
- return columnId.startsWith("call") ? "call" : "put";
140
+ function optionColumnRole(column: Pick<OptionColumn, "field" | "side">): OptionColorRole {
141
+ if (column.field === "strike") return "strike";
142
+ if (column.field === "iv") return "iv";
143
+ if (column.field === "volume" || column.field === "openInterest") return "activity";
144
+ if (column.field === "bid" || column.field === "ask") return "price";
145
+ return column.side ?? "strike";
85
146
  }
86
147
 
87
148
  function optionRoleThemeColor(role: OptionColorRole): string {
@@ -131,12 +192,12 @@ function optionMutedColor(surface: string): string {
131
192
  function optionMoneynessBackground(
132
193
  row: OptionTableRow,
133
194
  contract: OptionContract | undefined,
134
- columnId: OptionColumnId,
195
+ column: OptionColumn,
135
196
  rowState: { selected: boolean },
136
197
  ): string | undefined {
137
- if (rowState.selected) return undefined;
138
- const inTheMoney = inferColumnMoneyness(row, contract, columnId);
139
- const sideColor = columnId.startsWith("call") ? colors.positive : colors.negative;
198
+ if (rowState.selected || !column.side) return undefined;
199
+ const inTheMoney = inferColumnMoneyness(row, contract, column.side);
200
+ const sideColor = column.side === "call" ? colors.positive : colors.negative;
140
201
  return inTheMoney
141
202
  ? blendHex(colors.bg, sideColor, 0.13)
142
203
  : blendHex(colors.bg, colors.neutral, 0.055);
@@ -145,34 +206,43 @@ function optionMoneynessBackground(
145
206
  function inferColumnMoneyness(
146
207
  row: OptionTableRow,
147
208
  contract: OptionContract | undefined,
148
- columnId: OptionColumnId,
209
+ side: OptionSide,
149
210
  ): boolean {
150
211
  if (contract) return contract.inTheMoney;
151
- const oppositeContract = columnId.startsWith("call") ? row.put : row.call;
212
+ const oppositeContract = side === "call" ? row.put : row.call;
152
213
  return oppositeContract ? !oppositeContract.inTheMoney : false;
153
214
  }
154
215
 
155
- function formatOptionContractCell(contract: OptionContract | undefined, column: OptionColumn): string {
216
+ function formatOptionContractCell(
217
+ row: OptionTableRow,
218
+ contract: OptionContract | undefined,
219
+ column: OptionColumn,
220
+ ): string {
156
221
  if (!contract) return "—";
157
- switch (column.id) {
158
- case "callLast":
159
- case "putLast":
222
+ const greeks = optionGreeksForColumn(row, column);
223
+ switch (column.field) {
224
+ case "last":
160
225
  return formatMarketPrice(contract.lastPrice, { assetCategory: "OPT", maxWidth: column.width });
161
- case "callBid":
162
- case "putBid":
226
+ case "bid":
163
227
  return formatMarketPrice(contract.bid, { assetCategory: "OPT", maxWidth: column.width });
164
- case "callAsk":
165
- case "putAsk":
228
+ case "ask":
166
229
  return formatMarketPrice(contract.ask, { assetCategory: "OPT", maxWidth: column.width });
167
- case "callVolume":
168
- case "putVolume":
230
+ case "volume":
169
231
  return formatCompact(contract.volume);
170
- case "callOpenInterest":
171
- case "putOpenInterest":
232
+ case "openInterest":
172
233
  return formatCompact(contract.openInterest);
173
- case "callIv":
174
- case "putIv":
234
+ case "iv":
175
235
  return formatIv(contract.impliedVolatility);
236
+ case "delta":
237
+ return formatGreek(greeks?.delta);
238
+ case "gamma":
239
+ return formatGreek(greeks?.gamma);
240
+ case "theta":
241
+ return formatGreek(greeks?.thetaPerDay);
242
+ case "vega":
243
+ return formatGreek(greeks?.vegaPerPoint);
244
+ case "rho":
245
+ return formatGreek(greeks?.rhoPerPoint);
176
246
  case "strike":
177
247
  return formatStrikeLabel(contract.strike);
178
248
  }
@@ -187,7 +257,7 @@ export function renderOptionCell(
187
257
  const selectedColor = rowState.selected ? colors.selectedText : undefined;
188
258
  const rowSurface = rowState.selected ? colors.selected : colors.bg;
189
259
 
190
- if (column.id === "strike") {
260
+ if (column.field === "strike") {
191
261
  const backgroundColor = rowState.selected
192
262
  ? undefined
193
263
  : blendHex(colors.bg, row.isPositionStrike ? colors.borderFocused : colors.header, row.isPositionStrike ? 0.18 : 0.1);
@@ -200,12 +270,12 @@ export function renderOptionCell(
200
270
  };
201
271
  }
202
272
 
203
- const contract = optionContractForColumn(row, column.id);
204
- const backgroundColor = optionMoneynessBackground(row, contract, column.id, rowState);
273
+ const contract = optionContractForColumn(row, column);
274
+ const backgroundColor = optionMoneynessBackground(row, contract, column, rowState);
205
275
  const surface = backgroundColor ?? rowSurface;
206
276
  return {
207
- text: formatOptionContractCell(contract, column),
208
- color: selectedColor ?? (contract ? optionRoleColor(optionColumnRole(column.id), surface) : optionMutedColor(surface)),
277
+ text: formatOptionContractCell(row, contract, column),
278
+ color: selectedColor ?? (contract ? optionRoleColor(optionColumnRole(column), surface) : optionMutedColor(surface)),
209
279
  backgroundColor,
210
280
  };
211
281
  }
@@ -1,27 +1,34 @@
1
1
  import type { DataTableColumn } from "../../../components";
2
2
  import type { OptionContract } from "../../../types/financials";
3
+ import type { OptionSide, OptionValuation } from "../options-calculator/model";
3
4
 
4
- export type OptionColumnId =
5
- | "callLast"
6
- | "callBid"
7
- | "callAsk"
8
- | "callVolume"
9
- | "callOpenInterest"
10
- | "callIv"
11
- | "strike"
12
- | "putLast"
13
- | "putBid"
14
- | "putAsk"
15
- | "putVolume"
16
- | "putOpenInterest"
17
- | "putIv";
5
+ export type OptionFieldId =
6
+ | "bid"
7
+ | "ask"
8
+ | "last"
9
+ | "delta"
10
+ | "gamma"
11
+ | "theta"
12
+ | "vega"
13
+ | "rho"
14
+ | "iv"
15
+ | "volume"
16
+ | "openInterest";
18
17
 
19
- export type OptionColumn = DataTableColumn & { id: OptionColumnId };
18
+ export type OptionColumnId = "strike" | `${OptionSide}${Capitalize<OptionFieldId>}`;
19
+
20
+ export type OptionColumn = DataTableColumn & {
21
+ id: OptionColumnId;
22
+ field: OptionFieldId | "strike";
23
+ side: OptionSide | null;
24
+ };
20
25
 
21
26
  export interface OptionTableRow {
22
27
  strike: number;
23
28
  call?: OptionContract;
24
29
  put?: OptionContract;
30
+ callGreeks?: OptionValuation;
31
+ putGreeks?: OptionValuation;
25
32
  isPositionStrike: boolean;
26
33
  }
27
34
 
@@ -176,6 +176,30 @@ test("defaults the table around the nearest strike to the current quote", async
176
176
  expect(frame).not.toContain(" 50 ");
177
177
  });
178
178
 
179
+ test("shows volatility statistics and mirrored default Greeks", async () => {
180
+ const provider = createTestDataProvider({
181
+ getOptionsChain: async () => makeChain([100, 101], 101),
182
+ });
183
+ setSharedMarketDataCoordinator(new MarketDataCoordinator(provider));
184
+
185
+ await act(async () => {
186
+ testSetup = await testRender(
187
+ <OptionsHarness ticker={makeTicker("AAPL")} quotePrice={101} />,
188
+ { width: 124, height: 16 },
189
+ );
190
+ });
191
+ await renderSettled();
192
+
193
+ const frame = testSetup!.captureCharFrame();
194
+ expect(frame).toContain("ATM IV 20.0%");
195
+ expect(frame).toContain("HV30 —");
196
+ expect(frame).toContain("EXP VOL");
197
+ expect(frame).toContain("C Δ");
198
+ expect(frame).toContain("C Γ");
199
+ expect(frame).toContain("P Γ");
200
+ expect(frame).toContain("P Δ");
201
+ });
202
+
179
203
  test("streams live quotes without resetting manual scroll", async () => {
180
204
  const strikes = Array.from({ length: 100 }, (_, index) => 50 + index);
181
205
  const subscriptions: QuoteSubscriptionTarget[][] = [];
@@ -3,6 +3,7 @@ import { Box, Text } from "../../../ui";
3
3
  import { usePaneSettingValue, usePaneTicker } from "../../../state/app/context";
4
4
  import { colors } from "../../../theme/colors";
5
5
  import { isPlainKey } from "../../../utils/keyboard";
6
+ import { formatCompact } from "../../../utils/format";
6
7
  import { formatExpDate, resolveOptionsTarget } from "../../../utils/options";
7
8
  import { useOptionsQuery, useResolvedEntryValue, useTickerFinancials } from "../../../market-data/hooks";
8
9
  import {
@@ -21,15 +22,19 @@ import {
21
22
  type OptionSide,
22
23
  } from "../options-calculator/model";
23
24
  import { buildChainCalcParams, resolveCalcSide } from "./calc-seed";
25
+ import { calculateOptionGreeks, calculateOptionsSummary, type OptionsSummary } from "./analytics";
24
26
  import {
25
- OPTION_COLUMNS,
27
+ DEFAULT_OPTION_FIELD_IDS,
26
28
  buildStrikeList,
29
+ createOptionColumns,
27
30
  findNearestStrikeIndex,
31
+ formatIv,
28
32
  optionColumnColor,
29
33
  renderOptionCell,
30
34
  resolveDefaultStrikeTarget,
35
+ resolveOptionFieldIds,
31
36
  } from "./table";
32
- import type { OptionColumn, OptionTableRow, OptionsViewProps } from "./types";
37
+ import type { OptionColumn, OptionFieldId, OptionTableRow, OptionsViewProps } from "./types";
33
38
  import {
34
39
  buildOptionQuoteTargets,
35
40
  overlayOptionRowQuotes,
@@ -39,6 +44,48 @@ import {
39
44
  import { useOptionsAccessFooter } from "./footer";
40
45
  import { useLiveStreamingSetting } from "../shared/live-streaming";
41
46
 
47
+ type SummaryMetric = { label: string; value: string };
48
+
49
+ function formatRatio(value: number | null | undefined): string {
50
+ return value == null || !Number.isFinite(value) ? "—" : value.toFixed(2);
51
+ }
52
+
53
+ function SummaryRow({ metrics }: { metrics: SummaryMetric[] }) {
54
+ return (
55
+ <Box flexDirection="row" height={1} gap={3} overflow="hidden">
56
+ {metrics.map((metric) => (
57
+ <Box key={metric.label} flexDirection="row" flexShrink={0}>
58
+ <Text fg={colors.textDim}>{`${metric.label} `}</Text>
59
+ <Text fg={colors.textBright}>{metric.value}</Text>
60
+ </Box>
61
+ ))}
62
+ </Box>
63
+ );
64
+ }
65
+
66
+ function OptionsSummaryStrip({ summary, secondary }: {
67
+ summary: OptionsSummary | null;
68
+ secondary: boolean;
69
+ }) {
70
+ const volatility: SummaryMetric[] = [
71
+ { label: "ATM IV", value: formatIv(summary?.atmImpliedVolatility ?? undefined) },
72
+ { label: "HV30", value: formatIv(summary?.historicalVolatility30d ?? undefined) },
73
+ { label: "IV/HV", value: formatRatio(summary?.impliedHistoricalRatio) },
74
+ ];
75
+ return (
76
+ <Box flexDirection="column" height={secondary ? 2 : 1}>
77
+ <SummaryRow metrics={volatility} />
78
+ {secondary && (
79
+ <SummaryRow metrics={[
80
+ { label: "EXP VOL", value: summary ? formatCompact(summary.expirationVolume) : "—" },
81
+ { label: "P/C VOL", value: formatRatio(summary?.putCallVolumeRatio) },
82
+ { label: "P/C OI", value: formatRatio(summary?.putCallOpenInterestRatio) },
83
+ ]} />
84
+ )}
85
+ </Box>
86
+ );
87
+ }
88
+
42
89
  export function OptionsView({ width, height, focused, onCapture = () => {} }: OptionsViewProps) {
43
90
  const { ticker, financials } = usePaneTicker();
44
91
  const { createPaneFromTemplate } = usePluginAppActions();
@@ -61,6 +108,9 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
61
108
  const effectiveTicker = target?.effectiveTicker ?? "";
62
109
  const effectiveExchange = target?.effectiveExchange ?? "";
63
110
  const underlyingFinancials = useTickerFinancials(isOpt ? effectiveTicker : null, null);
111
+ const underlying = isOpt ? underlyingFinancials : financials;
112
+ const spot = underlying?.quote?.price;
113
+ const dividendYield = underlying?.fundamentals?.dividendYield;
64
114
  const instrument = target?.instrument ?? null;
65
115
  const baseRequest = target
66
116
  ? {
@@ -74,6 +124,8 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
74
124
  }
75
125
  : null;
76
126
  const [chainRefreshMinutes] = usePaneSettingValue<string>("chainRefreshMinutes", "");
127
+ const [storedOptionFieldIds] = usePaneSettingValue<OptionFieldId[]>("optionColumnIds", DEFAULT_OPTION_FIELD_IDS);
128
+ const optionFieldIds = useMemo(() => resolveOptionFieldIds(storedOptionFieldIds), [storedOptionFieldIds]);
77
129
  const initialChainEntry = useOptionsQuery(baseRequest);
78
130
  const initialChain = useResolvedEntryValue(initialChainEntry);
79
131
  const selectedExpiration = initialChain?.expirationDates[expIdx];
@@ -157,12 +209,27 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
157
209
  () => new Map(strikeChain?.puts.map((p) => [p.strike, p]) ?? []),
158
210
  [strikeChain],
159
211
  );
160
- const snapshotRows = useMemo<OptionTableRow[]>(() => strikes.map((strike) => ({
161
- strike,
162
- call: callsByStrike.get(strike),
163
- put: putsByStrike.get(strike),
164
- isPositionStrike: !!parsed && Math.abs(strike - parsed.strike) < 0.01,
165
- })), [callsByStrike, parsed, putsByStrike, strikes]);
212
+ const snapshotRows = useMemo<OptionTableRow[]>(() => {
213
+ const now = Date.now();
214
+ return strikes.map((strike) => {
215
+ const call = callsByStrike.get(strike);
216
+ const put = putsByStrike.get(strike);
217
+ return {
218
+ strike,
219
+ call,
220
+ put,
221
+ callGreeks: calculateOptionGreeks(call, "call", spot, dividendYield, now),
222
+ putGreeks: calculateOptionGreeks(put, "put", spot, dividendYield, now),
223
+ isPositionStrike: !!parsed && Math.abs(strike - parsed.strike) < 0.01,
224
+ };
225
+ });
226
+ }, [callsByStrike, dividendYield, parsed, putsByStrike, spot, strikes]);
227
+ const summary = useMemo(
228
+ () => strikeChain
229
+ ? calculateOptionsSummary(strikeChain, spot, underlying?.priceHistory ?? [])
230
+ : null,
231
+ [spot, strikeChain, underlying?.priceHistory],
232
+ );
166
233
  const visibleStrikeRange = visibleStrikeViewport?.key === viewportKey
167
234
  ? visibleStrikeViewport.range
168
235
  : null;
@@ -210,10 +277,10 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
210
277
  ),
211
278
  [optionQuoteEntries, optionQuoteFreshness, optionQuoteTargets],
212
279
  );
213
- const optionColumns: OptionColumn[] = OPTION_COLUMNS.map((column) => ({
280
+ const optionColumns = useMemo<OptionColumn[]>(() => createOptionColumns(optionFieldIds).map((column) => ({
214
281
  ...column,
215
- headerColor: optionColumnColor(column.id, colors.panel),
216
- }));
282
+ headerColor: optionColumnColor(column, colors.panel),
283
+ })), [optionFieldIds]);
217
284
 
218
285
  const selectedRow = rows[strikeIdx] ?? null;
219
286
  const calcParams = useMemo(() => buildChainCalcParams({
@@ -222,9 +289,9 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
222
289
  side: resolveCalcSide(calcSide, parsed?.side, selectedRow),
223
290
  // On an option ticker the pane quote is the contract's own price, so load
224
291
  // the underlying snapshot rather than silently using the option mark as spot.
225
- spot: (isOpt ? underlyingFinancials : financials)?.quote?.price,
226
- dividendYield: (isOpt ? underlyingFinancials : financials)?.fundamentals?.dividendYield,
227
- }), [calcSide, effectiveTicker, financials, isOpt, parsed?.side, selectedRow, underlyingFinancials]);
292
+ spot,
293
+ dividendYield,
294
+ }), [calcSide, dividendYield, effectiveTicker, parsed?.side, selectedRow, spot]);
228
295
 
229
296
  const openCalculator = useCallback(() => {
230
297
  if (!calcParams) return;
@@ -243,10 +310,10 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
243
310
  rowState: { selected: boolean },
244
311
  ) => {
245
312
  const cell = renderOptionCell(row, column, index, rowState);
246
- if (column.id === "strike") return cell;
313
+ if (!column.side) return cell;
247
314
  // Clicking a call or put cell is the mouse way to choose which contract
248
315
  // [c]alc opens, so it has to select the row itself as well.
249
- const side: OptionSide = column.id.startsWith("call") ? "call" : "put";
316
+ const side: OptionSide = column.side;
250
317
  return {
251
318
  ...cell,
252
319
  onMouseDown: () => {
@@ -277,12 +344,12 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
277
344
 
278
345
  useEffect(() => {
279
346
  if (strikes.length === 0 || userSelectedStrikeRef.current) return;
280
- const targetStrike = resolveDefaultStrikeTarget(parsed?.strike, financials?.quote?.price);
347
+ const targetStrike = resolveDefaultStrikeTarget(parsed?.strike, spot);
281
348
  if (targetStrike == null) return;
282
349
  setScrollToIndexAlign("center");
283
350
  setStrikeIdx(findNearestStrikeIndex(strikes, targetStrike));
284
351
  setAutoScrollVersion((version) => version + 1);
285
- }, [expIdx, financials?.quote?.price, parsed?.strike, strikes]);
352
+ }, [expIdx, parsed?.strike, spot, strikes]);
286
353
 
287
354
  useShortcut((event) => {
288
355
  if (event.defaultPrevented || event.propagationStopped || event.targetEditable) return;
@@ -399,13 +466,18 @@ export function OptionsView({ width, height, focused, onCapture = () => {} }: Op
399
466
  ? ticker.metadata.positions.reduce((sum, p) => sum + p.shares, 0)
400
467
  : 0;
401
468
  const expirationTabsWidth = Math.max(width - 9 - (loading ? 2 : 0), 8);
402
- const tableHeight = Math.max(1, height - 1 - (isOpt && parsed ? 1 : 0));
469
+ const summaryRowCount = height >= 10 ? 2 : height >= 7 ? 1 : 0;
470
+ const tableHeight = Math.max(1, height - 1 - summaryRowCount - (isOpt && parsed ? 1 : 0));
403
471
  // The strip scrolls; without a marker a clipped last date reads as the last expiry.
404
472
  const expirationStripOverflows = chain.expirationDates
405
473
  .reduce((total, ts) => total + formatExpDate(ts).length + 2, 0) > expirationTabsWidth;
406
474
 
407
475
  return (
408
476
  <Box flexDirection="column" flexGrow={1} paddingX={1} onMouseDown={() => { if (!interactive) enterInteractive(); }}>
477
+ {summaryRowCount > 0 && (
478
+ <OptionsSummaryStrip summary={summary} secondary={summaryRowCount > 1} />
479
+ )}
480
+
409
481
  <Box flexDirection="row" height={1} gap={1}>
410
482
  <Text fg={colors.textDim}>Exp:</Text>
411
483
  <Box width={expirationTabsWidth} height={1} overflow="hidden">
@@ -19,8 +19,7 @@ describe("composeBuiltinPlugin", () => {
19
19
  test("combines every declarative contribution under the parent plugin", async () => {
20
20
  const registeredBrokers: string[] = [];
21
21
  const first: PluginModule = {
22
- cliCommands: [{ name: "legacy", description: "Legacy", execute: () => {} }],
23
- cli: { commands: [{ name: "typed", summary: "Typed" }] },
22
+ cliCommands: [{ name: "example", description: "Example", execute: () => {} }],
24
23
  panes: [{ id: "one", name: "One", component: () => null, defaultPosition: "right" }],
25
24
  paneTemplates: [{ id: "one-pane", paneId: "one", label: "One", description: "One" }],
26
25
  capabilities: [{ id: "capability-one" } as never],
@@ -40,8 +39,7 @@ describe("composeBuiltinPlugin", () => {
40
39
  modules: [first, second],
41
40
  });
42
41
 
43
- expect(plugin.cliCommands?.map((command) => command.name)).toEqual(["legacy"]);
44
- expect(plugin.cli?.commands?.map((command) => command.name)).toEqual(["typed"]);
42
+ expect(plugin.cliCommands?.map((command) => command.name)).toEqual(["example"]);
45
43
  expect(plugin.panes?.map((pane) => pane.id)).toEqual(["one", "two"]);
46
44
  expect(plugin.paneTemplates?.map((template) => template.id)).toEqual(["one-pane"]);
47
45
  expect(plugin.capabilities?.map((capability) => capability.id)).toEqual(["capability-one"]);
@@ -2,7 +2,6 @@ import { Fragment, createElement, type ReactNode } from "react";
2
2
  import type {
3
3
  GloomPlugin,
4
4
  GloomPluginContext,
5
- PluginCliDescriptor,
6
5
  } from "../../types/plugin";
7
6
 
8
7
  type PluginMetadataKey = "id" | "name" | "version" | "description" | "toggleable" | "order";
@@ -12,7 +11,6 @@ export type PluginModule = Omit<GloomPlugin, PluginMetadataKey>;
12
11
 
13
12
  const HANDLED_MODULE_KEYS = [
14
13
  "cliCommands",
15
- "cli",
16
14
  "setup",
17
15
  "dispose",
18
16
  "panes",
@@ -23,11 +21,8 @@ const HANDLED_MODULE_KEYS = [
23
21
  ] as const satisfies readonly (keyof PluginModule)[];
24
22
 
25
23
  type MissingModuleKey = Exclude<keyof PluginModule, typeof HANDLED_MODULE_KEYS[number]>;
26
- type MissingCliDescriptorKey = Exclude<keyof PluginCliDescriptor, "commands">;
27
24
  const ALL_MODULE_KEYS_HANDLED: MissingModuleKey extends never ? true : never = true;
28
- const ALL_CLI_DESCRIPTOR_KEYS_HANDLED: MissingCliDescriptorKey extends never ? true : never = true;
29
25
  void ALL_MODULE_KEYS_HANDLED;
30
- void ALL_CLI_DESCRIPTOR_KEYS_HANDLED;
31
26
 
32
27
  interface CompositePluginOptions extends PluginMetadata {
33
28
  modules: readonly PluginModule[];
@@ -72,7 +67,6 @@ function composeSlots(modules: readonly PluginModule[]): GloomPlugin["slots"] {
72
67
  export function composeBuiltinPlugin(options: CompositePluginOptions): GloomPlugin {
73
68
  const { modules, ...metadata } = options;
74
69
  const cliCommands = modules.flatMap((module) => module.cliCommands ?? []);
75
- const cliDescriptors = modules.flatMap((module) => module.cli?.commands ?? []);
76
70
  const panes = modules.flatMap((module) => module.panes ?? []);
77
71
  const paneTemplates = modules.flatMap((module) => module.paneTemplates ?? []);
78
72
  const capabilities = modules.flatMap((module) => module.capabilities ?? []);
@@ -83,7 +77,6 @@ export function composeBuiltinPlugin(options: CompositePluginOptions): GloomPlug
83
77
  return {
84
78
  ...metadata,
85
79
  ...(cliCommands.length > 0 ? { cliCommands } : {}),
86
- ...(cliDescriptors.length > 0 ? { cli: { commands: cliDescriptors } } : {}),
87
80
  ...(panes.length > 0 ? { panes } : {}),
88
81
  ...(paneTemplates.length > 0 ? { paneTemplates } : {}),
89
82
  ...(capabilities.length > 0 ? { capabilities } : {}),
@@ -48,6 +48,7 @@ export const portfolioListModule: PluginModule = {
48
48
  defaultMode: "floating",
49
49
  defaultWidth: "40%",
50
50
  tickerSource: true,
51
+ tableExport: true,
51
52
  quickSettings: [LIVE_STREAMING_QUICK_SETTING],
52
53
  portableShare: {
53
54
  private: {
@@ -72,6 +72,7 @@ export const scannerModule: PluginModule = {
72
72
  defaultPosition: "right",
73
73
  defaultMode: "floating",
74
74
  defaultFloatingSize: { width: 76, height: 26 },
75
+ tableExport: true,
75
76
  settings: hiloSettings(),
76
77
  },
77
78
  {
@@ -82,6 +83,7 @@ export const scannerModule: PluginModule = {
82
83
  defaultPosition: "right",
83
84
  defaultMode: "floating",
84
85
  defaultFloatingSize: { width: 88, height: 28 },
86
+ tableExport: true,
85
87
  settings: flowSettings(),
86
88
  },
87
89
  ],
@@ -320,6 +320,7 @@ export const sectorsModule: PluginModule = {
320
320
  defaultPosition: "right",
321
321
  defaultMode: "floating",
322
322
  defaultFloatingSize: { width: 82, height: 18 },
323
+ tableExport: true,
323
324
  settings: (context) => ({
324
325
  title: "Sector Performance Settings",
325
326
  values: {