gloomberb 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/bin/gloomberb +9 -1
  2. package/package.json +1 -1
  3. package/src/api-client/central-bank-rates.ts +3 -0
  4. package/src/api-client/types.ts +1 -1
  5. package/src/app/pane-runtime/index.tsx +7 -5
  6. package/src/app/runtime/broker-import.test.tsx +89 -0
  7. package/src/app/runtime/broker-import.ts +29 -8
  8. package/src/brokers/sync-broker-instance.ts +67 -0
  9. package/src/cli/commands/overview.ts +6 -2
  10. package/src/cli/native-smoke.ts +2 -0
  11. package/src/components/chart/composite/composite-chart.tsx +21 -1
  12. package/src/components/chart/composite/format.ts +71 -8
  13. package/src/components/chart/composite/fx-precision.test.ts +21 -3
  14. package/src/components/chart/composite/scene.ts +13 -1
  15. package/src/components/chart/composite/types.ts +4 -0
  16. package/src/components/layout/market-summary.ts +4 -2
  17. package/src/components/ticker/badge/format.test.ts +3 -3
  18. package/src/components/ticker/badge/format.ts +11 -9
  19. package/src/components/ticker/badge/text.test.tsx +6 -6
  20. package/src/components/ui/index.ts +2 -1
  21. package/src/components/ui/multi-select/dialog.tsx +46 -4
  22. package/src/components/ui/number-prompt-dialog.tsx +71 -0
  23. package/src/core/state/app/state.ts +8 -2
  24. package/src/market-data/coordinator/events.ts +5 -0
  25. package/src/market-data/coordinator/index.ts +44 -10
  26. package/src/market-data/market/format.test.ts +54 -0
  27. package/src/market-data/market/format.ts +164 -5
  28. package/src/market-data/query-store.test.ts +87 -0
  29. package/src/market-data/query-store.ts +66 -9
  30. package/src/plugins/builtin/alerts/format.ts +4 -3
  31. package/src/plugins/builtin/analytics/display.ts +3 -3
  32. package/src/plugins/builtin/analytics/index.test.tsx +22 -22
  33. package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
  34. package/src/plugins/builtin/analytics/pane-model.ts +8 -8
  35. package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
  36. package/src/plugins/builtin/analytics/risk-client.ts +6 -8
  37. package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
  38. package/src/plugins/builtin/analytics/risk-model.ts +8 -0
  39. package/src/plugins/builtin/analytics/view.tsx +2 -2
  40. package/src/plugins/builtin/backtest/engine.test.ts +0 -1
  41. package/src/plugins/builtin/backtest/engine.ts +1 -4
  42. package/src/plugins/builtin/backtest/pane.tsx +1 -1
  43. package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
  44. package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
  45. package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
  46. package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
  47. package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
  48. package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
  49. package/src/plugins/builtin/chart-composer/pane.tsx +50 -3
  50. package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
  51. package/src/plugins/builtin/chart-composer/presets.ts +48 -0
  52. package/src/plugins/builtin/chart-composer/settings.ts +42 -11
  53. package/src/plugins/builtin/chat/chat.test.tsx +2 -2
  54. package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
  55. package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
  56. package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
  57. package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
  58. package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
  59. package/src/plugins/builtin/congress-trades/table.tsx +1 -1
  60. package/src/plugins/builtin/cot/client.ts +2 -1
  61. package/src/plugins/builtin/cot/model.test.ts +9 -0
  62. package/src/plugins/builtin/cot/model.ts +30 -1
  63. package/src/plugins/builtin/cot/pane.tsx +2 -4
  64. package/src/plugins/builtin/credit-conditions/client.ts +2 -1
  65. package/src/plugins/builtin/crypto-board/client.ts +2 -1
  66. package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
  67. package/src/plugins/builtin/crypto-board/model.ts +11 -3
  68. package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
  69. package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
  70. package/src/plugins/builtin/custom-view/pane.tsx +18 -7
  71. package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
  72. package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
  73. package/src/plugins/builtin/debt-maturities/client.ts +16 -3
  74. package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
  75. package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
  76. package/src/plugins/builtin/debt-maturities/model.ts +2 -1
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
  78. package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
  79. package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
  80. package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
  81. package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
  82. package/src/plugins/builtin/econ/index.tsx +4 -2
  83. package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
  84. package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
  85. package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
  86. package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
  87. package/src/plugins/builtin/equity-screener/headless.ts +18 -10
  88. package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
  89. package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
  90. package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
  91. package/src/plugins/builtin/futures/table.tsx +4 -3
  92. package/src/plugins/builtin/futures-curve/client.ts +2 -1
  93. package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
  94. package/src/plugins/builtin/futures-curve/model.ts +18 -14
  95. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  96. package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
  97. package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
  98. package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
  99. package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
  100. package/src/plugins/builtin/insider/index.tsx +15 -12
  101. package/src/plugins/builtin/insider/pane.test.tsx +24 -0
  102. package/src/plugins/builtin/market-movers/headless.ts +2 -2
  103. package/src/plugins/builtin/market-movers/model.ts +23 -10
  104. package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
  105. package/src/plugins/builtin/market-movers/screener.ts +3 -0
  106. package/src/plugins/builtin/market-movers/table.ts +2 -2
  107. package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
  108. package/src/plugins/builtin/market-valuation/client.ts +6 -1
  109. package/src/plugins/builtin/money-markets/client.ts +2 -1
  110. package/src/plugins/builtin/money-markets/pane.tsx +0 -1
  111. package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
  112. package/src/plugins/builtin/options/footer.test.ts +2 -2
  113. package/src/plugins/builtin/options/footer.ts +2 -2
  114. package/src/plugins/builtin/options/market-reference.test.ts +2 -2
  115. package/src/plugins/builtin/options/market-reference.ts +14 -2
  116. package/src/plugins/builtin/options/table.ts +5 -8
  117. package/src/plugins/builtin/options/view.test.tsx +2 -2
  118. package/src/plugins/builtin/options/view.tsx +1 -1
  119. package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
  120. package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
  121. package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
  122. package/src/plugins/builtin/options-calculator/surface.ts +13 -6
  123. package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
  124. package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
  125. package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
  126. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
  127. package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
  128. package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
  129. package/src/plugins/builtin/rate-path/client.ts +2 -1
  130. package/src/plugins/builtin/rate-path/headless.ts +1 -1
  131. package/src/plugins/builtin/rate-path/pane.tsx +0 -1
  132. package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
  133. package/src/plugins/builtin/realized-vol/headless.ts +1 -1
  134. package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
  135. package/src/plugins/builtin/realized-vol/model.ts +24 -9
  136. package/src/plugins/builtin/relative-rotation/client.ts +2 -1
  137. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
  138. package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
  139. package/src/plugins/builtin/risk-factors/data.ts +8 -1
  140. package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
  141. package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
  142. package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
  143. package/src/plugins/builtin/short-volume/client.ts +2 -1
  144. package/src/plugins/builtin/short-volume/pane.tsx +0 -1
  145. package/src/plugins/builtin/thirteenf/headless.ts +3 -3
  146. package/src/plugins/builtin/thirteenf/model.ts +14 -4
  147. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
  148. package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
  149. package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
  150. package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
  151. package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
  152. package/src/plugins/builtin/thirteenf/table.ts +9 -3
  153. package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
  154. package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
  155. package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
  156. package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
  157. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  158. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
  159. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
  160. package/src/plugins/builtin/time-sales/model.test.ts +17 -1
  161. package/src/plugins/builtin/time-sales/model.ts +14 -3
  162. package/src/plugins/builtin/time-sales/pane.tsx +8 -4
  163. package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
  164. package/src/plugins/builtin/vol-surface/client.ts +4 -1
  165. package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
  166. package/src/plugins/builtin/vol-surface/model.ts +14 -2
  167. package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
  168. package/src/plugins/builtin/volatility/client.test.ts +13 -8
  169. package/src/plugins/builtin/volatility/client.ts +3 -2
  170. package/src/plugins/builtin/volatility/headless.test.ts +2 -2
  171. package/src/plugins/builtin/volatility/model.test.ts +4 -0
  172. package/src/plugins/builtin/volatility/model.ts +6 -7
  173. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
  174. package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
  175. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
  176. package/src/renderers/electrobun/bun/index.ts +2 -1
  177. package/src/renderers/opentui/render-timing-smoke.ts +20 -0
  178. package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
  179. package/src/sources/yahoo-finance.test.ts +5 -1
  180. package/src/sources/yahoo-finance.ts +2 -1
  181. package/src/time-series/forward-valuation.ts +6 -0
  182. package/src/time-series/resolve.ts +5 -1
  183. package/src/time-series/valuation-currency.test.ts +2 -0
  184. package/src/time-series/valuation-currency.ts +3 -1
  185. package/src/utils/exchange-rate-snapshot.test.ts +12 -1
  186. package/src/utils/exchange-rate-snapshot.ts +3 -2
  187. package/src/utils/format.ts +55 -20
  188. package/src/utils/fx-market-hours.test.ts +28 -0
  189. package/src/utils/fx-market-hours.ts +42 -0
@@ -147,9 +147,9 @@ test("actual analytics account switching changes quote, sector denominator and h
147
147
  await act(async () => { setup!.mockInput.pressArrow("right"); await setup!.renderOnce(); }); await settleFrame(setup!, 20);
148
148
  expect(latestState.paneState[paneId]?.portfolioId).toBe("b");
149
149
  const frame = setup!.captureCharFrame();
150
- expect(frame).toContain("Val 4k");
151
- expect(frame).toContain("P&L +800");
152
- expect(frame).toContain("Technology 50.0% 2k");
150
+ expect(frame).toContain("Val 4.0k");
151
+ expect(frame).toContain("P&L +800.00");
152
+ expect(frame).toContain("Technology 50.0% 2.0k");
153
153
  expect(frame).not.toContain("Weights unavailable");
154
154
  const requested = readChart.mock.calls.filter(([request]) => request.instrument.symbol === "ACME").map(([request]) => request.instrument.instrument?.conId);
155
155
  expect(requested).toContain(101); expect(requested).toContain(202);
@@ -163,7 +163,7 @@ for (const portfolioId of ["a", "b"]) test(`actual analytics ${portfolioId} reje
163
163
  await act(async () => f.coordinator.primeCachedFinancials([{ instrument: instrumentFromTicker(ticker, "ACME", { portfolioId })!, financials: financials("ACME", 300) }]));
164
164
  await settleFrame(setup!, 20);
165
165
  expect(setup!.captureCharFrame()).toContain("60.0%");
166
- expect(setup!.captureCharFrame()).toContain("Val 5k");
166
+ expect(setup!.captureCharFrame()).toContain("Val 5.0k");
167
167
  });
168
168
 
169
169
  test("actual shared-portfolio preview uses its selected quote and history contracts", async () => {
@@ -195,7 +195,7 @@ test("unresolved selected contract skips market requests, keeps its own mark and
195
195
  restore.push(() => quotes.mockRestore(), () => charts.mockRestore());
196
196
  await render(f, "b", new Map([["ACME", financials("ACME", 999)]]));
197
197
  const frame = setup!.captureCharFrame();
198
- expect(frame).toContain("Technology 50.0% 2k");
198
+ expect(frame).toContain("Technology 50.0% 2.0k");
199
199
  expect(frame).toContain("Broker contract unavailable for ACME");
200
200
  expect(quotes.mock.calls.filter(([request]) => request.symbol === "ACME")).toEqual([]);
201
201
  expect(charts.mock.calls.filter(([request]) => request.instrument.symbol === "ACME")).toEqual([]);
@@ -249,7 +249,7 @@ test("actual PF rejects another account's symbol-only quote on a scoped cache mi
249
249
  const f = fixture(false, "b");
250
250
  await render(f, "b", new Map([["ACME", financials("ACME", 999)]]), false, "portfolio-list");
251
251
  const frame = setup!.captureCharFrame();
252
- expect(frame).toContain("2k"); expect(frame).toContain("50.00%"); expect(frame).not.toContain("9.99k");
252
+ expect(frame).toContain("2.0k"); expect(frame).toContain("50.00%"); expect(frame).not.toContain("10.0k");
253
253
  });
254
254
 
255
255
  test("actual Kelly uses scoped bankroll, current holding and price without research quote overriding them", async () => {
@@ -57,7 +57,7 @@ const sessionError = (error: unknown) =>
57
57
  export const portfolioRiskCache = createPluginCache<RiskMarketSnapshot>({
58
58
  kind: "portfolio-risk",
59
59
  source: "gloom-cloud",
60
- schemaVersion: 1,
60
+ schemaVersion: 2,
61
61
  policy: { staleMs: 2 * 60_000, expireMs: 24 * 60 * 60_000 },
62
62
  });
63
63
  type RiskCloudClient = Pick<
@@ -288,8 +288,9 @@ export async function fetchPortfolioRiskMarket(
288
288
  const start = new Date(now);
289
289
  start.setUTCMonth(start.getUTCMonth() - 18);
290
290
  start.setUTCDate(1);
291
+ const today = now.toISOString().slice(0, 10);
291
292
  const startDate = start.toISOString().slice(0, 10),
292
- endDate = new Date(Date.parse(now.toISOString().slice(0, 10)) - 86_400_000)
293
+ endDate = new Date(Date.parse(today) - 86_400_000)
293
294
  .toISOString()
294
295
  .slice(0, 10);
295
296
  const histories: RiskMarketHistory[] = new Array(unique.length);
@@ -307,7 +308,9 @@ export async function fetchPortfolioRiskMarket(
307
308
  {
308
309
  interval: "1day",
309
310
  startDate,
310
- endDate,
311
+ // validateRiskHistory drops today's bar; ending on yesterday
312
+ // loses yesterday's close for most listings.
313
+ endDate: today,
311
314
  outputsize: 1000,
312
315
  rangeKey: "2Y",
313
316
  },
@@ -364,11 +367,6 @@ export async function fetchPortfolioRiskMarket(
364
367
  );
365
368
  }
366
369
  }
367
- const closeMarked = histories.filter((row) => row?.closeMark).length;
368
- if (closeMarked)
369
- warnings.push(
370
- `${closeMarked} instrument${closeMarked === 1 ? "" : "s"} had no current quote; holdings are weighted at the latest completed close.`,
371
- );
372
370
  return {
373
371
  histories,
374
372
  yields: fred[0]!.status === "fulfilled" ? fred[0]!.value : null,
@@ -29,13 +29,16 @@ function holding(symbol: string, quantity = 1): TickerRecord {
29
29
  },
30
30
  };
31
31
  }
32
- function market(): RiskMarketSnapshot {
32
+ function market(staleQuotes: readonly string[] = []): RiskMarketSnapshot {
33
33
  return {
34
34
  histories: RISK_FACTOR_INSTRUMENTS.map((instrument) => {
35
35
  const history = riskHistory();
36
36
  history.providerMeta!.normalizedSymbol = instrument.symbol;
37
37
  history.providerMeta!.normalizedExchange = instrument.exchange;
38
- const quote = riskQuote(instrument.symbol, instrument.exchange),
38
+ const quote = {
39
+ ...riskQuote(instrument.symbol, instrument.exchange),
40
+ ...(staleQuotes.includes(instrument.symbol) ? { stale: true } : {}),
41
+ },
39
42
  valid = validateRiskHistory(history, instrument, quote, now);
40
43
  return { instrument, currency: "USD", quote, ...valid, error: null };
41
44
  }),
@@ -96,6 +99,22 @@ test("factor proxies resolve on their own listing venue", () => {
96
99
  expect(factor.samples).toBe(60);
97
100
  expect(factor.value).not.toBeNull();
98
101
  });
102
+ test("only held positions marked at a close raise the close-mark warning", () => {
103
+ // Factor proxies with no current quote never weight a holding.
104
+ const snapshot = market(["IWM", "IWD", "IWF", "MTUM", "IEF", "HYG"]);
105
+ const warning = (model: ReturnType<typeof buildPortfolioRisk>) =>
106
+ model.warnings.filter((row) => row.includes("no current quote"));
107
+ expect(
108
+ warning(buildPortfolioRisk(portfolio, [holding("SPY")], snapshot)),
109
+ ).toEqual([]);
110
+ expect(
111
+ warning(
112
+ buildPortfolioRisk(portfolio, [holding("SPY"), holding("IWM")], snapshot),
113
+ ),
114
+ ).toEqual([
115
+ "1 holding had no current quote; weighted at the latest completed close.",
116
+ ]);
117
+ });
99
118
  test("signed equity exposure remains in concentration while shorts block the unfinanced basket", () => {
100
119
  const model = buildPortfolioRisk(
101
120
  portfolio,
@@ -215,8 +215,16 @@ export function buildPortfolioRisk(
215
215
  for (const holding of holdings)
216
216
  holding.weight =
217
217
  book?.rows.find((row) => row.id === holding.id)?.weight ?? null;
218
+ const closeMarked = holdings.filter(
219
+ (row) => row.markSource === "close" && row.value != null,
220
+ ).length;
218
221
  const warnings = [
219
222
  ...market.warnings,
223
+ ...(closeMarked
224
+ ? [
225
+ `${closeMarked} holding${closeMarked === 1 ? "" : "s"} had no current quote; weighted at the latest completed close.`,
226
+ ]
227
+ : []),
220
228
  ...market.histories.flatMap((row) =>
221
229
  row.error ? [`${row.instrument.symbol}: ${row.error}`] : [],
222
230
  ),
@@ -8,7 +8,7 @@ import type { StaticChartSurfaceProps } from "../../../components/chart/static";
8
8
  import type { DataTableCell } from "../../../components/ui/data-table/types";
9
9
  import { colors, priceColor } from "../../../theme/colors";
10
10
  import { Box, Text } from "../../../ui";
11
- import { formatCompact, formatPercentRaw } from "../../../utils/format";
11
+ import { formatCompactAmount, formatPercentRaw } from "../../../utils/format";
12
12
  import { formatSignedCompact, formatWeight, renderBar } from "./display";
13
13
  import type {
14
14
  SectorSortPreference,
@@ -143,7 +143,7 @@ function renderSectorCell(row: SectorTableRow, column: SectorTableColumn): DataT
143
143
  case "weight":
144
144
  return { text: formatWeight(row.weight) };
145
145
  case "value":
146
- return { text: row.value == null ? "—" : formatCompact(row.value) };
146
+ return { text: formatCompactAmount(row.value ?? undefined) };
147
147
  case "pnl":
148
148
  return {
149
149
  text: formatSignedCompact(row.pnl),
@@ -50,7 +50,6 @@ test("signals fill at the next open with costs, and a final open trade is marked
50
50
  expect(result.strategy.hitRatePct).toBe(0);
51
51
  // Buy-and-hold buys at the first fill after the start with the same cost.
52
52
  expect(result.equity.at(-1)!.benchmark).toBeCloseTo(15 / (9 * 1.01), 10);
53
- expect(result.warnings[0]).toContain("too few");
54
53
  });
55
54
 
56
55
  test("a signal on the final session does not trade", () => {
@@ -153,10 +153,7 @@ export function runBacktest(
153
153
  windows += 1;
154
154
  if (strategy[index]! / strategy[index - TRADING_DAYS]! > benchmark[index]! / benchmark[index - TRADING_DAYS]!) beat += 1;
155
155
  }
156
- const warnings = [
157
- ...(closed.length < 10 ? [`${closed.length} closed trade${closed.length === 1 ? "" : "s"}: too few to judge a hit rate.`] : []),
158
- ...(clean.length < bars.length ? [`${bars.length - clean.length} sessions without a positive close were skipped.`] : []),
159
- ];
156
+ const warnings = clean.length < bars.length ? [`${bars.length - clean.length} sessions without a positive close were skipped.`] : [];
160
157
  return {
161
158
  start: startDate, end: endDate, sessions: dates.length, equity, trades,
162
159
  strategy: {
@@ -235,7 +235,7 @@ export function BacktestPane({ width, height, focused }: PaneProps) {
235
235
  : column.id === "returnPct" ? (Math.abs(trade.returnPct) < 0.05 ? "0.0%" : `${trade.returnPct > 0 ? "+" : ""}${trade.returnPct.toFixed(1)}%`)
236
236
  : String(trade.sessions),
237
237
  color: column.id === "returnPct" ? (Math.abs(trade.returnPct) < 0.05 ? undefined : trade.returnPct > 0 ? colors.positive : colors.negative)
238
- : trade.open && column.id === "exitDate" ? colors.warning : undefined,
238
+ : trade.open && column.id === "exitDate" ? colors.textMuted : undefined,
239
239
  })}
240
240
  emptyStateTitle="No trades in this window."
241
241
  />
@@ -1,5 +1,5 @@
1
1
  import { Section } from "../../../../components";
2
- import { formatMarketPriceWithCurrency } from "../../../../market-data/market/format";
2
+ import { formatMarketPriceWithCurrency, liveQuoteFormatOptions } from "../../../../market-data/market/format";
3
3
  import { useInlineTickerQuote } from "../../../../state/hooks/inline-tickers";
4
4
  import { useTickerQuoteStream } from "../../../../state/hooks/live-ticker-financials";
5
5
  import { colors } from "../../../../theme/colors";
@@ -32,8 +32,8 @@ export function liveCompanyValues(company: BuildoutCompany, quote: Quote | null)
32
32
  ? datasetCap * (quote.price / datasetPrice)
33
33
  : null;
34
34
  return {
35
- price: formatMarketPriceWithCurrency(quote.price, quote.currency, { minimumFractionDigits: 2 }),
36
- marketCap: liveCap != null ? `$${formatCompact(liveCap)}` : null,
35
+ price: formatMarketPriceWithCurrency(quote.price, quote.currency, liveQuoteFormatOptions(quote, quote.currency, "STK")),
36
+ marketCap: liveCap != null ? `$${formatCompact(liveCap, { fixedDecimals: true })}` : null,
37
37
  };
38
38
  }
39
39
 
@@ -59,7 +59,8 @@ export async function fetchCentralBankRates(client: Pick<typeof apiClient, "getC
59
59
  export interface CentralBankRatesResource { payload: CentralBankRatesPayload; stale: boolean; refreshError: string | null }
60
60
  export function getCachedCentralBankRates(): CentralBankRatesResource | null {
61
61
  const cached = centralBankRatesCache.get("g20", { allowExpired: true });
62
- return cached ? { payload: cached.data, stale: cached.stale, refreshError: null } : null;
62
+ // The pane revalidates this copy on mount; only a failed refresh makes it stale.
63
+ return cached ? { payload: cached.data, stale: false, refreshError: null } : null;
63
64
  }
64
65
  export async function loadCentralBankRates(force = false): Promise<CentralBankRatesResource> {
65
66
  const result = await centralBankRatesCache.load("g20", () => fetchCentralBankRates(), { force });
@@ -1,6 +1,6 @@
1
1
  import type { HeadlessPaneDefinition } from "../../../types/plugin";
2
2
  import { fetchCentralBankRates } from "./client";
3
- import { policyBoardRow, policyNotices } from "./model";
3
+ import { hasNoPolicyRate, policyBoardRow, policyNotices } from "./model";
4
4
 
5
5
  export const centralBankRatesHeadless: HeadlessPaneDefinition<"bundle"> = {
6
6
  discovery: { aliases: ["CBR", "ECFC", "CBRT"], dataRequirements: ["Gloom Cloud central-bank-rates endpoint"],
@@ -18,7 +18,7 @@ export const centralBankRatesHeadless: HeadlessPaneDefinition<"bundle"> = {
18
18
  ], rows: data.rows.map(({ history: _history, ...row }) => {
19
19
  const board = policyBoardRow({ ...row, history: [] });
20
20
  return { ...row, valueText: board.valueText, changeText: board.changeText, percentileValue: row.percentile.value };
21
- }) }], errors, unavailableSymbols: data.rows.filter((row) => row.status === "unavailable").map((row) => row.id),
21
+ }) }], errors, unavailableSymbols: data.rows.filter((row) => row.status === "unavailable" && !hasNoPolicyRate(row)).map((row) => row.id),
22
22
  metadata: { ...data, complete } };
23
23
  },
24
24
  };
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { CentralBankRatesPayload, CentralBankRow } from "../../../api-client/central-bank-rates";
3
3
  import { ApiRequestError } from "../../../api-client/errors";
4
+ import type { HeadlessPaneContext } from "../../../types/plugin";
4
5
  import { fetchCentralBankRates, validateCentralBankRates } from "./client";
6
+ import { centralBankRatesHeadless } from "./headless";
5
7
  import { policyBoardRow, policyHistory, policyNotices } from "./model";
6
8
 
7
9
  function row(overrides: Partial<CentralBankRow> = {}): CentralBankRow {
@@ -36,7 +38,20 @@ describe("central bank policy boundary", () => {
36
38
  previousValue: null, previousAsOf: null, direction: "unavailable", history: [], status: "unavailable", unavailableReason: "no-policy-rate" }));
37
39
  expect(validateCentralBankRates(data).rows[1]!.value).toBeNull();
38
40
  expect(policyBoardRow(data.rows[1]!).history).toEqual([]);
39
- expect(policyNotices(data)).toHaveLength(1);
41
+ expect(policyNotices(data)).toEqual([]);
42
+ });
43
+ test("members without a policy rate stay out of notices and report completeness", async () => {
44
+ const unavailable = { value: null, range: null, asOf: null, lagDays: null, changeBps: null, lastChangeDate: null,
45
+ previousValue: null, previousAsOf: null, direction: "unavailable" as const, history: [], status: "unavailable" as const };
46
+ const data = payload();
47
+ data.rows.push(row({ id: "AR", label: "Argentina", ...unavailable, unavailableReason: "no-policy-rate" }),
48
+ row({ id: "african-union", label: "African Union", ...unavailable, unavailableReason: "no-unified-rate" }));
49
+ const load = (body: CentralBankRatesPayload) => centralBankRatesHeadless.load({ rawArgument: "", argument: null, symbols: [], options: {} },
50
+ { apiClient: { getCloudCentralBankRates: async () => body } } as unknown as HeadlessPaneContext);
51
+ expect(await load(data)).toMatchObject({ complete: true, errors: [], unavailableSymbols: [] });
52
+ data.status = "partial";
53
+ data.rows.push(row({ id: "IN", label: "India", ...unavailable, unavailableReason: "source-unavailable" }));
54
+ expect(await load(data)).toMatchObject({ complete: false, errors: ["India: source unavailable."], unavailableSymbols: ["IN"] });
40
55
  });
41
56
  test("rejects impossible dates, wrong units, contradictory ranges and undated changes", () => {
42
57
  for (const overrides of [
@@ -7,17 +7,17 @@ export const policyRate = (value: number | null) => value == null ? "--" : `${pe
7
7
  export const policyLevel = (row: CentralBankRow) => row.range ? `${percentText(row.range.lower)}-${percentText(row.range.upper)}%` : policyRate(row.value);
8
8
  export const policyChange = (row: CentralBankRow) => row.changeBps == null ? "--"
9
9
  : `${row.changeBps > 0 ? "+" : ""}${Number(row.changeBps.toFixed(1))}bp ${row.direction === "hike" ? "↑" : row.direction === "cut" ? "↓" : ""}`.trim();
10
+ /** Members with no policy rate to publish, kept out of the board and the notices; docs/research-data.md explains them. */
11
+ export const hasNoPolicyRate = (row: CentralBankRow) => row.status === "unavailable"
12
+ && (row.unavailableReason === "no-policy-rate" || row.unavailableReason === "no-unified-rate");
10
13
  export function policyNotices(data: CentralBankRatesPayload): string[] {
11
- return data.rows.flatMap((row) => row.status === "stale" ? [`${row.label}: stale, latest observation ${row.asOf ?? "unknown"}${row.lagDays == null ? "" : ` (${row.lagDays} days old)`}.`]
14
+ return data.rows.filter((row) => !hasNoPolicyRate(row)).flatMap((row) => row.status === "stale" ? [`${row.label}: stale, latest observation ${row.asOf ?? "unknown"}${row.lagDays == null ? "" : ` (${row.lagDays} days old)`}.`]
12
15
  : row.status === "unavailable" ? [`${row.label}: ${(row.unavailableReason ?? "unavailable").replaceAll("-", " ")}.`] : []);
13
16
  }
14
17
  export function policyHistory(row: CentralBankRow) {
15
18
  return row.history.filter((point) => (!row.percentile.windowStart || point.date >= row.percentile.windowStart)
16
19
  && (!row.percentile.windowEnd || point.date <= row.percentile.windowEnd));
17
20
  }
18
- /** Members with no policy rate to publish, kept out of the board and listed in the notices. */
19
- export const hasNoPolicyRate = (row: CentralBankRow) => row.status === "unavailable"
20
- && (row.unavailableReason === "no-policy-rate" || row.unavailableReason === "no-unified-rate");
21
21
  export interface PolicyBoardRow extends MarketBoardRow { observation: CentralBankRow }
22
22
  export function policyBoardRow(row: CentralBankRow): PolicyBoardRow {
23
23
  return { id: row.id, label: row.label, labelDetail: row.instrument, value: row.value, valueText: policyLevel(row),
@@ -29,7 +29,8 @@ function PolicyDetail({ row, width, height, focused }: { row: CentralBankRow; wi
29
29
  // A target range ranks and charts its midpoint, which the band's range would not say.
30
30
  { id: "range", label: "1Y range", value: `${policyRate(p.min)} to ${policyRate(p.max)}`, detail: row.range ? "midpoint" : undefined },
31
31
  { id: "asOf", label: "As of", value: row.asOf ?? "--", tone: row.status === "stale" ? "warning" : undefined,
32
- detail: [row.publicationFrequency, row.lagDays == null ? null : `${row.lagDays}d lag`, row.status === "stale" ? "stale" : null]
32
+ detail: [row.publicationFrequency, row.lagDays == null ? null : `${row.lagDays}d lag`,
33
+ row.confirmedAt ? `confirmed ${row.confirmedAt.slice(0, 10)}` : null, row.status === "stale" ? "stale" : null]
33
34
  .filter(Boolean).join(" · ") || undefined },
34
35
  { id: "meeting", label: "Next meeting", value: row.nextMeeting?.date ?? "--" },
35
36
  { id: "source", label: "Source", value: row.source?.toUpperCase() ?? "--", detail: row.sourceSeriesIds.join(", ") || undefined },
@@ -64,7 +65,6 @@ export function CentralBankRatesPane({ width, height, focused }: PaneProps) {
64
65
  info: data ? [
65
66
  ...(updatedAgo ? [{ id: "updated", parts: [{ text: updatedAgo, tone: "muted" as const }] }] : []),
66
67
  ...(resource.data?.stale ? [{ id: "stale", parts: [{ text: "stale", tone: "warning" as const }] }] : []),
67
- ...(data.status !== "available" ? [{ id: "partial", parts: [{ text: data.status, tone: "warning" as const }] }] : []),
68
68
  ] : [],
69
69
  });
70
70
  return <Box width={width} height={height} flexDirection="column">
@@ -11,7 +11,9 @@ import {
11
11
  } from "../../../components";
12
12
  import {
13
13
  MultiSelectDialogButton,
14
+ NumberPromptDialog,
14
15
  type MultiSelectDialogButtonHandle,
16
+ type MultiSelectRowAction,
15
17
  } from "../../../components/ui";
16
18
  import { CompositeChart } from "../../../components/chart/composite";
17
19
  import type { PaneProps, TickerResearchTabProps } from "../../../types/plugin";
@@ -23,6 +25,7 @@ import {
23
25
  } from "../../../time-series/resolution";
24
26
  import { useResolvedChartSpec } from "../../../time-series/hooks";
25
27
  import { LIVE_CHART_TERMINAL_FRAME_MS } from "../../../time-series/live-quotes";
28
+ import { FORWARD_VALUATION_BASIS_NOTICES } from "../../../time-series/forward-valuation";
26
29
  import { chartSeriesSourceKey } from "../../../capabilities";
27
30
  import { useShortcut } from "../../../react/input";
28
31
  import { useDialog, useDialogState, type PromptContext } from "../../../ui/dialog";
@@ -57,11 +60,16 @@ import {
57
60
  buildPriceChartPreset,
58
61
  chartSeriesLabel,
59
62
  defaultFinancialTimestampMode,
63
+ builtinStudyPeriod,
60
64
  getSelectedBuiltinStudies,
61
65
  getSelectedPairStudies,
66
+ isPeriodStudy,
62
67
  setBuiltinStudies,
68
+ setBuiltinStudyPeriod,
63
69
  setPairStudies,
64
70
  rebindResearchChartSpec,
71
+ STUDY_PERIOD_MAX,
72
+ STUDY_PERIOD_MIN,
65
73
  type BuiltinStudySelection,
66
74
  type PairStudySelection,
67
75
  } from "./presets";
@@ -70,7 +78,9 @@ import {
70
78
  CHART_FORMULA_OPTIONS,
71
79
  CHART_RANGES as RANGES,
72
80
  CHART_RESOLUTIONS as RESOLUTIONS,
73
- CHART_STUDY_OPTIONS,
81
+ chartStudyLabel,
82
+ chartStudyOptionsFor,
83
+ chartStudyPeriodTitle,
74
84
  } from "./settings";
75
85
  import { resolveChartComposerShortcut } from "./shortcuts";
76
86
  import { describeChartResolution, formatChartDateWindow, formatChartResolution } from "./viewport-labels";
@@ -309,6 +319,37 @@ function ChartComposerSurface({
309
319
  const interactionCaptureRef = useRef(false);
310
320
  const interactionCaptureSourcesRef = useRef(new Set<string>());
311
321
  const indicatorsDialogRef = useRef<MultiSelectDialogButtonHandle | null>(null);
322
+ // The Indicators dialog keeps the callbacks it opened with; reading the spec
323
+ // through this ref keeps a toggle from undoing a period edit made meanwhile.
324
+ const specRef = useRef(spec);
325
+ specRef.current = spec;
326
+ const studyOptions = useMemo(() => chartStudyOptionsFor(spec), [spec]);
327
+ const studyPeriodAction = useMemo<MultiSelectRowAction>(() => ({
328
+ label: "Period",
329
+ shortcut: "p",
330
+ appliesTo: (value, selected) => selected && isPeriodStudy(value),
331
+ run: async (value) => {
332
+ if (!isPeriodStudy(value)) return;
333
+ const current = builtinStudyPeriod(specRef.current, value) ?? STUDY_PERIOD_MIN;
334
+ const next = await dialog.prompt<number>({
335
+ content: (ctx: PromptContext<number>) => (
336
+ <NumberPromptDialog
337
+ {...ctx}
338
+ title={chartStudyPeriodTitle(value)}
339
+ initialValue={current}
340
+ min={STUDY_PERIOD_MIN}
341
+ max={STUDY_PERIOD_MAX}
342
+ invalidMessage={`Whole number from ${STUDY_PERIOD_MIN} to ${STUDY_PERIOD_MAX}`}
343
+ />
344
+ ),
345
+ });
346
+ if (next === undefined || next === current) return;
347
+ const nextSpec = setBuiltinStudyPeriod(specRef.current, value, next);
348
+ specRef.current = nextSpec;
349
+ setSpec(nextSpec);
350
+ return chartStudyLabel(value, next);
351
+ },
352
+ }), [dialog, setSpec]);
312
353
  const formulasDialogRef = useRef<MultiSelectDialogButtonHandle | null>(null);
313
354
  const indicatorsDisabled = !isPriceStudyTarget(spec);
314
355
  const formulasDisabled = spec.series.filter((series) => series.visible !== false).length < 2;
@@ -560,6 +601,7 @@ function ChartComposerSurface({
560
601
  : []);
561
602
  const statusWarnings = resolution.warnings.filter((warning) => (
562
603
  warning !== FINANCIAL_VINTAGE_NOTICE && warning !== SEC_EPS_BASIS_NOTICE && warning !== comparisonNotice
604
+ && !FORWARD_VALUATION_BASIS_NOTICES.has(warning)
563
605
  && !duplicateErrorNotices.has(warning) && !comparisonEmptyNotices.has(warning)
564
606
  && !spec.studies.some((study) => (
565
607
  (study.kind === "ratio" && warning.startsWith(`${study.id}: ratio inputs use different currencies (`)
@@ -640,9 +682,14 @@ function ChartComposerSurface({
640
682
  ref={indicatorsDialogRef}
641
683
  label="Indicators"
642
684
  title="Chart Indicators"
643
- options={CHART_STUDY_OPTIONS}
685
+ options={studyOptions}
644
686
  selectedValues={selectedStudies}
645
- onChange={(values) => setSpec(setBuiltinStudies(spec, values as BuiltinStudySelection[]))}
687
+ onChange={(values) => {
688
+ const nextSpec = setBuiltinStudies(specRef.current, values as BuiltinStudySelection[]);
689
+ specRef.current = nextSpec;
690
+ setSpec(nextSpec);
691
+ }}
692
+ rowAction={studyPeriodAction}
646
693
  disabled={indicatorsDisabled}
647
694
  idPrefix={`${footerId}:indicators`}
648
695
  shortcutKey="i"
@@ -653,3 +653,20 @@ describe("chart composer CLI options", () => {
653
653
  });
654
654
  });
655
655
  });
656
+
657
+ describe("indicator periods", () => {
658
+ test("an edited period survives toggling other indicators and names itself in the picker", async () => {
659
+ const { builtinStudyPeriod, setBuiltinStudyPeriod } = await import("./presets");
660
+ const { chartStudyLabel } = await import("./settings");
661
+ const withSma = setBuiltinStudies(buildPriceChartPreset("AAPL"), ["sma50"]);
662
+ const edited = setBuiltinStudyPeriod(withSma, "sma50", 30);
663
+ expect(builtinStudyPeriod(edited, "sma50")).toBe(30);
664
+ // Turning another indicator on and off rebuilds the list from the selection.
665
+ const toggled = setBuiltinStudies(setBuiltinStudies(edited, ["sma50", "rsi14"]), ["sma50"]);
666
+ expect(builtinStudyPeriod(toggled, "sma50")).toBe(30);
667
+ expect(chartStudyLabel("sma50", builtinStudyPeriod(toggled, "sma50"))).toBe("Simple moving average (SMA 30)");
668
+ // An indicator that is not on keeps its default and is left alone.
669
+ expect(setBuiltinStudyPeriod(toggled, "ema20", 9)).toBe(toggled);
670
+ expect(builtinStudyPeriod(toggled, "ema20")).toBe(20);
671
+ });
672
+ });
@@ -794,6 +794,54 @@ export function setBuiltinStudies(spec: ChartSpec, selected: readonly BuiltinStu
794
794
  return { ...spec, studies, panels: reconcilePanels(spec.panels, spec.series, studies) };
795
795
  }
796
796
 
797
+ /** Builtin studies whose lookback the Indicators dialog lets you change. */
798
+ const PERIOD_STUDIES = new Set<BuiltinStudySelection>(["sma20", "sma50", "sma200", "ema20", "bollinger20", "rsi14"]);
799
+
800
+ export const STUDY_PERIOD_MIN = 2;
801
+ export const STUDY_PERIOD_MAX = 500;
802
+
803
+ export function isPeriodStudy(selection: string): selection is BuiltinStudySelection {
804
+ return PERIOD_STUDIES.has(selection as BuiltinStudySelection);
805
+ }
806
+
807
+ function builtinStudy(spec: ChartSpec, selection: BuiltinStudySelection): ChartStudySpec | undefined {
808
+ const prefix = `${BUILTIN_STUDY_ID_PREFIX}${selection}:`;
809
+ return spec.studies.find((study) => study.id.startsWith(prefix));
810
+ }
811
+
812
+ export function defaultStudyPeriod(selection: BuiltinStudySelection): number | null {
813
+ const defaults = STUDY_DEFAULTS[selection].parameters as Record<string, unknown>;
814
+ return typeof defaults.period === "number" ? defaults.period : null;
815
+ }
816
+
817
+ /** The period a builtin study runs with: its own when selected, the default otherwise. */
818
+ export function builtinStudyPeriod(spec: ChartSpec, selection: BuiltinStudySelection): number | null {
819
+ const period = builtinStudy(spec, selection)?.parameters.period;
820
+ return typeof period === "number" && Number.isFinite(period) ? period : defaultStudyPeriod(selection);
821
+ }
822
+
823
+ /**
824
+ * Changes the period of a selected builtin study. The study keeps its id, so
825
+ * `setBuiltinStudies` carries the new period through later toggles; an
826
+ * unselected study is left alone.
827
+ */
828
+ export function setBuiltinStudyPeriod(
829
+ spec: ChartSpec,
830
+ selection: BuiltinStudySelection,
831
+ period: number,
832
+ ): ChartSpec {
833
+ if (!isPeriodStudy(selection)) return spec;
834
+ const bounded = Math.round(Math.min(STUDY_PERIOD_MAX, Math.max(STUDY_PERIOD_MIN, period)));
835
+ const target = builtinStudy(spec, selection);
836
+ if (!target) return spec;
837
+ return {
838
+ ...spec,
839
+ studies: spec.studies.map((study) => (
840
+ study.id === target.id ? { ...study, parameters: { ...study.parameters, period: bounded } } : study
841
+ )),
842
+ };
843
+ }
844
+
797
845
  export type PairStudySelection = "ratio" | "spread" | "correlation";
798
846
 
799
847
  const PAIR_STUDY_ID_PREFIX = "pair:";
@@ -24,7 +24,10 @@ import {
24
24
  chartSeriesLabel,
25
25
  formatSeriesExpression,
26
26
  getCompatibleSeriesStyles,
27
+ builtinStudyPeriod,
28
+ defaultStudyPeriod,
27
29
  getSelectedBuiltinStudies,
30
+ isPeriodStudy,
28
31
  getSelectedPairStudies,
29
32
  setBuiltinStudies,
30
33
  setPairStudies,
@@ -38,17 +41,45 @@ import {
38
41
 
39
42
  export { CHART_RANGES, CHART_RESOLUTIONS };
40
43
 
41
- export const CHART_STUDY_OPTIONS: Array<PaneSettingOption & { value: BuiltinStudySelection }> = [
42
- { value: "volume", label: "Volume", description: "Volume columns in a lower panel." },
43
- { value: "sma20", label: "SMA 20", description: "20-bar simple moving average on the primary price series." },
44
- { value: "sma50", label: "SMA 50", description: "50-bar simple moving average on the primary price series." },
45
- { value: "sma200", label: "SMA 200", description: "200-bar simple moving average on the primary price series." },
46
- { value: "ema20", label: "EMA 20", description: "20-bar exponential moving average on the primary price series." },
47
- { value: "bollinger20", label: "Bollinger 20", description: "20-bar Bollinger Bands at two standard deviations." },
48
- { value: "rsi14", label: "RSI 14", description: "14-bar Relative Strength Index in a lower panel." },
49
- { value: "macd", label: "MACD", description: "12/26/9 MACD in a lower panel." },
50
- { value: "realized-vol", label: "Realized Volatility", description: "Annualized daily volatility in a lower panel." },
51
- ];
44
+ /** Full name and short form for each builtin indicator; the label adds its period. */
45
+ const CHART_STUDY_NAMES: Record<BuiltinStudySelection, { name: string; short?: string; description: string }> = {
46
+ volume: { name: "Volume", description: "Volume columns in a lower panel." },
47
+ sma20: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
48
+ sma50: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
49
+ sma200: { name: "Simple moving average", short: "SMA", description: "Simple moving average on the primary price series." },
50
+ ema20: { name: "Exponential moving average", short: "EMA", description: "Exponential moving average on the primary price series." },
51
+ bollinger20: { name: "Bollinger Bands", short: "BB", description: "Bollinger Bands at two standard deviations." },
52
+ rsi14: { name: "Relative strength index", short: "RSI", description: "Relative Strength Index in a lower panel." },
53
+ macd: { name: "MACD", short: "12, 26, 9", description: "12/26/9 MACD in a lower panel." },
54
+ "realized-vol": { name: "Realized volatility", description: "Annualized daily volatility in a lower panel." },
55
+ };
56
+
57
+ /** Title of the prompt that edits a study's period: `SMA period`. */
58
+ export function chartStudyPeriodTitle(selection: BuiltinStudySelection): string {
59
+ return `${CHART_STUDY_NAMES[selection].short ?? CHART_STUDY_NAMES[selection].name} period`;
60
+ }
61
+
62
+ /** `Simple moving average (SMA 50)`: the name, the acronym and the period it runs with. */
63
+ export function chartStudyLabel(selection: BuiltinStudySelection, period: number | null): string {
64
+ const { name, short } = CHART_STUDY_NAMES[selection];
65
+ if (!short) return name;
66
+ return isPeriodStudy(selection) && period != null ? `${name} (${short} ${period})` : `${name} (${short})`;
67
+ }
68
+
69
+ function chartStudyOptions(periodOf: (selection: BuiltinStudySelection) => number | null) {
70
+ return (Object.keys(CHART_STUDY_NAMES) as BuiltinStudySelection[]).map((value) => ({
71
+ value,
72
+ label: chartStudyLabel(value, periodOf(value)),
73
+ description: CHART_STUDY_NAMES[value].description,
74
+ }));
75
+ }
76
+
77
+ export const CHART_STUDY_OPTIONS: Array<PaneSettingOption & { value: BuiltinStudySelection }> = chartStudyOptions(defaultStudyPeriod);
78
+
79
+ /** The indicator options with the periods this chart actually uses. */
80
+ export function chartStudyOptionsFor(spec: ChartSpec): Array<PaneSettingOption & { value: BuiltinStudySelection }> {
81
+ return chartStudyOptions((selection) => builtinStudyPeriod(spec, selection));
82
+ }
52
83
 
53
84
  export const CHART_FORMULA_OPTIONS: Array<PaneSettingOption & { value: PairStudySelection }> = [
54
85
  { value: "ratio", label: "Ratio", description: "First series divided by the second series." },
@@ -1076,8 +1076,8 @@ describe("ChatContent", () => {
1076
1076
  await flushFrame();
1077
1077
 
1078
1078
  const lines = setup().captureCharFrame().split("\n");
1079
- const row = lines.findIndex((line) => line.includes("TSLA -5%"));
1080
- const col = lines[row]?.indexOf("TSLA -5%") ?? -1;
1079
+ const row = lines.findIndex((line) => line.includes("TSLA -5.0%"));
1080
+ const col = lines[row]?.indexOf("TSLA -5.0%") ?? -1;
1081
1081
 
1082
1082
  expect(row).toBeGreaterThanOrEqual(0);
1083
1083
  expect(col).toBeGreaterThanOrEqual(0);
@@ -190,7 +190,7 @@ function ToolTimelineRow({
190
190
  <Text fg={colors.textDim}>{` ${truncateWithEllipsis(row.argumentSummary, summaryWidth)}`}</Text>
191
191
  ) : null}
192
192
  <Box flexGrow={1} />
193
- {tier ? <Text fg={colors.warning}>{`${tier} `}</Text> : null}
193
+ {tier ? <Text fg={row.writeTier === "ui-write" ? colors.textMuted : colors.warning}>{`${tier} `}</Text> : null}
194
194
  {row.status === "running" || row.status === "pending" ? (
195
195
  <Spinner label={status} />
196
196
  ) : (