Files
backend/src/modules/investments/instruments/domain/candle-coverage.ts
T

97 lines
2.8 KiB
TypeScript

import { MILLISECONDS_PER_DAY } from '../../../../common/time';
const FIRST_RANGE_END_INDEX = 1;
export type CandleRange = { from: Date; to: Date };
export type CandleCoverage = CandleRange & {
fetchedAt: Date;
isComplete: boolean;
};
export type SavedCandle = {
close: string;
isComplete: boolean;
time: string;
};
const windowDays = 365;
const tailFreshForMs = 60_000;
export function utcDay(value: Date): Date {
return new Date(
Math.floor(value.valueOf() / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY,
);
}
// A clock transition never turns an unconfirmed range into closed coverage.
export function candleGaps(
range: CandleRange,
coverage: CandleCoverage[],
now: Date,
): CandleRange[] {
let gaps = [range];
for (const covered of coverage) {
if (
!covered.isComplete &&
(now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs ||
utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf())
) {
continue;
}
gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [
...(covered.from > gap.from
? [{ from: gap.from, to: covered.from }]
: []),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
];
});
}
return gaps.filter((gap) => gap.from < gap.to);
}
export function candleWindows(range: CandleRange): CandleRange[] {
const windows: CandleRange[] = [];
for (let start = range.from.valueOf(); start < range.to.valueOf();) {
const end = Math.min(
start + windowDays * MILLISECONDS_PER_DAY,
range.to.valueOf(),
);
windows.push({ from: new Date(start), to: new Date(end) });
start = end;
}
return windows;
}
export function confirmedCandleCoverage(
range: CandleRange,
candles: SavedCandle[],
fetchedAt: Date,
): CandleCoverage[] {
const boundaries = new Set([range.from.valueOf(), range.to.valueOf()]);
const midnight = utcDay(fetchedAt).valueOf();
const incompleteDays = new Set(
candles
.filter((candle) => !candle.isComplete)
.map((candle) => utcDay(new Date(candle.time)).valueOf()),
);
for (const boundary of [
midnight,
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
]) {
if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) {
boundaries.add(boundary);
}
}
const sorted = [...boundaries].sort((a, b) => a - b);
return sorted.slice(FIRST_RANGE_END_INDEX).map((to, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array.
const from = sorted[index];
return {
from: new Date(from),
to: new Date(to),
fetchedAt,
isComplete:
to <= midnight && !incompleteDays.has(utcDay(new Date(from)).valueOf()),
};
});
}