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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/patches/electrobun@1.18.1.patch +32 -1
- package/src/account/auth-model.ts +7 -3
- package/src/api-client/auth.ts +9 -4
- package/src/api-client/catalysts.ts +2 -1
- package/src/api-client/data.ts +14 -0
- package/src/api-client/index.ts +160 -11
- package/src/api-client/live-scan.ts +91 -0
- package/src/api-client/members.ts +12 -0
- package/src/api-client/request.ts +222 -41
- package/src/api-client/research-activity.ts +3 -10
- package/src/api-client/types.ts +6 -0
- package/src/app/keybindings/index.ts +3 -0
- package/src/app/keybindings/labels.ts +18 -1
- package/src/app/keybindings/resolve.ts +14 -0
- package/src/app/runtime/broker-import.ts +43 -26
- package/src/architecture/core-builtin-allowlist.json +1 -1
- package/src/cli/commands/fred-series.ts +5 -0
- package/src/cli/commands/keybindings.ts +23 -0
- package/src/cli/commands/market.ts +68 -22
- package/src/cli/commands/options-chain.ts +59 -3
- package/src/cli/commands/overview.ts +119 -31
- package/src/cli/commands/search.ts +18 -2
- package/src/cli/commands/ticker.ts +2 -1
- package/src/cli/errors.ts +2 -2
- package/src/cli/export-source.ts +29 -1
- package/src/cli/glossary.ts +49 -0
- package/src/cli/history-rows.ts +30 -7
- package/src/cli/known-gaps.ts +5 -2
- package/src/cli/listing-arg.ts +16 -5
- package/src/cli/pane-functions/freshness.ts +25 -13
- package/src/cli/pane-functions/function-help.ts +10 -3
- package/src/cli/pane-functions/headless.ts +39 -5
- package/src/cli/pane-functions/index.ts +10 -3
- package/src/cli/pane-functions/market-session.ts +15 -5
- package/src/cli/pane-functions/report.ts +5 -1
- package/src/cli/pane-functions/resolver.ts +18 -2
- package/src/cli/quote-answer.ts +10 -4
- package/src/cli/report-tables.ts +18 -7
- package/src/cli/restore-plugins.ts +13 -1
- package/src/cli/result.ts +57 -5
- package/src/components/chart/composite/composite-chart.tsx +22 -4
- package/src/components/chart/composite/format.ts +27 -0
- package/src/components/chart/composite/levels.ts +2 -0
- package/src/components/chart/composite/panel-series.ts +1 -0
- package/src/components/chart/composite/panel-surface.tsx +9 -2
- package/src/components/chart/composite/range-statistics.ts +62 -0
- package/src/components/chart/composite/scene.ts +7 -3
- package/src/components/chart/composite/series-axis-tags.tsx +16 -9
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/command-bar/panel/prompt-binding.ts +0 -8
- package/src/components/command-bar/workflow/alert.ts +1 -1
- package/src/components/layout/header.tsx +26 -40
- package/src/components/layout/pane/footer/index.tsx +1 -0
- package/src/components/layout/pane/footer/model.ts +27 -0
- package/src/components/onboarding/onboarding-frame.tsx +1 -1
- package/src/components/onboarding/portfolio-step/positions-panel.tsx +2 -2
- package/src/components/ui/display.tsx +5 -1
- package/src/core/state/app/layout.ts +2 -0
- package/src/core/state/app/state.ts +9 -0
- package/src/core/state/app/types.ts +3 -0
- package/src/data/saved-copies.ts +55 -16
- package/src/docs/snapshot.json +361 -84
- package/src/i18n/es.ts +1 -1
- package/src/i18n/ja.ts +1 -1
- package/src/i18n/ko.ts +1 -1
- package/src/i18n/zh-cn.ts +1 -1
- package/src/i18n/zh-tw.ts +1 -1
- package/src/market-data/coordinator/financials.ts +10 -2
- package/src/market-data/coordinator/index.ts +3 -1
- package/src/market-data/coordinator/quotes.ts +4 -2
- package/src/market-data/market/change-basis.ts +33 -5
- package/src/market-data/market/dated-previous-close.ts +203 -0
- package/src/market-data/market/futures-week.ts +68 -0
- package/src/market-data/market/status.ts +16 -4
- package/src/market-data/options-alternatives.ts +60 -0
- package/src/market-data/quotes/contributions.ts +20 -2
- package/src/market-data/quotes/resolution.ts +3 -0
- package/src/market-data/selectors.ts +3 -0
- package/src/notifications/notification-log.ts +6 -4
- package/src/plugins/builtin/alerts/alert-engine.ts +5 -1
- package/src/plugins/builtin/alerts/calendar-events.ts +57 -6
- package/src/plugins/builtin/alerts/cli.ts +59 -20
- package/src/plugins/builtin/alerts/command.ts +7 -0
- package/src/plugins/builtin/alerts/constants.ts +1 -1
- package/src/plugins/builtin/alerts/fired-log.ts +45 -4
- package/src/plugins/builtin/alerts/format.ts +8 -2
- package/src/plugins/builtin/alerts/headless.ts +10 -5
- package/src/plugins/builtin/alerts/history-rows.ts +5 -2
- package/src/plugins/builtin/alerts/history.ts +2 -0
- package/src/plugins/builtin/alerts/index.tsx +66 -16
- package/src/plugins/builtin/alerts/live.ts +3 -3
- package/src/plugins/builtin/alerts/metric-command.ts +61 -0
- package/src/plugins/builtin/alerts/metric-data.ts +70 -0
- package/src/plugins/builtin/alerts/metric-model.ts +175 -0
- package/src/plugins/builtin/alerts/metric-parity-fixture.json +725 -0
- package/src/plugins/builtin/alerts/pane.tsx +42 -29
- package/src/plugins/builtin/alerts/storage.ts +33 -10
- package/src/plugins/builtin/alerts/types.ts +3 -0
- package/src/plugins/builtin/backtest/headless.ts +10 -5
- package/src/plugins/builtin/backtest/pane.tsx +9 -4
- package/src/plugins/builtin/backtest/presets.ts +29 -3
- package/src/plugins/builtin/broker-manager/index.tsx +3 -2
- package/src/plugins/builtin/broker-manager/model.ts +6 -1
- package/src/plugins/builtin/broker-trading/controller.ts +22 -12
- package/src/plugins/builtin/broker-trading/index.tsx +13 -1
- package/src/plugins/builtin/broker-trading/order-feed.ts +9 -4
- package/src/plugins/builtin/broker-trading/pane.tsx +1 -1
- package/src/plugins/builtin/builtin-plugin-meta.ts +1 -1
- package/src/plugins/builtin/catalysts/model.ts +2 -1
- package/src/plugins/builtin/chart-composer/catalog-inventory.ts +3 -1
- package/src/plugins/builtin/chart-composer/chart-spec-edit.ts +1 -1
- package/src/plugins/builtin/chart-composer/cli-options.ts +5 -5
- package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +5 -2
- package/src/plugins/builtin/chart-composer/editor/controller.ts +2 -0
- package/src/plugins/builtin/chart-composer/editor/model.ts +2 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +7 -5
- package/src/plugins/builtin/chart-composer/formula.ts +71 -18
- package/src/plugins/builtin/chart-composer/headless-schema.ts +14 -3
- package/src/plugins/builtin/chart-composer/headless.ts +13 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +17 -2
- package/src/plugins/builtin/chart-composer/presets.ts +50 -15
- package/src/plugins/builtin/chart-composer/quick-add.tsx +40 -25
- package/src/plugins/builtin/chart-composer/session-history.ts +18 -9
- package/src/plugins/builtin/chart-composer/use-series-catalog.ts +40 -26
- package/src/plugins/builtin/chat/content/command-suggestions.ts +92 -0
- package/src/plugins/builtin/chat/content/composer.tsx +13 -6
- package/src/plugins/builtin/chat/content/footer.tsx +6 -3
- package/src/plugins/builtin/chat/content/index.tsx +50 -21
- package/src/plugins/builtin/chat/content/open-targets.ts +14 -3
- package/src/plugins/builtin/chat/controller/session-runtime.ts +4 -2
- package/src/plugins/builtin/chat/index.tsx +23 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +1 -0
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +23 -0
- package/src/plugins/builtin/chat/message/pane-chip.tsx +50 -20
- package/src/plugins/builtin/chat/pane-links/commands.ts +265 -0
- package/src/plugins/builtin/chat/pane-links/model.ts +12 -3
- package/src/plugins/builtin/chat/pane-links/open.ts +8 -1
- package/src/plugins/builtin/chat/unread-inbox-pane.tsx +10 -1
- package/src/plugins/builtin/cloud/plugin.tsx +1 -1
- package/src/plugins/builtin/econ/calendar-model.ts +14 -9
- package/src/plugins/builtin/econ/figures.ts +72 -0
- package/src/plugins/builtin/econ/index.tsx +33 -3
- package/src/plugins/builtin/econ/types.ts +2 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +79 -31
- package/src/plugins/builtin/econ-statistics/stats.ts +1 -0
- package/src/plugins/builtin/econ-statistics/view.ts +3 -0
- package/src/plugins/builtin/equity-screener/pane.tsx +2 -2
- package/src/plugins/builtin/exposure/charts.tsx +2 -2
- package/src/plugins/builtin/exposure/model.ts +13 -1
- package/src/plugins/builtin/exposure/pane.tsx +4 -4
- package/src/plugins/builtin/futures/index.tsx +1 -0
- package/src/plugins/builtin/futures/model.ts +2 -1
- package/src/plugins/builtin/futures/table.tsx +2 -2
- package/src/plugins/builtin/fx-matrix/client.ts +6 -1
- package/src/plugins/builtin/fx-matrix/headless.ts +5 -2
- package/src/plugins/builtin/fx-matrix/index.tsx +13 -3
- package/src/plugins/builtin/fx-matrix/live-legs.ts +12 -0
- package/src/plugins/builtin/help/keybindings-editor.tsx +18 -14
- package/src/plugins/builtin/iv-history/headless.ts +3 -0
- package/src/plugins/builtin/macro-day/pane.tsx +7 -5
- package/src/plugins/builtin/market-movers/footer.ts +2 -2
- package/src/plugins/builtin/members/breadth-pane.tsx +81 -0
- package/src/plugins/builtin/members/client.ts +19 -4
- package/src/plugins/builtin/members/headless.ts +20 -7
- package/src/plugins/builtin/members/index.tsx +9 -9
- package/src/plugins/builtin/members/model.ts +23 -4
- package/src/plugins/builtin/members/pane.tsx +25 -15
- package/src/plugins/builtin/options/analytics.ts +26 -2
- package/src/plugins/builtin/options/model-spot.ts +5 -2
- package/src/plugins/builtin/options/strike-window.ts +2 -1
- package/src/plugins/builtin/options/table.ts +1 -7
- package/src/plugins/builtin/options/view.tsx +25 -8
- package/src/plugins/builtin/options-positioning/client.ts +19 -0
- package/src/plugins/builtin/options-positioning/headless.ts +54 -14
- package/src/plugins/builtin/options-positioning/index.tsx +3 -3
- package/src/plugins/builtin/options-positioning/model.ts +47 -8
- package/src/plugins/builtin/options-positioning/pane.tsx +68 -38
- package/src/plugins/builtin/perps/freshness.ts +12 -10
- package/src/plugins/builtin/perps/live-rows.ts +10 -3
- package/src/plugins/builtin/perps/pane.tsx +15 -11
- package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -2
- package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +11 -8
- package/src/plugins/builtin/rate-path/pane.tsx +2 -2
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +4 -4
- package/src/plugins/builtin/scanner/feed.ts +2 -2
- package/src/plugins/builtin/scanner/index.ts +24 -1
- package/src/plugins/builtin/scanner/live-condition-editor.tsx +222 -0
- package/src/plugins/builtin/scanner/live-feed.ts +168 -0
- package/src/plugins/builtin/scanner/live-headless.ts +114 -0
- package/src/plugins/builtin/scanner/live-model.ts +351 -0
- package/src/plugins/builtin/scanner/live-pane.tsx +677 -0
- package/src/plugins/builtin/scanner/live-track.ts +186 -0
- package/src/plugins/builtin/series-inventory.ts +1 -0
- package/src/plugins/builtin/shared/macro-releases.ts +65 -0
- package/src/plugins/builtin/shared/scanner-capture.ts +3 -0
- package/src/plugins/builtin/shared/use-quote-board.ts +8 -2
- package/src/plugins/builtin/shared/volatility/pricing.ts +7 -1
- package/src/plugins/builtin/themes/headless.ts +6 -4
- package/src/plugins/builtin/themes/model.ts +1 -1
- package/src/plugins/builtin/themes/pane.tsx +3 -3
- package/src/plugins/builtin/ticker-detail/quote-footer.ts +6 -1
- package/src/plugins/builtin/vol-surface/charts.tsx +9 -4
- package/src/plugins/builtin/vol-surface/client.ts +6 -4
- package/src/plugins/builtin/vol-surface/headless.ts +62 -13
- package/src/plugins/builtin/vol-surface/index.tsx +2 -2
- package/src/plugins/builtin/vol-surface/model.ts +22 -7
- package/src/plugins/builtin/vol-surface/pane.tsx +44 -7
- package/src/plugins/builtin/vol-surface/tables.ts +31 -3
- package/src/plugins/builtin/volatility/dvol.ts +46 -0
- package/src/plugins/builtin/volatility/pane.tsx +2 -2
- package/src/plugins/builtin/volatility/series.ts +26 -0
- package/src/plugins/builtin/world-indices/footer.ts +1 -1
- package/src/plugins/catalog-browser.ts +13 -8
- package/src/portfolio/column-values.ts +5 -5
- package/src/public/__snapshots__/public-api.test.ts.snap +1 -0
- package/src/renderers/browser/app-services.ts +1 -7
- package/src/renderers/browser/config-host.ts +2 -55
- package/src/renderers/browser/main.tsx +7 -57
- package/src/renderers/electrobun/bun/desktop/app-name.ts +222 -0
- package/src/renderers/electrobun/bun/desktop/backend.ts +17 -7
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +74 -25
- package/src/renderers/electrobun/bun/desktop/state-broadcaster.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/update.ts +70 -3
- package/src/renderers/electrobun/bun/desktop/workspace/index.ts +2 -0
- package/src/renderers/electrobun/bun/index.ts +25 -1
- package/src/renderers/electrobun/shared/app-name.ts +17 -0
- package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +3 -3
- package/src/renderers/electrobun/view/desktop/window/snapshot.ts +1 -0
- package/src/renderers/opentui/toast-host.tsx +71 -43
- package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
- package/src/sources/provider-router/batches.ts +10 -2
- package/src/sources/provider-router/financial-routes.ts +3 -3
- package/src/sources/provider-router/history.ts +28 -7
- package/src/sources/provider-router/index.ts +146 -10
- package/src/sources/provider-router/search.ts +13 -2
- package/src/state/app/context/index.tsx +3 -0
- package/src/sync/auth-transition.ts +19 -9
- package/src/sync/baseline.ts +10 -4
- package/src/sync/controller.ts +66 -16
- package/src/sync/react.ts +4 -1
- package/src/sync/types.ts +3 -0
- package/src/telemetry/usage-counts.ts +0 -18
- package/src/theme/colors.ts +8 -0
- package/src/tickers/company-name.ts +59 -0
- package/src/tickers/listing-venues.ts +23 -0
- package/src/tickers/search/bare-symbol.ts +36 -0
- package/src/tickers/search/index.ts +36 -7
- package/src/tickers/search/ranking.ts +10 -1
- package/src/tickers/search/types.ts +5 -0
- package/src/time-series/base-series.ts +13 -5
- package/src/time-series/daily-valuation.ts +62 -0
- package/src/time-series/field-catalog.ts +1 -3
- package/src/time-series/forward-valuation.ts +32 -0
- package/src/time-series/fundamentals.ts +155 -7
- package/src/time-series/hooks.ts +1 -0
- package/src/time-series/price-units.ts +72 -2
- package/src/time-series/resolve-context.ts +5 -1
- package/src/time-series/resolve.ts +61 -12
- package/src/time-series/series-presentation.ts +40 -6
- package/src/time-series/spec.ts +2 -1
- package/src/time-series/studies.ts +270 -72
- package/src/time-series/transforms.ts +1 -0
- package/src/time-series/types.ts +5 -0
- package/src/time-series/valuation-releases.ts +39 -0
- package/src/types/data-provider.ts +9 -1
- package/src/types/desktop-window.ts +2 -0
- package/src/types/financials.ts +41 -1
- package/src/types/headless.ts +42 -4
- package/src/types/instrument.ts +3 -0
- package/src/types/plugin.ts +1 -1
- package/src/updater/command-names.ts +12 -7
- package/src/utils/financial-statements.ts +6 -1
- package/src/utils/network-failure.ts +9 -6
- package/src/utils/option-expiry.ts +48 -1
- package/src/utils/options.ts +10 -0
- package/src/utils/plugin-proxy-hosts.json +1 -0
- package/src/utils/price-history.ts +5 -0
- 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,
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
31
|
-
import {
|
|
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(
|
|
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(
|
|
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(", ")}` : ""}
|
|
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
|
|
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 ?
|
|
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: "
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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) =>
|
|
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
|
|
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: [{
|