From 46edc49cf766d13a14ef218f9ec7753185fd7351 Mon Sep 17 00:00:00 2001
From: Juha Kangas <42040080+valuecodes@users.noreply.github.com>
Date: Sun, 12 Jul 2026 11:06:00 +0300
Subject: [PATCH 1/3] feat: add sp500 drawdown-from-ATH threshold alert
---
apps/alerts/src/alerts/index.ts | 3 +-
apps/alerts/src/alerts/sp500-drawdown.test.ts | 109 ++++++++++++++++++
apps/alerts/src/alerts/sp500-drawdown.ts | 101 ++++++++++++++++
3 files changed, 212 insertions(+), 1 deletion(-)
create mode 100644 apps/alerts/src/alerts/sp500-drawdown.test.ts
create mode 100644 apps/alerts/src/alerts/sp500-drawdown.ts
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-drawdown.test.ts b/apps/alerts/src/alerts/sp500-drawdown.test.ts
new file mode 100644
index 0000000..3638270
--- /dev/null
+++ b/apps/alerts/src/alerts/sp500-drawdown.test.ts
@@ -0,0 +1,109 @@
+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 above the 10% threshold.
+ 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 threshold", async () => {
+ // ATH 100; prev −8% (band 0) → today −12% (band 10%).
+ series = seriesFromCloses([88, 92, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("fell below");
+ expect(message).toContain("10%");
+ expect(message).toContain("2026-07-11");
+ });
+
+ it("reports a recovery back above a threshold", async () => {
+ // ATH 100; prev −12% (band 10%) → today −8% (band 0).
+ series = seriesFromCloses([92, 88, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("recovered above");
+ expect(message).toContain("10%");
+ });
+
+ it("lists every threshold crossed in a single-day move", async () => {
+ // ATH 100; prev −8% (band 0) → today −22% (band 20%): crosses 10% and 20%.
+ series = seriesFromCloses([78, 92, 100]);
+
+ const message = await run();
+
+ expect(message).toContain("fell below");
+ expect(message).toContain("10%");
+ expect(message).toContain("20%");
+ });
+
+ 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..4201dfa
--- /dev/null
+++ b/apps/alerts/src/alerts/sp500-drawdown.ts
@@ -0,0 +1,101 @@
+import { AlphaVantageClient } from "@repo/alpha-vantage";
+import type { DailyBar } from "@repo/alpha-vantage/types";
+
+import type { Alert } from "./types";
+
+/**
+ * Drawdown thresholds (fractions of the all-time high) to watch. Adjust this
+ * list to change which levels trigger an alert — e.g. add `0.05` or drop `0.5`.
+ */
+const DRAWDOWN_THRESHOLDS = [0.1, 0.2, 0.3, 0.4, 0.5];
+
+/**
+ * The deepest threshold the drawdown has breached (`dd >= T`), or `0` when the
+ * price is above every threshold. Two closes in the same band mean no crossing.
+ */
+const bandFor = (drawdown: number): number => {
+ let band = 0;
+ for (const threshold of DRAWDOWN_THRESHOLDS) {
+ if (drawdown >= threshold) {
+ band = 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 threshold relative to its
+ * all-time-high daily close — in either direction. 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;
+ }
+
+ if (bandToday > bandPrev) {
+ // Market fell through one or more thresholds since the prior close.
+ const crossed = DRAWDOWN_THRESHOLDS.filter(
+ (t) => t > bandPrev && t <= bandToday
+ );
+ const levels = crossed.map(formatPercent).join(", ");
+ return [
+ `📉 S&P 500 fell below ${levels} from its all-time high`,
+ `${today.date}: SPY $${today.close.toFixed(2)} (−${formatPercent(ddToday)} from ATH $${athToday.toFixed(2)})`,
+ ].join("\n");
+ }
+
+ // Market recovered back above one or more thresholds.
+ const crossed = DRAWDOWN_THRESHOLDS.filter(
+ (t) => t > bandToday && t <= bandPrev
+ );
+ const levels = crossed.map(formatPercent).join(", ");
+ return [
+ `📈 S&P 500 recovered above ${levels} from its all-time high`,
+ `${today.date}: SPY $${today.close.toFixed(2)} (−${formatPercent(ddToday)} from ATH $${athToday.toFixed(2)})`,
+ ].join("\n");
+ },
+};
+
+export { sp500Drawdown, DRAWDOWN_THRESHOLDS };
From eb1f41818ffad3ece564ba7922d81e81f6f28a33 Mon Sep 17 00:00:00 2001
From: Juha Kangas <42040080+valuecodes@users.noreply.github.com>
Date: Sun, 12 Jul 2026 11:09:44 +0300
Subject: [PATCH 2/3] feat: show all-time-high status in sp500 close alert
---
apps/alerts/src/alerts/sp500-close.ts | 21 +++++++++++++++++++--
1 file changed, 19 insertions(+), 2 deletions(-)
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})`;
},
};
From a9dcdd76c3fb68c45bd399a1f88610e78b77c762 Mon Sep 17 00:00:00 2001
From: Juha Kangas <42040080+valuecodes@users.noreply.github.com>
Date: Sun, 12 Jul 2026 12:13:19 +0300
Subject: [PATCH 3/3] feat: add staged dry-powder deploy guidance to sp500
drawdown alert
---
apps/alerts/src/alerts/sp500-drawdown.test.ts | 41 ++++++---
apps/alerts/src/alerts/sp500-drawdown.ts | 86 +++++++++++++------
2 files changed, 87 insertions(+), 40 deletions(-)
diff --git a/apps/alerts/src/alerts/sp500-drawdown.test.ts b/apps/alerts/src/alerts/sp500-drawdown.test.ts
index 3638270..19a376f 100644
--- a/apps/alerts/src/alerts/sp500-drawdown.test.ts
+++ b/apps/alerts/src/alerts/sp500-drawdown.test.ts
@@ -56,7 +56,7 @@ describe("sp500Drawdown", () => {
});
it("returns null when both closes are in the same drawdown band", async () => {
- // ATH 100; today −8%, prev −7% → both above the 10% threshold.
+ // ATH 100; today −8%, prev −7% → both sit in the 5% band, no crossing.
series = seriesFromCloses([92, 93, 100]);
expect(await run()).toBeNull();
@@ -68,36 +68,49 @@ describe("sp500Drawdown", () => {
expect(await run()).toBeNull();
});
- it("reports a downward crossing of a single threshold", async () => {
- // ATH 100; prev −8% (band 0) → today −12% (band 10%).
+ 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("fell below");
- expect(message).toContain("10%");
+ 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 threshold", async () => {
- // ATH 100; prev −12% (band 10%) → today −8% (band 0).
+ 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("recovered above");
- expect(message).toContain("10%");
+ expect(message).toContain("REBOUND");
+ expect(message).toContain("Rebuild");
+ expect(message).toContain("15%");
});
- it("lists every threshold crossed in a single-day move", async () => {
- // ATH 100; prev −8% (band 0) → today −22% (band 20%): crosses 10% and 20%.
+ 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("fell below");
- expect(message).toContain("10%");
- expect(message).toContain("20%");
+ 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 () => {
diff --git a/apps/alerts/src/alerts/sp500-drawdown.ts b/apps/alerts/src/alerts/sp500-drawdown.ts
index 4201dfa..12ea6c5 100644
--- a/apps/alerts/src/alerts/sp500-drawdown.ts
+++ b/apps/alerts/src/alerts/sp500-drawdown.ts
@@ -3,21 +3,39 @@ 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 thresholds (fractions of the all-time high) to watch. Adjust this
- * list to change which levels trigger an alert — e.g. add `0.05` or drop `0.5`.
+ * 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_THRESHOLDS = [0.1, 0.2, 0.3, 0.4, 0.5];
+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 threshold the drawdown has breached (`dd >= T`), or `0` when the
- * price is above every threshold. Two closes in the same band mean no crossing.
+ * 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 threshold of DRAWDOWN_THRESHOLDS) {
- if (drawdown >= threshold) {
- band = threshold;
+ for (const level of DRAWDOWN_LEVELS) {
+ if (drawdown >= level.threshold) {
+ band = level.threshold;
}
}
return band;
@@ -31,11 +49,12 @@ const formatPercent = (fraction: number): string =>
`${Math.round(fraction * 100)}%`;
/**
- * Alerts when the S&P 500 (via SPY) crosses a drawdown threshold relative to its
- * all-time-high daily close — in either direction. 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.
+ * 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.
@@ -74,28 +93,43 @@ const sp500Drawdown: Alert = {
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 thresholds since the prior close.
- const crossed = DRAWDOWN_THRESHOLDS.filter(
- (t) => t > bandPrev && t <= bandToday
+ // Market fell through one or more levels since the prior close.
+ const crossed = DRAWDOWN_LEVELS.filter(
+ (l) => l.threshold > bandPrev && l.threshold <= bandToday
);
- const levels = crossed.map(formatPercent).join(", ");
+ 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 [
- `📉 S&P 500 fell below ${levels} from its all-time high`,
- `${today.date}: SPY $${today.close.toFixed(2)} (−${formatPercent(ddToday)} from ATH $${athToday.toFixed(2)})`,
+ `${deepest.headline} — S&P 500 down ${formatPercent(bandToday)} from its all-time high!`,
+ stats,
+ action,
].join("\n");
}
- // Market recovered back above one or more thresholds.
- const crossed = DRAWDOWN_THRESHOLDS.filter(
- (t) => t > bandToday && t <= bandPrev
+ // Market recovered back above one or more levels.
+ const crossed = DRAWDOWN_LEVELS.filter(
+ (l) => l.threshold > bandToday && l.threshold <= bandPrev
);
- const levels = crossed.map(formatPercent).join(", ");
+ // 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 [
- `📈 S&P 500 recovered above ${levels} from its all-time high`,
- `${today.date}: SPY $${today.close.toFixed(2)} (−${formatPercent(ddToday)} from ATH $${athToday.toFixed(2)})`,
+ `📈 REBOUND — S&P 500 back above ${formatPercent(reclaimed)} from its all-time high`,
+ stats,
+ action,
].join("\n");
},
};
-export { sp500Drawdown, DRAWDOWN_THRESHOLDS };
+export { sp500Drawdown, DRAWDOWN_LEVELS };