chore: strengthen backend eslint profile
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user