chore: strengthen backend eslint profile
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
@@ -40,15 +41,18 @@ export class BrokerageAccountDto {
|
||||
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare openedOn: string | null;
|
||||
declare openedOn: null | string;
|
||||
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field.
|
||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||
portfolioSummary?: PortfolioSummaryDto | null;
|
||||
portfolioSummary?: null | PortfolioSummaryDto;
|
||||
}
|
||||
|
||||
export class BrokerageAccountsResourceDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
|
||||
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
||||
declare data: BrokerageAccountDto[] | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
jest.mock('../portfolio/application/portfolio.service', () => ({
|
||||
PortfolioService: class PortfolioService {},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -11,15 +12,15 @@ export class BrokerageAccountsService {
|
||||
async list(userId: string, observe = false) {
|
||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||
const data: BrokerageAccountDto[] | null =
|
||||
saved.membership.availability === 'missing'
|
||||
? null
|
||||
: saved.accounts.map((account) => ({
|
||||
saved.membership.availability === 'missing' ?
|
||||
null :
|
||||
saved.accounts.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
type: account.type as BrokerageAccountDto['type'],
|
||||
openedOn: account.openedOn,
|
||||
portfolioSummary: account.snapshot
|
||||
? {
|
||||
portfolioSummary: account.snapshot ?
|
||||
{
|
||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||
stockPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'STOCK',
|
||||
@@ -27,8 +28,8 @@ export class BrokerageAccountsService {
|
||||
bondPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'BOND',
|
||||
).length,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
}));
|
||||
return {
|
||||
data,
|
||||
@@ -46,18 +47,18 @@ export class BrokerageAccountsService {
|
||||
)
|
||||
.digest('base64url'),
|
||||
availability:
|
||||
data === null
|
||||
? 'missing'
|
||||
: saved.accounts.some((account) => !account.snapshot)
|
||||
? 'partial'
|
||||
: 'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt
|
||||
? new Date(saved.membership.lastSuccessfulAt)
|
||||
: null,
|
||||
data === null ?
|
||||
'missing' :
|
||||
saved.accounts.some((account) => !account.snapshot) ?
|
||||
'partial' :
|
||||
'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt ?
|
||||
new Date(saved.membership.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: 300_000,
|
||||
work: saved.membership.sync.id
|
||||
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||
: null,
|
||||
work: saved.membership.sync.id ?
|
||||
{ ...saved.membership.sync, id: saved.membership.sync.id } :
|
||||
null,
|
||||
dependencies: saved.accounts.map((account) => account.meta),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import {
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeAnalyticsQueryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import {
|
||||
InvestmentDependencyDto,
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
@@ -102,16 +102,16 @@ export class IncomeInstrumentDto {
|
||||
declare key: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare figi: string | null;
|
||||
declare figi: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentUid: string | null;
|
||||
declare instrumentUid: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare ticker: string | null;
|
||||
declare ticker: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentType: string | null;
|
||||
declare instrumentType: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare displayName: string;
|
||||
@@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
declare yearComparison: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||
declare bucketGranularity: 'day' | 'week' | 'month';
|
||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||
|
||||
@ApiProperty({ type: IncomeBucketDto, isArray: true })
|
||||
declare buckets: IncomeBucketDto[];
|
||||
@@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
export class IncomeSummaryResourceDto {
|
||||
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
||||
declare data: IncomeSummaryDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||
}
|
||||
|
||||
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
|
||||
@ApiProperty({
|
||||
type: IncomeDependencyDto,
|
||||
isArray: true,
|
||||
@@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
|
||||
export class IncomeAnalyticsResourceDto {
|
||||
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||
declare data: IncomeAnalyticsDto | null;
|
||||
|
||||
@ApiProperty({ type: IncomeAnalyticsMetaDto })
|
||||
declare meta: IncomeAnalyticsMetaDto;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||
}));
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||
|
||||
describe('IncomeAnalyticsService', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
add,
|
||||
compare,
|
||||
@@ -7,24 +8,21 @@ import {
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
BrokerageOperationsService,
|
||||
} from '../operations/operations.api';
|
||||
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeComparisonDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
IncomeSummaryDto,
|
||||
IncomeSummaryResourceDto,
|
||||
} from './income-analytics.dto';
|
||||
import {
|
||||
IncomeBucketGranularity,
|
||||
@@ -96,9 +94,9 @@ export class IncomeAnalyticsService {
|
||||
{ ...current.meta, role: 'selected' },
|
||||
{ ...prior.meta, role: 'previous' },
|
||||
{
|
||||
...(plan.yearAgo && year
|
||||
? year.meta
|
||||
: investmentResourceMeta({
|
||||
...(plan.yearAgo && year ?
|
||||
year.meta :
|
||||
investmentResourceMeta({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
dataRevision: 'unavailable',
|
||||
availability: 'missing',
|
||||
@@ -119,9 +117,9 @@ export class IncomeAnalyticsService {
|
||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||
dataRevision: current.meta.dataRevision,
|
||||
availability: current.meta.availability,
|
||||
fetchedAt: current.meta.lastSuccessfulAt
|
||||
? new Date(current.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
fetchedAt: current.meta.lastSuccessfulAt ?
|
||||
new Date(current.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
coverage: current.meta.coverage,
|
||||
@@ -144,13 +142,13 @@ export class IncomeAnalyticsService {
|
||||
current.meta.lastSuccessfulAt,
|
||||
),
|
||||
previous:
|
||||
prior.items === null
|
||||
? null
|
||||
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
prior.items === null ?
|
||||
null :
|
||||
comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
yearComparison:
|
||||
plan.yearAgo && year?.items !== null && year
|
||||
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||
: null,
|
||||
plan.yearAgo && year?.items !== null && year ?
|
||||
comparison(selected, plan.yearAgo, qualifying(year.items)) :
|
||||
null,
|
||||
bucketGranularity: plan.granularity,
|
||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||
instruments: instruments(selected),
|
||||
@@ -184,9 +182,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
if (!entry.payment) continue;
|
||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||
const value = decimalFromString(entry.payment.value);
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON')
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
||||
total.coupon = add(total.coupon, value);
|
||||
else total.dividend = add(total.dividend, value);
|
||||
} else total.dividend = add(total.dividend, value);
|
||||
values.set(entry.payment.currency, total);
|
||||
}
|
||||
return [...values]
|
||||
@@ -207,6 +205,7 @@ function comparison(
|
||||
);
|
||||
const currencies = [
|
||||
...new Set([...selected.keys(), ...compared.keys()]),
|
||||
// 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();
|
||||
return {
|
||||
period: comparedPeriod,
|
||||
@@ -279,8 +278,9 @@ function instruments(
|
||||
function instrumentKey(entry: BrokerageOperationDto): string {
|
||||
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
|
||||
if (entry.figi) return `figi:${entry.figi}`;
|
||||
if (entry.ticker || entry.instrumentType)
|
||||
if (entry.ticker || entry.instrumentType) {
|
||||
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
|
||||
}
|
||||
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { dateOnlyBounds } from '../investment-calendar';
|
||||
import { planIncomePeriod, rangeBuckets } from './income-period';
|
||||
|
||||
@@ -17,8 +18,9 @@ describe('income period planning', () => {
|
||||
['2026-03-02', '2026-03-01'],
|
||||
['2026-01-01', '2027-01-02'],
|
||||
['2026-01-01', '2026-04-01'],
|
||||
])
|
||||
]) {
|
||||
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateParts,
|
||||
@@ -13,41 +14,41 @@ const DAYS_PER_WEEK = 7;
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
|
||||
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
|
||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
||||
|
||||
export function planIncomePeriod(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
openedDate?: Date,
|
||||
): {
|
||||
selected: DateOnlyRange;
|
||||
previous: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
granularity: IncomeBucketGranularity;
|
||||
previous: DateOnlyRange;
|
||||
selected: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
} {
|
||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||
const today = moscowDate(new Date());
|
||||
const selected =
|
||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||
assertRange(selected, today);
|
||||
const previous = isFullMonth(selected)
|
||||
? previousMonth(selected.from)
|
||||
: shiftDays(selected, -rangeDays(selected));
|
||||
const previous = isFullMonth(selected) ?
|
||||
previousMonth(selected.from) :
|
||||
shiftDays(selected, -rangeDays(selected));
|
||||
const candidate = yearAgo(selected);
|
||||
const yearAgoRange =
|
||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
||||
? candidate
|
||||
: null;
|
||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
||||
candidate :
|
||||
null;
|
||||
return {
|
||||
selected,
|
||||
previous,
|
||||
yearAgo: yearAgoRange,
|
||||
granularity:
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||
? 'day'
|
||||
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
||||
? 'week'
|
||||
: 'month',
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
||||
'day' :
|
||||
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
||||
'week' :
|
||||
'month',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,24 +56,25 @@ export function rangeBuckets(
|
||||
range: DateOnlyRange,
|
||||
granularity: IncomeBucketGranularity,
|
||||
): DateOnlyRange[] {
|
||||
if (granularity === 'day')
|
||||
if (granularity === 'day') {
|
||||
return dates(range).map((date) => ({ from: date, to: date }));
|
||||
}
|
||||
const buckets: DateOnlyRange[] = [];
|
||||
let cursor =
|
||||
granularity === 'week'
|
||||
? addDays(
|
||||
granularity === 'week' ?
|
||||
addDays(
|
||||
range.from,
|
||||
-(
|
||||
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
|
||||
DAYS_PER_WEEK
|
||||
),
|
||||
)
|
||||
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||
) :
|
||||
`${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
|
||||
while (cursor <= range.to) {
|
||||
const end =
|
||||
granularity === 'week'
|
||||
? addDays(cursor, DAYS_PER_WEEK - 1)
|
||||
: endOfMonth(cursor);
|
||||
granularity === 'week' ?
|
||||
addDays(cursor, DAYS_PER_WEEK - 1) :
|
||||
endOfMonth(cursor);
|
||||
buckets.push({
|
||||
from: cursor < range.from ? range.from : cursor,
|
||||
to: end > range.to ? range.to : end,
|
||||
@@ -86,7 +88,7 @@ export function rangeDays(range: DateOnlyRange): number {
|
||||
return (
|
||||
Math.round(
|
||||
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_DAY,
|
||||
) + 1
|
||||
);
|
||||
}
|
||||
@@ -109,8 +111,9 @@ function addDays(value: string, days: number): string {
|
||||
|
||||
function dates(range: DateOnlyRange): string[] {
|
||||
const result: string[] = [];
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1))
|
||||
for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
|
||||
result.push(date);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -142,9 +145,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||
const shift = (value: string) => {
|
||||
const [year, month, day] = dateParts(value);
|
||||
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return utcDate(result).toISOString().slice(0, 10) === result
|
||||
? result
|
||||
: null;
|
||||
return utcDate(result).toISOString().slice(0, 10) === result ?
|
||||
result :
|
||||
null;
|
||||
};
|
||||
const from = shift(range.from);
|
||||
const to = shift(range.to);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { OperationsModule } from '../operations/operations.module';
|
||||
import { IncomeAnalyticsController } from './income-analytics.controller';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -16,35 +16,36 @@ import {
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import {
|
||||
candleGaps,
|
||||
type CandleRange,
|
||||
candleWindows,
|
||||
utcDay,
|
||||
type CandleRange,
|
||||
} from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
type CandleInput = {
|
||||
uid: string;
|
||||
instrumentType: 'share' | 'bond';
|
||||
period: 'month' | 'halfYear' | 'year' | 'all';
|
||||
instrumentType: 'bond' | 'share';
|
||||
period: 'all' | 'halfYear' | 'month' | 'year';
|
||||
range?: CandleRange;
|
||||
uid: string;
|
||||
};
|
||||
type Producer = {
|
||||
input: CandleInput;
|
||||
attempt: TbankSyncAttempt;
|
||||
now: Date;
|
||||
force: boolean;
|
||||
priority: number;
|
||||
input: CandleInput;
|
||||
now: Date;
|
||||
observers: TbankSyncAttempt[];
|
||||
pass?: {
|
||||
now: Date;
|
||||
force: boolean;
|
||||
ranges: CandleRange[];
|
||||
nextWindow: number;
|
||||
now: Date;
|
||||
ranges: CandleRange[];
|
||||
};
|
||||
priority: number;
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
@@ -61,18 +62,20 @@ function rangeFor(
|
||||
const from = utcDay(now);
|
||||
const history = timestamp(instrument.first_1dayCandleDate);
|
||||
if (input.period === 'all') {
|
||||
if (!history)
|
||||
if (!history) {
|
||||
throw new ServiceUnavailableException({
|
||||
code:
|
||||
input.instrumentType === 'share'
|
||||
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||
input.instrumentType === 'share' ?
|
||||
'STOCK_HISTORY_START_UNAVAILABLE' :
|
||||
'BOND_HISTORY_START_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
} else {
|
||||
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
||||
if (history && Date.parse(history) > from.valueOf())
|
||||
if (history && Date.parse(history) > from.valueOf()) {
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
}
|
||||
}
|
||||
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
||||
}
|
||||
@@ -102,16 +105,18 @@ export class CandleService {
|
||||
true,
|
||||
['detail'],
|
||||
);
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument)
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const now = new Date();
|
||||
const range = detail.instrument
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
const range = detail.instrument ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||
if (!observe && (!saved || gaps.length))
|
||||
if (!observe && (!saved || gaps.length)) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
const fetchedAt =
|
||||
saved?.coverage
|
||||
@@ -129,14 +134,14 @@ export class CandleService {
|
||||
});
|
||||
return {
|
||||
data:
|
||||
!saved || !covered
|
||||
? null
|
||||
: {
|
||||
!saved || !covered ?
|
||||
null :
|
||||
{
|
||||
candles: saved.data,
|
||||
currency:
|
||||
input.instrumentType === 'share'
|
||||
? detail.instrument?.currency?.toUpperCase() || null
|
||||
: null,
|
||||
input.instrumentType === 'share' ?
|
||||
detail.instrument?.currency?.toUpperCase() || null :
|
||||
null,
|
||||
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||
},
|
||||
@@ -149,8 +154,8 @@ export class CandleService {
|
||||
work,
|
||||
coverage: {
|
||||
requested: range ? [publicRange(range)] : [],
|
||||
completeRanges: range
|
||||
? completeCoverage.flatMap((row) => {
|
||||
completeRanges: range ?
|
||||
completeCoverage.flatMap((row) => {
|
||||
const from = new Date(
|
||||
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||
);
|
||||
@@ -158,22 +163,22 @@ export class CandleService {
|
||||
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||
);
|
||||
return from < to ? [publicRange({ from, to })] : [];
|
||||
})
|
||||
: [],
|
||||
}) :
|
||||
[],
|
||||
missingRanges: missing.map(publicRange),
|
||||
asOf: fetchedAt?.toISOString() ?? null,
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||
? 'known'
|
||||
: 'unknown',
|
||||
historyState: complete
|
||||
? 'complete'
|
||||
: work?.state === 'failed'
|
||||
? 'failed'
|
||||
: work?.state === 'running'
|
||||
? 'loading'
|
||||
: covered
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
|
||||
'known' :
|
||||
'unknown',
|
||||
historyState: complete ?
|
||||
'complete' :
|
||||
work?.state === 'failed' ?
|
||||
'failed' :
|
||||
work?.state === 'running' ?
|
||||
'loading' :
|
||||
covered ?
|
||||
'partial' :
|
||||
'missing',
|
||||
},
|
||||
dependencies: [
|
||||
detail.meta,
|
||||
@@ -183,9 +188,9 @@ export class CandleService {
|
||||
dataRevision: String(row.dataRevision),
|
||||
availability: 'complete',
|
||||
fetchedAt: row.fetchedAt,
|
||||
freshForMs: row.isComplete
|
||||
? Number.POSITIVE_INFINITY
|
||||
: tailFreshForMs,
|
||||
freshForMs: row.isComplete ?
|
||||
Number.POSITIVE_INFINITY :
|
||||
tailFreshForMs,
|
||||
work: null,
|
||||
}),
|
||||
),
|
||||
@@ -197,13 +202,14 @@ export class CandleService {
|
||||
prefetch(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: string | null; type: 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);
|
||||
@@ -218,14 +224,14 @@ export class CandleService {
|
||||
private async prefetchPositions(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: string | null; type: string }[],
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const now = new Date();
|
||||
const unique = new Map(
|
||||
positions
|
||||
.filter(
|
||||
(position) =>
|
||||
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
|
||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
||||
)
|
||||
.map((position) => [position.instrumentUid ?? '', position]),
|
||||
);
|
||||
@@ -267,7 +273,7 @@ export class CandleService {
|
||||
instruments: [
|
||||
{
|
||||
uid,
|
||||
type: position.type as 'STOCK' | 'BOND',
|
||||
type: position.type as 'BOND' | 'STOCK',
|
||||
historyFrom: history ? new Date(history) : null,
|
||||
},
|
||||
],
|
||||
@@ -280,9 +286,9 @@ export class CandleService {
|
||||
) {
|
||||
const input = { uid, instrumentType, period: 'all' as const };
|
||||
const range =
|
||||
detail?.instrument && history
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
detail?.instrument && history ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
if (
|
||||
!range ||
|
||||
candleGaps(
|
||||
@@ -290,8 +296,9 @@ export class CandleService {
|
||||
coverage.filter((row) => row.instrumentUid === uid),
|
||||
now,
|
||||
).length
|
||||
)
|
||||
) {
|
||||
await this.request(userId, revision, input, now, false, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,6 +386,7 @@ export class CandleService {
|
||||
}
|
||||
const { ranges, now, force } = pass;
|
||||
const index = pass.nextWindow;
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
||||
let window = ranges[index];
|
||||
if (window && !force) {
|
||||
const latest = await this.repository.read(input.uid, window);
|
||||
@@ -386,6 +394,7 @@ export class CandleService {
|
||||
.flatMap(candleWindows)
|
||||
.reverse();
|
||||
ranges.splice(index, 1, ...gaps);
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
||||
window = ranges[index];
|
||||
}
|
||||
if (window) {
|
||||
@@ -408,8 +417,9 @@ export class CandleService {
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!force &&
|
||||
!(await this.repository.publish(
|
||||
@@ -417,8 +427,9 @@ export class CandleService {
|
||||
{ uid: input.uid, ranges: [window] },
|
||||
false,
|
||||
))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pass.nextWindow = index + 1;
|
||||
if (pass.nextWindow < ranges.length) return null;
|
||||
}
|
||||
@@ -441,16 +452,18 @@ export class CandleService {
|
||||
}
|
||||
success = result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(producer.attempt);
|
||||
}
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
const response = error.getResponse();
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
'code' in response &&
|
||||
typeof response.code === 'string'
|
||||
)
|
||||
) {
|
||||
code = response.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seal publication with reservations so a late forced join cannot be
|
||||
@@ -458,8 +471,9 @@ export class CandleService {
|
||||
const finished = this.reservations.then(async () => {
|
||||
const pass = producer.pass;
|
||||
if (success && pass) {
|
||||
if (producer.force && (!pass.force || producer.now > pass.now))
|
||||
if (producer.force && (!pass.force || producer.now > pass.now)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
success = await this.repository.publish(producer.attempt, {
|
||||
uid: producer.input.uid,
|
||||
@@ -478,8 +492,9 @@ export class CandleService {
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
) {
|
||||
await this.repository.fail(observer, code);
|
||||
}
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
@@ -490,7 +505,7 @@ export class CandleService {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
if (success && !producer.force && producer.input.period !== 'all')
|
||||
if (success && !producer.force && producer.input.period !== 'all') {
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
producer.attempt.revision,
|
||||
@@ -499,6 +514,7 @@ export class CandleService {
|
||||
false,
|
||||
2,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'candles.persistence-failed', {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
catalogScope,
|
||||
type CatalogQuery,
|
||||
catalogScope,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import {
|
||||
catalogSearchLimit,
|
||||
selectCatalogInstruments,
|
||||
@@ -28,10 +29,10 @@ import {
|
||||
const tickerAmbiguityLimit = 2;
|
||||
|
||||
type Producer = {
|
||||
input: CatalogQuery;
|
||||
attempt: TbankSyncAttempt;
|
||||
observers: TbankSyncAttempt[];
|
||||
force: boolean;
|
||||
input: CatalogQuery;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@Injectable()
|
||||
export class CatalogDiscoveryService {
|
||||
@@ -43,8 +44,9 @@ export class CatalogDiscoveryService {
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
@@ -73,14 +75,14 @@ export class CatalogDiscoveryService {
|
||||
|
||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||
const discovery = await this.read(userId, input, observe);
|
||||
const sources = discovery.data
|
||||
? await this.enrichment.readMany(
|
||||
const sources = discovery.data ?
|
||||
await this.enrichment.readMany(
|
||||
userId,
|
||||
input.instrumentType,
|
||||
discovery.data.map((item) => item.uid),
|
||||
observe,
|
||||
)
|
||||
: [];
|
||||
) :
|
||||
[];
|
||||
const dependencies = [
|
||||
discovery.meta,
|
||||
...sources.map((source) => source.meta),
|
||||
@@ -108,22 +110,22 @@ export class CatalogDiscoveryService {
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability:
|
||||
discovery.data === null
|
||||
? 'missing'
|
||||
: sources.every((source) => source.meta.availability === 'complete')
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||
? new Date(discovery.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
discovery.data === null ?
|
||||
'missing' :
|
||||
sources.every((source) => source.meta.availability === 'complete') ?
|
||||
'complete' :
|
||||
'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt ?
|
||||
new Date(discovery.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.catalog,
|
||||
work: discovery.meta.sync.id
|
||||
? {
|
||||
work: discovery.meta.sync.id ?
|
||||
{
|
||||
id: discovery.meta.sync.id,
|
||||
state: discovery.meta.sync.state,
|
||||
errorCode: discovery.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
@@ -131,7 +133,7 @@ export class CatalogDiscoveryService {
|
||||
|
||||
async detail(
|
||||
userId: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
observe = false,
|
||||
) {
|
||||
@@ -143,22 +145,24 @@ export class CatalogDiscoveryService {
|
||||
['detail'],
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.availability === 'complete' && !source.instrument)
|
||||
if (source.meta.availability === 'complete' && !source.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const instrument = source.instrument;
|
||||
let slug = uid;
|
||||
const dependencies = [source.meta];
|
||||
if (
|
||||
instrument?.ticker &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
|
||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
||||
) {
|
||||
const alias = await this.read(
|
||||
userId,
|
||||
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||
observe,
|
||||
);
|
||||
if (alias.data?.length === 1 && alias.data[0].uid === uid)
|
||||
if (alias.data?.length === 1 && alias.data[0].uid === uid) {
|
||||
slug = instrument.ticker.toUpperCase();
|
||||
}
|
||||
dependencies.push(alias.meta);
|
||||
}
|
||||
return {
|
||||
@@ -169,17 +173,17 @@ export class CatalogDiscoveryService {
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: source.meta.availability,
|
||||
fetchedAt: source.meta.lastSuccessfulAt
|
||||
? new Date(source.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
fetchedAt: source.meta.lastSuccessfulAt ?
|
||||
new Date(source.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: source.meta.sync.id
|
||||
? {
|
||||
work: source.meta.sync.id ?
|
||||
{
|
||||
id: source.meta.sync.id,
|
||||
state: source.meta.sync.state,
|
||||
errorCode: source.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
@@ -187,7 +191,7 @@ export class CatalogDiscoveryService {
|
||||
|
||||
async byTicker(
|
||||
userId: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
ticker: string,
|
||||
observe = false,
|
||||
) {
|
||||
@@ -197,38 +201,39 @@ export class CatalogDiscoveryService {
|
||||
observe,
|
||||
);
|
||||
if (lookup.data?.length === 0) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > 1)
|
||||
if (lookup.data && lookup.data.length > 1) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
? 'STOCK_TICKER_AMBIGUOUS'
|
||||
: 'BOND_TICKER_AMBIGUOUS',
|
||||
instrumentType === 'share' ?
|
||||
'STOCK_TICKER_AMBIGUOUS' :
|
||||
'BOND_TICKER_AMBIGUOUS',
|
||||
});
|
||||
const result = lookup.data
|
||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||
: null;
|
||||
}
|
||||
const result = lookup.data ?
|
||||
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
|
||||
null;
|
||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||
return {
|
||||
data: result?.data
|
||||
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||
: null,
|
||||
data: result?.data ?
|
||||
{ ...result.data, slug: ticker.trim().toUpperCase() } :
|
||||
null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||
dataRevision: dependencies
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: result?.meta.availability ?? 'missing',
|
||||
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||
? new Date(lookup.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
fetchedAt: lookup.meta.lastSuccessfulAt ?
|
||||
new Date(lookup.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: lookup.meta.sync.id
|
||||
? {
|
||||
work: lookup.meta.sync.id ?
|
||||
{
|
||||
id: lookup.meta.sync.id,
|
||||
state: lookup.meta.sync.state,
|
||||
errorCode: lookup.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
@@ -245,8 +250,9 @@ export class CatalogDiscoveryService {
|
||||
const saved = await this.repository.read(input);
|
||||
if (
|
||||
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const reservation = await this.repository.reserve(
|
||||
{ userId, revision },
|
||||
input,
|
||||
@@ -291,12 +297,13 @@ export class CatalogDiscoveryService {
|
||||
|
||||
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
||||
const id = await this.request(userId, revision, query, true);
|
||||
if (id)
|
||||
if (id) {
|
||||
await Promise.all(
|
||||
[...this.completions.values()]
|
||||
.filter((entry) => entry.workId === id)
|
||||
.map((entry) => entry.done),
|
||||
);
|
||||
}
|
||||
const saved = await this.repository.read(catalogQuery(query));
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
@@ -323,19 +330,19 @@ export class CatalogDiscoveryService {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (token) {
|
||||
const found =
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.findShares(token, input.query)
|
||||
: await this.tbank.findBonds(token, input.query);
|
||||
input.instrumentType === 'share' ?
|
||||
await this.tbank.findShares(token, input.query) :
|
||||
await this.tbank.findBonds(token, input.query);
|
||||
success = await this.repository.publish(
|
||||
attempt,
|
||||
input,
|
||||
selectCatalogInstruments(
|
||||
input.exactTicker
|
||||
? found.filter(
|
||||
input.exactTicker ?
|
||||
found.filter(
|
||||
(item) =>
|
||||
item.ticker?.trim().toUpperCase() === input.query,
|
||||
)
|
||||
: found,
|
||||
) :
|
||||
found,
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(
|
||||
@@ -346,17 +353,18 @@ export class CatalogDiscoveryService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
const saved = await this.repository.read(input);
|
||||
for (const observer of [attempt, ...producer.observers])
|
||||
if (!input.exactTicker || saved.data?.length === 1)
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
if (!input.exactTicker || saved.data?.length === 1) {
|
||||
await this.enrichment.requestMany(
|
||||
observer.userId,
|
||||
observer.revision,
|
||||
@@ -365,6 +373,8 @@ export class CatalogDiscoveryService {
|
||||
producer.force,
|
||||
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.producers.delete(catalogScope(input));
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_SECOND,
|
||||
} from '../../../../common/time';
|
||||
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
TbankClient,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogScope,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
|
||||
type Producer = {
|
||||
input: CatalogSource;
|
||||
attempt: TbankSyncAttempt;
|
||||
input: CatalogSource;
|
||||
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
|
||||
};
|
||||
@Injectable()
|
||||
@@ -54,13 +54,13 @@ export class CatalogEnrichmentService {
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
async ensureDetail(
|
||||
userId: string,
|
||||
revision: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
) {
|
||||
const ids = await this.requestMany(
|
||||
@@ -82,6 +82,7 @@ export class CatalogEnrichmentService {
|
||||
if (!rows.length) throw new Error('Instrument dependency missing');
|
||||
return (rows[0].payload as CatalogPayload).instrument ?? null;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
@@ -94,21 +95,21 @@ export class CatalogEnrichmentService {
|
||||
|
||||
async readMany(
|
||||
userId: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uids: string[],
|
||||
observe: boolean,
|
||||
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||
range?: {
|
||||
from: string;
|
||||
to: string;
|
||||
ticker?: string;
|
||||
tickers?: Record<string, string>;
|
||||
to: string;
|
||||
},
|
||||
) {
|
||||
const { revision } = await this.access.local(userId);
|
||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
|
||||
if (!observe)
|
||||
if (!observe) {
|
||||
await this.requestMany(
|
||||
userId,
|
||||
revision,
|
||||
@@ -118,9 +119,10 @@ export class CatalogEnrichmentService {
|
||||
kinds,
|
||||
range,
|
||||
);
|
||||
const work = inputs.length
|
||||
? await this.repository.workMany(userId, revision, inputs)
|
||||
: [];
|
||||
}
|
||||
const work = inputs.length ?
|
||||
await this.repository.workMany(userId, revision, inputs) :
|
||||
[];
|
||||
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||
return inputs.map((input) => {
|
||||
const row = rows.find(
|
||||
@@ -130,16 +132,16 @@ export class CatalogEnrichmentService {
|
||||
const payload = row?.payload as CatalogPayload | undefined;
|
||||
return {
|
||||
input,
|
||||
version: row
|
||||
? {
|
||||
version: row ?
|
||||
{
|
||||
instrumentUid: row.instrumentUid,
|
||||
source: row.source,
|
||||
kind: row.kind,
|
||||
rangeKey: row.rangeKey,
|
||||
dataRevision: row.dataRevision,
|
||||
fetchedAt: row.fetchedAt,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
payload,
|
||||
instrument: payload?.instrument ?? null,
|
||||
price: payload?.price ?? null,
|
||||
@@ -158,22 +160,22 @@ export class CatalogEnrichmentService {
|
||||
requestMany(
|
||||
userId: string,
|
||||
revision: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uids: string[],
|
||||
force = false,
|
||||
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||
range?: {
|
||||
from: string;
|
||||
to: string;
|
||||
ticker?: string;
|
||||
tickers?: Record<string, string>;
|
||||
to: string;
|
||||
},
|
||||
) {
|
||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||
const reservation = this.reservations.then(async () => {
|
||||
const rows = inputs.length
|
||||
? await this.repository.readSources(inputs)
|
||||
: [];
|
||||
const rows = inputs.length ?
|
||||
await this.repository.readSources(inputs) :
|
||||
[];
|
||||
const ids: string[] = [];
|
||||
for (const input of inputs) {
|
||||
const row = rows.find(
|
||||
@@ -187,8 +189,9 @@ export class CatalogEnrichmentService {
|
||||
new Date(),
|
||||
force,
|
||||
)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const reserved = await this.repository.reserve(
|
||||
{ userId, revision },
|
||||
input,
|
||||
@@ -229,24 +232,24 @@ export class CatalogEnrichmentService {
|
||||
|
||||
async section(
|
||||
userId: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
section: 'quote' | 'company' | 'metrics',
|
||||
section: 'company' | 'metrics' | 'quote',
|
||||
observe = false,
|
||||
) {
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
: [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
section === 'company' ?
|
||||
['detail', 'description', 'fundamentals'] :
|
||||
[
|
||||
...(instrumentType === 'bond' ?
|
||||
['bond-current' as const] :
|
||||
['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
...(section === 'metrics' ?
|
||||
['coupons' as const, 'yield' as const, 'rating' as const] :
|
||||
[]),
|
||||
];
|
||||
const sources = await this.readMany(
|
||||
userId,
|
||||
@@ -256,8 +259,9 @@ export class CatalogEnrichmentService {
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
if (first.meta.availability === 'complete' && !first.instrument)
|
||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const values = sources.reduce<CatalogPayload>(
|
||||
(result, source) => ({ ...result, ...source.payload }),
|
||||
{},
|
||||
@@ -271,18 +275,18 @@ export class CatalogEnrichmentService {
|
||||
dataRevision: dependencies
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: !first.instrument
|
||||
? 'missing'
|
||||
: complete
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
availability: !first.instrument ?
|
||||
'missing' :
|
||||
complete ?
|
||||
'complete' :
|
||||
'partial',
|
||||
fetchedAt: first.meta.lastSuccessfulAt ?
|
||||
new Date(first.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs:
|
||||
section === 'company'
|
||||
? tbankSourceTtlMs.company
|
||||
: tbankSourceTtlMs.quote,
|
||||
section === 'company' ?
|
||||
tbankSourceTtlMs.company :
|
||||
tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
@@ -307,18 +311,21 @@ export class CatalogEnrichmentService {
|
||||
): Promise<CatalogPayload> {
|
||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||
const instrument =
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, input.uid)
|
||||
: await this.tbank.getBond(token, input.uid);
|
||||
if (instrument && instrument.uid !== input.uid)
|
||||
input.instrumentType === 'share' ?
|
||||
await this.tbank.getShare(token, input.uid) :
|
||||
await this.tbank.getBond(token, input.uid);
|
||||
if (instrument && instrument.uid !== input.uid) {
|
||||
throw new Error('Catalog UID mismatch');
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||
}
|
||||
if (input.kind === 'trading-status')
|
||||
if (input.kind === 'trading-status') {
|
||||
return {
|
||||
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||
};
|
||||
}
|
||||
if (input.kind === 'comparison') {
|
||||
const prices = await this.repository.readSources([
|
||||
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||
@@ -343,16 +350,18 @@ export class CatalogEnrichmentService {
|
||||
);
|
||||
const rows = response.map((candle) => {
|
||||
const time = timestamp(candle.time);
|
||||
if (!time || !candle.close)
|
||||
if (!time || !candle.close) {
|
||||
throw new Error('Invalid candle response');
|
||||
}
|
||||
return {
|
||||
time,
|
||||
close: formatDecimal(decimal(candle.close)),
|
||||
isComplete: candle.isComplete === true,
|
||||
};
|
||||
});
|
||||
if (!(await this.candles.stage(attempt, gap, rows, now)))
|
||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||
throw new Error('Cancelled candle comparison');
|
||||
}
|
||||
}
|
||||
if (
|
||||
gaps.length &&
|
||||
@@ -361,8 +370,9 @@ export class CatalogEnrichmentService {
|
||||
{ uid: input.uid, ranges: gaps },
|
||||
false,
|
||||
))
|
||||
)
|
||||
) {
|
||||
throw new Error('Cancelled candle comparison');
|
||||
}
|
||||
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||
(row) => {
|
||||
const value = divide(
|
||||
@@ -386,48 +396,51 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
const details = await this.repository.readSources([
|
||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||
...(input.instrumentType === 'bond'
|
||||
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||
: []),
|
||||
...(input.instrumentType === 'bond' ?
|
||||
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
|
||||
[]),
|
||||
]);
|
||||
const instrument = details
|
||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||
.find(Boolean);
|
||||
if (
|
||||
!instrument &&
|
||||
!['dividends', 'coupons', 'bond-events'].includes(input.kind)
|
||||
)
|
||||
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
|
||||
) {
|
||||
throw new Error('Instrument dependency missing');
|
||||
if (input.kind === 'description')
|
||||
}
|
||||
if (input.kind === 'description') {
|
||||
return {
|
||||
description: instrument?.assetUid
|
||||
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||
: null,
|
||||
description: instrument?.assetUid ?
|
||||
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
|
||||
null,
|
||||
};
|
||||
if (input.kind === 'fundamentals')
|
||||
}
|
||||
if (input.kind === 'fundamentals') {
|
||||
return {
|
||||
fundamentals: instrument?.assetUid
|
||||
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||
: null,
|
||||
fundamentals: instrument?.assetUid ?
|
||||
await this.tbank.getFundamentals(token, instrument?.assetUid) :
|
||||
null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||
const year = Number(input.rangeKey);
|
||||
const [from, to] = input.rangeKey?.startsWith('[')
|
||||
? (JSON.parse(input.rangeKey) as [string, string])
|
||||
: [
|
||||
const [from, to] = input.rangeKey?.startsWith('[') ?
|
||||
(JSON.parse(input.rangeKey) as [string, string]) :
|
||||
[
|
||||
`${year - payoutHistoryYears}-01-01`,
|
||||
`${year + payoutFutureYears}-01-01`,
|
||||
];
|
||||
return input.kind === 'coupons'
|
||||
? {
|
||||
return input.kind === 'coupons' ?
|
||||
{
|
||||
coupons: await this.tbank.getBondCoupons(
|
||||
token,
|
||||
input.uid,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
}
|
||||
: {
|
||||
} :
|
||||
{
|
||||
dividends: await this.tbank.getDividends(
|
||||
token,
|
||||
input.uid,
|
||||
@@ -441,23 +454,25 @@ export class CatalogEnrichmentService {
|
||||
if (!ticker) throw new Error('Bond ticker missing');
|
||||
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||
}
|
||||
if (input.kind === 'yield')
|
||||
if (input.kind === 'yield') {
|
||||
return {
|
||||
effectiveYield:
|
||||
instrument?.ticker && instrument.isin && instrument.classCode
|
||||
? await this.moex.bondYield(
|
||||
instrument?.ticker && instrument.isin && instrument.classCode ?
|
||||
await this.moex.bondYield(
|
||||
instrument.ticker,
|
||||
instrument.isin,
|
||||
instrument.classCode,
|
||||
)
|
||||
: null,
|
||||
) :
|
||||
null,
|
||||
};
|
||||
if (input.kind === 'rating')
|
||||
}
|
||||
if (input.kind === 'rating') {
|
||||
return {
|
||||
creditRating: instrument?.isin
|
||||
? await this.ratings.rating(instrument.isin)
|
||||
: null,
|
||||
creditRating: instrument?.isin ?
|
||||
await this.ratings.rating(instrument.isin) :
|
||||
null,
|
||||
};
|
||||
}
|
||||
throw new Error('Unsupported catalog source');
|
||||
}
|
||||
|
||||
@@ -473,6 +488,7 @@ export class CatalogEnrichmentService {
|
||||
let i = 0;
|
||||
i < this.queue.length && batch.length < catalogSearchLimit;
|
||||
) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
const next = this.queue[i];
|
||||
if (
|
||||
next.input.kind === 'last-price' &&
|
||||
@@ -490,38 +506,40 @@ export class CatalogEnrichmentService {
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(producer.attempt);
|
||||
if (!token) failed = true;
|
||||
else if (producer.input.kind === 'last-price')
|
||||
else if (producer.input.kind === 'last-price') {
|
||||
prices = await this.tbank.getLastPrices(
|
||||
token,
|
||||
batch.map((item) => item.input.uid),
|
||||
);
|
||||
else
|
||||
} else {
|
||||
payload = await this.load(producer.input, token, producer.attempt);
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(producer.attempt);
|
||||
else
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of batch) {
|
||||
const price =
|
||||
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||
null;
|
||||
const value =
|
||||
item.input.kind === 'last-price'
|
||||
? {
|
||||
price: price
|
||||
? {
|
||||
item.input.kind === 'last-price' ?
|
||||
{
|
||||
price: price ?
|
||||
{
|
||||
instrumentUid: item.input.uid,
|
||||
price: price.price,
|
||||
time: price.time,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: payload;
|
||||
} :
|
||||
null,
|
||||
} :
|
||||
payload;
|
||||
// Keep accepting observers while publication awaits SQLite.
|
||||
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||
for (const observer of item.observers) {
|
||||
@@ -558,14 +576,14 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
}
|
||||
function sourceInputs(
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
uids: string[],
|
||||
kinds: CatalogSource['kind'][],
|
||||
range?: {
|
||||
from: string;
|
||||
to: string;
|
||||
ticker?: string;
|
||||
tickers?: Record<string, string>;
|
||||
to: string;
|
||||
},
|
||||
): CatalogSource[] {
|
||||
return kinds.flatMap((kind) =>
|
||||
@@ -573,20 +591,22 @@ function sourceInputs(
|
||||
instrumentType,
|
||||
uid,
|
||||
kind,
|
||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||
: {}),
|
||||
...(['coupons', 'dividends'].includes(kind)
|
||||
? {
|
||||
rangeKey: range
|
||||
? JSON.stringify([range.from, range.to])
|
||||
: String(new Date().getUTCFullYear()),
|
||||
}
|
||||
: {}),
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ?
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
{ ticker: range?.tickers?.[uid] || range?.ticker } :
|
||||
{}),
|
||||
...(['coupons', 'dividends'].includes(kind) ?
|
||||
{
|
||||
rangeKey: range ?
|
||||
JSON.stringify([range.from, range.to]) :
|
||||
String(new Date().getUTCFullYear()),
|
||||
} :
|
||||
{}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
function publicInstrument(value: TbankShare | TbankBond) {
|
||||
function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
return {
|
||||
uid: value.uid,
|
||||
@@ -625,38 +645,39 @@ const previousCloseLookbackMs =
|
||||
const payoutHistoryYears = 5;
|
||||
const payoutFutureYears = 2;
|
||||
export type CatalogPayload = {
|
||||
instrument?: TbankShare | TbankBond | null;
|
||||
price?: TbankLastPrice | null;
|
||||
candle?: TbankCandle | null;
|
||||
tradingStatus?: string | null;
|
||||
description?: string | null;
|
||||
fundamentals?: TbankFundamentals | null;
|
||||
coupons?: TbankCoupon[];
|
||||
dividends?: TbankDividend[];
|
||||
bondEvents?: MoexBondEvent[];
|
||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||
candle?: null | TbankCandle;
|
||||
coupons?: TbankCoupon[];
|
||||
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||
description?: null | string;
|
||||
dividends?: TbankDividend[];
|
||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||
fundamentals?: null | TbankFundamentals;
|
||||
instrument?: null | TbankBond | TbankShare;
|
||||
price?: null | TbankLastPrice;
|
||||
tradingStatus?: null | string;
|
||||
};
|
||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
if (
|
||||
[
|
||||
'last-price',
|
||||
'bond-current',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'yield',
|
||||
].includes(kind)
|
||||
)
|
||||
) {
|
||||
return 'quote';
|
||||
}
|
||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar';
|
||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
||||
return 'metadata';
|
||||
}
|
||||
function producerKey(input: CatalogSource) {
|
||||
return input.instrumentType === 'bond' &&
|
||||
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||
? `bond-metadata:${input.uid}`
|
||||
: catalogScope(input);
|
||||
(input.kind === 'detail' || input.kind === 'bond-current') ?
|
||||
`bond-metadata:${input.uid}` :
|
||||
catalogScope(input);
|
||||
}
|
||||
|
||||
const quotationScale = 9;
|
||||
|
||||
@@ -5,13 +5,13 @@ export const catalogSearchLimit = 30;
|
||||
export function selectCatalogInstruments<T extends TbankShare>(
|
||||
found: T[],
|
||||
query: string,
|
||||
instrumentType: 'share' | 'bond',
|
||||
instrumentType: 'bond' | 'share',
|
||||
): (T & { uid: string })[] {
|
||||
const seen = new Set<string>();
|
||||
const kind =
|
||||
instrumentType === 'share'
|
||||
? 'INSTRUMENT_TYPE_SHARE'
|
||||
: 'INSTRUMENT_TYPE_BOND';
|
||||
instrumentType === 'share' ?
|
||||
'INSTRUMENT_TYPE_SHARE' :
|
||||
'INSTRUMENT_TYPE_BOND';
|
||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||
return found
|
||||
.filter(
|
||||
@@ -21,17 +21,17 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.apiTradeAvailableFlag === true) -
|
||||
Number(a.apiTradeAvailableFlag === true) ||
|
||||
Number(a.apiTradeAvailableFlag === true) ||
|
||||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
|
||||
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
||||
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
||||
Number(b.classCode === preferredClass) -
|
||||
Number(a.classCode === preferredClass) ||
|
||||
Number(a.classCode === preferredClass) ||
|
||||
a.uid.localeCompare(b.uid),
|
||||
)
|
||||
.filter((instrument) => {
|
||||
const key = instrument.isin?.trim()
|
||||
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||
: `uid:${instrument.uid}`;
|
||||
const key = instrument.isin?.trim() ?
|
||||
`isin:${instrument.isin.trim().toUpperCase()}` :
|
||||
`uid:${instrument.uid}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
|
||||
const MOSCOW_OFFSET_MS = 10_800_000;
|
||||
export function timestamp(value?: TbankTimestamp): string | null {
|
||||
export function timestamp(value?: TbankTimestamp): null | string {
|
||||
const seconds = Number(value?.seconds);
|
||||
return Number.isFinite(seconds) && seconds > 0
|
||||
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
|
||||
: null;
|
||||
return Number.isFinite(seconds) && seconds > 0 ?
|
||||
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
|
||||
null;
|
||||
}
|
||||
export function moscowDay(iso: string): string {
|
||||
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
||||
@@ -19,7 +20,7 @@ export function moscowDay(iso: string): string {
|
||||
export function previousClose(
|
||||
candles: TbankCandle[],
|
||||
priceAt: string,
|
||||
): TbankCandle | null {
|
||||
): null | TbankCandle {
|
||||
return (
|
||||
candles
|
||||
.filter((candle) => {
|
||||
@@ -32,6 +33,6 @@ export function previousClose(
|
||||
);
|
||||
})
|
||||
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
|
||||
null
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../common/decimal';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import {
|
||||
PortfolioDto,
|
||||
@@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6;
|
||||
export function instrumentPosition(
|
||||
portfolio: PortfolioDto,
|
||||
uid: string,
|
||||
type: 'STOCK' | 'BOND',
|
||||
type: 'BOND' | 'STOCK',
|
||||
): StockPositionDto {
|
||||
const positions = portfolio.positions.filter(
|
||||
(p) => p.type === type && p.instrumentUid === uid,
|
||||
@@ -40,8 +39,8 @@ export function instrumentPosition(
|
||||
currentValue,
|
||||
absoluteReturn,
|
||||
averagePurchasePrice:
|
||||
positive && totalCost
|
||||
? {
|
||||
positive && totalCost ?
|
||||
{
|
||||
currency: totalCost.currency,
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
@@ -50,15 +49,15 @@ export function instrumentPosition(
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
||||
accounts: positions.map((p) => ({
|
||||
...p,
|
||||
returnPercent:
|
||||
decimalFromString(p.quantity).value > 0n
|
||||
? percent(p.absoluteReturn, cost(p))
|
||||
: null,
|
||||
decimalFromString(p.quantity).value > 0n ?
|
||||
percent(p.absoluteReturn, cost(p)) :
|
||||
null,
|
||||
})),
|
||||
isStale: portfolio.isStale,
|
||||
observedAt: portfolio.lastSuccessfulAt,
|
||||
@@ -66,8 +65,8 @@ export function instrumentPosition(
|
||||
}
|
||||
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||
const price = position.averagePurchasePrice;
|
||||
return price
|
||||
? {
|
||||
return price ?
|
||||
{
|
||||
currency: price.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
@@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||
decimalFromString(position.quantity),
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
}
|
||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||
const currency = values[0]?.currency;
|
||||
if (
|
||||
!currency ||
|
||||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
currency,
|
||||
value: formatDecimal(
|
||||
@@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||
function percent(
|
||||
result: MoneyDto | null,
|
||||
base: MoneyDto | null,
|
||||
): string | null {
|
||||
): null | string {
|
||||
if (
|
||||
!base ||
|
||||
!result ||
|
||||
base.currency !== result.currency ||
|
||||
decimalFromString(base.value).value <= 0n
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return formatDecimal(
|
||||
divide(
|
||||
multiply(decimalFromString(result.value), decimalFromString('100')),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
BondDto,
|
||||
@@ -32,23 +34,24 @@ export class BondCatalogService {
|
||||
data:
|
||||
result.data?.map((item) => {
|
||||
const short = toBond(item);
|
||||
const detail = item.detail
|
||||
? toBond({ ...item.detail, uid: item.uid })
|
||||
: short;
|
||||
const detail = item.detail ?
|
||||
toBond({ ...item.detail, uid: item.uid }) :
|
||||
short;
|
||||
const last = item.price;
|
||||
return {
|
||||
...detail,
|
||||
classCode: detail.classCode ?? short.classCode,
|
||||
isin: detail.isin ?? short.isin,
|
||||
apiTradeAvailable: short.apiTradeAvailable,
|
||||
pricePercent: last?.price
|
||||
? formatDecimal(decimal(last.price))
|
||||
: null,
|
||||
pricePercent: last?.price ?
|
||||
formatDecimal(decimal(last.price)) :
|
||||
null,
|
||||
priceAt: timestamp(last?.time),
|
||||
};
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -57,11 +60,12 @@ export class BondCatalogService {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
data: result.data ?
|
||||
{ ...toBond(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
@@ -75,9 +79,9 @@ export class BondCatalogService {
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
data: result.data ?
|
||||
{ ...toBond(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
divide,
|
||||
} from '../../../../../common/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankCoupon,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
PortfolioService,
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -20,14 +21,14 @@ import {
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
import {
|
||||
BondCouponsDto,
|
||||
BondCouponsResourceDto,
|
||||
BondPositionDto,
|
||||
BondMetricsDto,
|
||||
BondMetricsResourceDto,
|
||||
BondPositionDto,
|
||||
} from '../presentation/bond.dto';
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
const INCOME_HISTORY_YEARS = 5;
|
||||
@@ -40,6 +41,7 @@ export class BondIncomeService {
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly forecasts: ForecastService,
|
||||
) {}
|
||||
|
||||
async metrics(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -53,9 +55,9 @@ export class BondIncomeService {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
data: result.values.instrument ?
|
||||
bondMetrics(result.values, result.unavailable, result.observedAt) :
|
||||
null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
@@ -67,6 +69,7 @@ export class BondIncomeService {
|
||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||
};
|
||||
}
|
||||
|
||||
async coupons(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -85,21 +88,22 @@ export class BondIncomeService {
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.coupons;
|
||||
const position = portfolio.data
|
||||
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||
: null;
|
||||
const projected = events
|
||||
? bondCoupons(
|
||||
const position = portfolio.data ?
|
||||
instrumentPosition(portfolio.data, uid, 'BOND') :
|
||||
null;
|
||||
const projected = events ?
|
||||
bondCoupons(
|
||||
events,
|
||||
position,
|
||||
{ from, to, today },
|
||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
!portfolio.data,
|
||||
)
|
||||
: undefined;
|
||||
) :
|
||||
undefined;
|
||||
const result = await this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
@@ -110,9 +114,9 @@ export class BondIncomeService {
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
return result.data || !projected ?
|
||||
result :
|
||||
{
|
||||
data: bondCoupons(
|
||||
events ?? [],
|
||||
null,
|
||||
@@ -135,19 +139,19 @@ export function bondCoupons(
|
||||
const { from, to, today } = window;
|
||||
const quantity =
|
||||
position?.accounts.length &&
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
||||
? decimalFromString(position.quantity)
|
||||
: null;
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
|
||||
decimalFromString(position.quantity) :
|
||||
null;
|
||||
const coupons = events
|
||||
.map((event) => {
|
||||
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
|
||||
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
|
||||
const perBond = money(event.payOneBond);
|
||||
const timing = !paymentDate
|
||||
? ('undated' as const)
|
||||
: paymentDate >= today
|
||||
? ('upcoming' as const)
|
||||
: ('past' as const);
|
||||
const timing = !paymentDate ?
|
||||
('undated' as const) :
|
||||
paymentDate >= today ?
|
||||
('upcoming' as const) :
|
||||
('past' as const);
|
||||
return {
|
||||
number: event.couponNumber ?? null,
|
||||
paymentDate,
|
||||
@@ -161,14 +165,14 @@ export function bondCoupons(
|
||||
perBond &&
|
||||
timing === 'upcoming' &&
|
||||
recordDate &&
|
||||
recordDate >= today
|
||||
? {
|
||||
recordDate >= today ?
|
||||
{
|
||||
currency: perBond.currency,
|
||||
value: formatDecimal(
|
||||
multiply(quantity, decimalFromString(perBond.value)),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||
@@ -214,8 +218,8 @@ export function bondMetrics(
|
||||
clean &&
|
||||
coupon.currency === clean.currency &&
|
||||
decimalFromString(coupon.value).value >= 0n &&
|
||||
decimalFromString(clean.value).value > 0n
|
||||
? formatDecimal(
|
||||
decimalFromString(clean.value).value > 0n ?
|
||||
formatDecimal(
|
||||
divide(
|
||||
multiply(
|
||||
decimalFromString(coupon.value),
|
||||
@@ -227,14 +231,14 @@ export function bondMetrics(
|
||||
),
|
||||
COUPON_YIELD_DECIMAL_PLACES,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
) :
|
||||
null;
|
||||
const couponType =
|
||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
|
||||
? next.couponType
|
||||
: instrument.floatingCouponFlag === true
|
||||
? 'COUPON_TYPE_FLOATING'
|
||||
: null;
|
||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
|
||||
next.couponType :
|
||||
instrument.floatingCouponFlag === true ?
|
||||
'COUPON_TYPE_FLOATING' :
|
||||
null;
|
||||
return {
|
||||
creditRating,
|
||||
coupon,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -14,15 +11,19 @@ import {
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
import {
|
||||
BondCandlesQueryDto,
|
||||
BondQuoteDto,
|
||||
BondQuoteResourceDto,
|
||||
} from '../presentation/bond.dto';
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const PRICE_DECIMAL_PLACES = 9;
|
||||
@Injectable()
|
||||
export class BondMarketService {
|
||||
@@ -30,6 +31,7 @@ export class BondMarketService {
|
||||
private readonly history: CandleService,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -43,12 +45,13 @@ export class BondMarketService {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
data: result.values.instrument ?
|
||||
bondQuote(result.values, result.unavailable, result.observedAt) :
|
||||
null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
candles(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -77,8 +80,8 @@ export function bondQuote(
|
||||
const cleanPrice =
|
||||
pricePercent !== null &&
|
||||
nominal &&
|
||||
decimalFromString(nominal.value).value > 0n
|
||||
? {
|
||||
decimalFromString(nominal.value).value > 0n ?
|
||||
{
|
||||
currency: nominal.currency,
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
@@ -90,11 +93,11 @@ export function bondQuote(
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
const dirtyPrice =
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||
? {
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency ?
|
||||
{
|
||||
currency: cleanPrice.currency,
|
||||
value: formatDecimal(
|
||||
add(
|
||||
@@ -102,8 +105,8 @@ export function bondQuote(
|
||||
decimalFromString(aci.value),
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
return {
|
||||
pricePercent,
|
||||
cleanPrice,
|
||||
@@ -112,9 +115,9 @@ export function bondQuote(
|
||||
priceAt,
|
||||
tradingStatus,
|
||||
changePercentagePoints:
|
||||
last?.price && previous?.close
|
||||
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||
: null,
|
||||
last?.price && previous?.close ?
|
||||
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
|
||||
null,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||
unavailable,
|
||||
observedAt,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
@@ -122,9 +123,9 @@ describe('bond financial projections', () => {
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond:
|
||||
scenario === 'unknown'
|
||||
? undefined
|
||||
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
scenario === 'unknown' ?
|
||||
undefined :
|
||||
money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export function money(value?: TbankMoney) {
|
||||
const currency = value?.currency?.toUpperCase();
|
||||
return value && currency
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
return value && currency ?
|
||||
{ currency, value: formatDecimal(decimal(value)) } :
|
||||
null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -9,24 +10,25 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
import { BondIncomeService } from '../application/bond-income.service';
|
||||
import { BondMarketService } from '../application/bond-market.service';
|
||||
import {
|
||||
BondCandlesResourceDto,
|
||||
BondCandlesQueryDto,
|
||||
BondCandlesResourceDto,
|
||||
BondCouponsResourceDto,
|
||||
BondResourceDto,
|
||||
BondMetricsResourceDto,
|
||||
BondPositionResourceDto,
|
||||
BondQuoteResourceDto,
|
||||
BondResourceDto,
|
||||
BondSearchQueryDto,
|
||||
BondSearchResourceDto,
|
||||
BondTickerParamsDto,
|
||||
} from './bond.dto';
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
|
||||
@ApiTags('bonds')
|
||||
@ApiProblemResponses()
|
||||
@@ -39,6 +41,7 @@ export class BondController {
|
||||
private readonly market: BondMarketService,
|
||||
private readonly income: BondIncomeService,
|
||||
) {}
|
||||
|
||||
@Get(':uid/metrics')
|
||||
@ApiOkResponse({ type: BondMetricsResourceDto })
|
||||
metrics(
|
||||
@@ -48,6 +51,7 @@ export class BondController {
|
||||
) {
|
||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: BondSearchResourceDto })
|
||||
search(
|
||||
@@ -60,6 +64,7 @@ export class BondController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-ticker/:ticker')
|
||||
@ApiOkResponse({ type: BondResourceDto })
|
||||
getByTicker(
|
||||
@@ -73,6 +78,7 @@ export class BondController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid')
|
||||
@ApiOkResponse({ type: BondResourceDto })
|
||||
get(
|
||||
@@ -82,6 +88,7 @@ export class BondController {
|
||||
) {
|
||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@ApiOkResponse({ type: BondQuoteResourceDto })
|
||||
quote(
|
||||
@@ -91,6 +98,7 @@ export class BondController {
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@ApiOkResponse({ type: BondCandlesResourceDto })
|
||||
candles(
|
||||
@@ -105,6 +113,7 @@ export class BondController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid/position')
|
||||
@ApiOkResponse({ type: BondPositionResourceDto })
|
||||
position(
|
||||
@@ -118,6 +127,7 @@ export class BondController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid/coupons')
|
||||
@ApiOkResponse({ type: BondCouponsResourceDto })
|
||||
coupons(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import {
|
||||
StockCandlesDto,
|
||||
StockCandlesQueryDto,
|
||||
@@ -20,44 +20,61 @@ export class BondDto {
|
||||
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
|
||||
})
|
||||
declare slug: string;
|
||||
|
||||
@ApiProperty() declare name: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare isin: null | string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare classCode:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare realExchange:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
|
||||
boolean | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare ticker: null | string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare logoName:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare currency:
|
||||
string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare sector: null | string;
|
||||
@ApiProperty({ type: Number, nullable: true }) declare lot: null | number;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare historyFrom: string | null;
|
||||
declare historyFrom: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare nominal: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare initialNominal: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare maturityDate:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare callDate:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare bondType:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare riskLevel:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear:
|
||||
number | null;
|
||||
null | number;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon:
|
||||
boolean | null;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare perpetual:
|
||||
boolean | null;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare amortization:
|
||||
boolean | null;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare subordinated:
|
||||
boolean | null;
|
||||
}
|
||||
@@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto {
|
||||
nullable: true,
|
||||
description: 'Clean price in percent of nominal, not money.',
|
||||
})
|
||||
declare pricePercent: string | null;
|
||||
declare pricePercent: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare priceAt: string | null;
|
||||
declare priceAt: null | string;
|
||||
}
|
||||
export class BondQuoteDto {
|
||||
@ApiProperty({ type: String, nullable: true }) declare pricePercent:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({
|
||||
type: MoneyDto,
|
||||
nullable: true,
|
||||
@@ -82,6 +101,7 @@ export class BondQuoteDto {
|
||||
'Indicative clean value at the current nominal; no FX conversion.',
|
||||
})
|
||||
declare cleanPrice: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
|
||||
@ApiProperty({
|
||||
type: MoneyDto,
|
||||
@@ -90,41 +110,53 @@ export class BondQuoteDto {
|
||||
'Indicative clean value plus current accrued interest, excluding fees.',
|
||||
})
|
||||
declare dirtyPrice: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare changePercentagePoints: string | null;
|
||||
declare changePercentagePoints: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare previousCloseDate:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare priceAt: string | null;
|
||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||
declare priceAt: null | string;
|
||||
|
||||
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
export class BondCouponDto {
|
||||
@ApiProperty({ type: String, nullable: true }) declare number: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare number: null | string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare paymentDate:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare recordDate:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare couponType:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true }) declare periodDays:
|
||||
number | null;
|
||||
null | number;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare perBond: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare estimatedAmount: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
|
||||
'upcoming' | 'past' | 'undated';
|
||||
'past' | 'undated' | 'upcoming';
|
||||
}
|
||||
export class BondCouponsDto {
|
||||
@ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[];
|
||||
@ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[];
|
||||
@ApiProperty() declare from: string;
|
||||
@ApiProperty() declare to: string;
|
||||
@ApiProperty() declare observedAt: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare positionAt:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty() declare estimateUnavailable: boolean;
|
||||
@ApiProperty() declare positionIsStale: boolean;
|
||||
}
|
||||
@@ -132,6 +164,7 @@ export class BondCouponsDto {
|
||||
export class BondYieldDto {
|
||||
@ApiProperty({ description: 'MOEX effective annual yield in percent.' })
|
||||
declare value: string;
|
||||
|
||||
@ApiProperty() declare pricePercent: string;
|
||||
@ApiProperty({ format: 'date' }) declare date: string;
|
||||
@ApiProperty({
|
||||
@@ -139,14 +172,16 @@ export class BondYieldDto {
|
||||
nullable: true,
|
||||
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
|
||||
})
|
||||
declare dateType: string | null;
|
||||
declare dateType: null | string;
|
||||
|
||||
@ApiProperty({ format: 'date-time' }) declare tradeAt: string;
|
||||
}
|
||||
export class BondCreditRatingDto {
|
||||
@ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR';
|
||||
@ApiProperty({ type: String, nullable: true }) declare value: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare value: null | string;
|
||||
@ApiProperty({ enum: ['active', 'withdrawn'] }) declare status:
|
||||
'active' | 'withdrawn';
|
||||
|
||||
@ApiProperty({ format: 'date' }) declare date: string;
|
||||
@ApiProperty({ format: 'uri' }) declare sourceUrl: string;
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
@@ -154,39 +189,50 @@ export class BondCreditRatingDto {
|
||||
export class BondMetricsDto {
|
||||
@ApiProperty({ type: BondCreditRatingDto, nullable: true })
|
||||
declare creditRating: BondCreditRatingDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare coupon: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare couponDate: string | null;
|
||||
declare couponDate: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare couponType:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays:
|
||||
number | null;
|
||||
null | number;
|
||||
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description:
|
||||
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.',
|
||||
})
|
||||
declare couponYieldPercent: string | null;
|
||||
declare couponYieldPercent: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare couponYieldPriceAt: string | null;
|
||||
declare couponYieldPriceAt: null | string;
|
||||
|
||||
@ApiProperty({ type: BondYieldDto, nullable: true })
|
||||
declare effectiveYield: BondYieldDto | null;
|
||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||
|
||||
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
|
||||
export class BondPositionResourceDto {
|
||||
@ApiProperty({ type: BondPositionDto, nullable: true })
|
||||
declare data: BondPositionDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class BondSearchResourceDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
|
||||
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
|
||||
declare data: BondSearchResultDto[] | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -200,6 +246,7 @@ export class BondResourceDto {
|
||||
export class BondQuoteResourceDto {
|
||||
@ApiProperty({ type: BondQuoteDto, nullable: true })
|
||||
declare data: BondQuoteDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -207,6 +254,7 @@ export class BondQuoteResourceDto {
|
||||
export class BondMetricsResourceDto {
|
||||
@ApiProperty({ type: BondMetricsDto, nullable: true })
|
||||
declare data: BondMetricsDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -214,6 +262,7 @@ export class BondMetricsResourceDto {
|
||||
export class BondCandlesResourceDto {
|
||||
@ApiProperty({ type: BondCandlesDto, nullable: true })
|
||||
declare data: BondCandlesDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
@@ -221,6 +270,7 @@ export class BondCandlesResourceDto {
|
||||
export class BondCouponsResourceDto {
|
||||
@ApiProperty({ type: BondCouponsDto, nullable: true })
|
||||
declare data: BondCouponsDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => {
|
||||
expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual(
|
||||
365 * 86400000,
|
||||
);
|
||||
if (index) expect(window.from).toEqual(windows[index - 1].to);
|
||||
expect(window.from).toEqual(index ? windows[index - 1].to : range.from);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
|
||||
export type CandleRange = { from: Date; to: Date };
|
||||
export type CandleCoverage = CandleRange & {
|
||||
isComplete: boolean;
|
||||
fetchedAt: Date;
|
||||
isComplete: boolean;
|
||||
};
|
||||
export type SavedCandle = {
|
||||
time: string;
|
||||
close: string;
|
||||
isComplete: boolean;
|
||||
time: string;
|
||||
};
|
||||
const windowDays = 365;
|
||||
const tailFreshForMs = 60_000;
|
||||
@@ -30,14 +30,15 @@ export function candleGaps(
|
||||
!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.from > gap.from ?
|
||||
[{ from: gap.from, to: covered.from }] :
|
||||
[]),
|
||||
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
||||
];
|
||||
});
|
||||
@@ -73,11 +74,14 @@ export function confirmedCandleCoverage(
|
||||
for (const boundary of [
|
||||
midnight,
|
||||
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
|
||||
])
|
||||
if (boundary > range.from.valueOf() && boundary < range.to.valueOf())
|
||||
]) {
|
||||
if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) {
|
||||
boundaries.add(boundary);
|
||||
}
|
||||
}
|
||||
const sorted = [...boundaries].sort((a, b) => a - b);
|
||||
return sorted.slice(1).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),
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
confirmedCandleCoverage,
|
||||
type CandleRange,
|
||||
confirmedCandleCoverage,
|
||||
type SavedCandle,
|
||||
} from '../domain/candle-coverage';
|
||||
|
||||
const provider = 'tbank';
|
||||
const interval = 'day';
|
||||
type CandleIntent = {
|
||||
instrumentType?: 'share' | 'bond';
|
||||
uid: string;
|
||||
ranges: CandleRange[];
|
||||
period?: string;
|
||||
now?: string;
|
||||
force?: boolean;
|
||||
instrumentType?: 'bond' | 'share';
|
||||
now?: string;
|
||||
period?: string;
|
||||
ranges: CandleRange[];
|
||||
uid: string;
|
||||
};
|
||||
@Injectable()
|
||||
export class CandleRepository {
|
||||
@@ -57,7 +58,7 @@ export class CandleRepository {
|
||||
}
|
||||
|
||||
reserve(
|
||||
owner: { userId: string; revision: string },
|
||||
owner: { revision: string; userId: string },
|
||||
scopeKey: string,
|
||||
intent: CandleIntent,
|
||||
runId: string,
|
||||
@@ -88,29 +89,31 @@ export class CandleRepository {
|
||||
(!intent.force ||
|
||||
(previousIntent.force === true &&
|
||||
(!intent.now || intent.now <= previousNow)))
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
...previousIntent,
|
||||
...(intent.force
|
||||
? {
|
||||
...(intent.force ?
|
||||
{
|
||||
force: true,
|
||||
...(intent.now && intent.now > previousNow
|
||||
? { now: intent.now }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(intent.now && intent.now > previousNow ?
|
||||
{ now: intent.now } :
|
||||
{}),
|
||||
} :
|
||||
{}),
|
||||
},
|
||||
},
|
||||
});
|
||||
return { work, launch: false };
|
||||
}
|
||||
if (previous)
|
||||
if (previous) {
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId: runId,
|
||||
@@ -142,12 +145,15 @@ export class CandleRepository {
|
||||
fetchedAt: Date,
|
||||
) {
|
||||
// Reject malformed financial input instead of publishing a false empty range.
|
||||
for (const candle of candles)
|
||||
for (const candle of candles) {
|
||||
if (
|
||||
!Number.isFinite(Date.parse(candle.time)) ||
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
|
||||
!/^-?\d+(?:\.\d+)?$/.test(candle.close)
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid candle');
|
||||
}
|
||||
}
|
||||
const key = `${range.from.toISOString()}/${range.to.toISOString()}`;
|
||||
const rows = [
|
||||
...new Map(
|
||||
@@ -155,7 +161,7 @@ export class CandleRepository {
|
||||
.filter(
|
||||
(candle) =>
|
||||
new Date(candle.time) >= range.from &&
|
||||
new Date(candle.time) < range.to,
|
||||
new Date(candle.time) < range.to,
|
||||
)
|
||||
.map((candle) => [candle.time, candle]),
|
||||
).values(),
|
||||
@@ -222,8 +228,9 @@ export class CandleRepository {
|
||||
`${range.from.toISOString()}/${range.to.toISOString()}`,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error('Incomplete candle reconciliation');
|
||||
}
|
||||
const identity = { provider, instrumentUid: intent.uid, interval };
|
||||
for (const range of intent.ranges) {
|
||||
const row = staged.find(
|
||||
@@ -254,21 +261,22 @@ export class CandleRepository {
|
||||
// Preserve coverage outside the requested reconciliation boundaries.
|
||||
for (const previous of old) {
|
||||
for (const part of [
|
||||
...(previous.from < range.from
|
||||
? [{ from: previous.from, to: range.from }]
|
||||
: []),
|
||||
...(previous.to > range.to
|
||||
? [{ from: range.to, to: previous.to }]
|
||||
: []),
|
||||
])
|
||||
...(previous.from < range.from ?
|
||||
[{ from: previous.from, to: range.from }] :
|
||||
[]),
|
||||
...(previous.to > range.to ?
|
||||
[{ from: range.to, to: previous.to }] :
|
||||
[]),
|
||||
]) {
|
||||
await tx.tbankCandleCoverage.create({
|
||||
data: { ...previous, ...part },
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.tbankCandle.deleteMany({
|
||||
where: { ...identity, time: { gte: range.from, lt: range.to } },
|
||||
});
|
||||
for (const candle of payload.candles)
|
||||
for (const candle of payload.candles) {
|
||||
await tx.tbankCandle.create({
|
||||
data: {
|
||||
...identity,
|
||||
@@ -279,14 +287,16 @@ export class CandleRepository {
|
||||
fetchedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const covered of confirmedCandleCoverage(
|
||||
range,
|
||||
payload.candles,
|
||||
fetchedAt,
|
||||
))
|
||||
)) {
|
||||
await tx.tbankCandleCoverage.create({
|
||||
data: { ...identity, ...covered },
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.tbankSyncStage.deleteMany({
|
||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||
|
||||
+61
-56
@@ -1,40 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
export type CatalogQuery = {
|
||||
instrumentType: 'share' | 'bond';
|
||||
query: string;
|
||||
exactTicker?: boolean;
|
||||
instrumentType: 'bond' | 'share';
|
||||
query: string;
|
||||
};
|
||||
export type CatalogSource = {
|
||||
instrumentType: 'share' | 'bond';
|
||||
uid: string;
|
||||
instrumentType: 'bond' | 'share';
|
||||
kind:
|
||||
| 'detail'
|
||||
| 'last-price'
|
||||
| 'bond-current'
|
||||
| 'description'
|
||||
| 'fundamentals'
|
||||
| 'trading-status'
|
||||
| 'comparison'
|
||||
| 'coupons'
|
||||
| 'yield'
|
||||
| 'rating'
|
||||
| 'dividends'
|
||||
| 'bond-events';
|
||||
| 'bond-current' |
|
||||
'bond-events' |
|
||||
'comparison' |
|
||||
'coupons' |
|
||||
'description' |
|
||||
'detail' |
|
||||
'dividends' |
|
||||
'fundamentals' |
|
||||
'last-price' |
|
||||
'rating' |
|
||||
'trading-status' |
|
||||
'yield';
|
||||
rangeKey?: string;
|
||||
ticker?: string;
|
||||
uid: string;
|
||||
};
|
||||
export type CatalogWork = CatalogQuery | CatalogSource;
|
||||
export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string };
|
||||
export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string };
|
||||
const provider = 'tbank';
|
||||
export function catalogQuery(input: CatalogQuery): CatalogQuery {
|
||||
return {
|
||||
@@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery {
|
||||
};
|
||||
}
|
||||
export function catalogScope(input: CatalogWork) {
|
||||
if ('uid' in input)
|
||||
if ('uid' in input) {
|
||||
return `catalog-source:${JSON.stringify([
|
||||
input.kind,
|
||||
input.kind === 'detail' ? input.instrumentType : null,
|
||||
input.rangeKey ?? '',
|
||||
input.uid,
|
||||
])}`;
|
||||
}
|
||||
return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`;
|
||||
}
|
||||
|
||||
@@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository {
|
||||
|
||||
read(input: CatalogQuery) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const alias = input.exactTicker
|
||||
? await tx.tbankInstrumentLookup.findUnique({
|
||||
const alias = input.exactTicker ?
|
||||
await tx.tbankInstrumentLookup.findUnique({
|
||||
where: {
|
||||
provider_identifierType_identifier: {
|
||||
provider,
|
||||
@@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository {
|
||||
identifier: input.query,
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const discovery = input.exactTicker
|
||||
? alias
|
||||
? {
|
||||
}) :
|
||||
null;
|
||||
const discovery = input.exactTicker ?
|
||||
alias ?
|
||||
{
|
||||
instrumentUids: alias.matchedUids,
|
||||
fetchedAt: alias.fetchedAt,
|
||||
dataRevision: alias.dataRevision,
|
||||
}
|
||||
: null
|
||||
: await tx.tbankCatalogDiscovery.findUnique({
|
||||
} :
|
||||
null :
|
||||
await tx.tbankCatalogDiscovery.findUnique({
|
||||
where: {
|
||||
provider_instrumentType_query: {
|
||||
provider,
|
||||
@@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository {
|
||||
});
|
||||
const byUid = new Map(rows.map((row) => [row.instrumentUid, row]));
|
||||
// A damaged/missing dependency is unknown, never a confirmed empty search.
|
||||
if (uids.some((uid) => !byUid.has(uid)))
|
||||
if (uids.some((uid) => !byUid.has(uid))) {
|
||||
return {
|
||||
data: null,
|
||||
fetchedAt: null,
|
||||
dataRevision: String(discovery.dataRevision),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument),
|
||||
fetchedAt: discovery.fetchedAt,
|
||||
@@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository {
|
||||
kind: input.kind,
|
||||
source: sourceProvider(input),
|
||||
rangeKey:
|
||||
input.kind === 'detail'
|
||||
? input.instrumentType
|
||||
: (input.rangeKey ?? ''),
|
||||
input.kind === 'detail' ?
|
||||
input.instrumentType :
|
||||
(input.rangeKey ?? ''),
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository {
|
||||
},
|
||||
});
|
||||
const instrument = (
|
||||
value as { instrument?: TbankShare | TbankBond | null }
|
||||
value as { instrument?: null | TbankBond | TbankShare }
|
||||
).instrument;
|
||||
if (
|
||||
(input.kind === 'detail' || input.kind === 'bond-current') &&
|
||||
@@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository {
|
||||
increment: isDeepStrictEqual(
|
||||
previousAlias?.matchedUids,
|
||||
matchedUids,
|
||||
)
|
||||
? 0
|
||||
: 1,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository {
|
||||
source: sourceProvider(input),
|
||||
kind: input.kind,
|
||||
rangeKey:
|
||||
input.kind === 'detail'
|
||||
? input.instrumentType
|
||||
: (input.rangeKey ?? ''),
|
||||
input.kind === 'detail' ?
|
||||
input.instrumentType :
|
||||
(input.rangeKey ?? ''),
|
||||
};
|
||||
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||
@@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository {
|
||||
}
|
||||
|
||||
reserve(
|
||||
owner: { userId: string; revision: string },
|
||||
owner: { revision: string; userId: string },
|
||||
input: CatalogWork,
|
||||
runId: string,
|
||||
active: ReadonlySet<string>,
|
||||
@@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
processRunId: runId,
|
||||
@@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository {
|
||||
instrumentKind: instrument.instrumentKind,
|
||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||
currency: instrument.currency,
|
||||
brand: instrument.brand
|
||||
? { logoName: instrument.brand.logoName }
|
||||
: undefined,
|
||||
brand: instrument.brand ?
|
||||
{ logoName: instrument.brand.logoName } :
|
||||
undefined,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
await tx.tbankInstrument.upsert({
|
||||
@@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository {
|
||||
increment: isDeepStrictEqual(
|
||||
previous?.matchedUids,
|
||||
instrumentUids,
|
||||
)
|
||||
? 0
|
||||
: 1,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository {
|
||||
increment: isDeepStrictEqual(
|
||||
previous?.instrumentUids,
|
||||
instrumentUids,
|
||||
)
|
||||
? 0
|
||||
: 1,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository {
|
||||
}
|
||||
|
||||
function sourceProvider(input: CatalogSource) {
|
||||
return input.kind === 'yield' || input.kind === 'bond-events'
|
||||
? 'moex'
|
||||
: input.kind === 'rating'
|
||||
? 'ncr'
|
||||
: 'tbank';
|
||||
return input.kind === 'yield' || input.kind === 'bond-events' ?
|
||||
'moex' :
|
||||
input.kind === 'rating' ?
|
||||
'ncr' :
|
||||
'tbank';
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
export { BondIncomeService } from './bonds/application/bond-income.service';
|
||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { CandleService } from './application/candle.service';
|
||||
import { CandleRepository } from './infrastructure/candle.repository';
|
||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
import { CandleRepository } from './infrastructure/candle.repository';
|
||||
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { CandleService } from './application/candle.service';
|
||||
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
export type { CatalogPayload } from './application/catalog-enrichment.service';
|
||||
|
||||
export { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
|
||||
export type { CatalogPayload } from './application/catalog-enrichment.service';
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { InstrumentSourcesModule } from './instrument-sources.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||
import { BondIncomeService } from './bonds/application/bond-income.service';
|
||||
import { BondMarketService } from './bonds/application/bond-market.service';
|
||||
import { BondController } from './bonds/presentation/bond.controller';
|
||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||
import { InstrumentSourcesModule } from './instrument-sources.module';
|
||||
import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case';
|
||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||
import { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
import { StockMarketService } from './stocks/application/stock-market.service';
|
||||
import { StockController } from './stocks/presentation/stock.controller';
|
||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
|
||||
|
||||
+20
-17
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import {
|
||||
StockCompanyResourceDto,
|
||||
@@ -21,8 +23,8 @@ export class GetStockCompanyUseCase {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? {
|
||||
data: result.values.instrument ?
|
||||
{
|
||||
description: result.values.description ?? null,
|
||||
metrics: metrics(
|
||||
result.values.fundamentals ?? null,
|
||||
@@ -30,14 +32,14 @@ export class GetStockCompanyUseCase {
|
||||
),
|
||||
unavailable: result.unavailable,
|
||||
observedAt: result.observedAt,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
export function metrics(
|
||||
values: TbankFundamentals | null,
|
||||
values: null | TbankFundamentals,
|
||||
sector?: string,
|
||||
): StockMetricDto[] {
|
||||
if (!values) return [];
|
||||
@@ -49,30 +51,31 @@ export function metrics(
|
||||
][] = [
|
||||
['marketCapitalization', 'money', null],
|
||||
['peRatioTtm', 'ratio', 'TTM'],
|
||||
...(bank
|
||||
? ([
|
||||
...(bank ?
|
||||
([
|
||||
['priceToBookTtm', 'ratio', 'TTM'],
|
||||
['roe', 'percent', null],
|
||||
] as typeof selection)
|
||||
: ([
|
||||
] as typeof selection) :
|
||||
([
|
||||
['revenueTtm', 'money', 'TTM'],
|
||||
['ebitdaTtm', 'money', 'TTM'],
|
||||
] as typeof selection)),
|
||||
['netIncomeTtm', 'money', 'TTM'],
|
||||
...(!bank
|
||||
? ([
|
||||
...(!bank ?
|
||||
([
|
||||
['totalDebtMrq', 'money', 'MRQ'],
|
||||
['netDebtToEbitda', 'ratio', null],
|
||||
] as typeof selection)
|
||||
: []),
|
||||
] as typeof selection) :
|
||||
[]),
|
||||
];
|
||||
return selection.flatMap(([key, unit, period]) => {
|
||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
||||
const value = values[key];
|
||||
const currency = values.currency?.toUpperCase() || null;
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
(unit !== 'money' || currency)
|
||||
? [
|
||||
(unit !== 'money' || currency) ?
|
||||
[
|
||||
{
|
||||
key,
|
||||
value,
|
||||
@@ -80,7 +83,7 @@ export function metrics(
|
||||
period,
|
||||
currency: unit === 'money' ? currency : null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
] :
|
||||
[];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
StockDto,
|
||||
@@ -30,9 +32,9 @@ export class StockCatalogService {
|
||||
data:
|
||||
result.data?.map((item) => {
|
||||
const short = stock(item);
|
||||
const detail = item.detail
|
||||
? stock({ ...item.detail, uid: item.uid })
|
||||
: short;
|
||||
const detail = item.detail ?
|
||||
stock({ ...item.detail, uid: item.uid }) :
|
||||
short;
|
||||
const last = item.price;
|
||||
return {
|
||||
...detail,
|
||||
@@ -40,17 +42,18 @@ export class StockCatalogService {
|
||||
isin: detail.isin ?? short.isin,
|
||||
apiTradeAvailable: short.apiTradeAvailable,
|
||||
price:
|
||||
last?.price && detail.currency
|
||||
? {
|
||||
last?.price && detail.currency ?
|
||||
{
|
||||
currency: detail.currency,
|
||||
value: formatDecimal(decimal(last.price)),
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
priceAt: timestamp(last?.time),
|
||||
};
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -59,11 +62,12 @@ export class StockCatalogService {
|
||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...stock(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
data: result.data ?
|
||||
{ ...stock(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
@@ -77,9 +81,9 @@ export class StockCatalogService {
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...stock(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
data: result.data ?
|
||||
{ ...stock(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankDividend,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
PortfolioService,
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import {
|
||||
StockDividendDto,
|
||||
StockDividendsDto,
|
||||
StockDividendsResourceDto,
|
||||
StockPositionDto,
|
||||
StockDividendDto,
|
||||
} from '../presentation/stock.dto';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
const INCOME_HISTORY_YEARS = 5;
|
||||
@Injectable()
|
||||
@@ -40,6 +41,7 @@ export class StockIncomeService {
|
||||
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
|
||||
};
|
||||
}
|
||||
|
||||
async dividends(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -58,21 +60,22 @@ export class StockIncomeService {
|
||||
['detail', 'dividends'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.dividends;
|
||||
const position = portfolio.data
|
||||
? instrumentPosition(portfolio.data, uid, 'STOCK')
|
||||
: null;
|
||||
const projected = events
|
||||
? stockDividends(
|
||||
const position = portfolio.data ?
|
||||
instrumentPosition(portfolio.data, uid, 'STOCK') :
|
||||
null;
|
||||
const projected = events ?
|
||||
stockDividends(
|
||||
events,
|
||||
position,
|
||||
{ from, to, today },
|
||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
!portfolio.data,
|
||||
)
|
||||
: undefined;
|
||||
) :
|
||||
undefined;
|
||||
const result = await this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
@@ -83,9 +86,9 @@ export class StockIncomeService {
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
return result.data || !projected ?
|
||||
result :
|
||||
{
|
||||
data: stockDividends(
|
||||
events ?? [],
|
||||
null,
|
||||
@@ -100,7 +103,7 @@ export class StockIncomeService {
|
||||
|
||||
export function stockDividends(
|
||||
events: TbankDividend[],
|
||||
position: StockPositionDto | null,
|
||||
position: null | StockPositionDto,
|
||||
window: { from: string; to: string; today: string },
|
||||
observedAt: string,
|
||||
positionUnavailable: boolean,
|
||||
@@ -109,9 +112,9 @@ export function stockDividends(
|
||||
const quantity =
|
||||
position &&
|
||||
position.accounts.length &&
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
||||
? decimalFromString(position.quantity)
|
||||
: null;
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
|
||||
decimalFromString(position.quantity) :
|
||||
null;
|
||||
const dividends: StockDividendDto[] = events
|
||||
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
||||
.map((event) => {
|
||||
@@ -120,29 +123,29 @@ export function stockDividends(
|
||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||
const perShare =
|
||||
currency && event.dividendNet
|
||||
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
||||
: null;
|
||||
currency && event.dividendNet ?
|
||||
{ currency, value: formatDecimal(decimal(event.dividendNet)) } :
|
||||
null;
|
||||
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
||||
const eventDate = paymentDate ?? recordDate;
|
||||
const timing: StockDividendDto['timing'] = eventDate
|
||||
? eventDate >= today
|
||||
? 'upcoming'
|
||||
: 'past'
|
||||
: 'undated';
|
||||
const timing: StockDividendDto['timing'] = eventDate ?
|
||||
eventDate >= today ?
|
||||
'upcoming' :
|
||||
'past' :
|
||||
'undated';
|
||||
const estimatedAmount =
|
||||
quantity &&
|
||||
perShare &&
|
||||
recordDate &&
|
||||
recordDate >= today &&
|
||||
timing === 'upcoming'
|
||||
? {
|
||||
timing === 'upcoming' ?
|
||||
{
|
||||
currency: perShare.currency,
|
||||
value: formatDecimal(
|
||||
multiply(quantity, decimalFromString(perShare.value)),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
return {
|
||||
perShare,
|
||||
recordDate,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
@@ -12,6 +8,11 @@ import {
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
} from '../../application/catalog-enrichment.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
StockCandlesQueryDto,
|
||||
@@ -26,6 +27,7 @@ export class StockMarketService {
|
||||
private readonly history: CandleService,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -39,12 +41,13 @@ export class StockMarketService {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
? stockQuote(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
data: result.values.instrument ?
|
||||
stockQuote(result.values, result.unavailable, result.observedAt) :
|
||||
null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
candles(
|
||||
userId: string,
|
||||
uid: string,
|
||||
@@ -69,20 +72,20 @@ export function stockQuote(
|
||||
const priceAt = timestamp(last?.time);
|
||||
const currency = share?.currency?.toUpperCase();
|
||||
const price =
|
||||
last?.price && currency
|
||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
||||
: null;
|
||||
last?.price && currency ?
|
||||
{ currency, value: formatDecimal(decimal(last.price)) } :
|
||||
null;
|
||||
const previousPrice =
|
||||
previous?.close && currency
|
||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
||||
: null;
|
||||
previous?.close && currency ?
|
||||
{ currency, value: formatDecimal(decimal(previous.close)) } :
|
||||
null;
|
||||
const difference =
|
||||
price && previousPrice
|
||||
? subtract(
|
||||
price && previousPrice ?
|
||||
subtract(
|
||||
decimalFromString(price.value),
|
||||
decimalFromString(previousPrice.value),
|
||||
)
|
||||
: null;
|
||||
) :
|
||||
null;
|
||||
return {
|
||||
price,
|
||||
priceAt,
|
||||
@@ -90,21 +93,21 @@ export function stockQuote(
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||
previousClose: previousPrice,
|
||||
change:
|
||||
difference && currency
|
||||
? { currency, value: formatDecimal(difference) }
|
||||
: null,
|
||||
difference && currency ?
|
||||
{ currency, value: formatDecimal(difference) } :
|
||||
null,
|
||||
changePercent:
|
||||
difference &&
|
||||
previousPrice &&
|
||||
decimalFromString(previousPrice.value).value > 0n
|
||||
? formatDecimal(
|
||||
decimalFromString(previousPrice.value).value > 0n ?
|
||||
formatDecimal(
|
||||
divide(
|
||||
multiply(difference, decimalFromString('100')),
|
||||
decimalFromString(previousPrice.value),
|
||||
CHANGE_PERCENT_DECIMAL_PLACES,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
) :
|
||||
null,
|
||||
unavailable,
|
||||
observedAt,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { previousClose } from '../../application/instrument-market';
|
||||
import { metrics } from './get-stock-company.use-case';
|
||||
import { stockDividends } from './stock-income.service';
|
||||
import { stockQuote } from './stock-market.service';
|
||||
import { previousClose } from '../../application/instrument-market';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -9,25 +10,26 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||
import { StockIncomeService } from '../application/stock-income.service';
|
||||
import { StockMarketService } from '../application/stock-market.service';
|
||||
import {
|
||||
StockCandlesResourceDto,
|
||||
StockCandlesQueryDto,
|
||||
StockCandlesResourceDto,
|
||||
StockCompanyResourceDto,
|
||||
StockDividendsResourceDto,
|
||||
StockResourceDto,
|
||||
StockPositionResourceDto,
|
||||
StockQuoteResourceDto,
|
||||
StockResourceDto,
|
||||
StockSearchQueryDto,
|
||||
StockSearchResourceDto,
|
||||
StockTickerParamsDto,
|
||||
} from './stock.dto';
|
||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||
|
||||
@ApiTags('stocks')
|
||||
@ApiProblemResponses()
|
||||
@@ -41,6 +43,7 @@ export class StockController {
|
||||
private readonly income: StockIncomeService,
|
||||
private readonly companyInfo: GetStockCompanyUseCase,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: StockSearchResourceDto })
|
||||
search(
|
||||
@@ -53,6 +56,7 @@ export class StockController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get('by-ticker/:ticker')
|
||||
@ApiOkResponse({ type: StockResourceDto })
|
||||
getByTicker(
|
||||
@@ -66,6 +70,7 @@ export class StockController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid')
|
||||
@ApiOkResponse({ type: StockResourceDto })
|
||||
get(
|
||||
@@ -75,6 +80,7 @@ export class StockController {
|
||||
) {
|
||||
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@ApiOkResponse({ type: StockQuoteResourceDto })
|
||||
quote(
|
||||
@@ -84,6 +90,7 @@ export class StockController {
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@ApiOkResponse({ type: StockCandlesResourceDto })
|
||||
candles(
|
||||
@@ -98,6 +105,7 @@ export class StockController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid/position')
|
||||
@ApiOkResponse({ type: StockPositionResourceDto })
|
||||
position(
|
||||
@@ -111,6 +119,7 @@ export class StockController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid/company')
|
||||
@ApiOkResponse({ type: StockCompanyResourceDto })
|
||||
company(
|
||||
@@ -124,6 +133,7 @@ export class StockController {
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':uid/dividends')
|
||||
@ApiOkResponse({ type: StockDividendsResourceDto })
|
||||
dividends(
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../../investment-resource.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
@@ -12,7 +8,12 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../../investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||
|
||||
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
|
||||
@@ -36,7 +37,7 @@ export class StockTickerParamsDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
|
||||
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
|
||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||
declare ticker: string;
|
||||
}
|
||||
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
||||
@@ -46,7 +47,7 @@ export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['month', 'halfYear', 'year', 'all'])
|
||||
period?: 'month' | 'halfYear' | 'year' | 'all';
|
||||
period?: 'all' | 'halfYear' | 'month' | 'year';
|
||||
}
|
||||
export class StockDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
|
||||
@@ -54,48 +55,63 @@ export class StockDto {
|
||||
description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
|
||||
})
|
||||
declare slug: string;
|
||||
|
||||
@ApiProperty() declare name: string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare isin: string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare isin: null | string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare classCode:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare realExchange:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
|
||||
boolean | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare ticker: null | string;
|
||||
@ApiProperty({ type: String, nullable: true }) declare logoName:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare currency:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare shareType:
|
||||
string | null;
|
||||
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare sector: null | string;
|
||||
@ApiProperty({ type: Number, nullable: true }) declare lot: null | number;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare historyFrom: string | null;
|
||||
declare historyFrom: null | string;
|
||||
}
|
||||
export class StockSearchResultDto extends StockDto {
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare price: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare priceAt: string | null;
|
||||
declare priceAt: null | string;
|
||||
}
|
||||
export class StockQuoteDto {
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare price: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare priceAt: string | null;
|
||||
declare priceAt: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare tradingStatus:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare change: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare changePercent:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare previousCloseDate: string | null;
|
||||
declare previousCloseDate: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare previousClose: MoneyDto | null;
|
||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||
|
||||
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
export class StockCandleDto {
|
||||
@@ -104,29 +120,36 @@ export class StockCandleDto {
|
||||
@ApiProperty() declare isComplete: boolean;
|
||||
}
|
||||
export class StockCandlesDto {
|
||||
@ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[];
|
||||
@ApiProperty({ type: StockCandleDto, isArray: true }) declare candles: StockCandleDto[];
|
||||
@ApiProperty({ type: String, nullable: true }) declare currency:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare historyFrom: string | null;
|
||||
declare historyFrom: null | string;
|
||||
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
export class StockPositionRowDto extends PortfolioPositionDto {
|
||||
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
|
||||
string | null;
|
||||
null | string;
|
||||
}
|
||||
export class StockPositionDto {
|
||||
@ApiProperty() declare quantity: string;
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare currentValue: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare averagePurchasePrice: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare absoluteReturn: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare returnPercent:
|
||||
string | null;
|
||||
@ApiProperty({ type: [StockPositionRowDto] })
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: StockPositionRowDto, isArray: true })
|
||||
declare accounts: StockPositionRowDto[];
|
||||
|
||||
@ApiProperty() declare isStale: boolean;
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
@@ -134,90 +157,108 @@ export class StockMetricDto {
|
||||
@ApiProperty() declare key: string;
|
||||
@ApiProperty() declare value: number;
|
||||
@ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit:
|
||||
'money' | 'ratio' | 'percent';
|
||||
'money' | 'percent' | 'ratio';
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare currency:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] })
|
||||
declare period: 'TTM' | 'MRQ' | null;
|
||||
declare period: 'MRQ' | 'TTM' | null;
|
||||
}
|
||||
export class StockCompanyDto {
|
||||
@ApiProperty({ type: String, nullable: true }) declare description:
|
||||
string | null;
|
||||
@ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[];
|
||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: StockMetricDto, isArray: true }) declare metrics: StockMetricDto[];
|
||||
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
}
|
||||
export class StockDividendDto {
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare perShare: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare recordDate: string | null;
|
||||
declare recordDate: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare lastBuyDate: string | null;
|
||||
declare lastBuyDate: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
declare paymentDate: string | null;
|
||||
declare paymentDate: null | string;
|
||||
|
||||
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
|
||||
'upcoming' | 'past' | 'undated';
|
||||
'past' | 'undated' | 'upcoming';
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare estimatedAmount: MoneyDto | null;
|
||||
}
|
||||
export class StockDividendsDto {
|
||||
@ApiProperty({ type: [StockDividendDto] })
|
||||
@ApiProperty({ type: StockDividendDto, isArray: true })
|
||||
declare dividends: StockDividendDto[];
|
||||
|
||||
@ApiProperty({ format: 'date' }) declare from: string;
|
||||
@ApiProperty({ format: 'date' }) declare to: string;
|
||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare positionAt: string | null;
|
||||
declare positionAt: null | string;
|
||||
|
||||
@ApiProperty() declare estimateUnavailable: boolean;
|
||||
@ApiProperty() declare positionIsStale: boolean;
|
||||
}
|
||||
|
||||
export class StockPositionResourceDto {
|
||||
@ApiProperty({ type: StockPositionDto, nullable: true })
|
||||
declare data: StockPositionDto | null;
|
||||
declare data: null | StockPositionDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockSearchResourceDto {
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
|
||||
@ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true })
|
||||
declare data: StockSearchResultDto[] | null;
|
||||
declare data: null | StockSearchResultDto[];
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockResourceDto {
|
||||
@ApiProperty({ type: StockDto, nullable: true })
|
||||
declare data: StockDto | null;
|
||||
declare data: null | StockDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockQuoteResourceDto {
|
||||
@ApiProperty({ type: StockQuoteDto, nullable: true })
|
||||
declare data: StockQuoteDto | null;
|
||||
declare data: null | StockQuoteDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockCompanyResourceDto {
|
||||
@ApiProperty({ type: StockCompanyDto, nullable: true })
|
||||
declare data: StockCompanyDto | null;
|
||||
declare data: null | StockCompanyDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockCandlesResourceDto {
|
||||
@ApiProperty({ type: StockCandlesDto, nullable: true })
|
||||
declare data: StockCandlesDto | null;
|
||||
declare data: null | StockCandlesDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
export class StockDividendsResourceDto {
|
||||
@ApiProperty({ type: StockDividendsDto, nullable: true })
|
||||
declare data: StockDividendsDto | null;
|
||||
declare data: null | StockDividendsDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,9 @@ export function dateParts(value: string): [number, number, number] {
|
||||
number,
|
||||
number,
|
||||
];
|
||||
if (utcDate(value).toISOString().slice(0, 10) !== value)
|
||||
if (utcDate(value).toISOString().slice(0, 10) !== value) {
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,22 @@ export class InvestmentReadQueryDto {
|
||||
|
||||
export class InvestmentSyncDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare id: string | null;
|
||||
declare id: null | string;
|
||||
|
||||
@ApiProperty({ enum: syncStateValues })
|
||||
declare state: (typeof syncStateValues)[number];
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare errorCode: string | null;
|
||||
declare errorCode: null | string;
|
||||
}
|
||||
|
||||
export class InvestmentRangeDto {
|
||||
@ApiPropertyOptional()
|
||||
accountId?: string;
|
||||
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
declare from: string;
|
||||
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
declare to: string;
|
||||
}
|
||||
@@ -33,38 +37,51 @@ export class InvestmentRangeDto {
|
||||
export class InvestmentCoverageDto {
|
||||
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||
declare requested: InvestmentRangeDto[];
|
||||
|
||||
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||
declare completeRanges: InvestmentRangeDto[];
|
||||
|
||||
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||
declare missingRanges: InvestmentRangeDto[];
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare asOf: string | null;
|
||||
declare asOf: null | string;
|
||||
|
||||
@ApiProperty({ enum: ['known', 'unknown'] })
|
||||
declare historyBoundary: 'known' | 'unknown';
|
||||
|
||||
@ApiProperty({
|
||||
enum: ['missing', 'loading', 'partial', 'complete', 'failed'],
|
||||
})
|
||||
declare historyState:
|
||||
'missing' | 'loading' | 'partial' | 'complete' | 'failed';
|
||||
'complete' | 'failed' | 'loading' | 'missing' | 'partial';
|
||||
}
|
||||
|
||||
export class InvestmentDependencyDto {
|
||||
@ApiProperty()
|
||||
declare dataRevision: string;
|
||||
|
||||
@ApiProperty()
|
||||
declare stateVersion: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String })
|
||||
unavailableReason?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: () => InvestmentCoverageDto })
|
||||
coverage?: InvestmentCoverageDto;
|
||||
|
||||
@ApiProperty()
|
||||
declare resourceKey: string;
|
||||
|
||||
@ApiProperty({ enum: availabilityValues })
|
||||
declare availability: (typeof availabilityValues)[number];
|
||||
|
||||
@ApiProperty({ enum: freshnessValues })
|
||||
declare freshness: (typeof freshnessValues)[number];
|
||||
|
||||
@ApiProperty({ type: String, format: 'date-time', nullable: true })
|
||||
declare lastSuccessfulAt: string | null;
|
||||
declare lastSuccessfulAt: null | string;
|
||||
|
||||
@ApiProperty({ type: InvestmentSyncDto })
|
||||
declare sync: InvestmentSyncDto;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||
|
||||
export function investmentResourceMeta(input: {
|
||||
resourceKey: string;
|
||||
dataRevision: string;
|
||||
availability: InvestmentResourceMetaDto['availability'];
|
||||
coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>;
|
||||
dataRevision: string;
|
||||
dependencies?: InvestmentResourceMetaDto[];
|
||||
fetchedAt: Date | null;
|
||||
freshForMs: number;
|
||||
work: { id: string; state: string; errorCode: string | null } | null;
|
||||
dependencies?: InvestmentResourceMetaDto[];
|
||||
coverage?: InvestmentResourceMetaDto['coverage'];
|
||||
resourceKey: string;
|
||||
unavailableReason?: string;
|
||||
work: null | { errorCode: null | string; id: string; state: string };
|
||||
}): InvestmentResourceMetaDto {
|
||||
const ownSync = {
|
||||
id: input.work?.id ?? null,
|
||||
state:
|
||||
input.work?.state === 'running'
|
||||
? ('running' as const)
|
||||
: input.work?.state === 'failed'
|
||||
? ('failed' as const)
|
||||
: ('idle' as const),
|
||||
input.work?.state === 'running' ?
|
||||
('running' as const) :
|
||||
input.work?.state === 'failed' ?
|
||||
('failed' as const) :
|
||||
('idle' as const),
|
||||
errorCode: input.work?.errorCode ?? null,
|
||||
};
|
||||
const states = [
|
||||
@@ -33,11 +34,11 @@ export function investmentResourceMeta(input: {
|
||||
states.find((state) => state.state === 'queued') ??
|
||||
states.find((state) => state.state === 'failed') ??
|
||||
ownSync;
|
||||
const sourceFreshness = input.fetchedAt
|
||||
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
|
||||
? ('fresh' as const)
|
||||
: ('stale' as const)
|
||||
: ('unknown' as const);
|
||||
const sourceFreshness = input.fetchedAt ?
|
||||
Date.now() - input.fetchedAt.valueOf() < input.freshForMs ?
|
||||
('fresh' as const) :
|
||||
('stale' as const) :
|
||||
('unknown' as const);
|
||||
const freshness = [
|
||||
sourceFreshness,
|
||||
...(input.dependencies ?? [])
|
||||
@@ -51,19 +52,20 @@ export function investmentResourceMeta(input: {
|
||||
),
|
||||
]
|
||||
.filter((timestamp): timestamp is string => Boolean(timestamp))
|
||||
// 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 meta = {
|
||||
resourceKey: input.resourceKey,
|
||||
...(input.unavailableReason
|
||||
? { unavailableReason: input.unavailableReason }
|
||||
: {}),
|
||||
...(input.unavailableReason ?
|
||||
{ unavailableReason: input.unavailableReason } :
|
||||
{}),
|
||||
dataRevision: input.dataRevision,
|
||||
availability: input.availability,
|
||||
freshness: freshness.includes('unknown')
|
||||
? ('unknown' as const)
|
||||
: freshness.includes('stale')
|
||||
? ('stale' as const)
|
||||
: ('fresh' as const),
|
||||
freshness: freshness.includes('unknown') ?
|
||||
('unknown' as const) :
|
||||
freshness.includes('stale') ?
|
||||
('stale' as const) :
|
||||
('fresh' as const),
|
||||
lastSuccessfulAt: timestamps[0] ?? null,
|
||||
sync,
|
||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||
@@ -77,9 +79,9 @@ export function investmentResourceMeta(input: {
|
||||
...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,
|
||||
coverage: meta.coverage ?
|
||||
{ ...meta.coverage, requested: undefined } :
|
||||
undefined,
|
||||
}),
|
||||
)
|
||||
.digest('base64url'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
planInvestmentScreen,
|
||||
ledgerRange,
|
||||
accountHistory,
|
||||
ledgerRange,
|
||||
planInvestmentScreen,
|
||||
} from './investment-sync-plan';
|
||||
|
||||
describe('screen dependency and history planning', () => {
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { planIncomePeriod } from './income/income.api';
|
||||
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
||||
import {
|
||||
operationIntervals,
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
} from './operations/domain/operations-sync-plan';
|
||||
|
||||
export type InvestmentScreen = {
|
||||
screen:
|
||||
| 'accounts'
|
||||
| 'account'
|
||||
| 'operations'
|
||||
| 'income'
|
||||
| 'events'
|
||||
| 'stocks'
|
||||
| 'bonds'
|
||||
| 'stock'
|
||||
| 'bond';
|
||||
accountId?: string;
|
||||
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
|
||||
candleRange?: { from: Date; to: Date };
|
||||
from?: string;
|
||||
to?: string;
|
||||
instrumentUid?: string;
|
||||
query?: string;
|
||||
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||
candleRange?: { from: Date; to: Date };
|
||||
screen:
|
||||
| 'account' |
|
||||
'accounts' |
|
||||
'bond' |
|
||||
'bonds' |
|
||||
'events' |
|
||||
'income' |
|
||||
'operations' |
|
||||
'stock' |
|
||||
'stocks';
|
||||
to?: string;
|
||||
};
|
||||
const defaultEventDays = 30;
|
||||
const incomeHistoryYears = 5;
|
||||
const candleMonths = { month: 1, halfYear: 6, year: 12 };
|
||||
|
||||
type Account = { accountId: string; openedOn: string | null };
|
||||
type Account = { accountId: string; openedOn: null | string };
|
||||
type Instrument = {
|
||||
uid: string;
|
||||
type: 'STOCK' | 'BOND';
|
||||
historyFrom: Date | null;
|
||||
type: 'BOND' | 'STOCK';
|
||||
uid: string;
|
||||
};
|
||||
type Source = {
|
||||
kind:
|
||||
| 'membership'
|
||||
| 'portfolio'
|
||||
| 'operations'
|
||||
| 'metadata'
|
||||
| 'catalog'
|
||||
| 'quote'
|
||||
| 'company'
|
||||
| 'metrics'
|
||||
| 'calendar'
|
||||
| 'candles';
|
||||
accountId?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: 'STOCK' | 'BOND';
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
instrumentType?: 'BOND' | 'STOCK';
|
||||
instrumentUid?: string;
|
||||
kind:
|
||||
| 'calendar' |
|
||||
'candles' |
|
||||
'catalog' |
|
||||
'company' |
|
||||
'membership' |
|
||||
'metadata' |
|
||||
'metrics' |
|
||||
'operations' |
|
||||
'portfolio' |
|
||||
'quote';
|
||||
priority: 'discovery' | 'history' | 'screen';
|
||||
query?: string;
|
||||
priority: 'discovery' | 'screen' | 'history';
|
||||
to?: Date;
|
||||
};
|
||||
|
||||
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
|
||||
@@ -74,19 +74,19 @@ export function planInvestmentScreen(
|
||||
screen.accountId === 'all' ||
|
||||
account.accountId === screen.accountId,
|
||||
);
|
||||
const unavailable: { resourceKey: string; reason: string }[] = [];
|
||||
const unavailable: { reason: string; resourceKey: string }[] = [];
|
||||
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
||||
const targets = accounts.flatMap((account) =>
|
||||
from < to
|
||||
? [
|
||||
from < to ?
|
||||
[
|
||||
{
|
||||
accountId: account.accountId,
|
||||
from,
|
||||
to,
|
||||
...(instrumentUid ? { instrumentUid } : {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
sources.push(
|
||||
...operationIntervals(targets).map((range) => ({
|
||||
@@ -105,16 +105,18 @@ export function planInvestmentScreen(
|
||||
})),
|
||||
);
|
||||
const knownOpening =
|
||||
accounts.length && accounts.every((account) => account.openedOn)
|
||||
? new Date(
|
||||
accounts.length && accounts.every((account) => account.openedOn) ?
|
||||
new Date(
|
||||
// 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.
|
||||
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
||||
)
|
||||
: undefined;
|
||||
) :
|
||||
undefined;
|
||||
if (!context.membershipKnown) return { sources, unavailable, now };
|
||||
if (
|
||||
['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen)
|
||||
)
|
||||
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
||||
) {
|
||||
portfolio();
|
||||
}
|
||||
if (
|
||||
screen.screen === 'operations' ||
|
||||
screen.screen === 'stock' ||
|
||||
@@ -137,18 +139,19 @@ export function planInvestmentScreen(
|
||||
);
|
||||
for (const range of [
|
||||
period.selected,
|
||||
...(screen.screen === 'income'
|
||||
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||
: []),
|
||||
...(screen.screen === 'income' ?
|
||||
[period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] :
|
||||
[]),
|
||||
]) {
|
||||
const bounds = dateOnlyBounds(range, now);
|
||||
operations(bounds.from, bounds.to);
|
||||
}
|
||||
if (screen.screen === 'income' && !period.yearAgo)
|
||||
if (screen.screen === 'income' && !period.yearAgo) {
|
||||
unavailable.push({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
reason: 'calendar_or_account_boundary',
|
||||
});
|
||||
}
|
||||
if (screen.screen === 'account') {
|
||||
const range = ledgerRange(
|
||||
screen.from,
|
||||
@@ -160,13 +163,14 @@ export function planInvestmentScreen(
|
||||
operations(range.from, range.to);
|
||||
}
|
||||
}
|
||||
if (screen.screen === 'stocks' || screen.screen === 'bonds')
|
||||
if (screen.screen === 'stocks' || screen.screen === 'bonds') {
|
||||
sources.push({
|
||||
kind: 'catalog',
|
||||
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
|
||||
query: screen.query?.trim().toUpperCase() ?? '',
|
||||
priority: 'screen',
|
||||
});
|
||||
}
|
||||
const instruments = context.instruments.filter(
|
||||
(instrument) =>
|
||||
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
|
||||
@@ -175,20 +179,22 @@ export function planInvestmentScreen(
|
||||
screen.instrumentUid &&
|
||||
!instruments.length &&
|
||||
(screen.screen === 'stock' || screen.screen === 'bond')
|
||||
)
|
||||
) {
|
||||
instruments.push({
|
||||
uid: screen.instrumentUid,
|
||||
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
|
||||
historyFrom: null,
|
||||
});
|
||||
}
|
||||
for (const instrument of instruments) {
|
||||
const identity = {
|
||||
instrumentUid: instrument.uid,
|
||||
instrumentType: instrument.type,
|
||||
};
|
||||
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
|
||||
if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen))
|
||||
if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) {
|
||||
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
|
||||
}
|
||||
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
||||
sources.push({
|
||||
...identity,
|
||||
@@ -197,47 +203,51 @@ export function planInvestmentScreen(
|
||||
});
|
||||
const from = new Date(now);
|
||||
const period = screen.candlePeriod ?? 'month';
|
||||
if (period !== 'all')
|
||||
if (period !== 'all') {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union.
|
||||
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
|
||||
}
|
||||
const range =
|
||||
screen.candleRange ??
|
||||
(period === 'all'
|
||||
? instrument.historyFrom
|
||||
? { from: instrument.historyFrom, to: now }
|
||||
: null
|
||||
: { from, to: now });
|
||||
if (range)
|
||||
(period === 'all' ?
|
||||
instrument.historyFrom ?
|
||||
{ from: instrument.historyFrom, to: now } :
|
||||
null :
|
||||
{ from, to: now });
|
||||
if (range) {
|
||||
sources.push({
|
||||
...identity,
|
||||
kind: 'candles',
|
||||
...range,
|
||||
priority: 'screen',
|
||||
});
|
||||
else
|
||||
} else {
|
||||
unavailable.push({
|
||||
resourceKey: `candles/${instrument.uid}/all`,
|
||||
reason: 'history_start_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) {
|
||||
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
|
||||
const day =
|
||||
screen.screen === 'stock' || screen.screen === 'bond'
|
||||
? moscowDate(now)
|
||||
: now.toISOString().slice(0, 10);
|
||||
screen.screen === 'stock' || screen.screen === 'bond' ?
|
||||
moscowDate(now) :
|
||||
now.toISOString().slice(0, 10);
|
||||
const from =
|
||||
screen.screen === 'events' && screen.from
|
||||
? new Date(`${screen.from}T00:00:00Z`)
|
||||
: new Date(`${day}T00:00:00Z`);
|
||||
screen.screen === 'events' && screen.from ?
|
||||
new Date(`${screen.from}T00:00:00Z`) :
|
||||
new Date(`${day}T00:00:00Z`);
|
||||
const to =
|
||||
screen.screen === 'events' && screen.to
|
||||
? new Date(`${screen.to}T00:00:00Z`)
|
||||
: new Date(from);
|
||||
screen.screen === 'events' && screen.to ?
|
||||
new Date(`${screen.to}T00:00:00Z`) :
|
||||
new Date(from);
|
||||
if (
|
||||
!(screen.screen === 'events' && screen.to) &&
|
||||
screen.screen !== 'stock' &&
|
||||
screen.screen !== 'bond'
|
||||
)
|
||||
) {
|
||||
to.setUTCDate(to.getUTCDate() + defaultEventDays);
|
||||
}
|
||||
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
||||
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
|
||||
to.setUTCFullYear(to.getUTCFullYear() + 1);
|
||||
@@ -252,9 +262,9 @@ export function planInvestmentScreen(
|
||||
}
|
||||
if (
|
||||
!force &&
|
||||
['account', 'operations', 'income', 'events'].includes(screen.screen)
|
||||
['account', 'events', 'income', 'operations'].includes(screen.screen)
|
||||
) {
|
||||
if (instrument.historyFrom)
|
||||
if (instrument.historyFrom) {
|
||||
sources.push({
|
||||
...identity,
|
||||
kind: 'candles',
|
||||
@@ -262,14 +272,15 @@ export function planInvestmentScreen(
|
||||
to: now,
|
||||
priority: 'history',
|
||||
});
|
||||
else
|
||||
} else {
|
||||
unavailable.push({
|
||||
resourceKey: `candles/${instrument.uid}/all`,
|
||||
reason: 'history_start_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!force && !['stocks', 'bonds'].includes(screen.screen))
|
||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
||||
sources.push(
|
||||
...accountHistory(accounts, now).map((range) => ({
|
||||
...range,
|
||||
@@ -277,6 +288,7 @@ export function planInvestmentScreen(
|
||||
priority: 'history' as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const priorities = { discovery: 0, screen: 1, history: 2 };
|
||||
sources.sort(
|
||||
(left, right) => priorities[left.priority] - priorities[right.priority],
|
||||
@@ -290,9 +302,9 @@ export function accountHistory(
|
||||
): OperationInterval[] {
|
||||
return accounts.flatMap((account) => {
|
||||
const from = new Date(
|
||||
account.openedOn
|
||||
? `${account.openedOn}T00:00:00Z`
|
||||
: '1970-01-01T00:00:00Z',
|
||||
account.openedOn ?
|
||||
`${account.openedOn}T00:00:00Z` :
|
||||
'1970-01-01T00:00:00Z',
|
||||
);
|
||||
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
|
||||
});
|
||||
@@ -305,21 +317,22 @@ export function ledgerRange(
|
||||
accounts: Account[],
|
||||
now: Date,
|
||||
) {
|
||||
const opening = accounts.every((account) => account.openedOn)
|
||||
? accounts.map((account) => account.openedOn ?? '').sort()[0]
|
||||
: undefined;
|
||||
const from = fromValue
|
||||
? new Date(fromValue)
|
||||
: instrumentUid
|
||||
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
|
||||
: new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
||||
const end = toValue
|
||||
? new Date(
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
|
||||
? `${toValue}T23:59:59.999Z`
|
||||
: toValue,
|
||||
)
|
||||
: now;
|
||||
const opening = accounts.every((account) => account.openedOn) ?
|
||||
// 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.
|
||||
accounts.map((account) => account.openedOn ?? '').sort()[0] :
|
||||
undefined;
|
||||
const from = fromValue ?
|
||||
new Date(fromValue) :
|
||||
instrumentUid ?
|
||||
new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') :
|
||||
new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
||||
const end = toValue ?
|
||||
new Date(
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(toValue) ?
|
||||
`${toValue}T23:59:59.999Z` :
|
||||
toValue,
|
||||
) :
|
||||
now;
|
||||
return {
|
||||
from,
|
||||
to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())),
|
||||
|
||||
+36
-27
@@ -1,22 +1,23 @@
|
||||
import { accountHistory } from '../../investment-sync-plan';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { accountHistory } from '../../investment-sync-plan';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
planOperationRead,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
import {
|
||||
BrokerageOperationsRepository,
|
||||
type OperationAttempt,
|
||||
} from '../infrastructure/brokerage-operations.repository';
|
||||
import {
|
||||
operationIntervals,
|
||||
planOperationRead,
|
||||
type OperationInterval,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
|
||||
const operationsPageSize = 1000;
|
||||
const priorities = { screen: 1, history: 2 } as const;
|
||||
@@ -29,6 +30,7 @@ export class BrokerageOperationsSyncService {
|
||||
string,
|
||||
{ attempt: OperationAttempt; priority: number }
|
||||
>();
|
||||
|
||||
private draining = false;
|
||||
// Only short DB reservations/publications are serialized, never RPCs.
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
@@ -44,10 +46,10 @@ export class BrokerageOperationsSyncService {
|
||||
userId: string,
|
||||
intervals: OperationInterval[],
|
||||
options: {
|
||||
movingEnd?: boolean;
|
||||
force?: boolean;
|
||||
observe?: boolean;
|
||||
history?: boolean;
|
||||
movingEnd?: boolean;
|
||||
observe?: boolean;
|
||||
revision?: string;
|
||||
} = {},
|
||||
) {
|
||||
@@ -55,8 +57,9 @@ export class BrokerageOperationsSyncService {
|
||||
if (
|
||||
!connection.membershipAt ||
|
||||
(options.revision && options.revision !== connection.revision)
|
||||
)
|
||||
) {
|
||||
return { plan: null, handle: null };
|
||||
}
|
||||
if (
|
||||
intervals.some(
|
||||
(interval) =>
|
||||
@@ -64,8 +67,9 @@ export class BrokerageOperationsSyncService {
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const coverage = await this.repository.coverage(
|
||||
userId,
|
||||
connection.revision,
|
||||
@@ -76,24 +80,24 @@ export class BrokerageOperationsSyncService {
|
||||
...intervals.map((interval) => interval.to.valueOf()),
|
||||
);
|
||||
const targets = intervals.map((interval) =>
|
||||
options.movingEnd && interval.to.valueOf() === movingTo
|
||||
? { ...interval, to: now }
|
||||
: interval,
|
||||
options.movingEnd && interval.to.valueOf() === movingTo ?
|
||||
{ ...interval, to: now } :
|
||||
interval,
|
||||
);
|
||||
const plan = planOperationRead(targets, coverage, now, options);
|
||||
const complete = plan.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
);
|
||||
const handle =
|
||||
!options.observe && plan.load.length
|
||||
? await this.request(
|
||||
!options.observe && plan.load.length ?
|
||||
await this.request(
|
||||
userId,
|
||||
plan.load,
|
||||
complete || Boolean(options.force),
|
||||
'screen',
|
||||
connection.revision,
|
||||
)
|
||||
: null;
|
||||
) :
|
||||
null;
|
||||
if (!options.observe && !options.force && options.history) {
|
||||
const accountIds = new Set(
|
||||
intervals.map((interval) => interval.accountId),
|
||||
@@ -108,7 +112,7 @@ export class BrokerageOperationsSyncService {
|
||||
movingEnd: true,
|
||||
}).load;
|
||||
const remaining = uncoveredOperationIntervals(missing, targets);
|
||||
if (remaining.length)
|
||||
if (remaining.length) {
|
||||
await this.request(
|
||||
userId,
|
||||
remaining,
|
||||
@@ -116,6 +120,7 @@ export class BrokerageOperationsSyncService {
|
||||
'history',
|
||||
connection.revision,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { plan, handle };
|
||||
}
|
||||
@@ -126,7 +131,7 @@ export class BrokerageOperationsSyncService {
|
||||
atomic: boolean,
|
||||
priority: keyof typeof priorities = 'screen',
|
||||
revision?: string,
|
||||
): Promise<string | null> {
|
||||
): Promise<null | string> {
|
||||
const connection = await this.access.local(userId);
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
@@ -136,8 +141,9 @@ export class BrokerageOperationsSyncService {
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (revision && revision !== connection.revision) return null;
|
||||
const cutoff = new Date();
|
||||
const bounded = operationIntervals(intervals)
|
||||
@@ -155,6 +161,7 @@ export class BrokerageOperationsSyncService {
|
||||
this.processRunId,
|
||||
atomic,
|
||||
this.runningAttempts,
|
||||
// eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union.
|
||||
priorities[priority],
|
||||
);
|
||||
if (!requested) return null;
|
||||
@@ -258,17 +265,18 @@ export class BrokerageOperationsSyncService {
|
||||
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1,
|
||||
),
|
||||
},
|
||||
...(interval.instrumentUid
|
||||
? { instrumentId: interval.instrumentUid }
|
||||
: {}),
|
||||
...(interval.instrumentUid ?
|
||||
{ instrumentId: interval.instrumentUid } :
|
||||
{}),
|
||||
cursor,
|
||||
limit: operationsPageSize,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
});
|
||||
if (
|
||||
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!plan.atomic && !response.hasNext) {
|
||||
if (
|
||||
!(await this.repository.publish(
|
||||
@@ -277,8 +285,9 @@ export class BrokerageOperationsSyncService {
|
||||
new Date(),
|
||||
true,
|
||||
))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// One page per turn: newly requested screen work can overtake backfill.
|
||||
// TbankClient alone owns the bounded RPC queue and retries.
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
import {
|
||||
ledgerRange,
|
||||
accountHistory,
|
||||
planInvestmentScreen,
|
||||
} from '../../investment-sync-plan';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||
import {
|
||||
planOperationRead,
|
||||
uncoveredOperationIntervals,
|
||||
operationIntervalsOverlap,
|
||||
} from '../domain/operations-sync-plan';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
tbankSourceTtlMs,
|
||||
type TbankOperationItem,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
|
||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
DEFAULT_OPERATIONS_PAGE_SIZE,
|
||||
BrokerageOperationsQueryDto,
|
||||
} from '../presentation/brokerage-operation.dto';
|
||||
accountHistory,
|
||||
ledgerRange,
|
||||
planInvestmentScreen,
|
||||
} from '../../investment-sync-plan';
|
||||
import {
|
||||
operationIntervalsOverlap,
|
||||
planOperationRead,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
||||
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
||||
import {
|
||||
BrokerageOperationsQueryDto,
|
||||
DEFAULT_OPERATIONS_PAGE_SIZE,
|
||||
} from '../presentation/brokerage-operation.dto';
|
||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||
|
||||
type LocalOperationsCursor = {
|
||||
movingEnd?: boolean;
|
||||
binding: string;
|
||||
to: string;
|
||||
at: string;
|
||||
accountId: string;
|
||||
at: string;
|
||||
binding: string;
|
||||
id: string;
|
||||
movingEnd?: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -70,18 +71,19 @@ export class BrokerageOperationsService {
|
||||
const from = range.from;
|
||||
const to = cursor ? new Date(cursor.to) : range.to;
|
||||
const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf();
|
||||
// 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.
|
||||
const categories = [...new Set(query.categories ?? [])].sort();
|
||||
const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE;
|
||||
const filterKey = JSON.stringify([
|
||||
accountId,
|
||||
from.toISOString(),
|
||||
query.to
|
||||
? new Date(
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(query.to)
|
||||
? `${query.to}T23:59:59.999Z`
|
||||
: query.to,
|
||||
).toISOString()
|
||||
: 'now',
|
||||
query.to ?
|
||||
new Date(
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(query.to) ?
|
||||
`${query.to}T23:59:59.999Z` :
|
||||
query.to,
|
||||
).toISOString() :
|
||||
'now',
|
||||
query.instrumentUid ?? null,
|
||||
categories,
|
||||
limit,
|
||||
@@ -118,9 +120,9 @@ export class BrokerageOperationsService {
|
||||
source.priority === 'screen' &&
|
||||
source.accountId &&
|
||||
source.from &&
|
||||
source.to
|
||||
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
||||
: [],
|
||||
source.to ?
|
||||
[{ accountId: source.accountId, from: source.from, to: source.to }] :
|
||||
[],
|
||||
);
|
||||
if (query.sync !== 'observe') {
|
||||
const discovery = await this.membership.request(
|
||||
@@ -148,9 +150,9 @@ export class BrokerageOperationsService {
|
||||
accountId === 'all' || account.accountId === accountId,
|
||||
)
|
||||
.flatMap((account) =>
|
||||
currentRange.from < currentRange.to
|
||||
? [{ accountId: account.accountId, ...currentRange }]
|
||||
: [],
|
||||
currentRange.from < currentRange.to ?
|
||||
[{ accountId: account.accountId, ...currentRange }] :
|
||||
[],
|
||||
);
|
||||
await this.sync.readPlan(userId, targets, {
|
||||
movingEnd,
|
||||
@@ -170,16 +172,16 @@ export class BrokerageOperationsService {
|
||||
const saved = await this.repository.readPage(readInput);
|
||||
const accounts = saved?.connection.accounts ?? [];
|
||||
const targets = accounts.flatMap((account) =>
|
||||
from < to
|
||||
? [
|
||||
from < to ?
|
||||
[
|
||||
{
|
||||
accountId: account.accountId,
|
||||
from,
|
||||
to,
|
||||
instrumentUid: query.instrumentUid,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||
movingEnd,
|
||||
@@ -191,8 +193,8 @@ export class BrokerageOperationsService {
|
||||
row.accountId === target.accountId &&
|
||||
(!row.instrumentUid ||
|
||||
row.instrumentUid === target.instrumentUid) &&
|
||||
row.from < target.to &&
|
||||
target.from < row.to,
|
||||
row.from < target.to &&
|
||||
target.from < row.to,
|
||||
)
|
||||
.map((row) => ({
|
||||
accountId: target.accountId,
|
||||
@@ -230,13 +232,8 @@ export class BrokerageOperationsService {
|
||||
sources
|
||||
.map((row) => row.fetchedAt)
|
||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||
(complete ? saved?.connection.membershipAt : null) ??
|
||||
null;
|
||||
const serialize = (range: { accountId: string; from: Date; to: Date }) => ({
|
||||
accountId: range.accountId,
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
});
|
||||
(complete ? saved?.connection.membershipAt : null) ??
|
||||
null;
|
||||
const historyTargets = accountHistory(accounts, now);
|
||||
const history = planOperationRead(
|
||||
historyTargets,
|
||||
@@ -273,9 +270,11 @@ export class BrokerageOperationsService {
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
for (const identity of identities.values())
|
||||
if (!instruments.get(metadataKey(identity))?.fresh)
|
||||
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',
|
||||
@@ -288,17 +287,17 @@ export class BrokerageOperationsService {
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
|
||||
dataRevision: saved?.binding ?? 'missing',
|
||||
availability: !available
|
||||
? 'missing'
|
||||
: complete
|
||||
? 'complete'
|
||||
: confirmed.length
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
availability: !available ?
|
||||
'missing' :
|
||||
complete ?
|
||||
'complete' :
|
||||
confirmed.length ?
|
||||
'partial' :
|
||||
'missing',
|
||||
fetchedAt: lastSuccessfulAt,
|
||||
freshForMs: plan.intervals.some((interval) => interval.stale)
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY,
|
||||
freshForMs: plan.intervals.some((interval) => interval.stale) ?
|
||||
0 :
|
||||
Number.POSITIVE_INFINITY,
|
||||
work: work ?? saved?.membershipWork ?? null,
|
||||
dependencies: [membershipMeta, ...metadataDependencies],
|
||||
coverage: {
|
||||
@@ -312,8 +311,8 @@ export class BrokerageOperationsService {
|
||||
(interval) =>
|
||||
interval.accountId === account.accountId && interval.asOf,
|
||||
),
|
||||
)
|
||||
? new Date(
|
||||
) ?
|
||||
new Date(
|
||||
Math.min(
|
||||
...accounts.map((account) =>
|
||||
Math.max(
|
||||
@@ -326,42 +325,42 @@ export class BrokerageOperationsService {
|
||||
),
|
||||
),
|
||||
),
|
||||
).toISOString()
|
||||
: null,
|
||||
).toISOString() :
|
||||
null,
|
||||
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
||||
historyState: saved?.work.some(
|
||||
(work) => work.state === 'running' && work.priority > 1,
|
||||
)
|
||||
? 'loading'
|
||||
: saved?.work.some(
|
||||
(work) => work.state === 'failed' && work.priority > 1,
|
||||
)
|
||||
? 'failed'
|
||||
: knownBoundary &&
|
||||
history.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
)
|
||||
? 'complete'
|
||||
: saved?.coverage.length
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
) ?
|
||||
'loading' :
|
||||
saved?.work.some(
|
||||
(work) => work.state === 'failed' && work.priority > 1,
|
||||
) ?
|
||||
'failed' :
|
||||
knownBoundary &&
|
||||
history.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
) ?
|
||||
'complete' :
|
||||
saved?.coverage.length ?
|
||||
'partial' :
|
||||
'missing',
|
||||
},
|
||||
});
|
||||
const last = rows.at(-1);
|
||||
const hasNext = Boolean(saved && saved.rows.length > limit);
|
||||
return {
|
||||
data:
|
||||
meta.availability === 'missing'
|
||||
? null
|
||||
: {
|
||||
meta.availability === 'missing' ?
|
||||
null :
|
||||
{
|
||||
accountId,
|
||||
items: rows.map((row) => ({
|
||||
...mapBrokerageOperation(
|
||||
row.payload as TbankOperationItem,
|
||||
row.occurredAt,
|
||||
),
|
||||
...(accountId === 'all'
|
||||
? {
|
||||
...(accountId === 'all' ?
|
||||
{
|
||||
account: {
|
||||
id: row.accountId,
|
||||
name:
|
||||
@@ -369,8 +368,8 @@ export class BrokerageOperationsService {
|
||||
(account) => account.accountId === row.accountId,
|
||||
)?.name ?? row.accountId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
logoName:
|
||||
instruments
|
||||
.get(metadataKey(row.payload as TbankOperationItem))
|
||||
@@ -378,16 +377,16 @@ export class BrokerageOperationsService {
|
||||
})),
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last && saved
|
||||
? this.encodeCursor({
|
||||
hasNext && last && saved ?
|
||||
this.encodeCursor({
|
||||
binding: saved.binding,
|
||||
movingEnd,
|
||||
to: to.toISOString(),
|
||||
at: last.occurredAt.toISOString(),
|
||||
accountId: last.accountId,
|
||||
id: last.brokerOperationId,
|
||||
})
|
||||
: null,
|
||||
}) :
|
||||
null,
|
||||
observedAt: now.toISOString(),
|
||||
},
|
||||
meta,
|
||||
@@ -413,23 +412,25 @@ export class BrokerageOperationsService {
|
||||
extra ||
|
||||
actual.length !== expected.length ||
|
||||
!timingSafeEqual(actual, expected)
|
||||
)
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
const cursor = JSON.parse(
|
||||
Buffer.from(body, 'base64url').toString(),
|
||||
) as Partial<LocalOperationsCursor>;
|
||||
if (
|
||||
(cursor.movingEnd !== undefined &&
|
||||
typeof cursor.movingEnd !== 'boolean') ||
|
||||
!cursor.binding ||
|
||||
!cursor.accountId ||
|
||||
!cursor.id ||
|
||||
!cursor.at ||
|
||||
!cursor.to ||
|
||||
!Number.isFinite(Date.parse(cursor.at)) ||
|
||||
!Number.isFinite(Date.parse(cursor.to))
|
||||
)
|
||||
!cursor.binding ||
|
||||
!cursor.accountId ||
|
||||
!cursor.id ||
|
||||
!cursor.at ||
|
||||
!cursor.to ||
|
||||
!Number.isFinite(Date.parse(cursor.at)) ||
|
||||
!Number.isFinite(Date.parse(cursor.to))
|
||||
) {
|
||||
throw new Error();
|
||||
}
|
||||
return cursor as LocalOperationsCursor;
|
||||
} catch {
|
||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
||||
@@ -465,12 +466,13 @@ export class BrokerageOperationsService {
|
||||
...period,
|
||||
})),
|
||||
);
|
||||
if (targets.length)
|
||||
if (targets.length) {
|
||||
await this.sync.readPlan(userId, targets, {
|
||||
movingEnd: snapshot.periods?.some((period) => period.movingEnd),
|
||||
history: true,
|
||||
revision: connection.revision,
|
||||
});
|
||||
}
|
||||
};
|
||||
if (!observe) {
|
||||
const discovery = await this.membership.request(
|
||||
@@ -530,17 +532,8 @@ export class BrokerageOperationsService {
|
||||
sources
|
||||
.map((row) => row.fetchedAt)
|
||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||
(complete ? saved?.connection.membershipAt : null) ??
|
||||
null;
|
||||
const serialize = (range: {
|
||||
accountId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}) => ({
|
||||
accountId: range.accountId,
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString(),
|
||||
});
|
||||
(complete ? saved?.connection.membershipAt : null) ??
|
||||
null;
|
||||
const confirmed = plan.intervals.flatMap((target) =>
|
||||
sources
|
||||
.filter(
|
||||
@@ -555,15 +548,16 @@ export class BrokerageOperationsService {
|
||||
})),
|
||||
);
|
||||
const meta = investmentResourceMeta({
|
||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
||||
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
||||
dataRevision: saved?.binding ?? 'missing',
|
||||
availability: !known
|
||||
? 'missing'
|
||||
: complete
|
||||
? 'complete'
|
||||
: sources.length
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
availability: !known ?
|
||||
'missing' :
|
||||
complete ?
|
||||
'complete' :
|
||||
sources.length ?
|
||||
'partial' :
|
||||
'missing',
|
||||
fetchedAt,
|
||||
freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity,
|
||||
work,
|
||||
@@ -576,8 +570,8 @@ export class BrokerageOperationsService {
|
||||
confirmed,
|
||||
).map(serialize),
|
||||
asOf:
|
||||
complete && targets.length
|
||||
? new Date(
|
||||
complete && targets.length ?
|
||||
new Date(
|
||||
Math.min(
|
||||
...targets.map((target) =>
|
||||
Math.max(
|
||||
@@ -592,24 +586,24 @@ export class BrokerageOperationsService {
|
||||
),
|
||||
),
|
||||
),
|
||||
).toISOString()
|
||||
: null,
|
||||
).toISOString() :
|
||||
null,
|
||||
historyBoundary: (saved?.connection.accounts ?? []).every(
|
||||
(account) => account.openedOn,
|
||||
)
|
||||
? 'known'
|
||||
: 'unknown',
|
||||
historyState: complete
|
||||
? 'complete'
|
||||
: sources.length
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
) ?
|
||||
'known' :
|
||||
'unknown',
|
||||
historyState: complete ?
|
||||
'complete' :
|
||||
sources.length ?
|
||||
'partial' :
|
||||
'missing',
|
||||
},
|
||||
});
|
||||
return {
|
||||
meta,
|
||||
items: complete
|
||||
? (saved?.rows ?? [])
|
||||
items: complete ?
|
||||
(saved?.rows ?? [])
|
||||
.filter((row) =>
|
||||
plan.intervals.some(
|
||||
(interval) =>
|
||||
@@ -623,8 +617,8 @@ export class BrokerageOperationsService {
|
||||
row.payload as TbankOperationItem,
|
||||
row.occurredAt,
|
||||
),
|
||||
...(accountId === 'all'
|
||||
? {
|
||||
...(accountId === 'all' ?
|
||||
{
|
||||
account: {
|
||||
id: row.accountId,
|
||||
name:
|
||||
@@ -632,11 +626,15 @@ export class BrokerageOperationsService {
|
||||
(account) => account.accountId === row.accountId,
|
||||
)?.name ?? row.accountId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
: null,
|
||||
} :
|
||||
{}),
|
||||
})) :
|
||||
null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(range: { accountId: string; from: Date; to: Date }) {
|
||||
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export type OperationInterval = {
|
||||
accountId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
instrumentUid?: string;
|
||||
to: Date;
|
||||
};
|
||||
|
||||
// Normalize duplicate/overlapping targets before creating a durable identity.
|
||||
@@ -12,8 +12,10 @@ export function operationIntervals(
|
||||
): OperationInterval[] {
|
||||
const groups = new Map<string, OperationInterval[]>();
|
||||
for (const interval of requested) {
|
||||
if (!(interval.from < interval.to))
|
||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||
if (!(interval.from < interval.to)) {
|
||||
throw new Error('Invalid operations interval');
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.instrumentUid ?? '',
|
||||
@@ -23,17 +25,18 @@ export function operationIntervals(
|
||||
groups.set(key, group);
|
||||
}
|
||||
const result: OperationInterval[] = [];
|
||||
// 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.
|
||||
for (const key of [...groups.keys()].sort()) {
|
||||
const merged: OperationInterval[] = [];
|
||||
for (const interval of (groups.get(key) ?? []).sort(
|
||||
(left, right) => left.from.valueOf() - right.from.valueOf(),
|
||||
)) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous && interval.from <= previous.to)
|
||||
if (previous && interval.from <= previous.to) {
|
||||
previous.to = new Date(
|
||||
Math.max(previous.to.valueOf(), interval.to.valueOf()),
|
||||
);
|
||||
else merged.push({ ...interval });
|
||||
} else merged.push({ ...interval });
|
||||
}
|
||||
for (const interval of merged) {
|
||||
let from = interval.from;
|
||||
@@ -61,8 +64,8 @@ export function operationIntervalsOverlap(
|
||||
(!left.instrumentUid ||
|
||||
!right.instrumentUid ||
|
||||
left.instrumentUid === right.instrumentUid) &&
|
||||
left.from < right.to &&
|
||||
right.from < left.to
|
||||
left.from < right.to &&
|
||||
right.from < left.to
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,15 +81,16 @@ export function uncoveredOperationIntervals(
|
||||
!operationIntervalsOverlap(fragment, covered) ||
|
||||
(covered.instrumentUid &&
|
||||
covered.instrumentUid !== fragment.instrumentUid)
|
||||
)
|
||||
) {
|
||||
return [fragment];
|
||||
}
|
||||
return [
|
||||
...(fragment.from < covered.from
|
||||
? [{ ...fragment, to: covered.from }]
|
||||
: []),
|
||||
...(covered.to < fragment.to
|
||||
? [{ ...fragment, from: covered.to }]
|
||||
: []),
|
||||
...(fragment.from < covered.from ?
|
||||
[{ ...fragment, to: covered.from }] :
|
||||
[]),
|
||||
...(covered.to < fragment.to ?
|
||||
[{ ...fragment, from: covered.to }] :
|
||||
[]),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -95,8 +99,8 @@ export function uncoveredOperationIntervals(
|
||||
}
|
||||
|
||||
export type OperationCoverage = OperationInterval & {
|
||||
fetchedAt: Date;
|
||||
asOf: Date;
|
||||
fetchedAt: Date;
|
||||
};
|
||||
const operationsFreshForMs = 300_000;
|
||||
|
||||
@@ -104,7 +108,7 @@ export function planOperationRead(
|
||||
requested: OperationInterval[],
|
||||
coverage: OperationCoverage[],
|
||||
now: Date,
|
||||
options: { movingEnd?: boolean; force?: boolean } = {},
|
||||
options: { force?: boolean; movingEnd?: boolean } = {},
|
||||
) {
|
||||
const monthStart = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
|
||||
@@ -137,8 +141,9 @@ export function planOperationRead(
|
||||
row.asOf.valueOf() === row.to.valueOf() && row.to > target.from,
|
||||
)
|
||||
.sort((left, right) => right.to.valueOf() - left.to.valueOf())[0];
|
||||
if (latest && latest.to < target.to)
|
||||
if (latest && latest.to < target.to) {
|
||||
effective = { ...target, to: latest.to };
|
||||
}
|
||||
}
|
||||
const missing = uncoveredOperationIntervals([effective], ranges);
|
||||
const complete = missing.length === 0;
|
||||
@@ -163,20 +168,20 @@ export function planOperationRead(
|
||||
// Completeness is bounded by the observed end, never by the new wall clock.
|
||||
return {
|
||||
...effective,
|
||||
availability: complete
|
||||
? ('complete' as const)
|
||||
: sources.length
|
||||
? ('partial' as const)
|
||||
: ('missing' as const),
|
||||
availability: complete ?
|
||||
('complete' as const) :
|
||||
sources.length ?
|
||||
('partial' as const) :
|
||||
('missing' as const),
|
||||
stale,
|
||||
asOf: sources.length
|
||||
? new Date(
|
||||
asOf: sources.length ?
|
||||
new Date(
|
||||
Math.min(
|
||||
effective.to.valueOf(),
|
||||
Math.max(...sources.map((row) => row.asOf.valueOf())),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
) :
|
||||
null,
|
||||
};
|
||||
});
|
||||
return { intervals, load: operationIntervals(load) };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankMoney,
|
||||
TbankOperationItem,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
OperationCategory,
|
||||
@@ -59,14 +59,14 @@ export function mapBrokerageOperation(
|
||||
logoName: null,
|
||||
payment: money(operation.payment),
|
||||
quantity:
|
||||
(operationCategory(operation.type) === 'trade'
|
||||
? operation.quantityDone
|
||||
: operation.quantity) === undefined
|
||||
? null
|
||||
: String(
|
||||
operationCategory(operation.type) === 'trade'
|
||||
? operation.quantityDone
|
||||
: operation.quantity,
|
||||
(operationCategory(operation.type) === 'trade' ?
|
||||
operation.quantityDone :
|
||||
operation.quantity) === undefined ?
|
||||
null :
|
||||
String(
|
||||
operationCategory(operation.type) === 'trade' ?
|
||||
operation.quantityDone :
|
||||
operation.quantity,
|
||||
),
|
||||
price: money(operation.price),
|
||||
accruedInterest: money(operation.accruedInt),
|
||||
@@ -74,22 +74,22 @@ export function mapBrokerageOperation(
|
||||
};
|
||||
}
|
||||
|
||||
function text(value: string | undefined): string | null {
|
||||
function text(value: string | undefined): null | string {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function money(value: TbankMoney | undefined) {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
return currency && value ?
|
||||
{ currency, value: formatDecimal(decimal(value)) } :
|
||||
null;
|
||||
}
|
||||
|
||||
export function operationTimestamp(
|
||||
value: { seconds?: string | number; nanos?: number } | undefined,
|
||||
value: undefined | { nanos?: number; seconds?: number | string },
|
||||
) {
|
||||
return new Date(
|
||||
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND +
|
||||
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
|
||||
Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND),
|
||||
);
|
||||
}
|
||||
|
||||
+142
-114
@@ -5,46 +5,48 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type {
|
||||
TbankOperationItem,
|
||||
TbankOperationsByCursorResponse,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateOnlyBounds,
|
||||
DateOnlyRange,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import {
|
||||
operationCategory,
|
||||
operationTimestamp,
|
||||
} from './brokerage-operation.mapper';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
operationIntervalsOverlap,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
import {
|
||||
operationCategory,
|
||||
operationTimestamp,
|
||||
} from './brokerage-operation.mapper';
|
||||
export type { OperationInterval } from '../domain/operations-sync-plan';
|
||||
|
||||
export type OperationAttempt = {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
userId: string;
|
||||
id: string;
|
||||
revision: string;
|
||||
userId: string;
|
||||
};
|
||||
type OperationsIntent = {
|
||||
intervals: string[];
|
||||
atomic: boolean;
|
||||
atomicIntervals?: string[];
|
||||
intervals: string[];
|
||||
screenIntervals?: string[];
|
||||
};
|
||||
type PageProgress = {
|
||||
complete: boolean;
|
||||
cursor: string;
|
||||
seen: string[];
|
||||
pages: number;
|
||||
seen: string[];
|
||||
};
|
||||
const progressItem = '';
|
||||
|
||||
@@ -53,17 +55,17 @@ export class BrokerageOperationsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async readPage(input: {
|
||||
userId: string;
|
||||
revision: string;
|
||||
accountId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
instrumentUid?: string;
|
||||
categories: string[];
|
||||
cursor?: { accountId: string; at: string; binding: string; id: string };
|
||||
filterKey: string;
|
||||
from: Date;
|
||||
instrumentUid?: string;
|
||||
limit?: number;
|
||||
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||
filterKey: string;
|
||||
cursor?: { binding: string; at: string; accountId: string; id: string };
|
||||
revision: string;
|
||||
to: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const connection = await tx.tbankConnection.findUnique({
|
||||
@@ -92,14 +94,16 @@ export class BrokerageOperationsRepository {
|
||||
connection.membershipAt &&
|
||||
input.accountId !== 'all' &&
|
||||
!connection.accounts.length
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const accounts = connection.accounts;
|
||||
// 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.
|
||||
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||
const openedAt =
|
||||
oldest && accounts.every((account) => account.openedOn)
|
||||
? new Date(`${oldest}T00:00:00+03:00`)
|
||||
: undefined;
|
||||
oldest && accounts.every((account) => account.openedOn) ?
|
||||
new Date(`${oldest}T00:00:00+03:00`) :
|
||||
undefined;
|
||||
const periods = input.periods?.(openedAt).map((period) => {
|
||||
const now = new Date();
|
||||
const bounds = dateOnlyBounds(period, now);
|
||||
@@ -123,8 +127,9 @@ export class BrokerageOperationsRepository {
|
||||
]),
|
||||
)
|
||||
.digest('base64url');
|
||||
if (input.cursor && input.cursor.binding !== binding)
|
||||
if (input.cursor && input.cursor.binding !== binding) {
|
||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
||||
}
|
||||
const scope = {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
@@ -162,21 +167,21 @@ export class BrokerageOperationsRepository {
|
||||
const rows = await tx.tbankOperation.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
...(periods
|
||||
? {
|
||||
...(periods ?
|
||||
{
|
||||
OR: periods.map((period) => ({
|
||||
occurredAt: { gte: period.from, lt: period.to },
|
||||
})),
|
||||
}
|
||||
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid }
|
||||
: {}),
|
||||
...(input.categories.length
|
||||
? { category: { in: input.categories } }
|
||||
: {}),
|
||||
...(after
|
||||
? {
|
||||
} :
|
||||
{ occurredAt: { gte: input.from, lt: input.to } }),
|
||||
...(input.instrumentUid ?
|
||||
{ instrumentUid: input.instrumentUid } :
|
||||
{}),
|
||||
...(input.categories.length ?
|
||||
{ category: { in: input.categories } } :
|
||||
{}),
|
||||
...(after ?
|
||||
{
|
||||
OR: [
|
||||
{ occurredAt: { lt: new Date(after.at) } },
|
||||
{
|
||||
@@ -189,8 +194,8 @@ export class BrokerageOperationsRepository {
|
||||
brokerOperationId: { gt: after.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
},
|
||||
orderBy: [
|
||||
{ occurredAt: 'desc' },
|
||||
@@ -247,6 +252,7 @@ export class BrokerageOperationsRepository {
|
||||
const scopeKey =
|
||||
'operations:' +
|
||||
createHash('sha256')
|
||||
// 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.
|
||||
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||
.digest('hex');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
@@ -271,14 +277,15 @@ export class BrokerageOperationsRepository {
|
||||
(account) => account.accountId === interval.accountId,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (priority > 1) {
|
||||
for (const account of accounts.filter((account) =>
|
||||
intervals.some(
|
||||
(interval) => interval.accountId === account.accountId,
|
||||
),
|
||||
))
|
||||
)) {
|
||||
await tx.tbankAccount.update({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
@@ -289,13 +296,14 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
data: {
|
||||
historyFrom: new Date(
|
||||
account.openedOn
|
||||
? `${account.openedOn}T00:00:00Z`
|
||||
: '1970-01-01T00:00:00Z',
|
||||
account.openedOn ?
|
||||
`${account.openedOn}T00:00:00Z` :
|
||||
'1970-01-01T00:00:00Z',
|
||||
),
|
||||
historyBoundary: account.openedOn ? 'known' : 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||
where: {
|
||||
@@ -366,42 +374,42 @@ export class BrokerageOperationsRepository {
|
||||
) {
|
||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||
const additions =
|
||||
previous.scopeKey === scopeKey
|
||||
? []
|
||||
: uncoveredOperationIntervals(intervals, pending);
|
||||
previous.scopeKey === scopeKey ?
|
||||
[] :
|
||||
uncoveredOperationIntervals(intervals, pending);
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
intent: {
|
||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
||||
atomicIntervals: previousIntent?.atomic
|
||||
? [
|
||||
atomicIntervals: previousIntent?.atomic ?
|
||||
[
|
||||
...new Set([
|
||||
...(previousIntent.atomicIntervals ??
|
||||
previousIntent.intervals),
|
||||
...additions.map(intervalKey),
|
||||
]),
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
screenIntervals: [
|
||||
...new Set([
|
||||
...(previousIntent?.screenIntervals ??
|
||||
(previous.priority === 1
|
||||
? (previousIntent?.intervals ?? [])
|
||||
: [])),
|
||||
...(priority === 1
|
||||
? [...pending, ...additions]
|
||||
(previous.priority === 1 ?
|
||||
(previousIntent?.intervals ?? []) :
|
||||
[])),
|
||||
...(priority === 1 ?
|
||||
[...pending, ...additions]
|
||||
.filter((item) =>
|
||||
intervals.some((target) =>
|
||||
operationIntervalsOverlap(item, target),
|
||||
),
|
||||
)
|
||||
.map(intervalKey)
|
||||
: []),
|
||||
.map(intervalKey) :
|
||||
[]),
|
||||
]),
|
||||
],
|
||||
intervals: (priority < previous.priority
|
||||
? [
|
||||
intervals: (priority < previous.priority ?
|
||||
[
|
||||
...pending.filter((item) =>
|
||||
intervals.some((target) =>
|
||||
operationIntervalsOverlap(item, target),
|
||||
@@ -414,8 +422,8 @@ export class BrokerageOperationsRepository {
|
||||
operationIntervalsOverlap(item, target),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [...pending, ...additions]
|
||||
] :
|
||||
[...pending, ...additions]
|
||||
).map(intervalKey),
|
||||
},
|
||||
priority: Math.min(priority, previous.priority),
|
||||
@@ -427,9 +435,9 @@ export class BrokerageOperationsRepository {
|
||||
previous &&
|
||||
previous.state !== 'complete' &&
|
||||
previous.state !== 'superseded';
|
||||
const retained = recovering
|
||||
? (previousIntent?.intervals.map(decodeInterval) ?? [])
|
||||
: [];
|
||||
const retained = recovering ?
|
||||
(previousIntent?.intervals.map(decodeInterval) ?? []) :
|
||||
[];
|
||||
// A bridge between two active groups must fence both producers before
|
||||
// either can publish a subset of the newly combined result.
|
||||
for (const joined of touching) {
|
||||
@@ -448,26 +456,27 @@ export class BrokerageOperationsRepository {
|
||||
});
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } });
|
||||
}
|
||||
if (previous)
|
||||
if (previous) {
|
||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||
}
|
||||
const targets =
|
||||
recovering && !upgradeGroup && touching.length <= 1
|
||||
? previous?.scopeKey === scopeKey
|
||||
? retained
|
||||
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
|
||||
: operationIntervals([...retained, ...intervals]);
|
||||
recovering && !upgradeGroup && touching.length <= 1 ?
|
||||
previous?.scopeKey === scopeKey ?
|
||||
retained :
|
||||
[...retained, ...uncoveredOperationIntervals(intervals, retained)] :
|
||||
operationIntervals([...retained, ...intervals]);
|
||||
const atomicTargets = [
|
||||
...(publicationAtomic ? intervals : []),
|
||||
...(recovering && previousIntent?.atomic
|
||||
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
||||
...(recovering && previousIntent?.atomic ?
|
||||
(previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
||||
decodeInterval,
|
||||
)
|
||||
: []),
|
||||
) :
|
||||
[]),
|
||||
...touching.flatMap((work) => {
|
||||
const joined = work.intent as OperationsIntent;
|
||||
return joined.atomic
|
||||
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
|
||||
: [];
|
||||
return joined.atomic ?
|
||||
(joined.atomicIntervals ?? joined.intervals).map(decodeInterval) :
|
||||
[];
|
||||
}),
|
||||
];
|
||||
const intent = {
|
||||
@@ -486,14 +495,14 @@ export class BrokerageOperationsRepository {
|
||||
intervals.some((incoming) =>
|
||||
operationIntervalsOverlap(target, incoming),
|
||||
)) ||
|
||||
(
|
||||
previousIntent?.screenIntervals ??
|
||||
(previous?.priority === 1
|
||||
? (previousIntent?.intervals ?? [])
|
||||
: [])
|
||||
).some((key) =>
|
||||
operationIntervalsOverlap(target, decodeInterval(key)),
|
||||
),
|
||||
(
|
||||
previousIntent?.screenIntervals ??
|
||||
(previous?.priority === 1 ?
|
||||
(previousIntent?.intervals ?? []) :
|
||||
[])
|
||||
).some((key) =>
|
||||
operationIntervalsOverlap(target, decodeInterval(key)),
|
||||
),
|
||||
)
|
||||
.map(intervalKey),
|
||||
atomic: atomicTargets.length > 0,
|
||||
@@ -545,20 +554,20 @@ export class BrokerageOperationsRepository {
|
||||
marker.payload as PageProgress,
|
||||
]),
|
||||
);
|
||||
const group = intent.atomic
|
||||
? (intent.atomicIntervals ?? intent.intervals)
|
||||
: intent.intervals;
|
||||
const group = intent.atomic ?
|
||||
(intent.atomicIntervals ?? intent.intervals) :
|
||||
intent.intervals;
|
||||
const nextKey = group.find((key) => !progress.get(key)?.complete);
|
||||
return {
|
||||
priority: work.priority,
|
||||
atomic: intent.atomic,
|
||||
intervals: group.map(decodeInterval),
|
||||
next: nextKey
|
||||
? {
|
||||
next: nextKey ?
|
||||
{
|
||||
interval: decodeInterval(nextKey),
|
||||
cursor: progress.get(nextKey)?.cursor || undefined,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -582,8 +591,9 @@ export class BrokerageOperationsRepository {
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!work || work.state !== 'superseded')
|
||||
if (!work || work.state !== 'superseded') {
|
||||
return work ? { ...work, id } : null;
|
||||
}
|
||||
target = (work.progress as { successorId: string }).successorId;
|
||||
}
|
||||
throw new Error('Cyclic operations work successor');
|
||||
@@ -599,7 +609,7 @@ export class BrokerageOperationsRepository {
|
||||
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
});
|
||||
if (invalidToken)
|
||||
if (invalidToken) {
|
||||
await tx.tbankConnection.update({
|
||||
where: {
|
||||
userId_revision: {
|
||||
@@ -609,6 +619,7 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
data: { invalidToken: true },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -636,34 +647,40 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
});
|
||||
const progress = marker?.payload as PageProgress | undefined;
|
||||
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? ''))
|
||||
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) {
|
||||
throw new Error('Unexpected operations page');
|
||||
if (response.hasNext === undefined)
|
||||
}
|
||||
if (response.hasNext === undefined) {
|
||||
throw new Error('Missing operations pagination state');
|
||||
}
|
||||
const nextCursor = response.nextCursor ?? '';
|
||||
if (
|
||||
response.hasNext &&
|
||||
(!nextCursor ||
|
||||
nextCursor === cursor ||
|
||||
progress?.seen.includes(nextCursor))
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid operations continuation');
|
||||
}
|
||||
for (const operation of response.items ?? []) {
|
||||
if (!operation.id?.trim())
|
||||
if (!operation.id?.trim()) {
|
||||
throw new Error('Missing broker operation identity');
|
||||
}
|
||||
const occurredAt = operationTimestamp(operation.date);
|
||||
if (
|
||||
operation.date?.seconds === undefined ||
|
||||
!Number.isFinite(occurredAt.valueOf())
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid operation timestamp');
|
||||
}
|
||||
if (
|
||||
occurredAt < interval.from ||
|
||||
occurredAt >= interval.to ||
|
||||
(interval.instrumentUid &&
|
||||
operation.instrumentUid !== interval.instrumentUid)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({ ...operation, cursor: undefined }),
|
||||
) as Prisma.InputJsonObject;
|
||||
@@ -672,10 +689,12 @@ export class BrokerageOperationsRepository {
|
||||
const previous = await tx.tbankSyncStage.findUnique({
|
||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||
});
|
||||
if (previous && !isDeepStrictEqual(previous.payload, payload))
|
||||
if (previous && !isDeepStrictEqual(previous.payload, payload)) {
|
||||
throw new Error('Conflicting broker operation identity');
|
||||
if (!previous)
|
||||
}
|
||||
if (!previous) {
|
||||
await tx.tbankSyncStage.create({ data: { ...identity, payload } });
|
||||
}
|
||||
}
|
||||
const payload: PageProgress = {
|
||||
complete: !response.hasNext,
|
||||
@@ -712,8 +731,9 @@ export class BrokerageOperationsRepository {
|
||||
fetchedAt: Date,
|
||||
advanceWork = false,
|
||||
): Promise<boolean> {
|
||||
if (!intervals.length)
|
||||
if (!intervals.length) {
|
||||
throw new Error('Empty operations publication group');
|
||||
}
|
||||
return this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
if (!(await claim(tx, attempt))) return false;
|
||||
@@ -734,8 +754,9 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!(marker?.payload as PageProgress | undefined)?.complete)
|
||||
if (!(marker?.payload as PageProgress | undefined)?.complete) {
|
||||
throw new Error('Incomplete operations publication group');
|
||||
}
|
||||
const staged = await tx.tbankSyncStage.findMany({
|
||||
where: { ...stage, itemKey: { not: progressItem } },
|
||||
});
|
||||
@@ -745,10 +766,11 @@ export class BrokerageOperationsRepository {
|
||||
if (
|
||||
previous !== undefined &&
|
||||
!isDeepStrictEqual(previous, row.payload)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Conflicting broker operation identity across intervals',
|
||||
);
|
||||
}
|
||||
groupRows.set(identity, row.payload);
|
||||
}
|
||||
const rows = staged.filter((row) => {
|
||||
@@ -764,16 +786,16 @@ export class BrokerageOperationsRepository {
|
||||
where: {
|
||||
...scope,
|
||||
occurredAt: { gte: interval.from, lt: interval.to },
|
||||
...(interval.instrumentUid
|
||||
? { instrumentUid: interval.instrumentUid }
|
||||
: {}),
|
||||
...(interval.instrumentUid ?
|
||||
{ instrumentUid: interval.instrumentUid } :
|
||||
{}),
|
||||
},
|
||||
});
|
||||
const incoming = new Set(rows.map((row) => row.itemKey));
|
||||
const removed = existing.filter(
|
||||
(row) => !incoming.has(row.brokerOperationId),
|
||||
);
|
||||
for (const row of removed)
|
||||
for (const row of removed) {
|
||||
await tx.tbankOperation.delete({
|
||||
where: {
|
||||
userId_revision_accountId_brokerOperationId: {
|
||||
@@ -782,6 +804,7 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (removed.length) changedAccounts.add(interval.accountId);
|
||||
for (const row of rows) {
|
||||
const operation = row.payload as TbankOperationItem;
|
||||
@@ -789,8 +812,9 @@ export class BrokerageOperationsRepository {
|
||||
const previous = await tx.tbankOperation.findUnique({
|
||||
where: { userId_revision_accountId_brokerOperationId: identity },
|
||||
});
|
||||
if (previous && isDeepStrictEqual(previous.payload, operation))
|
||||
if (previous && isDeepStrictEqual(previous.payload, operation)) {
|
||||
continue;
|
||||
}
|
||||
const data = {
|
||||
occurredAt: operationTimestamp(operation.date),
|
||||
instrumentUid: operation.instrumentUid ?? null,
|
||||
@@ -818,7 +842,7 @@ export class BrokerageOperationsRepository {
|
||||
update: { fetchedAt, asOf: interval.to },
|
||||
});
|
||||
}
|
||||
for (const accountId of changedAccounts)
|
||||
for (const accountId of changedAccounts) {
|
||||
await tx.tbankAccount.update({
|
||||
where: {
|
||||
userId_revision_accountId: {
|
||||
@@ -829,7 +853,8 @@ export class BrokerageOperationsRepository {
|
||||
},
|
||||
data: { operationsRevision: { increment: 1 } },
|
||||
});
|
||||
for (const interval of intervals)
|
||||
}
|
||||
for (const interval of intervals) {
|
||||
await tx.tbankSyncStage.deleteMany({
|
||||
where: {
|
||||
workId: attempt.id,
|
||||
@@ -837,6 +862,7 @@ export class BrokerageOperationsRepository {
|
||||
sourceKey: intervalKey(interval),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (advanceWork) {
|
||||
const work = await tx.tbankSyncWork.findUniqueOrThrow({
|
||||
where: { id: attempt.id },
|
||||
@@ -867,9 +893,9 @@ export class BrokerageOperationsRepository {
|
||||
intent.screenIntervals ??
|
||||
(work.priority === 1 ? intent.intervals : [])
|
||||
).includes(key),
|
||||
)
|
||||
? 1
|
||||
: 2,
|
||||
) ?
|
||||
1 :
|
||||
2,
|
||||
progress: { remainingIntervals: remaining.length },
|
||||
},
|
||||
});
|
||||
@@ -882,8 +908,10 @@ export class BrokerageOperationsRepository {
|
||||
}
|
||||
|
||||
function intervalKey(interval: OperationInterval): string {
|
||||
if (!(interval.from < interval.to))
|
||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||
if (!(interval.from < interval.to)) {
|
||||
throw new Error('Invalid operations interval');
|
||||
}
|
||||
return JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.from.toISOString(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
|
||||
export type { OperationInterval } from './domain/operations-sync-plan';
|
||||
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||
export { operationCategories } from './presentation/brokerage-operation.dto';
|
||||
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule],
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../investment-resource.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -16,9 +12,13 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../investment-resource.dto';
|
||||
|
||||
const MAX_CURSOR_LENGTH = 4096;
|
||||
const MAX_OPERATIONS_PAGE_SIZE = 1000;
|
||||
@@ -102,25 +102,25 @@ export class BrokerageOperationDto {
|
||||
declare description: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare figi: string | null;
|
||||
declare figi: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentUid: string | null;
|
||||
declare instrumentUid: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare ticker: string | null;
|
||||
declare ticker: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentType: string | null;
|
||||
declare instrumentType: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare logoName: string | null;
|
||||
declare logoName: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare payment: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare quantity: string | null;
|
||||
declare quantity: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare price: MoneyDto | null;
|
||||
@@ -140,7 +140,7 @@ export class BrokerageOperationsPageDto {
|
||||
declare items: BrokerageOperationDto[];
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare nextCursor: string | null;
|
||||
declare nextCursor: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare hasNext: boolean;
|
||||
@@ -152,6 +152,7 @@ export class BrokerageOperationsPageDto {
|
||||
export class BrokerageOperationsResourceDto {
|
||||
@ApiProperty({ type: BrokerageOperationsPageDto, nullable: true })
|
||||
declare data: BrokerageOperationsPageDto | null;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import {
|
||||
BrokerageOperationsResourceDto,
|
||||
BrokerageOperationsQueryDto,
|
||||
} from './brokerage-operation.dto';
|
||||
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
||||
import {
|
||||
BrokerageOperationsQueryDto,
|
||||
BrokerageOperationsResourceDto,
|
||||
} from './brokerage-operation.dto';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
@ApiProblemResponses()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import {
|
||||
ForecastRepository,
|
||||
type ForecastSourceVersion,
|
||||
@@ -12,16 +14,16 @@ export class ForecastService {
|
||||
constructor(private readonly repository: ForecastRepository) {}
|
||||
|
||||
async result<T extends object>(input: {
|
||||
userId: string;
|
||||
revision: string;
|
||||
membershipRevision: number;
|
||||
scopeKey: string;
|
||||
portfolio: InvestmentResourceMetaDto;
|
||||
sources: {
|
||||
version: ForecastSourceVersion | null;
|
||||
meta: InvestmentResourceMetaDto;
|
||||
}[];
|
||||
candidate?: T;
|
||||
membershipRevision: number;
|
||||
portfolio: InvestmentResourceMetaDto;
|
||||
revision: string;
|
||||
scopeKey: string;
|
||||
sources: {
|
||||
meta: InvestmentResourceMetaDto;
|
||||
version: ForecastSourceVersion | null;
|
||||
}[];
|
||||
userId: string;
|
||||
}) {
|
||||
const dependencies = [
|
||||
input.portfolio,
|
||||
@@ -31,8 +33,8 @@ export class ForecastService {
|
||||
...input,
|
||||
portfolioProjection: input.portfolio.dataRevision,
|
||||
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
||||
dependency.resourceKey.startsWith('portfolio/')
|
||||
? [
|
||||
dependency.resourceKey.startsWith('portfolio/') ?
|
||||
[
|
||||
{
|
||||
accountId: decodeURIComponent(
|
||||
dependency.resourceKey.slice('portfolio/'.length),
|
||||
@@ -41,65 +43,65 @@ export class ForecastService {
|
||||
(dependency as InvestmentResourceMetaDto).dataRevision,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
),
|
||||
sources: input.sources.flatMap((source) =>
|
||||
source.version ? [source.version] : [],
|
||||
),
|
||||
timestamps: dependencies.flatMap((dependency) =>
|
||||
dependency.lastSuccessfulAt
|
||||
? [
|
||||
dependency.lastSuccessfulAt ?
|
||||
[
|
||||
{
|
||||
resourceKey: dependency.resourceKey,
|
||||
at: dependency.lastSuccessfulAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
),
|
||||
});
|
||||
const timestamps = stored?.sourceTimestamps as
|
||||
{ resourceKey: string; at: string }[] | undefined;
|
||||
undefined | { at: string; resourceKey: string }[];
|
||||
const first = timestamps?.map((entry) => entry.at).sort()[0];
|
||||
const work =
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'running') ??
|
||||
dependencies
|
||||
.map((dependency) => dependency.sync)
|
||||
.find((sync) => sync.state === 'failed');
|
||||
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'
|
||||
? {
|
||||
typeof payload.positionAt === 'string' ?
|
||||
{
|
||||
...payload,
|
||||
positionIsStale: tbankSourceNeedsRefresh(
|
||||
'portfolio',
|
||||
new Date(payload.positionAt),
|
||||
new Date(Date.now()),
|
||||
),
|
||||
}
|
||||
: payload;
|
||||
} :
|
||||
payload;
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? 0),
|
||||
availability: stored
|
||||
? 'complete'
|
||||
: dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
)
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
availability: stored ?
|
||||
'complete' :
|
||||
dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
) ?
|
||||
'partial' :
|
||||
'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs:
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY,
|
||||
data && 'positionIsStale' in data && data.positionIsStale === true ?
|
||||
0 :
|
||||
Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
dependencies,
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('persistent portfolio projection', () => {
|
||||
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
||||
@@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => {
|
||||
let index = 0;
|
||||
index < 10 && !repository.publish.mock.calls.length;
|
||||
index += 1
|
||||
)
|
||||
) {
|
||||
await immediate();
|
||||
}
|
||||
const snapshot = (
|
||||
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
||||
)[0][2];
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
PortfolioRepository,
|
||||
portfolioFreshForMs,
|
||||
portfolioWorkKey,
|
||||
} from '../infrastructure/portfolio.repository';
|
||||
import {
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -21,22 +14,30 @@ import {
|
||||
subtract,
|
||||
} from '../../../../common/decimal';
|
||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
type TbankInstrument,
|
||||
type MetadataIdentity,
|
||||
metadataKey,
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankMoney,
|
||||
type TbankInstrument,
|
||||
TbankMembershipService,
|
||||
TbankMoney,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||
import {
|
||||
portfolioFreshForMs,
|
||||
PortfolioRepository,
|
||||
portfolioWorkKey,
|
||||
} from '../infrastructure/portfolio.repository';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
@@ -80,7 +81,7 @@ export class PortfolioService {
|
||||
userId,
|
||||
connection.revision,
|
||||
);
|
||||
if (discovery)
|
||||
if (discovery) {
|
||||
void discovery.done
|
||||
.then(async () => {
|
||||
const current = await this.access.local(userId, accountId);
|
||||
@@ -88,12 +89,13 @@ export class PortfolioService {
|
||||
for (const account of current.accounts.filter(
|
||||
(account) =>
|
||||
accountId === 'all' || account.accountId === accountId,
|
||||
))
|
||||
)) {
|
||||
await this.queueSnapshot(
|
||||
userId,
|
||||
current.revision,
|
||||
account.accountId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
this.logger.event(
|
||||
@@ -102,22 +104,25 @@ export class PortfolioService {
|
||||
{ userId },
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const account of (discovery ? [] : connection.accounts).filter(
|
||||
(account) => accountId === 'all' || account.accountId === accountId,
|
||||
))
|
||||
)) {
|
||||
await this.queueSnapshot(
|
||||
userId,
|
||||
connection.revision,
|
||||
account.accountId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const saved = await this.repository.read(userId, connection.revision);
|
||||
if (
|
||||
saved?.membershipAt &&
|
||||
accountId !== 'all' &&
|
||||
!saved.accounts.some((account) => account.accountId === accountId)
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (!observe) {
|
||||
const identities = new Map<string, MetadataIdentity>();
|
||||
for (const account of (saved?.accounts ?? []).filter(
|
||||
@@ -125,17 +130,20 @@ export class PortfolioService {
|
||||
)) {
|
||||
const snapshot = account.portfolio?.payload as unknown as
|
||||
PortfolioDto | undefined;
|
||||
for (const position of snapshot?.positions ?? [])
|
||||
for (const position of snapshot?.positions ?? []) {
|
||||
identities.set(metadataKey(position), position);
|
||||
}
|
||||
}
|
||||
const savedMetadata = await this.metadata.savedMany([
|
||||
...identities.values(),
|
||||
]);
|
||||
for (const identity of identities.values())
|
||||
if (!savedMetadata.get(metadataKey(identity))?.fresh)
|
||||
for (const identity of identities.values()) {
|
||||
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
|
||||
await this.metadata.request(userId, connection.revision, identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!observe)
|
||||
if (!observe) {
|
||||
void this.candles
|
||||
.prefetch(
|
||||
userId,
|
||||
@@ -156,6 +164,7 @@ export class PortfolioService {
|
||||
.catch(() =>
|
||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
||||
);
|
||||
}
|
||||
const membershipMeta = investmentResourceMeta({
|
||||
resourceKey: 'accounts/membership',
|
||||
dataRevision: String(saved?.membershipRevision ?? 0),
|
||||
@@ -175,15 +184,15 @@ export class PortfolioService {
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
openedOn: account.openedOn,
|
||||
snapshot: account.portfolio
|
||||
? {
|
||||
snapshot: account.portfolio ?
|
||||
{
|
||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
||||
isStale:
|
||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
||||
portfolioFreshForMs,
|
||||
}
|
||||
: null,
|
||||
} :
|
||||
null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||
dataRevision: String(account.portfolio?.dataRevision ?? 0),
|
||||
@@ -209,8 +218,8 @@ export class PortfolioService {
|
||||
);
|
||||
const metadataDependencies = [...metadata.entries()].flatMap(
|
||||
([key, source]) =>
|
||||
source.complete && source.data
|
||||
? [
|
||||
source.complete && source.data ?
|
||||
[
|
||||
investmentResourceMeta({
|
||||
resourceKey: `instrument/metadata/${key}`,
|
||||
dataRevision: source.dataRevision,
|
||||
@@ -219,20 +228,20 @@ export class PortfolioService {
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: null,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
const portfolios = saved.accounts.flatMap((account) =>
|
||||
account.snapshot
|
||||
? [
|
||||
account.snapshot ?
|
||||
[
|
||||
{
|
||||
...account.snapshot,
|
||||
positions: account.snapshot.positions.map((position) => {
|
||||
const shared = metadata.get(metadataKey(position))?.data;
|
||||
return {
|
||||
...position,
|
||||
...(shared
|
||||
? {
|
||||
...(shared ?
|
||||
{
|
||||
displayName:
|
||||
shared.name?.trim() ||
|
||||
shared.ticker?.trim() ||
|
||||
@@ -241,28 +250,29 @@ export class PortfolioService {
|
||||
isin: shared.isin?.trim() || position.isin,
|
||||
logoName:
|
||||
shared.brand?.logoName?.trim() || position.logoName,
|
||||
}
|
||||
: {}),
|
||||
...(accountId === 'all'
|
||||
? { account: { id: account.id, name: account.name } }
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
...(accountId === 'all' ?
|
||||
{ account: { id: account.id, name: account.name } } :
|
||||
{}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
const lastSuccessfulAt =
|
||||
// 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.
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
saved.membership.lastSuccessfulAt;
|
||||
const data =
|
||||
missing || !lastSuccessfulAt
|
||||
? null
|
||||
: {
|
||||
missing || !lastSuccessfulAt ?
|
||||
null :
|
||||
{
|
||||
lastSuccessfulAt,
|
||||
isStale: portfolios.length
|
||||
? portfolios.some((portfolio) => portfolio.isStale)
|
||||
: saved.membership.freshness !== 'fresh',
|
||||
isStale: portfolios.length ?
|
||||
portfolios.some((portfolio) => portfolio.isStale) :
|
||||
saved.membership.freshness !== 'fresh',
|
||||
totalsByCurrency: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||
),
|
||||
@@ -279,10 +289,10 @@ export class PortfolioService {
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'running') ??
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'failed') ??
|
||||
saved.membership.sync;
|
||||
saved.accounts
|
||||
.map((account) => account.meta.sync)
|
||||
.find((work) => work.state === 'failed') ??
|
||||
saved.membership.sync;
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
@@ -294,15 +304,15 @@ export class PortfolioService {
|
||||
]),
|
||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||
]),
|
||||
availability: missing
|
||||
? portfolios.length
|
||||
? 'partial'
|
||||
: 'missing'
|
||||
: 'complete',
|
||||
availability: missing ?
|
||||
portfolios.length ?
|
||||
'partial' :
|
||||
'missing' :
|
||||
'complete',
|
||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||
freshForMs: portfolios.length
|
||||
? portfolioFreshForMs
|
||||
: membershipFreshForMs,
|
||||
freshForMs: portfolios.length ?
|
||||
portfolioFreshForMs :
|
||||
membershipFreshForMs,
|
||||
work: work.id ? { ...work, id: work.id } : null,
|
||||
dependencies: [
|
||||
saved.membership,
|
||||
@@ -333,11 +343,12 @@ export class PortfolioService {
|
||||
force,
|
||||
);
|
||||
if (!requested) return null;
|
||||
if (!requested.launch)
|
||||
if (!requested.launch) {
|
||||
return {
|
||||
id: requested.work.id,
|
||||
done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(),
|
||||
};
|
||||
}
|
||||
const { id, attemptId } = requested.work;
|
||||
this.active.add(attemptId);
|
||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||
@@ -360,22 +371,24 @@ export class PortfolioService {
|
||||
snapshot,
|
||||
);
|
||||
if (published) {
|
||||
for (const position of snapshot.positions)
|
||||
for (const position of snapshot.positions) {
|
||||
await this.metadata.request(userId, revision, position);
|
||||
if (!force)
|
||||
}
|
||||
if (!force) {
|
||||
await this.candles.prefetch(
|
||||
userId,
|
||||
revision,
|
||||
snapshot.positions,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN'
|
||||
)
|
||||
) {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else await this.repository.fail(attempt);
|
||||
} else await this.repository.fail(attempt);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
@@ -396,8 +409,9 @@ export class PortfolioService {
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
const result = await this.read(userId, accountId);
|
||||
if (!result.data)
|
||||
if (!result.data) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -407,12 +421,12 @@ export class PortfolioService {
|
||||
|
||||
private async normalize(
|
||||
upstream: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: { units?: string | number; nano?: string | number };
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
currentPrice?: TbankMoney;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
instrumentUid?: string;
|
||||
quantity?: { nano?: number | string; units?: number | string };
|
||||
}[],
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
@@ -450,8 +464,8 @@ export class PortfolioService {
|
||||
|
||||
private position(
|
||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
||||
metadata: TbankInstrument | null,
|
||||
): PortfolioPositionDto | null {
|
||||
metadata: null | TbankInstrument,
|
||||
): null | PortfolioPositionDto {
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (isZero(quantity)) return null;
|
||||
const figi = position.figi?.trim() || null;
|
||||
@@ -462,8 +476,8 @@ export class PortfolioService {
|
||||
const absoluteReturn =
|
||||
currentPrice &&
|
||||
averagePurchasePrice &&
|
||||
currentPrice.currency === averagePurchasePrice.currency
|
||||
? {
|
||||
currentPrice.currency === averagePurchasePrice.currency ?
|
||||
{
|
||||
currency: currentPrice.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
@@ -474,8 +488,8 @@ export class PortfolioService {
|
||||
quantity,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
} :
|
||||
null;
|
||||
return {
|
||||
figi,
|
||||
instrumentUid,
|
||||
@@ -507,8 +521,8 @@ export class PortfolioService {
|
||||
|
||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
return currency && value ?
|
||||
{ currency, value: formatDecimal(decimal(value)) } :
|
||||
null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { portfolioStructure } from './portfolio-structure';
|
||||
import { PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||
import { portfolioStructure } from './portfolio-structure';
|
||||
|
||||
const position = (
|
||||
type: string,
|
||||
value: string | null,
|
||||
value: null | string,
|
||||
currency = 'RUB',
|
||||
): PortfolioPositionDto => ({
|
||||
type,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
const SHARE_PERCENT_DECIMAL_PLACES = 6;
|
||||
const PERCENT_SCALED_FACTOR = 100_000_000n;
|
||||
|
||||
@@ -13,14 +13,14 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
|
||||
|
||||
// Build from positions and cash, never from totals (which already include cash).
|
||||
export function portfolioStructure(
|
||||
positions: { type: string; currentValue: Money | null }[],
|
||||
positions: { currentValue: Money | null; type: string }[],
|
||||
cash: Money[],
|
||||
) {
|
||||
const currencies = new Map<
|
||||
string,
|
||||
{
|
||||
values: ReturnType<typeof decimalFromString>[];
|
||||
hasNegativeValues: boolean;
|
||||
values: ReturnType<typeof decimalFromString>[];
|
||||
}
|
||||
>();
|
||||
const unvalued = new Map<string, number>();
|
||||
@@ -31,14 +31,15 @@ export function portfolioStructure(
|
||||
};
|
||||
const index = categoryTypes.findIndex((category) => category === type);
|
||||
const value = decimalFromString(money.value);
|
||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
||||
group.values[index] = add(group.values[index], value);
|
||||
group.hasNegativeValues ||= value.value < 0n;
|
||||
currencies.set(money.currency, group);
|
||||
};
|
||||
for (const position of positions) {
|
||||
const type = ['STOCK', 'BOND'].includes(position.type)
|
||||
? position.type
|
||||
: 'OTHER';
|
||||
const type = ['BOND', 'STOCK'].includes(position.type) ?
|
||||
position.type :
|
||||
'OTHER';
|
||||
if (position.currentValue) include(type, position.currentValue);
|
||||
else unvalued.set(type, (unvalued.get(type) ?? 0) + 1);
|
||||
}
|
||||
@@ -53,21 +54,22 @@ export function portfolioStructure(
|
||||
const canCalculateShares =
|
||||
!unvalued.size && !group.hasNegativeValues && total.value > 0n;
|
||||
const categories = group.values.map((value, index) => ({
|
||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
||||
type: categoryTypes[index],
|
||||
value: formatDecimal(value),
|
||||
// Rounded to six decimal places using integer arithmetic. Only the
|
||||
// resulting dimensionless percentage is passed to chart geometry.
|
||||
sharePercent: canCalculateShares
|
||||
? formatDecimal({
|
||||
sharePercent: canCalculateShares ?
|
||||
formatDecimal({
|
||||
value:
|
||||
(value.value *
|
||||
10n ** BigInt(total.scale - value.scale) *
|
||||
PERCENT_SCALED_FACTOR +
|
||||
total.value / 2n) /
|
||||
total.value,
|
||||
total.value,
|
||||
scale: SHARE_PERCENT_DECIMAL_PLACES,
|
||||
})
|
||||
: null,
|
||||
}) :
|
||||
null,
|
||||
}));
|
||||
return {
|
||||
currency,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
export function sumMoneyByCurrency(amounts: Money[]): Money[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
export type ForecastSourceVersion = {
|
||||
instrumentUid: string;
|
||||
source: string;
|
||||
kind: string;
|
||||
rangeKey: string;
|
||||
dataRevision: number;
|
||||
fetchedAt: Date;
|
||||
instrumentUid: string;
|
||||
kind: string;
|
||||
rangeKey: string;
|
||||
source: string;
|
||||
};
|
||||
const forecastSourceReadBatchSize = 100;
|
||||
|
||||
@@ -18,15 +20,15 @@ export class ForecastRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
resolve(input: {
|
||||
userId: string;
|
||||
revision: string;
|
||||
membershipRevision: number;
|
||||
scopeKey: string;
|
||||
candidate?: object;
|
||||
membershipRevision: number;
|
||||
portfolioProjection: string;
|
||||
portfolios: { accountId: string; dataRevision: number }[];
|
||||
revision: string;
|
||||
scopeKey: string;
|
||||
sources: ForecastSourceVersion[];
|
||||
timestamps: { resourceKey: string; at: string }[];
|
||||
timestamps: { at: string; resourceKey: string }[];
|
||||
userId: string;
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const owner = { userId: input.userId, revision: input.revision };
|
||||
@@ -54,9 +56,9 @@ export class ForecastRepository {
|
||||
where: { userId_revision_scopeKey: key },
|
||||
});
|
||||
const fallback =
|
||||
previous?.membershipRevision === input.membershipRevision
|
||||
? previous
|
||||
: null;
|
||||
previous?.membershipRevision === input.membershipRevision ?
|
||||
previous :
|
||||
null;
|
||||
if (!input.candidate) return fallback;
|
||||
const portfolios = await tx.tbankPortfolio.findMany({
|
||||
where: {
|
||||
@@ -74,15 +76,16 @@ export class ForecastRepository {
|
||||
row.dataRevision === expected.dataRevision,
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
// Four identity parameters per source: stay below SQLite's parameter limit.
|
||||
const sources: ForecastSourceVersion[] = [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < input.sources.length;
|
||||
offset += forecastSourceReadBatchSize
|
||||
)
|
||||
) {
|
||||
sources.push(
|
||||
...(await tx.tbankInstrumentSource.findMany({
|
||||
where: {
|
||||
@@ -106,6 +109,7 @@ export class ForecastRepository {
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.sources.some(
|
||||
(expected) =>
|
||||
@@ -119,8 +123,9 @@ export class ForecastRepository {
|
||||
row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(),
|
||||
),
|
||||
)
|
||||
)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
const revisions = JSON.parse(
|
||||
JSON.stringify({
|
||||
portfolioProjection: input.portfolioProjection,
|
||||
@@ -133,8 +138,9 @@ export class ForecastRepository {
|
||||
fallback &&
|
||||
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
||||
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
||||
)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
const data = {
|
||||
membershipRevision: input.membershipRevision,
|
||||
payload: JSON.parse(
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import {
|
||||
tbankSourceTtlMs,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
type PortfolioAttempt = {
|
||||
id: string;
|
||||
attemptId: string;
|
||||
userId: string;
|
||||
id: string;
|
||||
revision: string;
|
||||
userId: string;
|
||||
};
|
||||
export const portfolioFreshForMs = tbankSourceTtlMs.portfolio;
|
||||
|
||||
@@ -71,8 +73,9 @@ export class PortfolioRepository {
|
||||
!force &&
|
||||
snapshot &&
|
||||
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date())
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
@@ -81,8 +84,9 @@ export class PortfolioRepository {
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
active.has(previous.attemptId)
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
state: 'running',
|
||||
attemptId: randomUUID(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from '../../../../common/decimal';
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
|
||||
import {
|
||||
TbankPortfolio,
|
||||
TbankPositions,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export { ForecastService } from './application/forecast.service';
|
||||
export { PortfolioService } from './application/portfolio.service';
|
||||
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
} from './presentation/portfolio.dto';
|
||||
export { PortfolioService } from './application/portfolio.service';
|
||||
|
||||
export { ForecastService } from './application/forecast.service';
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ForecastService } from './application/forecast.service';
|
||||
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioController } from './presentation/portfolio.controller';
|
||||
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';
|
||||
import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
||||
import { PortfolioController } from './presentation/portfolio.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { InvestmentReadQueryDto } from '../../investment-resource.dto';
|
||||
import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import { PortfolioResourceDto } from './portfolio.dto';
|
||||
import { InvestmentReadQueryDto } from '../../investment-resource.dto';
|
||||
import { PortfolioService } from '../application/portfolio.service';
|
||||
import { PortfolioResourceDto } from './portfolio.dto';
|
||||
|
||||
@ApiTags('brokerage-accounts')
|
||||
@ApiProblemResponses()
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
export class PortfolioPositionDto {
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
instrumentUid?: string | null;
|
||||
instrumentUid?: null | string;
|
||||
|
||||
@ApiPropertyOptional({ type: AccountReferenceDto })
|
||||
account?: AccountReferenceDto;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare figi: string | null;
|
||||
declare figi: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare displayName: string;
|
||||
@@ -44,13 +45,13 @@ export class PortfolioPositionDto {
|
||||
declare absoluteReturn: MoneyDto | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare isin: string | null;
|
||||
declare isin: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare ticker: string | null;
|
||||
declare ticker: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare logoName: string | null;
|
||||
declare logoName: null | string;
|
||||
}
|
||||
|
||||
export class PortfolioStructureCategoryDto {
|
||||
@@ -66,7 +67,7 @@ export class PortfolioStructureCategoryDto {
|
||||
description:
|
||||
'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.',
|
||||
})
|
||||
declare sharePercent: string | null;
|
||||
declare sharePercent: null | string;
|
||||
}
|
||||
|
||||
export class PortfolioStructureCurrencyDto {
|
||||
@@ -81,7 +82,7 @@ export class PortfolioStructureCurrencyDto {
|
||||
})
|
||||
declare hasNegativeValues: boolean;
|
||||
|
||||
@ApiProperty({ type: [PortfolioStructureCategoryDto] })
|
||||
@ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true })
|
||||
declare categories: PortfolioStructureCategoryDto[];
|
||||
}
|
||||
|
||||
@@ -94,11 +95,11 @@ export class PortfolioUnvaluedCategoryDto {
|
||||
}
|
||||
|
||||
export class PortfolioStructureDto {
|
||||
@ApiProperty({ type: [PortfolioStructureCurrencyDto] })
|
||||
@ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true })
|
||||
declare currencies: PortfolioStructureCurrencyDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [PortfolioUnvaluedCategoryDto],
|
||||
type: PortfolioUnvaluedCategoryDto, isArray: true,
|
||||
description:
|
||||
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
|
||||
})
|
||||
@@ -127,7 +128,8 @@ export class PortfolioDto {
|
||||
|
||||
export class PortfolioResourceDto {
|
||||
@ApiProperty({ type: PortfolioDto, nullable: true })
|
||||
declare data: PortfolioDto | null;
|
||||
declare data: null | PortfolioDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import {
|
||||
|
||||
@@ -16,5 +16,5 @@ export class TbankConnectionStatusDto {
|
||||
declare connected: boolean;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
declare connectedAt: string | null;
|
||||
declare connectedAt: null | string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankConnectionController } from './tbank-connection.controller';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
supportedTbankAccounts,
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
supportedTbankAccounts,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
|
||||
@@ -40,8 +40,9 @@ describe('manual sync screen boundary', () => {
|
||||
from: '2026-02-01',
|
||||
to: '2026-01-01',
|
||||
},
|
||||
] as Parameters<typeof normalizeScreen>[0][])
|
||||
] as Parameters<typeof normalizeScreen>[0][]) {
|
||||
expect(() => normalizeScreen(scope)).toThrow();
|
||||
}
|
||||
});
|
||||
it('retains inclusive calendar days, rejecting instant values for date-only owners', () => {
|
||||
const scope = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
|
||||
// Reject fields from other screens before reserving any source work.
|
||||
@@ -41,68 +42,78 @@ export function normalizeScreen(
|
||||
});
|
||||
};
|
||||
if (!allowed[input.screen]) fail();
|
||||
for (const key of Object.keys(input))
|
||||
for (const key of Object.keys(input)) {
|
||||
if (
|
||||
key !== 'screen' &&
|
||||
input[key as keyof InvestmentScreenDto] !== undefined &&
|
||||
!allowed[input.screen].includes(key)
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
}
|
||||
const accountId = input.accountId?.trim();
|
||||
const query = input.query?.trim().toUpperCase();
|
||||
if (
|
||||
['account', 'operations', 'income', 'events'].includes(input.screen) &&
|
||||
['account', 'events', 'income', 'operations'].includes(input.screen) &&
|
||||
!accountId
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
if (
|
||||
['stocks', 'bonds'].includes(input.screen) &&
|
||||
(!query || query.length < 2)
|
||||
)
|
||||
fail();
|
||||
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
||||
if (input.accountId !== undefined && !accountId) fail();
|
||||
if (['income', 'events'].includes(input.screen)) {
|
||||
if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to))
|
||||
fail();
|
||||
for (const value of [input.from, input.to])
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||
}
|
||||
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
||||
if (
|
||||
['bonds', 'stocks'].includes(input.screen) &&
|
||||
(!query || query.length < 2)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
if (['bond', 'stock'].includes(input.screen) && !input.instrumentUid) fail();
|
||||
if (input.accountId !== undefined && !accountId) fail();
|
||||
if (['events', 'income'].includes(input.screen)) {
|
||||
if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to)) {
|
||||
fail();
|
||||
}
|
||||
for (const value of [input.from, input.to]) {
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||
}
|
||||
}
|
||||
if (input.from && input.to && new Date(input.from) > new Date(input.to)) {
|
||||
fail();
|
||||
}
|
||||
if (
|
||||
input.screen === 'events' &&
|
||||
input.to &&
|
||||
(input.from ?? new Date().toISOString().slice(0, 10)) > input.to
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
if (
|
||||
input.candleRange &&
|
||||
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
||||
)
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
// Fixed field order is part of coalescing, irrespective of JSON input order.
|
||||
return {
|
||||
screen: input.screen,
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(input.from ? { from: input.from } : {}),
|
||||
...(input.to ? { to: input.to } : {}),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid.toLowerCase() }
|
||||
: {}),
|
||||
...(input.instrumentUid ?
|
||||
{ instrumentUid: input.instrumentUid.toLowerCase() } :
|
||||
{}),
|
||||
...(query ? { query } : {}),
|
||||
...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}),
|
||||
...(input.candleRange
|
||||
? {
|
||||
...(input.candleRange ?
|
||||
{
|
||||
candleRange: {
|
||||
from: new Date(input.candleRange.from).toISOString(),
|
||||
to: new Date(input.candleRange.to).toISOString(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(input.categories
|
||||
? { categories: [...new Set(input.categories)].sort() }
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
...(input.categories ?
|
||||
// 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.
|
||||
{ categories: [...new Set(input.categories)].sort() } :
|
||||
{}),
|
||||
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
Body,
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
@@ -19,17 +21,17 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import {
|
||||
InvestmentScreenDto,
|
||||
IncomeInvestmentScreenDto,
|
||||
EventsInvestmentScreenDto,
|
||||
TimestampInvestmentScreenDto,
|
||||
IncomeInvestmentScreenDto,
|
||||
InvestmentScreenDto,
|
||||
investmentScreenSchema,
|
||||
TbankSyncAcceptedDto,
|
||||
TbankSyncStatusDto,
|
||||
TimestampInvestmentScreenDto,
|
||||
} from './tbank-sync.dto';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
|
||||
@@ -53,6 +55,7 @@ export class TbankSyncController {
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
forbidUnknownValues: true,
|
||||
expectedType: InvestmentScreenDto,
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
@@ -65,6 +68,7 @@ export class TbankSyncController {
|
||||
) {
|
||||
return this.sync.request(request.user.sub, input as InvestmentScreenDto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOkResponse({ type: TbankSyncStatusDto })
|
||||
status(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { operationCategories } from '../operations/operations.api';
|
||||
|
||||
@@ -46,12 +47,14 @@ export class InvestmentScreenDto {
|
||||
@ApiProperty({ enum: investmentScreens })
|
||||
@IsIn(investmentScreens)
|
||||
declare screen: (typeof investmentScreens)[number];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(maxAccountIdLength)
|
||||
accountId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
oneOf: [
|
||||
{ type: 'string', format: 'date' },
|
||||
@@ -61,6 +64,7 @@ export class InvestmentScreenDto {
|
||||
@IsOptional()
|
||||
@IsDateString({ strict: true })
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
oneOf: [
|
||||
{ type: 'string', format: 'date' },
|
||||
@@ -70,30 +74,36 @@ export class InvestmentScreenDto {
|
||||
@IsOptional()
|
||||
@IsDateString({ strict: true })
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
instrumentUid?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(maxQueryLength)
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] })
|
||||
@IsOptional()
|
||||
@IsIn(['month', 'halfYear', 'year', 'all'])
|
||||
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
|
||||
|
||||
@ApiPropertyOptional({ type: SyncCandleRangeDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => SyncCandleRangeDto)
|
||||
candleRange?: SyncCandleRangeDto;
|
||||
|
||||
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(operationCategories, { each: true })
|
||||
categories?: (typeof operationCategories)[number][];
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: maxPageSize })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@@ -106,6 +116,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||
'accountId',
|
||||
]) {
|
||||
@ApiProperty({ enum: ['income'] }) declare screen: 'income';
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType.
|
||||
@ApiProperty({ required: true }) declare accountId: string;
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
@@ -113,6 +124,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||
'Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.',
|
||||
})
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description:
|
||||
@@ -125,6 +137,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||
'accountId',
|
||||
]) {
|
||||
@ApiProperty({ enum: ['events'] }) declare screen: 'events';
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType.
|
||||
@ApiProperty({ required: true }) declare accountId: string;
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
@@ -132,6 +145,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [
|
||||
'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.',
|
||||
})
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description:
|
||||
@@ -157,11 +171,13 @@ export class TimestampInvestmentScreenDto extends OmitType(
|
||||
// The runtime validator retains legacy date-only inputs; the public contract uses instants.
|
||||
@ApiProperty({ enum: timestampScreens })
|
||||
declare screen: (typeof timestampScreens)[number];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date-time',
|
||||
description: 'Start instant for operations.',
|
||||
})
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date-time',
|
||||
description: 'End instant for operations.',
|
||||
@@ -194,6 +210,7 @@ export class TbankSyncAcceptedDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||
@ApiProperty({ type: Object, ...investmentScreenSchema })
|
||||
declare scope: InvestmentScreenDto;
|
||||
|
||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||
}
|
||||
export class TbankSyncProgressDto {
|
||||
@@ -205,13 +222,17 @@ export class TbankSyncStatusDto {
|
||||
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
|
||||
declare scope: InvestmentScreenDto | null;
|
||||
|
||||
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
||||
declare state: 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
declare state: 'cancelled' | 'failed' | 'running' | 'succeeded';
|
||||
|
||||
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
||||
string | null;
|
||||
null | string;
|
||||
|
||||
@ApiProperty({ type: TbankSyncProgressDto })
|
||||
declare progress: TbankSyncProgressDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
|
||||
declare resources: InvestmentResourceMetaDto[];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { OperationsModule } from '../operations/operations.module';
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { InstrumentsModule } from '../instruments/instruments.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 { TbankSyncService } from './tbank-sync.service';
|
||||
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||
import { TbankSyncService } from './tbank-sync.service';
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
|
||||
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
||||
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
|
||||
export type ScreenProgress = {
|
||||
children: string[];
|
||||
attempts?: Record<string, string>;
|
||||
children: string[];
|
||||
dispatched: boolean;
|
||||
};
|
||||
@Injectable()
|
||||
@@ -38,8 +40,9 @@ export class TbankSyncRepository {
|
||||
(await tx.tbankSyncWork.count({
|
||||
where: { userId, revision, id: { in: children }, state: 'running' },
|
||||
}))
|
||||
)
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
attemptId: randomUUID(),
|
||||
@@ -60,6 +63,7 @@ export class TbankSyncRepository {
|
||||
return { work, launch: true };
|
||||
});
|
||||
}
|
||||
|
||||
read(userId: string, revision: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const work = await tx.tbankSyncWork.findFirst({
|
||||
@@ -75,19 +79,20 @@ export class TbankSyncRepository {
|
||||
work,
|
||||
children: children.map((child) =>
|
||||
progress.attempts?.[child.id] &&
|
||||
progress.attempts[child.id] !== child.attemptId
|
||||
? { ...child, state: 'cancelled', errorCode: null }
|
||||
: child,
|
||||
progress.attempts[child.id] !== child.attemptId ?
|
||||
{ ...child, state: 'cancelled', errorCode: null } :
|
||||
child,
|
||||
),
|
||||
progress,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async progress(
|
||||
attempt: TbankSyncAttempt,
|
||||
children: string[],
|
||||
dispatched: boolean,
|
||||
errorCode: string | null = null,
|
||||
errorCode: null | string = null,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
import {
|
||||
Injectable,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
TbankAccessService,
|
||||
TbankMembershipService,
|
||||
InstrumentMetadataService,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
|
||||
import { IncomeAnalyticsService } from '../income/income-read.api';
|
||||
import {
|
||||
BrokerageOperationsService,
|
||||
BrokerageOperationsSyncService,
|
||||
type OperationInterval,
|
||||
} from '../operations/operations.api';
|
||||
BondIncomeService,
|
||||
StockIncomeService,
|
||||
} from '../instruments/instrument-income.api';
|
||||
import {
|
||||
CandleService,
|
||||
CatalogDiscoveryService,
|
||||
CatalogEnrichmentService,
|
||||
} from '../instruments/instruments.api';
|
||||
import {
|
||||
StockIncomeService,
|
||||
BondIncomeService,
|
||||
} from '../instruments/instrument-income.api';
|
||||
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
|
||||
import { IncomeAnalyticsService } from '../income/income-read.api';
|
||||
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
||||
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import {
|
||||
TbankSyncRepository,
|
||||
type ScreenIntent,
|
||||
} from './tbank-sync.repository';
|
||||
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
|
||||
import { normalizeScreen } from './tbank-screen';
|
||||
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 {
|
||||
type ScreenIntent,
|
||||
TbankSyncRepository,
|
||||
} from './tbank-sync.repository';
|
||||
|
||||
@Injectable()
|
||||
export class TbankSyncService {
|
||||
@@ -88,7 +90,7 @@ export class TbankSyncService {
|
||||
|
||||
private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) {
|
||||
const children: string[] = [];
|
||||
const add = async (ids: (string | null | undefined)[]) => {
|
||||
const add = async (ids: (null | string | undefined)[]) => {
|
||||
children.push(...ids.filter((id): id is string => Boolean(id)));
|
||||
await this.repository.progress(attempt, children, false);
|
||||
};
|
||||
@@ -107,14 +109,14 @@ export class TbankSyncService {
|
||||
accountScope === 'all' || account.accountId === accountScope,
|
||||
);
|
||||
if (
|
||||
['accounts', 'account', 'events', 'stock', 'bond'].includes(
|
||||
['account', 'accounts', 'bond', 'events', 'stock'].includes(
|
||||
scope.screen,
|
||||
)
|
||||
) {
|
||||
for (const account of scope.screen === 'stock' ||
|
||||
scope.screen === 'bond'
|
||||
? current.accounts
|
||||
: selectedAccounts) {
|
||||
scope.screen === 'bond' ?
|
||||
current.accounts :
|
||||
selectedAccounts) {
|
||||
const work = await this.portfolio.request(
|
||||
userId,
|
||||
revision,
|
||||
@@ -127,9 +129,9 @@ export class TbankSyncService {
|
||||
}
|
||||
const saved = await this.portfolio.read(
|
||||
userId,
|
||||
scope.screen === 'stock' || scope.screen === 'bond'
|
||||
? 'all'
|
||||
: accountScope,
|
||||
scope.screen === 'stock' || scope.screen === 'bond' ?
|
||||
'all' :
|
||||
accountScope,
|
||||
true,
|
||||
);
|
||||
const positions = saved.data?.positions ?? [];
|
||||
@@ -137,8 +139,8 @@ export class TbankSyncService {
|
||||
...new Map(
|
||||
positions.flatMap((position) =>
|
||||
position.instrumentUid &&
|
||||
(position.type === 'STOCK' || position.type === 'BOND')
|
||||
? [
|
||||
(position.type === 'STOCK' || position.type === 'BOND') ?
|
||||
[
|
||||
[
|
||||
position.instrumentUid,
|
||||
{
|
||||
@@ -147,27 +149,27 @@ export class TbankSyncService {
|
||||
historyFrom: null,
|
||||
},
|
||||
] as const,
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
),
|
||||
).values(),
|
||||
];
|
||||
const plan = planInvestmentScreen(
|
||||
{
|
||||
...scope,
|
||||
candleRange: scope.candleRange
|
||||
? {
|
||||
candleRange: scope.candleRange ?
|
||||
{
|
||||
from: new Date(scope.candleRange.from),
|
||||
to: new Date(scope.candleRange.to),
|
||||
}
|
||||
: undefined,
|
||||
} :
|
||||
undefined,
|
||||
},
|
||||
{
|
||||
accounts: selectedAccounts,
|
||||
instruments:
|
||||
scope.screen === 'stocks' || scope.screen === 'bonds'
|
||||
? []
|
||||
: instruments,
|
||||
scope.screen === 'stocks' || scope.screen === 'bonds' ?
|
||||
[] :
|
||||
instruments,
|
||||
membershipKnown: Boolean(current.membershipAt),
|
||||
},
|
||||
now,
|
||||
@@ -177,20 +179,20 @@ export class TbankSyncService {
|
||||
source.kind === 'operations' &&
|
||||
source.accountId &&
|
||||
source.from &&
|
||||
source.to
|
||||
? [
|
||||
source.to ?
|
||||
[
|
||||
{
|
||||
accountId: source.accountId,
|
||||
from: source.from,
|
||||
to: source.to,
|
||||
...(source.instrumentUid
|
||||
? { instrumentUid: source.instrumentUid }
|
||||
: {}),
|
||||
...(source.instrumentUid ?
|
||||
{ instrumentUid: source.instrumentUid } :
|
||||
{}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
] :
|
||||
[],
|
||||
);
|
||||
if (intervals.length)
|
||||
if (intervals.length) {
|
||||
await add([
|
||||
await this.operations.request(
|
||||
userId,
|
||||
@@ -200,24 +202,27 @@ export class TbankSyncService {
|
||||
revision,
|
||||
),
|
||||
]);
|
||||
}
|
||||
for (const source of plan.sources) {
|
||||
const uid = source.instrumentUid;
|
||||
const type = source.instrumentType === 'BOND' ? 'bond' : 'share';
|
||||
if (source.kind === 'catalog')
|
||||
if (source.kind === 'catalog') {
|
||||
await add(
|
||||
await this.discovery.refresh(userId, revision, {
|
||||
instrumentType: type,
|
||||
query: source.query ?? '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!uid) continue;
|
||||
if (source.kind === 'metadata')
|
||||
if (source.kind === 'metadata') {
|
||||
await add([
|
||||
await this.metadata.request(userId, revision, {
|
||||
instrumentUid: uid,
|
||||
}),
|
||||
]);
|
||||
if (source.kind === 'quote')
|
||||
}
|
||||
if (source.kind === 'quote') {
|
||||
await add(
|
||||
await this.sources.requestMany(
|
||||
userId,
|
||||
@@ -233,7 +238,8 @@ export class TbankSyncService {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (source.kind === 'company')
|
||||
}
|
||||
if (source.kind === 'company') {
|
||||
await add(
|
||||
await this.sources.requestMany(
|
||||
userId,
|
||||
@@ -244,7 +250,8 @@ export class TbankSyncService {
|
||||
['description', 'fundamentals'],
|
||||
),
|
||||
);
|
||||
if (source.kind === 'metrics')
|
||||
}
|
||||
if (source.kind === 'metrics') {
|
||||
await add(
|
||||
await this.sources.requestMany(
|
||||
userId,
|
||||
@@ -255,7 +262,8 @@ export class TbankSyncService {
|
||||
['coupons', 'yield', 'rating'],
|
||||
),
|
||||
);
|
||||
if (source.kind === 'calendar' && source.from && source.to)
|
||||
}
|
||||
if (source.kind === 'calendar' && source.from && source.to) {
|
||||
await add(
|
||||
await this.sources.requestMany(
|
||||
userId,
|
||||
@@ -264,11 +272,11 @@ export class TbankSyncService {
|
||||
[uid],
|
||||
true,
|
||||
[
|
||||
type === 'share'
|
||||
? 'dividends'
|
||||
: scope.screen === 'bond'
|
||||
? 'coupons'
|
||||
: 'bond-events',
|
||||
type === 'share' ?
|
||||
'dividends' :
|
||||
scope.screen === 'bond' ?
|
||||
'coupons' :
|
||||
'bond-events',
|
||||
],
|
||||
{
|
||||
from: source.from.toISOString().slice(0, 10),
|
||||
@@ -279,6 +287,7 @@ export class TbankSyncService {
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
||||
@@ -292,14 +301,14 @@ export class TbankSyncService {
|
||||
uid: scope.instrumentUid,
|
||||
instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
|
||||
period: scope.candlePeriod ?? 'month',
|
||||
...(scope.candleRange
|
||||
? {
|
||||
...(scope.candleRange ?
|
||||
{
|
||||
range: {
|
||||
from: new Date(scope.candleRange.from),
|
||||
to: new Date(scope.candleRange.to),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} :
|
||||
{}),
|
||||
},
|
||||
now,
|
||||
true,
|
||||
@@ -324,15 +333,16 @@ export class TbankSyncService {
|
||||
});
|
||||
const saved = await this.repository.read(userId, connection.revision, id);
|
||||
if (!saved) throw new NotFoundException();
|
||||
if (!saved.work.scopeKey.startsWith('screen:'))
|
||||
if (!saved.work.scopeKey.startsWith('screen:')) {
|
||||
return this.sourceStatus(userId, saved.work);
|
||||
}
|
||||
const intent = saved.work.intent as unknown as ScreenIntent;
|
||||
await this.access.local(userId, intent.scope.accountId);
|
||||
const children = await Promise.all(
|
||||
saved.children.map((child) =>
|
||||
child.state === 'superseded'
|
||||
? this.operations.observe(userId, child.id)
|
||||
: Promise.resolve(child),
|
||||
child.state === 'superseded' ?
|
||||
this.operations.observe(userId, child.id) :
|
||||
Promise.resolve(child),
|
||||
),
|
||||
);
|
||||
const running = children.some((child) => child.state === 'running');
|
||||
@@ -342,21 +352,22 @@ export class TbankSyncService {
|
||||
null;
|
||||
const state =
|
||||
saved.work.processRunId !== this.runId &&
|
||||
(!saved.progress.dispatched || running)
|
||||
? 'cancelled'
|
||||
: !saved.progress.dispatched || running
|
||||
? 'running'
|
||||
: failed
|
||||
? 'failed'
|
||||
: children.some((child) => child.state === 'cancelled')
|
||||
? 'cancelled'
|
||||
: 'succeeded';
|
||||
(!saved.progress.dispatched || running) ?
|
||||
'cancelled' :
|
||||
!saved.progress.dispatched || running ?
|
||||
'running' :
|
||||
failed ?
|
||||
'failed' :
|
||||
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
|
||||
)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
...intent,
|
||||
@@ -380,16 +391,16 @@ export class TbankSyncService {
|
||||
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
||||
resources.push((await read).meta);
|
||||
};
|
||||
if (work.scopeKey === 'membership')
|
||||
if (work.scopeKey === 'membership') {
|
||||
await add(this.accounts.list(userId, true));
|
||||
else if (
|
||||
} else if (
|
||||
input.source === 'portfolio' &&
|
||||
typeof input.accountId === 'string'
|
||||
)
|
||||
) {
|
||||
await add(this.portfolio.read(userId, input.accountId, true));
|
||||
else if (input.source === 'catalog') {
|
||||
} else if (input.source === 'catalog') {
|
||||
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
|
||||
if (typeof input.query === 'string')
|
||||
if (typeof input.query === 'string') {
|
||||
await add(
|
||||
this.discovery.search(
|
||||
userId,
|
||||
@@ -401,14 +412,14 @@ export class TbankSyncService {
|
||||
true,
|
||||
),
|
||||
);
|
||||
else if (
|
||||
} 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;
|
||||
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ?
|
||||
(JSON.parse(input.rangeKey) as string[]) :
|
||||
null;
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
type,
|
||||
@@ -417,9 +428,9 @@ export class TbankSyncService {
|
||||
[
|
||||
input.kind as Parameters<
|
||||
CatalogEnrichmentService['readMany']
|
||||
>[4] extends (infer K)[] | undefined
|
||||
? K
|
||||
: never,
|
||||
>[4] extends (infer K)[] | undefined ?
|
||||
K :
|
||||
never,
|
||||
],
|
||||
range ? { from: range[0], to: range[1] } : undefined,
|
||||
);
|
||||
@@ -452,7 +463,7 @@ export class TbankSyncService {
|
||||
{
|
||||
uid: input.uid,
|
||||
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
|
||||
period: input.period as 'month' | 'halfYear' | 'year' | 'all',
|
||||
period: input.period as 'all' | 'halfYear' | 'month' | 'year',
|
||||
},
|
||||
true,
|
||||
),
|
||||
@@ -475,21 +486,22 @@ export class TbankSyncService {
|
||||
);
|
||||
}
|
||||
}
|
||||
const observed = work.scopeKey.startsWith('operations:')
|
||||
? await this.operations.observe(userId, work.id)
|
||||
: work;
|
||||
const observed = work.scopeKey.startsWith('operations:') ?
|
||||
await this.operations.observe(userId, work.id) :
|
||||
work;
|
||||
const state =
|
||||
observed.state === 'running'
|
||||
? observed.updatedAt < this.startedAt
|
||||
? 'cancelled'
|
||||
: 'running'
|
||||
: observed.state === 'failed'
|
||||
? 'failed'
|
||||
: observed.state === 'cancelled'
|
||||
? 'cancelled'
|
||||
: 'succeeded';
|
||||
if ((await this.access.local(userId)).revision !== work.revision)
|
||||
observed.state === 'running' ?
|
||||
observed.updatedAt < this.startedAt ?
|
||||
'cancelled' :
|
||||
'running' :
|
||||
observed.state === 'failed' ?
|
||||
'failed' :
|
||||
observed.state === 'cancelled' ?
|
||||
'cancelled' :
|
||||
'succeeded';
|
||||
if ((await this.access.local(userId)).revision !== work.revision) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return {
|
||||
id: work.id,
|
||||
scope: null,
|
||||
@@ -511,14 +523,15 @@ export class TbankSyncService {
|
||||
resources.push((await read).meta);
|
||||
};
|
||||
const account = scope.accountId ?? 'all';
|
||||
if (scope.screen === 'accounts')
|
||||
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', 'operations', 'stock', 'bond'].includes(scope.screen))
|
||||
if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) {
|
||||
await add(
|
||||
this.ledger.read(userId, account, {
|
||||
from: scope.from,
|
||||
@@ -529,11 +542,13 @@ export class TbankSyncService {
|
||||
sync: 'observe',
|
||||
}),
|
||||
);
|
||||
if (scope.screen === 'income')
|
||||
}
|
||||
if (scope.screen === 'income') {
|
||||
await add(
|
||||
this.income.analytics(userId, account, scope.from, scope.to, true),
|
||||
);
|
||||
if (scope.screen === 'events')
|
||||
}
|
||||
if (scope.screen === 'events') {
|
||||
await add(
|
||||
this.events.get(userId, account, {
|
||||
from: scope.from,
|
||||
@@ -541,9 +556,10 @@ export class TbankSyncService {
|
||||
sync: 'observe',
|
||||
}),
|
||||
);
|
||||
}
|
||||
const type =
|
||||
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
|
||||
if (scope.screen === 'stocks' || scope.screen === 'bonds')
|
||||
if (scope.screen === 'stocks' || scope.screen === 'bonds') {
|
||||
await add(
|
||||
this.discovery.search(
|
||||
userId,
|
||||
@@ -551,6 +567,7 @@ export class TbankSyncService {
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
||||
scope.instrumentUid
|
||||
@@ -574,27 +591,27 @@ export class TbankSyncService {
|
||||
uid,
|
||||
instrumentType: type,
|
||||
period: scope.candlePeriod ?? 'month',
|
||||
...(scope.candleRange
|
||||
? {
|
||||
...(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),
|
||||
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),
|
||||
type === 'share' ?
|
||||
this.stockIncome.dividends(userId, uid, true) :
|
||||
this.bondIncome.coupons(userId, uid, true),
|
||||
);
|
||||
}
|
||||
return resources;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import {
|
||||
UpcomingEventsResourceDto,
|
||||
UpcomingEventsQueryDto,
|
||||
UpcomingEventsResourceDto,
|
||||
} from './upcoming-events.dto';
|
||||
import { UpcomingEventsService } from './upcoming-events.service';
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { AccountReferenceDto } from '../account-reference.dto';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { AccountReferenceDto } from '../account-reference.dto';
|
||||
|
||||
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
@@ -43,10 +44,10 @@ export class UpcomingEventDto {
|
||||
declare dateKind: 'event' | 'registry';
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare figi: string | null;
|
||||
declare figi: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare instrumentUid: string | null;
|
||||
declare instrumentUid: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare ticker: string;
|
||||
@@ -55,10 +56,10 @@ export class UpcomingEventDto {
|
||||
declare displayName: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare isin: string | null;
|
||||
declare isin: null | string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
declare logoName: string | null;
|
||||
declare logoName: null | string;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
declare projectedAmount: MoneyDto | null;
|
||||
@@ -80,7 +81,8 @@ export class UpcomingEventsDto {
|
||||
|
||||
export class UpcomingEventsResourceDto {
|
||||
@ApiProperty({ type: UpcomingEventsDto, nullable: true })
|
||||
declare data: UpcomingEventsDto | null;
|
||||
declare data: null | UpcomingEventsDto;
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
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 { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { UpcomingEventsController } from './upcoming-events.controller';
|
||||
import { UpcomingEventsService } from './upcoming-events.service';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dividendEvent, bondEvent } from './upcoming-events.service';
|
||||
import { decimalFromString } from '../../../common/decimal';
|
||||
import { bondEvent, dividendEvent } from './upcoming-events.service';
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||
|
||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
@@ -8,21 +10,21 @@ import {
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
|
||||
import { decimal } from '../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
TbankAccessService,
|
||||
type TbankDividend,
|
||||
} from '../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
PortfolioService,
|
||||
ForecastService,
|
||||
type PortfolioPositionDto,
|
||||
} from '../portfolio/portfolio.api';
|
||||
import { CatalogEnrichmentService } from '../instruments/instruments.api';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
ForecastService,
|
||||
type PortfolioPositionDto,
|
||||
PortfolioService,
|
||||
} from '../portfolio/portfolio.api';
|
||||
import {
|
||||
UpcomingEventDto,
|
||||
UpcomingEventsDto,
|
||||
@@ -74,7 +76,7 @@ export class UpcomingEventsService {
|
||||
metadata.get(metadataKey(position))?.data?.ticker ||
|
||||
'',
|
||||
}))
|
||||
.filter((entry): entry is { uid: string; ticker: string } =>
|
||||
.filter((entry): entry is { ticker: string; uid: string } =>
|
||||
Boolean(entry.uid),
|
||||
);
|
||||
const uids = [...new Set(identities.map((entry) => entry.uid))];
|
||||
@@ -82,7 +84,7 @@ export class UpcomingEventsService {
|
||||
let offset = 0;
|
||||
offset < uids.length;
|
||||
offset += calendarReadBatchSize
|
||||
)
|
||||
) {
|
||||
sources.push(
|
||||
...(await this.sources.readMany(
|
||||
userId,
|
||||
@@ -98,6 +100,7 @@ export class UpcomingEventsService {
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const position of positions) {
|
||||
const generic = metadata.get(metadataKey(position))?.data;
|
||||
@@ -126,7 +129,7 @@ export class UpcomingEventsService {
|
||||
(source) =>
|
||||
source.input.uid === uid &&
|
||||
source.input.kind ===
|
||||
(type === 'share' ? 'dividends' : 'bond-events'),
|
||||
(type === 'share' ? 'dividends' : 'bond-events'),
|
||||
);
|
||||
const instrument =
|
||||
sources.find(
|
||||
@@ -148,15 +151,15 @@ export class UpcomingEventsService {
|
||||
};
|
||||
const quantity = decimalFromString(position.quantity);
|
||||
const projected =
|
||||
type === 'share'
|
||||
? (calendar.payload?.dividends ?? [])
|
||||
type === 'share' ?
|
||||
(calendar.payload?.dividends ?? [])
|
||||
.filter(
|
||||
(event) =>
|
||||
event.dividendType?.toLowerCase() !== 'cancelled' &&
|
||||
Boolean(event.recordDate),
|
||||
)
|
||||
.map((event) => dividendEvent(identity, quantity, event))
|
||||
: (calendar.payload?.bondEvents ?? []).map((event) =>
|
||||
.map((event) => dividendEvent(identity, quantity, event)) :
|
||||
(calendar.payload?.bondEvents ?? []).map((event) =>
|
||||
bondEvent(identity, quantity, event),
|
||||
);
|
||||
events.push(
|
||||
@@ -166,9 +169,9 @@ export class UpcomingEventsService {
|
||||
)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
...(accountId === 'all' && position.account
|
||||
? { account: position.account }
|
||||
: {}),
|
||||
...(accountId === 'all' && position.account ?
|
||||
{ account: position.account } :
|
||||
{}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -179,14 +182,14 @@ export class UpcomingEventsService {
|
||||
a.type.localeCompare(b.type) ||
|
||||
a.ticker.localeCompare(b.ticker),
|
||||
);
|
||||
const candidate: UpcomingEventsDto | undefined = ready
|
||||
? {
|
||||
const candidate: undefined | UpcomingEventsDto = ready ?
|
||||
{
|
||||
accountId,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
events,
|
||||
incomeTotalsByCurrency: incomeTotals(events),
|
||||
}
|
||||
: undefined;
|
||||
} :
|
||||
undefined;
|
||||
return this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
@@ -197,6 +200,7 @@ export class UpcomingEventsService {
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
|
||||
getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
|
||||
return this.get(userId, 'all', query);
|
||||
}
|
||||
@@ -205,7 +209,7 @@ export class UpcomingEventsService {
|
||||
export function dividendEvent(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: TbankDividend,
|
||||
@@ -217,20 +221,20 @@ export function dividendEvent(
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
dateKind: 'registry',
|
||||
projectedAmount: event.dividendNet
|
||||
? projectedMoney(
|
||||
projectedAmount: event.dividendNet ?
|
||||
projectedMoney(
|
||||
quantity,
|
||||
formatDecimal(decimal(event.dividendNet)),
|
||||
event.dividendNet.currency,
|
||||
)
|
||||
: null,
|
||||
) :
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
export function bondEvent(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
|
||||
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
event: MoexBondEvent,
|
||||
@@ -241,16 +245,16 @@ export function bondEvent(
|
||||
date: event.date,
|
||||
dateKind: 'event',
|
||||
projectedAmount:
|
||||
'value' in event
|
||||
? projectedMoney(quantity, event.value, event.currency)
|
||||
: null,
|
||||
'value' in event ?
|
||||
projectedMoney(quantity, event.value, event.currency) :
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
function supported(position: Position): boolean {
|
||||
return (
|
||||
!isZero(decimalFromString(position.quantity)) &&
|
||||
['STOCK', 'BOND'].includes(position.type)
|
||||
['BOND', 'STOCK'].includes(position.type)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,8 +263,9 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
||||
const end = new Date(`${from}T00:00:00.000Z`);
|
||||
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
|
||||
const to = query.to ?? end.toISOString().slice(0, 10);
|
||||
if (from > to)
|
||||
if (from > to) {
|
||||
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
@@ -280,8 +285,9 @@ function projectedMoney(
|
||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const event of events) {
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount)
|
||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
||||
continue;
|
||||
}
|
||||
const current = totals.get(event.projectedAmount.currency) ?? {
|
||||
value: 0n,
|
||||
scale: 0,
|
||||
|
||||
Reference in New Issue
Block a user