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:
+145
-4
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user