feat(market-index): store MOEX index closes with confirmed coverage (WealthLedger/main#54)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user