feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped

This commit is contained in:
2026-10-09 09:04:41 +03:00
parent 03e75f8d2b
commit 0057925888
49 changed files with 1674 additions and 1870 deletions
@@ -135,50 +135,14 @@ export class BrokerageOperationsService {
? [{ accountId: source.accountId, from: source.from, to: source.to }]
: [],
);
if (query.sync !== 'observe') {
const discovery = await this.membership.request(
userId,
connection.revision,
);
if (discovery) {
void discovery.done
.then(async () => {
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
const currentRange = ledgerRange(
query.from,
query.to,
query.instrumentUid,
current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
),
now,
);
const targets = current.accounts
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap((account) =>
currentRange.from < currentRange.to
? [{ accountId: account.accountId, ...currentRange }]
: [],
);
await this.sync.readPlan(userId, targets, {
movingEnd,
history: true,
revision: connection.revision,
});
})
.catch(() => undefined);
} else if (intervals.length) {
await this.sync.readPlan(userId, intervals, {
movingEnd,
history: true,
revision: connection.revision,
});
}
// The ledger only starts the first-time load of missing intervals; the
// mutable tail belongs to the hourly walk and membership is not rechecked.
if (intervals.length) {
await this.sync.readPlan(userId, intervals, {
movingEnd,
history: true,
revision: connection.revision,
});
}
const saved = await this.repository.readPage(readInput);
const accounts = saved?.connection.accounts ?? [];
@@ -252,26 +216,10 @@ export class BrokerageOperationsService {
dataRevision: value.dataRevision,
availability: 'complete',
fetchedAt: value.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
);
if (query.sync !== 'observe') {
const identities = new Map(
rows.map((row) => {
const identity = row.payload as TbankOperationItem;
return [
metadataKey(identity),
{ instrumentUid: identity.instrumentUid, figi: identity.figi },
] as const;
}),
);
for (const identity of identities.values()) {
if (!instruments.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -71,7 +71,7 @@ describe('operation completeness and freshness', () => {
const asOf = new Date('2026-09-25T12:00:00Z');
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
it.each([1, 299, 300])(
it.each([1, 3599, 3600])(
'preserves the observed complete end after %s seconds, separately from TTL',
(seconds) => {
const now = new Date(asOf.valueOf() + seconds * 1000);
@@ -85,10 +85,10 @@ describe('operation completeness and freshness', () => {
availability: 'complete',
asOf,
to: asOf,
stale: seconds >= 300,
stale: seconds >= 3600,
});
expect(plan.load).toEqual(
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [],
[],
);
},
);
@@ -128,7 +128,7 @@ export type OperationCoverage = OperationInterval & {
asOf: Date;
fetchedAt: Date;
};
const operationsFreshForMs = 300_000;
const operationsFreshForMs = 3_600_000;
export function planOperationRead(
requested: OperationInterval[],
@@ -188,8 +188,9 @@ export function planOperationRead(
.filter((row) => row.from < row.to),
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
).length > EMPTY_INTERVAL_COUNT;
// The mutable tail is owned by the hourly walk; reads only load what is
// missing, never re-verify complete or fresh intervals.
if (options.force) load.push(target);
else if (stale) load.push({ ...target, from: monthStart, to: now });
else load.push(...missing);
// Completeness is bounded by the observed end, never by the new wall clock.
return {
@@ -15,10 +15,7 @@ import {
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../investment-resource.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
const MIN_OPERATIONS_PAGE_SIZE = 1;
@@ -36,7 +33,7 @@ export const operationCategories = [
] as const;
export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({
format: 'uuid',
description: