diff --git a/src/tbank/all-accounts.spec.ts b/src/tbank/all-accounts.spec.ts index 067f229..45f5b0b 100644 --- a/src/tbank/all-accounts.spec.ts +++ b/src/tbank/all-accounts.spec.ts @@ -89,6 +89,73 @@ const operation = ( const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 }; describe('All accounts', () => { + it('does not truncate instrument history when an account opening date is missing', async () => { + const { context, operations, tbank } = setup(); + Object.assign(context.accounts[0], { openedOn: null }); + tbank.getOperationsByCursor.mockResolvedValue({ items: [] }); + await operations.getAll('user', { instrumentUid: 'common' }); + expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ + from: { seconds: '0' }, + instrumentId: 'common', + }), + ); + }); + + it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => { + const { operations, tbank } = setup(); + tbank.getOperationsByCursor.mockImplementation( + ( + _token: string, + request: { instrumentId: string; accountId: string }, + ) => ({ + items: [ + { + ...operation( + request.accountId + '-dividend', + 9, + 'OPERATION_TYPE_DIVIDEND', + ), + instrumentUid: request.instrumentId, + }, + { + ...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'), + instrumentUid: 'preferred', + }, + ], + }), + ); + const first = await operations.getAll('user', { + instrumentUid: 'common', + limit: 1, + }); + expect(first.items).toHaveLength(1); + expect(first.items[0].instrumentUid).toBe('common'); + expect(tbank.getOperationsByCursor).toHaveBeenCalledWith( + 'token', + expect.objectContaining({ + instrumentId: 'common', + from: { seconds: '1577836800' }, + }), + ); + await expect( + operations.getAll('user', { + instrumentUid: 'preferred', + limit: 1, + cursor: first.nextCursor!, + }), + ).rejects.toBeInstanceOf(BadRequestException); + const second = await operations.getAll('user', { + instrumentUid: 'common', + limit: 1, + cursor: first.nextCursor!, + }); + expect(second.items[0].instrumentUid).toBe('common'); + expect(second.items[0].id).not.toBe(first.items[0].id); + expect(second.hasNext).toBe(false); + }); + afterEach(() => jest.useRealTimers()); it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => { diff --git a/src/tbank/brokerage-account.dto.ts b/src/tbank/brokerage-account.dto.ts index 8f1ebb7..b9066c8 100644 --- a/src/tbank/brokerage-account.dto.ts +++ b/src/tbank/brokerage-account.dto.ts @@ -4,6 +4,20 @@ import { MoneyDto } from './portfolio.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) totalsByCurrency: MoneyDto[]; + + @ApiProperty({ + type: 'integer', + minimum: 0, + description: 'Number of non-zero stock positions, not the number of units.', + }) + stockPositionCount: number; + + @ApiProperty({ + type: 'integer', + minimum: 0, + description: 'Number of non-zero bond positions, not the number of units.', + }) + bondPositionCount: number; } export class BrokerageAccountDto { diff --git a/src/tbank/brokerage-accounts.service.spec.ts b/src/tbank/brokerage-accounts.service.spec.ts index 1d3b30e..fae80d0 100644 --- a/src/tbank/brokerage-accounts.service.spec.ts +++ b/src/tbank/brokerage-accounts.service.spec.ts @@ -73,14 +73,22 @@ describe('BrokerageAccountsService', () => { type: 'BROKERAGE', name: 'Основной', openedOn: '2024-08-13', - portfolioSummary: { totalsByCurrency: [] }, + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 0, + bondPositionCount: 0, + }, }, { id: 'iis', type: 'INDIVIDUAL_INVESTMENT', name: 'ИИС', openedOn: null, - portfolioSummary: { totalsByCurrency: [] }, + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 0, + bondPositionCount: 0, + }, }, ]); }); @@ -115,6 +123,8 @@ describe('BrokerageAccountsService', () => { openedOn: null, portfolioSummary: { totalsByCurrency: [{ currency: 'RUB', value: '6.5' }], + stockPositionCount: 0, + bondPositionCount: 0, }, }, { @@ -124,6 +134,8 @@ describe('BrokerageAccountsService', () => { openedOn: null, portfolioSummary: { totalsByCurrency: [{ currency: 'USD', value: '4' }], + stockPositionCount: 0, + bondPositionCount: 0, }, }, ]); @@ -131,6 +143,39 @@ describe('BrokerageAccountsService', () => { expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second'); }); + it('counts non-zero stock and bond positions even without a valuation', async () => { + prisma.tbankConnection.findUnique.mockResolvedValue({ + encryptedToken: tokens.encrypt('token'), + }); + tbank.getOpenAccounts.mockResolvedValue([ + { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' }, + ]); + tbank.getPortfolio.mockResolvedValue({ + positions: [ + { instrumentType: 'share', quantity: { units: 200 } }, + { instrumentType: 'SHARE', quantity: { units: -5 } }, + { instrumentType: 'share', quantity: { units: 0, nano: 1 } }, + { instrumentType: 'bond', quantity: { units: 80 } }, + { instrumentType: 'bond', quantity: { units: 0 } }, + { instrumentType: 'share' }, + { instrumentType: 'etf', quantity: { units: 10 } }, + { instrumentType: 'currency', quantity: { units: 100 } }, + { quantity: { units: 1 } }, + ], + virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }], + }); + + await expect(service.list('user-1')).resolves.toMatchObject([ + { + portfolioSummary: { + totalsByCurrency: [], + stockPositionCount: 3, + bondPositionCount: 1, + }, + }, + ]); + }); + it('localizes an unavailable summary without suppressing another account', async () => { prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: tokens.encrypt('token'), diff --git a/src/tbank/brokerage-accounts.service.ts b/src/tbank/brokerage-accounts.service.ts index 5c70572..5201609 100644 --- a/src/tbank/brokerage-accounts.service.ts +++ b/src/tbank/brokerage-accounts.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common'; import { BrokerageAccountDto } from './brokerage-account.dto'; +import { decimal, isZero } from './decimal'; import { totalsByCurrency } from './portfolio-totals'; import { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; @@ -39,13 +40,24 @@ export class BrokerageAccountsService { positionsResult.status === 'rejected' ) return { ...account, portfolioSummary: null }; + const positions = portfolioResult.value.positions ?? []; + let stockPositionCount = 0; + let bondPositionCount = 0; + for (const position of positions) { + if (isZero(decimal(position.quantity ?? {}))) continue; + const type = position.instrumentType?.toLowerCase(); + if (type === 'share') stockPositionCount += 1; + if (type === 'bond') bondPositionCount += 1; + } return { ...account, portfolioSummary: { totalsByCurrency: totalsByCurrency( - portfolioResult.value.positions ?? [], + positions, positionsResult.value.money ?? [], ), + stockPositionCount, + bondPositionCount, }, }; }), diff --git a/src/tbank/brokerage-operation.dto.ts b/src/tbank/brokerage-operation.dto.ts index 1eb5ce1..40d5ef8 100644 --- a/src/tbank/brokerage-operation.dto.ts +++ b/src/tbank/brokerage-operation.dto.ts @@ -7,6 +7,7 @@ import { IsInt, IsOptional, IsString, + IsUUID, Max, MaxLength, Min, @@ -25,6 +26,15 @@ export const operationCategories = [ export type OperationCategory = (typeof operationCategories)[number]; export class BrokerageOperationsQueryDto { + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Instrument UID. Without from, includes history since the earliest available account opening.', + }) + @IsOptional() + @IsUUID() + instrumentUid?: string; + @ApiPropertyOptional({ format: 'date-time' }) @IsOptional() @IsDateString() diff --git a/src/tbank/brokerage-operation.mapper.spec.ts b/src/tbank/brokerage-operation.mapper.spec.ts index c6d7f96..cf569ca 100644 --- a/src/tbank/brokerage-operation.mapper.spec.ts +++ b/src/tbank/brokerage-operation.mapper.spec.ts @@ -46,3 +46,19 @@ describe('mapBrokerageOperation', () => { }); }); }); + +describe('executed trade quantity', () => { + it('uses the executed part, and does not invent execution quantity when absent', () => { + expect( + mapBrokerageOperation({ + type: 'OPERATION_TYPE_BUY', + quantity: '100', + quantityDone: '7', + }).quantity, + ).toBe('7'); + expect( + mapBrokerageOperation({ type: 'OPERATION_TYPE_BUY', quantity: '100' }) + .quantity, + ).toBeNull(); + }); +}); diff --git a/src/tbank/brokerage-operation.mapper.ts b/src/tbank/brokerage-operation.mapper.ts index 9d899f1..47119b7 100644 --- a/src/tbank/brokerage-operation.mapper.ts +++ b/src/tbank/brokerage-operation.mapper.ts @@ -15,6 +15,9 @@ const types: Record = { OPERATION_TYPE_COUPON: 'income', OPERATION_TYPE_DIVIDEND: 'income', OPERATION_TYPE_DIVIDEND_TRANSFER: 'income', + OPERATION_TYPE_DIV_EXT: 'income', + OPERATION_TYPE_TRANS_IIS_BS: 'transfer', + OPERATION_TYPE_TRANS_BS_BS: 'transfer', OPERATION_TYPE_BOND_TAX: 'tax', OPERATION_TYPE_TAX: 'tax', OPERATION_TYPE_DIVIDEND_TAX: 'tax', @@ -48,7 +51,15 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - operation.quantity === undefined ? null : String(operation.quantity), + (operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity) === undefined + ? null + : String( + operationCategory(operation.type) === 'trade' + ? operation.quantityDone + : operation.quantity, + ), price: money(operation.price), accruedInterest: money(operation.accruedInt), commission: money(operation.commission), diff --git a/src/tbank/brokerage-operations.service.ts b/src/tbank/brokerage-operations.service.ts index d2405d4..fdeddc4 100644 --- a/src/tbank/brokerage-operations.service.ts +++ b/src/tbank/brokerage-operations.service.ts @@ -71,8 +71,23 @@ export class BrokerageOperationsService { this.requestContext.set({ userId, brokerageAccountId: accountId }); const range = this.range(query); - return this.access.withAccount(userId, accountId, ({ token, generation }) => - this.page(token, generation, userId, accountId, query, range), + return this.access.withAccount( + userId, + accountId, + ({ token, generation, account }) => + this.page( + token, + generation, + userId, + accountId, + query, + query.instrumentUid && !query.from + ? { + ...range, + from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000), + } + : range, + ), ); } @@ -87,6 +102,7 @@ export class BrokerageOperationsService { userId, generation, accounts: accounts.map((account) => account.id).sort(), + instrumentUid: query.instrumentUid ?? null, from: query.from ?? null, to: query.to ?? null, categories: [...new Set(query.categories ?? [])].sort(), @@ -101,7 +117,22 @@ export class BrokerageOperationsService { ) throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); if (continuation?.page) return continuation.page; - const range = continuation?.range ?? this.range(query); + const oldest = accounts + .map((account) => account.openedOn) + .filter((date): date is string => Boolean(date)) + .sort()[0]; + const range = + continuation?.range ?? + this.range({ + ...query, + from: + query.from ?? + (query.instrumentUid + ? ((accounts.every((account) => account.openedOn) + ? oldest + : undefined) ?? '1970-01-01') + : undefined), + }); const streams: OperationStream[] = continuation ? continuation.streams.map((stream) => ({ ...stream, @@ -362,11 +393,13 @@ export class BrokerageOperationsService { cursor: query.cursor, limit: query.limit ?? 100, state: 'OPERATION_STATE_EXECUTED', + ...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}), }); const items = (response.items ?? []).filter( (item) => - !query.categories?.length || - query.categories.includes(operationCategory(item.type)), + (!query.instrumentUid || item.instrumentUid === query.instrumentUid) && + (!query.categories?.length || + query.categories.includes(operationCategory(item.type))), ); const logoNames = new Map>(); const page = { @@ -441,6 +474,7 @@ function cacheKey( from: range.from.toISOString(), to: end, categories, + instrumentUid: query.instrumentUid ?? null, cursor: query.cursor ?? null, limit: query.limit ?? 100, }); diff --git a/src/tbank/decimal.ts b/src/tbank/decimal.ts index dd6236a..11e817a 100644 --- a/src/tbank/decimal.ts +++ b/src/tbank/decimal.ts @@ -59,3 +59,14 @@ export function formatDecimal({ value, scale }: Decimal): string { const fraction = digits.slice(-scale).replace(/0+$/, ''); return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; } + +// Round half away from zero; callers choose presentation precision explicitly. +export function divide(left: Decimal, right: Decimal, scale: number): Decimal { + if (right.value === 0n) throw new Error('Division by zero'); + const numerator = left.value * 10n ** BigInt(right.scale + scale); + const denominator = right.value * 10n ** BigInt(left.scale); + const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; + const n = numerator < 0n ? -numerator : numerator; + const d = denominator < 0n ? -denominator : denominator; + return { value: sign * ((n + d / 2n) / d), scale }; +} diff --git a/src/tbank/portfolio.dto.ts b/src/tbank/portfolio.dto.ts index 9062104..865799d 100644 --- a/src/tbank/portfolio.dto.ts +++ b/src/tbank/portfolio.dto.ts @@ -10,6 +10,9 @@ export class MoneyDto { } export class PortfolioPositionDto { + @ApiPropertyOptional({ type: String, nullable: true }) + instrumentUid?: string | null; + @ApiPropertyOptional({ type: AccountReferenceDto }) account?: AccountReferenceDto; diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index 43c9e6b..eb8f449 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -209,6 +209,7 @@ export class PortfolioService { const metadata = await this.metadata.resolve(token, instrumentUid, figi); return { figi, + instrumentUid, displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || diff --git a/src/tbank/proto/contracts/marketdata.proto b/src/tbank/proto/contracts/marketdata.proto new file mode 100644 index 0000000..e1afb1e --- /dev/null +++ b/src/tbank/proto/contracts/marketdata.proto @@ -0,0 +1,692 @@ +syntax = "proto3"; + +package tinkoff.public.invest.api.contract.v1; + +option go_package = "./;investapi"; +option java_package = "ru.tinkoff.piapi.contract.v1"; +option java_multiple_files = true; +option csharp_namespace = "Tinkoff.InvestApi.V1"; +option objc_class_prefix = "TIAPI"; +option php_namespace = "Tinkoff\\Invest\\V1"; + +import "google/protobuf/timestamp.proto"; +import "common.proto"; +import "google/api/field_behavior.proto"; + +service MarketDataService {//Сервис для получения биржевой информации:
1. Свечи.
2. Стаканы.
3. Торговые статусы.
4. Лента сделок. + + //GetCandles — исторические свечи по инструменту + rpc GetCandles(GetCandlesRequest) returns (GetCandlesResponse); + + //GetLastPrices — цены последних сделок по инструментам + rpc GetLastPrices(GetLastPricesRequest) returns (GetLastPricesResponse); + + //GetOrderBook — стакан по инструменту + rpc GetOrderBook(GetOrderBookRequest) returns (GetOrderBookResponse); + + //GetTradingStatus — статус торгов по инструменту + rpc GetTradingStatus(GetTradingStatusRequest) returns (GetTradingStatusResponse); + + //GetTradingStatuses — статус торгов по инструментам + rpc GetTradingStatuses(GetTradingStatusesRequest) returns (GetTradingStatusesResponse); + + //GetLastTrades — обезличенные сделки + //Обезличенные сделки по инструменту. Метод гарантирует получение информации за последний час. + rpc GetLastTrades(GetLastTradesRequest) returns (GetLastTradesResponse); + + //GetClosePrices — цены закрытия торговой сессии по инструментам + rpc GetClosePrices(GetClosePricesRequest) returns (GetClosePricesResponse); + + //GetTechAnalysis — технические индикаторы по инструменту + rpc GetTechAnalysis(GetTechAnalysisRequest) returns (GetTechAnalysisResponse); + + //GetMarketValues — рыночные данные по инструментам + rpc GetMarketValues(GetMarketValuesRequest) returns (GetMarketValuesResponse); +} + +service MarketDataStreamService { + //MarketDataStream — bidirectional стрим предоставления биржевой информации + rpc MarketDataStream(stream MarketDataRequest) returns (stream MarketDataResponse); + + //MarketDataServerSideStream — server-side стрим предоставления биржевой информации + rpc MarketDataServerSideStream(MarketDataServerSideStreamRequest) returns (stream MarketDataResponse); +} + +//Запрос подписки или отписки на определенные биржевые данные. +message MarketDataRequest { + oneof payload { + SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи. + SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы. + SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок. + SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов. + SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок. + GetMySubscriptions get_my_subscriptions = 6; //Запрос своих подписок. + PingRequest ping = 7; //Запрос проверки активности соединения. + PingDelaySettings ping_settings = 15; //Запрос настройки пинга. + } +} + +message MarketDataServerSideStreamRequest { + SubscribeCandlesRequest subscribe_candles_request = 1; //Запрос подписки на свечи. + SubscribeOrderBookRequest subscribe_order_book_request = 2; //Запрос подписки на стаканы. + SubscribeTradesRequest subscribe_trades_request = 3; //Запрос подписки на ленту обезличенных сделок. + SubscribeInfoRequest subscribe_info_request = 4; //Запрос подписки на торговые статусы инструментов. + SubscribeLastPriceRequest subscribe_last_price_request = 5; //Запрос подписки на цены последних сделок. + PingDelaySettings ping_settings = 15; //Запрос настройки пинга. +} + +//Пакет биржевой информации по подписке. +message MarketDataResponse { + oneof payload { + SubscribeCandlesResponse subscribe_candles_response = 1; //Результат подписки на свечи. + SubscribeOrderBookResponse subscribe_order_book_response = 2; //Результат подписки на стаканы. + SubscribeTradesResponse subscribe_trades_response = 3; //Результат подписки на поток обезличенных сделок. + SubscribeInfoResponse subscribe_info_response = 4; //Результат подписки на торговые статусы инструментов. + Candle candle = 5; //Свеча. + Trade trade = 6; //Сделки. + OrderBook orderbook = 7; //Стакан. + TradingStatus trading_status = 8; //Торговый статус. + Ping ping = 9; //Проверка активности стрима. + SubscribeLastPriceResponse subscribe_last_price_response = 10; //Результат подписки на цены последние сделок по инструментам. + LastPrice last_price = 11; //Цена последней сделки. + OpenInterest open_interest = 12; //Открытый интерес. + } +} + +// subscribeCandles | Изменения статуса подписки на свечи. +message SubscribeCandlesRequest { + SubscriptionAction subscription_action = 1; //Изменение статуса подписки. + repeated CandleInstrument instruments = 2; //Массив инструментов для подписки на свечи. + bool waiting_close = 3; //Флаг ожидания закрытия временного интервала для отправки свечи. + optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей. +} + +//Тип операции со списком подписок. +enum SubscriptionAction { + SUBSCRIPTION_ACTION_UNSPECIFIED = 0; //Статус подписки не определен. + SUBSCRIPTION_ACTION_SUBSCRIBE = 1; //Подписаться. + SUBSCRIPTION_ACTION_UNSUBSCRIBE = 2; //Отписаться. +} + +//Интервал свечи. +enum SubscriptionInterval { + SUBSCRIPTION_INTERVAL_UNSPECIFIED = 0; //Интервал свечи не определен. + SUBSCRIPTION_INTERVAL_ONE_MINUTE = 1; //Минутные свечи. + SUBSCRIPTION_INTERVAL_FIVE_MINUTES = 2; //Пятиминутные свечи. + SUBSCRIPTION_INTERVAL_FIFTEEN_MINUTES = 3; //Пятнадцатиминутные свечи. + SUBSCRIPTION_INTERVAL_ONE_HOUR = 4; //Часовые свечи. + SUBSCRIPTION_INTERVAL_ONE_DAY = 5; //Дневные свечи. + SUBSCRIPTION_INTERVAL_2_MIN = 6; //Двухминутные свечи. + SUBSCRIPTION_INTERVAL_3_MIN = 7; //Трехминутные свечи. + SUBSCRIPTION_INTERVAL_10_MIN = 8; //Десятиминутные свечи. + SUBSCRIPTION_INTERVAL_30_MIN = 9; //Тридцатиминутные свечи. + SUBSCRIPTION_INTERVAL_2_HOUR = 10; //Двухчасовые свечи. + SUBSCRIPTION_INTERVAL_4_HOUR = 11; //Четырехчасовые свечи. + SUBSCRIPTION_INTERVAL_WEEK = 12; //Недельные свечи. + SUBSCRIPTION_INTERVAL_MONTH = 13; //Месячные свечи. +} + +//Запрос изменения статус подписки на свечи. +message CandleInstrument { + string figi = 1 [deprecated = true]; // Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + SubscriptionInterval interval = 2; //Интервал свечей. Двухчасовые и четырехчасовые свечи в стриме отсчитываются с 0:00 по UTC. + string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Результат изменения статус подписки на свечи. +message SubscribeCandlesResponse { + string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id). + repeated CandleSubscription candles_subscriptions = 2; //Массив статусов подписки на свечи. +} + +//Статус подписки на свечи. +message CandleSubscription { + string figi = 1; //FIGI-идентификатор инструмента. + SubscriptionInterval interval = 2; //Интервал свечей. + SubscriptionStatus subscription_status = 3; //Статус подписки. + string instrument_uid = 4; //UID инструмента. + bool waiting_close = 5; //Флаг ожидания закрытия временного интервала для отправки свечи. + string stream_id = 6; //Идентификатор открытого соединения. + string subscription_id = 7; //Идентификатор подписки в формате `UUID`. + SubscriptionAction subscription_action = 8; //Действие подписки. + optional GetCandlesRequest.CandleSource candle_source_type = 9; //Источник свечей. + string ticker = 10; //Тикер инструмента. + string class_code = 11; //Класс-код (секция торгов). +} + +//Результат подписки. +enum SubscriptionStatus { + SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Статус подписки не определен. + SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно. + SUBSCRIPTION_STATUS_INSTRUMENT_NOT_FOUND = 2; //Инструмент не найден. + SUBSCRIPTION_STATUS_SUBSCRIPTION_ACTION_IS_INVALID = 3; //Некорректный статус подписки. [Список возможных значений](./marketdata#subscriptionaction). + SUBSCRIPTION_STATUS_DEPTH_IS_INVALID = 4; //Некорректная глубина стакана. Доступные значения — 1, 10, 20, 30, 40, 50. + SUBSCRIPTION_STATUS_INTERVAL_IS_INVALID = 5; //Некорректный интервал свечей. [Список возможных значений](./marketdata#subscriptioninterval). + SUBSCRIPTION_STATUS_LIMIT_IS_EXCEEDED = 6; //Превышен лимит на общее количество подписок в рамках стрима. [Лимитная политика](./limits/). + SUBSCRIPTION_STATUS_INTERNAL_ERROR = 7; //Внутренняя ошибка сервиса. + SUBSCRIPTION_STATUS_TOO_MANY_REQUESTS = 8; //Превышен лимит на количество запросов на подписки в течение установленного отрезка времени. + SUBSCRIPTION_STATUS_SUBSCRIPTION_NOT_FOUND = 9; // Активная подписка не найдена. Ошибка может возникнуть только при отписке от несуществующей подписки. + SUBSCRIPTION_STATUS_SOURCE_IS_INVALID = 10; //Указан некорректный источник. +} + +//Запрос на изменение статуса подписки на стаканы. +message SubscribeOrderBookRequest { + SubscriptionAction subscription_action = 1; //Изменение статуса подписки. + repeated OrderBookInstrument instruments = 2; //Массив инструментов для подписки на стаканы. +} + +//Запрос подписки на стаканы. +message OrderBookInstrument { + string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + int32 depth = 2; //Глубина стакана. + string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. + OrderBookType order_book_type = 4; //Тип стакана. Значение по умолчанию — `ORDERBOOK_TYPE_ALL`, стакан биржевой и дилера. +} + +//Результат изменения статуса подписки на стаканы. +message SubscribeOrderBookResponse { + string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id). + repeated OrderBookSubscription order_book_subscriptions = 2; //Массив статусов подписки на стаканы. +} + +//Статус подписки. +message OrderBookSubscription { + string figi = 1; //FIGI-идентификатор инструмента. + int32 depth = 2; //Глубина стакана. + SubscriptionStatus subscription_status = 3; //Статус подписки. + string instrument_uid = 4; //UID инструмента. + string stream_id = 5; //Идентификатор открытого соединения. + string subscription_id = 6; //Идентификатор подписки в формате `UUID`. + OrderBookType order_book_type = 7; //Тип стакана. + SubscriptionAction subscription_action = 8; //Действие подписки. + string ticker = 9; //Тикер инструмента. + string class_code = 10; //Класс-код (секция торгов). +} + +//Типы источников сделок. +enum TradeSourceType { + TRADE_SOURCE_UNSPECIFIED = 0; //Тип источника сделки не определён. + TRADE_SOURCE_EXCHANGE = 1; // Биржевые сделки. + TRADE_SOURCE_DEALER = 2; // Сделки дилера. + TRADE_SOURCE_ALL = 3; // Все сделки. +} + +//Изменение статуса подписки на поток обезличенных сделок. +message SubscribeTradesRequest { + SubscriptionAction subscription_action = 1; //Изменение статуса подписки. + repeated TradeInstrument instruments = 2; //Массив инструментов для подписки на поток обезличенных сделок. + TradeSourceType trade_source = 3; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки. + bool with_open_interest = 4; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов +} + +//Запрос подписки на поток обезличенных сделок. +message TradeInstrument { + string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`. + string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Результат изменения статуса подписки на поток обезличенных сделок. +message SubscribeTradesResponse { + string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id). + repeated TradeSubscription trade_subscriptions = 2; //Массив статусов подписки на поток сделок. + TradeSourceType trade_source = 3; //Тип источника сделок. +} + +//Статус подписки. +message TradeSubscription { + string figi = 1; //FIGI-идентификатор инструмента. + SubscriptionStatus subscription_status = 2; //Статус подписки. + string instrument_uid = 3; //UID инструмента. + string stream_id = 4; //Идентификатор открытого соединения. + string subscription_id = 5; //Идентификатор подписки в формате UUID. + bool with_open_interest = 6; //Флаг открытого интереса. **true** - в стриме дополнительно передается информация об открытом интересе для фьючерсов + SubscriptionAction subscription_action = 7; //Действие подписки. + string ticker = 8; //Тикер инструмента. + string class_code = 9; //Класс-код (секция торгов). +} + +//Изменение статуса подписки на торговый статус инструмента. +message SubscribeInfoRequest { + SubscriptionAction subscription_action = 1; //Изменение статуса подписки. + repeated InfoInstrument instruments = 2; //Массив инструментов для подписки на торговый статус. +} + +//Запрос подписки на торговый статус. +message InfoInstrument { + string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`. + string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Результат изменения статуса подписки на торговый статус. +message SubscribeInfoResponse { + string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id). + repeated InfoSubscription info_subscriptions = 2; //Массив статусов подписки на торговый статус. +} + +//Статус подписки. +message InfoSubscription { + string figi = 1; //FIGI-идентификатор инструмента. + SubscriptionStatus subscription_status = 2; //Статус подписки. + string instrument_uid = 3; //UID инструмента. + string stream_id = 4; //Идентификатор открытого соединения. + string subscription_id = 5; //Идентификатор подписки в формате UUID. + SubscriptionAction subscription_action = 6; //Действие подписки. + string ticker = 7; //Тикер инструмента. + string class_code = 8; //Класс-код (секция торгов). +} + +//Изменение статуса подписки на цену последней сделки по инструменту. +message SubscribeLastPriceRequest { + SubscriptionAction subscription_action = 1; //Изменение статуса подписки. + repeated LastPriceInstrument instruments = 2; //Массив инструментов для подписки на цену последней сделки. +} + +//Запрос подписки на последнюю цену. +message LastPriceInstrument { + string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте instrument_id`. + string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Результат изменения статуса подписки на цену последней сделки. +message SubscribeLastPriceResponse { + string tracking_id = 1; //Уникальный идентификатор запроса. [Подробнее](./grpc#tracking-id). + repeated LastPriceSubscription last_price_subscriptions = 2; //Массив статусов подписки на цену последней сделки. +} + +//Статус подписки на цену последней сделки. +message LastPriceSubscription { + string figi = 1; //FIGI-идентификатор инструмента. + SubscriptionStatus subscription_status = 2; //Статус подписки. + string instrument_uid = 3; //UID инструмента. + string stream_id = 4; //Идентификатор открытого соединения. + string subscription_id = 5; //Идентификатор подписки в формате `UUID`. + SubscriptionAction subscription_action = 6; //Действие подписки. + string ticker = 7; //Тикер инструмента. + string class_code = 8; //Класс-код (секция торгов). +} + +//Пакет свечей в рамках стрима. +message Candle { + string figi = 1; //FIGI-идентификатор инструмента. + SubscriptionInterval interval = 2; //Интервал свечи. + Quotation open = 3; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation high = 4; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation low = 5; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation close = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + int64 volume = 7; //Объем сделок в лотах. + google.protobuf.Timestamp time = 8; //Время начала интервала свечи по UTC. + google.protobuf.Timestamp last_trade_ts = 9; //Время последней сделки, вошедшей в свечу по UTC. + string instrument_uid = 10; //UID инструмента. + string ticker = 11; //Тикер инструмента. + string class_code = 12; //Класс-код (секция торгов). + int64 volume_buy = 13; //Объем торгов на покупку. + int64 volume_sell = 14; //Объём торгов на продажу. + CandleSource candle_source_type = 19; //Источник свечей. +} + +//Пакет стаканов в рамках стрима. +message OrderBook { + string figi = 1; //FIGI-идентификатор инструмента. + int32 depth = 2; //Глубина стакана. + bool is_consistent = 3; //Флаг консистентности стакана. **false** — не все заявки попали в стакан из-за сетевых задержек или нарушения порядка доставки. + repeated Order bids = 4; //Массив предложений. + repeated Order asks = 5; //Массив спроса. + google.protobuf.Timestamp time = 6; //Время формирования стакана в часовом поясе UTC по времени биржи. + Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + string instrument_uid = 9; //UID инструмента. + OrderBookType order_book_type = 10; //Тип стакана. + string ticker = 11; //Тикер инструмента. + string class_code = 12; //Класс-код (секция торгов). +} + +//Массив предложений/спроса. +message Order { + Quotation price = 1; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + int64 quantity = 2; //Количество в лотах. +} + +//Информация о сделке. +message Trade { + string figi = 1; //FIGI-идентификатор инструмента. + TradeDirection direction = 2; //Направление сделки. + Quotation price = 3; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + int64 quantity = 4; //Количество лотов. + google.protobuf.Timestamp time = 5; //Время сделки в часовом поясе UTC по времени биржи. + string instrument_uid = 6; //UID инструмента. + TradeSourceType trade_source = 7; //Тип источника сделки. + string ticker = 8; //Тикер инструмента. + string class_code = 9; //Класс-код (секция торгов). +} + +//Направление сделки. +enum TradeDirection { + TRADE_DIRECTION_UNSPECIFIED = 0; //Направление сделки не определено. + TRADE_DIRECTION_BUY = 1; //Покупка. + TRADE_DIRECTION_SELL = 2; //Продажа. +} + +//Пакет изменения торгового статуса. +message TradingStatus { + string figi = 1; //FIGI-идентификатор инструмента. + SecurityTradingStatus trading_status = 2; //Статус торговли инструментом. + google.protobuf.Timestamp time = 3; //Время изменения торгового статуса по UTC. + bool limit_order_available_flag = 4; //Признак доступности выставления лимитной заявки по инструменту. + bool market_order_available_flag = 5; //Признак доступности выставления рыночной заявки по инструменту. + string instrument_uid = 6; //UID инструмента. + string ticker = 7; //Тикер инструмента. + string class_code = 8; //Класс-код (секция торгов). +} + +//Запрос исторических свечей. +message GetCandlesRequest { + optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC. + google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC. + CandleInterval interval = 4 [(google.api.field_behavior) = REQUIRED]; //Интервал запрошенных свечей. + optional string instrument_id = 5; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. + optional CandleSource candle_source_type = 7; //Тип источника свечи. + optional int32 limit = 10; //Максимальное количество свечей в ответе. + + enum CandleSource { + CANDLE_SOURCE_UNSPECIFIED = 0; //Все свечи. + CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи. + CANDLE_SOURCE_INCLUDE_WEEKEND = 3; //Все свечи с учетом торговли по выходным. + } +} + +//Интервал свечей. Максимальное значение интервала приведено ориентировочно, может отличаться в большую сторону в зависимости от параметров запроса. +enum CandleInterval { + CANDLE_INTERVAL_UNSPECIFIED = 0; //Интервал не определен. + CANDLE_INTERVAL_1_MIN = 1; //От 1 минуты до 1 дня. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_5_MIN = 2; //От 5 минут до недели. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_15_MIN = 3; //От 15 минут до 3 недель. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_HOUR = 4; //От 1 часа до 3 месяцев. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_DAY = 5; //От 1 дня до 6 лет. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_2_MIN = 6; //От 2 минут до 1 дня. Максимальное значение `limit` — 1200. + CANDLE_INTERVAL_3_MIN = 7; //От 3 минут до 1 дня. Максимальное значение `limit` — 750. + CANDLE_INTERVAL_10_MIN = 8; //От 10 минут до недели. Максимальное значение `limit` — 1200. + CANDLE_INTERVAL_30_MIN = 9; //От 30 минут до 3 недель. Максимальное значение `limit` — 1200. + CANDLE_INTERVAL_2_HOUR = 10; //От 2 часов до 3 месяцев. Максимальное значение `limit` — 2400. + CANDLE_INTERVAL_4_HOUR = 11; //От 4 часов до 3 месяцев. Максимальное значение `limit` — 700. + CANDLE_INTERVAL_WEEK = 12; //От 1 недели до 5 лет. Максимальное значение `limit` — 300. + CANDLE_INTERVAL_MONTH = 13; //От 1 месяца до 10 лет. Максимальное значение `limit` — 120. + CANDLE_INTERVAL_5_SEC = 14; //От 5 секунд до 200 минут. Максимальное значение `limit` — 2500. + CANDLE_INTERVAL_10_SEC = 15; //От 10 секунд до 200 минут. Максимальное значение `limit` — 1250. + CANDLE_INTERVAL_30_SEC = 16; //От 30 секунд до 20 часов. Максимальное значение `limit` — 2500. +} + +enum CandleSource { + CANDLE_SOURCE_UNSPECIFIED = 0; //Источник свечей не определен. + CANDLE_SOURCE_EXCHANGE = 1; //Биржевые свечи. + CANDLE_SOURCE_DEALER_WEEKEND = 2; //Свечи дилера в результате торговли по выходным. +} + +//Список свечей. +message GetCandlesResponse { + repeated HistoricCandle candles = 1; //Массив свечей. +} + +//Информация о свече. +message HistoricCandle { + Quotation open = 1; //Цена открытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation high = 2; //Максимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation low = 3; //Минимальная цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation close = 4; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + int64 volume = 5; //Объем торгов в лотах. + google.protobuf.Timestamp time = 6; //Время свечи в часовом поясе UTC. + bool is_complete = 7; //Признак завершенности свечи. **false** — свеча за текущие интервал еще сформирована не полностью. + CandleSource candle_source = 9; //Тип источника свечи + int64 volume_buy = 10; //Объем торгов на покупку. + int64 volume_sell = 11; //Объём торгов на продажу. +} + +//Запрос получения цен последних сделок. +message GetLastPricesRequest { + repeated string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + repeated string instrument_id = 2; //Массив идентификаторов инструмента. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`. + LastPriceType last_price_type = 3; //Тип запрашиваемой последней цены. + optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus). +} + +//Список цен последних сделок. +message GetLastPricesResponse { + repeated LastPrice last_prices = 1; //Массив цен последних сделок. +} + +//Информация о цене последней сделки. +message LastPrice { + string figi = 1; //FIGI инструмента. + Quotation price = 2; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + google.protobuf.Timestamp time = 3; //Время получения последней цены в часовом поясе UTC по времени биржи. + string ticker = 9; //Тикер инструмента. + string class_code = 10; //Класс-код (секция торгов). + string instrument_uid = 11; //UID инструмента. + LastPriceType last_price_type = 12; //Тип последней цены. +} + +message OpenInterest { + string instrument_uid = 1; //UID инструмента. + google.protobuf.Timestamp time = 2; //Время получения открытого интереса в часовом поясе UTC по времени биржи. + int64 open_interest = 3; //Открытый интерес. + string ticker = 4; //Тикер инструмента. + string class_code = 5; //Класс-код (секция торгов). +} + +//Запрос стакана. +message GetOrderBookRequest { + optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + int32 depth = 2 [(google.api.field_behavior) = REQUIRED]; //Глубина стакана. + optional string instrument_id = 3; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`. +} + +//Информация о стакане. +message GetOrderBookResponse { + string figi = 1; //FIGI-идентификатор инструмента. + int32 depth = 2; //Глубина стакана. + repeated Order bids = 3; //Множество пар значений на покупку. + repeated Order asks = 4; //Множество пар значений на продажу. + Quotation last_price = 5; //Цена последней сделки за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation close_price = 6; //Цена закрытия за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation limit_up = 7; //Верхний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + Quotation limit_down = 8; //Нижний лимит цены за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента. [Подробнее про перевод цен в валюту](./faq_marketdata/#_15). + string instrument_uid = 9; //UID инструмента. + string ticker = 10; //Тикер инструмента. + string class_code = 11; //Класс-код (секция торгов). + google.protobuf.Timestamp last_price_ts = 21; //Время получения цены последней сделки. + google.protobuf.Timestamp close_price_ts = 22; //Время получения цены закрытия. + google.protobuf.Timestamp orderbook_ts = 23; //Время формирования стакана на бирже. +} + +//Запрос получения торгового статуса. +message GetTradingStatusRequest { + optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code` +} + +//Запрос получения торгового статуса. +message GetTradingStatusesRequest { + repeated string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code` +} + +//Информация о торговом статусе. +message GetTradingStatusesResponse { + repeated GetTradingStatusResponse trading_statuses = 1; //Массив информации о торговых статусах. +} + +//Информация о торговом статусе. +message GetTradingStatusResponse { + string figi = 1; //FIGI-идентификатор инструмента. + SecurityTradingStatus trading_status = 2; //Статус торговли инструментом. + bool limit_order_available_flag = 3; //Признак доступности выставления лимитной заявки по инструменту. + bool market_order_available_flag = 4; //Признак доступности выставления рыночной заявки по инструменту. + bool api_trade_available_flag = 5; //Признак доступности торгов через API. + string instrument_uid = 6; //UID инструмента. + bool bestprice_order_available_flag = 8; //Признак доступности завяки по лучшей цене. + bool only_best_price = 9; //Признак доступности только заявки по лучшей цене. + string ticker = 10; //Тикер инструмента. + string class_code = 11; //Класс-код (секция торгов). +} + +//Запрос обезличенных сделок за последний час. +message GetLastTradesRequest { + optional string figi = 1 [deprecated = true]; //Deprecated FIGI-идентификатор инструмента. Используйте `instrument_id`. + google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC. + google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC. + optional string instrument_id = 4; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code` + TradeSourceType trade_source = 5; //Тип источника сделок. Значение по умолчанию — `TRADE_SOURCE_ALL`, все сделки. +} + +//Обезличенных сделок за последний час. +message GetLastTradesResponse { + repeated Trade trades = 1; //Массив сделок. +} + +//Запрос активных подписок. Возвращает по одному сообщению на каждый тип активных подписок — `SubscribeLastPriceResponse`, `SubscribeInfoResponse`, `SubscribeTradesResponse`, `SubscribeOrderBookResponse`, `SubscribeCandlesResponse`. +message GetMySubscriptions {} + +//Запрос цен закрытия торговой сессии по инструментам. +message GetClosePricesRequest { + repeated InstrumentClosePriceRequest instruments = 1 [(google.api.field_behavior) = REQUIRED]; //Массив по инструментам. + optional InstrumentStatus instrument_status = 9; //Статус запрашиваемых инструментов. [Возможные значения](#instrumentstatus). +} + +//Запрос цен закрытия торговой сессии по инструменту. +message InstrumentClosePriceRequest { + string instrument_id = 1; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code` +} + +//Цены закрытия торговой сессии по инструментам. +message GetClosePricesResponse { + repeated InstrumentClosePriceResponse close_prices = 1; //Массив по инструментам. +} + +//Цена закрытия торговой сессии по инструменту. +message InstrumentClosePriceResponse { + string figi = 1; //FIGI инструмента. + string instrument_uid = 2; //UID инструмента. + string ticker = 3; //Тикер инструмента. + string class_code = 4; //Класс-код (секция торгов). + Quotation price = 11; //Цена закрытия торговой сессии. + Quotation evening_session_price = 12; //Цена последней сделки с вечерней сессии. Цена публикуется биржей по торговым дням и в нерабочие дни не обновляется. + google.protobuf.Timestamp time = 21; //Дата совершения торгов. + google.protobuf.Timestamp evening_session_price_time = 23; //Дата цены закрытия вечерней сессии. +} + +message GetTechAnalysisRequest { + IndicatorType indicator_type = 1 [(google.api.field_behavior) = REQUIRED]; //Тип технического индикатора. + string instrument_uid = 2 [(google.api.field_behavior) = REQUIRED]; //UID инструмента. + google.protobuf.Timestamp from = 3 [(google.api.field_behavior) = REQUIRED]; //Начало запрашиваемого периода по UTC. + google.protobuf.Timestamp to = 4 [(google.api.field_behavior) = REQUIRED]; //Окончание запрашиваемого периода по UTC. + IndicatorInterval interval = 5 [(google.api.field_behavior) = REQUIRED]; //Интервал, за который рассчитывается индикатор. + TypeOfPrice type_of_price = 6 [(google.api.field_behavior) = REQUIRED]; //Тип цены, который используется при расчете индикатора. + int32 length = 7; //Торговый период, за который рассчитывается индикатор. + Deviation deviation = 8; //Параметры отклонения. + Smoothing smoothing = 9; //Параметры сглаживания. + + message Smoothing { + int32 fast_length = 1; //Короткий период сглаживания для первой экспоненциальной скользящей средней (EMA). + int32 slow_length = 2; //Длинный период сглаживания для второй экспоненциальной скользящей средней (EMA). + int32 signal_smoothing = 3; //Период сглаживания для третьей экспоненциальной скользящей средней (EMA) + } + + message Deviation { + Quotation deviation_multiplier = 1; //Количество стандартных отклонений, на которые отступают верхняя и нижняя границы. + } + + //Интервал свечи. + enum IndicatorInterval { + INDICATOR_INTERVAL_UNSPECIFIED = 0; //Интервал не определен. + INDICATOR_INTERVAL_ONE_MINUTE = 1; //1 минута. + INDICATOR_INTERVAL_FIVE_MINUTES = 2; //5 минут. + INDICATOR_INTERVAL_FIFTEEN_MINUTES = 3; //15 минут. + INDICATOR_INTERVAL_ONE_HOUR = 4; //1 час. + INDICATOR_INTERVAL_ONE_DAY = 5; //1 день. + INDICATOR_INTERVAL_2_MIN = 6; //2 минуты. + INDICATOR_INTERVAL_3_MIN = 7; //3 минуты. + INDICATOR_INTERVAL_10_MIN = 8; //10 минут. + INDICATOR_INTERVAL_30_MIN = 9; //30 минут. + INDICATOR_INTERVAL_2_HOUR = 10; //2 часа. + INDICATOR_INTERVAL_4_HOUR = 11; //4 часа. + INDICATOR_INTERVAL_WEEK = 12; //Неделя. + INDICATOR_INTERVAL_MONTH = 13; //Месяц. + } + + enum TypeOfPrice { + TYPE_OF_PRICE_UNSPECIFIED = 0; //Не указано. + TYPE_OF_PRICE_CLOSE = 1; //Цена закрытия. + TYPE_OF_PRICE_OPEN = 2; //Цена открытия. + TYPE_OF_PRICE_HIGH = 3; //Максимальное значение за выбранный интервал. + TYPE_OF_PRICE_LOW = 4; //Минимальное значение за выбранный интервал. + TYPE_OF_PRICE_AVG = 5; //Среднее значение по показателям [ (close + open + high + low) / 4 ]. + } + + enum IndicatorType { + INDICATOR_TYPE_UNSPECIFIED = 0; //Не определен. + INDICATOR_TYPE_BB = 1; //Bollinger Bands — линия Боллинжера. + INDICATOR_TYPE_EMA = 2; //Exponential Moving Average — EMA, экспоненциальная скользящая средняя. + INDICATOR_TYPE_RSI = 3; //Relative Strength Index — индекс относительной силы. + INDICATOR_TYPE_MACD = 4; //Moving Average Convergence/Divergence — схождение/расхождение скользящих средних. + INDICATOR_TYPE_SMA = 5; //Simple Moving Average — простое скользящее среднее. + } +} + +message GetTechAnalysisResponse { + repeated TechAnalysisItem technical_indicators = 1; //Массив значений результатов технического анализа. + + message TechAnalysisItem { + google.protobuf.Timestamp timestamp = 1; //Временная метка по UTC, для которой были рассчитаны значения индикатора. + optional Quotation middle_band = 2; //Значение простого скользящего среднего (средней линии). + optional Quotation upper_band = 3; //Значение верхней линии Боллинджера. + optional Quotation lower_band = 4; //Значение нижней линии Боллинджера. + optional Quotation signal = 5; //Значение сигнальной линии. + optional Quotation macd = 6; //Значение линии MACD. + } +} + +message GetMarketValuesRequest { + repeated string instrument_id = 1; //Массив идентификаторов инструментов. Принимает значения `figi`, `instrument_uid` или `ticker + '_' + class_code`. + repeated MarketValueType values = 2; //Массив запрашиваемых параметров. +} + +message GetMarketValuesResponse { + repeated MarketValueInstrument instruments = 1; //Массив значений параметров. +} + +message MarketValueInstrument { + string instrument_uid = 1; //Идентификатор инструмента. + repeated MarketValue values = 2; //Массив параметров инструмента. + string ticker = 3; //Тикер инструмента. + string class_code = 4; //Класс-код (секция торгов). +} + +message MarketValue { + optional MarketValueType type = 1; //Тип параметра. + optional Quotation value = 2; //Значение. + optional google.protobuf.Timestamp time = 3; //Дата и время. +} + +enum MarketValueType { + INSTRUMENT_VALUE_UNSPECIFIED = 0; //Не определен. + INSTRUMENT_VALUE_LAST_PRICE = 1; //Последняя биржевая цена. + INSTRUMENT_VALUE_LAST_PRICE_DEALER = 2; //Последняя цена дилера. + INSTRUMENT_VALUE_CLOSE_PRICE = 3; //Цена закрытия. + INSTRUMENT_VALUE_EVENING_SESSION_PRICE = 4; //Цена последней сделки с вечерней сессии. + INSTRUMENT_VALUE_OPEN_INTEREST = 5; // Открытый интерес, возвращается только для фьючерсов. + INSTRUMENT_VALUE_THEOR_PRICE = 6; //Теоретическая цена, возвращается только для опционов. + INSTRUMENT_VALUE_YIELD = 7; //Доходность +} + +enum OrderBookType { + ORDERBOOK_TYPE_UNSPECIFIED = 0; //Не определен. + ORDERBOOK_TYPE_EXCHANGE = 1; //Биржевой стакан. + ORDERBOOK_TYPE_DEALER = 2; //Стакан дилера. + ORDERBOOK_TYPE_ALL = 3; //Стакан биржевой и дилера. +} + +//Тип последней цены +enum LastPriceType { + LAST_PRICE_UNSPECIFIED = 0; //Не определен. + LAST_PRICE_EXCHANGE = 1; // Цена биржи. + LAST_PRICE_DEALER = 2; // Цена дилера +} diff --git a/src/tbank/stock-position.spec.ts b/src/tbank/stock-position.spec.ts new file mode 100644 index 0000000..7097d20 --- /dev/null +++ b/src/tbank/stock-position.spec.ts @@ -0,0 +1,83 @@ +import { stockPosition } from './stock-position'; +import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; + +const money = (value: string, currency = 'RUB') => ({ value, currency }); +const row = ( + quantity = '2', + average = '10', + value = '30', + uid = 'common', +): PortfolioPositionDto => ({ + instrumentUid: uid, + figi: uid, + displayName: uid, + type: 'STOCK', + quantity, + averagePurchasePrice: money(average), + currentValue: money(value), + absoluteReturn: money('10'), + isin: null, + ticker: null, + logoName: null, +}); +const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ + positions, + cashBalances: [], + totalsByCurrency: [], + isStale: false, + lastSuccessfulAt: '2026-09-10T00:00:00Z', + structure: { currencies: [], unvaluedCategories: [] }, +}); + +describe('stock position', () => { + it('weights costs exactly and keeps preferred shares separate', () => { + const result = stockPosition( + portfolio([ + row(), + row('3', '20', '70'), + row('100', '1', '100', 'preferred'), + ]), + 'common', + ); + expect(result.quantity).toBe('5'); + expect(result.averagePurchasePrice).toEqual(money('16')); + expect(result.currentValue).toEqual(money('100')); + expect(result.absoluteReturn).toEqual(money('20')); + expect(result.returnPercent).toBe('25'); + expect(result.accounts[0].returnPercent).toBe('50'); + }); + it('does not round large amounts through floating point', () => { + const result = stockPosition( + portfolio([row('9007199254740993', '0.1', '900719925474099.3')]), + 'common', + ); + expect(result.quantity).toBe('9007199254740993'); + expect(result.averagePurchasePrice?.value).toBe('0.1'); + }); + it('does not sum currencies or treat unknown values as zero', () => { + const second = { + ...row(), + currentValue: money('2', 'USD'), + averagePurchasePrice: null, + absoluteReturn: null, + }; + const result = stockPosition(portfolio([row(), second]), 'common'); + expect(result.quantity).toBe('4'); + expect(result.currentValue).toBeNull(); + expect(result.averagePurchasePrice).toBeNull(); + expect(result.absoluteReturn).toBeNull(); + expect(result.returnPercent).toBeNull(); + }); + it('keeps no-position and offsetting long/short holdings honest', () => { + expect(stockPosition(portfolio([]), 'common')).toMatchObject({ + quantity: '0', + accounts: [], + currentValue: null, + returnPercent: null, + }); + expect( + stockPosition(portfolio([row(), row('-2')]), 'common') + .averagePurchasePrice, + ).toBeNull(); + }); +}); diff --git a/src/tbank/stock-position.ts b/src/tbank/stock-position.ts new file mode 100644 index 0000000..7b7b3d7 --- /dev/null +++ b/src/tbank/stock-position.ts @@ -0,0 +1,100 @@ +import { + add, + decimalFromString, + divide, + formatDecimal, + multiply, +} from './decimal'; +import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +import { StockPositionDto } from './stock.dto'; + +export function stockPosition( + portfolio: PortfolioDto, + uid: string, +): StockPositionDto { + const positions = portfolio.positions.filter( + (p) => p.type === 'STOCK' && p.instrumentUid === uid, + ); + const zero = decimalFromString('0'); + const quantity = positions + .map((p) => decimalFromString(p.quantity)) + .reduce(add, zero); + const costs = positions.map(cost); + const totalCost = sumComplete(costs); + const currentValue = sumComplete(positions.map((p) => p.currentValue)); + const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); + const positive = + positions.length > 0 && + positions.every((p) => decimalFromString(p.quantity).value > 0n); + return { + quantity: formatDecimal(quantity), + currentValue, + absoluteReturn, + averagePurchasePrice: + positive && totalCost + ? { + currency: totalCost.currency, + value: formatDecimal( + divide(decimalFromString(totalCost.value), quantity, 9), + ), + } + : null, + returnPercent: positive ? percent(absoluteReturn, totalCost) : null, + accounts: positions.map((p) => ({ + ...p, + returnPercent: + decimalFromString(p.quantity).value > 0n + ? percent(p.absoluteReturn, cost(p)) + : null, + })), + isStale: portfolio.isStale, + observedAt: portfolio.lastSuccessfulAt, + }; +} +function cost(position: PortfolioPositionDto): MoneyDto | null { + const price = position.averagePurchasePrice; + return price + ? { + currency: price.currency, + value: formatDecimal( + multiply( + decimalFromString(price.value), + decimalFromString(position.quantity), + ), + ), + } + : null; +} +function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { + const currency = values[0]?.currency; + if (!currency || values.some((v) => !v || v.currency !== currency)) + return null; + return { + currency, + value: formatDecimal( + values.reduce( + (sum, value) => add(sum, decimalFromString(value!.value)), + decimalFromString('0'), + ), + ), + }; +} +function percent( + result: MoneyDto | null, + base: MoneyDto | null, +): string | null { + if ( + !base || + !result || + base.currency !== result.currency || + decimalFromString(base.value).value <= 0n + ) + return null; + return formatDecimal( + divide( + multiply(decimalFromString(result.value), decimalFromString('100')), + decimalFromString(base.value), + 6, + ), + ); +} diff --git a/src/tbank/stock.controller.ts b/src/tbank/stock.controller.ts new file mode 100644 index 0000000..e71af2b --- /dev/null +++ b/src/tbank/stock.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AccessPayload } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { StockService } from './stock.service'; +import { + StockCandlesDto, + StockCandlesQueryDto, + StockCompanyDto, + StockDividendsDto, + StockDto, + StockTickerParamsDto, + StockPositionDto, + StockQuoteDto, + StockSearchQueryDto, + StockSearchResultDto, +} from './stock.dto'; + +@ApiTags('stocks') +@ApiProblemResponses() +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('stocks') +export class StockController { + constructor(private readonly stocks: StockService) {} + @Get() + @ApiOkResponse({ type: [StockSearchResultDto] }) + search( + @Req() request: Request & { user: AccessPayload }, + @Query() query: StockSearchQueryDto, + ) { + return this.stocks.search(request.user.sub, query.query); + } + @Get('by-ticker/:ticker') + @ApiOkResponse({ type: StockDto }) + getByTicker( + @Req() request: Request & { user: AccessPayload }, + @Param() params: StockTickerParamsDto, + ) { + return this.stocks.getByTicker(request.user.sub, params.ticker); + } + @Get(':uid') + @ApiOkResponse({ type: StockDto }) + get( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.stocks.get(request.user.sub, uid); + } + @Get(':uid/quote') + @ApiOkResponse({ type: StockQuoteDto }) + quote( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.stocks.quote(request.user.sub, uid); + } + @Get(':uid/candles') + @ApiOkResponse({ type: StockCandlesDto }) + candles( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: StockCandlesQueryDto, + ) { + return this.stocks.candles(request.user.sub, uid, query.period); + } + @Get(':uid/position') + @ApiOkResponse({ type: StockPositionDto }) + position( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.stocks.position(request.user.sub, uid); + } + @Get(':uid/company') + @ApiOkResponse({ type: StockCompanyDto }) + company( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.stocks.company(request.user.sub, uid); + } + @Get(':uid/dividends') + @ApiOkResponse({ type: StockDividendsDto }) + dividends( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.stocks.dividends(request.user.sub, uid); + } +} diff --git a/src/tbank/stock.dto.ts b/src/tbank/stock.dto.ts new file mode 100644 index 0000000..4f6f344 --- /dev/null +++ b/src/tbank/stock.dto.ts @@ -0,0 +1,149 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsIn, + IsOptional, + IsString, + MaxLength, + Matches, + MinLength, +} from 'class-validator'; +import { MoneyDto, PortfolioPositionDto } from './portfolio.dto'; + +export class StockSearchQueryDto { + @ApiProperty({ minLength: 2, maxLength: 80 }) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @MinLength(2) + @MaxLength(80) + query: string; +} +export class StockTickerParamsDto { + @ApiProperty({ + minLength: 1, + maxLength: 80, + pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$', + }) + @IsString() + @MinLength(1) + @MaxLength(80) + @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) + ticker: string; +} +export class StockCandlesQueryDto { + @ApiPropertyOptional({ + enum: ['month', 'halfYear', 'year', 'all'], + default: 'month', + }) + @IsOptional() + @IsIn(['month', 'halfYear', 'year', 'all']) + period?: 'month' | 'halfYear' | 'year' | 'all'; +} +export class StockDto { + @ApiProperty({ format: 'uuid' }) instrumentUid: string; + @ApiProperty({ + description: 'Unambiguous ticker URL segment, or the exact instrument UID.', + }) + slug: string; + @ApiProperty() name: string; + @ApiProperty({ type: String, nullable: true }) isin: string | null; + @ApiProperty({ type: String, nullable: true }) classCode: string | null; + @ApiProperty({ type: String, nullable: true }) realExchange: string | null; + @ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: + boolean | null; + @ApiProperty({ type: String, nullable: true }) ticker: string | null; + @ApiProperty({ type: String, nullable: true }) logoName: string | null; + @ApiProperty({ type: String, nullable: true }) currency: string | null; + @ApiProperty({ type: String, nullable: true }) shareType: string | null; + @ApiProperty({ type: String, nullable: true }) sector: string | null; + @ApiProperty({ type: Number, nullable: true }) lot: number | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + historyFrom: string | null; +} +export class StockSearchResultDto extends StockDto { + @ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: + string | null; +} +export class StockQuoteDto { + @ApiProperty({ type: MoneyDto, nullable: true }) price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: + string | null; + @ApiProperty({ type: String, nullable: true }) tradingStatus: string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) change: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) changePercent: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + previousCloseDate: string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + previousClose: MoneyDto | null; + @ApiProperty({ type: [String] }) unavailable: string[]; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class StockCandleDto { + @ApiProperty({ format: 'date-time' }) time: string; + @ApiProperty() close: string; + @ApiProperty() isComplete: boolean; +} +export class StockCandlesDto { + @ApiProperty({ type: [StockCandleDto] }) candles: StockCandleDto[]; + @ApiProperty({ type: String, nullable: true }) currency: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + historyFrom: string | null; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class StockPositionRowDto extends PortfolioPositionDto { + @ApiProperty({ type: String, nullable: true }) returnPercent: string | null; +} +export class StockPositionDto { + @ApiProperty() quantity: string; + @ApiProperty({ type: MoneyDto, nullable: true }) + currentValue: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + averagePurchasePrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + absoluteReturn: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) returnPercent: string | null; + @ApiProperty({ type: [StockPositionRowDto] }) accounts: StockPositionRowDto[]; + @ApiProperty() isStale: boolean; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class StockMetricDto { + @ApiProperty() key: string; + @ApiProperty() value: number; + @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) unit: + 'money' | 'ratio' | 'percent'; + @ApiProperty({ type: String, nullable: true }) currency: string | null; + @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) period: + 'TTM' | 'MRQ' | null; +} +export class StockCompanyDto { + @ApiProperty({ type: String, nullable: true }) description: string | null; + @ApiProperty({ type: [StockMetricDto] }) metrics: StockMetricDto[]; + @ApiProperty({ type: [String] }) unavailable: string[]; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class StockDividendDto { + @ApiProperty({ type: MoneyDto, nullable: true }) perShare: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) recordDate: + string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) lastBuyDate: + string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) paymentDate: + string | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: + 'upcoming' | 'past' | 'undated'; + @ApiProperty({ type: MoneyDto, nullable: true }) + estimatedAmount: MoneyDto | null; +} +export class StockDividendsDto { + @ApiProperty({ type: [StockDividendDto] }) dividends: StockDividendDto[]; + @ApiProperty({ format: 'date' }) from: string; + @ApiProperty({ format: 'date' }) to: string; + @ApiProperty({ format: 'date-time' }) observedAt: string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + positionAt: string | null; + @ApiProperty() estimateUnavailable: boolean; + @ApiProperty() positionIsStale: boolean; +} diff --git a/src/tbank/stock.service.spec.ts b/src/tbank/stock.service.spec.ts new file mode 100644 index 0000000..543bb8a --- /dev/null +++ b/src/tbank/stock.service.spec.ts @@ -0,0 +1,354 @@ +jest.mock('./tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, +})); +import { + ServiceUnavailableException, + ConflictException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { StockService, previousClose } from './stock.service'; +import { TbankAccessService } from './tbank-access.service'; +import { TbankClient } from './tbank.client'; +import { PortfolioService } from './portfolio.service'; +const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) }); +const share = { + uid: 'uid', + assetUid: 'asset', + currency: 'rub', + sector: 'financial', + first_1dayCandleDate: time('2025-11-20'), +}; +function setup() { + let generation = 'a'; + const tbank = { + findShares: jest.fn().mockResolvedValue([]), + getLastPrices: jest.fn().mockResolvedValue([]), + getShare: jest.fn().mockResolvedValue(share), + getCandles: jest.fn().mockResolvedValue([]), + getAssetDescription: jest.fn().mockResolvedValue('Company'), + getFundamentals: jest.fn().mockResolvedValue({ + assetUid: 'asset', + currency: 'rub', + peRatioTtm: 6, + netIncomeTtm: 10, + }), + getDividends: jest.fn().mockResolvedValue([]), + }; + const access = { + withConnection: jest.fn( + (user: string, handler: (context: unknown) => unknown) => + handler({ token: user, generation }), + ), + }; + const portfolios = { + getAll: jest.fn().mockResolvedValue({ + positions: [], + lastSuccessfulAt: '2026-09-10T00:00:00Z', + isStale: false, + }), + }; + const service = new StockService( + access as unknown as TbankAccessService, + tbank as unknown as TbankClient, + portfolios as unknown as PortfolioService, + ); + return { + service, + tbank, + portfolios, + changeGeneration: () => { + generation = 'b'; + }, + }; +} +describe('stock market use case', () => { + beforeEach(() => + jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')), + ); + afterEach(() => jest.useRealTimers()); + it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => { + const { service, tbank } = setup(); + const listings = [ + { + uid: 'special', + ticker: 'SBER', + classCode: 'SPEQ', + apiTradeAvailableFlag: false, + }, + { + uid: 'preferred', + ticker: 'SBERP', + classCode: 'TQBR', + apiTradeAvailableFlag: true, + }, + { + uid: 'common', + ticker: 'SBER', + classCode: 'TQBR', + apiTradeAvailableFlag: true, + }, + ].map((item) => ({ + ...item, + name: 'Сбербанк', + isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN', + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + })); + tbank.findShares.mockResolvedValue([ + ...listings, + { uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' }, + ]); + tbank.getShare.mockImplementation((_token: string, uid: string) => + Promise.resolve({ + ...listings.find((item) => item.uid === uid), + currency: 'rub', + brand: { logoName: 'sber.png' }, + shareType: + uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON', + }), + ); + tbank.getLastPrices.mockResolvedValue([ + { + instrumentUid: 'preferred', + price: { units: '305' }, + time: time('2026-09-09'), + }, + { + instrumentUid: 'common', + price: { units: '306', nano: 120000000 }, + time: time('2026-09-10'), + }, + ]); + const result = await service.search('user', 'SBER'); + expect(result.map((item) => item.instrumentUid)).toEqual([ + 'common', + 'preferred', + ]); + expect(result[0]).toMatchObject({ + classCode: 'TQBR', + isin: 'ISIN', + logoName: 'sber.png', + price: { currency: 'RUB', value: '306.12' }, + priceAt: '2026-09-10T00:00:00.000Z', + }); + expect(result[1]).toMatchObject({ + shareType: 'SHARE_TYPE_PREFERRED', + price: { value: '305' }, + }); + expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); + }); + it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => { + const { service, tbank } = setup(); + const hit = { + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + ticker: 'SBER', + apiTradeAvailableFlag: true, + }; + tbank.findShares.mockResolvedValue([ + ...Array.from({ length: 35 }, (_, i) => ({ + ...hit, + uid: `alternate-${i}`, + isin: ' isin ', + classCode: 'SPEQ', + })), + { ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' }, + { + ...hit, + uid: 'preferred', + ticker: 'SBERP', + isin: 'ISINP', + classCode: 'TQBR', + }, + { ...hit, uid: 'unknown-a', isin: '' }, + { ...hit, uid: 'unknown-b' }, + ]); + tbank.getShare.mockResolvedValue(null); + const result = await service.search('user', 'SBER'); + expect(result.map((item) => item.instrumentUid)).toEqual([ + 'common', + 'unknown-a', + 'unknown-b', + 'preferred', + ]); + expect(tbank.getShare).toHaveBeenCalledTimes(4); + }); + it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => { + const { service, tbank } = setup(); + tbank.findShares.mockResolvedValue([ + { + uid: 'uid', + ticker: 'SBER', + classCode: 'TQBR', + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + }, + ]); + tbank.getShare.mockRejectedValue(new ServiceUnavailableException()); + tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException()); + expect(await service.search('user', 'SBER')).toEqual([ + expect.objectContaining({ + instrumentUid: 'uid', + classCode: 'TQBR', + price: null, + logoName: null, + }), + ]); + tbank.getShare.mockResolvedValue(null); + expect(await service.search('user', 'sber')).toEqual([ + expect.objectContaining({ instrumentUid: 'uid' }), + ]); + tbank.getShare.mockRejectedValue(new UnauthorizedException()); + await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + it('skips enrichment for an empty catalog result', async () => { + const { service, tbank } = setup(); + expect(await service.search('user', 'none')).toEqual([]); + expect(tbank.getShare).not.toHaveBeenCalled(); + expect(tbank.getLastPrices).not.toHaveBeenCalled(); + }); + it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => { + const { service, tbank } = setup(); + const listings = [ + { + uid: 'common', + ticker: 'SBER', + isin: 'COMMON', + classCode: 'TQBR', + apiTradeAvailableFlag: true, + }, + { + uid: 'special', + ticker: 'SBER', + isin: 'COMMON', + classCode: 'SPEQ', + apiTradeAvailableFlag: false, + }, + { + uid: 'preferred', + ticker: 'SBERP', + isin: 'PREFERRED', + classCode: 'TQBR', + apiTradeAvailableFlag: true, + }, + ].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' })); + tbank.findShares.mockResolvedValue(listings); + tbank.getShare.mockImplementation((_token: string, uid: string) => + Promise.resolve(listings.find((item) => item.uid === uid)), + ); + expect(await service.getByTicker('user', 'sberp')).toMatchObject({ + instrumentUid: 'preferred', + slug: 'SBERP', + }); + expect(await service.get('user', 'common')).toMatchObject({ + instrumentUid: 'common', + slug: 'SBER', + }); + expect(await service.get('user', 'special')).toMatchObject({ + instrumentUid: 'special', + slug: 'special', + }); + await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => { + const { service, tbank } = setup(); + const listings = ['one', 'two'].map((uid) => ({ + uid, + ticker: 'SAME', + isin: uid, + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + })); + tbank.findShares.mockResolvedValue(listings); + tbank.getShare.mockResolvedValue(listings[0]); + await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(await service.get('user', 'one')).toMatchObject({ + instrumentUid: 'one', + slug: 'one', + }); + }); + it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => { + const { service, tbank, changeGeneration } = setup(); + tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' }); + tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException()); + expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' }); + tbank.findShares.mockResolvedValue([ + { ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, + ]); + expect(await service.getByTicker('user', 'F')).toMatchObject({ + instrumentUid: 'uid', + slug: 'F', + }); + changeGeneration(); + expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' }); + expect(tbank.findShares).toHaveBeenCalledTimes(3); + }); + it('uses a complete previous trading day, never today’s main close', () => { + const candles = [ + { time: time('2026-09-09'), close: { units: '10' }, isComplete: true }, + { time: time('2026-09-10'), close: { units: '11' }, isComplete: true }, + { time: time('2026-09-08'), close: { units: '9' }, isComplete: false }, + ]; + expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]); + expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]); + }); + it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => { + const { service, tbank } = setup(); + tbank.getCandles.mockResolvedValue([ + { time: time('2026-09-09'), close: { units: '10' }, isComplete: false }, + ]); + const result = await service.candles('user', 'uid', 'all'); + expect(tbank.getCandles).toHaveBeenCalledWith( + 'user', + 'uid', + new Date('2025-11-20'), + expect.any(Date), + ); + expect(result.candles).toHaveLength(1); + expect(result.candles[0].isComplete).toBe(false); + }); + it('does not manufacture zero fundamentals and isolates optional failures', async () => { + const { service, tbank } = setup(); + tbank.getAssetDescription.mockRejectedValue( + new ServiceUnavailableException(), + ); + const result = await service.company('user', 'uid'); + expect(result.description).toBeNull(); + expect(result.unavailable).toEqual(['description']); + expect(result.metrics.map((item) => item.key)).toEqual([ + 'peRatioTtm', + 'netIncomeTtm', + ]); + }); + it('isolates cache by user and connection generation', async () => { + const { service, tbank, changeGeneration } = setup(); + await service.get('a', 'uid'); + await service.get('a', 'uid'); + expect(tbank.getShare).toHaveBeenCalledTimes(1); + await service.get('b', 'uid'); + changeGeneration(); + await service.get('a', 'uid'); + expect(tbank.getShare).toHaveBeenCalledTimes(3); + }); + it('keeps public dividends when there is no holding or position retrieval fails', async () => { + const { service, tbank, portfolios } = setup(); + portfolios.getAll.mockRejectedValue(new ServiceUnavailableException()); + tbank.getDividends.mockResolvedValue([ + { + dividendNet: { currency: 'rub', units: '32', nano: 880000000 }, + recordDate: time('2026-10-13'), + paymentDate: time('2026-10-27'), + }, + ]); + const result = await service.dividends('user', 'uid'); + expect(result.estimateUnavailable).toBe(true); + expect(result.dividends[0]).toMatchObject({ + perShare: { currency: 'RUB', value: '32.88' }, + estimatedAmount: null, + timing: 'upcoming', + }); + }); +}); diff --git a/src/tbank/stock.service.ts b/src/tbank/stock.service.ts new file mode 100644 index 0000000..044e4ba --- /dev/null +++ b/src/tbank/stock.service.ts @@ -0,0 +1,549 @@ +import { + Injectable, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; +import { + decimal, + decimalFromString, + divide, + formatDecimal, + multiply, + subtract, +} from './decimal'; +import { PortfolioService } from './portfolio.service'; +import { stockPosition } from './stock-position'; +import { TbankAccessService } from './tbank-access.service'; +import { TbankClient, TbankTimestamp } from './tbank.client'; +import type { + TbankCandle, + TbankFundamentals, + TbankShare, +} from './tbank-market.types'; +import { + StockCandlesDto, + StockCandlesQueryDto, + StockCompanyDto, + StockDividendDto, + StockDividendsDto, + StockDto, + StockMetricDto, + StockPositionDto, + StockQuoteDto, + StockSearchResultDto, +} from './stock.dto'; + +const dayMs = 86_400_000; + +@Injectable() +export class StockService { + private readonly cache = new LRUCache>({ + max: 300, + ttl: 30_000, + }); + constructor( + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly portfolios: PortfolioService, + ) {} + + search(userId: string, query: string): Promise { + return this.cached(userId, `search:${query}`, async (token) => { + const found = await this.tbank.findShares(token, query); + const shares = selectShares(found, query).slice(0, 30); + if (!shares.length) return []; + // One market-data request for the list; failed enrichment must not hide a catalog result. + const [details, prices] = await Promise.all([ + Promise.all( + shares.map( + async (share) => + (await optional( + () => + this.get(userId, share.uid!).catch((error: unknown) => { + if (error instanceof NotFoundException) return stock(share); + throw error; + }), + 'details', + [], + )) ?? stock(share), + ), + ), + optional( + () => + this.tbank.getLastPrices( + token, + shares.map((share) => share.uid!), + ), + 'prices', + [], + ), + ]); + return details.map((detail, index) => { + const short = stock(shares[index]); + const last = prices?.find( + (price) => price.instrumentUid === detail.instrumentUid, + ); + return { + ...detail, + classCode: detail.classCode ?? short.classCode, + isin: detail.isin ?? short.isin, + apiTradeAvailable: short.apiTradeAvailable, + price: + last?.price && detail.currency + ? { + currency: detail.currency, + value: formatDecimal(decimal(last.price)), + } + : null, + priceAt: timestamp(last?.time), + }; + }); + }); + } + get(userId: string, uid: string): Promise { + return this.cached(userId, `share:${uid}`, async (token) => { + const result = stock(await this.share(token, uid)); + if ( + !result.ticker || + !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker) + ) + return result; + try { + const match = await this.tickerMatch(userId, result.ticker); + if (match.uid === uid) result.slug = result.ticker.toUpperCase(); + } catch (error) { + // A readable alias is optional for an exact UID URL, never a reason to substitute another listing. + if ( + !(error instanceof NotFoundException) && + !(error instanceof ConflictException) && + !(error instanceof ServiceUnavailableException) + ) + throw error; + } + return result; + }); + } + async getByTicker(userId: string, ticker: string): Promise { + const match = await this.tickerMatch(userId, ticker); + return { + ...(await this.get(userId, match.uid!)), + slug: ticker.trim().toUpperCase(), + }; + } + private tickerMatch(userId: string, ticker: string): Promise { + const normalized = ticker.trim().toUpperCase(); + return this.cached(userId, `ticker:${normalized}`, async (token) => { + const found = await this.tbank.findShares(token, normalized); + const matches = selectShares( + found.filter( + (item) => item.ticker?.trim().toUpperCase() === normalized, + ), + normalized, + ); + if (!matches.length) throw new NotFoundException(); + if (matches.length > 1) + throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }); + return matches[0]; + }); + } + quote(userId: string, uid: string): Promise { + return this.cached(userId, `quote:${uid}`, async (token) => { + const share = await this.share(token, uid); + const unavailable: string[] = []; + const [last, tradingStatus] = await Promise.all([ + optional( + () => this.tbank.getLastPrice(token, uid), + 'price', + unavailable, + ), + optional( + () => this.tbank.getTradingStatus(token, uid), + 'status', + unavailable, + ), + ]); + const priceAt = timestamp(last?.time); + const currency = share.currency?.toUpperCase(); + const price = + last?.price && currency + ? { currency, value: formatDecimal(decimal(last.price)) } + : null; + const previous = priceAt + ? await optional( + async () => { + const end = new Date(priceAt); + return previousClose( + await this.tbank.getCandles( + token, + uid, + new Date(end.valueOf() - 20 * dayMs), + end, + ), + priceAt, + ); + }, + 'comparison', + unavailable, + ) + : null; + const previousPrice = + previous?.close && currency + ? { currency, value: formatDecimal(decimal(previous.close)) } + : null; + const difference = + price && previousPrice + ? subtract( + decimalFromString(price.value), + decimalFromString(previousPrice.value), + ) + : null; + return { + price, + priceAt, + tradingStatus, + previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + previousClose: previousPrice, + change: + difference && currency + ? { currency, value: formatDecimal(difference) } + : null, + changePercent: + difference && + previousPrice && + decimalFromString(previousPrice.value).value > 0n + ? formatDecimal( + divide( + multiply(difference, decimalFromString('100')), + decimalFromString(previousPrice.value), + 6, + ), + ) + : null, + unavailable, + observedAt: new Date().toISOString(), + }; + }); + } + candles( + userId: string, + uid: string, + period: StockCandlesQueryDto['period'] = 'month', + ): Promise { + return this.cached(userId, `candles:${uid}:${period}`, async (token) => { + const share = await this.share(token, uid); + const historyFrom = timestamp(share.first_1dayCandleDate); + const to = new Date(); + const from = new Date(to); + from.setUTCMonth( + from.getUTCMonth() - + (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), + ); + if (period === 'all') { + if (!historyFrom) + throw new ServiceUnavailableException({ + code: 'STOCK_HISTORY_START_UNAVAILABLE', + }); + from.setTime(Date.parse(historyFrom)); + } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) + from.setTime(Date.parse(historyFrom)); + const collected = new Map(); + // Conservative one-year windows also work with older deployed RPC limits. + for (let start = from.valueOf(); start < to.valueOf();) { + const end = Math.min(start + 365 * dayMs, to.valueOf()); + for (const candle of await this.tbank.getCandles( + token, + uid, + new Date(start), + new Date(end), + )) { + const time = timestamp(candle.time); + if ( + time && + candle.close && + Date.parse(time) >= from.valueOf() - dayMs && + Date.parse(time) <= to.valueOf() + ) + collected.set(time, { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }); + } + start = end; + } + return { + candles: [...collected.values()].sort((a, b) => + a.time.localeCompare(b.time), + ), + currency: share.currency?.toUpperCase() || null, + historyFrom, + observedAt: new Date().toISOString(), + }; + }); + } + async position(userId: string, uid: string): Promise { + return this.cached(userId, `position:${uid}`, async () => + stockPosition(await this.portfolios.getAll(userId), uid), + ); + } + company(userId: string, uid: string): Promise { + return this.cached(userId, `company:${uid}`, async (token) => { + const share = await this.share(token, uid); + const unavailable: string[] = []; + if (!share.assetUid) + return { + description: null, + metrics: [], + unavailable, + observedAt: new Date().toISOString(), + }; + const [description, fundamentals] = await Promise.all([ + optional( + () => this.tbank.getAssetDescription(token, share.assetUid!), + 'description', + unavailable, + ), + optional( + () => this.tbank.getFundamentals(token, share.assetUid!), + 'fundamentals', + unavailable, + ), + ]); + return { + description, + metrics: metrics(fundamentals, share.sector), + unavailable, + observedAt: new Date().toISOString(), + }; + }); + } + dividends(userId: string, uid: string): Promise { + return this.cached(userId, `dividends:${uid}`, async (token) => { + await this.share(token, uid); + const today = moscowDay(new Date().toISOString()); + const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; + const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + const unavailable: string[] = []; + const [events, position] = await Promise.all([ + this.tbank.getDividends(token, uid, from, to), + optional(() => this.position(userId, uid), 'position', unavailable), + ]); + const quantity = + position && + position.accounts.length && + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) + ? decimalFromString(position.quantity) + : null; + const dividends: StockDividendDto[] = events + .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') + .map((event) => { + const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null; + const paymentDate = + timestamp(event.paymentDate)?.slice(0, 10) ?? null; + const lastBuyDate = + timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; + const currency = event.dividendNet?.currency?.toUpperCase(); + const perShare = + currency && event.dividendNet + ? { currency, value: formatDecimal(decimal(event.dividendNet)) } + : null; + // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. + const timing: StockDividendDto['timing'] = + (paymentDate ?? recordDate) + ? (paymentDate ?? recordDate)! >= today + ? 'upcoming' + : 'past' + : 'undated'; + const estimatedAmount = + quantity && + perShare && + recordDate && + recordDate >= today && + timing === 'upcoming' + ? { + currency: perShare.currency, + value: formatDecimal( + multiply(quantity, decimalFromString(perShare.value)), + ), + } + : null; + return { + perShare, + recordDate, + lastBuyDate, + paymentDate, + timing, + estimatedAmount, + }; + }) + .sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? '')); + return { + dividends, + from, + to, + observedAt: new Date().toISOString(), + positionAt: quantity ? (position?.observedAt ?? null) : null, + estimateUnavailable: unavailable.length > 0, + positionIsStale: position?.isStale ?? false, + }; + }); + } + private async share(token: string, uid: string): Promise { + const share = await this.tbank.getShare(token, uid); + if (!share || share.uid !== uid) throw new NotFoundException(); + return share; + } + private cached( + userId: string, + key: string, + operation: (token: string) => Promise, + ): Promise { + return this.access.withConnection(userId, ({ token, generation }) => { + const cacheKey = JSON.stringify([userId, generation, key]); + const cached = this.cache.get(cacheKey); + if (cached) return cached as Promise; + const pending = operation(token); + this.cache.set(cacheKey, pending); + return pending.catch((error) => { + if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey); + throw error; + }); + }); + } +} + +function selectShares(found: TbankShare[], query: string): TbankShare[] { + const seen = new Set(); + return found + .filter( + (share) => share.uid && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE', + ) + .sort( + (a, b) => + Number(b.apiTradeAvailableFlag === true) - + Number(a.apiTradeAvailableFlag === true) || + Number(b.ticker?.toUpperCase() === query.toUpperCase()) - + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') || + a.uid!.localeCompare(b.uid!), + ) + .filter((share) => { + // A missing ISIN must not collapse unrelated catalog instruments. + const key = share.isin?.trim() + ? `isin:${share.isin.trim().toUpperCase()}` + : `uid:${share.uid!}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function stock(share: TbankShare): StockDto { + return { + instrumentUid: share.uid!, + slug: share.uid!, + name: share.name?.trim() || share.ticker || share.uid!, + ticker: share.ticker?.trim() || null, + isin: share.isin?.trim() || null, + classCode: share.classCode?.trim() || null, + realExchange: share.realExchange || null, + apiTradeAvailable: share.apiTradeAvailableFlag ?? null, + logoName: share.brand?.logoName?.trim() || null, + currency: share.currency?.toUpperCase() || null, + shareType: share.shareType || null, + sector: share.sector || null, + lot: share.lot && share.lot > 0 ? share.lot : null, + historyFrom: timestamp(share.first_1dayCandleDate), + }; +} +function timestamp(value?: TbankTimestamp): string | null { + const seconds = Number(value?.seconds); + return Number.isFinite(seconds) && seconds > 0 + ? new Date(seconds * 1000).toISOString() + : null; +} +function moscowDay(iso: string): string { + return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); +} +export function previousClose( + candles: TbankCandle[], + priceAt: string, +): TbankCandle | null { + return ( + candles + .filter((candle) => { + const time = timestamp(candle.time); + return ( + candle.isComplete && + candle.close && + time && + time.slice(0, 10) < moscowDay(priceAt) + ); + }) + .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? + null + ); +} +async function optional( + operation: () => Promise, + name: string, + unavailable: string[], +): Promise { + try { + return await operation(); + } catch (error) { + if (!(error instanceof ServiceUnavailableException)) throw error; + unavailable.push(name); + return null; + } +} +function metrics( + values: TbankFundamentals | null, + sector?: string, +): StockMetricDto[] { + if (!values) return []; + const bank = sector?.toLowerCase() === 'financial'; + const selection: [ + keyof TbankFundamentals, + StockMetricDto['unit'], + StockMetricDto['period'], + ][] = [ + ['marketCapitalization', 'money', null], + ['peRatioTtm', 'ratio', 'TTM'], + ...(bank + ? ([ + ['priceToBookTtm', 'ratio', 'TTM'], + ['roe', 'percent', null], + ] as typeof selection) + : ([ + ['revenueTtm', 'money', 'TTM'], + ['ebitdaTtm', 'money', 'TTM'], + ] as typeof selection)), + ['netIncomeTtm', 'money', 'TTM'], + ...(!bank + ? ([ + ['totalDebtMrq', 'money', 'MRQ'], + ['netDebtToEbitda', 'ratio', null], + ] as typeof selection) + : []), + ]; + return selection.flatMap(([key, unit, period]) => { + const value = values[key]; + const currency = values.currency?.toUpperCase() || null; + return typeof value === 'number' && + Number.isFinite(value) && + (unit !== 'money' || currency) + ? [ + { + key, + value, + unit, + period, + currency: unit === 'money' ? currency : null, + }, + ] + : []; + }); +} diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index 68bb029..f22a998 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -1,3 +1,5 @@ +import { StockController } from './stock.controller'; +import { StockService } from './stock.service'; import { Module } from '@nestjs/common'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; @@ -19,6 +21,7 @@ import { IncomeAnalyticsService } from './income-analytics.service'; @Module({ controllers: [ + StockController, TbankConnectionController, BrokerageAccountsController, BrokerageOperationsController, @@ -27,6 +30,7 @@ import { IncomeAnalyticsService } from './income-analytics.service'; IncomeAnalyticsController, ], providers: [ + StockService, TbankClient, TbankConnectionService, BrokerageAccountsService, diff --git a/src/tbank/tbank-market.types.ts b/src/tbank/tbank-market.types.ts new file mode 100644 index 0000000..fdd97b6 --- /dev/null +++ b/src/tbank/tbank-market.types.ts @@ -0,0 +1,43 @@ +import type { + TbankDecimal, + TbankInstrument, + TbankTimestamp, +} from './tbank.client'; + +export type TbankShare = TbankInstrument & { + uid?: string; + figi?: string; + assetUid?: string; + classCode?: string; + realExchange?: string; + apiTradeAvailableFlag?: boolean; + currency?: string; + shareType?: string; + instrumentKind?: string; + sector?: string; + lot?: number; + first_1dayCandleDate?: TbankTimestamp; +}; +export type TbankCandle = { + time?: TbankTimestamp; + close?: TbankDecimal; + isComplete?: boolean; +}; +export type TbankLastPrice = { + instrumentUid?: string; + price?: TbankDecimal; + time?: TbankTimestamp; +}; +export type TbankFundamentals = { + assetUid?: string; + currency?: string; + marketCapitalization?: number; + peRatioTtm?: number; + priceToBookTtm?: number; + revenueTtm?: number; + netIncomeTtm?: number; + ebitdaTtm?: number; + roe?: number; + totalDebtMrq?: number; + netDebtToEbitda?: number; +}; diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index 04b502d..f44b349 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -1,4 +1,8 @@ -import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ChannelCredentials, @@ -15,6 +19,13 @@ import { join } from 'node:path'; import { Environment } from '../config/env.validation'; import { StructuredLogger } from '../common/logging/structured-logger.service'; +import type { + TbankShare, + TbankCandle, + TbankLastPrice, + TbankFundamentals, +} from './tbank-market.types'; + const requestDeadlineMs = 15_000; type Unary = ( @@ -53,6 +64,8 @@ export type TbankInstrument = { export type TbankDividend = { dividendNet?: TbankMoney; recordDate?: TbankTimestamp; + paymentDate?: TbankTimestamp; + lastBuyDate?: TbankTimestamp; dividendType?: string; }; export type TbankTimestamp = { seconds?: string | number; nanos?: number }; @@ -72,9 +85,11 @@ export type TbankOperationItem = { commission?: TbankMoney; accruedInt?: TbankMoney; quantity?: string | number; + quantityDone?: string | number; }; export type TbankOperationsByCursorRequest = { accountId: string; + instrumentId?: string; from?: TbankTimestamp; to?: TbankTimestamp; cursor?: string; @@ -140,6 +155,8 @@ export class TbankClient { private readonly users: TbankClientService; private readonly operations: TbankClientService; private readonly instruments: TbankClientService; + private readonly sparseInstruments: TbankClientService; + private readonly marketData: TbankClientService; constructor( config: ConfigService, @@ -150,9 +167,12 @@ export class TbankClient { ? sourceProtoRoot : join(process.cwd(), 'dist/tbank/proto/contracts'); const definition = loadSync( - ['users.proto', 'operations.proto', 'instruments.proto'].map((file) => - join(protoRoot, file), - ), + [ + 'users.proto', + 'operations.proto', + 'instruments.proto', + 'marketdata.proto', + ].map((file) => join(protoRoot, file)), { includeDirs: [protoRoot], keepCase: false, @@ -180,6 +200,26 @@ export class TbankClient { this.users = create('UsersService'); this.operations = create('OperationsService'); this.instruments = create('InstrumentsService'); + this.marketData = create('MarketDataService'); + // Preserve field presence for fundamentals; the established clients retain their decoding contract. + const sparse = ( + loadPackageDefinition( + loadSync(join(protoRoot, 'instruments.proto'), { + includeDirs: [protoRoot], + keepCase: false, + longs: String, + enums: String, + defaults: false, + oneofs: true, + }), + ) as unknown as TbankNamespace + ).tinkoff.public.invest.api.contract.v1; + this.sparseInstruments = new ( + sparse.InstrumentsService as new ( + address: string, + credentials: ChannelCredentials, + ) => TbankClientService + )(endpoint, credentials); this.queue = new RateLimiter( config.getOrThrow('TBANK_RATE_LIMIT_PER_SECOND'), ); @@ -254,6 +294,105 @@ export class TbankClient { ); } + findShares(token: string, query: string): Promise { + return this.call<{ instruments?: TbankShare[] }>( + token, + this.instruments, + 'findInstrument', + { + query, + instrumentKind: 'INSTRUMENT_TYPE_SHARE', + }, + ).then((response) => response.instruments ?? []); + } + + getShare(token: string, uid: string): Promise { + return this.call<{ instrument?: TbankShare }>( + token, + this.instruments, + 'shareBy', + { + idType: 'INSTRUMENT_ID_TYPE_UID', + id: uid, + }, + ).then((response) => response.instrument ?? null); + } + + getAssetDescription(token: string, assetUid: string): Promise { + return this.call<{ asset?: { description?: string } }>( + token, + this.instruments, + 'getAssetBy', + { + id: assetUid, + }, + ).then((response) => response.asset?.description?.trim() || null); + } + + getFundamentals( + token: string, + assetUid: string, + ): Promise { + return this.call<{ fundamentals?: TbankFundamentals[] }>( + token, + this.sparseInstruments, + 'getAssetFundamentals', + { + assets: [assetUid], + }, + ).then( + (response) => + response.fundamentals?.find((item) => item.assetUid === assetUid) ?? + null, + ); + } + + getLastPrices(token: string, uids: string[]): Promise { + return this.call<{ lastPrices?: TbankLastPrice[] }>( + token, + this.marketData, + 'getLastPrices', + { instrumentId: uids }, + ).then((response) => response.lastPrices ?? []); + } + + getLastPrice(token: string, uid: string): Promise { + return this.getLastPrices(token, [uid]).then( + (prices) => prices.find((item) => item.instrumentUid === uid) ?? null, + ); + } + + getTradingStatus(token: string, uid: string): Promise { + return this.call<{ tradingStatus?: string }>( + token, + this.marketData, + 'getTradingStatus', + { + instrumentId: uid, + }, + ).then((response) => response.tradingStatus || null); + } + + getCandles( + token: string, + uid: string, + from: Date, + to: Date, + ): Promise { + return this.call<{ candles?: TbankCandle[] }>( + token, + this.marketData, + 'getCandles', + { + instrumentId: uid, + from: { seconds: String(Math.floor(from.valueOf() / 1000)) }, + to: { seconds: String(Math.floor(to.valueOf() / 1000)) }, + interval: 'CANDLE_INTERVAL_DAY', + limit: 2400, + }, + ).then((response) => response.candles ?? []); + } + private async call( token: string, client: TbankClientService, @@ -308,6 +447,8 @@ export class TbankClient { [status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) ) throw new Error('INVALID_TBANK_TOKEN'); + if (grpcCode === status.NOT_FOUND && method === 'shareBy') + throw new NotFoundException(); throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } } diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts new file mode 100644 index 0000000..5eb77d6 --- /dev/null +++ b/test/stocks.e2e-spec.ts @@ -0,0 +1,131 @@ +import { + INestApplication, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { Server } from 'node:http'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { StockService } from '../src/tbank/stock.service'; + +const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; +describe('Stock HTTP contract', () => { + let app: INestApplication; + const stocks = { + getByTicker: jest.fn(), + get: jest.fn(), + search: jest.fn(), + candles: jest.fn(), + }; + const token = new JwtService().sign( + { sub: 'stock-reader', type: 'access' }, + { secret: process.env.JWT_SECRET }, + ); + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(StockService) + .useValue(stocks) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + }); + beforeEach(() => { + jest.clearAllMocks(); + stocks.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' }); + stocks.search.mockResolvedValue([]); + stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' }); + }); + afterAll(() => app.close()); + it('requires authentication before invoking the use case', async () => { + await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401); + expect(stocks.get).not.toHaveBeenCalled(); + }); + it.each([ + '/stocks/not-a-uid', + '/stocks/by-ticker/%20', + '/stocks/by-ticker/' + 'S'.repeat(81), + '/stocks?query=x', + '/stocks?query=', + `/stocks/${uid}/candles?period=century`, + '/brokerage-accounts/all/operations?instrumentUid=not-a-uid', + ])('validates %s in the configured pipeline', async (path) => { + await request(app.getHttpServer()) + .get(`/api/v1${path}`) + .set('Authorization', `Bearer ${token}`) + .expect(400); + expect(stocks.get).not.toHaveBeenCalled(); + expect(stocks.candles).not.toHaveBeenCalled(); + }); + it('uses verified identity and supports a direct instrument URL', async () => { + await request(app.getHttpServer()) + .get(`/api/v1/stocks/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid); + }); + it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])( + 'resolves ticker %s through the authenticated endpoint', + async (ticker) => { + const path = `/api/v1/stocks/by-ticker/${encodeURIComponent(ticker)}`; + await request(app.getHttpServer()).get(path).expect(401); + const response = await request(app.getHttpServer()) + .get(path) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(response.body).toMatchObject({ + instrumentUid: uid, + slug: 'SBERP', + }); + expect(stocks.getByTicker).toHaveBeenCalledWith('stock-reader', ticker); + }, + ); + it('returns an explicit conflict for an ambiguous ticker', async () => { + stocks.getByTicker.mockRejectedValueOnce( + new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' }), + ); + const response = await request(app.getHttpServer()) + .get('/api/v1/stocks/by-ticker/SAME') + .set('Authorization', `Bearer ${token}`) + .expect(409); + expect(response.body).toMatchObject({ code: 'STOCK_TICKER_AMBIGUOUS' }); + }); + it('returns listing identity and nullable search prices through the search endpoint', async () => { + const item = { + instrumentUid: uid, + name: 'Sber', + classCode: 'TQBR', + isin: 'ISIN', + apiTradeAvailable: true, + price: { currency: 'RUB', value: '306.12' }, + priceAt: null, + }; + stocks.search.mockResolvedValue([item]); + const response = await request(app.getHttpServer()) + .get('/api/v1/stocks?query=SBER') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER'); + expect(response.body).toEqual([item]); + }); + it('preserves safe not-found and upstream errors', async () => { + stocks.get.mockRejectedValueOnce(new NotFoundException()); + await request(app.getHttpServer()) + .get(`/api/v1/stocks/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(404); + stocks.get.mockRejectedValueOnce( + new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), + ); + const response = await request(app.getHttpServer()) + .get(`/api/v1/stocks/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(503); + expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' }); + expect(response.body).not.toHaveProperty('stack'); + }); +});