feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import {
|
||||
ForecastRepository,
|
||||
type ForecastSourceVersion,
|
||||
} from '../infrastructure/forecast.repository';
|
||||
|
||||
@Injectable()
|
||||
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;
|
||||
}) {
|
||||
const dependencies = [
|
||||
input.portfolio,
|
||||
...input.sources.map((source) => source.meta),
|
||||
];
|
||||
const stored = await this.repository.resolve({
|
||||
...input,
|
||||
portfolioProjection: input.portfolio.dataRevision,
|
||||
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
||||
dependency.resourceKey.startsWith('portfolio/')
|
||||
? [
|
||||
{
|
||||
accountId: decodeURIComponent(
|
||||
dependency.resourceKey.slice('portfolio/'.length),
|
||||
),
|
||||
dataRevision: Number(
|
||||
(dependency as InvestmentResourceMetaDto).dataRevision,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
sources: input.sources.flatMap((source) =>
|
||||
source.version ? [source.version] : [],
|
||||
),
|
||||
timestamps: dependencies.flatMap((dependency) =>
|
||||
dependency.lastSuccessfulAt
|
||||
? [
|
||||
{
|
||||
resourceKey: dependency.resourceKey,
|
||||
at: dependency.lastSuccessfulAt,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
});
|
||||
const timestamps = stored?.sourceTimestamps as
|
||||
{ resourceKey: string; at: string }[] | undefined;
|
||||
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');
|
||||
return {
|
||||
data: stored ? (stored.payload as T) : null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: input.scopeKey,
|
||||
dataRevision: String(stored?.dataRevision ?? 0),
|
||||
availability: stored
|
||||
? 'complete'
|
||||
: dependencies.some(
|
||||
(dependency) => dependency.availability !== 'missing',
|
||||
)
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
fetchedAt: first ? new Date(first) : null,
|
||||
freshForMs: Number.POSITIVE_INFINITY,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,373 +1,197 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
import { setImmediate as immediate } from 'node:timers/promises';
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
|
||||
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
|
||||
import type {
|
||||
TbankAccountContext,
|
||||
TbankConnectionContext,
|
||||
} from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
const access = {
|
||||
withConnection: jest.fn(),
|
||||
withAccountForConnection: jest.fn(),
|
||||
describe('persistent portfolio projection', () => {
|
||||
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
||||
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
|
||||
const metadata = { savedMany: jest.fn(), request: jest.fn() };
|
||||
const repository = {
|
||||
read: jest.fn(),
|
||||
reserve: jest.fn(),
|
||||
publish: jest.fn(),
|
||||
fail: jest.fn(),
|
||||
};
|
||||
const tbank = {
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
const metadata = new InstrumentMetadataService(
|
||||
tbank as never,
|
||||
logger as never,
|
||||
);
|
||||
const service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
metadata,
|
||||
);
|
||||
let generation: string;
|
||||
|
||||
let service: PortfolioService;
|
||||
const membershipAt = new Date();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service.invalidate('user');
|
||||
generation = 'generation-1';
|
||||
access.withConnection.mockImplementation(
|
||||
(
|
||||
_userId: string,
|
||||
handler: (context: TbankConnectionContext) => Promise<unknown>,
|
||||
) => handler({ token: 'token', generation }),
|
||||
jest.resetAllMocks();
|
||||
service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
{ set: jest.fn() } as never,
|
||||
metadata as never,
|
||||
repository as never,
|
||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ event: jest.fn() } as never,
|
||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
||||
);
|
||||
access.withAccountForConnection.mockImplementation(
|
||||
(
|
||||
connection: TbankConnectionContext,
|
||||
_accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||
) =>
|
||||
handler({
|
||||
...connection,
|
||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||
}),
|
||||
);
|
||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||
access.local.mockResolvedValue({
|
||||
revision: 'private',
|
||||
membershipAt,
|
||||
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
||||
});
|
||||
access.tokenForAttempt.mockResolvedValue('synthetic');
|
||||
repository.reserve.mockResolvedValue(null);
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [],
|
||||
work: [],
|
||||
});
|
||||
metadata.savedMany.mockResolvedValue(new Map());
|
||||
metadata.request.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
||||
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
|
||||
repository.reserve.mockResolvedValueOnce({
|
||||
launch: true,
|
||||
work: { id: 'work', attemptId: 'attempt' },
|
||||
});
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
figi: 'A',
|
||||
instrumentUid: 'uid',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'rub', units: 3 },
|
||||
averagePositionPrice: { currency: 'RUB', units: 2 },
|
||||
quantity: { units: '2' },
|
||||
currentPrice: { currency: 'rub', units: '3', nano: 1 },
|
||||
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
figi: 'B',
|
||||
instrumentType: 'new-kind',
|
||||
quantity: { nano: -500000000 },
|
||||
currentPrice: { currency: 'USD', units: 4 },
|
||||
currentPrice: { currency: 'USD', units: '4' },
|
||||
},
|
||||
{ figi: 'ZERO', quantity: {} },
|
||||
{ figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } },
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [
|
||||
{ currency: 'RUB', units: 1 },
|
||||
{ currency: 'rub', nano: 500000000 },
|
||||
{ currency: 'USD' },
|
||||
{ currency: 'rub', units: '1' },
|
||||
{ currency: 'EUR', units: '0' },
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByFigi
|
||||
.mockResolvedValueOnce({
|
||||
ticker: 'ONE',
|
||||
name: 'Первая позиция',
|
||||
isin: 'RU000A0JX0J2',
|
||||
brand: { logoName: 'RU000A0JX0J2x160.png' },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('unavailable'));
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [
|
||||
metadata.savedMany.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
JSON.stringify(['uid', 'A']),
|
||||
{ data: { name: 'Known', ticker: 'A' } },
|
||||
],
|
||||
]),
|
||||
);
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
displayName: 'Первая позиция',
|
||||
type: 'STOCK',
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '6' },
|
||||
absoluteReturn: { currency: 'RUB', value: '2' },
|
||||
isin: 'RU000A0JX0J2',
|
||||
ticker: 'ONE',
|
||||
logoName: 'RU000A0JX0J2x160.png',
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
displayName: 'TWO',
|
||||
type: 'OTHER',
|
||||
quantity: '-0.5',
|
||||
currentValue: { currency: 'USD', value: '-2' },
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
accountId: 'a',
|
||||
name: 'A',
|
||||
type: 'BROKERAGE',
|
||||
openedOn: null,
|
||||
portfolio: null,
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.5' }],
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '7.5' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
],
|
||||
work: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { currency: 'RUB' },
|
||||
},
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { units: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [{ currency: 'USD' }, { units: 2 }],
|
||||
});
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
totalsByCurrency: [],
|
||||
cashBalances: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'FIGI',
|
||||
instrumentUid: 'UID',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [{ displayName: 'Имя по UID', logoName: null }],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
|
||||
const positions = Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BATCH-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
}));
|
||||
tbank.getPortfolio.mockResolvedValue({ positions });
|
||||
let active = 0;
|
||||
tbank.getInstrumentByFigi.mockImplementation(
|
||||
async (_token: string, figi: string) => {
|
||||
active += 1;
|
||||
try {
|
||||
if (active > 50)
|
||||
throw new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
await Promise.resolve();
|
||||
return {
|
||||
name: `Название ${figi}`,
|
||||
ticker: figi,
|
||||
isin: `ISIN-${figi}`,
|
||||
brand: { logoName: `${figi}.png` },
|
||||
};
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = await service.get('user', 'account');
|
||||
expect(result.positions).toMatchObject(
|
||||
positions.map((position, index) => ({
|
||||
displayName: `Название BATCH-${index}`,
|
||||
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
|
||||
ticker: `BATCH-${index}`,
|
||||
isin: `ISIN-BATCH-${index}`,
|
||||
logoName: `BATCH-${index}.png`,
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
|
||||
try {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BUDGET-${index}`,
|
||||
instrumentUid: `budget-uid-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
})),
|
||||
});
|
||||
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
|
||||
const pending = service.get('user', 'account');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
const result = await pending;
|
||||
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
|
||||
expect(result.positions).toHaveLength(60);
|
||||
expect(result.positions[59]).toMatchObject({
|
||||
displayName: 'BUDGET-59',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
});
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
service.invalidate('user');
|
||||
await service.get('user', 'account');
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mask an invalid token with a snapshot', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reuse a snapshot or fallback across connection generations', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
generation = 'generation-2';
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return a snapshot when the current connection is absent', async () => {
|
||||
await service.get('user', 'account');
|
||||
access.withConnection.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a late previous-generation portfolio snapshot', async () => {
|
||||
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
|
||||
const previous = new Promise<{ positions: never[] }>((resolve) => {
|
||||
finishPrevious = resolve;
|
||||
});
|
||||
tbank.getPortfolio
|
||||
.mockReturnValueOnce(previous)
|
||||
.mockResolvedValueOnce({ positions: [] })
|
||||
.mockResolvedValueOnce({ positions: [] });
|
||||
tbank.getPositions
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
|
||||
|
||||
const previousRequest = service.get('user', 'account');
|
||||
generation = 'generation-2';
|
||||
const current = await service.get('user', 'account');
|
||||
finishPrevious?.({ positions: [] });
|
||||
await previousRequest;
|
||||
const afterLateCompletion = await service.get('user', 'account');
|
||||
|
||||
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
|
||||
expect(afterLateCompletion.cashBalances).toEqual([
|
||||
{ currency: 'USD', value: '3' },
|
||||
await service.read('user', 'a');
|
||||
for (
|
||||
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];
|
||||
expect(
|
||||
snapshot.positions.map((position) => [
|
||||
position.displayName,
|
||||
position.type,
|
||||
position.quantity,
|
||||
]),
|
||||
).toEqual([
|
||||
['Known', 'STOCK', '2'],
|
||||
['B', 'OTHER', '-0.5'],
|
||||
]);
|
||||
expect(snapshot.positions[0].absoluteReturn).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '2.000000002',
|
||||
});
|
||||
expect(snapshot.totalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '7.000000002' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
]);
|
||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||
});
|
||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||
const snapshot: PortfolioDto = {
|
||||
lastSuccessfulAt: membershipAt.toISOString(),
|
||||
isStale: false,
|
||||
positions: [],
|
||||
cashBalances: [],
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
|
||||
};
|
||||
const account = {
|
||||
accountId: 'a',
|
||||
name: 'A',
|
||||
type: 'BROKERAGE',
|
||||
openedOn: null,
|
||||
portfolio: {
|
||||
payload: snapshot,
|
||||
fetchedAt: membershipAt,
|
||||
dataRevision: 1,
|
||||
},
|
||||
};
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
|
||||
work: [],
|
||||
});
|
||||
const partial = await service.read('user', 'all', true);
|
||||
expect(partial.data).toBeNull();
|
||||
expect(partial.meta.availability).toBe('partial');
|
||||
const saved = await service.saved('user', 'all', true);
|
||||
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
||||
snapshot.totalsByCurrency,
|
||||
);
|
||||
expect(saved.accounts[1].snapshot).toBeNull();
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt,
|
||||
membershipRevision: 1,
|
||||
accounts: [
|
||||
account,
|
||||
{
|
||||
...account,
|
||||
accountId: 'b',
|
||||
portfolio: { ...account.portfolio, fetchedAt: new Date(0) },
|
||||
},
|
||||
],
|
||||
work: [],
|
||||
});
|
||||
const complete = await service.read('user', 'all', true);
|
||||
expect(complete.data?.totalsByCurrency).toEqual([
|
||||
{ currency: 'RUB', value: '2.000000002' },
|
||||
]);
|
||||
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||
expect(complete.data?.isStale).toBe(true);
|
||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||
});
|
||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
||||
repository.read.mockResolvedValue({
|
||||
membershipAt: null,
|
||||
membershipRevision: 0,
|
||||
accounts: [],
|
||||
work: [],
|
||||
});
|
||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
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,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
add,
|
||||
formatDecimal,
|
||||
@@ -10,10 +23,14 @@ import { RequestContextService } from '../../../../common/logging/request-contex
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
type TbankInstrument,
|
||||
type MetadataIdentity,
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankConnectionContext,
|
||||
TbankMoney,
|
||||
TbankMembershipService,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
@@ -24,10 +41,7 @@ import {
|
||||
PortfolioPositionDto,
|
||||
} from '../presentation/portfolio.dto';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const fallbackForMs = 300_000;
|
||||
const positionsBatchSize = 5;
|
||||
const metadataTimeoutMs = 30_000;
|
||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||
const types: Record<string, string> = {
|
||||
share: 'STOCK',
|
||||
bond: 'BOND',
|
||||
@@ -39,116 +53,358 @@ const types: Record<string, string> = {
|
||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||
};
|
||||
|
||||
type CachedSnapshot = {
|
||||
generation: string;
|
||||
snapshot: PortfolioDto;
|
||||
storedAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
||||
private readonly processRunId = randomUUID();
|
||||
private readonly active = new Set<string>();
|
||||
private readonly pending = new Map<string, Promise<void>>();
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
private readonly repository: PortfolioRepository,
|
||||
private readonly membership: TbankMembershipService,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly candles: CandleService,
|
||||
) {}
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
async saved(userId: string, accountId: string, observe = false) {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
return this.access.withConnection(userId, (connection) =>
|
||||
this.snapshot(userId, accountId, connection),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(userId: string): Promise<PortfolioDto> {
|
||||
return this.access.withSupportedAccounts(userId, async (context) => {
|
||||
const portfolios: PortfolioDto[] = [];
|
||||
for (const account of context.accounts) {
|
||||
const portfolio = await this.snapshot(userId, account.id, context);
|
||||
portfolios.push({
|
||||
...portfolio,
|
||||
positions: portfolio.positions.map((position) => ({
|
||||
...position,
|
||||
account: { id: account.id, name: account.name },
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
lastSuccessfulAt:
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
new Date().toISOString(),
|
||||
isStale: portfolios.some((portfolio) => portfolio.isStale),
|
||||
totalsByCurrency: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||
),
|
||||
cashBalances: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
structure: portfolioStructure(
|
||||
portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async snapshot(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
{ token, generation }: TbankConnectionContext,
|
||||
): Promise<PortfolioDto> {
|
||||
const key = `${userId}:${accountId}`;
|
||||
const cached = this.snapshots.get(key);
|
||||
if (
|
||||
cached?.generation === generation &&
|
||||
Date.now() - cached.storedAt <= freshForMs
|
||||
)
|
||||
return cached.snapshot;
|
||||
try {
|
||||
const snapshot = await this.access.withAccountForConnection(
|
||||
{ token, generation },
|
||||
accountId,
|
||||
async () => {
|
||||
const [portfolio, positions] = await Promise.all([
|
||||
this.tbank.getPortfolio(token, accountId),
|
||||
this.tbank.getPositions(token, accountId),
|
||||
]);
|
||||
return this.normalize(
|
||||
token,
|
||||
portfolio.positions ?? [],
|
||||
positions.money ?? [],
|
||||
);
|
||||
},
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
if (!observe) {
|
||||
const discovery = await this.membership.request(
|
||||
userId,
|
||||
connection.revision,
|
||||
);
|
||||
this.snapshots.set(key, {
|
||||
generation,
|
||||
snapshot,
|
||||
storedAt: Date.parse(snapshot.lastSuccessfulAt),
|
||||
});
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ServiceUnavailableException &&
|
||||
cached?.generation === generation &&
|
||||
Date.now() - cached.storedAt <= fallbackForMs
|
||||
) {
|
||||
return { ...cached.snapshot, isStale: true };
|
||||
}
|
||||
throw error;
|
||||
if (discovery)
|
||||
void discovery.done
|
||||
.then(async () => {
|
||||
const current = await this.access.local(userId, accountId);
|
||||
if (current.revision !== connection.revision) return;
|
||||
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(
|
||||
'warn',
|
||||
'tbank.portfolio.discovery_not_available',
|
||||
{ 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(
|
||||
(account) => accountId === 'all' || account.accountId === accountId,
|
||||
)) {
|
||||
const snapshot = account.portfolio?.payload as unknown as
|
||||
PortfolioDto | undefined;
|
||||
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)
|
||||
await this.metadata.request(userId, connection.revision, identity);
|
||||
}
|
||||
if (!observe)
|
||||
void this.candles
|
||||
.prefetch(
|
||||
userId,
|
||||
connection.revision,
|
||||
(saved?.accounts ?? [])
|
||||
.filter(
|
||||
(account) =>
|
||||
accountId === 'all' || account.accountId === accountId,
|
||||
)
|
||||
.flatMap(
|
||||
(account) =>
|
||||
(
|
||||
account.portfolio?.payload as unknown as
|
||||
PortfolioDto | undefined
|
||||
)?.positions ?? [],
|
||||
),
|
||||
)
|
||||
.catch(() =>
|
||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
||||
);
|
||||
const membershipMeta = investmentResourceMeta({
|
||||
resourceKey: 'accounts/membership',
|
||||
dataRevision: String(saved?.membershipRevision ?? 0),
|
||||
availability: saved?.membershipAt ? 'complete' : 'missing',
|
||||
fetchedAt: saved?.membershipAt ?? null,
|
||||
freshForMs: membershipFreshForMs,
|
||||
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
|
||||
});
|
||||
return {
|
||||
membership: membershipMeta,
|
||||
accounts: (saved?.accounts ?? [])
|
||||
.filter(
|
||||
(account) => accountId === 'all' || account.accountId === accountId,
|
||||
)
|
||||
.map((account) => ({
|
||||
id: account.accountId,
|
||||
name: account.name,
|
||||
type: account.type,
|
||||
openedOn: account.openedOn,
|
||||
snapshot: account.portfolio
|
||||
? {
|
||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
||||
isStale:
|
||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
||||
portfolioFreshForMs,
|
||||
}
|
||||
: null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||
dataRevision: String(account.portfolio?.dataRevision ?? 0),
|
||||
availability: account.portfolio ? 'complete' : 'missing',
|
||||
fetchedAt: account.portfolio?.fetchedAt ?? null,
|
||||
freshForMs: portfolioFreshForMs,
|
||||
work:
|
||||
saved?.work.find(
|
||||
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
|
||||
) ?? null,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
invalidate(userId: string): void {
|
||||
for (const key of this.snapshots.keys())
|
||||
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
|
||||
async read(userId: string, accountId: string, observe = false) {
|
||||
const saved = await this.saved(userId, accountId, observe);
|
||||
const missing =
|
||||
saved.membership.availability === 'missing' ||
|
||||
saved.accounts.some((account) => !account.snapshot);
|
||||
const metadata = await this.metadata.savedMany(
|
||||
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
|
||||
);
|
||||
const metadataDependencies = [...metadata.entries()].flatMap(
|
||||
([key, source]) =>
|
||||
source.complete && source.data
|
||||
? [
|
||||
investmentResourceMeta({
|
||||
resourceKey: `instrument/metadata/${key}`,
|
||||
dataRevision: source.dataRevision,
|
||||
availability: 'complete',
|
||||
fetchedAt: source.fetchedAt,
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: null,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const portfolios = saved.accounts.flatMap((account) =>
|
||||
account.snapshot
|
||||
? [
|
||||
{
|
||||
...account.snapshot,
|
||||
positions: account.snapshot.positions.map((position) => {
|
||||
const shared = metadata.get(metadataKey(position))?.data;
|
||||
return {
|
||||
...position,
|
||||
...(shared
|
||||
? {
|
||||
displayName:
|
||||
shared.name?.trim() ||
|
||||
shared.ticker?.trim() ||
|
||||
position.displayName,
|
||||
ticker: shared.ticker?.trim() || position.ticker,
|
||||
isin: shared.isin?.trim() || position.isin,
|
||||
logoName:
|
||||
shared.brand?.logoName?.trim() || position.logoName,
|
||||
}
|
||||
: {}),
|
||||
...(accountId === 'all'
|
||||
? { account: { id: account.id, name: account.name } }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const lastSuccessfulAt =
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
saved.membership.lastSuccessfulAt;
|
||||
const data =
|
||||
missing || !lastSuccessfulAt
|
||||
? null
|
||||
: {
|
||||
lastSuccessfulAt,
|
||||
isStale: portfolios.length
|
||||
? portfolios.some((portfolio) => portfolio.isStale)
|
||||
: saved.membership.freshness !== 'fresh',
|
||||
totalsByCurrency: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||
),
|
||||
cashBalances: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
structure: portfolioStructure(
|
||||
portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
};
|
||||
const work =
|
||||
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;
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `portfolio/${encodeURIComponent(accountId)}`,
|
||||
dataRevision: JSON.stringify([
|
||||
saved.accounts.map((account) => [
|
||||
account.id,
|
||||
account.meta.dataRevision,
|
||||
]),
|
||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||
]),
|
||||
availability: missing
|
||||
? portfolios.length
|
||||
? 'partial'
|
||||
: 'missing'
|
||||
: 'complete',
|
||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||
freshForMs: portfolios.length
|
||||
? portfolioFreshForMs
|
||||
: membershipFreshForMs,
|
||||
work: work.id ? { ...work, id: work.id } : null,
|
||||
dependencies: [
|
||||
saved.membership,
|
||||
...saved.accounts.map((account) => account.meta),
|
||||
...metadataDependencies,
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
request(userId: string, revision: string, accountId: string, force = false) {
|
||||
return this.queueSnapshot(userId, revision, accountId, force);
|
||||
}
|
||||
|
||||
private queueSnapshot(
|
||||
userId: string,
|
||||
revision: string,
|
||||
accountId: string,
|
||||
force = false,
|
||||
) {
|
||||
const reserved = this.reservations.then(async () => {
|
||||
const requested = await this.repository.reserve(
|
||||
userId,
|
||||
revision,
|
||||
accountId,
|
||||
this.processRunId,
|
||||
this.active,
|
||||
force,
|
||||
);
|
||||
if (!requested) return null;
|
||||
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))
|
||||
.then(async () => {
|
||||
const attempt = { id, attemptId, userId, revision };
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (!token) return;
|
||||
const [portfolio, positions] = await Promise.all([
|
||||
this.tbank.getPortfolio(token, accountId),
|
||||
this.tbank.getPositions(token, accountId),
|
||||
]);
|
||||
const snapshot = await this.normalize(
|
||||
portfolio.positions ?? [],
|
||||
positions.money ?? [],
|
||||
);
|
||||
const published = await this.repository.publish(
|
||||
attempt,
|
||||
accountId,
|
||||
snapshot,
|
||||
);
|
||||
if (published) {
|
||||
for (const position of snapshot.positions)
|
||||
await this.metadata.request(userId, revision, position);
|
||||
if (!force)
|
||||
await this.candles.prefetch(
|
||||
userId,
|
||||
revision,
|
||||
snapshot.positions,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN'
|
||||
)
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else await this.repository.fail(attempt);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
this.logger.event('error', 'tbank.portfolio.persistence_failed', {
|
||||
userId,
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
this.active.delete(attemptId);
|
||||
this.pending.delete(attemptId);
|
||||
});
|
||||
this.pending.set(attemptId, done);
|
||||
return { id, done };
|
||||
});
|
||||
this.reservations = reserved.catch(() => undefined);
|
||||
return reserved;
|
||||
}
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
const result = await this.read(userId, accountId);
|
||||
if (!result.data)
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
return result.data;
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<PortfolioDto> {
|
||||
return this.get(userId, 'all');
|
||||
}
|
||||
|
||||
private async normalize(
|
||||
token: string,
|
||||
upstream: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
@@ -160,28 +416,13 @@ export class PortfolioService {
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
|
||||
const positions: (PortfolioPositionDto | null)[] = [];
|
||||
try {
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < upstream.length;
|
||||
offset += positionsBatchSize
|
||||
) {
|
||||
positions.push(
|
||||
...(await Promise.all(
|
||||
upstream
|
||||
.slice(offset, offset + positionsBatchSize)
|
||||
.map((position) =>
|
||||
this.position(token, position, controller.signal),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||
const positions = upstream.map((position) =>
|
||||
this.position(
|
||||
position,
|
||||
savedMetadata.get(metadataKey(position))?.data ?? null,
|
||||
),
|
||||
);
|
||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const value of money) {
|
||||
const currency = value.currency?.trim().toUpperCase();
|
||||
@@ -206,11 +447,10 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private async position(
|
||||
token: string,
|
||||
position: Parameters<PortfolioService['normalize']>[1][number],
|
||||
signal: AbortSignal,
|
||||
): Promise<PortfolioPositionDto | null> {
|
||||
private position(
|
||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
||||
metadata: TbankInstrument | null,
|
||||
): PortfolioPositionDto | null {
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (isZero(quantity)) return null;
|
||||
const figi = position.figi?.trim() || null;
|
||||
@@ -235,12 +475,6 @@ export class PortfolioService {
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const metadata = await this.metadata.resolve(
|
||||
token,
|
||||
instrumentUid,
|
||||
figi,
|
||||
signal,
|
||||
);
|
||||
return {
|
||||
figi,
|
||||
instrumentUid,
|
||||
|
||||
Reference in New Issue
Block a user