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
19 changes: 18 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ env:
IMAGE_NAME: ${{ github.repository }}

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

security:
runs-on: ubuntu-latest
permissions:
Expand All @@ -32,7 +49,7 @@ jobs:
run: npm audit --audit-level=high

build:
needs: security
needs: [test, security]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
Expand Down
54 changes: 54 additions & 0 deletions lib/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
formatCurrency,
formatNumber,
formatPercent,
formatQuantity,
formatSignedCurrency,
} from "@/lib/format";

describe("formatCurrency", () => {
it("formats a USD amount", () => {
expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50");
});

it("falls back to USD for invalid currency codes", () => {
expect(formatCurrency(10, "EURO")).toBe("$10.00");
});
});

describe("formatNumber", () => {
it("trims trailing zeros", () => {
expect(formatNumber(12.5)).toBe("12.5");
});
});

describe("formatQuantity", () => {
it("uses more precision for small quantities", () => {
expect(formatQuantity(0.00012345)).toBe("0.00012345");
});

it("uses two decimals for large quantities", () => {
expect(formatQuantity(250.1234)).toBe("250.12");
});
});

describe("formatPercent", () => {
it("adds a plus sign for positive values by default", () => {
expect(formatPercent(1.5)).toBe("+1.50%");
});

it("keeps the minus sign for negative values", () => {
expect(formatPercent(-2.25)).toBe("-2.25%");
});
});

describe("formatSignedCurrency", () => {
it("prefixes positive amounts with +", () => {
expect(formatSignedCurrency(12, "USD")).toBe("+$12.00");
});

it("prefixes negative amounts with -", () => {
expect(formatSignedCurrency(-12, "USD")).toBe("-$12.00");
});
});
76 changes: 76 additions & 0 deletions lib/indicators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
ema,
highLow,
macd,
momentum,
rsi,
sma,
} from "@/lib/indicators";

describe("sma", () => {
it("returns null when there is not enough data", () => {
expect(sma([1, 2], 3)).toBeNull();
});

it("averages the last period values", () => {
expect(sma([1, 2, 3, 4, 5], 3)).toBe(4);
});
});

describe("ema", () => {
it("returns null when there is not enough data", () => {
expect(ema([1, 2, 3], 5)).toBeNull();
});

it("returns a finite latest value with enough data", () => {
const values = Array.from({ length: 20 }, (_, i) => 100 + i);
const value = ema(values, 10);
expect(value).not.toBeNull();
expect(value!).toBeGreaterThan(100);
});
});

describe("rsi", () => {
it("returns null when there is not enough data", () => {
expect(rsi([1, 2, 3], 14)).toBeNull();
});

it("returns 100 on a strictly rising series", () => {
const values = Array.from({ length: 20 }, (_, i) => i + 1);
expect(rsi(values, 14)).toBe(100);
});
});

describe("macd", () => {
it("returns nulls when there is not enough data", () => {
expect(macd([1, 2, 3])).toEqual({ macd: null, signal: null });
});

it("returns values with enough history", () => {
const values = Array.from({ length: 60 }, (_, i) => 100 + Math.sin(i / 3) * 5);
const result = macd(values);
expect(result.macd).not.toBeNull();
expect(result.signal).not.toBeNull();
});
});

describe("momentum", () => {
it("returns the percentage change over the period", () => {
expect(momentum([100, 110, 120], 2)).toBeCloseTo(20);
});

it("returns null when past close is zero", () => {
expect(momentum([0, 1, 2], 2)).toBeNull();
});
});

describe("highLow", () => {
it("returns null for an empty series", () => {
expect(highLow([])).toBeNull();
});

it("returns the highest and lowest closes", () => {
expect(highLow([3, 1, 4, 2])).toEqual({ high: 4, low: 1 });
});
});
51 changes: 51 additions & 0 deletions lib/labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
assetSubtitle,
assetTitle,
isIsinLike,
tidyAssetName,
} from "@/lib/labels";

describe("isIsinLike", () => {
it("accepts a valid ISIN shape", () => {
expect(isIsinLike("IE00B5BMR087")).toBe(true);
});

it("rejects tickers and short codes", () => {
expect(isIsinLike("AAPL")).toBe(false);
expect(isIsinLike("CW8")).toBe(false);
});
});

describe("tidyAssetName", () => {
it("applies known aliases", () => {
expect(tidyAssetName("Core S&P 500 USD (Acc)")).toBe("S&P 500 (Core)");
});

it("strips common issuer prefixes", () => {
expect(
tidyAssetName("Amundi Index Solutions - MSCI World UCITS ETF"),
).toBe("MSCI World UCITS ETF");
});
});

describe("assetTitle", () => {
it("prefers the human name over the ticker", () => {
expect(assetTitle("Core S&P 500 USD (Acc)", "CSPX")).toBe("S&P 500 (Core)");
});

it("falls back to the symbol when the name is an ISIN", () => {
expect(assetTitle("IE00B5BMR087", "CSPX")).toBe("CSPX");
});
});

describe("assetSubtitle", () => {
it("returns the ticker when it differs from the title", () => {
expect(assetSubtitle("Core S&P 500 USD (Acc)", "CSPX")).toBe("CSPX");
});

it("hides ISINs and identical titles", () => {
expect(assetSubtitle("Apple", "IE00B5BMR087")).toBeNull();
expect(assetSubtitle("AAPL", "AAPL")).toBeNull();
});
});
89 changes: 89 additions & 0 deletions lib/month.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
dateMonthKey,
daysIntoMonth,
missingCurrentMonthCsv,
monthKey,
monthStart,
monthToDateReturnPct,
} from "@/lib/month";
import type { Candle } from "@/lib/types";

describe("monthKey", () => {
it("formats YYYY-MM with a padded month", () => {
expect(monthKey(new Date(2026, 0, 15))).toBe("2026-01");
expect(monthKey(new Date(2026, 11, 1))).toBe("2026-12");
});
});

describe("monthStart", () => {
it("returns noon on the first day of the month", () => {
const start = monthStart(new Date(2026, 6, 27, 18, 30));
expect(start.getFullYear()).toBe(2026);
expect(start.getMonth()).toBe(6);
expect(start.getDate()).toBe(1);
expect(start.getHours()).toBe(12);
});
});

describe("dateMonthKey", () => {
it("extracts YYYY-MM from an ISO date", () => {
expect(dateMonthKey("2026-07-27")).toBe("2026-07");
});

it("returns null for short or empty values", () => {
expect(dateMonthKey("")).toBeNull();
expect(dateMonthKey("2026")).toBeNull();
});
});

describe("monthToDateReturnPct", () => {
it("returns null for an empty candle series", () => {
expect(monthToDateReturnPct([])).toBeNull();
});

it("computes the month-to-date return from the prior close", () => {
const now = new Date(2026, 6, 27);
const candles: Candle[] = [
{ t: new Date(2026, 5, 30, 12).getTime(), close: 100 },
{ t: new Date(2026, 6, 2, 12).getTime(), close: 105 },
{ t: new Date(2026, 6, 15, 12).getTime(), close: 110 },
];
// Prior close on/before month start (100) → latest close (110).
expect(monthToDateReturnPct(candles, now)).toBeCloseTo(10);
});
});

describe("missingCurrentMonthCsv", () => {
it("stays quiet early in the month", () => {
expect(
missingCurrentMonthCsv(
{ importedAt: "2026-07-01", csvFirstDate: "2026-01-01", csvLastDate: "2026-06-30" },
true,
new Date(2026, 6, 10),
),
).toBe(false);
});

it("flags a stale CSV late in the month", () => {
expect(
missingCurrentMonthCsv(
{ importedAt: "2026-07-01", csvFirstDate: "2026-01-01", csvLastDate: "2026-06-30" },
true,
new Date(2026, 6, 26),
),
).toBe(true);
});

it("ignores portfolios without Trade Republic imports", () => {
expect(
missingCurrentMonthCsv(null, false, new Date(2026, 6, 26)),
).toBe(false);
});
});

describe("daysIntoMonth", () => {
it("returns the calendar day", () => {
expect(daysIntoMonth(new Date(2026, 6, 27))).toBe(27);
});
});
Loading
Loading