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 };