gloomberb 0.18.4 → 0.18.5

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 (282) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +32 -1
  5. package/src/account/auth-model.ts +7 -3
  6. package/src/api-client/auth.ts +9 -4
  7. package/src/api-client/catalysts.ts +2 -1
  8. package/src/api-client/data.ts +14 -0
  9. package/src/api-client/index.ts +160 -11
  10. package/src/api-client/live-scan.ts +91 -0
  11. package/src/api-client/members.ts +12 -0
  12. package/src/api-client/request.ts +222 -41
  13. package/src/api-client/research-activity.ts +3 -10
  14. package/src/api-client/types.ts +6 -0
  15. package/src/app/keybindings/index.ts +3 -0
  16. package/src/app/keybindings/labels.ts +18 -1
  17. package/src/app/keybindings/resolve.ts +14 -0
  18. package/src/app/runtime/broker-import.ts +43 -26
  19. package/src/architecture/core-builtin-allowlist.json +1 -1
  20. package/src/cli/commands/fred-series.ts +5 -0
  21. package/src/cli/commands/keybindings.ts +23 -0
  22. package/src/cli/commands/market.ts +68 -22
  23. package/src/cli/commands/options-chain.ts +59 -3
  24. package/src/cli/commands/overview.ts +119 -31
  25. package/src/cli/commands/search.ts +18 -2
  26. package/src/cli/commands/ticker.ts +2 -1
  27. package/src/cli/errors.ts +2 -2
  28. package/src/cli/export-source.ts +29 -1
  29. package/src/cli/glossary.ts +49 -0
  30. package/src/cli/history-rows.ts +30 -7
  31. package/src/cli/known-gaps.ts +5 -2
  32. package/src/cli/listing-arg.ts +16 -5
  33. package/src/cli/pane-functions/freshness.ts +25 -13
  34. package/src/cli/pane-functions/function-help.ts +10 -3
  35. package/src/cli/pane-functions/headless.ts +39 -5
  36. package/src/cli/pane-functions/index.ts +10 -3
  37. package/src/cli/pane-functions/market-session.ts +15 -5
  38. package/src/cli/pane-functions/report.ts +5 -1
  39. package/src/cli/pane-functions/resolver.ts +18 -2
  40. package/src/cli/quote-answer.ts +10 -4
  41. package/src/cli/report-tables.ts +18 -7
  42. package/src/cli/restore-plugins.ts +13 -1
  43. package/src/cli/result.ts +57 -5
  44. package/src/components/chart/composite/composite-chart.tsx +22 -4
  45. package/src/components/chart/composite/format.ts +27 -0
  46. package/src/components/chart/composite/levels.ts +2 -0
  47. package/src/components/chart/composite/panel-series.ts +1 -0
  48. package/src/components/chart/composite/panel-surface.tsx +9 -2
  49. package/src/components/chart/composite/range-statistics.ts +62 -0
  50. package/src/components/chart/composite/scene.ts +7 -3
  51. package/src/components/chart/composite/series-axis-tags.tsx +16 -9
  52. package/src/components/chart/composite/types.ts +4 -0
  53. package/src/components/command-bar/panel/prompt-binding.ts +0 -8
  54. package/src/components/command-bar/workflow/alert.ts +1 -1
  55. package/src/components/layout/header.tsx +26 -40
  56. package/src/components/layout/pane/footer/index.tsx +1 -0
  57. package/src/components/layout/pane/footer/model.ts +27 -0
  58. package/src/components/onboarding/onboarding-frame.tsx +1 -1
  59. package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
  60. package/src/components/ui/display.tsx +5 -1
  61. package/src/core/state/app/layout.ts +2 -0
  62. package/src/core/state/app/state.ts +9 -0
  63. package/src/core/state/app/types.ts +3 -0
  64. package/src/data/saved-copies.ts +55 -16
  65. package/src/docs/snapshot.json +361 -84
  66. package/src/i18n/es.ts +1 -1
  67. package/src/i18n/ja.ts +1 -1
  68. package/src/i18n/ko.ts +1 -1
  69. package/src/i18n/zh-cn.ts +1 -1
  70. package/src/i18n/zh-tw.ts +1 -1
  71. package/src/market-data/coordinator/financials.ts +10 -2
  72. package/src/market-data/coordinator/index.ts +3 -1
  73. package/src/market-data/coordinator/quotes.ts +4 -2
  74. package/src/market-data/market/change-basis.ts +33 -5
  75. package/src/market-data/market/dated-previous-close.ts +203 -0
  76. package/src/market-data/market/futures-week.ts +68 -0
  77. package/src/market-data/market/status.ts +16 -4
  78. package/src/market-data/options-alternatives.ts +60 -0
  79. package/src/market-data/quotes/contributions.ts +20 -2
  80. package/src/market-data/quotes/resolution.ts +3 -0
  81. package/src/market-data/selectors.ts +3 -0
  82. package/src/notifications/notification-log.ts +6 -4
  83. package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
  84. package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
  85. package/src/plugins/builtin/alerts/cli.ts +59 -20
  86. package/src/plugins/builtin/alerts/command.ts +7 -0
  87. package/src/plugins/builtin/alerts/constants.ts +1 -1
  88. package/src/plugins/builtin/alerts/fired-log.ts +45 -4
  89. package/src/plugins/builtin/alerts/format.ts +8 -2
  90. package/src/plugins/builtin/alerts/headless.ts +10 -5
  91. package/src/plugins/builtin/alerts/history-rows.ts +5 -2
  92. package/src/plugins/builtin/alerts/history.ts +2 -0
  93. package/src/plugins/builtin/alerts/index.tsx +66 -16
  94. package/src/plugins/builtin/alerts/live.ts +3 -3
  95. package/src/plugins/builtin/alerts/metric-command.ts +61 -0
  96. package/src/plugins/builtin/alerts/metric-data.ts +70 -0
  97. package/src/plugins/builtin/alerts/metric-model.ts +175 -0
  98. package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
  99. package/src/plugins/builtin/alerts/pane.tsx +42 -29
  100. package/src/plugins/builtin/alerts/storage.ts +33 -10
  101. package/src/plugins/builtin/alerts/types.ts +3 -0
  102. package/src/plugins/builtin/backtest/headless.ts +10 -5
  103. package/src/plugins/builtin/backtest/pane.tsx +9 -4
  104. package/src/plugins/builtin/backtest/presets.ts +29 -3
  105. package/src/plugins/builtin/broker-manager/index.tsx +3 -2
  106. package/src/plugins/builtin/broker-manager/model.ts +6 -1
  107. package/src/plugins/builtin/broker-trading/controller.ts +22 -12
  108. package/src/plugins/builtin/broker-trading/index.tsx +13 -1
  109. package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
  110. package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
  111. package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
  112. package/src/plugins/builtin/catalysts/model.ts +2 -1
  113. package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
  114. package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
  115. package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
  116. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
  117. package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
  118. package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
  119. package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
  120. package/src/plugins/builtin/chart-composer/formula.ts +71 -18
  121. package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
  122. package/src/plugins/builtin/chart-composer/headless.ts +13 -2
  123. package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
  124. package/src/plugins/builtin/chart-composer/presets.ts +50 -15
  125. package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
  126. package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
  127. package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
  128. package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
  129. package/src/plugins/builtin/chat/content/composer.tsx +13 -6
  130. package/src/plugins/builtin/chat/content/footer.tsx +6 -3
  131. package/src/plugins/builtin/chat/content/index.tsx +50 -21
  132. package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
  133. package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
  134. package/src/plugins/builtin/chat/index.tsx +23 -0
  135. package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
  136. package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
  137. package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
  138. package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
  139. package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
  140. package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
  141. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
  142. package/src/plugins/builtin/cloud/plugin.tsx +1 -1
  143. package/src/plugins/builtin/econ/calendar-model.ts +14 -9
  144. package/src/plugins/builtin/econ/figures.ts +72 -0
  145. package/src/plugins/builtin/econ/index.tsx +33 -3
  146. package/src/plugins/builtin/econ/types.ts +2 -0
  147. package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
  148. package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
  149. package/src/plugins/builtin/econ-statistics/view.ts +3 -0
  150. package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
  151. package/src/plugins/builtin/exposure/charts.tsx +2 -2
  152. package/src/plugins/builtin/exposure/model.ts +13 -1
  153. package/src/plugins/builtin/exposure/pane.tsx +4 -4
  154. package/src/plugins/builtin/futures/index.tsx +1 -0
  155. package/src/plugins/builtin/futures/model.ts +2 -1
  156. package/src/plugins/builtin/futures/table.tsx +2 -2
  157. package/src/plugins/builtin/fx-matrix/client.ts +6 -1
  158. package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
  159. package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
  160. package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
  161. package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
  162. package/src/plugins/builtin/iv-history/headless.ts +3 -0
  163. package/src/plugins/builtin/macro-day/pane.tsx +7 -5
  164. package/src/plugins/builtin/market-movers/footer.ts +2 -2
  165. package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
  166. package/src/plugins/builtin/members/client.ts +19 -4
  167. package/src/plugins/builtin/members/headless.ts +20 -7
  168. package/src/plugins/builtin/members/index.tsx +9 -9
  169. package/src/plugins/builtin/members/model.ts +23 -4
  170. package/src/plugins/builtin/members/pane.tsx +25 -15
  171. package/src/plugins/builtin/options/analytics.ts +26 -2
  172. package/src/plugins/builtin/options/model-spot.ts +5 -2
  173. package/src/plugins/builtin/options/strike-window.ts +2 -1
  174. package/src/plugins/builtin/options/table.ts +1 -7
  175. package/src/plugins/builtin/options/view.tsx +25 -8
  176. package/src/plugins/builtin/options-positioning/client.ts +19 -0
  177. package/src/plugins/builtin/options-positioning/headless.ts +54 -14
  178. package/src/plugins/builtin/options-positioning/index.tsx +3 -3
  179. package/src/plugins/builtin/options-positioning/model.ts +47 -8
  180. package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
  181. package/src/plugins/builtin/perps/freshness.ts +12 -10
  182. package/src/plugins/builtin/perps/live-rows.ts +10 -3
  183. package/src/plugins/builtin/perps/pane.tsx +15 -11
  184. package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
  185. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  186. package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
  187. package/src/plugins/builtin/rate-path/pane.tsx +2 -2
  188. package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
  189. package/src/plugins/builtin/scanner/feed.ts +2 -2
  190. package/src/plugins/builtin/scanner/index.ts +24 -1
  191. package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
  192. package/src/plugins/builtin/scanner/live-feed.ts +168 -0
  193. package/src/plugins/builtin/scanner/live-headless.ts +114 -0
  194. package/src/plugins/builtin/scanner/live-model.ts +351 -0
  195. package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
  196. package/src/plugins/builtin/scanner/live-track.ts +186 -0
  197. package/src/plugins/builtin/series-inventory.ts +1 -0
  198. package/src/plugins/builtin/shared/macro-releases.ts +65 -0
  199. package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
  200. package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
  201. package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
  202. package/src/plugins/builtin/themes/headless.ts +6 -4
  203. package/src/plugins/builtin/themes/model.ts +1 -1
  204. package/src/plugins/builtin/themes/pane.tsx +3 -3
  205. package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
  206. package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
  207. package/src/plugins/builtin/vol-surface/client.ts +6 -4
  208. package/src/plugins/builtin/vol-surface/headless.ts +62 -13
  209. package/src/plugins/builtin/vol-surface/index.tsx +2 -2
  210. package/src/plugins/builtin/vol-surface/model.ts +22 -7
  211. package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
  212. package/src/plugins/builtin/vol-surface/tables.ts +31 -3
  213. package/src/plugins/builtin/volatility/dvol.ts +46 -0
  214. package/src/plugins/builtin/volatility/pane.tsx +2 -2
  215. package/src/plugins/builtin/volatility/series.ts +26 -0
  216. package/src/plugins/builtin/world-indices/footer.ts +1 -1
  217. package/src/plugins/catalog-browser.ts +13 -8
  218. package/src/portfolio/column-values.ts +5 -5
  219. package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
  220. package/src/renderers/browser/app-services.ts +1 -7
  221. package/src/renderers/browser/config-host.ts +2 -55
  222. package/src/renderers/browser/main.tsx +7 -57
  223. package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
  224. package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
  225. package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
  226. package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
  227. package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
  228. package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
  229. package/src/renderers/electrobun/bun/index.ts +25 -1
  230. package/src/renderers/electrobun/shared/app-name.ts +17 -0
  231. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
  232. package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
  233. package/src/renderers/opentui/toast-host.tsx +71 -43
  234. package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
  235. package/src/sources/provider-router/batches.ts +10 -2
  236. package/src/sources/provider-router/financial-routes.ts +3 -3
  237. package/src/sources/provider-router/history.ts +28 -7
  238. package/src/sources/provider-router/index.ts +146 -10
  239. package/src/sources/provider-router/search.ts +13 -2
  240. package/src/state/app/context/index.tsx +3 -0
  241. package/src/sync/auth-transition.ts +19 -9
  242. package/src/sync/baseline.ts +10 -4
  243. package/src/sync/controller.ts +66 -16
  244. package/src/sync/react.ts +4 -1
  245. package/src/sync/types.ts +3 -0
  246. package/src/telemetry/usage-counts.ts +0 -18
  247. package/src/theme/colors.ts +8 -0
  248. package/src/tickers/company-name.ts +59 -0
  249. package/src/tickers/listing-venues.ts +23 -0
  250. package/src/tickers/search/bare-symbol.ts +36 -0
  251. package/src/tickers/search/index.ts +36 -7
  252. package/src/tickers/search/ranking.ts +10 -1
  253. package/src/tickers/search/types.ts +5 -0
  254. package/src/time-series/base-series.ts +13 -5
  255. package/src/time-series/daily-valuation.ts +62 -0
  256. package/src/time-series/field-catalog.ts +1 -3
  257. package/src/time-series/forward-valuation.ts +32 -0
  258. package/src/time-series/fundamentals.ts +155 -7
  259. package/src/time-series/hooks.ts +1 -0
  260. package/src/time-series/price-units.ts +72 -2
  261. package/src/time-series/resolve-context.ts +5 -1
  262. package/src/time-series/resolve.ts +61 -12
  263. package/src/time-series/series-presentation.ts +40 -6
  264. package/src/time-series/spec.ts +2 -1
  265. package/src/time-series/studies.ts +270 -72
  266. package/src/time-series/transforms.ts +1 -0
  267. package/src/time-series/types.ts +5 -0
  268. package/src/time-series/valuation-releases.ts +39 -0
  269. package/src/types/data-provider.ts +9 -1
  270. package/src/types/desktop-window.ts +2 -0
  271. package/src/types/financials.ts +41 -1
  272. package/src/types/headless.ts +42 -4
  273. package/src/types/instrument.ts +3 -0
  274. package/src/types/plugin.ts +1 -1
  275. package/src/updater/command-names.ts +12 -7
  276. package/src/utils/financial-statements.ts +6 -1
  277. package/src/utils/network-failure.ts +9 -6
  278. package/src/utils/option-expiry.ts +48 -1
  279. package/src/utils/options.ts +10 -0
  280. package/src/utils/plugin-proxy-hosts.json +1 -0
  281. package/src/utils/price-history.ts +5 -0
  282. package/src/renderers/browser/first-screen.ts +0 -193
@@ -221,6 +221,8 @@ function assignDailyChangeFields(
221
221
  }
222
222
 
223
223
  assignField(target, provenance, "previousClose", referenceProvider);
224
+ // The day that close is from comes with it, from the same source.
225
+ if (referenceProvider.previousCloseDate) target.previousCloseDate = referenceProvider.previousCloseDate;
224
226
  const price = target.price;
225
227
  const previousClose = target.previousClose;
226
228
  if (!finitePositiveNumber(price) || !finitePositiveNumber(previousClose)) {
@@ -500,6 +502,7 @@ export function resolveCanonicalQuote(
500
502
  // What that move is from when it is not previousClose; absent when it is.
501
503
  ...(resolved.changeBasis ? { changeBasis: resolved.changeBasis } : {}),
502
504
  previousClose: resolved.previousClose as Quote["previousClose"],
505
+ ...(resolved.previousCloseDate ? { previousCloseDate: resolved.previousCloseDate } : {}),
503
506
  regularClose: priceProvider?.regularClose,
504
507
  regularCloseSessionDate: priceProvider?.regularClose != null ? priceProvider.regularCloseSessionDate : undefined,
505
508
  regularChange: priceProvider?.regularClose != null ? priceProvider.regularChange : undefined,
@@ -6,6 +6,7 @@ import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
6
6
  import { normalizePriceHistory } from "../utils/price-history";
7
7
  import { resolveTickerFinancialsQuoteState } from "./quotes/resolution";
8
8
  import { instrumentIdentityKey } from "../utils/instrument-identity";
9
+ import { withSavedCopyMarkOf } from "../data/saved-copies";
9
10
 
10
11
  export function toMarketDataContext(instrument: InstrumentRef): MarketDataRequestContext {
11
12
  return {
@@ -123,6 +124,8 @@ export function buildTickerFinancialsSnapshot(
123
124
 
124
125
  return {
125
126
  ...resolved,
127
+ // The quote shown is a saved copy when the newest quote answered was one.
128
+ ...(resolved.quote ? { quote: withSavedCopyMarkOf(resolved.quote, quote ?? snapshot?.quote) } : {}),
126
129
  priceHistory: normalizePriceHistory(priceHistory ?? snapshot?.priceHistory ?? []),
127
130
  };
128
131
  }
@@ -6,22 +6,24 @@ export const NOTIFICATION_LOG_LIMIT = 300;
6
6
 
7
7
  /**
8
8
  * The notices worth finding again after their toast is gone: a price alert or
9
- * a catalyst that fired, a high-impact economic print, and a team card. Chat
9
+ * a catalyst that fired, a high-impact economic print, a team card, and new
10
+ * matches of a live scan with its alerts on. Chat
10
11
  * keeps its own unread state on the account, and plain info, success and
11
12
  * error toasts are not logged.
12
13
  */
13
- export type MissedNoticeKind = "price" | "catalyst" | "econ" | "team";
14
+ export type MissedNoticeKind = "price" | "catalyst" | "econ" | "team" | "scan";
14
15
 
15
16
  const KIND_PREFIXES: ReadonlyArray<readonly [MissedNoticeKind, string]> = [
16
17
  ["price", "price:"],
17
18
  ["catalyst", "catalyst:"],
18
19
  ["econ", "econ:"],
19
20
  ["team", "team:"],
21
+ ["scan", "scan:"],
20
22
  ];
21
23
 
22
24
  export interface NotificationLogEntry {
23
25
  id: string;
24
- /** `price:<alertId>`, `catalyst:<eventId>`, `econ:<eventId>` or `team:<cardId>`. A later notice with the same ref updates this row. */
26
+ /** `price:<alertId>`, `catalyst:<eventId>`, `econ:<eventId>`, `team:<cardId>` or `scan:<paneId>`. A later notice with the same ref updates this row. */
25
27
  refId: string;
26
28
  title?: string;
27
29
  body: string;
@@ -137,7 +139,7 @@ export function unreadNotificationLogCount(log: readonly NotificationLogEntry[]
137
139
 
138
140
  /**
139
141
  * Records a notice whose `refId` names a price alert, a catalyst, an economic
140
- * release or a team card; anything else returns null and is not kept. The same ref replaces its
142
+ * release, a team card or a live scan; anything else returns null and is not kept. The same ref replaces its
141
143
  * row: a price alert that fires again is new and unread again.
142
144
  */
143
145
  export function appendNotificationLog(
@@ -1,3 +1,4 @@
1
+ import { metricDescription, readAlertMetric, validMetricRule } from "./metric-model";
1
2
  import type { AlertCondition, AlertRule } from "./types";
2
3
  import { logFailure } from "../../../utils/log-failure";
3
4
 
@@ -42,6 +43,7 @@ export function editAlert(
42
43
  armedAt: Date.now(),
43
44
  status: "active",
44
45
  message: alert.message,
46
+ ...(alert.metric ? { metric: alert.metric } : {}),
45
47
  };
46
48
  }
47
49
 
@@ -50,7 +52,7 @@ export function rearmAlert(alert: AlertRule): AlertRule {
50
52
  }
51
53
 
52
54
  export function evaluateAlert(alert: AlertRule, currentPrice: number): boolean {
53
- if (alert.status !== "active") return false;
55
+ if (alert.metric || alert.status !== "active") return false;
54
56
 
55
57
  switch (alert.condition) {
56
58
  case "above":
@@ -69,6 +71,7 @@ export function evaluateAlert(alert: AlertRule, currentPrice: number): boolean {
69
71
  }
70
72
 
71
73
  export function formatAlertDescription(alert: AlertRule): string {
74
+ if (alert.metric) return metricDescription(alert);
72
75
  const prefix = alert.condition === "above" ? ">"
73
76
  : alert.condition === "below" ? "<" : "↕";
74
77
  return `${alert.symbol} ${prefix} ${alert.targetPrice}`;
@@ -94,6 +97,7 @@ function readAlerts(json: unknown): { alerts: AlertRule[]; error: string | null
94
97
  a != null && typeof a.id === "string" && !!a.id && typeof a.symbol === "string" && !!a.symbol
95
98
  && typeof a.condition === "string" && !!a.condition && typeof a.targetPrice === "number"
96
99
  && (a.exchange == null || typeof a.exchange === "string")
100
+ && (a.metric == null || (readAlertMetric(a.metric) != null && validMetricRule(a)))
97
101
  ));
98
102
  return {
99
103
  alerts,
@@ -5,6 +5,7 @@
5
5
  // two cases of the same alert, whose dates come from their own calendars.
6
6
  import { dateKey, eventCountry, matchesCountry, type CountryFilter } from "../econ/calendar-model";
7
7
  import { isAllDayEvent, hasNoReleaseTime, type EconEvent, type EconImpact } from "../econ/types";
8
+ import { RENAMED_RELEASES } from "../shared/macro-releases";
8
9
  import { MACRO_EVENT_NAMES } from "./research-builder";
9
10
  import type { MacroAlertEvent } from "./research-rules";
10
11
 
@@ -52,6 +53,39 @@ export function calendarNameWords(name: string): string[] {
52
53
 
53
54
  const nameKey = (name: string) => calendarNameWords(name).join(" ");
54
55
 
56
+ /** A release series' former name a typed name used, read as its current one. */
57
+ export interface ReleaseRename {
58
+ former: string;
59
+ current: string;
60
+ since: string;
61
+ }
62
+
63
+ /**
64
+ * A name's words with a renamed series' former name put as its current one
65
+ * (`caixin pmi` as `ratingdog pmi`), and the rename; null for a name that
66
+ * carries none.
67
+ */
68
+ function withCurrentName(words: readonly string[]): { words: string[]; rename: ReleaseRename } | null {
69
+ for (const { former, current, since } of RENAMED_RELEASES) {
70
+ for (const name of former) {
71
+ const old = calendarNameWords(name);
72
+ for (let at = 0; at + old.length <= words.length; at += 1) {
73
+ if (!old.every((word, index) => words[at + index] === word)) continue;
74
+ return {
75
+ words: [...words.slice(0, at), ...calendarNameWords(current), ...words.slice(at + old.length)],
76
+ rename: { former: name, current, since },
77
+ };
78
+ }
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /** "Caixin releases were renamed RatingDog from the August 2025 survey." */
85
+ export function releaseRenameNote({ former, current, since }: ReleaseRename): string {
86
+ return `${former} releases were renamed ${current} from ${since}.`;
87
+ }
88
+
55
89
  /** The UTC day the calendar lists a row under. */
56
90
  const utcDay = (time: number) => new Date(time).toISOString().slice(0, 10);
57
91
 
@@ -121,13 +155,17 @@ function calendarChoices(events: readonly EconEvent[], now: number): RankedChoic
121
155
  }
122
156
 
123
157
  /** What a typed release names in the calendar. */
124
- export type CalendarEventMatch =
158
+ export type CalendarEventMatch = (
125
159
  /** One release, the best of those the words name, and the others they name in the same country. */
126
160
  | { kind: "match"; choice: CalendarEventChoice; others: CalendarEventChoice[] }
127
161
  /** The words name releases in several countries: the best of each. */
128
162
  | { kind: "countries"; choices: CalendarEventChoice[] }
129
163
  /** Nothing the words name; the nearest names, best first. */
130
- | { kind: "none"; suggestions: CalendarEventChoice[] };
164
+ | { kind: "none"; suggestions: CalendarEventChoice[] }
165
+ ) & {
166
+ /** The former name the words used, read as the series' current one (`Caixin PMI` as RatingDog's). */
167
+ renamed?: ReleaseRename;
168
+ };
131
169
 
132
170
  /** Character pairs of a name, for how near a slip is to it. */
133
171
  function pairs(text: string): Map<string, number> {
@@ -186,7 +224,8 @@ function oneSlipApart(left: string, right: string): boolean {
186
224
  * then the closest name and the soonest date, so `NBS PMI` is the headline
187
225
  * NBS Manufacturing PMI and an old name listed only in the past never beats
188
226
  * the live one. Several countries ask which; no match offers the nearest
189
- * names.
227
+ * names. A series' former name reads as its current one (`Caixin PMI` is
228
+ * RatingDog's), and the match says so.
190
229
  */
191
230
  export function matchCalendarEvent(
192
231
  query: string,
@@ -194,7 +233,18 @@ export function matchCalendarEvent(
194
233
  events: readonly EconEvent[],
195
234
  now: number,
196
235
  ): CalendarEventMatch {
197
- const words = calendarNameWords(query);
236
+ const typedWords = calendarNameWords(query);
237
+ const current = withCurrentName(typedWords);
238
+ const match = matchCalendarWords(current?.words ?? typedWords, country, events, now);
239
+ return current ? { ...match, renamed: current.rename } : match;
240
+ }
241
+
242
+ function matchCalendarWords(
243
+ words: readonly string[],
244
+ country: CountryFilter | null,
245
+ events: readonly EconEvent[],
246
+ now: number,
247
+ ): CalendarEventMatch {
198
248
  const choices = calendarChoices(country ? events.filter((event) => matchesCountry(event, country)) : events, now);
199
249
  const exact = choices.filter((choice) => choice.words.join(" ") === words.join(" "));
200
250
  const naming = (typed: readonly string[], loose: boolean) => typed.length === 0 ? [] : choices.filter((choice) =>
@@ -266,9 +316,10 @@ function renamedBy(words: readonly string[], other: readonly string[]): number |
266
316
  */
267
317
  function releaseRows(target: CalendarReleaseTarget, events: readonly EconEvent[]): EconEvent[] {
268
318
  const words = calendarNameWords(target.event);
269
- const key = words.join(" ");
319
+ // An alert kept under a series' former name follows the rows under its current one.
320
+ const keys = new Set([words.join(" "), withCurrentName(words)?.words.join(" ")]);
270
321
  const own = events.filter((event) => eventCountry(event) === target.country);
271
- const named = own.filter((event) => nameKey(event.event) === key);
322
+ const named = own.filter((event) => keys.has(nameKey(event.event)));
272
323
  if (!target.publisher) return named;
273
324
  const namedDays = new Set(named.map((event) => dateKey(event.date)));
274
325
  const renamed = own.flatMap((event) => {
@@ -1,3 +1,6 @@
1
+ import { metricDescription } from "./metric-model";
2
+ import { parseMetricAlert, type MetricAlertInput } from "./metric-command";
3
+ import { metricReadings } from "./metric-data";
1
4
  import { withConfigData, withMarketData } from "../../../cli/scoped-context";
2
5
  import { CLI_COMMAND_GROUPS } from "../../../cli/help";
3
6
  import { dryRunNote } from "../../../cli/helpers";
@@ -24,11 +27,13 @@ import {
24
27
  macroEventNamed,
25
28
  matchCalendarEvent,
26
29
  releaseInstant,
30
+ releaseRenameNote,
27
31
  releaseRowOn,
28
32
  type CalendarEventChoice,
33
+ type ReleaseRename,
29
34
  } from "./calendar-events";
30
- import { createAlert, rearmAlert, serializeAlerts } from "./alert-engine";
31
- import { ALERTS_KEY, ALERTS_PLUGIN_ID } from "./constants";
35
+ import { createAlert, rearmAlert } from "./alert-engine";
36
+ import { ALERTS_PLUGIN_ID } from "./constants";
32
37
  import { MAX_EVENT_ALERTS, eventAlertTarget, toggleEventAlert, writeEventAlertStores, type EventAlertRule } from "./events";
33
38
  import { alertKindLabel } from "./history";
34
39
  import { IV_LEVEL_CHECKS_KEY, readIvLevelChecks, rearmIvLevelAlert, type IvLevelCheck } from "./iv-level";
@@ -58,7 +63,7 @@ import {
58
63
  type ScheduledEventTarget,
59
64
  } from "./scheduled";
60
65
  import { scheduledEventSources } from "./scheduled-sources";
61
- import { readAlertStores } from "./storage";
66
+ import { readAlertStores, writeLevelAlertStores } from "./storage";
62
67
  import type { AlertCondition, AlertRule } from "./types";
63
68
 
64
69
  /**
@@ -78,6 +83,7 @@ const COUNTRY_RULE = "--country is for an alert on a calendar event, such as ale
78
83
 
79
84
  /** What `alerts add` was asked for, before any listing or date is looked up. */
80
85
  export type AlertAddRequest =
86
+ | ({ kind: "metric" } & MetricAlertInput)
81
87
  | {
82
88
  kind: "price"; symbol: string; condition: AlertCondition; price: number;
83
89
  /** A price typed as 81.000, which the last price may show was 81000 with a dot between thousands. */
@@ -151,6 +157,11 @@ export function parseAlertAddArgs(
151
157
  const [first, second, third, ...rest] = args;
152
158
  if (!first) throw new Error(ADD_USAGE);
153
159
  const countryFilter = readCountryOption(country);
160
+ const metric = parseMetricAlert(args.join(" "));
161
+ if (metric) {
162
+ if (days !== undefined || country !== undefined) throw new Error("Valuation and daily technical alerts take neither --days nor --country.");
163
+ return { kind: "metric", ...metric };
164
+ }
154
165
  const research = parseResearchAddArgs(args);
155
166
  if (research) {
156
167
  if (days !== undefined) throw new Error("--days is for earnings and calendar event alerts, not news, flow or IV alerts.");
@@ -346,7 +357,7 @@ type AlertListRow = Record<string, unknown> & {
346
357
  };
347
358
 
348
359
  function priceRow(alert: AlertRule): AlertListRow {
349
- return { ...alert, kind: "price", target: alert.exchange ? `${alert.symbol}:${alert.exchange}` : alert.symbol };
360
+ return { ...alert, kind: alert.metric?.kind ?? "price", rule: alert.metric ? metricDescription(alert) : undefined, target: alert.exchange ? `${alert.symbol}:${alert.exchange}` : alert.symbol };
350
361
  }
351
362
 
352
363
  function eventRow(rule: EventAlertRule, now: number, eventDate: string | null | undefined, check?: IvLevelCheck): AlertListRow {
@@ -380,11 +391,11 @@ function textRow(row: AlertListRow): Record<string, unknown> {
380
391
  id: row.id,
381
392
  kind: price ? "Price" : alertKindLabel(row.kind),
382
393
  target: row.target,
383
- trigger: price
394
+ trigger: row.rule ?? (price
384
395
  ? `${row.condition} ${row.targetPrice}`
385
396
  : typeof row.leadDays === "number"
386
397
  ? scheduledTriggerText({ leadDays: row.leadDays, trigger: row.when === "results" ? "results" : "date", prints: row.onRelease === true })
387
- : "",
398
+ : ""),
388
399
  eventDate: typeof row.leadDays === "number" ? row.eventDate ?? "not known" : "",
389
400
  status: row.status,
390
401
  lastCheckedPrice: row.lastCheckedPrice,
@@ -455,10 +466,16 @@ function releaseData(choice: CalendarEventChoice) {
455
466
  const choiceList = (choices: readonly CalendarEventChoice[]) => choices.map((choice) => calendarEventLabel({ event: choice.name, country: choice.country }));
456
467
 
457
468
  /** What a name read as, and what else it names in that country: said beside the alert added. */
458
- function releaseReadNotes(query: string, choice: CalendarEventChoice, others: readonly CalendarEventChoice[]): string[] {
469
+ function releaseReadNotes(
470
+ query: string,
471
+ choice: CalendarEventChoice,
472
+ others: readonly CalendarEventChoice[],
473
+ renamed?: ReleaseRename,
474
+ ): string[] {
459
475
  const notes: string[] = [];
460
476
  if (calendarNameWords(query).join(" ") !== calendarNameWords(choice.name).join(" ")) {
461
- notes.push(`Read "${query}" as ${calendarEventLabel({ event: choice.name, country: choice.country })}, ${choice.impact} impact.`);
477
+ notes.push(`Read "${query}" as ${calendarEventLabel({ event: choice.name, country: choice.country })}, ${choice.impact} impact.`
478
+ + (renamed ? ` ${releaseRenameNote(renamed)}` : ""));
462
479
  }
463
480
  if (others.length > 0) {
464
481
  const shown = choiceList(others.slice(0, 4));
@@ -482,9 +499,15 @@ function calendarUnavailableMessage(query: string, country: CountryFilter | null
482
499
  }
483
500
 
484
501
  /** The refusal of a name nothing in the calendar matches: the nearest names, and every form `alerts add` takes. */
485
- function noReleaseMessage(query: string, country: CountryFilter | null, suggestions: readonly CalendarEventChoice[]): string {
502
+ function noReleaseMessage(
503
+ query: string,
504
+ country: CountryFilter | null,
505
+ suggestions: readonly CalendarEventChoice[],
506
+ renamed?: ReleaseRename,
507
+ ): string {
486
508
  const near = suggestions.length > 0 ? ` Did you mean ${choiceList(suggestions).join(", ")}?` : "";
487
- return `No release in the economic calendar matches "${query}"${country ? ` in ${country.split(",").join(", ")}` : ""}.${near} `
509
+ return `No release in the economic calendar matches "${query}"${country ? ` in ${country.split(",").join(", ")}` : ""}.`
510
+ + `${renamed ? ` ${releaseRenameNote(renamed)}` : ""}${near} `
488
511
  + `${ADD_FORMS} gloom econ --country <code> lists the calendar.`;
489
512
  }
490
513
 
@@ -511,12 +534,15 @@ async function requireEventDate(
511
534
  export const alertsCliCommand: CliCommandDef = {
512
535
  name: "alerts",
513
536
  aliases: ["alert"],
514
- description: "List, add, and remove price alerts, alerts before earnings and any economic calendar release (CPI, FOMC, NBS PMI), and news, options flow and IV alerts",
537
+ description: "List, add, and remove price, valuation and daily technical alerts, alerts before earnings and any economic calendar release (CPI, FOMC, NBS PMI), and news, options flow and IV alerts",
515
538
  help: {
516
539
  group: CLI_COMMAND_GROUPS.portfolios,
517
540
  usage: [
518
541
  "alerts list",
519
542
  "alerts add <symbol> <above|below|crosses> <price>",
543
+ "alerts add <symbol> <pe|ps|rsi(14)> <above|below> <level>",
544
+ "alerts add <symbol> ma(50) crosses <above|below> ma(200)",
545
+ "alerts add <symbol> closes <above|below> ma(200)",
520
546
  "alerts add <symbol> earnings [--days <n> | results]",
521
547
  "alerts add <calendar event> [--country <code>] [--days <n>]",
522
548
  "alerts add [symbol] news <keyword>",
@@ -534,6 +560,11 @@ export const alertsCliCommand: CliCommandDef = {
534
560
  examples: [
535
561
  "alerts",
536
562
  "alerts add AAPL above 250",
563
+ "alerts add AAPL pe below 25",
564
+ "alerts add AAPL ps above 8",
565
+ 'alerts add NVDA "rsi(14)" under 30',
566
+ 'alerts add SPY "ma(50)" crosses above "ma(200)"',
567
+ 'alerts add TSLA closes below "ma(200)"',
537
568
  "alerts add SAN:EPA below 70",
538
569
  "alerts add CRWV earnings --days 3",
539
570
  "alerts add CRWV earnings results",
@@ -568,7 +599,7 @@ export const alertsCliCommand: CliCommandDef = {
568
599
  };
569
600
  const request = action === "add" ? parseAdd() : null;
570
601
  const research = request && isResearchRequest(request) ? request : null;
571
- const listedSymbol = request?.kind === "price" ? request.symbol
602
+ const listedSymbol = (request?.kind === "price" || request?.kind === "metric") ? request.symbol
572
603
  : research && research.kind !== "iv_spike" ? research.symbol : null;
573
604
  // A price alert is kept for the listing `quote` prints for the symbol, and for no symbol that quotes nothing;
574
605
  // a news, flow or IV rule on a symbol follows that listing too, and only in the US, where their data is.
@@ -588,6 +619,12 @@ export const alertsCliCommand: CliCommandDef = {
588
619
  })
589
620
  : null;
590
621
  const priceListing = priced?.listing ?? null;
622
+ if (request?.kind === "metric" && priced) {
623
+ const exchange = canonicalExchange(priced.listing.exchange || priced.quote.listingExchangeName || priced.quote.exchangeName);
624
+ const preview = { ...createAlert(priced.listing.symbol, request.condition, request.price, exchange), metric: request.metric };
625
+ await withMarketData(ctx, market => metricReadings(market.dataProvider)(preview)).catch(error => ctx.fail(error instanceof Error ? error.message : String(error)));
626
+ priced.listing.exchange = exchange;
627
+ }
591
628
  if (request?.kind === "price" && request.thousandsDot && priced) {
592
629
  const question = thousandsDotMessage(request, priced.listing.key, lastPrice(priced.quote), exchangeOption);
593
630
  if (question) ctx.fail(question);
@@ -628,7 +665,7 @@ export const alertsCliCommand: CliCommandDef = {
628
665
  const match = matchCalendarEvent(request.query, request.country, events, now);
629
666
  if (match.kind !== "match") {
630
667
  return ctx.fail(match.kind === "none"
631
- ? noReleaseMessage(request.query, request.country, match.suggestions)
668
+ ? noReleaseMessage(request.query, request.country, match.suggestions, match.renamed)
632
669
  : severalCountriesMessage(request.query, match.choices));
633
670
  }
634
671
  const { choice, others } = match;
@@ -638,7 +675,7 @@ export const alertsCliCommand: CliCommandDef = {
638
675
  read = [`Read "${request.query}" as the ${MACRO_EVENT_NAMES[macroEvent]} alert, which follows ${calendarEventLabel({ event: choice.name, country: choice.country })}.`];
639
676
  } else {
640
677
  newRelease = choice;
641
- read = releaseReadNotes(request.query, choice, others);
678
+ read = releaseReadNotes(request.query, choice, others, match.renamed);
642
679
  const target: ScheduledEventTarget = { kind: "release", event: choice.name, country: choice.country, ...(choice.publisher ? { publisher: choice.publisher } : {}) };
643
680
  try {
644
681
  newEventRule = createScheduledAlert(target, request.leadDays, choice.next ? dateKey(choice.next.date) : null);
@@ -714,16 +751,18 @@ export const alertsCliCommand: CliCommandDef = {
714
751
  }
715
752
 
716
753
  await withConfigData(ctx, async (config) => {
717
- const { pluginState, alerts, alertsError, events } = readAlertStores(config.config);
754
+ const { pluginState, alerts, alertsError, levelErrors, events } = readAlertStores(config.config);
718
755
  const save = async (next: { alerts?: AlertRule[]; events?: EventAlertRule[] }) => {
719
- if (next.alerts && alertsError) ctx.fail(alertsError);
756
+ if (next.alerts) for (const [key, value] of Object.entries(writeLevelAlertStores(next.alerts))) {
757
+ if (levelErrors[key] && value !== "[]") ctx.fail(levelErrors[key]!);
758
+ }
720
759
  const nextConfig = {
721
760
  ...config.config,
722
761
  pluginConfig: {
723
762
  ...config.config.pluginConfig,
724
763
  [ALERTS_PLUGIN_ID]: {
725
764
  ...pluginState,
726
- ...(next.alerts ? { [ALERTS_KEY]: serializeAlerts(next.alerts) } : {}),
765
+ ...(next.alerts ? Object.fromEntries(Object.entries(writeLevelAlertStores(next.alerts)).filter(([key]) => !levelErrors[key])) : {}),
727
766
  // Each list keeps what it holds of kinds this version does not know; IV levels go in their own.
728
767
  ...(next.events ? writeEventAlertStores(events.lists, next.events) : {}),
729
768
  },
@@ -737,14 +776,14 @@ export const alertsCliCommand: CliCommandDef = {
737
776
  return events.rules;
738
777
  };
739
778
 
740
- if (request?.kind === "price") {
779
+ if (request?.kind === "price" || request?.kind === "metric") {
741
780
  const { condition, price } = request;
742
781
  const symbol = requireArg(priceListing?.key, ADD_USAGE, ctx);
743
- const alert = createAlert(symbol, condition, price, priceListing?.exchange);
782
+ const alert = { ...createAlert(symbol, condition, price, priceListing?.exchange), ...(request.kind === "metric" ? { metric: request.metric } : {}) };
744
783
  await save({ alerts: [...alerts, alert] });
745
784
  ctx.printResult({ data: { changed: !ctx.cliOptions.dryRun, dryRun: ctx.cliOptions.dryRun, alert }, ...(read.length > 0 ? { warnings: read } : {}) }, {
746
785
  // The delivery, as docs/usage.md states it, before any full stop, which after the price would read as part of it (below 81.).
747
- text: (data) => `Added alert ${data.alert.id}: ${symbol} ${condition} ${price} (${PRICE_DELIVERY}).${dryRunNote(data.dryRun)}`,
786
+ text: (data) => `Added alert ${data.alert.id}: ${data.alert.metric ? metricDescription(data.alert) : `${symbol} ${condition} ${price}`} (${PRICE_DELIVERY}).${dryRunNote(data.dryRun)}`,
748
787
  });
749
788
  return;
750
789
  }
@@ -1,3 +1,5 @@
1
+ import { metricFormInput, metricShortcut, parseMetricAlert } from "./metric-command";
2
+ import type { AlertMetric } from "./metric-model";
1
3
  import type { AlertCondition } from "./types";
2
4
  import { readAlertPrice } from "./price";
3
5
  import { normalizeAlertSymbol } from "./quotes";
@@ -6,6 +8,7 @@ export interface AlertCommandInput {
6
8
  symbol: string;
7
9
  condition: AlertCondition;
8
10
  price: number;
11
+ metric?: AlertMetric;
9
12
  }
10
13
 
11
14
  function parseAlertCondition(value: string | undefined): AlertCondition | null {
@@ -34,6 +37,8 @@ export function parseAlertShortcutValues(
34
37
  input: string,
35
38
  context?: { activeTicker: string | null },
36
39
  ): Record<string, string> {
40
+ const metric = parseMetricAlert(input);
41
+ if (metric) return metricShortcut(metric);
37
42
  const parts = input.trim().split(/\s+/).filter(Boolean);
38
43
  if (parts.length === 0) {
39
44
  const activeTicker = normalizeAlertSymbol(context?.activeTicker);
@@ -73,6 +78,8 @@ export function parseAlertCommandValues(
73
78
  };
74
79
  }
75
80
 
81
+ const metric = values ? metricFormInput(values) : null;
82
+ if (metric) return metric;
76
83
  const symbol = values?.symbol?.trim().toUpperCase();
77
84
  const condition = parseAlertCondition(values?.condition);
78
85
  const priceStr = values?.price?.trim();
@@ -3,7 +3,7 @@ export const ALERTS_KEY = "alerts";
3
3
 
4
4
  /** The Alerts pane's tabs: what `ALRT --tab` takes, and the pane state `tab` holds. */
5
5
  export const ALERT_TABS = [
6
- { label: "Prices", value: "prices" },
6
+ { label: "Levels", value: "prices" },
7
7
  { label: "Events", value: "events" },
8
8
  { label: "History", value: "history" },
9
9
  ];
@@ -1,5 +1,6 @@
1
1
  import type { PluginResumeState } from "../../../types/plugin";
2
2
  import type { QuoteDataSource } from "../../../types/financials";
3
+ import { ALERTS_PLUGIN_ID } from "./constants";
3
4
 
4
5
  /**
5
6
  * The alerts this device's app fired, kept on the device (plugin resume
@@ -14,7 +15,7 @@ export interface FiredAlert {
14
15
  id: string;
15
16
  /** When the app fired it, ISO. */
16
17
  firedAt: string;
17
- /** The rule's kind: `price`, `iv_level`, `earnings_date`, `macro_event`. */
18
+ /** The rule's kind: `price`, `iv_level`, `earnings_date`, `macro_event`, `live_scan`. */
18
19
  kind: string;
19
20
  ruleId: string;
20
21
  /** The rule as it read when it went off: `BTC above 85000`, `IBIT · 30d ATM IV above 50%`. */
@@ -59,11 +60,51 @@ export function readFiredAlerts(value: unknown): FiredAlert[] {
59
60
  && optionalText(entry.unit) && optionalText(entry.detail) && optionalText(entry.symbol) && optionalText(entry.exchange));
60
61
  }
61
62
 
63
+ function firedAlertEntry(input: FiredAlertInput, id: string = crypto.randomUUID()): FiredAlert {
64
+ const { firedAt, ...rest } = input;
65
+ return { id, firedAt: firedAlertTime(firedAt) ?? new Date().toISOString(), ...rest };
66
+ }
67
+
62
68
  /** `entries` with one more at the end, the oldest dropped past the cap. */
63
69
  export function appendFiredAlert(entries: readonly FiredAlert[], input: FiredAlertInput, id: string = crypto.randomUUID()): FiredAlert[] {
64
- const { firedAt, ...rest } = input;
65
- const entry: FiredAlert = { id, firedAt: firedAlertTime(firedAt) ?? new Date().toISOString(), ...rest };
66
- return [...entries, entry].slice(-MAX_FIRED_ALERTS);
70
+ return [...entries, firedAlertEntry(input, id)].slice(-MAX_FIRED_ALERTS);
71
+ }
72
+
73
+ /** `entries` keeping only the newest `max` of each kind in `kinds`; other kinds are untouched. */
74
+ function capFiredAlertKinds(entries: readonly FiredAlert[], kinds: ReadonlySet<string>, max: number): FiredAlert[] {
75
+ const over = new Map<string, number>();
76
+ for (const entry of entries) if (kinds.has(entry.kind)) over.set(entry.kind, (over.get(entry.kind) ?? 0) + 1);
77
+ for (const [kind, count] of over) over.set(kind, count - max);
78
+ // Oldest first: the first entries of a kind over its cap are the ones to drop.
79
+ return entries.filter((entry) => {
80
+ const extra = over.get(entry.kind) ?? 0;
81
+ if (extra <= 0) return true;
82
+ over.set(entry.kind, extra - 1);
83
+ return false;
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Records an alert another plugin fired (a live scan's new matches) in this
89
+ * log, through the runtime's access to the alerts plugin's resume state.
90
+ * `keepAtMost` bounds how many entries of the recorded kinds the shared log
91
+ * keeps, so a busy source cannot push the price and event alerts out.
92
+ */
93
+ export function recordFiredAlertFor(
94
+ runtime: { getResumeState(pluginId: string, key: string): unknown; setResumeState(pluginId: string, key: string, value: unknown): void },
95
+ inputs: readonly FiredAlertInput[],
96
+ onError: (error: unknown) => void,
97
+ keepAtMost?: number,
98
+ ): void {
99
+ if (!inputs.length) return;
100
+ try {
101
+ let entries = [...readFiredAlerts(runtime.getResumeState(ALERTS_PLUGIN_ID, FIRED_LOG_KEY)), ...inputs.map((input) => firedAlertEntry(input))];
102
+ // The source's own oldest entries go first, before the log's cap reaches anyone else's.
103
+ if (keepAtMost != null) entries = capFiredAlertKinds(entries, new Set(inputs.map((input) => input.kind)), keepAtMost);
104
+ runtime.setResumeState(ALERTS_PLUGIN_ID, FIRED_LOG_KEY, entries.slice(-MAX_FIRED_ALERTS));
105
+ } catch (error) {
106
+ onError(error);
107
+ }
67
108
  }
68
109
 
69
110
  /** Records a fired alert in the device's log. A log that cannot be written never stops the alert itself. */
@@ -1,3 +1,4 @@
1
+ import { isDailyMetric } from "./metric-model";
1
2
  import { formatMarketPrice } from "../../../market-data/market/format";
2
3
  import { formatQuoteAgeWithSource } from "../../../market-data/quotes/time";
3
4
  import type { AlertCondition, AlertRule } from "./types";
@@ -15,7 +16,9 @@ export function relativeTime(ts: number): string {
15
16
  }
16
17
 
17
18
  export function formatCurrentPrice(alert: AlertRule, maxWidth = 9): string {
18
- if (alert.lastCheckError) return "No quote";
19
+ if (alert.lastCheckError) return !alert.metric ? "No quote"
20
+ : /US listing|US equities|calendar/.test(alert.lastCheckError) ? "US only"
21
+ : /missing|bars|history/i.test(alert.lastCheckError) ? "Bar gap" : "No data";
19
22
  return alert.lastCheckedPrice == null
20
23
  ? "-"
21
24
  : formatMarketPrice(alert.lastCheckedPrice, {
@@ -25,6 +28,8 @@ export function formatCurrentPrice(alert: AlertRule, maxWidth = 9): string {
25
28
  }
26
29
 
27
30
  export function formatAlertTargetPrice(alert: AlertRule, maxWidth = 9): string {
31
+ if (alert.metric?.kind === "ma_cross") return `${alert.metric.slowPeriod}-day MA`;
32
+ if (alert.metric?.kind === "close_ma") return `${alert.metric.period}-day MA`;
28
33
  return formatMarketPrice(alert.targetPrice, {
29
34
  maxWidth,
30
35
  minimumFractionDigits: 2,
@@ -37,7 +42,7 @@ export function formatAlertDistance(alert: AlertRule): string {
37
42
  if (currentPrice == null || !Number.isFinite(currentPrice) || currentPrice === 0) {
38
43
  return "-";
39
44
  }
40
- const percent = ((alert.targetPrice - currentPrice) / currentPrice) * 100;
45
+ const percent = (((alert.lastCheckedTarget ?? alert.targetPrice) - currentPrice) / currentPrice) * 100;
41
46
  if (!Number.isFinite(percent)) return "-";
42
47
  // One decimal at any distance, so the column holds still as the price moves;
43
48
  // a distance that rounds to zero is unsigned (0.0%, never -0.0%).
@@ -47,6 +52,7 @@ export function formatAlertDistance(alert: AlertRule): string {
47
52
 
48
53
  /** The Quote cell: the quote's age and feed, or, on the row of an alert that got none, why (a stale quote, or none). */
49
54
  export function formatQuoteChecked(alert: AlertRule): string {
55
+ if (alert.metric && isDailyMetric(alert.metric)) return "on daily close";
50
56
  if (alert.lastCheckError) return /\bstale\b/i.test(alert.lastCheckError) ? "Stale" : "No quote";
51
57
  if (alert.lastQuoteUpdatedAt) {
52
58
  return formatQuoteAgeWithSource({
@@ -1,3 +1,4 @@
1
+ import { isDailyMetric, metricName } from "./metric-model";
1
2
  /**
2
3
  * `gloom fn ALRT`: the Alerts pane's three tabs as reports. Prices lists
3
4
  * the price alerts as the pane's table does, Events the event alerts (earnings,
@@ -47,10 +48,14 @@ const instant = (value: unknown) => (typeof value === "number" ? firedAlertTime(
47
48
  const PRICE_COLUMNS: HeadlessPaneColumn[] = [
48
49
  { key: "status", header: "State", format: (value) => (value === "triggered" ? "Triggered" : "Active") },
49
50
  { key: "symbol", header: "Symbol", format: (value, row) => (row.exchange ? `${text(value)}:${text(row.exchange)}` : text(value)) },
51
+ { key: "metric", header: "Measure", format: (_value, row) => asAlert(row).metric ? metricName(asAlert(row).metric!) : "Price" },
50
52
  { key: "lastCheckedPrice", header: "Current", align: "right", textFormat: (_value, row) => formatCurrentPrice(asAlert(row), 14) },
51
53
  { key: "targetPrice", header: "Target", align: "right", textFormat: (_value, row) => formatAlertTargetPrice(asAlert(row), 14) },
52
54
  { key: "awayPercent", header: "Away", align: "right", format: percent, textFormat: (_value, row) => formatAlertDistance(asAlert(row)) },
53
- { key: "condition", header: "Trigger", format: (value) => conditionLabel(value as AlertRule["condition"]) },
55
+ { key: "condition", header: "Trigger", format: (value, row) => {
56
+ const metric = asAlert(row).metric;
57
+ return metric?.kind === "ma_cross" ? `Cross ${metric.direction}` : conditionLabel(value as AlertRule["condition"]);
58
+ } },
54
59
  { key: "quoteAt", header: "Quote", textFormat: (_value, row) => formatQuoteChecked(asAlert(row)) },
55
60
  { key: "quoteSource", header: "Quote source" },
56
61
  { key: "lastCheckedAt", header: "Checked", format: instant },
@@ -93,13 +98,13 @@ const HISTORY_TEXT_COLUMNS = HISTORY_COLUMNS.filter((column) => column.key !== "
93
98
  function priceRow(alert: AlertRule): HeadlessPaneRow {
94
99
  const { lastQuoteProviderId: _provider, ...stored } = alert;
95
100
  const price = alert.lastCheckError ? null : alert.lastCheckedPrice;
96
- const away = price == null || !Number.isFinite(price) || price === 0 ? null : ((alert.targetPrice - price) / price) * 100;
101
+ const away = price == null || !Number.isFinite(price) || price === 0 ? null : (((alert.lastCheckedTarget ?? alert.targetPrice) - price) / price) * 100;
97
102
  const quoted = !alert.lastCheckError;
98
103
  return {
99
104
  ...stored,
100
105
  awayPercent: away != null && Number.isFinite(away) ? Number(away.toFixed(2)) : null,
101
106
  quoteAt: quoted ? firedAlertTime(alert.lastQuoteUpdatedAt) : null,
102
- quoteSource: quoted ? quoteSourceWords(alert.lastQuoteSource) : null,
107
+ quoteSource: quoted ? alert.metric && isDailyMetric(alert.metric) ? "daily close" : quoteSourceWords(alert.lastQuoteSource) : null,
103
108
  };
104
109
  }
105
110
 
@@ -111,7 +116,7 @@ function pricesResult(alerts: readonly AlertRule[], error: string | null, events
111
116
  columns: PRICE_COLUMNS,
112
117
  textColumns: PRICE_TEXT_COLUMNS,
113
118
  rows: [...active, ...triggered].map(priceRow),
114
- empty: "No price alerts. Add one with gloom alerts add AAPL above 250.",
119
+ empty: "No level alerts. Add one with gloom alerts add AAPL above 250.",
115
120
  ...(error ? { errors: [error], complete: false } : {}),
116
121
  ...(events > 0 ? { notes: [`${plural(events, "event alert")} not listed here: gloom fn ALRT --tab events`] } : {}),
117
122
  };
@@ -214,7 +219,7 @@ async function historyResult(ctx: HeadlessPaneContext): Promise<HeadlessRowsResu
214
219
 
215
220
  export const alertsHeadless: HeadlessPaneDefinition<"rows"> = {
216
221
  shape: "rows",
217
- description: "Your alerts: price alerts with current price and distance to target, event alerts (earnings, CPI, FOMC and other economic calendar releases, filings, news, IV levels, market moves) or the alerts that went off, from this app and, signed in, Gloom Cloud.",
222
+ description: "Your alerts: price, valuation and daily technical alerts with current price and distance to target, event alerts (earnings, CPI, FOMC and other economic calendar releases, filings, news, IV levels, market moves) or the alerts that went off, from this app and, signed in, Gloom Cloud.",
218
223
  freshness: { source: "Your alerts", status: "not-a-feed", basis: "your alert rules" },
219
224
  argument: { kind: "none" },
220
225
  options: [{