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
133 changes: 133 additions & 0 deletions apps/alerts/src/alerts/sp500-close.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { DailyBar, DailyTimeSeries } from "@repo/alpha-vantage/types";
import type { Logger } from "@repo/logger";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { sp500Close } from "./sp500-close";

// The alert (via `fetchSp500`) calls both `getDailyTimeSeries` (recent closes)
// and `getWeeklyTimeSeries` (ATH baseline). `daily` drives the reported close;
// `weekly` supplies the historical high. Both are controlled per-test.
let daily: DailyTimeSeries;
let weekly: DailyTimeSeries;

vi.mock("@repo/alpha-vantage", () => ({
AlphaVantageClient: class {
getDailyTimeSeries = () => Promise.resolve(daily);
getWeeklyTimeSeries = () => Promise.resolve(weekly);
},
}));

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). */
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,
})
),
});

/**
* Weekly series with week-ending dates in June — strictly *before* the July
* daily window above, so `fetchSp500` counts them as the historical baseline.
*/
const weeklyFromCloses = (closes: number[]): DailyTimeSeries => ({
symbol: "SPY",
lastRefreshed: "2026-06-26",
timeZone: "US/Eastern",
bars: closes.map(
(close, i): DailyBar => ({
date: `2026-06-${String(26 - i * 7).padStart(2, "0")}`,
open: close,
high: close,
low: close,
close,
volume: 5_000_000,
})
),
});

const env = {
TELEGRAM_BOT_TOKEN: "test-token",
ALLOWED_CHAT_ID: "12345",
ALPHA_VANTAGE_API_KEY: "test-key",
};

const run = () => sp500Close.run({ env, logger: createMockLogger() });

describe("sp500Close", () => {
beforeEach(() => {
daily = seriesFromCloses([100]);
weekly = weeklyFromCloses([100]);
});

it("returns null when no bars are returned", async () => {
daily = { ...seriesFromCloses([]), bars: [] };
weekly = weeklyFromCloses([100]);

expect(await run()).toBeNull();
});

it("flags an all-time high when the latest close ties the ATH", async () => {
daily = seriesFromCloses([120, 110]);
weekly = weeklyFromCloses([120, 90]);

const message = await run();

expect(message).toContain("all-time high");
expect(message).toContain("$120.00");
});

it("uses the weekly history for the ATH baseline", async () => {
// Latest daily close 90, but the weekly high is 150 → −40% from ATH.
daily = seriesFromCloses([90, 95]);
weekly = weeklyFromCloses([150, 120]);

const message = await run();

expect(message).not.toContain("all-time high");
expect(message).toContain("−40.0%");
expect(message).toContain("$150.00");
});

it("lets a fresh daily high beat the weekly baseline", async () => {
// Recent daily high 160 exceeds the weekly high 150 → new ATH.
daily = seriesFromCloses([160, 140]);
weekly = weeklyFromCloses([150, 120]);

const message = await run();

expect(message).toContain("all-time high");
});

it("ignores an overlapping weekly bar dated inside the daily window", async () => {
// A weekly bar dated within the July daily window must not seed the ATH —
// only the daily closes cover that period. Weekly's high (200) shares the
// latest daily date, so it is excluded; the ATH is the daily high, 100.
daily = seriesFromCloses([100, 95]);
weekly = {
...seriesFromCloses([200]),
symbol: "SPY",
};

const message = await run();

expect(message).toContain("all-time high");
expect(message).not.toContain("200");
});
});
28 changes: 13 additions & 15 deletions apps/alerts/src/alerts/sp500-close.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AlphaVantageClient } from "@repo/alpha-vantage";
import type { DailyBar } from "@repo/alpha-vantage/types";

import { fetchSp500, SP500_SYMBOL } from "./sp500";
import type { Alert } from "./types";

/**
Expand All @@ -12,29 +12,27 @@ 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);
// `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 });
// Free daily `compact` closes + the free weekly series for the ATH baseline
// (daily `full` history is a premium feature). See `fetchSp500`.
const { bars, priorHigh } = await fetchSp500(client);
if (bars.length === 0) {
logger.warn("no SPY bars returned", { symbol: SP500_SYMBOL });
return null;
}

const latest = series.bars[0];
// 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 latest = bars[0];
// All-time high close: the highest weekly *close* from prior history,
// lifted by any fresher close among the recent daily bars.
const ath = bars.reduce((max, bar) => Math.max(max, bar.close), priorHigh);
const isNewAth = latest.close >= ath;

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})`;
const drawdown = (ath - latest.close) / ath;
return `${head} (−${(drawdown * 100).toFixed(1)}% from ATH $${ath.toFixed(2)})`;
},
};

Expand Down
104 changes: 90 additions & 14 deletions apps/alerts/src/alerts/sp500-drawdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ 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;
// The alert (via `fetchSp500`) calls both `getDailyTimeSeries` (recent closes)
// and `getWeeklyTimeSeries` (historical ATH baseline). Both are controlled
// per-test. The default weekly baseline is well below the daily ATH, so the
// daily series drives the classic cases; the last two tests exercise the
// weekly baseline and the date-cutoff that keeps today out of it.
let daily: DailyTimeSeries;
let weekly: DailyTimeSeries;

vi.mock("@repo/alpha-vantage", () => ({
AlphaVantageClient: class {
getDailyTimeSeries = () => Promise.resolve(series);
getDailyTimeSeries = () => Promise.resolve(daily);
getWeeklyTimeSeries = () => Promise.resolve(weekly);
},
}));

Expand All @@ -23,8 +28,9 @@ const createMockLogger = (): Logger =>
}) 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.
* Build a newest-first daily series from `closes` (index 0 is the latest close,
* dated 2026-07-11). Only `date`/`close` matter to the alert; other OHLCV
* fields are filler.
*/
const seriesFromCloses = (closes: number[]): DailyTimeSeries => ({
symbol: "SPY",
Expand All @@ -42,6 +48,26 @@ const seriesFromCloses = (closes: number[]): DailyTimeSeries => ({
),
});

/**
* Weekly series with week-ending dates in June — strictly *before* the July
* daily window, so `fetchSp500` counts them as the historical baseline.
*/
const weeklyFromCloses = (closes: number[]): DailyTimeSeries => ({
symbol: "SPY",
lastRefreshed: "2026-06-26",
timeZone: "US/Eastern",
bars: closes.map(
(close, i): DailyBar => ({
date: `2026-06-${String(26 - i * 7).padStart(2, "0")}`,
open: close,
high: close,
low: close,
close,
volume: 5_000_000,
})
),
});

const env = {
TELEGRAM_BOT_TOKEN: "test-token",
ALLOWED_CHAT_ID: "12345",
Expand All @@ -52,25 +78,28 @@ const run = () => sp500Drawdown.run({ env, logger: createMockLogger() });

describe("sp500Drawdown", () => {
beforeEach(() => {
series = seriesFromCloses([100]);
daily = seriesFromCloses([100]);
// Low historical baseline: below the in-daily ATH, so the daily series
// drives the classic cases below.
weekly = weeklyFromCloses([50]);
});

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]);
daily = seriesFromCloses([92, 93, 100]);

expect(await run()).toBeNull();
});

it("returns null with fewer than two bars", async () => {
series = seriesFromCloses([95]);
daily = 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]);
daily = seriesFromCloses([88, 92, 100]);

const message = await run();

Expand All @@ -82,7 +111,7 @@ describe("sp500Drawdown", () => {

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]);
daily = seriesFromCloses([92, 88, 100]);

const message = await run();

Expand All @@ -93,7 +122,7 @@ describe("sp500Drawdown", () => {

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]);
daily = seriesFromCloses([78, 92, 100]);

const message = await run();

Expand All @@ -104,7 +133,7 @@ describe("sp500Drawdown", () => {

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]);
daily = seriesFromCloses([94, 97, 100]);

const message = await run();

Expand All @@ -115,7 +144,54 @@ describe("sp500Drawdown", () => {

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]);
daily = seriesFromCloses([105, 100, 90]);

expect(await run()).toBeNull();
});

it("drives the drawdown off the weekly ATH when it exceeds recent closes", async () => {
// Recent daily closes sit below a historical weekly high of 200: today
// −25% (band 20%), prev −7.5% (band 5%) → crosses 10% and 20% (BEAR).
daily = seriesFromCloses([150, 185]);
weekly = weeklyFromCloses([200, 180]);

const message = await run();

expect(message).toContain("BEAR MARKET");
expect(message).toContain("45%"); // 15% + 30%
expect(message).toContain("$200.00"); // ATH sourced from weekly history
});

it("keeps today out of the ATH baseline (no false rebound on a new high)", async () => {
// Today prints a fresh high (130) after a flat prior close (100). A weekly
// bar dated on today's date carries that 130 — if it leaked into the
// baseline, `athPrev` would inflate to 130 and fabricate a REBOUND. The
// date cutoff excludes it, so only the June baseline (100) counts → no
// crossing.
daily = seriesFromCloses([130, 100, 100]);
weekly = {
symbol: "SPY",
lastRefreshed: "2026-07-11",
timeZone: "US/Eastern",
bars: [
{
date: "2026-07-11", // overlaps the daily window → must be ignored
open: 130,
high: 130,
low: 130,
close: 130,
volume: 5_000_000,
},
{
date: "2026-06-26",
open: 100,
high: 100,
low: 100,
close: 100,
volume: 5_000_000,
},
],
};

expect(await run()).toBeNull();
});
Expand Down
Loading