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
+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' });
}
}