188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { isDeepStrictEqual } from 'node:util';
|
|
|
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
|
|
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
|
|
const FIRST_BATCH_OFFSET = 0;
|
|
|
|
export type ForecastSourceVersion = {
|
|
dataRevision: number;
|
|
fetchedAt: Date;
|
|
instrumentUid: string;
|
|
kind: string;
|
|
rangeKey: string;
|
|
source: string;
|
|
};
|
|
const forecastSourceReadBatchSize = 100;
|
|
|
|
@Injectable()
|
|
export class ForecastRepository {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
resolve(input: {
|
|
candidate?: object;
|
|
membershipRevision: number;
|
|
portfolioProjection: string;
|
|
portfolios: { accountId: string; dataRevision: number }[];
|
|
revision: string;
|
|
scopeKey: string;
|
|
sources: ForecastSourceVersion[];
|
|
timestamps: { at: string; resourceKey: string }[];
|
|
userId: string;
|
|
}) {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const owner = { userId: input.userId, revision: input.revision };
|
|
if (input.candidate) {
|
|
const current = await tx.tbankConnection.updateMany({
|
|
where: {
|
|
...owner,
|
|
membershipRevision: input.membershipRevision,
|
|
invalidToken: false,
|
|
},
|
|
data: { membershipRevision: { increment: 0 } },
|
|
});
|
|
if (!current.count) return null;
|
|
} else if (!(await tx.tbankConnection.findFirst({
|
|
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
|
|
}))) return null;
|
|
const key = { ...owner, scopeKey: input.scopeKey };
|
|
const previous = await tx.tbankForecast.findUnique({
|
|
where: { userId_revision_scopeKey: key },
|
|
});
|
|
const requestedWindow = eventsWindow(input.scopeKey);
|
|
const saved = previous ?? (requestedWindow
|
|
? (
|
|
await tx.tbankForecast.findMany({
|
|
where: {
|
|
...owner,
|
|
scopeKey: { startsWith: 'events/' },
|
|
membershipRevision: input.membershipRevision,
|
|
},
|
|
})
|
|
).find((forecast) => {
|
|
const window = eventsWindow(forecast.scopeKey);
|
|
return (
|
|
window &&
|
|
window.accountId === requestedWindow.accountId &&
|
|
window.from <= requestedWindow.to &&
|
|
requestedWindow.from <= window.to
|
|
);
|
|
})
|
|
: null);
|
|
const fallback =
|
|
saved?.membershipRevision === input.membershipRevision
|
|
? saved
|
|
: null;
|
|
if (!input.candidate) return fallback;
|
|
if (!previous && fallback) return fallback;
|
|
const portfolios = await tx.tbankPortfolio.findMany({
|
|
where: {
|
|
...owner,
|
|
accountId: { in: input.portfolios.map((row) => row.accountId) },
|
|
},
|
|
select: { accountId: true, dataRevision: true },
|
|
});
|
|
if (
|
|
input.portfolios.some(
|
|
(expected) =>
|
|
!portfolios.some(
|
|
(row) =>
|
|
row.accountId === expected.accountId &&
|
|
row.dataRevision === expected.dataRevision,
|
|
),
|
|
)
|
|
) {
|
|
return fallback;
|
|
}
|
|
// Four identity parameters per source: stay below SQLite's parameter limit.
|
|
const sources: ForecastSourceVersion[] = [];
|
|
for (
|
|
let offset = FIRST_BATCH_OFFSET;
|
|
offset < input.sources.length;
|
|
offset += forecastSourceReadBatchSize
|
|
) {
|
|
sources.push(
|
|
...(await tx.tbankInstrumentSource.findMany({
|
|
where: {
|
|
provider: 'tbank',
|
|
OR: input.sources
|
|
.slice(offset, offset + forecastSourceReadBatchSize)
|
|
.map(({ instrumentUid, source, kind, rangeKey }) => ({
|
|
instrumentUid,
|
|
source,
|
|
kind,
|
|
rangeKey,
|
|
})),
|
|
},
|
|
select: {
|
|
instrumentUid: true,
|
|
source: true,
|
|
kind: true,
|
|
rangeKey: true,
|
|
dataRevision: true,
|
|
fetchedAt: true,
|
|
},
|
|
})),
|
|
);
|
|
}
|
|
if (
|
|
input.sources.some(
|
|
(expected) =>
|
|
!sources.some(
|
|
(row) =>
|
|
row.instrumentUid === expected.instrumentUid &&
|
|
row.source === expected.source &&
|
|
row.kind === expected.kind &&
|
|
row.rangeKey === expected.rangeKey &&
|
|
row.dataRevision === expected.dataRevision &&
|
|
row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(),
|
|
),
|
|
)
|
|
) {
|
|
return fallback;
|
|
}
|
|
const revisions = JSON.parse(
|
|
JSON.stringify({
|
|
portfolioProjection: input.portfolioProjection,
|
|
portfolios: input.portfolios,
|
|
sources: input.sources,
|
|
}),
|
|
) as Prisma.InputJsonObject;
|
|
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
|
if (
|
|
fallback &&
|
|
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
|
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
|
) {
|
|
return fallback;
|
|
}
|
|
const data = {
|
|
membershipRevision: input.membershipRevision,
|
|
payload: JSON.parse(
|
|
JSON.stringify(input.candidate),
|
|
) as Prisma.InputJsonObject,
|
|
dependencyRevisions: revisions,
|
|
sourceTimestamps: timestamps,
|
|
calculatedAt: new Date(),
|
|
};
|
|
return tx.tbankForecast.upsert({
|
|
where: { userId_revision_scopeKey: key },
|
|
create: { ...key, ...data },
|
|
update: { ...data, dataRevision: { increment: 1 } },
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function eventsWindow(scopeKey: string) {
|
|
if (!scopeKey.startsWith('events/')) return null;
|
|
try {
|
|
const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
|
return { accountId, from, to };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|