Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/alerts/src/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
21 changes: 19 additions & 2 deletions apps/alerts/src/alerts/sp500-close.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AlphaVantageClient } from "@repo/alpha-vantage";
import type { DailyBar } from "@repo/alpha-vantage/types";

import type { Alert } from "./types";

Expand All @@ -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",
});
Comment on lines +15 to +18
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})`;
Comment on lines +25 to +37
},
};

Expand Down
122 changes: 122 additions & 0 deletions apps/alerts/src/alerts/sp500-drawdown.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
135 changes: 135 additions & 0 deletions apps/alerts/src/alerts/sp500-drawdown.ts
Original file line number Diff line number Diff line change
@@ -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 <b>${formatPercent(deploySum)}</b> of your original cash reserve`
: `🧊 Just a dip — keep your powder dry`;
return [
`${deepest.headline} — <b>S&P 500 down ${formatPercent(bandToday)} from its all-time high!</b>`,
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 <b>${formatPercent(refillSum)}</b>`
: `🌤️ Storm passing — nothing to rebuild yet`;
return [
`📈 <b>REBOUND — S&P 500 back above ${formatPercent(reclaimed)} from its all-time high</b>`,
stats,
action,
].join("\n");
},
};

export { sp500Drawdown, DRAWDOWN_LEVELS };