fix(events): refresh saved payout calendars once per hourly pass (WealthLedger/backend#53) #157
@@ -110,6 +110,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
@@ -123,6 +124,7 @@ export class CatalogEnrichmentService {
|
|||||||
false,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
range,
|
range,
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const work = inputs.length
|
const work = inputs.length
|
||||||
@@ -175,6 +177,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
@@ -189,7 +192,9 @@ export class CatalogEnrichmentService {
|
|||||||
);
|
);
|
||||||
const policy = sourcePolicy(input.kind);
|
const policy = sourcePolicy(input.kind);
|
||||||
const needsRefresh = force || !row?.fetchedAt ||
|
const needsRefresh = force || !row?.fetchedAt ||
|
||||||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) ||
|
||||||
|
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
|
||||||
|
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
|
||||||
if (!needsRefresh) {
|
if (!needsRefresh) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -665,6 +670,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
) {
|
) {
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
}
|
||||||
|
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
|
||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
account.accountId,
|
account.accountId,
|
||||||
dispatch,
|
dispatch,
|
||||||
dispatchProblems,
|
dispatchProblems,
|
||||||
|
walkStartedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,9 +341,12 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
dispatch: DispatchStep,
|
dispatch: DispatchStep,
|
||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
|
walkStartedAt: Date,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ids = await this.withBound(this.events.refresh(userId, accountId));
|
const ids = await this.withBound(
|
||||||
|
this.events.refresh(userId, accountId, {}, walkStartedAt),
|
||||||
|
);
|
||||||
if (ids !== BOUND_EXPIRED) {
|
if (ids !== BOUND_EXPIRED) {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await dispatch({ accountId, id, section: 'events' });
|
await dispatch({ accountId, id, section: 'events' });
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export class UpcomingEventsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
query: UpcomingEventsQueryDto = {},
|
query: UpcomingEventsQueryDto = {},
|
||||||
|
walkStartedAt?: Date,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const window = dateWindow(query);
|
const window = dateWindow(query);
|
||||||
await this.access.local(userId, accountId);
|
await this.access.local(userId, accountId);
|
||||||
@@ -195,6 +196,7 @@ export class UpcomingEventsService {
|
|||||||
positions,
|
positions,
|
||||||
window,
|
window,
|
||||||
false,
|
false,
|
||||||
|
walkStartedAt,
|
||||||
);
|
);
|
||||||
return launched;
|
return launched;
|
||||||
}
|
}
|
||||||
@@ -204,6 +206,7 @@ export class UpcomingEventsService {
|
|||||||
positions: Position[],
|
positions: Position[],
|
||||||
window: { from: string; to: string },
|
window: { from: string; to: string },
|
||||||
observe: boolean,
|
observe: boolean,
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const metadata = await this.metadata.savedMany(positions);
|
const metadata = await this.metadata.savedMany(positions);
|
||||||
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||||
@@ -229,6 +232,7 @@ export class UpcomingEventsService {
|
|||||||
identities.map((entry) => [entry.uid, entry.ticker]),
|
identities.map((entry) => [entry.uid, entry.ticker]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
sources.push(...batch);
|
sources.push(...batch);
|
||||||
for (const source of batch) {
|
for (const source of batch) {
|
||||||
|
|||||||
@@ -775,6 +775,121 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hourly pass refreshes saved payout calendars', () => {
|
||||||
|
const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const dividend = (units: string, dividendType = 'Regular') => ({
|
||||||
|
recordDate: time(date),
|
||||||
|
dividendType,
|
||||||
|
dividendNet: { units, currency: 'usd' },
|
||||||
|
});
|
||||||
|
// One hourly walk: the scheduler stamps it once and passes it to every account.
|
||||||
|
const startWalk = async () => {
|
||||||
|
await delay(10);
|
||||||
|
return new Date();
|
||||||
|
};
|
||||||
|
const pass = async (walkStartedAt: Date, accountId = 'account') => {
|
||||||
|
await app
|
||||||
|
.get(UpcomingEventsService)
|
||||||
|
.refresh('owner', accountId, {}, walkStartedAt);
|
||||||
|
await settled();
|
||||||
|
};
|
||||||
|
const latest = async () =>
|
||||||
|
(await get('brokerage-accounts/account/events?sync=observe').expect(200))
|
||||||
|
.body as UpcomingEventsResourceDto;
|
||||||
|
const dividendRow = () =>
|
||||||
|
prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||||
|
where: { instrumentUid: shareUid, kind: 'dividends' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '20' },
|
||||||
|
]);
|
||||||
|
const first = await dividendRow();
|
||||||
|
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('30')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
||||||
|
{ currency: 'USD', value: '60' },
|
||||||
|
]);
|
||||||
|
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
|
||||||
|
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect((await latest()).data?.events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first complete calendar, time and forecast when the next refresh fails', async () => {
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
const before = await latest();
|
||||||
|
const row = await dividendRow();
|
||||||
|
|
||||||
|
upstream.getDividends.mockRejectedValue(new Error('outage'));
|
||||||
|
await pass(await startWalk());
|
||||||
|
const after = await latest();
|
||||||
|
|
||||||
|
expect(after.data).toEqual(before.data);
|
||||||
|
expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt);
|
||||||
|
expect(
|
||||||
|
after.meta.dependencies?.some(
|
||||||
|
(dependency) => dependency.lastAttempt.state === 'failed',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
const kept = await dividendRow();
|
||||||
|
expect(kept.dataRevision).toBe(row.dataRevision);
|
||||||
|
expect(kept.fetchedAt).toEqual(row.fetchedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads one shared calendar once per walk across accounts and leaves detail, GET and a plain refresh alone', async () => {
|
||||||
|
await prisma.tbankAccount.create({
|
||||||
|
data: {
|
||||||
|
userId: 'owner',
|
||||||
|
revision: 'owner-revision',
|
||||||
|
accountId: 'second',
|
||||||
|
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
name: 'Second',
|
||||||
|
upstreamOrder: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await portfolio([position()], 'owner', 'second');
|
||||||
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
const detailCalls = () =>
|
||||||
|
upstream.getShare.mock.calls.length +
|
||||||
|
upstream.getInstrumentByUid.mock.calls.length;
|
||||||
|
const detailBefore = detailCalls();
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const walk = await startWalk();
|
||||||
|
await pass(walk, 'account');
|
||||||
|
await pass(walk, 'second');
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
|
||||||
|
await settled();
|
||||||
|
await latest();
|
||||||
|
await get('brokerage-accounts/all/events').expect(200);
|
||||||
|
expect(upstream.getDividends).toHaveBeenCalledTimes(2);
|
||||||
|
expect(detailCalls()).toBe(detailBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-reads the bond calendar whose key never changes, only on a walk pass', async () => {
|
||||||
|
await portfolio([position(bondUid, '3', 'BOND')]);
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await app.get(UpcomingEventsService).refresh('owner', 'account', {});
|
||||||
|
await settled();
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await pass(await startWalk());
|
||||||
|
expect(moex.bondEvents).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => {
|
||||||
let release: (value: unknown[]) => void = () => {};
|
let release: (value: unknown[]) => void = () => {};
|
||||||
upstream.getDividends.mockReturnValueOnce(
|
upstream.getDividends.mockReturnValueOnce(
|
||||||
|
|||||||
Reference in New Issue
Block a user