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
6 changes: 6 additions & 0 deletions apps/alerts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ Local secrets live in `apps/alerts/.dev.vars` (gitignored):
```
TELEGRAM_BOT_TOKEN=...
ALLOWED_CHAT_ID=...
ALPHA_VANTAGE_API_KEY=...
```

All keys are required — the worker validates them on every run and fails all
alerts if any is missing. A free Alpha Vantage key is available at
<https://www.alphavantage.co/support/#api-key>.

## Secrets & deploy

Production secrets are **not** in `wrangler.jsonc`. Set them once per Cloudflare
Expand All @@ -55,6 +60,7 @@ environment:
```sh
pnpm --filter @repo/alerts exec wrangler secret put TELEGRAM_BOT_TOKEN
pnpm --filter @repo/alerts exec wrangler secret put ALLOWED_CHAT_ID
pnpm --filter @repo/alerts exec wrangler secret put ALPHA_VANTAGE_API_KEY
```

Deploys run automatically on push to `main` (see `.github/workflows/main.yml`).
1 change: 1 addition & 0 deletions apps/alerts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/alpha-vantage": "workspace:*",
"@repo/logger": "workspace:*",
"@repo/telegram": "workspace:*",
"zod": "4.3.6"
Expand Down
3 changes: 2 additions & 1 deletion apps/alerts/src/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { healthcheck } from "./healthcheck";
import { sp500Close } from "./sp500-close";
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];
const alerts: Alert[] = [healthcheck, sp500Close];

export { alerts };
24 changes: 24 additions & 0 deletions apps/alerts/src/alerts/sp500-close.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AlphaVantageClient } from "@repo/alpha-vantage";

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

/**
* Reports the latest daily close for SPY (the S&P 500 ETF). Doubles as the
* reference implementation for wiring `@repo/alpha-vantage` into an alert.
*/
const sp500Close: Alert = {
name: "sp500-close",
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");
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)}`;
},
};

export { sp500Close };
41 changes: 35 additions & 6 deletions apps/alerts/src/scheduled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,31 @@ vi.mock("@repo/telegram", () => ({
},
}));

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,
},
],
});
},
}));

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

const ctx = {
Expand All @@ -30,14 +52,21 @@ describe("handleScheduled", () => {
sendMessage.mockResolvedValue({ ok: true });
});

it("sends the healthcheck message when its cron fires", async () => {
it("sends the alert messages when the daily cron fires", async () => {
await handleScheduled(makeEvent("0 8 * * *"), env, ctx);

expect(sendMessage).toHaveBeenCalledTimes(1);
const params = sendMessage.mock.calls[0][0];
expect(params.chat_id).toBe(12345);
expect(params.text).toContain("alerts worker healthy");
expect(params.parse_mode).toBe("HTML");
expect(sendMessage).toHaveBeenCalledTimes(2);
const texts = sendMessage.mock.calls.map(([params]) => params.text);
expect(texts).toEqual(
expect.arrayContaining([
expect.stringContaining("alerts worker healthy"),
expect.stringContaining("SPY close 2024-01-05"),
])
);
for (const [params] of sendMessage.mock.calls) {
expect(params.chat_id).toBe(12345);
expect(params.parse_mode).toBe("HTML");
}
});

it("does not send anything when no alert matches the fired cron", async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/alerts/src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
const envSchema = z.object({
TELEGRAM_BOT_TOKEN: z.string().min(1),
ALLOWED_CHAT_ID: z.string().min(1),
ALPHA_VANTAGE_API_KEY: z.string().min(1),
});

type Env = z.infer<typeof envSchema>;
Expand Down
2 changes: 1 addition & 1 deletion apps/operator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@repo/telegram": "workspace:*",
"@repo/url-validator": "workspace:*",
"drizzle-orm": "0.45.2",
"hono": "4.12.18",
"hono": "4.12.27",
"node-html-markdown": "2.0.0",
"openai": "6.34.0",
"zod": "4.3.6"
Expand Down
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@
"@ianvs/prettier-plugin-sort-imports": "4.7.1",
"@repo/prettier": "workspace:*",
"prettier": "3.8.2",
"prettier-plugin-tailwindcss": "0.7.2"
"prettier-plugin-tailwindcss": "0.7.2",
"vite": "8.0.16"
},
"pnpm": {
"overrides": {
"esbuild": "0.27.3",
"postcss": "8.5.10",
"brace-expansion": "5.0.6"
"esbuild": ">=0.28.1",
"undici": ">=7.28.0",
"ws": ">=8.21.0",
"js-yaml": ">=4.2.0",
"@babel/core": ">=7.29.6"
Comment on lines +30 to +34
}
}
}
5 changes: 5 additions & 0 deletions packages/alpha-vantage/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* eslint-disable import/no-default-export */
import { baseConfig } from "@repo/eslint/base";
import { defineConfig } from "eslint/config";

export default defineConfig(baseConfig);
31 changes: 31 additions & 0 deletions packages/alpha-vantage/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@repo/alpha-vantage",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/alpha-vantage.ts",
"./types": "./src/types.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"format": "prettier --check . --ignore-path ../../.gitignore"
},
"dependencies": {
"@repo/http-client": "workspace:*",
"@repo/logger": "workspace:*",
"zod": "4.3.6"
},
"devDependencies": {
"@repo/eslint": "workspace:*",
"@repo/prettier": "workspace:*",
"@repo/typescript": "workspace:*",
"eslint": "9.39.1",
"prettier": "3.8.2",
"typescript": "6.0.2",
"vitest": "4.1.4"
},
"prettier": "@repo/prettier"
}
118 changes: 118 additions & 0 deletions packages/alpha-vantage/src/alpha-vantage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Logger } from "@repo/logger";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { AlphaVantageClient, AlphaVantageError } from "./alpha-vantage";

const mockFetch = vi.fn();
globalThis.fetch = mockFetch;

const createJsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});

const createMockLogger = (): Logger =>
({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}) as unknown as Logger;

const dailySuccessBody = {
"Meta Data": {
"1. Information": "Daily Prices (open, high, low, close) and Volumes",
"2. Symbol": "SPY",
"3. Last Refreshed": "2024-01-05",
"4. Output Size": "Compact",
"5. Time Zone": "US/Eastern",
},
"Time Series (Daily)": {
"2024-01-04": {
"1. open": "467.9900",
"2. high": "470.7500",
"3. low": "466.4300",
"4. close": "467.9200",
"5. volume": "85337630",
},
"2024-01-05": {
"1. open": "468.3000",
"2. high": "469.1300",
"3. low": "464.4500",
"4. close": "467.2800",
"5. volume": "92955850",
},
},
};

describe("AlphaVantageClient", () => {
let client: AlphaVantageClient;

beforeEach(() => {
mockFetch.mockReset();
client = new AlphaVantageClient("test-key", createMockLogger());
});

describe("getDailyTimeSeries", () => {
it("normalizes a daily response with bars newest-first and numbers parsed", async () => {
mockFetch.mockResolvedValueOnce(createJsonResponse(dailySuccessBody));

const series = await client.getDailyTimeSeries("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).toEqual({
date: "2024-01-05",
open: 468.3,
high: 469.13,
low: 464.45,
close: 467.28,
volume: 92955850,
});
});

it("sends the expected query parameters", async () => {
mockFetch.mockResolvedValueOnce(createJsonResponse(dailySuccessBody));

await client.getDailyTimeSeries("QQQ", { outputSize: "full" });

const url = mockFetch.mock.calls[0]?.[0] as string;
expect(url).toContain("https://www.alphavantage.co/query?");
expect(url).toContain("function=TIME_SERIES_DAILY");
expect(url).toContain("symbol=QQQ");
expect(url).toContain("outputsize=full");
expect(url).toContain("apikey=test-key");
});

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.getDailyTimeSeries("SPY")).rejects.toThrow(
AlphaVantageError
);
});

it("throws AlphaVantageError on an invalid symbol", async () => {
mockFetch.mockResolvedValueOnce(
createJsonResponse({
"Error Message":
"Invalid API call. Please retry or visit the documentation.",
})
);

await expect(client.getDailyTimeSeries("NOPE")).rejects.toThrow(
AlphaVantageError
);
});
});
});
Loading