From 902623bb55d36c7725061facbbf87c22e7e38356 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:35:15 +0300 Subject: [PATCH 1/2] fix: derive sp500 ATH from free weekly series --- apps/alerts/src/alerts/sp500-close.test.ts | 133 ++++++++++++++++++ apps/alerts/src/alerts/sp500-close.ts | 28 ++-- apps/alerts/src/alerts/sp500-drawdown.test.ts | 104 ++++++++++++-- apps/alerts/src/alerts/sp500-drawdown.ts | 33 +++-- apps/alerts/src/alerts/sp500.ts | 58 ++++++++ apps/alerts/src/scheduled.test.ts | 34 ++--- .../alpha-vantage/src/alpha-vantage.test.ts | 68 +++++++++ packages/alpha-vantage/src/alpha-vantage.ts | 35 ++++- packages/alpha-vantage/src/types.ts | 93 +++++++----- 9 files changed, 493 insertions(+), 93 deletions(-) create mode 100644 apps/alerts/src/alerts/sp500-close.test.ts create mode 100644 apps/alerts/src/alerts/sp500.ts diff --git a/apps/alerts/src/alerts/sp500-close.test.ts b/apps/alerts/src/alerts/sp500-close.test.ts new file mode 100644 index 0000000..45dbe61 --- /dev/null +++ b/apps/alerts/src/alerts/sp500-close.test.ts @@ -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"); + }); +}); diff --git a/apps/alerts/src/alerts/sp500-close.ts b/apps/alerts/src/alerts/sp500-close.ts index 05b26d9..d3fcbcb 100644 --- a/apps/alerts/src/alerts/sp500-close.ts +++ b/apps/alerts/src/alerts/sp500-close.ts @@ -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"; /** @@ -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 long-history weekly high, lifted by any fresher + // high among the recent daily closes. + 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)})`; }, }; diff --git a/apps/alerts/src/alerts/sp500-drawdown.test.ts b/apps/alerts/src/alerts/sp500-drawdown.test.ts index 19a376f..fe3f194 100644 --- a/apps/alerts/src/alerts/sp500-drawdown.test.ts +++ b/apps/alerts/src/alerts/sp500-drawdown.test.ts @@ -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); }, })); @@ -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", @@ -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", @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); }); diff --git a/apps/alerts/src/alerts/sp500-drawdown.ts b/apps/alerts/src/alerts/sp500-drawdown.ts index 12ea6c5..630a5c3 100644 --- a/apps/alerts/src/alerts/sp500-drawdown.ts +++ b/apps/alerts/src/alerts/sp500-drawdown.ts @@ -1,6 +1,7 @@ import { AlphaVantageClient } from "@repo/alpha-vantage"; import type { DailyBar } from "@repo/alpha-vantage/types"; +import { fetchSp500 } from "./sp500"; import type { Alert } from "./types"; type DrawdownLevel = { @@ -50,12 +51,16 @@ const formatPercent = (fraction: number): string => /** * 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 + * all-time-high 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. * + * The ATH combines recent daily closes with the free weekly-close history (see + * `fetchSp500`), so deep history is a weekly-closing-high approximation rather + * than an exact daily-close ATH. + * * 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. */ @@ -64,24 +69,26 @@ const sp500Drawdown: 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", - }); + // 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 (series.bars.length < 2) { + if (bars.length < 2) { logger.warn("need at least 2 SPY bars for drawdown", { - symbol: series.symbol, - count: series.bars.length, + count: 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 [today, prev] = bars; + + // ATH = the pre-window weekly high, lifted by the highest recent daily + // close. `priorHigh` excludes the daily window entirely (so it can't carry + // today's close), which lets `athPrev` drop today's bar cleanly and avoid a + // false recovery crossing when today prints a new high. + const athToday = Math.max(priorHigh, highClose(bars)); + const athPrev = Math.max(priorHigh, highClose(bars.slice(1))); const ddToday = (athToday - today.close) / athToday; const ddPrev = (athPrev - prev.close) / athPrev; diff --git a/apps/alerts/src/alerts/sp500.ts b/apps/alerts/src/alerts/sp500.ts new file mode 100644 index 0000000..91e0e9c --- /dev/null +++ b/apps/alerts/src/alerts/sp500.ts @@ -0,0 +1,58 @@ +import type { AlphaVantageClient } from "@repo/alpha-vantage"; +import type { DailyBar } from "@repo/alpha-vantage/types"; + +/** The S&P 500 ETF symbol both SP500 alerts track. */ +const SP500_SYMBOL = "SPY"; + +type Sp500Data = { + /** Recent daily bars (free `compact` history), newest-first. */ + bars: DailyBar[]; + /** + * All-time-high baseline from the free weekly series, covering only the weeks + * that ended *before* the daily `compact` window begins. The daily bars cover + * the recent window precisely, so the effective ATH is + * `Math.max(priorHigh, )`. + * + * Two important consequences of the date cutoff: + * - No overlap/double-counting with the daily bars. + * - Today's close never leaks into `priorHigh`, so a caller can derive a + * contamination-free "as of yesterday" high by slicing the daily bars — + * otherwise the current (still-forming) weekly bar, whose close tracks the + * latest daily close, would inflate an all-but-today high. + * + * Caveat: a weekly close is the week's last trading day, so a record *daily* + * close made mid-week and older than the daily window is approximated by that + * week's (possibly lower) close. Accepted — the deployment alerts treat this + * as a drawdown-from-weekly-closing-high approximation. + */ + priorHigh: number; +}; + +/** + * Fetches the data both SP500 alerts need without the now-premium + * `TIME_SERIES_DAILY&outputsize=full` endpoint: the free daily `compact` series + * for precise recent closes, plus the free weekly series to establish the + * all-time high. Two independent API calls per alert; well within the free tier. + * If either call fails the whole fetch rejects, so the alert sends nothing + * rather than acting on partial data. + */ +const fetchSp500 = async (client: AlphaVantageClient): Promise => { + const [daily, weekly] = await Promise.all([ + client.getDailyTimeSeries(SP500_SYMBOL, { outputSize: "compact" }), + client.getWeeklyTimeSeries(SP500_SYMBOL), + ]); + + // Oldest daily bar (bars are newest-first) marks where the daily window + // starts; only count weekly closes from strictly before it. + const earliestDailyDate = daily.bars.at(-1)?.date; + const priorHigh = weekly.bars + .filter( + (bar) => earliestDailyDate === undefined || bar.date < earliestDailyDate + ) + .reduce((max, bar) => (bar.close > max ? bar.close : max), 0); + + return { bars: daily.bars, priorHigh }; +}; + +export { fetchSp500, SP500_SYMBOL }; +export type { Sp500Data }; diff --git a/apps/alerts/src/scheduled.test.ts b/apps/alerts/src/scheduled.test.ts index e36b84f..8d74b38 100644 --- a/apps/alerts/src/scheduled.test.ts +++ b/apps/alerts/src/scheduled.test.ts @@ -11,24 +11,26 @@ vi.mock("@repo/telegram", () => ({ }, })); +const spySeries = { + symbol: "SPY", + lastRefreshed: "2024-01-05", + timeZone: "US/Eastern", + bars: [ + { + date: "2024-01-05", + open: 468.3, + high: 469.13, + low: 464.45, + close: 467.28, + volume: 92955850, + }, + ], +}; + vi.mock("@repo/alpha-vantage", () => ({ AlphaVantageClient: class { - getDailyTimeSeries = () => - Promise.resolve({ - symbol: "SPY", - lastRefreshed: "2024-01-05", - timeZone: "US/Eastern", - bars: [ - { - date: "2024-01-05", - open: 468.3, - high: 469.13, - low: 464.45, - close: 467.28, - volume: 92955850, - }, - ], - }); + getDailyTimeSeries = () => Promise.resolve(spySeries); + getWeeklyTimeSeries = () => Promise.resolve(spySeries); }, })); diff --git a/packages/alpha-vantage/src/alpha-vantage.test.ts b/packages/alpha-vantage/src/alpha-vantage.test.ts index c07e3e1..e9a3bfb 100644 --- a/packages/alpha-vantage/src/alpha-vantage.test.ts +++ b/packages/alpha-vantage/src/alpha-vantage.test.ts @@ -46,6 +46,31 @@ const dailySuccessBody = { }, }; +const weeklySuccessBody = { + "Meta Data": { + "1. Information": "Weekly Prices (open, high, low, close) and Volumes", + "2. Symbol": "SPY", + "3. Last Refreshed": "2024-01-05", + "4. Time Zone": "US/Eastern", + }, + "Weekly Time Series": { + "2023-12-29": { + "1. open": "469.5200", + "2. high": "477.5500", + "3. low": "469.3200", + "4. close": "475.3100", + "5. volume": "312292710", + }, + "2024-01-05": { + "1. open": "472.9400", + "2. high": "473.4000", + "3. low": "464.4500", + "4. close": "467.2800", + "5. volume": "343549680", + }, + }, +}; + describe("AlphaVantageClient", () => { let client: AlphaVantageClient; @@ -115,4 +140,47 @@ describe("AlphaVantageClient", () => { ); }); }); + + describe("getWeeklyTimeSeries", () => { + it("normalizes a weekly response with bars newest-first and numbers parsed", async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse(weeklySuccessBody)); + + const series = await client.getWeeklyTimeSeries("SPY"); + + expect(series.symbol).toBe("SPY"); + expect(series.lastRefreshed).toBe("2024-01-05"); + expect(series.timeZone).toBe("US/Eastern"); + expect(series.bars).toHaveLength(2); + + const [latest] = series.bars; + expect(latest.date).toBe("2024-01-05"); + expect(latest.close).toBe(467.28); + }); + + it("sends the expected query parameters without outputsize", async () => { + mockFetch.mockResolvedValueOnce(createJsonResponse(weeklySuccessBody)); + + await client.getWeeklyTimeSeries("SPY"); + + const url = mockFetch.mock.calls[0]?.[0] as string; + expect(url).toContain("https://www.alphavantage.co/query?"); + expect(url).toContain("function=TIME_SERIES_WEEKLY"); + expect(url).toContain("symbol=SPY"); + expect(url).toContain("apikey=test-key"); + expect(url).not.toContain("outputsize"); + }); + + it("throws AlphaVantageError on a rate-limit body", async () => { + mockFetch.mockResolvedValueOnce( + createJsonResponse({ + Information: + "We have detected your API rate limit... 25 requests per day.", + }) + ); + + await expect(client.getWeeklyTimeSeries("SPY")).rejects.toThrow( + AlphaVantageError + ); + }); + }); }); diff --git a/packages/alpha-vantage/src/alpha-vantage.ts b/packages/alpha-vantage/src/alpha-vantage.ts index 305c64d..e5ced72 100644 --- a/packages/alpha-vantage/src/alpha-vantage.ts +++ b/packages/alpha-vantage/src/alpha-vantage.ts @@ -3,7 +3,11 @@ import type { Logger } from "@repo/logger"; import { z } from "zod"; import type { DailyTimeSeries } from "./types"; -import { alphaVantageErrorSchema, dailyResponseSchema } from "./types"; +import { + alphaVantageErrorSchema, + dailyResponseSchema, + weeklyResponseSchema, +} from "./types"; const BASE_URL = "https://www.alphavantage.co"; @@ -74,6 +78,35 @@ class AlphaVantageClient { return dailyResponseSchema.parse(raw); } + + /** + * Fetch the weekly OHLCV timeseries for `symbol`. Unlike the daily endpoint, + * `TIME_SERIES_WEEKLY` returns the full multi-year history for free (no + * `outputsize` parameter, no premium gating) — use it to establish an + * all-time high without a paid plan. + * + * @throws {AlphaVantageError} on an API-level error (bad key/symbol, rate limit). + */ + async getWeeklyTimeSeries(symbol: string): Promise { + const raw = await this.client.get("/query", { + schema: rawResponseSchema, + // Passed as `query` (not baked into the path) so the API key stays out of logs. + query: { + function: "TIME_SERIES_WEEKLY", + symbol, + apikey: this.apiKey, + }, + }); + + const apiError = alphaVantageErrorSchema.parse(raw); + const errorMessage = + apiError["Error Message"] ?? apiError.Note ?? apiError.Information; + if (errorMessage !== undefined) { + throw new AlphaVantageError(errorMessage); + } + + return weeklyResponseSchema.parse(raw); + } } export { AlphaVantageClient, AlphaVantageError }; diff --git a/packages/alpha-vantage/src/types.ts b/packages/alpha-vantage/src/types.ts index a01928d..30f7e43 100644 --- a/packages/alpha-vantage/src/types.ts +++ b/packages/alpha-vantage/src/types.ts @@ -38,7 +38,7 @@ const alphaVantageErrorSchema = z .partial() .loose(); -const rawDailyBarSchema = z.object({ +const rawBarSchema = z.object({ "1. open": z.string(), "2. high": z.string(), "3. low": z.string(), @@ -46,43 +46,68 @@ const rawDailyBarSchema = z.object({ "5. volume": z.string(), }); +type RawBar = z.infer; + +const toBar = (date: string, bar: RawBar): DailyBar => ({ + date, + open: Number(bar["1. open"]), + high: Number(bar["2. high"]), + low: Number(bar["3. low"]), + close: Number(bar["4. close"]), + volume: Number(bar["5. volume"]), +}); + +/** + * Builds a schema for one of the OHLCV timeseries endpoints. They share the + * bar shape but differ in the series key (`Time Series (Daily)` vs + * `Weekly Time Series`) and where the time zone lives in `Meta Data` (daily + * puts it at `5. Time Zone`, weekly at `4. Time Zone`). Bars come out + * newest-first. + */ +const timeSeriesSchema = (seriesKey: string, timeZoneKey: string) => + z + .object({ + "Meta Data": z + .object({ + "2. Symbol": z.string(), + "3. Last Refreshed": z.string(), + [timeZoneKey]: z.string(), + }) + .loose(), + [seriesKey]: z.record(z.string(), rawBarSchema), + }) + .transform((raw): DailyTimeSeries => { + const meta = raw["Meta Data"] as Record; + const series = raw[seriesKey] as Record; + const bars = Object.entries(series) + .map(([date, bar]) => toBar(date, bar)) + .sort((a, b) => (a.date < b.date ? 1 : -1)); + + return { + symbol: meta["2. Symbol"], + lastRefreshed: meta["3. Last Refreshed"], + timeZone: meta[timeZoneKey], + bars, + }; + }); + /** * Raw `TIME_SERIES_DAILY` success payload, transformed into the clean * {@link DailyTimeSeries} shape. */ -const dailyResponseSchema = z - .object({ - "Meta Data": z - .object({ - "2. Symbol": z.string(), - "3. Last Refreshed": z.string(), - "5. Time Zone": z.string(), - }) - .loose(), - "Time Series (Daily)": z.record(z.string(), rawDailyBarSchema), - }) - .transform((raw): DailyTimeSeries => { - const meta = raw["Meta Data"]; - const bars = Object.entries(raw["Time Series (Daily)"]) - .map( - ([date, bar]): DailyBar => ({ - date, - open: Number(bar["1. open"]), - high: Number(bar["2. high"]), - low: Number(bar["3. low"]), - close: Number(bar["4. close"]), - volume: Number(bar["5. volume"]), - }) - ) - .sort((a, b) => (a.date < b.date ? 1 : -1)); +const dailyResponseSchema = timeSeriesSchema( + "Time Series (Daily)", + "5. Time Zone" +); - return { - symbol: meta["2. Symbol"], - lastRefreshed: meta["3. Last Refreshed"], - timeZone: meta["5. Time Zone"], - bars, - }; - }); +/** + * Raw `TIME_SERIES_WEEKLY` success payload, transformed into the same + * {@link DailyTimeSeries} shape (a weekly bar is structurally identical). + */ +const weeklyResponseSchema = timeSeriesSchema( + "Weekly Time Series", + "4. Time Zone" +); -export { alphaVantageErrorSchema, dailyResponseSchema }; +export { alphaVantageErrorSchema, dailyResponseSchema, weeklyResponseSchema }; export type { DailyBar, DailyTimeSeries }; From 52deae44c620b041195b8b9d191fa1b0976958a4 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:52:38 +0300 Subject: [PATCH 2/2] docs: clarify weekly-close ATH comments --- apps/alerts/src/alerts/sp500-close.ts | 4 ++-- apps/alerts/src/alerts/sp500-drawdown.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/alerts/src/alerts/sp500-close.ts b/apps/alerts/src/alerts/sp500-close.ts index d3fcbcb..7836a41 100644 --- a/apps/alerts/src/alerts/sp500-close.ts +++ b/apps/alerts/src/alerts/sp500-close.ts @@ -21,8 +21,8 @@ const sp500Close: Alert = { } const latest = bars[0]; - // All-time high close: the long-history weekly high, lifted by any fresher - // high among the recent daily closes. + // 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; diff --git a/apps/alerts/src/alerts/sp500-drawdown.ts b/apps/alerts/src/alerts/sp500-drawdown.ts index 630a5c3..6ad2c85 100644 --- a/apps/alerts/src/alerts/sp500-drawdown.ts +++ b/apps/alerts/src/alerts/sp500-drawdown.ts @@ -83,10 +83,10 @@ const sp500Drawdown: Alert = { // Bars are newest-first: [0] is today's close, [1] is the prior close. const [today, prev] = bars; - // ATH = the pre-window weekly high, lifted by the highest recent daily - // close. `priorHigh` excludes the daily window entirely (so it can't carry - // today's close), which lets `athPrev` drop today's bar cleanly and avoid a - // false recovery crossing when today prints a new high. + // ATH = the highest pre-window weekly *close*, lifted by the highest recent + // daily close. `priorHigh` excludes the daily window entirely (so it can't + // carry today's close), which lets `athPrev` drop today's bar cleanly and + // avoid a false recovery crossing when today prints a new high. const athToday = Math.max(priorHigh, highClose(bars)); const athPrev = Math.max(priorHigh, highClose(bars.slice(1)));