refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
@@ -1 +0,0 @@
|
||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import {
|
||||
candleGaps,
|
||||
type CandleRange,
|
||||
@@ -28,8 +27,6 @@ import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
||||
const REPLACED_WINDOW_COUNT = 1;
|
||||
const NEXT_WINDOW_OFFSET = 1;
|
||||
|
||||
@@ -55,7 +52,6 @@ type Producer = {
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
const prefetchReadBatchSize = 100;
|
||||
function scope(input: CandleInput) {
|
||||
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||
}
|
||||
@@ -93,7 +89,6 @@ export class CandleService {
|
||||
private readonly queue: Producer[] = [];
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
private running = false;
|
||||
private readonly prefetches = new Map<string, Promise<void>>();
|
||||
constructor(
|
||||
private readonly repository: CandleRepository,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
@@ -205,110 +200,6 @@ export class CandleService {
|
||||
};
|
||||
}
|
||||
|
||||
prefetch(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const key = JSON.stringify([
|
||||
userId,
|
||||
revision,
|
||||
positions
|
||||
.map((position) => [position.instrumentUid, position.type])
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
.sort(),
|
||||
]);
|
||||
const existing = this.prefetches.get(key);
|
||||
if (existing) return existing;
|
||||
const work = this.prefetchPositions(userId, revision, positions).finally(
|
||||
() => this.prefetches.delete(key),
|
||||
);
|
||||
this.prefetches.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
private async prefetchPositions(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const now = new Date();
|
||||
const unique = new Map(
|
||||
positions
|
||||
.filter(
|
||||
(position) =>
|
||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
||||
)
|
||||
.map((position) => [position.instrumentUid ?? '', position]),
|
||||
);
|
||||
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||
[];
|
||||
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
||||
for (const instrumentType of ['share', 'bond'] as const) {
|
||||
const uids = [...unique]
|
||||
.filter(
|
||||
([, position]) =>
|
||||
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
||||
)
|
||||
.map(([uid]) => uid);
|
||||
for (
|
||||
let offset = FIRST_BATCH_OFFSET;
|
||||
offset < uids.length;
|
||||
offset += prefetchReadBatchSize
|
||||
) {
|
||||
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
||||
details.push(
|
||||
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
||||
'detail',
|
||||
])),
|
||||
);
|
||||
coverage.push(...(await this.repository.coverage(batch)));
|
||||
}
|
||||
}
|
||||
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
||||
for (const [uid, position] of unique) {
|
||||
const instrumentType: CandleInput['instrumentType'] =
|
||||
position.type === 'STOCK' ? 'share' : 'bond';
|
||||
const detail = byUid.get(uid);
|
||||
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
||||
const plan = planInvestmentScreen(
|
||||
{ screen: 'account' },
|
||||
{
|
||||
membershipKnown: true,
|
||||
accounts: [],
|
||||
instruments: [
|
||||
{
|
||||
uid,
|
||||
type: position.type as 'BOND' | 'STOCK',
|
||||
historyFrom: history ? new Date(history) : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (
|
||||
!detail?.instrument ||
|
||||
plan.sources.some((source) => source.kind === 'candles')
|
||||
) {
|
||||
const input = { uid, instrumentType, period: 'all' as const };
|
||||
const range =
|
||||
detail?.instrument && history
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
if (
|
||||
!range ||
|
||||
candleGaps(
|
||||
range,
|
||||
coverage.filter((row) => row.instrumentUid === uid),
|
||||
now,
|
||||
).length
|
||||
) {
|
||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
@@ -499,17 +390,6 @@ export class CandleService {
|
||||
this.reservations = finished.catch(() => undefined);
|
||||
if (!(await finished)) {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
if (success && !producer.force && producer.input.period !== 'all') {
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
producer.attempt.revision,
|
||||
{ ...producer.input, period: 'all' },
|
||||
producer.now,
|
||||
false,
|
||||
BACKGROUND_SYNC_PRIORITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
@@ -122,6 +123,10 @@ export class BondIncomeService {
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
if (result.data?.positionAt) {
|
||||
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||
}
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
|
||||
@@ -53,12 +53,6 @@ export class CandleRepository {
|
||||
});
|
||||
}
|
||||
|
||||
coverage(uids: string[]) {
|
||||
return this.prisma.tbankCandleCoverage.findMany({
|
||||
where: { provider, instrumentUid: { in: uids }, interval },
|
||||
});
|
||||
}
|
||||
|
||||
reserve(
|
||||
owner: { revision: string; userId: string },
|
||||
scopeKey: string,
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BondIncomeService } from './bonds/application/bond-income.service';
|
||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankDividend,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
@@ -91,6 +92,10 @@ export class StockIncomeService {
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
if (result.data?.positionAt) {
|
||||
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||
}
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
||||
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
|
||||
export const syncStateValues = ['idle', 'running', 'failed'] as const;
|
||||
|
||||
export class InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['observe'] })
|
||||
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
|
||||
@ApiProperty()
|
||||
declare dataRevision: string;
|
||||
|
||||
@ApiProperty()
|
||||
declare stateVersion: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String })
|
||||
unavailableReason?: string;
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
|
||||
const stale = investmentResourceMeta(input);
|
||||
expect(stale.freshness).toBe('stale');
|
||||
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
||||
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
|
||||
expect(
|
||||
investmentResourceMeta({
|
||||
...input,
|
||||
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
|
||||
});
|
||||
expect(terminal.sync).toEqual(failed.sync);
|
||||
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
||||
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
|
||||
expect(
|
||||
investmentResourceMeta({
|
||||
...input,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||
|
||||
export function investmentResourceMeta(input: {
|
||||
@@ -31,7 +29,6 @@ export function investmentResourceMeta(input: {
|
||||
];
|
||||
const sync =
|
||||
states.find((state) => state.state === 'running') ??
|
||||
states.find((state) => state.state === 'queued') ??
|
||||
states.find((state) => state.state === 'failed') ??
|
||||
ownSync;
|
||||
const sourceFreshness = input.fetchedAt
|
||||
@@ -71,19 +68,5 @@ export function investmentResourceMeta(input: {
|
||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||
};
|
||||
return {
|
||||
...meta,
|
||||
stateVersion: createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
...meta,
|
||||
// The requested window is identified by resourceKey. Its moving "now"
|
||||
// boundary is not a change to saved coverage between status and GET.
|
||||
coverage: meta.coverage
|
||||
? { ...meta.coverage, requested: undefined }
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
.digest('base64url'),
|
||||
};
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
|
||||
priority: 'screen',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!force &&
|
||||
['account', 'events', 'income', 'operations'].includes(screen.screen)
|
||||
) {
|
||||
if (instrument.historyFrom) {
|
||||
sources.push({
|
||||
...identity,
|
||||
kind: 'candles',
|
||||
from: instrument.historyFrom,
|
||||
to: now,
|
||||
priority: 'history',
|
||||
});
|
||||
} else {
|
||||
unavailable.push({
|
||||
resourceKey: `candles/${instrument.uid}/all`,
|
||||
reason: 'history_start_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
||||
sources.push(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
ForecastRepository,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
} from '../infrastructure/forecast.repository';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
const STALE_FRESHNESS_MS = 0;
|
||||
|
||||
@Injectable()
|
||||
export class ForecastService {
|
||||
@@ -73,23 +71,8 @@ export class ForecastService {
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'failed');
|
||||
const payload = stored ? (stored.payload as T) : null;
|
||||
const data =
|
||||
payload &&
|
||||
'positionIsStale' in payload &&
|
||||
'positionAt' in payload &&
|
||||
typeof payload.positionAt === 'string'
|
||||
? {
|
||||
...payload,
|
||||
positionIsStale: tbankSourceNeedsRefresh(
|
||||
'portfolio',
|
||||
new Date(payload.positionAt),
|
||||
new Date(Date.now()),
|
||||
),
|
||||
}
|
||||
: payload;
|
||||
return {
|
||||
data,
|
||||
data: stored ? (stored.payload as T) : null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
@@ -101,10 +84,7 @@ export class ForecastService {
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs:
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
||||
? STALE_FRESHNESS_MS
|
||||
: Number.POSITIVE_INFINITY,
|
||||
freshForMs: Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
dependencies,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
|
||||
service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
{ set: jest.fn() } as never,
|
||||
metadata as never,
|
||||
repository as never,
|
||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ event: jest.fn() } as never,
|
||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
);
|
||||
access.local.mockResolvedValue({
|
||||
revision: 'private',
|
||||
@@ -221,15 +218,4 @@ describe('persistent portfolio projection', () => {
|
||||
expect(complete.data?.isStale).toBe(true);
|
||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt: null,
|
||||
membershipRevision: 0,
|
||||
accounts: [],
|
||||
work: [],
|
||||
});
|
||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
type TbankPortfolio,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||
@@ -38,23 +35,19 @@ const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private readonly processRunId = randomUUID();
|
||||
private readonly active = new Set<string>();
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
private readonly repository: PortfolioRepository,
|
||||
private readonly membership: TbankMembershipService,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly candles: CandleService,
|
||||
) {}
|
||||
|
||||
async saved(userId: string, accountId: string, observe = false) {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
if (!observe) {
|
||||
const discovery = await this.membership.request(
|
||||
@@ -123,28 +116,6 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!observe) {
|
||||
void this.candles
|
||||
.prefetch(
|
||||
userId,
|
||||
connection.revision,
|
||||
(saved?.accounts ?? [])
|
||||
.filter(
|
||||
(account) =>
|
||||
accountId === 'all' || account.accountId === accountId,
|
||||
)
|
||||
.flatMap(
|
||||
(account) =>
|
||||
(
|
||||
account.portfolio?.payload as unknown as
|
||||
PortfolioDto | undefined
|
||||
)?.positions ?? [],
|
||||
),
|
||||
)
|
||||
.catch(() =>
|
||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
||||
);
|
||||
}
|
||||
const membershipMeta = investmentResourceMeta({
|
||||
resourceKey: 'accounts/membership',
|
||||
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
||||
@@ -319,7 +290,7 @@ export class PortfolioService {
|
||||
revision,
|
||||
accountId,
|
||||
this.processRunId,
|
||||
this.active,
|
||||
this.pending,
|
||||
force,
|
||||
);
|
||||
if (!requested) return null;
|
||||
@@ -330,7 +301,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
const { id, attemptId } = requested.work;
|
||||
this.active.add(attemptId);
|
||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||
.then(async () => {
|
||||
const attempt = { id, attemptId, userId, revision };
|
||||
@@ -354,13 +324,6 @@ export class PortfolioService {
|
||||
for (const position of snapshot.positions) {
|
||||
await this.metadata.request(userId, revision, position);
|
||||
}
|
||||
if (!force) {
|
||||
await this.candles.prefetch(
|
||||
userId,
|
||||
revision,
|
||||
snapshot.positions,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -377,7 +340,6 @@ export class PortfolioService {
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
this.active.delete(attemptId);
|
||||
this.pending.delete(attemptId);
|
||||
});
|
||||
this.pending.set(attemptId, done);
|
||||
@@ -387,18 +349,6 @@ export class PortfolioService {
|
||||
return reserved;
|
||||
}
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
const result = await this.read(userId, accountId);
|
||||
if (!result.data) {
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<PortfolioDto> {
|
||||
return this.get(userId, 'all');
|
||||
}
|
||||
|
||||
private async normalize(
|
||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||
money: TbankMoney[],
|
||||
|
||||
@@ -44,15 +44,9 @@ export class ForecastRepository {
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
}
|
||||
const connection = await tx.tbankConnection.findFirst({
|
||||
where: {
|
||||
...owner,
|
||||
membershipRevision: input.membershipRevision,
|
||||
invalidToken: false,
|
||||
},
|
||||
});
|
||||
if (!connection) 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 },
|
||||
|
||||
@@ -52,7 +52,7 @@ export class PortfolioRepository {
|
||||
revision: string,
|
||||
accountId: string,
|
||||
processRunId: string,
|
||||
active: ReadonlySet<string>,
|
||||
pending: ReadonlyMap<string, Promise<void>>,
|
||||
force: boolean,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
@@ -83,7 +83,7 @@ export class PortfolioRepository {
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
active.has(previous.attemptId)
|
||||
pending.has(previous.attemptId)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { ForecastService } from './application/forecast.service';
|
||||
import { PortfolioService } from './application/portfolio.service';
|
||||
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
||||
import { PortfolioController } from './presentation/portfolio.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
|
||||
imports: [AuthModule, TbankModule],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
PortfolioService,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { operationCategories } from '../operations/operations.api';
|
||||
|
||||
const MIN_ACCOUNT_ID_LENGTH = 1;
|
||||
@@ -216,11 +215,6 @@ export class TbankSyncAcceptedDto {
|
||||
|
||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||
}
|
||||
export class TbankSyncProgressDto {
|
||||
@ApiProperty() declare completed: number;
|
||||
@ApiProperty() declare total: number;
|
||||
@ApiProperty() declare discovering: boolean;
|
||||
}
|
||||
export class TbankSyncStatusDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
||||
@@ -232,10 +226,4 @@ export class TbankSyncStatusDto {
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: TbankSyncProgressDto })
|
||||
declare progress: TbankSyncProgressDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
|
||||
declare resources: InvestmentResourceMetaDto[];
|
||||
}
|
||||
|
||||
@@ -2,13 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
|
||||
import { IncomeModule } from '../income/income.module';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { InstrumentsModule } from '../instruments/instruments.module';
|
||||
import { OperationsModule } from '../operations/operations.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
||||
import { TbankSyncController } from './tbank-sync.controller';
|
||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
@@ -19,10 +15,6 @@ import { TbankSyncService } from './tbank-sync.service';
|
||||
PortfolioModule,
|
||||
OperationsModule,
|
||||
InstrumentSourcesModule,
|
||||
InstrumentsModule,
|
||||
BrokerageAccountsModule,
|
||||
IncomeModule,
|
||||
UpcomingEventsModule,
|
||||
],
|
||||
controllers: [TbankSyncController],
|
||||
providers: [TbankSyncService, TbankSyncRepository],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../generated/prisma/client';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
||||
@@ -27,7 +27,7 @@ export class TbankSyncRepository {
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!valid.count) return null;
|
||||
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`;
|
||||
const scopeKey = `screen:${JSON.stringify(scope)}`;
|
||||
const key = { userId, revision, scopeKey };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
||||
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
@@ -16,26 +14,17 @@ import {
|
||||
TbankMembershipService,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
|
||||
import { IncomeAnalyticsService } from '../income/income-read.api';
|
||||
import {
|
||||
BondIncomeService,
|
||||
StockIncomeService,
|
||||
} from '../instruments/instrument-income.api';
|
||||
import {
|
||||
CandleService,
|
||||
CatalogDiscoveryService,
|
||||
CatalogEnrichmentService,
|
||||
} from '../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||
import {
|
||||
BrokerageOperationsService,
|
||||
BrokerageOperationsSyncService,
|
||||
type OperationInterval,
|
||||
} from '../operations/operations.api';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
||||
import { normalizeScreen } from './tbank-screen';
|
||||
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
|
||||
import {
|
||||
@@ -44,9 +33,6 @@ import {
|
||||
} from './tbank-sync.repository';
|
||||
|
||||
const FOREGROUND_CANDLE_PRIORITY = 0;
|
||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
||||
const PENDING_CHILD_COUNT = 0;
|
||||
const COMPLETED_CHILD_COUNT = 1;
|
||||
|
||||
@Injectable()
|
||||
export class TbankSyncService {
|
||||
@@ -59,16 +45,10 @@ export class TbankSyncService {
|
||||
private readonly membership: TbankMembershipService,
|
||||
private readonly portfolio: PortfolioService,
|
||||
private readonly operations: BrokerageOperationsSyncService,
|
||||
private readonly ledger: BrokerageOperationsService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
private readonly discovery: CatalogDiscoveryService,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
private readonly candles: CandleService,
|
||||
private readonly accounts: BrokerageAccountsService,
|
||||
private readonly income: IncomeAnalyticsService,
|
||||
private readonly events: UpcomingEventsService,
|
||||
private readonly stockIncome: StockIncomeService,
|
||||
private readonly bondIncome: BondIncomeService,
|
||||
) {}
|
||||
|
||||
async request(userId: string, input: InvestmentScreenDto) {
|
||||
@@ -368,7 +348,6 @@ export class TbankSyncService {
|
||||
: children.some((child) => child.state === 'cancelled')
|
||||
? 'cancelled'
|
||||
: 'succeeded';
|
||||
const resources = await this.observe(userId, intent.scope);
|
||||
if (
|
||||
(await this.access.local(userId, intent.scope.accountId)).revision !==
|
||||
connection.revision
|
||||
@@ -380,12 +359,6 @@ export class TbankSyncService {
|
||||
...intent,
|
||||
state,
|
||||
errorCode: failed,
|
||||
progress: {
|
||||
completed: children.filter((child) => child.state !== 'running').length,
|
||||
total: children.length,
|
||||
discovering: !saved.progress.dispatched,
|
||||
},
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,106 +366,6 @@ export class TbankSyncService {
|
||||
userId: string,
|
||||
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
||||
): Promise<TbankSyncStatusDto> {
|
||||
const resources: InvestmentResourceMetaDto[] = [];
|
||||
const input = work.intent as Record<string, unknown>;
|
||||
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
||||
resources.push((await read).meta);
|
||||
};
|
||||
if (work.scopeKey === 'membership') {
|
||||
await add(this.accounts.list(userId, true));
|
||||
} else if (
|
||||
input.source === 'portfolio' &&
|
||||
typeof input.accountId === 'string'
|
||||
) {
|
||||
await add(this.portfolio.read(userId, input.accountId, true));
|
||||
} else if (input.source === 'catalog') {
|
||||
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
|
||||
if (typeof input.query === 'string') {
|
||||
await add(
|
||||
this.discovery.search(
|
||||
userId,
|
||||
{
|
||||
instrumentType: type,
|
||||
query: input.query,
|
||||
exactTicker: input.exactTicker === true,
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
typeof input.uid === 'string' &&
|
||||
typeof input.kind === 'string'
|
||||
) {
|
||||
const range =
|
||||
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
|
||||
? (JSON.parse(input.rangeKey) as string[])
|
||||
: null;
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
type,
|
||||
[input.uid],
|
||||
true,
|
||||
[
|
||||
input.kind as Parameters<
|
||||
CatalogEnrichmentService['readMany']
|
||||
>[4] extends (infer K)[] | undefined
|
||||
? K
|
||||
: never,
|
||||
],
|
||||
range ? { from: range[0], to: range[1] } : undefined,
|
||||
);
|
||||
resources.push(...sources.map((source) => source.meta));
|
||||
}
|
||||
} else if (input.source === 'metadata') {
|
||||
const identity = {
|
||||
instrumentUid:
|
||||
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
|
||||
figi: typeof input.figi === 'string' ? input.figi : null,
|
||||
};
|
||||
const source = await this.metadata.read(userId, identity, true);
|
||||
resources.push(
|
||||
investmentResourceMeta({
|
||||
resourceKey: work.scopeKey,
|
||||
dataRevision: source?.dataRevision ?? '0',
|
||||
availability: source?.complete ? 'complete' : 'missing',
|
||||
fetchedAt: source?.fetchedAt ?? null,
|
||||
freshForMs: Number.POSITIVE_INFINITY,
|
||||
work,
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
work.scopeKey.startsWith('candles:') &&
|
||||
typeof input.uid === 'string'
|
||||
) {
|
||||
await add(
|
||||
this.candles.read(
|
||||
userId,
|
||||
{
|
||||
uid: input.uid,
|
||||
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
|
||||
period: input.period as 'all' | 'halfYear' | 'month' | 'year',
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
work.scopeKey.startsWith('operations:') &&
|
||||
Array.isArray(input.intervals)
|
||||
) {
|
||||
for (const key of input.intervals as string[]) {
|
||||
const [accountId, from, end, instrumentUid] = JSON.parse(
|
||||
key,
|
||||
) as string[];
|
||||
await add(
|
||||
this.ledger.read(userId, accountId, {
|
||||
from,
|
||||
to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(),
|
||||
...(instrumentUid ? { instrumentUid } : {}),
|
||||
sync: 'observe',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
const observed = work.scopeKey.startsWith('operations:')
|
||||
? await this.operations.observe(userId, work.id)
|
||||
: work;
|
||||
@@ -515,112 +388,6 @@ export class TbankSyncService {
|
||||
now: work.createdAt.toISOString(),
|
||||
state,
|
||||
errorCode: observed.errorCode,
|
||||
progress: {
|
||||
completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT,
|
||||
total: 1,
|
||||
discovering: false,
|
||||
},
|
||||
resources,
|
||||
};
|
||||
}
|
||||
|
||||
private async observe(userId: string, scope: InvestmentScreenDto) {
|
||||
const resources: InvestmentResourceMetaDto[] = [];
|
||||
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
||||
resources.push((await read).meta);
|
||||
};
|
||||
const account = scope.accountId ?? 'all';
|
||||
if (scope.screen === 'accounts') {
|
||||
await add(this.accounts.list(userId, true));
|
||||
}
|
||||
if (scope.screen === 'account') {
|
||||
await add(this.portfolio.read(userId, account, true));
|
||||
await add(this.income.summary(userId, account, true));
|
||||
await add(this.events.get(userId, account, { sync: 'observe' }));
|
||||
}
|
||||
if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) {
|
||||
await add(
|
||||
this.ledger.read(userId, account, {
|
||||
from: scope.from,
|
||||
to: scope.to,
|
||||
instrumentUid: scope.instrumentUid,
|
||||
categories: scope.categories,
|
||||
limit: scope.limit,
|
||||
sync: 'observe',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (scope.screen === 'income') {
|
||||
await add(
|
||||
this.income.analytics(userId, account, scope.from, scope.to, true),
|
||||
);
|
||||
}
|
||||
if (scope.screen === 'events') {
|
||||
await add(
|
||||
this.events.get(userId, account, {
|
||||
from: scope.from,
|
||||
to: scope.to,
|
||||
sync: 'observe',
|
||||
}),
|
||||
);
|
||||
}
|
||||
const type =
|
||||
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
|
||||
if (scope.screen === 'stocks' || scope.screen === 'bonds') {
|
||||
await add(
|
||||
this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: type, query: scope.query ?? '' },
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
||||
scope.instrumentUid
|
||||
) {
|
||||
const uid = scope.instrumentUid;
|
||||
await add(this.discovery.detail(userId, type, uid, true));
|
||||
await add(this.sources.section(userId, type, uid, 'quote', true));
|
||||
await add(
|
||||
this.sources.section(
|
||||
userId,
|
||||
type,
|
||||
uid,
|
||||
type === 'share' ? 'company' : 'metrics',
|
||||
true,
|
||||
),
|
||||
);
|
||||
await add(
|
||||
this.candles.read(
|
||||
userId,
|
||||
{
|
||||
uid,
|
||||
instrumentType: type,
|
||||
period: scope.candlePeriod ?? 'month',
|
||||
...(scope.candleRange
|
||||
? {
|
||||
range: {
|
||||
from: new Date(scope.candleRange.from),
|
||||
to: new Date(scope.candleRange.to),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
await add(
|
||||
type === 'share'
|
||||
? this.stockIncome.readPosition(userId, uid, true)
|
||||
: this.bondIncome.readPosition(userId, uid, true),
|
||||
);
|
||||
await add(
|
||||
type === 'share'
|
||||
? this.stockIncome.dividends(userId, uid, true)
|
||||
: this.bondIncome.coupons(userId, uid, true),
|
||||
);
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||
import { decimal } from '../../../integrations/tbank/decimal';
|
||||
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
|
||||
import { UpcomingEventDto } from './upcoming-events.dto';
|
||||
|
||||
export function dividendEvent(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { UpcomingEventsService } from './upcoming-events.service';
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
type PortfolioPositionDto,
|
||||
PortfolioService,
|
||||
} from '../portfolio/portfolio.api';
|
||||
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
|
||||
import {
|
||||
UpcomingEventDto,
|
||||
UpcomingEventsDto,
|
||||
UpcomingEventsQueryDto,
|
||||
} from './upcoming-events.dto';
|
||||
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
|
||||
|
||||
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
|
||||
|
||||
@@ -226,7 +226,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
||||
}
|
||||
|
||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user