feat(performance): investment performance vs Moscow Exchange index (WealthLedger/main#54) (#200)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m19s

This commit was merged in pull request #200.
This commit is contained in:
2026-11-08 16:45:29 +03:00
parent 84433ee207
commit fee61537b6
28 changed files with 1507 additions and 4 deletions
@@ -0,0 +1,121 @@
import { MarketIndexHistoryService } from './market-index.service';
jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} }));
type State = {
coveredFrom: null | string;
coveredTo: null | string;
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
};
type Close = { close: string; tradeDate: string };
const HOUR_MS = 3_600_000;
// In-memory stand-in mirroring the repository's merge rules.
function setup(history: Close[]) {
let state: null | State = null;
const stored = new Map<string, string>();
const repository = {
read: jest.fn((_: string, from: string, to: string) => Promise.resolve({
closes: [...stored.entries()]
.filter(([date]) => date >= from && date <= to)
.sort(([a], [b]) => a.localeCompare(b))
.map(([tradeDate, close]) => ({ tradeDate, close })),
state: state && { ...state },
})),
attempt: jest.fn((_: string, at: Date) => {
state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at };
return Promise.resolve();
}),
save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => {
for (const close of closes) stored.set(close.tradeDate, close.close);
const current = state as State;
const joined = Boolean(current.coveredFrom && current.coveredTo &&
from <= current.coveredTo && to >= current.coveredFrom);
state = {
...current,
lastSuccessAt: at,
coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from,
coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to,
};
return Promise.resolve();
}),
};
const moex = {
indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve(
history.filter((row) => row.tradeDate >= from && row.tradeDate <= till),
)),
};
const service = new MarketIndexHistoryService(
repository as never, moex as never, { event: jest.fn() } as never,
);
return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) };
}
const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' }));
describe('MarketIndexHistoryService', () => {
afterEach(() => jest.useRealTimers());
it('loads a ten-day lookback once and reuses the confirmed range', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05');
});
it('extends history backward at once, even right after a successful load', async () => {
const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05'));
await service.read('MCFTRR', '2026-10-01', '2026-10-05');
expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3);
expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05');
});
it('re-checks a missing tail close after an hour, but not sooner', async () => {
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05'));
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
jest.setSystemTime(Date.now() + HOUR_MS + 1);
const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05');
expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05');
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
});
it('keeps saved history usable when a later refresh fails and throttles retries', async () => {
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
jest.setSystemTime(Date.now() + 1000);
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
});
it('returns no history when nothing was ever loaded and MOEX fails', async () => {
const { service, moex } = setup([]);
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull();
});
it('refuses a range that does not reach the last cash flow', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull();
});
it('shares one request for parallel reads', async () => {
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
await Promise.all([
service.read('IMOEX', '2026-10-02', '2026-10-05'),
service.read('IMOEX', '2026-10-02', '2026-10-05'),
]);
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
});
});