diff --git a/apps/alerts/src/alerts/index.ts b/apps/alerts/src/alerts/index.ts
index 1c6e662..27ebf1d 100644
--- a/apps/alerts/src/alerts/index.ts
+++ b/apps/alerts/src/alerts/index.ts
@@ -1,11 +1,12 @@
import { healthcheck } from "./healthcheck";
import { sp500Close } from "./sp500-close";
+import { sp500Drawdown } from "./sp500-drawdown";
import type { Alert } from "./types";
/**
* The registry of alerts. Add new alerts here; remember to also add each
* alert's `cron` to `wrangler.jsonc` `triggers.crons`.
*/
-const alerts: Alert[] = [healthcheck, sp500Close];
+const alerts: Alert[] = [healthcheck, sp500Close, sp500Drawdown];
export { alerts };
diff --git a/apps/alerts/src/alerts/sp500-close.ts b/apps/alerts/src/alerts/sp500-close.ts
index db09afa..05b26d9 100644
--- a/apps/alerts/src/alerts/sp500-close.ts
+++ b/apps/alerts/src/alerts/sp500-close.ts
@@ -1,4 +1,5 @@
import { AlphaVantageClient } from "@repo/alpha-vantage";
+import type { DailyBar } from "@repo/alpha-vantage/types";
import type { Alert } from "./types";
@@ -11,13 +12,29 @@ const sp500Close: Alert = {
cron: "0 8 * * *", // reuses the existing daily 08:00 UTC trigger
run: async ({ env, logger }) => {
const client = new AlphaVantageClient(env.ALPHA_VANTAGE_API_KEY, logger);
- const series = await client.getDailyTimeSeries("SPY");
+ // `full` history is required to establish a real all-time high.
+ const series = await client.getDailyTimeSeries("SPY", {
+ outputSize: "full",
+ });
if (series.bars.length === 0) {
logger.warn("no SPY bars returned", { symbol: series.symbol });
return null;
}
+
const latest = series.bars[0];
- return `๐ SPY close ${latest.date}: $${latest.close.toFixed(2)}`;
+ // The bar with the highest close; `latest` is at the ATH when it ties it.
+ const athBar = series.bars.reduce((max: DailyBar, bar: DailyBar) =>
+ bar.close > max.close ? bar : max
+ );
+ const isNewAth = latest.close >= athBar.close;
+
+ const head = `๐ SPY close ${latest.date}: $${latest.close.toFixed(2)}`;
+ if (isNewAth) {
+ return `${head} โ ๐ all-time high`;
+ }
+
+ const drawdown = (athBar.close - latest.close) / athBar.close;
+ return `${head} (โ${(drawdown * 100).toFixed(1)}% from ATH $${athBar.close.toFixed(2)} on ${athBar.date})`;
},
};
diff --git a/apps/alerts/src/alerts/sp500-drawdown.test.ts b/apps/alerts/src/alerts/sp500-drawdown.test.ts
new file mode 100644
index 0000000..19a376f
--- /dev/null
+++ b/apps/alerts/src/alerts/sp500-drawdown.test.ts
@@ -0,0 +1,122 @@
+import type { DailyBar, DailyTimeSeries } from "@repo/alpha-vantage/types";
+import type { Logger } from "@repo/logger";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { sp500Drawdown } from "./sp500-drawdown";
+
+// The alert constructs `new AlphaVantageClient(...)` and calls
+// `getDailyTimeSeries`; return a canned series controlled per-test.
+let series: DailyTimeSeries;
+
+vi.mock("@repo/alpha-vantage", () => ({
+ AlphaVantageClient: class {
+ getDailyTimeSeries = () => Promise.resolve(series);
+ },
+}));
+
+const createMockLogger = (): Logger =>
+ ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }) as unknown as Logger;
+
+/**
+ * Build a newest-first series from `closes` (index 0 is the latest close). Only
+ * `date`/`close` matter to the alert; other OHLCV fields are filler.
+ */
+const seriesFromCloses = (closes: number[]): DailyTimeSeries => ({
+ symbol: "SPY",
+ lastRefreshed: "2026-07-11",
+ timeZone: "US/Eastern",
+ bars: closes.map(
+ (close, i): DailyBar => ({
+ date: `2026-07-${String(11 - i).padStart(2, "0")}`,
+ open: close,
+ high: close,
+ low: close,
+ close,
+ volume: 1_000_000,
+ })
+ ),
+});
+
+const env = {
+ TELEGRAM_BOT_TOKEN: "test-token",
+ ALLOWED_CHAT_ID: "12345",
+ ALPHA_VANTAGE_API_KEY: "test-key",
+};
+
+const run = () => sp500Drawdown.run({ env, logger: createMockLogger() });
+
+describe("sp500Drawdown", () => {
+ beforeEach(() => {
+ series = seriesFromCloses([100]);
+ });
+
+ it("returns null when both closes are in the same drawdown band", async () => {
+ // ATH 100; today โ8%, prev โ7% โ both sit in the 5% band, no crossing.
+ series = seriesFromCloses([92, 93, 100]);
+
+ expect(await run()).toBeNull();
+ });
+
+ it("returns null with fewer than two bars", async () => {
+ series = seriesFromCloses([95]);
+
+ expect(await run()).toBeNull();
+ });
+
+ it("reports a downward crossing of a single level with deploy guidance", async () => {
+ // ATH 100; prev โ8% (band 5%) โ today โ12% (band 10%).
+ series = seriesFromCloses([88, 92, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("CORRECTION");
+ expect(message).toContain("Deploy");
+ expect(message).toContain("15%");
+ expect(message).toContain("2026-07-11");
+ });
+
+ it("reports a recovery back above a level with refill guidance", async () => {
+ // ATH 100; prev โ12% (band 10%) โ today โ8% (band 5%).
+ series = seriesFromCloses([92, 88, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("REBOUND");
+ expect(message).toContain("Rebuild");
+ expect(message).toContain("15%");
+ });
+
+ it("sums the deploy amounts for every level crossed in a single-day move", async () => {
+ // ATH 100; prev โ8% (band 5%) โ today โ22% (band 20%): crosses 10% and 20%.
+ series = seriesFromCloses([78, 92, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("BEAR MARKET");
+ expect(message).toContain("Deploy");
+ expect(message).toContain("45%"); // 15% + 30%
+ });
+
+ it("pings the 5% dip with no deploy guidance", async () => {
+ // ATH 100; prev โ3% (band 0) โ today โ6% (band 5%, deploy 0).
+ series = seriesFromCloses([94, 97, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("DIP");
+ expect(message).toContain("keep your powder dry");
+ expect(message).not.toContain("Deploy");
+ });
+
+ it("returns null when today prints a new all-time high", async () => {
+ // Both closes are at/above the prior high โ drawdown 0, same band.
+ series = seriesFromCloses([105, 100, 90]);
+
+ expect(await run()).toBeNull();
+ });
+});
diff --git a/apps/alerts/src/alerts/sp500-drawdown.ts b/apps/alerts/src/alerts/sp500-drawdown.ts
new file mode 100644
index 0000000..12ea6c5
--- /dev/null
+++ b/apps/alerts/src/alerts/sp500-drawdown.ts
@@ -0,0 +1,135 @@
+import { AlphaVantageClient } from "@repo/alpha-vantage";
+import type { DailyBar } from "@repo/alpha-vantage/types";
+
+import type { Alert } from "./types";
+
+type DrawdownLevel = {
+ /** Drawdown from the all-time high (fraction) that triggers this level. */
+ threshold: number;
+ /** Fraction of the *original* cash reserve to deploy when crossing down. */
+ deploy: number;
+ /** Punchy headline, escalating with severity. */
+ headline: string;
+};
+
+/**
+ * Drawdown levels to watch, with the staged cash-reserve deployment plan. Adjust
+ * this list to change thresholds, deploy amounts, or copy. `deploy` values are
+ * fractions of the original reserve and sum to 1.0 by the โ50% level; the โ5%
+ * level is an informational "dip" ping with no deployment.
+ */
+const DRAWDOWN_LEVELS: DrawdownLevel[] = [
+ { threshold: 0.05, deploy: 0, headline: "๐ง DIP" },
+ { threshold: 0.1, deploy: 0.15, headline: "๐ CORRECTION" },
+ { threshold: 0.2, deploy: 0.3, headline: "๐ป BEAR MARKET" },
+ { threshold: 0.3, deploy: 0.3, headline: "๐ฅ CRASH" },
+ { threshold: 0.4, deploy: 0.2, headline: "๐ฅ MELTDOWN" },
+ { threshold: 0.5, deploy: 0.05, headline: "โข๏ธ CAPITULATION" },
+];
+
+/**
+ * The deepest level the drawdown has breached (`dd >= threshold`), or `0` when
+ * the price is above every level. Two closes in the same band mean no crossing.
+ */
+const bandFor = (drawdown: number): number => {
+ let band = 0;
+ for (const level of DRAWDOWN_LEVELS) {
+ if (drawdown >= level.threshold) {
+ band = level.threshold;
+ }
+ }
+ return band;
+};
+
+/** All-time high close across the given bars. */
+const highClose = (bars: DailyBar[]): number =>
+ bars.reduce((max, bar) => (bar.close > max ? bar.close : max), 0);
+
+const formatPercent = (fraction: number): string =>
+ `${Math.round(fraction * 100)}%`;
+
+/**
+ * Alerts when the S&P 500 (via SPY) crosses a drawdown level relative to its
+ * all-time-high daily close โ in either direction โ and tells you how much of
+ * your cash reserve to deploy (falling) or rebuild (recovering). Detection is
+ * stateless: it compares the two most recent daily closes, so a crossing is
+ * reported exactly once, on the day the drawdown band changes. The alert runs at
+ * 08:00 UTC (before the US open), so both bars are always completed closes.
+ *
+ * Trade-off: if the worker misses a scheduled run, a crossing that happened on
+ * the skipped trading day is not reported. Accepted to avoid adding persistence.
+ */
+const sp500Drawdown: Alert = {
+ name: "sp500-drawdown",
+ cron: "0 8 * * *", // reuses the existing daily 08:00 UTC trigger
+ run: async ({ env, logger }) => {
+ const client = new AlphaVantageClient(env.ALPHA_VANTAGE_API_KEY, logger);
+ // `full` history is required to establish a real all-time high.
+ const series = await client.getDailyTimeSeries("SPY", {
+ outputSize: "full",
+ });
+
+ if (series.bars.length < 2) {
+ logger.warn("need at least 2 SPY bars for drawdown", {
+ symbol: series.symbol,
+ count: series.bars.length,
+ });
+ return null;
+ }
+
+ // Bars are newest-first: [0] is today's close, [1] is the prior close.
+ const [today, prev] = series.bars;
+
+ const athToday = highClose(series.bars);
+ const athPrev = highClose(series.bars.slice(1));
+
+ const ddToday = (athToday - today.close) / athToday;
+ const ddPrev = (athPrev - prev.close) / athPrev;
+
+ const bandToday = bandFor(ddToday);
+ const bandPrev = bandFor(ddPrev);
+
+ if (bandToday === bandPrev) {
+ return null;
+ }
+
+ const stats = `SPY $${today.close.toFixed(2)} on ${today.date} ยท ATH $${athToday.toFixed(2)} (โ${formatPercent(ddToday)})`;
+
+ if (bandToday > bandPrev) {
+ // Market fell through one or more levels since the prior close.
+ const crossed = DRAWDOWN_LEVELS.filter(
+ (l) => l.threshold > bandPrev && l.threshold <= bandToday
+ );
+ const deepest = crossed[crossed.length - 1];
+ const deploySum = crossed.reduce((sum, l) => sum + l.deploy, 0);
+ const action =
+ deploySum > 0
+ ? `๐ซก Deploy ${formatPercent(deploySum)} of your original cash reserve`
+ : `๐ง Just a dip โ keep your powder dry`;
+ return [
+ `${deepest.headline} โ S&P 500 down ${formatPercent(bandToday)} from its all-time high!`,
+ stats,
+ action,
+ ].join("\n");
+ }
+
+ // Market recovered back above one or more levels.
+ const crossed = DRAWDOWN_LEVELS.filter(
+ (l) => l.threshold > bandToday && l.threshold <= bandPrev
+ );
+ // Deepest level reclaimed (crossed is ascending by threshold).
+ const reclaimed = crossed[crossed.length - 1].threshold;
+ const refillSum = crossed.reduce((sum, l) => sum + l.deploy, 0);
+ const action =
+ refillSum > 0
+ ? `๐ฐ Rebuild your cash reserve: add back ${formatPercent(refillSum)}`
+ : `๐ค๏ธ Storm passing โ nothing to rebuild yet`;
+ return [
+ `๐ REBOUND โ S&P 500 back above ${formatPercent(reclaimed)} from its all-time high`,
+ stats,
+ action,
+ ].join("\n");
+ },
+};
+
+export { sp500Drawdown, DRAWDOWN_LEVELS };