diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 1c8f778..200d416 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -5,10 +5,8 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -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 { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, @@ -26,6 +24,7 @@ import { } from '../domain/candle-coverage'; import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { CandleRepository } from '../infrastructure/candle.repository'; +import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; @@ -413,17 +412,7 @@ export class CandleService { window.from, new Date(Math.min(window.to.valueOf(), now.valueOf())), ); - const rows = candles.flatMap((candle) => { - const time = timestamp(candle.time); - if (!time || !candle.close) throw new Error('Invalid candle response'); - return [ - { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }, - ]; - }); + const rows = candles.map(toSavedCandle); if (!(await this.repository.stage(attempt, window, rows, now))) { return false; } diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index cfc78a0..6cf0824 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto'; import { decimalFromString, divide, - formatDecimal, } from '../../../../common/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { @@ -13,7 +12,6 @@ import { } from '../../../../common/time'; import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankAccessService, @@ -37,6 +35,7 @@ import { catalogScope, type CatalogSource, } from '../infrastructure/catalog-discovery.repository'; +import { toSavedCandle } from '../infrastructure/tbank-candle.mapper'; import { catalogSearchLimit } from './catalog-selection'; import { previousClose, timestamp } from './instrument-market'; @@ -353,17 +352,7 @@ export class CatalogEnrichmentService { gap.from, gap.to, ); - const rows = response.map((candle) => { - const time = timestamp(candle.time); - if (!time || !candle.close) { - throw new Error('Invalid candle response'); - } - return { - time, - close: formatDecimal(decimal(candle.close)), - isComplete: candle.isComplete === true, - }; - }); + const rows = response.map(toSavedCandle); if (!(await this.candles.stage(attempt, gap, rows, now))) { throw new Error('Cancelled candle comparison'); } diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts new file mode 100644 index 0000000..52d7d59 --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts @@ -0,0 +1,19 @@ +import { toSavedCandle } from './tbank-candle.mapper'; + +describe('T-Bank candle mapping', () => { + it('keeps signed nano precision and requires explicit completeness', () => { + const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } }; + expect(toSavedCandle(input)).toEqual({ + time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false, + }); + expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true); + expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } }); + }); + it.each([ + { close: { units: '1' } }, + { time: { seconds: 'invalid' }, close: { units: '1' } }, + { time: { seconds: '1767225600' } }, + ])('rejects missing or malformed source fields: %j', (input) => { + expect(() => toSavedCandle(input)).toThrow('Invalid candle response'); + }); +}); diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts new file mode 100644 index 0000000..08942ea --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts @@ -0,0 +1,18 @@ +import type { TbankCandle } from '../../../../integrations/tbank/tbank.api'; +import type { SavedCandle } from '../domain/candle-coverage'; + +import { formatDecimal } from '../../../../common/decimal'; +import { decimal } from '../../../../integrations/tbank/decimal'; +import { timestamp } from '../application/instrument-market'; + +function toSavedCandle(candle: TbankCandle): SavedCandle { + const time = timestamp(candle.time); + if (!time || !candle.close) throw new Error('Invalid candle response'); + return { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }; +} + +export { toSavedCandle }; diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5df4ec9..f29cf3a 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -129,6 +129,43 @@ describe('persistent portfolio projection', () => { ]); expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); }); + it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => { + repository.reserve.mockResolvedValueOnce({ + launch: true, + work: { id: 'work', attemptId: 'attempt' }, + }); + tbank.getPortfolio.mockResolvedValue({ positions: [ + { quantity: { units: '1' }, currentPrice: { units: '3' } }, + { + quantity: { units: '1' }, + currentPrice: { currency: 'USD', units: '3' }, + averagePositionPrice: { currency: 'RUB', units: '2' }, + }, + ] }); + tbank.getPositions.mockResolvedValue({ money: [ + { currency: 'rub', units: '2' }, + { currency: 'RUB', units: '-2' }, + { units: '99' }, + { currency: 'EUR', units: '1' }, + ] }); + await service.request('user', 'private', 'a'); + for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) { + await immediate(); + } + const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; + expect(snapshot.cashBalances).toEqual([ + { currency: 'RUB', value: '0' }, + { currency: 'EUR', value: '1' }, + ]); + expect(snapshot.totalsByCurrency).toEqual([ + { currency: 'EUR', value: '1' }, + { currency: 'USD', value: '3' }, + ]); + expect(snapshot.positions[0].currentValue).toBeNull(); + expect(snapshot.positions[1].absoluteReturn).toBeNull(); + expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' }); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 8da8363..14d7f19 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -5,18 +5,8 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { - add, - decimalFromString, - formatDecimal, - isZero, - multiply, - 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 { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, @@ -24,9 +14,9 @@ import { metadataKey, TbankAccessService, TbankClient, - type TbankInstrument, TbankMembershipService, - TbankMoney, + type TbankMoney, + type TbankPortfolio, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; import { CandleService } from '../../instruments/instruments.api'; @@ -38,25 +28,12 @@ import { PortfolioRepository, portfolioWorkKey, } from '../infrastructure/portfolio.repository'; -import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; -import { - PortfolioDto, - PortfolioPositionDto, -} from '../presentation/portfolio.dto'; +import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; +import { PortfolioDto } from '../presentation/portfolio.dto'; const MISSING_DATA_REVISION = 0; const membershipFreshForMs = tbankSourceTtlMs.membership; -const types: Record = { - share: 'STOCK', - bond: 'BOND', - etf: 'FUND', - currency: 'CURRENCY', - futures: 'FUTURE', - option: 'OPTION', - sp: 'STRUCTURED_NOTE', - dfa: 'DIGITAL_FINANCIAL_ASSET', -}; @Injectable() export class PortfolioService { @@ -423,109 +400,11 @@ export class PortfolioService { } private async normalize( - upstream: { - averagePositionPrice?: TbankMoney; - currentPrice?: TbankMoney; - figi?: string; - instrumentType?: string; - instrumentUid?: string; - quantity?: { nano?: number | string; units?: number | string }; - }[], + upstream: NonNullable, money: TbankMoney[], ): Promise { const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); 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>(); - for (const value of money) { - const currency = value.currency?.trim().toUpperCase(); - const amount = decimal(value); - if (!currency || isZero(amount)) continue; - cash.set(currency, add(cash.get(currency) ?? decimal({}), amount)); - } - const cashBalances = [...cash].map(([currency, value]) => ({ - currency, - value: formatDecimal(value), - })); - const normalizedPositions = positions.filter( - (position): position is PortfolioPositionDto => position !== null, - ); - return { - lastSuccessfulAt, - isStale: false, - totalsByCurrency: totalsByCurrency(upstream, money), - positions: normalizedPositions, - cashBalances, - structure: portfolioStructure(normalizedPositions, cashBalances), - }; - } - - private position( - position: Parameters[0][number], - metadata: null | TbankInstrument, - ): null | PortfolioPositionDto { - const quantity = decimal(position.quantity ?? {}); - if (isZero(quantity)) return null; - const figi = position.figi?.trim() || null; - const instrumentUid = position.instrumentUid?.trim() || null; - const currentPrice = this.money(position.currentPrice); - const averagePurchasePrice = this.money(position.averagePositionPrice); - const currentValue = currentPrice && this.value(quantity, currentPrice); - const absoluteReturn = - currentPrice && - averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency - ? { - currency: currentPrice.currency, - value: formatDecimal( - multiply( - subtract( - decimalFromString(currentPrice.value), - decimalFromString(averagePurchasePrice.value), - ), - quantity, - ), - ), - } - : null; - return { - figi, - instrumentUid, - displayName: - metadata?.name?.trim() || - metadata?.ticker?.trim() || - figi || - 'Unknown instrument', - type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER', - quantity: formatDecimal(quantity), - currentValue, - averagePurchasePrice, - absoluteReturn, - isin: metadata?.isin?.trim() || null, - ticker: metadata?.ticker?.trim() || null, - logoName: metadata?.brand?.logoName?.trim() || null, - }; - } - - private value( - quantity: ReturnType, - price: MoneyDto, - ): MoneyDto { - return { - currency: price.currency, - value: formatDecimal(multiply(quantity, decimalFromString(price.value))), - }; - } - - private money(value: TbankMoney | undefined): MoneyDto | null { - const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt); } } diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index f4ae5b6..08c8873 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,16 +1,110 @@ import type { Money } from '../../../../common/money.types'; +import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto'; import { add, + decimalFromString, formatDecimal, isZero, multiply, + subtract, } from '../../../../common/decimal'; import { decimal } from '../../../../integrations/tbank/decimal'; import { + metadataKey, + type TbankInstrument, + type TbankMoney, TbankPortfolio, TbankPositions, } from '../../../../integrations/tbank/tbank.api'; +import { portfolioStructure } from '../domain/portfolio-structure'; + +const types: Record = { + share: 'STOCK', + bond: 'BOND', + etf: 'FUND', + currency: 'CURRENCY', + futures: 'FUTURE', + option: 'OPTION', + sp: 'STRUCTURED_NOTE', + dfa: 'DIGITAL_FINANCIAL_ASSET', +}; + +function mapPortfolio( + upstream: NonNullable, + cash: TbankMoney[], + metadata: ReadonlyMap, + lastSuccessfulAt: string, +): PortfolioDto { + const positions = upstream + .map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null)) + .filter((position): position is PortfolioPositionDto => position !== null); + const balances = new Map>(); + for (const value of cash) { + const currency = value.currency?.trim().toUpperCase(); + const amount = decimal(value); + if (!currency || isZero(amount)) continue; + balances.set(currency, add(balances.get(currency) ?? decimal({}), amount)); + } + const cashBalances = [...balances].map(([currency, value]) => ({ + currency, + value: formatDecimal(value), + })); + return { + lastSuccessfulAt, + isStale: false, + totalsByCurrency: totalsByCurrency(upstream, cash), + positions, + cashBalances, + structure: portfolioStructure(positions, cashBalances), + }; +} + +function mapPosition( + position: NonNullable[number], + metadata: null | TbankInstrument, +): null | PortfolioPositionDto { + const quantity = decimal(position.quantity ?? {}); + if (isZero(quantity)) return null; + const figi = position.figi?.trim() || null; + const instrumentUid = position.instrumentUid?.trim() || null; + const currentPrice = money(position.currentPrice); + const averagePurchasePrice = money(position.averagePositionPrice); + const currentValue = currentPrice && { + currency: currentPrice.currency, + value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))), + }; + const absoluteReturn = + currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency + ? { + currency: currentPrice.currency, + value: formatDecimal(multiply( + subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)), + quantity, + )), + } + : null; + return { + figi, + instrumentUid, + displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument', + type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER', + quantity: formatDecimal(quantity), + currentValue, + averagePurchasePrice, + absoluteReturn, + isin: metadata?.isin?.trim() || null, + ticker: metadata?.ticker?.trim() || null, + logoName: metadata?.brand?.logoName?.trim() || null, + }; +} + +function money(value: TbankMoney | undefined): Money | null { + const currency = value?.currency?.trim().toUpperCase(); + return currency && value + ? { currency, value: formatDecimal(decimal(value)) } + : null; +} function totalsByCurrency( positions: NonNullable, @@ -44,4 +138,4 @@ function totalsByCurrency( .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); } -export { totalsByCurrency }; +export { mapPortfolio, totalsByCurrency };