feat: add stock details and ticker resolution

Expose stock search, market data, company information, dividends and portfolio history with exact instrument identity and verified ticker aliases. Include stock and bond position counts in account summaries.
This commit is contained in:
2026-09-10 21:23:14 +03:00
parent 05747dc375
commit f24fd98fbc
22 changed files with 2584 additions and 13 deletions
+67
View File
@@ -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 () => {
+14
View File
@@ -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 {
+47 -2
View File
@@ -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'),
+13 -1
View File
@@ -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,
},
};
}),
+10
View File
@@ -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()
@@ -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();
});
});
+12 -1
View File
@@ -15,6 +15,9 @@ const types: Record<string, OperationCategory> = {
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),
+39 -5
View File
@@ -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<string, Promise<string | null>>();
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,
});
+11
View File
@@ -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 };
}
+3
View File
@@ -10,6 +10,9 @@ export class MoneyDto {
}
export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: string | null;
@ApiPropertyOptional({ type: AccountReferenceDto })
account?: AccountReferenceDto;
+1
View File
@@ -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() ||
+692
View File
@@ -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 {//Сервис для получения биржевой информации:<br/> 1. Свечи.<br/> 2. Стаканы.<br/> 3. Торговые статусы.<br/> 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; // Цена дилера
}
+83
View File
@@ -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();
});
});
+100
View File
@@ -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,
),
);
}
+101
View File
@@ -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);
}
}
+149
View File
@@ -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;
}
+354
View File
@@ -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',
});
});
});
+549
View File
@@ -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<string, Promise<object>>({
max: 300,
ttl: 30_000,
});
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
) {}
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
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<StockDto> {
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<StockDto> {
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<TbankShare> {
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<StockQuoteDto> {
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<StockCandlesDto> {
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<string, StockCandlesDto['candles'][number]>();
// 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<StockPositionDto> {
return this.cached(userId, `position:${uid}`, async () =>
stockPosition(await this.portfolios.getAll(userId), uid),
);
}
company(userId: string, uid: string): Promise<StockCompanyDto> {
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<StockDividendsDto> {
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<TbankShare> {
const share = await this.tbank.getShare(token, uid);
if (!share || share.uid !== uid) throw new NotFoundException();
return share;
}
private cached<T extends object>(
userId: string,
key: string,
operation: (token: string) => Promise<T>,
): Promise<T> {
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<T>;
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<string>();
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<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
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,
},
]
: [];
});
}
+4
View File
@@ -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,
+43
View File
@@ -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;
};
+145 -4
View File
@@ -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<Request, Response> = (
@@ -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<Environment, true>,
@@ -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<number>('TBANK_RATE_LIMIT_PER_SECOND'),
);
@@ -254,6 +294,105 @@ export class TbankClient {
);
}
findShares(token: string, query: string): Promise<TbankShare[]> {
return this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
},
).then((response) => response.instruments ?? []);
}
getShare(token: string, uid: string): Promise<TbankShare | null> {
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<string | null> {
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<TbankFundamentals | null> {
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<TbankLastPrice[]> {
return this.call<{ lastPrices?: TbankLastPrice[] }>(
token,
this.marketData,
'getLastPrices',
{ instrumentId: uids },
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
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<TbankCandle[]> {
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<Response>(
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' });
}
}
+131
View File
@@ -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<Server>;
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');
});
});