refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { type MoexBondEvent, MoexClient } from './moex.client';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
describe('MoexClient', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'RU000TEST', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'YIELDDATETYPE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'OTHER',
|
||||
99,
|
||||
20,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
-0.5,
|
||||
101,
|
||||
'2028-12-01',
|
||||
'OFFERDATE',
|
||||
'2026-09-10 18:49:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const client = new MoexClient();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({
|
||||
value: '-0.5',
|
||||
pricePercent: '101',
|
||||
date: '2028-12-01',
|
||||
dateType: 'OFFERDATE',
|
||||
tradeAt: '2026-09-10T15:49:00.000Z',
|
||||
});
|
||||
expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull();
|
||||
expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull();
|
||||
});
|
||||
it.each([null, 0])(
|
||||
'does not report unknown yield as zero: %s',
|
||||
async (value) => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
securities: {
|
||||
columns: ['SECID', 'ISIN', 'BOARDID'],
|
||||
data: [['BOND', 'ISIN', 'TQOB']],
|
||||
},
|
||||
marketdata_yields: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'EFFECTIVEYIELD',
|
||||
'PRICE',
|
||||
'YIELDDATE',
|
||||
'TRADEMOMENT',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'BOND',
|
||||
'TQOB',
|
||||
value,
|
||||
100,
|
||||
'2030-01-01',
|
||||
'2026-09-10 12:00:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB');
|
||||
expect(result?.value ?? null).toBe(value === null ? null : '0');
|
||||
},
|
||||
);
|
||||
it('treats a successful response without the requested block as no events', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
description: { columns: ['name', 'value'], data: [] },
|
||||
boards: { columns: ['secid'], data: [] },
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
const baseUrl = 'https://iss.moex.com/iss';
|
||||
const timeoutMs = 10_000;
|
||||
|
||||
export type MoexBondEvent =
|
||||
| { type: 'coupon'; date: string; value: string; currency: string }
|
||||
| { type: 'maturity'; date: string; value: string; currency: string }
|
||||
| { type: 'offer'; date: string };
|
||||
|
||||
type IssBlock = { columns?: unknown; data?: unknown };
|
||||
type IssResponse = Record<string, IssBlock | undefined>;
|
||||
export type MoexBondYield = {
|
||||
value: string;
|
||||
pricePercent: string;
|
||||
date: string;
|
||||
dateType: string | null;
|
||||
tradeAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MoexClient {
|
||||
async bondYield(
|
||||
secid: string,
|
||||
isin: string,
|
||||
board: string,
|
||||
): Promise<MoexBondYield | null> {
|
||||
const query = new URLSearchParams({
|
||||
'iss.meta': 'off',
|
||||
'iss.only': 'securities,marketdata_yields',
|
||||
});
|
||||
const response = await this.request(
|
||||
`${baseUrl}/engines/stock/markets/bonds/securities/${encodeURIComponent(secid)}.json?${query}`,
|
||||
);
|
||||
if (!response) return null;
|
||||
const listing = parseRows(response.securities).find(
|
||||
(row) =>
|
||||
row.secid === secid && row.isin === isin && row.boardid === board,
|
||||
);
|
||||
if (!listing) return null;
|
||||
const row = parseRows(response.marketdata_yields).find(
|
||||
(row) => row.secid === secid && row.boardid === board,
|
||||
);
|
||||
if (!row) return null;
|
||||
const value = numberText(row.effectiveyield);
|
||||
const pricePercent = numberText(row.price);
|
||||
const date = text(row.yielddate);
|
||||
const tradeAt = text(row.trademoment);
|
||||
if (
|
||||
value === null ||
|
||||
pricePercent === null ||
|
||||
Number(pricePercent) <= 0 ||
|
||||
!date ||
|
||||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
!tradeAt ||
|
||||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
|
||||
)
|
||||
return null;
|
||||
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
|
||||
if (!Number.isFinite(Date.parse(tradeIso))) return null;
|
||||
return {
|
||||
value,
|
||||
pricePercent,
|
||||
date,
|
||||
dateType: text(row.yielddatetype),
|
||||
tradeAt: new Date(tradeIso).toISOString(),
|
||||
};
|
||||
}
|
||||
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
||||
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
||||
const [coupons, offers, description] = await Promise.all([
|
||||
this.rows(path, 'coupons'),
|
||||
this.rows(path, 'offers'),
|
||||
this.rows(
|
||||
`securities/${encodeURIComponent(secid)}.json`,
|
||||
'description',
|
||||
false,
|
||||
),
|
||||
]);
|
||||
const events: MoexBondEvent[] = [];
|
||||
for (const row of coupons) {
|
||||
const date = text(row.coupondate);
|
||||
const value = numberText(row.value);
|
||||
const currency = currencyText(row.faceunit);
|
||||
if (date && value && currency)
|
||||
events.push({ type: 'coupon', date, value, currency });
|
||||
}
|
||||
for (const row of offers) {
|
||||
const date = text(row.offerdate);
|
||||
if (date) events.push({ type: 'offer', date });
|
||||
}
|
||||
const values = new Map(
|
||||
description.map((row) => [text(row.name), text(row.value)]),
|
||||
);
|
||||
const date = values.get('MATDATE');
|
||||
const value = numberText(values.get('FACEVALUE'));
|
||||
const currency = currencyText(values.get('FACEUNIT'));
|
||||
if (date && value && currency)
|
||||
events.push({ type: 'maturity', date, value, currency });
|
||||
return events;
|
||||
}
|
||||
|
||||
private async rows(
|
||||
path: string,
|
||||
block: string,
|
||||
paginated = true,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
let start = 0;
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({
|
||||
'iss.meta': 'off',
|
||||
'iss.only': `${block},${block}.cursor`,
|
||||
start: String(start),
|
||||
});
|
||||
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||
if (!response) return rows;
|
||||
const current = parseRows(response[block]);
|
||||
rows.push(...current);
|
||||
if (!paginated) return rows;
|
||||
const cursorBlock = response[`${block}.cursor`];
|
||||
if (!cursorBlock) {
|
||||
if (current.length < 100) return rows;
|
||||
start += 100;
|
||||
continue;
|
||||
}
|
||||
const cursor = parseRows(cursorBlock)[0];
|
||||
const index = integer(cursor?.index);
|
||||
const total = integer(cursor?.total);
|
||||
const pageSize = integer(cursor?.pagesize);
|
||||
if (
|
||||
index === null ||
|
||||
total === null ||
|
||||
pageSize === null ||
|
||||
index + pageSize >= total
|
||||
)
|
||||
return rows;
|
||||
start = index + pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
private async request(url: string): Promise<IssResponse | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
|
||||
const body: unknown = await response.json();
|
||||
if (!body || typeof body !== 'object')
|
||||
throw new Error('MOEX malformed response');
|
||||
return body as IssResponse;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||
if (!block) return [];
|
||||
if (!Array.isArray(block.columns) || !Array.isArray(block.data))
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
const columns = block.columns;
|
||||
if (!columns.every((column) => typeof column === 'string'))
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
return block.data.map((values) => {
|
||||
if (!Array.isArray(values))
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
return Object.fromEntries(
|
||||
columns.map((column, index) => [column.toLowerCase(), values[index]]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function numberText(value: unknown): string | null {
|
||||
const result = typeof value === 'number' ? String(value) : text(value);
|
||||
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
|
||||
}
|
||||
|
||||
function currencyText(value: unknown): string | null {
|
||||
const result = text(value)?.toUpperCase();
|
||||
return result === 'SUR' ? 'RUB' : (result ?? null);
|
||||
}
|
||||
|
||||
function integer(value: unknown): number | null {
|
||||
const result = Number(value);
|
||||
return Number.isSafeInteger(result) && result >= 0 ? result : null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClient } from './moex.client';
|
||||
|
||||
@Module({
|
||||
providers: [MoexClient],
|
||||
exports: [MoexClient],
|
||||
})
|
||||
export class MoexModule {}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
const isin = 'RU000A10EW44';
|
||||
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
|
||||
`<tr><td>Эмитент</td><td>Облигация</td><td>${value}</td><td><a href="${href}">${code}</a></td><td>REG</td><td>17.04.2026</td></tr>`;
|
||||
const table = (rows: string) =>
|
||||
`<table id="issues-table"><thead><tr><th>Лицо</th><th>Выпуск</th><th>Рейтинг</th><th>ISIN</th><th>Регномер</th><th>Дата</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
const response = (html: string) =>
|
||||
({ ok: true, text: () => Promise.resolve(html) }) as Response;
|
||||
const cacheClock = performance;
|
||||
describe('public NCR issue ratings', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z'));
|
||||
jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now());
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it('matches only exact ISIN and shares one public registry download', async () => {
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(response(table(row())));
|
||||
const client = new NcrRatingsClient();
|
||||
const [rating, missing] = await Promise.all([
|
||||
client.rating(isin),
|
||||
client.rating('RU000A1065B1'),
|
||||
]);
|
||||
expect(rating).toEqual({
|
||||
agency: 'NCR',
|
||||
value: 'AAA.ru',
|
||||
status: 'active',
|
||||
date: '2026-04-17',
|
||||
sourceUrl: 'https://ratings.ru/ratings/issues/123/',
|
||||
observedAt: '2026-09-10T10:00:00.000Z',
|
||||
});
|
||||
expect(missing).toBeNull();
|
||||
expect(await client.rating('issuer-name')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(6 * 60 * 60_000 + 1);
|
||||
await client.rating(isin);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('preserves withdrawal and never displays an issuer rating as an issue rating', async () => {
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(response(table(row('рейтинг отозван'))));
|
||||
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({
|
||||
status: 'withdrawn',
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
'<html>Service unavailable</html>',
|
||||
table(row('AAA.ru', isin, 'https://untrusted.test/')),
|
||||
table(row('unrecognized')),
|
||||
table(row() + row('AA.ru')),
|
||||
table(''),
|
||||
])(
|
||||
'reports changed or ambiguous registry as unavailable, not unrated',
|
||||
async (html) => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(response(html));
|
||||
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
},
|
||||
);
|
||||
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValue(response(table(row())));
|
||||
const client = new NcrRatingsClient();
|
||||
await expect(client.rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
await expect(client.rating(isin)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_001);
|
||||
expect(await client.rating(isin)).toMatchObject({ value: 'AAA.ru' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { load } from 'cheerio';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
const registryUrl = 'https://ratings.ru/ratings/issues/';
|
||||
export type NcrRating = {
|
||||
agency: 'NCR';
|
||||
value: string | null;
|
||||
status: 'active' | 'withdrawn';
|
||||
date: string;
|
||||
sourceUrl: string;
|
||||
observedAt: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NcrRatingsClient {
|
||||
// Public registry contains no user data. Share one download across all issues.
|
||||
private readonly cache = new LRUCache<
|
||||
string,
|
||||
Promise<Map<string, NcrRating>>
|
||||
>({ max: 1, ttl: 6 * 60 * 60_000 });
|
||||
|
||||
async rating(isin: string): Promise<NcrRating | null> {
|
||||
const key = isin.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null;
|
||||
let pending = this.cache.get('registry');
|
||||
if (!pending) {
|
||||
pending = this.registry().catch(() => {
|
||||
// Bound outage traffic too; never present a failed download as no rating.
|
||||
if (this.cache.get('registry') === pending)
|
||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
||||
});
|
||||
this.cache.set('registry', pending);
|
||||
}
|
||||
return (await pending).get(key) ?? null;
|
||||
}
|
||||
|
||||
private async registry(): Promise<Map<string, NcrRating>> {
|
||||
const response = await fetch(registryUrl, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error('NCR unavailable');
|
||||
const $ = load(await response.text());
|
||||
const table = $('#issues-table');
|
||||
const headings = table
|
||||
.find('thead th')
|
||||
.map((_, cell) => $(cell).text().trim())
|
||||
.get();
|
||||
if (
|
||||
table.length !== 1 ||
|
||||
headings.length !== 6 ||
|
||||
headings[2] !== 'Рейтинг' ||
|
||||
headings[3] !== 'ISIN' ||
|
||||
headings[5] !== 'Дата'
|
||||
)
|
||||
throw new Error('NCR registry changed');
|
||||
const observedAt = new Date().toISOString();
|
||||
const result = new Map<string, NcrRating>();
|
||||
table.find('tbody tr').each((_, row) => {
|
||||
const cells = $(row).find('td');
|
||||
if (cells.length !== 6) throw new Error('NCR row changed');
|
||||
const isin = cells.eq(3).text().trim().toUpperCase();
|
||||
// Unassigned issues cannot safely be matched to a held instrument.
|
||||
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
|
||||
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
|
||||
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
|
||||
if (
|
||||
!withdrawn &&
|
||||
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
|
||||
raw,
|
||||
)
|
||||
)
|
||||
throw new Error('NCR rating changed');
|
||||
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
|
||||
cells.eq(5).text().trim(),
|
||||
);
|
||||
const href = cells.eq(3).find('a').attr('href');
|
||||
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
|
||||
throw new Error('NCR identity missing');
|
||||
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
|
||||
if (
|
||||
!Number.isFinite(Date.parse(date)) ||
|
||||
new Date(date).toISOString().slice(0, 10) !== date
|
||||
)
|
||||
throw new Error('NCR date invalid');
|
||||
const rating: NcrRating = {
|
||||
agency: 'NCR',
|
||||
value: withdrawn ? null : raw,
|
||||
status: withdrawn ? 'withdrawn' : 'active',
|
||||
date,
|
||||
sourceUrl: `https://ratings.ru${href}`,
|
||||
observedAt,
|
||||
};
|
||||
const previous = result.get(isin);
|
||||
if (
|
||||
previous &&
|
||||
previous.date === date &&
|
||||
(previous.value !== rating.value || previous.status !== rating.status)
|
||||
)
|
||||
throw new Error('NCR rating ambiguous');
|
||||
if (!previous || previous.date < date) result.set(isin, rating);
|
||||
});
|
||||
if (!result.size) throw new Error('NCR registry empty');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { NcrRatingsClient } from './ncr-ratings.client';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NcrRatingsClient } from './ncr-ratings.client';
|
||||
|
||||
@Module({
|
||||
providers: [NcrRatingsClient],
|
||||
exports: [NcrRatingsClient],
|
||||
})
|
||||
export class NcrModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { add, formatDecimal, multiply, subtract } from '../../common/decimal';
|
||||
import { decimal } from './decimal';
|
||||
|
||||
describe('decimal', () => {
|
||||
it('keeps signed nanos exact without floats', () => {
|
||||
expect(formatDecimal(decimal({ units: 0, nano: -500000000 }))).toBe('-0.5');
|
||||
expect(
|
||||
formatDecimal(
|
||||
add(decimal({ units: '9007199254740993' }), decimal({ nano: 1 })),
|
||||
),
|
||||
).toBe('9007199254740993.000000001');
|
||||
expect(
|
||||
formatDecimal(
|
||||
subtract(decimal({ units: 1 }), decimal({ nano: 500000000 })),
|
||||
),
|
||||
).toBe('0.5');
|
||||
expect(
|
||||
formatDecimal(
|
||||
multiply(decimal({ units: 2 }), decimal({ nano: 500000000 })),
|
||||
),
|
||||
).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Decimal } from '../../common/decimal';
|
||||
|
||||
const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { units?: string | number; nano?: string | number };
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
|
||||
scale: 9,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { LRUCache } from 'lru-cache';
|
||||
import assert from 'node:assert/strict';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
describe('InstrumentMetadataService', () => {
|
||||
const tbank = {
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const logger = { event: jest.fn() };
|
||||
let service: InstrumentMetadataService;
|
||||
|
||||
function rememberExpired(key: string, value: TbankInstrument): void {
|
||||
const cache = (
|
||||
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
|
||||
).metadata;
|
||||
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tbank.getInstrumentByFigi.mockReset();
|
||||
tbank.getInstrumentByUid.mockReset();
|
||||
service = new InstrumentMetadataService(tbank as never, logger as never);
|
||||
});
|
||||
|
||||
it('prefers the instrument UID when metadata is available', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue({
|
||||
name: 'Инструмент',
|
||||
brand: { logoName: 'instrument.png' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({
|
||||
brand: { logoName: 'instrument.png' },
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to FIGI when the UID has no matching instrument', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({
|
||||
ticker: 'SBER',
|
||||
brand: { logoName: 'sber.png' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({
|
||||
ticker: 'SBER',
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
|
||||
});
|
||||
|
||||
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
|
||||
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('caches negative lookups and avoids repeated upstream requests', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
|
||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1'),
|
||||
).resolves.toMatchObject({ name: 'Облигация' });
|
||||
});
|
||||
|
||||
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
|
||||
const known = {
|
||||
name: 'Облигация',
|
||||
ticker: 'BOND',
|
||||
brand: { logoName: 'bond.png' },
|
||||
};
|
||||
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
|
||||
await service.resolve('token', 'uid-1', null);
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
tbank.getInstrumentByUid.mockResolvedValue({
|
||||
...known,
|
||||
name: 'Новое название',
|
||||
});
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', null),
|
||||
).resolves.toMatchObject({ name: 'Новое название' });
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
|
||||
const error = new Error('INVALID_TBANK_TOKEN');
|
||||
tbank.getInstrumentByUid.mockRejectedValue(error);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
|
||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid
|
||||
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
|
||||
.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
|
||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
||||
rememberExpired('figi:figi-1', known);
|
||||
tbank.getInstrumentByUid
|
||||
.mockResolvedValueOnce({ isin: 'RU0000000001' })
|
||||
.mockResolvedValueOnce({ name: 'Новое название' });
|
||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
||||
known,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
|
||||
name: 'Новое название',
|
||||
});
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cold partial fields without caching them', async () => {
|
||||
const partial = { isin: 'RU0000000001' };
|
||||
tbank.getInstrumentByUid.mockResolvedValue(partial);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
partial,
|
||||
);
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
||||
partial,
|
||||
);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('replaces an expired name with authoritative confirmed absence', async () => {
|
||||
rememberExpired('uid:uid-1', { name: 'Старое название' });
|
||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
||||
const error = new Error('INVALID_TBANK_TOKEN');
|
||||
const known = { name: 'Облигация' };
|
||||
let rejectInvalid: ((reason: Error) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
||||
token === 'invalid-token'
|
||||
? new Promise((_resolve, reject) => {
|
||||
rejectInvalid = reject;
|
||||
})
|
||||
: Promise.resolve(known),
|
||||
);
|
||||
|
||||
const invalid = expect(
|
||||
service.resolve('invalid-token', 'uid-1', null),
|
||||
).rejects.toBe(error);
|
||||
const valid = service.resolve('valid-token', 'uid-1', null);
|
||||
assert.ok(rejectInvalid);
|
||||
rejectInvalid(error);
|
||||
|
||||
await invalid;
|
||||
await expect(valid).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('another-token', 'uid-1', null),
|
||||
).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns cached or nullable metadata without starting requests after abort', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
rememberExpired('figi:figi-1', known);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
|
||||
).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
|
||||
).resolves.toBeNull();
|
||||
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expired metadata promptly when the waiting caller aborts', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
rememberExpired('uid:uid-1', known);
|
||||
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const result = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(result).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
||||
const known = { name: 'Облигация' };
|
||||
let finish: ((value: TbankInstrument) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const aborted = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(aborted).resolves.toBeNull();
|
||||
|
||||
const waiting = service.resolve('token', 'uid-1', null);
|
||||
assert.ok(finish);
|
||||
finish(known);
|
||||
|
||||
await expect(waiting).resolves.toEqual(known);
|
||||
await expect(
|
||||
service.resolve('another-token', 'uid-1', null),
|
||||
).resolves.toEqual(known);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
||||
let fail: ((reason: Error) => void) | undefined;
|
||||
tbank.getInstrumentByUid.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject;
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const result = service.resolve(
|
||||
'token',
|
||||
'uid-1',
|
||||
'figi-1',
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBeNull();
|
||||
|
||||
assert.ok(fail);
|
||||
fail(new Error('INVALID_TBANK_TOKEN'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
|
||||
const metadataForMs = 86_400_000;
|
||||
const maxCachedInstruments = 500;
|
||||
const missingInstrument = Symbol('missingInstrument');
|
||||
|
||||
@Injectable()
|
||||
export class InstrumentMetadataService {
|
||||
private readonly metadata = new LRUCache<
|
||||
string,
|
||||
TbankInstrument | typeof missingInstrument
|
||||
>({
|
||||
max: maxCachedInstruments,
|
||||
ttl: metadataForMs,
|
||||
noDeleteOnStaleGet: true,
|
||||
});
|
||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
||||
|
||||
constructor(
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
token: string,
|
||||
instrumentUid: string | null | undefined,
|
||||
figi: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const uid = instrumentUid?.trim();
|
||||
let byUid: TbankInstrument | null = null;
|
||||
if (uid) {
|
||||
byUid = await this.lookup(
|
||||
token,
|
||||
`uid:${uid}`,
|
||||
() => this.tbank.getInstrumentByUid(token, uid),
|
||||
signal,
|
||||
);
|
||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
||||
}
|
||||
const instrumentFigi = figi?.trim();
|
||||
const byFigi = instrumentFigi
|
||||
? await this.lookup(
|
||||
token,
|
||||
`figi:${instrumentFigi}`,
|
||||
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
||||
signal,
|
||||
)
|
||||
: null;
|
||||
return byFigi ?? byUid;
|
||||
}
|
||||
|
||||
private async lookup(
|
||||
token: string,
|
||||
key: string,
|
||||
fetch: () => Promise<TbankInstrument | null>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const cached = this.metadata.get(key);
|
||||
if (cached !== undefined)
|
||||
return cached === missingInstrument ? null : cached;
|
||||
if (signal?.aborted) return this.lastKnown(key);
|
||||
const owner = createHash('sha256').update(token).digest('hex');
|
||||
const requestKey = `${owner}:${key}`;
|
||||
let pending = this.pending.get(requestKey);
|
||||
if (!pending) {
|
||||
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
|
||||
this.pending.delete(requestKey);
|
||||
});
|
||||
this.pending.set(requestKey, pending);
|
||||
}
|
||||
if (!signal) return pending;
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => resolve(this.lastKnown(key));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
void pending.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private lastKnown(key: string): TbankInstrument | null {
|
||||
const value = this.metadata.peek(key, { allowStale: true });
|
||||
return value === missingInstrument ? null : (value ?? null);
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
key: string,
|
||||
request: () => Promise<TbankInstrument | null>,
|
||||
previous: TbankInstrument | null,
|
||||
): Promise<TbankInstrument | null> {
|
||||
try {
|
||||
const value = await request();
|
||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
||||
if (value === null || found)
|
||||
this.metadata.set(key, value ?? missingInstrument);
|
||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
||||
found,
|
||||
});
|
||||
return found || value === null ? value : (previous ?? value);
|
||||
} catch (error) {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code:
|
||||
error instanceof Error && 'response' in error
|
||||
? ((error as { response?: { code?: string } }).response?.code ??
|
||||
'UNKNOWN')
|
||||
: 'UNKNOWN',
|
||||
});
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw error;
|
||||
return error instanceof ServiceUnavailableException ? previous : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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";
|
||||
|
||||
//Тип инструмента.
|
||||
enum InstrumentType {
|
||||
INSTRUMENT_TYPE_UNSPECIFIED = 0;
|
||||
INSTRUMENT_TYPE_BOND = 1; //Облигация.
|
||||
INSTRUMENT_TYPE_SHARE = 2; //Акция.
|
||||
INSTRUMENT_TYPE_CURRENCY = 3; //Валюта.
|
||||
INSTRUMENT_TYPE_ETF = 4; //Exchange-traded fund. Фонд.
|
||||
INSTRUMENT_TYPE_FUTURES = 5; //Фьючерс.
|
||||
INSTRUMENT_TYPE_SP = 6; //Структурная нота.
|
||||
INSTRUMENT_TYPE_OPTION = 7; //Опцион.
|
||||
INSTRUMENT_TYPE_CLEARING_CERTIFICATE = 8; //Clearing certificate.
|
||||
INSTRUMENT_TYPE_INDEX = 9; //Индекс.
|
||||
INSTRUMENT_TYPE_COMMODITY = 10; //Товар.
|
||||
INSTRUMENT_TYPE_DFA = 11; //Цифровой актив.
|
||||
}
|
||||
|
||||
//Статус запрашиваемых инструментов.
|
||||
enum InstrumentStatus {
|
||||
INSTRUMENT_STATUS_UNSPECIFIED = 0; //Значение не определено.
|
||||
INSTRUMENT_STATUS_BASE = 1; //По умолчанию — базовый список инструментов, которыми можно торговать через T-Invest API. Сейчас списки доступных бумаг в API и других интерфейсах совпадают — кроме внебиржевых бумаг, но в будущем списки могут различаться.
|
||||
INSTRUMENT_STATUS_ALL = 2; //Список всех инструментов.
|
||||
}
|
||||
|
||||
//Денежная сумма в определенной валюте.
|
||||
message MoneyValue {
|
||||
|
||||
// Строковый ISO-код валюты.
|
||||
string currency = 1;
|
||||
|
||||
// Целая часть суммы, может быть отрицательным числом.
|
||||
int64 units = 2;
|
||||
|
||||
// Дробная часть суммы, может быть отрицательным числом.
|
||||
int32 nano = 3;
|
||||
}
|
||||
|
||||
//Котировка — денежная сумма без указания валюты.
|
||||
message Quotation {
|
||||
|
||||
// Целая часть суммы, может быть отрицательным числом.
|
||||
int64 units = 1;
|
||||
|
||||
// Дробная часть суммы, может быть отрицательным числом.
|
||||
int32 nano = 2;
|
||||
}
|
||||
|
||||
//Режим торгов инструмента
|
||||
enum SecurityTradingStatus {
|
||||
SECURITY_TRADING_STATUS_UNSPECIFIED = 0; //Торговый статус не определен.
|
||||
SECURITY_TRADING_STATUS_NOT_AVAILABLE_FOR_TRADING = 1; //Недоступен для торгов.
|
||||
SECURITY_TRADING_STATUS_OPENING_PERIOD = 2; //Период открытия торгов.
|
||||
SECURITY_TRADING_STATUS_CLOSING_PERIOD = 3; //Период закрытия торгов.
|
||||
SECURITY_TRADING_STATUS_BREAK_IN_TRADING = 4; //Перерыв в торговле.
|
||||
SECURITY_TRADING_STATUS_NORMAL_TRADING = 5; //Нормальная торговля.
|
||||
SECURITY_TRADING_STATUS_CLOSING_AUCTION = 6; //Аукцион закрытия.
|
||||
SECURITY_TRADING_STATUS_DARK_POOL_AUCTION = 7; //Аукцион крупных пакетов.
|
||||
SECURITY_TRADING_STATUS_DISCRETE_AUCTION = 8; //Дискретный аукцион.
|
||||
SECURITY_TRADING_STATUS_OPENING_AUCTION_PERIOD = 9; //Аукцион открытия.
|
||||
SECURITY_TRADING_STATUS_TRADING_AT_CLOSING_AUCTION_PRICE = 10; //Период торгов по цене аукциона закрытия.
|
||||
SECURITY_TRADING_STATUS_SESSION_ASSIGNED = 11; //Сессия назначена.
|
||||
SECURITY_TRADING_STATUS_SESSION_CLOSE = 12; //Сессия закрыта.
|
||||
SECURITY_TRADING_STATUS_SESSION_OPEN = 13; //Сессия открыта.
|
||||
SECURITY_TRADING_STATUS_DEALER_NORMAL_TRADING = 14; //Доступна торговля в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_DEALER_BREAK_IN_TRADING = 15; //Перерыв торговли в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_DEALER_NOT_AVAILABLE_FOR_TRADING = 16; //Недоступна торговля в режиме внутренней ликвидности брокера.
|
||||
SECURITY_TRADING_STATUS_STABILIZATION_AUCTION = 17; //Аукцион обновления цен.
|
||||
}
|
||||
|
||||
message PingRequest {
|
||||
optional google.protobuf.Timestamp time = 1; //Время формирования запроса.
|
||||
}
|
||||
|
||||
message PingDelaySettings {
|
||||
optional int32 ping_delay_ms = 15; //Задержка (пинг) сообщений: 5000–180 000 миллисекунд. Значение по умолчанию — 120 000.
|
||||
}
|
||||
|
||||
//Проверка активности стрима.
|
||||
message Ping {
|
||||
google.protobuf.Timestamp time = 1; //Время проверки.
|
||||
string stream_id = 2; //Идентификатор соединения.
|
||||
optional google.protobuf.Timestamp ping_request_time = 4; //Время формирования запроса.
|
||||
}
|
||||
|
||||
//Тип цены.
|
||||
enum PriceType {
|
||||
PRICE_TYPE_UNSPECIFIED = 0; //Значение не определено.
|
||||
PRICE_TYPE_POINT = 1; //Цена в пунктах (только для фьючерсов и облигаций).
|
||||
PRICE_TYPE_CURRENCY = 2; //Цена в валюте расчетов по инструменту.
|
||||
}
|
||||
|
||||
message Page {
|
||||
int32 limit = 1; //Максимальное число возвращаемых записей.
|
||||
int32 page_number = 2; //Порядковый номер страницы, начиная с 0.
|
||||
}
|
||||
|
||||
message PageResponse {
|
||||
int32 limit = 1; //Максимальное число возвращаемых записей.
|
||||
int32 page_number = 2; //Порядковый номер страницы, начиная с 0.
|
||||
int32 total_count = 3; //Общее количество записей.
|
||||
}
|
||||
|
||||
message ResponseMetadata {
|
||||
string tracking_id = 42; //Идентификатор трекинга.
|
||||
google.protobuf.Timestamp server_time = 43; //Серверное время.
|
||||
}
|
||||
|
||||
message BrandData {
|
||||
string logo_name = 1; // Логотип инструмента. Имя файла для получения логотипа.
|
||||
string logo_base_color = 2; // Цвет бренда.
|
||||
string text_color = 3; // Цвет текста для цвета логотипа бренда.
|
||||
}
|
||||
|
||||
enum ResultSubscriptionStatus {
|
||||
RESULT_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Статус подписки не определен.
|
||||
RESULT_SUBSCRIPTION_STATUS_OK = 1; //Подписка успешно установлена.
|
||||
RESULT_SUBSCRIPTION_STATUS_ERROR = 13; //Ошибка подписки
|
||||
}
|
||||
|
||||
message ErrorDetail {
|
||||
string code = 1; //Код ошибки.
|
||||
string message = 3; //Описание ошибки.
|
||||
}
|
||||
|
||||
//Реальная площадка исполнения расчетов.
|
||||
enum RealExchange {
|
||||
REAL_EXCHANGE_UNSPECIFIED = 0; //Тип не определен.
|
||||
REAL_EXCHANGE_MOEX = 1; //Московская биржа.
|
||||
REAL_EXCHANGE_RTS = 2; //Санкт-Петербургская биржа.
|
||||
REAL_EXCHANGE_OTC = 3; //Внебиржевой инструмент.
|
||||
REAL_EXCHANGE_DEALER = 4; //Инструмент, торгуемый на площадке брокера.
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package google.api;
|
||||
|
||||
import "google/protobuf/descriptor.proto";
|
||||
|
||||
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "FieldBehaviorProto";
|
||||
option java_package = "com.google.api";
|
||||
option objc_class_prefix = "GAPI";
|
||||
|
||||
extend google.protobuf.FieldOptions {
|
||||
// A designation of a specific field behavior (required, output only, etc.)
|
||||
// in protobuf messages.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// string name = 1 [(google.api.field_behavior) = REQUIRED];
|
||||
// State state = 1 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
// google.protobuf.Duration ttl = 1
|
||||
// [(google.api.field_behavior) = INPUT_ONLY];
|
||||
// google.protobuf.Timestamp expire_time = 1
|
||||
// [(google.api.field_behavior) = OUTPUT_ONLY,
|
||||
// (google.api.field_behavior) = IMMUTABLE];
|
||||
repeated google.api.FieldBehavior field_behavior = 1052 [packed = false];
|
||||
}
|
||||
|
||||
// An indicator of the behavior of a given field (for example, that a field
|
||||
// is required in requests, or given as output but ignored as input).
|
||||
// This **does not** change the behavior in protocol buffers itself; it only
|
||||
// denotes the behavior and may affect how API tooling handles the field.
|
||||
//
|
||||
// Note: This enum **may** receive new values in the future.
|
||||
enum FieldBehavior {
|
||||
// Conventional default for enums. Do not use this.
|
||||
FIELD_BEHAVIOR_UNSPECIFIED = 0;
|
||||
|
||||
// Specifically denotes a field as optional.
|
||||
// While all fields in protocol buffers are optional, this may be specified
|
||||
// for emphasis if appropriate.
|
||||
OPTIONAL = 1;
|
||||
|
||||
// Denotes a field as required.
|
||||
// This indicates that the field **must** be provided as part of the request,
|
||||
// and failure to do so will cause an error (usually `INVALID_ARGUMENT`).
|
||||
REQUIRED = 2;
|
||||
|
||||
// Denotes a field as output only.
|
||||
// This indicates that the field is provided in responses, but including the
|
||||
// field in a request does nothing (the server *must* ignore it and
|
||||
// *must not* throw an error as a result of the field's presence).
|
||||
OUTPUT_ONLY = 3;
|
||||
|
||||
// Denotes a field as input only.
|
||||
// This indicates that the field is provided in requests, and the
|
||||
// corresponding field is not included in output.
|
||||
INPUT_ONLY = 4;
|
||||
|
||||
// Denotes a field as immutable.
|
||||
// This indicates that the field may be set once in a request to create a
|
||||
// resource, but may not be changed thereafter.
|
||||
IMMUTABLE = 5;
|
||||
|
||||
// Denotes that a (repeated) field is an unordered list.
|
||||
// This indicates that the service may provide the elements of the list
|
||||
// in any arbitrary order, rather than the order the user originally
|
||||
// provided. Additionally, the list's order may or may not be stable.
|
||||
UNORDERED_LIST = 6;
|
||||
|
||||
// Denotes that this field returns a non-empty default value if not set.
|
||||
// This indicates that if the user provides the empty value in a request,
|
||||
// a non-empty value will be returned. The user will not be aware of what
|
||||
// non-empty value to expect.
|
||||
NON_EMPTY_DEFAULT = 7;
|
||||
|
||||
// Denotes that the field in a resource (a message annotated with
|
||||
// google.api.resource) is used in the resource name to uniquely identify the
|
||||
// resource. For AIP-compliant APIs, this should only be applied to the
|
||||
// `name` field on the resource.
|
||||
//
|
||||
// This behavior should not be applied to references to other resources within
|
||||
// the message.
|
||||
//
|
||||
// The identifier field of resources often have different field behavior
|
||||
// depending on the request it is embedded in (e.g. for Create methods name
|
||||
// is optional and unused, while for Update methods it is required). Instead
|
||||
// of method-specific annotations, only `IDENTIFIER` is required.
|
||||
IDENTIFIER = 8;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; // Цена дилера
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
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 OperationsService {/*С помощью методов сервиса можно получить:<br/><br/> **1**. Список операций по счету.<br/> **2**.
|
||||
Портфель по счету.<br/> **3**. Позиции ценных бумаг на счете.<br/> **4**.
|
||||
Доступный остаток для вывода средств.<br/> **5**. Различные отчеты.*/
|
||||
//Deprecated GetOperations — список операций по счету
|
||||
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
|
||||
rpc GetOperations(OperationsRequest) returns (OperationsResponse);
|
||||
|
||||
//GetPortfolio — портфель по счету
|
||||
rpc GetPortfolio(PortfolioRequest) returns (PortfolioResponse);
|
||||
|
||||
//GetPositions — список позиций по счету
|
||||
rpc GetPositions(PositionsRequest) returns (PositionsResponse);
|
||||
|
||||
//GetWithdrawLimits — доступный остаток для вывода средств
|
||||
rpc GetWithdrawLimits(WithdrawLimitsRequest) returns (WithdrawLimitsResponse);
|
||||
|
||||
//GetBrokerReport — брокерский отчет.
|
||||
rpc GetBrokerReport(BrokerReportRequest) returns (BrokerReportResponse);
|
||||
|
||||
//GetDividendsForeignIssuer — отчет «Справка о доходах за пределами РФ»
|
||||
rpc GetDividendsForeignIssuer(GetDividendsForeignIssuerRequest) returns (GetDividendsForeignIssuerResponse);
|
||||
|
||||
//GetOperationsByCursor — список операций по счету с пагинацией
|
||||
//При работе с методом учитывайте [особенности взаимодействия](/invest/services/operations/operations_problems).
|
||||
rpc GetOperationsByCursor(GetOperationsByCursorRequest) returns (GetOperationsByCursorResponse);
|
||||
}
|
||||
|
||||
service OperationsStreamService {
|
||||
//PortfolioStream — стрим обновлений портфеля
|
||||
rpc PortfolioStream(PortfolioStreamRequest) returns (stream PortfolioStreamResponse);
|
||||
|
||||
//PositionsStream — стрим обновлений информации по изменению позиций портфеля
|
||||
rpc PositionsStream(PositionsStreamRequest) returns (stream PositionsStreamResponse);
|
||||
|
||||
//OperationsStream — стрим обновлений операций
|
||||
rpc OperationsStream(OperationsStreamRequest) returns (stream OperationsStreamResponse);
|
||||
}
|
||||
|
||||
//Запрос получения списка операций по счету.
|
||||
message OperationsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
optional google.protobuf.Timestamp from = 2; //Начало периода по UTC.
|
||||
optional google.protobuf.Timestamp to = 3; //Окончание периода по UTC.
|
||||
optional OperationState state = 4; //Статус запрашиваемых операций.
|
||||
optional string figi = 5; //FIGI-идентификатор инструмента для фильтрации.
|
||||
}
|
||||
|
||||
//Список операций.
|
||||
message OperationsResponse {
|
||||
repeated Operation operations = 1; //Массив операций.
|
||||
}
|
||||
|
||||
//Данные по операции.
|
||||
message Operation {
|
||||
string id = 1; //Идентификатор операции.
|
||||
string parent_operation_id = 2; //Идентификатор родительской операции.
|
||||
string currency = 3; //Валюта операции.
|
||||
MoneyValue payment = 4; //Сумма операции.
|
||||
MoneyValue price = 5; //Цена операции за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
OperationState state = 6; //Статус операции.
|
||||
int64 quantity = 7; //Количество единиц инструмента.
|
||||
int64 quantity_rest = 8; //Неисполненный остаток по сделке.
|
||||
string figi = 9; //FIGI-идентификатор инструмента, связанного с операцией.
|
||||
string instrument_type = 10; //Тип инструмента. Возможные значения: <br/><br/>`bond` — облигация; <br/>`share` — акция; <br/>`currency` — валюта; <br/>`etf` — фонд; <br/>`futures` — фьючерс.
|
||||
google.protobuf.Timestamp date = 11; //Дата и время операции в формате часовом поясе UTC.
|
||||
string type = 12; //Текстовое описание типа операции.
|
||||
OperationType operation_type = 13; //Тип операции.
|
||||
repeated OperationTrade trades = 14; //Массив сделок.
|
||||
string asset_uid = 16; //Идентификатор актива
|
||||
string position_uid = 17; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 18; //Уникальный идентификатор инструмента.
|
||||
repeated ChildOperationItem child_operations = 19; //Массив дочерних операций.
|
||||
}
|
||||
|
||||
//Сделка по операции.
|
||||
message OperationTrade {
|
||||
string trade_id = 1; //Идентификатор сделки.
|
||||
google.protobuf.Timestamp date_time = 2; //Дата и время сделки по UTC.
|
||||
int64 quantity = 3; //Количество инструментов.
|
||||
MoneyValue price = 4; //Цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
}
|
||||
|
||||
//Запрос получения текущего портфеля по счету.
|
||||
message PortfolioRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
optional CurrencyRequest currency = 2; //Валюта, в которой нужно рассчитать портфель.
|
||||
enum CurrencyRequest {
|
||||
RUB = 0; //Рубли
|
||||
USD = 1; //Доллары
|
||||
EUR = 2; //Евро
|
||||
}
|
||||
}
|
||||
|
||||
//Текущий портфель по счету.
|
||||
message PortfolioResponse {
|
||||
MoneyValue total_amount_shares = 1; //Общая стоимость акций в портфеле.
|
||||
MoneyValue total_amount_bonds = 2; //Общая стоимость облигаций в портфеле.
|
||||
MoneyValue total_amount_etf = 3; //Общая стоимость фондов в портфеле.
|
||||
MoneyValue total_amount_currencies = 4; //Общая стоимость валют в портфеле.
|
||||
MoneyValue total_amount_futures = 5; //Общая стоимость фьючерсов в портфеле.
|
||||
Quotation expected_yield = 6; //Текущая относительная доходность портфеля в %.
|
||||
repeated PortfolioPosition positions = 7; //Список позиций портфеля.
|
||||
string account_id = 8; //Идентификатор счета пользователя.
|
||||
|
||||
MoneyValue total_amount_options = 9; //Общая стоимость опционов в портфеле.
|
||||
MoneyValue total_amount_sp = 10; //Общая стоимость структурных нот в портфеле.
|
||||
MoneyValue total_amount_portfolio = 11; //Общая стоимость портфеля.
|
||||
repeated VirtualPortfolioPosition virtual_positions = 12; //Массив виртуальных позиций портфеля.
|
||||
MoneyValue daily_yield = 15; // Рассчитанная доходность портфеля за день в рублях.
|
||||
Quotation daily_yield_relative = 16; //Относительная доходность в день в %.
|
||||
MoneyValue total_amount_dfa = 17; //Общая стоимость смарт-активов в портфеле в рублях.
|
||||
}
|
||||
|
||||
//Запрос позиций портфеля по счету.
|
||||
message PositionsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
}
|
||||
|
||||
//Список позиций по счету.
|
||||
message PositionsResponse {
|
||||
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
|
||||
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
|
||||
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
|
||||
bool limits_loading_in_progress = 4; //Признак идущей выгрузки лимитов в данный момент.
|
||||
repeated PositionsFutures futures = 5; //Список фьючерсов портфеля.
|
||||
repeated PositionsOptions options = 6; //Список опционов портфеля.
|
||||
string account_id = 15; //Идентификатор счёта пользователя.
|
||||
}
|
||||
|
||||
//Запрос доступного остатка для вывода.
|
||||
message WithdrawLimitsRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета пользователя.
|
||||
}
|
||||
|
||||
//Доступный остаток для вывода.
|
||||
message WithdrawLimitsResponse {
|
||||
repeated MoneyValue money = 1; //Массив валютных позиций портфеля.
|
||||
repeated MoneyValue blocked = 2; //Массив заблокированных валютных позиций портфеля.
|
||||
repeated MoneyValue blocked_guarantee = 3; //Заблокировано под гарантийное обеспечение фьючерсов.
|
||||
}
|
||||
|
||||
//Позиции портфеля.
|
||||
message PortfolioPosition {
|
||||
string figi = 1; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 2; //Тип инструмента.
|
||||
Quotation quantity = 3; //Количество инструмента в портфеле в штуках.
|
||||
MoneyValue average_position_price = 4; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation expected_yield = 5; //Текущая рассчитанная доходность позиции.
|
||||
MoneyValue current_nkd = 6; // Текущий НКД.
|
||||
Quotation average_position_price_pt = 7 [ deprecated = true ]; // Deprecated Средняя цена позиции в пунктах (для фьючерсов). Для пересчета возможна задержка до одной секунды.
|
||||
MoneyValue current_price = 8; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
MoneyValue average_position_price_fifo = 9; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation quantity_lots = 10 [ deprecated = true ]; //Deprecated Количество лотов в портфеле.
|
||||
bool blocked = 21; //Заблокировано на бирже.
|
||||
Quotation blocked_lots = 22; //Количество бумаг, заблокированных выставленными заявками.
|
||||
string position_uid = 24; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 25; //Уникальный идентификатор инструмента.
|
||||
MoneyValue var_margin = 26; //Вариационная маржа.
|
||||
Quotation expected_yield_fifo = 27; //Текущая рассчитанная доходность позиции.
|
||||
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
|
||||
string ticker = 32; //Тикер инструмента.
|
||||
string class_code = 33; //Класс-код (секция торгов).
|
||||
optional MoneyValue var_margin_settled = 34; //Вариационная маржа (расчетная).
|
||||
}
|
||||
|
||||
message VirtualPortfolioPosition {
|
||||
string position_uid = 1; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 2; //Уникальный идентификатор инструмента.
|
||||
string figi = 3; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 4; //Тип инструмента.
|
||||
Quotation quantity = 5; //Количество инструмента в портфеле в штуках.
|
||||
MoneyValue average_position_price = 6; //Средневзвешенная цена позиции. Для пересчета возможна задержка до одной секунды.
|
||||
Quotation expected_yield = 7; //Текущая рассчитанная доходность позиции.
|
||||
Quotation expected_yield_fifo = 8; //Текущая рассчитанная доходность позиции.
|
||||
google.protobuf.Timestamp expire_date = 9; //Дата, до которой нужно продать виртуальные бумаги. После этой даты виртуальная позиция «сгораетт».
|
||||
MoneyValue current_price = 10; //Текущая цена за 1 инструмент. Чтобы получить стоимость лота, нужно умножить на лотность инструмента.
|
||||
MoneyValue average_position_price_fifo = 11; //Средняя цена позиции по методу FIFO. Для пересчета возможна задержка до одной секунды.
|
||||
MoneyValue daily_yield = 31; // Рассчитанная доходность портфеля за день.
|
||||
string ticker = 32; //Тикер инструмента.
|
||||
string class_code = 33; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Баланс позиции ценной бумаги.
|
||||
message PositionsSecurities {
|
||||
string figi = 1; //FIGI-идентификатор бумаги.
|
||||
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 3; //Текущий незаблокированный баланс.
|
||||
string position_uid = 4; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 5; //Уникальный идентификатор инструмента.
|
||||
string ticker = 6; //Тикер инструмента.
|
||||
string class_code = 7; //Класс-код (секция торгов).
|
||||
bool exchange_blocked = 11; //Заблокировано на бирже.
|
||||
string instrument_type = 16; //Тип инструмента.
|
||||
}
|
||||
|
||||
//Баланс фьючерса.
|
||||
message PositionsFutures {
|
||||
string figi = 1; //FIGI-идентификатор фьючерса.
|
||||
int64 blocked = 2; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 3; //Текущий незаблокированный баланс.
|
||||
string position_uid = 4; //Уникальный идентификатор позиции.
|
||||
string instrument_uid = 5; //Уникальный идентификатор инструмента.
|
||||
string ticker = 6; //Тикер инструмента.
|
||||
string class_code = 7; //Класс-код (секция торгов).
|
||||
}
|
||||
|
||||
//Баланс опциона.
|
||||
message PositionsOptions {
|
||||
string position_uid = 1; //Уникальный идентификатор позиции опциона.
|
||||
string instrument_uid = 2; //Уникальный идентификатор инструмента.
|
||||
string ticker = 3; //Тикер инструмента.
|
||||
string class_code = 4; //Класс-код (секция торгов).
|
||||
int64 blocked = 11; //Количество бумаг, заблокированных выставленными заявками.
|
||||
int64 balance = 21; //Текущий незаблокированный баланс.
|
||||
}
|
||||
|
||||
message BrokerReportRequest {
|
||||
oneof payload {
|
||||
GenerateBrokerReportRequest generate_broker_report_request = 1;
|
||||
GetBrokerReportRequest get_broker_report_request = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message BrokerReportResponse {
|
||||
oneof payload {
|
||||
GenerateBrokerReportResponse generate_broker_report_response = 1;
|
||||
GetBrokerReportResponse get_broker_report_response = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message GenerateBrokerReportRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC.
|
||||
}
|
||||
|
||||
message GenerateBrokerReportResponse {
|
||||
string task_id = 1; //Идентификатор задачи формирования брокерского отчета.
|
||||
}
|
||||
|
||||
message GetBrokerReportRequest {
|
||||
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования брокерского отчета.
|
||||
optional int32 page = 2; //Номер страницы отчета, начинается с 1. Значение по умолчанию — 0.
|
||||
}
|
||||
|
||||
message GetBrokerReportResponse {
|
||||
repeated BrokerReport broker_report = 1;
|
||||
int32 itemsCount = 2; //Количество записей в отчете.
|
||||
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
|
||||
int32 page = 4; //Текущая страница, начинается с 0.
|
||||
string task_id = 5; //Идентификатор задачи формирования брокерского отчета.
|
||||
}
|
||||
|
||||
message BrokerReport {
|
||||
string trade_id = 1;//Номер сделки.
|
||||
string order_id = 2; //Номер поручения.
|
||||
string figi = 3; //FIGI-идентификаторинструмента.
|
||||
string execute_sign = 4; //Признак исполнения.
|
||||
google.protobuf.Timestamp trade_datetime = 5; //Дата и время заключения по UTC.
|
||||
string exchange = 6; //Торговая площадка.
|
||||
string class_code = 7; //Режим торгов.
|
||||
string direction = 8; //Вид сделки.
|
||||
string name = 9; //Сокращенное наименование актива.
|
||||
string ticker = 10; //Код актива.
|
||||
MoneyValue price = 11; //Цена за единицу.
|
||||
int64 quantity = 12; //Количество.
|
||||
MoneyValue order_amount = 13; //Сумма без НКД.
|
||||
Quotation aci_value = 14; //НКД.
|
||||
MoneyValue total_order_amount = 15; //Сумма сделки.
|
||||
MoneyValue broker_commission = 16; //Комиссия брокера.
|
||||
MoneyValue exchange_commission = 17; //Комиссия биржи.
|
||||
MoneyValue exchange_clearing_commission = 18; //Комиссия клирингового центра.
|
||||
Quotation repo_rate = 19; //Ставка РЕПО, %.
|
||||
string party = 20; //Контрагент или брокерарокер.
|
||||
google.protobuf.Timestamp clear_value_date = 21; //Дата расчетов по UTC.
|
||||
google.protobuf.Timestamp sec_value_date = 22; //Дата поставки по UTC.
|
||||
string broker_status = 23; //Статус брокера.
|
||||
string separate_agreement_type = 24; //Тип договора.
|
||||
string separate_agreement_number = 25; //Номер договора.
|
||||
string separate_agreement_date = 26; //Дата договора.
|
||||
string delivery_type = 27; //Тип расчета по сделке.
|
||||
}
|
||||
|
||||
//Статус запрашиваемых операций.
|
||||
enum OperationState {
|
||||
OPERATION_STATE_UNSPECIFIED = 0; //Статус операции не определен.
|
||||
OPERATION_STATE_EXECUTED = 1; //Исполнена частично или полностью.
|
||||
OPERATION_STATE_CANCELED = 2; //Отменена.
|
||||
OPERATION_STATE_PROGRESS = 3; //Исполняется.
|
||||
}
|
||||
|
||||
//Тип операции.
|
||||
enum OperationType {
|
||||
OPERATION_TYPE_UNSPECIFIED = 0; //Тип операции не определен.
|
||||
OPERATION_TYPE_INPUT = 1; //Пополнение брокерского счета.
|
||||
OPERATION_TYPE_BOND_TAX = 2; //Удержание НДФЛ по купонам.
|
||||
OPERATION_TYPE_OUTPUT_SECURITIES = 3; //Вывод ЦБ.
|
||||
OPERATION_TYPE_OVERNIGHT = 4; //Доход по сделке РЕПО овернайт.
|
||||
OPERATION_TYPE_TAX = 5; //Удержание налога.
|
||||
OPERATION_TYPE_BOND_REPAYMENT_FULL = 6; //Полное погашение облигаций.
|
||||
OPERATION_TYPE_SELL_CARD = 7; //Продажа ЦБ с карты.
|
||||
OPERATION_TYPE_DIVIDEND_TAX = 8; //Удержание налога по дивидендам.
|
||||
OPERATION_TYPE_OUTPUT = 9; //Вывод денежных средств.
|
||||
OPERATION_TYPE_BOND_REPAYMENT = 10; //Частичное погашение облигаций.
|
||||
OPERATION_TYPE_TAX_CORRECTION = 11; //Корректировка налога.
|
||||
OPERATION_TYPE_SERVICE_FEE = 12; //Удержание комиссии за обслуживание брокерского счета.
|
||||
OPERATION_TYPE_BENEFIT_TAX = 13; //Удержание налога за материальную выгоду.
|
||||
OPERATION_TYPE_MARGIN_FEE = 14; //Удержание комиссии за непокрытую позицию.
|
||||
OPERATION_TYPE_BUY = 15; //Покупка ЦБ.
|
||||
OPERATION_TYPE_BUY_CARD = 16; //Покупка ЦБ с карты.
|
||||
OPERATION_TYPE_INPUT_SECURITIES = 17; //Перевод ценных бумаг из другого депозитария.
|
||||
OPERATION_TYPE_SELL_MARGIN = 18; //Продажа в результате Margin-call.
|
||||
OPERATION_TYPE_BROKER_FEE = 19; //Удержание комиссии за операцию.
|
||||
OPERATION_TYPE_BUY_MARGIN = 20; //Покупка в результате Margin-call.
|
||||
OPERATION_TYPE_DIVIDEND = 21; //Выплата дивидендов.
|
||||
OPERATION_TYPE_SELL = 22; //Продажа ЦБ.
|
||||
OPERATION_TYPE_COUPON = 23; //Выплата купонов.
|
||||
OPERATION_TYPE_SUCCESS_FEE = 24; //Удержание комиссии SuccessFee.
|
||||
OPERATION_TYPE_DIVIDEND_TRANSFER = 25; //Передача дивидендного дохода.
|
||||
OPERATION_TYPE_ACCRUING_VARMARGIN = 26; //Зачисление вариационной маржи.
|
||||
OPERATION_TYPE_WRITING_OFF_VARMARGIN = 27; //Списание вариационной маржи.
|
||||
OPERATION_TYPE_DELIVERY_BUY = 28; //Покупка в рамках экспирации фьючерсного контракта.
|
||||
OPERATION_TYPE_DELIVERY_SELL = 29; //Продажа в рамках экспирации фьючерсного контракта.
|
||||
OPERATION_TYPE_TRACK_MFEE = 30; //Комиссия за управление по счету автоследования.
|
||||
OPERATION_TYPE_TRACK_PFEE = 31; //Комиссия за результат по счету автоследования.
|
||||
OPERATION_TYPE_TAX_PROGRESSIVE = 32; //Удержание налога по ставке 15%.
|
||||
OPERATION_TYPE_BOND_TAX_PROGRESSIVE = 33; //Удержание налога по купонам по ставке 15%.
|
||||
OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE = 34; //Удержание налога по дивидендам по ставке 15%.
|
||||
OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE = 35; //Удержание налога за материальную выгоду по ставке 15%.
|
||||
OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE = 36; //Корректировка налога по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO_PROGRESSIVE = 37; //Удержание налога за возмещение по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO = 38; //Удержание налога за возмещение по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_HOLD = 39; //Удержание налога по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_REFUND = 40; //Возврат налога по сделкам РЕПО.
|
||||
OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE = 41; //Удержание налога по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE = 42; //Возврат налога по сделкам РЕПО по ставке 15%.
|
||||
OPERATION_TYPE_DIV_EXT = 43; //Выплата дивидендов на карту.
|
||||
OPERATION_TYPE_TAX_CORRECTION_COUPON = 44; //Корректировка налога по купонам.
|
||||
OPERATION_TYPE_CASH_FEE = 45; //Комиссия за валютный остаток.
|
||||
OPERATION_TYPE_OUT_FEE = 46; //Комиссия за вывод валюты с брокерского счета.
|
||||
OPERATION_TYPE_OUT_STAMP_DUTY = 47; //Гербовый сбор.
|
||||
OPERATION_TYPE_OUTPUT_SWIFT = 50; // SWIFT-перевод.
|
||||
OPERATION_TYPE_INPUT_SWIFT = 51; // SWIFT-перевод.
|
||||
OPERATION_TYPE_OUTPUT_ACQUIRING = 53; // Перевод на карту.
|
||||
OPERATION_TYPE_INPUT_ACQUIRING = 54; // Перевод с карты.
|
||||
OPERATION_TYPE_OUTPUT_PENALTY = 55; // Комиссия за вывод средств.
|
||||
OPERATION_TYPE_ADVICE_FEE = 56; // Списание оплаты за сервис Советов.
|
||||
OPERATION_TYPE_TRANS_IIS_BS = 57; // Перевод ценных бумаг с ИИС на брокерский счет.
|
||||
OPERATION_TYPE_TRANS_BS_BS = 58; // Перевод ценных бумаг с одного брокерского счета на другой.
|
||||
OPERATION_TYPE_OUT_MULTI = 59; // Вывод денежных средств со счета.
|
||||
OPERATION_TYPE_INP_MULTI = 60; // Пополнение денежных средств со счета.
|
||||
OPERATION_TYPE_OVER_PLACEMENT = 61; // Размещение биржевого овернайта.
|
||||
OPERATION_TYPE_OVER_COM = 62; // Списание комиссии.
|
||||
OPERATION_TYPE_OVER_INCOME = 63; // Доход от оверанайта.
|
||||
OPERATION_TYPE_OPTION_EXPIRATION = 64; // Экспирация опциона.
|
||||
OPERATION_TYPE_FUTURE_EXPIRATION = 65; // Экспирация фьючерса.
|
||||
OPERATION_TYPE_OTHER_FEE = 66; // Прочие комиссии;
|
||||
OPERATION_TYPE_OTHER = 67; // Операция по счету;
|
||||
OPERATION_TYPE_DFA_REDEMPTION = 68; // погашение ЦФА-токена;
|
||||
OPERATION_TYPE_PRIMARY_ORDER =69; // отмена заявки на первичное размещение по ЦФА;
|
||||
OPERATION_TYPE_FUNDING = 70; //Списание фандинга.
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerRequest {
|
||||
oneof payload {
|
||||
GenerateDividendsForeignIssuerReportRequest generate_div_foreign_issuer_report = 1; //Объект запроса формирования отчета.
|
||||
GetDividendsForeignIssuerReportRequest get_div_foreign_issuer_report = 2; //Объект запроса сформированного отчета.
|
||||
}
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerResponse {
|
||||
oneof payload {
|
||||
GenerateDividendsForeignIssuerReportResponse generate_div_foreign_issuer_report_response = 1; //Объект результата задачи запуска формирования отчета.
|
||||
GetDividendsForeignIssuerReportResponse div_foreign_issuer_report = 2; //Отчет «Справка о доходах за пределами РФ».
|
||||
}
|
||||
}
|
||||
|
||||
//Объект запроса формирования отчета «Справка о доходах за пределами РФ».
|
||||
message GenerateDividendsForeignIssuerReportRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента.
|
||||
google.protobuf.Timestamp from = 2 [(google.api.field_behavior) = REQUIRED]; //Начало периода по UTC.
|
||||
google.protobuf.Timestamp to = 3 [(google.api.field_behavior) = REQUIRED]; //Окончание периода по UTC. Как правило, можно сформировать отчет по дату на несколько дней меньше текущей. Начало и окончание периода должны быть в рамках одного календарного года.
|
||||
}
|
||||
|
||||
// Объект запроса сформированного отчета «Справка о доходах за пределами РФ».
|
||||
message GetDividendsForeignIssuerReportRequest {
|
||||
string task_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор задачи формирования отчета.
|
||||
optional int32 page = 2; //Номер страницы отчета (начинается с 0), значение по умолчанию: 0.
|
||||
}
|
||||
|
||||
// Объект результата задачи запуска формирования отчета «Справка о доходах за пределами РФ».
|
||||
message GenerateDividendsForeignIssuerReportResponse {
|
||||
string task_id = 1; //Идентификатор задачи формирования отчета.
|
||||
}
|
||||
|
||||
message GetDividendsForeignIssuerReportResponse {
|
||||
repeated DividendsForeignIssuerReport dividends_foreign_issuer_report = 1;
|
||||
int32 itemsCount = 2; //Количество записей в отчете.
|
||||
int32 pagesCount = 3; //Количество страниц с данными отчета, начинается с 0.
|
||||
int32 page = 4; //Текущая страница, начинается с 0.
|
||||
}
|
||||
|
||||
// Отчет «Справка о доходах за пределами РФ».
|
||||
message DividendsForeignIssuerReport {
|
||||
google.protobuf.Timestamp record_date = 1; //Дата фиксации реестра.
|
||||
google.protobuf.Timestamp payment_date = 2; //Дата выплаты.
|
||||
string security_name = 3; //Наименование ценной бумаги.
|
||||
string isin = 4; //ISIN-идентификатор ценной бумаги.
|
||||
string issuer_country = 5; //Страна эмитента. Для депозитарных расписок указывается страна эмитента базового актива.
|
||||
int64 quantity = 6; //Количество ценных бумаг.
|
||||
Quotation dividend = 7; //Выплаты на одну бумагу
|
||||
Quotation external_commission = 8; //Комиссия внешних платежных агентов.
|
||||
Quotation dividend_gross = 9; //Сумма до удержания налога.
|
||||
Quotation tax = 10; //Сумма налога, удержанного агентом.
|
||||
Quotation dividend_amount = 11; //Итоговая сумма выплаты.
|
||||
string currency = 12; //Валюта.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения.
|
||||
message PortfolioStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по позициям и доходностям портфелей.
|
||||
message PortfolioStreamResponse {
|
||||
oneof payload {
|
||||
PortfolioSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
PortfolioResponse portfolio = 2; //Объект стриминга портфеля.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message PortfolioSubscriptionResult {
|
||||
repeated AccountSubscriptionStatus accounts = 1; //Массив счетов клиента.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Счет клиента.
|
||||
message AccountSubscriptionStatus {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
PortfolioSubscriptionStatus subscription_status = 6; //Результат подписки.
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum PortfolioSubscriptionStatus {
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
PORTFOLIO_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Запрос списка операций по счету с пагинацией.
|
||||
message GetOperationsByCursorRequest {
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED]; //Идентификатор счета клиента, обязательный параметр. Остальные параметры опциональны.
|
||||
optional string instrument_id = 2; //Идентификатор инструмента. Принимает значение `figi`, `instrument_uid` или `ticker + '_' + class_code`.
|
||||
optional google.protobuf.Timestamp from = 6; //Начало периода по UTC.
|
||||
optional google.protobuf.Timestamp to = 7; //Окончание периода по UTC.
|
||||
optional string cursor = 11; //Идентификатор элемента, с которого начать формировать ответ.
|
||||
optional int32 limit = 12; //Лимит количества операций. По умолчанию — `100`, максимальное значение — `1000`.
|
||||
repeated OperationType operation_types = 13; //Тип операции. Принимает значение из списка `OperationType`.
|
||||
optional OperationState state = 14; //Статус запрашиваемых операций. Возможные значения указаны в `OperationState`.
|
||||
optional bool without_commissions = 15; //Флаг возврата комиссии. По умолчанию — `false`.
|
||||
optional bool without_trades = 16; //Флаг получения ответа без массива сделок.
|
||||
optional bool without_overnights = 17; //Флаг показа overnight операций.
|
||||
}
|
||||
|
||||
//Список операций по счету с пагинацией.
|
||||
message GetOperationsByCursorResponse {
|
||||
bool has_next = 1; //Признак, есть ли следующий элемент.
|
||||
string next_cursor = 2; //Следующий курсор.
|
||||
repeated OperationItem items = 6; //Список операций.
|
||||
}
|
||||
|
||||
//Данные об операции.
|
||||
message OperationItem {
|
||||
string cursor = 1; //Курсор.
|
||||
string broker_account_id = 6; //Номер счета клиента.
|
||||
string id = 16; //Идентификатор операции, может меняться с течением времени.
|
||||
string parent_operation_id = 17; //Идентификатор родительской операции. Может измениться, если изменился ID родительской операции.
|
||||
string name = 18; //Название операции.
|
||||
google.protobuf.Timestamp date = 21; //Дата поручения.
|
||||
OperationType type = 22; //Тип операции.
|
||||
string description = 23; //Описание операции.
|
||||
OperationState state = 24; //Статус поручения.
|
||||
string instrument_uid = 31; //Уникальный идентификатор инструмента.
|
||||
string figi = 32; //FIGI.
|
||||
string instrument_type = 33; //Тип инструмента.
|
||||
InstrumentType instrument_kind = 34; //Тип инструмента.
|
||||
string position_uid = 35; //Уникальный идентификатор позиции.
|
||||
string ticker = 36; //Тикер инструмента.
|
||||
string class_code = 37; //Класс-код (секция торгов).
|
||||
MoneyValue payment = 41; //Сумма операции.
|
||||
MoneyValue price = 42; //Цена операции за 1 инструмент.
|
||||
MoneyValue commission = 43; //Комиссия.
|
||||
MoneyValue yield = 44; //Доходность.
|
||||
Quotation yield_relative = 45; //Относительная доходность.
|
||||
MoneyValue accrued_int = 46; //Накопленный купонный доход.
|
||||
int64 quantity = 51; //Количество единиц инструмента.
|
||||
int64 quantity_rest = 52; //Неисполненный остаток по сделке.
|
||||
int64 quantity_done = 53; //Исполненный остаток.
|
||||
google.protobuf.Timestamp cancel_date_time = 56; //Дата и время снятия заявки.
|
||||
string cancel_reason = 57; //Причина отмены операции.
|
||||
OperationItemTrades trades_info = 61; //Массив сделок.
|
||||
string asset_uid = 64; //Идентификатор актива.
|
||||
repeated ChildOperationItem child_operations = 65; //Массив дочерних операций.
|
||||
}
|
||||
|
||||
//Массив с информацией о сделках.
|
||||
message OperationItemTrades {
|
||||
repeated OperationItemTrade trades = 6;
|
||||
}
|
||||
|
||||
//Сделка по операции.
|
||||
message OperationItemTrade {
|
||||
string num = 1; //Номер сделки.
|
||||
google.protobuf.Timestamp date = 6; //Дата сделки.
|
||||
int64 quantity = 11; //Количество в единицах.
|
||||
MoneyValue price = 16; //Цена.
|
||||
MoneyValue yield = 21; //Доходность.
|
||||
Quotation yield_relative = 22; //Относительная доходность.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения позиций.
|
||||
message PositionsStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
bool with_initial_positions = 3; //Получение состояния позиций на момент подключения.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по изменению позиций портфеля.
|
||||
message PositionsStreamResponse {
|
||||
oneof payload {
|
||||
PositionsSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
PositionData position = 2; //Объект стриминга позиций.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
PositionsResponse initial_positions = 5; //Текущие позиции.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message PositionsSubscriptionResult {
|
||||
repeated PositionsSubscriptionStatus accounts = 1; //Массив счетов клиента.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Счет клиента.
|
||||
message PositionsSubscriptionStatus {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
PositionsAccountSubscriptionStatus subscription_status = 6; //Результат подписки.
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum PositionsAccountSubscriptionStatus {
|
||||
POSITIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
POSITIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Данные о позиции портфеля.
|
||||
message PositionData {
|
||||
string account_id = 1; //Идентификатор счета.
|
||||
repeated PositionsMoney money = 2; //Массив валютных позиций портфеля.
|
||||
repeated PositionsSecurities securities = 3; //Список ценно-бумажных позиций портфеля.
|
||||
repeated PositionsFutures futures = 4; //Список фьючерсов портфеля.
|
||||
repeated PositionsOptions options = 5; //Список опционов портфеля.
|
||||
google.protobuf.Timestamp date = 6; //Дата и время операции в формате UTC.
|
||||
}
|
||||
|
||||
//Валютная позиция портфеля.
|
||||
message PositionsMoney {
|
||||
MoneyValue available_value = 1; //Доступное количество валютный позиций.
|
||||
MoneyValue blocked_value = 2; //Заблокированное количество валютных позиций.
|
||||
}
|
||||
|
||||
message ChildOperationItem {
|
||||
string instrument_uid = 1; //Уникальный идентификатор инструмента.
|
||||
MoneyValue payment = 2; //Сумма операции.
|
||||
}
|
||||
|
||||
//Запрос установки stream-соединения операций.
|
||||
message OperationsStreamRequest {
|
||||
repeated string accounts = 1; //Массив идентификаторов счетов пользователя.
|
||||
PingDelaySettings ping_settings = 15; //Запрос настройки пинга.
|
||||
}
|
||||
|
||||
//Информация по операциям.
|
||||
message OperationsStreamResponse {
|
||||
oneof payload {
|
||||
OperationsSubscriptionResult subscriptions = 1; //Объект результата подписки.
|
||||
OperationData operation = 2; //Объект стриминга операций.
|
||||
Ping ping = 3; //Проверка активности стрима.
|
||||
}
|
||||
}
|
||||
|
||||
//Объект результата подписки.
|
||||
message OperationsSubscriptionResult {
|
||||
repeated string accounts = 1; //Массив счетов клиента.
|
||||
OperationsAccountSubscriptionStatus subscription_status = 2; //Результат подписки.
|
||||
string tracking_id = 7; //Уникальный идентификатор запроса, подробнее: [tracking_id](/invest/intro/developer/protocols/grpc#tracking-id).
|
||||
string stream_id = 8; //Идентификатор открытого соединения
|
||||
}
|
||||
|
||||
//Результат подписки.
|
||||
enum OperationsAccountSubscriptionStatus {
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_UNSPECIFIED = 0; //Тип не определен.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_SUCCESS = 1; //Успешно.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_ACCOUNT_NOT_FOUND = 2; //Счет не найден или недостаточно прав.
|
||||
OPERATIONS_SUBSCRIPTION_STATUS_INTERNAL_ERROR = 3; //Произошла ошибка.
|
||||
}
|
||||
|
||||
//Данные об операции.
|
||||
message OperationData {
|
||||
string broker_account_id = 1; //Идентификатор счета.
|
||||
string id = 2; //Номер поручения.
|
||||
string parent_operation_id = 3; //Номер родительского поручения.
|
||||
string name = 4; //Название инструмента.
|
||||
google.protobuf.Timestamp date = 5; //Дата.
|
||||
OperationType type = 6; //Тип операции.
|
||||
OperationState state = 7; //Статус поручения.
|
||||
string instrument_uid = 8; //Уникальный идентификатор инструмента.
|
||||
string figi = 9; //FIGI-идентификатор инструмента.
|
||||
string instrument_type = 10; //Тип инструмента.
|
||||
InstrumentType instrument_kind = 11; //Тип инструмента.
|
||||
string position_uid = 12; //Идентификатор позиции.
|
||||
string ticker = 13; //Тикер инструмента.
|
||||
string class_code = 14; //Класс-код (секция торгов).
|
||||
MoneyValue payment = 15; //Сумма операции.
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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 "google/api/field_behavior.proto";
|
||||
import "common.proto";
|
||||
|
||||
service UsersService { /*С помощью сервиса можно получить: <br/> 1.
|
||||
список счетов пользователя; <br/> 2. маржинальные показатели по счeту.*/
|
||||
|
||||
//GetAccounts — счета пользователя
|
||||
//Получить список счетов.
|
||||
rpc GetAccounts (GetAccountsRequest) returns (GetAccountsResponse);
|
||||
|
||||
//GetMarginAttributes — маржинальные показатели по счeту
|
||||
//Метод позволяет получить маржинальные показатели и ликвидность по заданному счeту.
|
||||
rpc GetMarginAttributes (GetMarginAttributesRequest) returns (GetMarginAttributesResponse);
|
||||
|
||||
//GetUserTariff — тариф пользователя
|
||||
//Получить информацию о текущих лимитах на подклчение, согласно текущему тарифу пользователя.
|
||||
rpc GetUserTariff (GetUserTariffRequest) returns (GetUserTariffResponse);
|
||||
|
||||
//GetInfo — информация о пользователе
|
||||
//Получить информацию о пользователе: тариф, признак квалификации, пройденные тесты и др.
|
||||
rpc GetInfo (GetInfoRequest) returns (GetInfoResponse);
|
||||
|
||||
//GetBankAccounts — банковские счета пользователя
|
||||
//Получить список счетов пользователя, в том числе и банковских.
|
||||
rpc GetBankAccounts(GetBankAccountsRequest) returns (GetBankAccountsResponse);
|
||||
|
||||
//CurrencyTransfer — перевод денежных средств между счетами
|
||||
//Перевести денежные средства между брокерскими счетами
|
||||
rpc CurrencyTransfer(CurrencyTransferRequest) returns (CurrencyTransferResponse);
|
||||
|
||||
//PayIn — пополнение брокерского счета
|
||||
//Пополнить брокерский счёт с банковского
|
||||
rpc PayIn(PayInRequest) returns (PayInResponse);
|
||||
|
||||
//GetAccountValues — дополнительные показатели счетов
|
||||
//Метод предназначен для получения дополнительных показателей счетов
|
||||
rpc GetAccountValues(GetAccountValuesRequest) returns (GetAccountValuesResponse);
|
||||
}
|
||||
|
||||
//Запрос получения счетов пользователя.
|
||||
message GetAccountsRequest {
|
||||
optional AccountStatus status = 1; //Статус счета.
|
||||
}
|
||||
|
||||
//Список счетов пользователя.
|
||||
message GetAccountsResponse {
|
||||
// Массив счетов клиента.
|
||||
repeated Account accounts = 1;
|
||||
}
|
||||
|
||||
//Информация о счeте.
|
||||
message Account {
|
||||
|
||||
// Идентификатор счeта.
|
||||
string id = 1;
|
||||
|
||||
// Тип счeта.
|
||||
AccountType type = 2;
|
||||
|
||||
// Название счeта.
|
||||
string name = 3;
|
||||
|
||||
// Статус счeта.
|
||||
AccountStatus status = 4;
|
||||
|
||||
// Дата открытия счeта в часовом поясе UTC.
|
||||
google.protobuf.Timestamp opened_date = 5;
|
||||
|
||||
// Дата закрытия счeта в часовом поясе UTC.
|
||||
google.protobuf.Timestamp closed_date = 6;
|
||||
|
||||
// Уровень доступа к текущему счeту (определяется токеном).
|
||||
AccessLevel access_level = 7;
|
||||
}
|
||||
|
||||
//Тип счeта.
|
||||
enum AccountType {
|
||||
ACCOUNT_TYPE_UNSPECIFIED = 0; //Тип аккаунта не определeн.
|
||||
ACCOUNT_TYPE_TINKOFF = 1; //Брокерский счeт Т-Инвестиций.
|
||||
ACCOUNT_TYPE_TINKOFF_IIS = 2; //ИИС.
|
||||
ACCOUNT_TYPE_INVEST_BOX = 3; //Инвесткопилка.
|
||||
ACCOUNT_TYPE_INVEST_FUND = 4; //Фонд денежного рынка.
|
||||
ACCOUNT_TYPE_DEBIT = 5; //Дебетовый карточный счeт.
|
||||
ACCOUNT_TYPE_SAVING = 6; //Накопительный счeт.
|
||||
ACCOUNT_TYPE_DFA = 7; //Смарт-счет.
|
||||
}
|
||||
|
||||
//Статус счeта.
|
||||
enum AccountStatus {
|
||||
ACCOUNT_STATUS_UNSPECIFIED = 0; //Статус счeта не определeн.
|
||||
ACCOUNT_STATUS_NEW = 1; //Новый, в процессе открытия.
|
||||
ACCOUNT_STATUS_OPEN = 2; //Открытый и активный счeт.
|
||||
ACCOUNT_STATUS_CLOSED = 3; //Закрытый счeт.
|
||||
ACCOUNT_STATUS_ALL = 4; //Все счета.
|
||||
}
|
||||
|
||||
//Запрос маржинальных показателей по счeту.
|
||||
message GetMarginAttributesRequest {
|
||||
|
||||
// Идентификатор счeта пользователя.
|
||||
string account_id = 1 [(google.api.field_behavior) = REQUIRED];
|
||||
}
|
||||
|
||||
//Маржинальные показатели по счeту.
|
||||
message GetMarginAttributesResponse {
|
||||
|
||||
// Ликвидная стоимость портфеля. [Подробнее про ликвидный портфель](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q4).
|
||||
MoneyValue liquid_portfolio = 1;
|
||||
|
||||
// Начальная маржа — начальное обеспечение для совершения новой сделки. [Подробнее про начальную и минимальную маржу](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q6).
|
||||
MoneyValue starting_margin = 2;
|
||||
|
||||
// Минимальная маржа — это минимальное обеспечение для поддержания позиции, которую вы уже открыли. [Подробнее про начальную и минимальную маржу](https://www.tbank.ru/invest/help/brokerage/account/margin/about/#q6).
|
||||
MoneyValue minimal_margin = 3;
|
||||
|
||||
// Уровень достаточности средств. Соотношение стоимости ликвидного портфеля к начальной марже.
|
||||
Quotation funds_sufficiency_level = 4;
|
||||
|
||||
// Объем недостающих средств. Разница между стартовой маржой и ликвидной стоимости портфеля.
|
||||
MoneyValue amount_of_missing_funds = 5;
|
||||
|
||||
// Скорректированная маржа. Начальная маржа, в которой плановые позиции рассчитываются с учeтом активных заявок на покупку позиций лонг или продажу позиций шорт.
|
||||
MoneyValue corrected_margin = 6;
|
||||
|
||||
// Размер гарантийного обеспечения, заблокированного под фьючерсы.
|
||||
MoneyValue guarantee_for_futures = 7;
|
||||
}
|
||||
|
||||
//Запрос текущих лимитов пользователя.
|
||||
message GetUserTariffRequest {
|
||||
}
|
||||
|
||||
//Текущие лимиты пользователя.
|
||||
message GetUserTariffResponse {
|
||||
repeated UnaryLimit unary_limits = 1; //Массив лимитов пользователя по unary-запросам.
|
||||
repeated StreamLimit stream_limits = 2; //Массив лимитов пользователей для stream-соединений.
|
||||
}
|
||||
|
||||
//Лимит unary-методов.
|
||||
message UnaryLimit {
|
||||
int32 limit_per_minute = 1; //Количество unary-запросов в минуту.
|
||||
repeated string methods = 2; //Названия методов.
|
||||
optional int32 limit_per_second = 3; //Количество unary-запросов в секунду.
|
||||
}
|
||||
|
||||
//Лимит stream-соединений.
|
||||
message StreamLimit {
|
||||
int32 limit = 1; //Максимальное количество stream-соединений.
|
||||
repeated string streams = 2; //Названия stream-методов.
|
||||
int32 open = 3; //Текущее количество открытых stream-соединений.
|
||||
}
|
||||
|
||||
//Запрос информации о пользователе.
|
||||
message GetInfoRequest {
|
||||
}
|
||||
|
||||
//Информация о пользователе.
|
||||
message GetInfoResponse {
|
||||
bool prem_status = 1; //Признак премиум клиента.
|
||||
bool qual_status = 2; //Признак квалифицированного инвестора.
|
||||
repeated string qualified_for_work_with = 3; //Набор требующих тестирования инструментов и возможностей, с которыми может работать пользователь. [Подробнее](/invest/services/accounts/faq_users).
|
||||
string tariff = 4; //Наименование тарифа пользователя.
|
||||
string user_id = 9; //Идентификатор пользователя.
|
||||
string risk_level_code = 12; // Категория риска.
|
||||
}
|
||||
|
||||
//Уровень доступа к счeту.
|
||||
enum AccessLevel {
|
||||
ACCOUNT_ACCESS_LEVEL_UNSPECIFIED = 0; //Уровень доступа не определeн.
|
||||
ACCOUNT_ACCESS_LEVEL_FULL_ACCESS = 1; //Полный доступ к счeту.
|
||||
ACCOUNT_ACCESS_LEVEL_READ_ONLY = 2; //Доступ с уровнем прав «только чтение».
|
||||
ACCOUNT_ACCESS_LEVEL_NO_ACCESS = 3; //Доступа нет.
|
||||
}
|
||||
|
||||
//Запрос списка банковских счетов пользователя.
|
||||
message GetBankAccountsRequest {
|
||||
}
|
||||
|
||||
//Список банковских счетов пользователя.
|
||||
message GetBankAccountsResponse {
|
||||
repeated BankAccount bank_accounts = 1; //Массив банковских счетов.
|
||||
}
|
||||
|
||||
//Банковский счeт.
|
||||
message BankAccount {
|
||||
string id = 1; //Идентификатор счeта.
|
||||
string name = 2; //Название счeта.
|
||||
repeated MoneyValue money = 3; //Список валютных позиций на счeте.
|
||||
google.protobuf.Timestamp opened_date = 4; //Дата открытия счeта в часовом поясе UTC.
|
||||
AccountType type = 5; //Тип счeта.
|
||||
}
|
||||
|
||||
message CurrencyTransferRequest {
|
||||
string from_account_id = 1 [(google.api.field_behavior) = REQUIRED]; // Номер счета списания.
|
||||
string to_account_id = 2 [(google.api.field_behavior) = REQUIRED]; // Номер счета зачисления.
|
||||
MoneyValue amount = 3 [(google.api.field_behavior) = REQUIRED]; // Сумма перевода с указанием валюты.
|
||||
string transaction_id = 4; // Идентификатор запроса выставления поручения для целей идемпотентности в формате UUID.
|
||||
}
|
||||
|
||||
message CurrencyTransferResponse {
|
||||
|
||||
}
|
||||
|
||||
message PayInRequest {
|
||||
string from_account_id = 1 [(google.api.field_behavior) = REQUIRED]; // Номер счета списания.
|
||||
string to_account_id = 2 [(google.api.field_behavior) = REQUIRED]; // Номер брокерского счета зачисления.
|
||||
MoneyValue amount = 3 [(google.api.field_behavior) = REQUIRED]; // Сумма перевода с указанием валюты.
|
||||
}
|
||||
|
||||
message PayInResponse {
|
||||
|
||||
}
|
||||
|
||||
enum AccountValue {
|
||||
ACCOUNT_VALUE_UNSPECIFIED = 0; // Не определён.
|
||||
ACCOUNT_VALUE_MARGIN_FEE = 1; // Размер комиссии за маржинальное кредитование.
|
||||
ACCOUNT_VALUE_AMOUNT_WITHOUT_EXTRA_FEE = 2; // Остаток доступного лимита с текущей комиссией.
|
||||
}
|
||||
|
||||
message GetAccountValuesRequest {
|
||||
repeated string accounts = 1; // Массив счетов пользователя.
|
||||
repeated AccountValue values = 2; // Массив запрашиваемых параметров.
|
||||
}
|
||||
|
||||
message GetAccountValuesResponse {
|
||||
repeated AccountValuesWithParameters accounts = 1; // Массив счетов с параметрами.
|
||||
}
|
||||
|
||||
message AccountValuesWithParameters {
|
||||
string account_id = 1; // Номер счета.
|
||||
repeated InstrumentParameter values = 2; // Массив параметров инструмента.
|
||||
}
|
||||
|
||||
message InstrumentParameter {
|
||||
AccountValue name = 1; // Тип запрашиваемого параметра.
|
||||
MoneyValue value = 2; // Значение запрашиваемого параметра.
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
jest.mock('../../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import type { TbankAccountContext } from './tbank-access.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
describe('TbankAccessService', () => {
|
||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
||||
const token = {
|
||||
decrypt: jest.fn(),
|
||||
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
|
||||
new TbankTokenService({
|
||||
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
||||
} as never).translateInvalidToken(operation),
|
||||
),
|
||||
};
|
||||
const tbank = { getOpenAccounts: jest.fn() };
|
||||
const service = new TbankAccessService(
|
||||
prisma as never,
|
||||
token as never,
|
||||
tbank as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||
encryptedToken: 'encrypted',
|
||||
});
|
||||
token.decrypt.mockReturnValue('plain-token');
|
||||
tbank.getOpenAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 'account',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
name: ' Основной ',
|
||||
openedDate: { seconds: '1723507200' },
|
||||
},
|
||||
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists supported accounts through the saved decrypted token', async () => {
|
||||
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
|
||||
{
|
||||
id: 'account',
|
||||
type: 'BROKERAGE',
|
||||
name: 'Основной',
|
||||
openedOn: '2024-08-13',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: 'user' },
|
||||
});
|
||||
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
|
||||
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
|
||||
});
|
||||
|
||||
it('rejects missing connections before calling T-Bank', async () => {
|
||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the persisted connection generation without an upstream account call', async () => {
|
||||
const context = await service.withConnection('user', (value) => value);
|
||||
|
||||
expect(context).toEqual({
|
||||
token: 'plain-token',
|
||||
generation: expect.any(String) as string,
|
||||
});
|
||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||
new Error('INVALID_TBANK_TOKEN'),
|
||||
);
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
|
||||
const unavailable = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
|
||||
await expect(service.listSupportedAccounts('user')).rejects.toBe(
|
||||
unavailable,
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps account-scoped handlers with resolved account access', async () => {
|
||||
const handler = jest.fn((context: TbankAccountContext) =>
|
||||
Promise.resolve({
|
||||
resolvedToken: context.token,
|
||||
generation: context.generation,
|
||||
accountId: context.account.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.withAccount('user', 'account', handler),
|
||||
).resolves.toEqual({
|
||||
resolvedToken: 'plain-token',
|
||||
generation: expect.any(String) as string,
|
||||
accountId: 'account',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
|
||||
const generation = () =>
|
||||
service.withAccount('user', 'account', ({ generation: value }) =>
|
||||
Promise.resolve(value),
|
||||
);
|
||||
prisma.tbankConnection.findUnique
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
|
||||
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
|
||||
|
||||
const first = await generation();
|
||||
const sameRecord = await generation();
|
||||
const replaced = await generation();
|
||||
|
||||
expect(sameRecord).toBe(first);
|
||||
expect(replaced).not.toBe(first);
|
||||
expect(first).not.toContain('plain-token');
|
||||
expect(first).not.toContain('encrypted:first-nonce');
|
||||
});
|
||||
|
||||
it('rejects inaccessible accounts before running the handler', async () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
await expect(
|
||||
service.withAccount('user', 'missing', handler),
|
||||
).rejects.toEqual(expect.any(NotFoundException));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
|
||||
await expect(
|
||||
service.withAccount('user', 'account', () =>
|
||||
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
|
||||
),
|
||||
).rejects.toEqual(expect.any(BadRequestException));
|
||||
|
||||
const failure = new Error('source failed');
|
||||
await expect(
|
||||
service.withAccount('user', 'account', () => Promise.reject(failure)),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
SupportedTbankAccount,
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankAccount, TbankClient } from './tbank.client';
|
||||
|
||||
export type TbankConnectionContext = {
|
||||
token: string;
|
||||
generation: string;
|
||||
};
|
||||
|
||||
export type TbankAccountContext = TbankConnectionContext & {
|
||||
account: TbankAccount;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TbankAccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tokens: TbankTokenService,
|
||||
private readonly tbank: TbankClient,
|
||||
) {}
|
||||
|
||||
async listSupportedAccounts(
|
||||
userId: string,
|
||||
): Promise<SupportedTbankAccount[]> {
|
||||
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
|
||||
}
|
||||
|
||||
async withSupportedAccounts<T>(
|
||||
userId: string,
|
||||
handler: (context: {
|
||||
token: string;
|
||||
generation: string;
|
||||
accounts: SupportedTbankAccount[];
|
||||
}) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
return this.withConnection(userId, async ({ token, generation }) =>
|
||||
handler({
|
||||
token,
|
||||
generation,
|
||||
accounts: supportedTbankAccounts(
|
||||
await this.tbank.getOpenAccounts(token),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async withConnection<T>(
|
||||
userId: string,
|
||||
handler: (context: TbankConnectionContext) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const connection = await this.connection(userId);
|
||||
return this.tokens.translateInvalidToken(async () => handler(connection));
|
||||
}
|
||||
|
||||
async withAccount<T>(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.withConnection(userId, (connection) =>
|
||||
this.withAccountForConnection(connection, accountId, handler),
|
||||
);
|
||||
}
|
||||
|
||||
async withAccountForConnection<T>(
|
||||
connection: TbankConnectionContext,
|
||||
accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { token, generation } = connection;
|
||||
return this.tokens.translateInvalidToken(async () => {
|
||||
const account = accessibleTbankAccount(
|
||||
await this.tbank.getOpenAccounts(token),
|
||||
accountId,
|
||||
);
|
||||
if (!account) throw new NotFoundException();
|
||||
return handler({ token, generation, account });
|
||||
});
|
||||
}
|
||||
|
||||
private async connection(
|
||||
userId: string,
|
||||
): Promise<{ token: string; generation: string }> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!connection)
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
return {
|
||||
token: this.tokens.decrypt(connection.encryptedToken),
|
||||
generation: createHash('sha256')
|
||||
.update(connection.encryptedToken)
|
||||
.digest('base64url'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
accessibleTbankAccount,
|
||||
supportedTbankAccounts,
|
||||
} from './tbank-account.policy';
|
||||
|
||||
describe('tbank-account-policy', () => {
|
||||
it('normalizes supported account types and fallback names', () => {
|
||||
expect(
|
||||
supportedTbankAccounts([
|
||||
{
|
||||
id: 'brokerage',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
name: ' Основной ',
|
||||
openedDate: { seconds: '1723507200' },
|
||||
},
|
||||
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Дебетовый' },
|
||||
{ id: 'iis', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: ' ' },
|
||||
{ id: 'box', type: 'ACCOUNT_TYPE_INVEST_BOX', name: ' ' },
|
||||
{ id: 'fund', type: 'ACCOUNT_TYPE_INVEST_FUND', name: ' ' },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'brokerage',
|
||||
type: 'BROKERAGE',
|
||||
name: 'Основной',
|
||||
openedOn: '2024-08-13',
|
||||
},
|
||||
{
|
||||
id: 'iis',
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
openedOn: null,
|
||||
},
|
||||
{
|
||||
id: 'box',
|
||||
type: 'INVEST_BOX',
|
||||
name: 'Инвесткопилка',
|
||||
openedOn: null,
|
||||
},
|
||||
{
|
||||
id: 'fund',
|
||||
type: 'MONEY_MARKET_FUND',
|
||||
name: 'Фонд денежного рынка',
|
||||
openedOn: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches account-scoped access only for supported account types', () => {
|
||||
expect(
|
||||
accessibleTbankAccount(
|
||||
[
|
||||
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
|
||||
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
|
||||
],
|
||||
'account',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
accessibleTbankAccount(
|
||||
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
|
||||
'account',
|
||||
),
|
||||
).toEqual({
|
||||
id: 'account',
|
||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
||||
name: 'Brokerage',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
},
|
||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
ACCOUNT_TYPE_INVEST_FUND: {
|
||||
type: 'MONEY_MARKET_FUND',
|
||||
name: 'Фонд денежного рынка',
|
||||
},
|
||||
} as const;
|
||||
|
||||
type AccountType = keyof typeof accountTypes;
|
||||
export type SupportedTbankAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: (typeof accountTypes)[AccountType]['type'];
|
||||
openedOn: string | null;
|
||||
};
|
||||
|
||||
export function supportedTbankAccounts(
|
||||
accounts: TbankAccount[],
|
||||
): SupportedTbankAccount[] {
|
||||
return accounts.flatMap((account) => {
|
||||
const details = accountTypes[account.type as AccountType];
|
||||
if (!details) return [];
|
||||
return [
|
||||
{
|
||||
id: account.id,
|
||||
name: account.name.trim() || details.name,
|
||||
type: details.type,
|
||||
openedOn: openedOn(account.openedDate?.seconds),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function accessibleTbankAccount(
|
||||
accounts: TbankAccount[],
|
||||
accountId: string,
|
||||
): TbankAccount | null {
|
||||
return (
|
||||
accounts.find(
|
||||
(account) => account.id === accountId && account.type in accountTypes,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function openedOn(seconds: string | number | undefined): string | null {
|
||||
if (seconds === undefined) return null;
|
||||
const date = new Date(Number(seconds) * 1000);
|
||||
if (Number.isNaN(date.valueOf())) return null;
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
TbankDecimal,
|
||||
TbankInstrument,
|
||||
TbankMoney,
|
||||
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;
|
||||
};
|
||||
|
||||
export type TbankBond = Omit<TbankShare, 'shareType'> & {
|
||||
nominal?: TbankMoney;
|
||||
initialNominal?: TbankMoney;
|
||||
aciValue?: TbankMoney;
|
||||
maturityDate?: TbankTimestamp;
|
||||
callDate?: TbankTimestamp;
|
||||
couponQuantityPerYear?: number;
|
||||
floatingCouponFlag?: boolean;
|
||||
perpetualFlag?: boolean;
|
||||
amortizationFlag?: boolean;
|
||||
subordinatedFlag?: boolean;
|
||||
bondType?: string;
|
||||
riskLevel?: string;
|
||||
};
|
||||
export type TbankCoupon = {
|
||||
couponNumber?: string;
|
||||
couponDate?: TbankTimestamp;
|
||||
fixDate?: TbankTimestamp;
|
||||
payOneBond?: TbankMoney;
|
||||
couponType?: string;
|
||||
couponStartDate?: TbankTimestamp;
|
||||
couponEndDate?: TbankTimestamp;
|
||||
couponPeriod?: number;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
describe('TbankTokenService', () => {
|
||||
const config = {
|
||||
getOrThrow: jest.fn(() => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='),
|
||||
};
|
||||
let service: TbankTokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new TbankTokenService(config as never);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts a token through the configured crypto', () => {
|
||||
const encrypted = service.encrypt('secret-token');
|
||||
|
||||
expect(encrypted).not.toContain('secret-token');
|
||||
expect(service.decrypt(encrypted)).toBe('secret-token');
|
||||
expect(config.getOrThrow).toHaveBeenCalledWith('TOKEN_ENCRYPTION_KEY');
|
||||
});
|
||||
|
||||
it('translates invalid T-Bank token errors and preserves unrelated failures', async () => {
|
||||
await expect(
|
||||
service.translateInvalidToken(() =>
|
||||
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
|
||||
),
|
||||
).rejects.toEqual(expect.any(BadRequestException));
|
||||
|
||||
const unavailable = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
await expect(
|
||||
service.translateInvalidToken(() => Promise.reject(unavailable)),
|
||||
).rejects.toBe(unavailable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@Injectable()
|
||||
export class TbankTokenService {
|
||||
private readonly crypto: TbankCrypto;
|
||||
|
||||
constructor(config: ConfigService<Environment, true>) {
|
||||
this.crypto = TbankCrypto.fromBase64(
|
||||
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
encrypt(value: string): string {
|
||||
return this.crypto.encrypt(value);
|
||||
}
|
||||
|
||||
decrypt(payload: string): string {
|
||||
return this.crypto.decrypt(payload);
|
||||
}
|
||||
|
||||
async translateInvalidToken<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||
export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
export {
|
||||
TbankAccessService,
|
||||
type TbankConnectionContext,
|
||||
} from './tbank-access.service';
|
||||
export {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
type TbankFundamentals,
|
||||
type TbankShare,
|
||||
} from './tbank-market.types';
|
||||
export { TbankTokenService } from './tbank-token.service';
|
||||
export {
|
||||
TbankClient,
|
||||
type TbankDividend,
|
||||
type TbankInstrument,
|
||||
type TbankMoney,
|
||||
type TbankOperationItem,
|
||||
type TbankPortfolio,
|
||||
type TbankPositions,
|
||||
type TbankTimestamp,
|
||||
} from './tbank.client';
|
||||
@@ -0,0 +1,306 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
const request = {
|
||||
accountId: 'account',
|
||||
from: { seconds: '1' },
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({ items: [] });
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
|
||||
await expect(
|
||||
client.getOperationsByCursor('token', request),
|
||||
).resolves.toEqual({
|
||||
items: [],
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'token',
|
||||
undefined,
|
||||
'getOperationsByCursor',
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a GetDividends request to InstrumentsService', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const call = jest.fn().mockResolvedValue({
|
||||
dividends: [{ dividendType: 'Regular Cash' }],
|
||||
});
|
||||
(client as unknown as { call: jest.Mock }).call = call;
|
||||
|
||||
await expect(
|
||||
client.getDividends('token', 'uid', '2026-08-19', '2026-09-18'),
|
||||
).resolves.toEqual([{ dividendType: 'Regular Cash' }]);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'getDividends', {
|
||||
instrumentId: 'uid',
|
||||
from: {
|
||||
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||
},
|
||||
to: {
|
||||
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates the normalized upstream error', async () => {
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
(client as unknown as { call: jest.Mock }).call = jest
|
||||
.fn()
|
||||
.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getOperationsByCursor('token', request)).rejects.toBe(
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs success, retry, and final failure with the RPC context', async () => {
|
||||
jest.useFakeTimers();
|
||||
const logger = { event: jest.fn(), info: jest.fn() };
|
||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||
(client as unknown as { logger: unknown }).logger = logger;
|
||||
(client as unknown as { queue: unknown }).queue = {
|
||||
size: 0,
|
||||
run: (operation: () => unknown) => operation(),
|
||||
};
|
||||
const invoke = (...args: [string, unknown, string, object]) =>
|
||||
(
|
||||
client as unknown as {
|
||||
call: (
|
||||
token: string,
|
||||
service: unknown,
|
||||
method: string,
|
||||
input: object,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
).call(...args);
|
||||
const requestRpc = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE });
|
||||
(client as unknown as { request: jest.Mock }).request = requestRpc;
|
||||
|
||||
await expect(invoke('token', {}, 'getPortfolio', {})).resolves.toEqual({
|
||||
ok: true,
|
||||
});
|
||||
const failed = invoke('token', {}, 'getPortfolio', {});
|
||||
const rejected = expect(failed).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
await rejected;
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'debug',
|
||||
'tbank.rpc.completed',
|
||||
expect.objectContaining({ operation: 'getPortfolio', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 1 }),
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ grpcCode: status.UNAVAILABLE, attempts: 2 }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient metadata retries', () => {
|
||||
const instrument = { name: 'Облигация', ticker: 'BOND' };
|
||||
let client: TbankClient;
|
||||
let requestRpc: jest.Mock;
|
||||
let queue: { size: number; run: jest.Mock };
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
requestRpc = jest.fn();
|
||||
queue = {
|
||||
size: 0,
|
||||
run: jest.fn((operation: () => Promise<unknown>) => operation()),
|
||||
};
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, unknown>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{
|
||||
request: requestRpc,
|
||||
queue,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
])(
|
||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
||||
async (code) => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(3999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
||||
queue.size = 50;
|
||||
requestRpc.mockResolvedValue({ instrument });
|
||||
|
||||
const result = client.getInstrumentByFigi('token', 'figi');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
queue.size = 49;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
||||
});
|
||||
|
||||
it('does not bypass queue admission after an upstream failure', async () => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
queue.size = 50;
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
queue.size = 0;
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||
queue.size = 50;
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||
expect(logger.event).toHaveBeenLastCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
||||
);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||
const result = expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(6000);
|
||||
await result;
|
||||
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([status.UNAUTHENTICATED, status.PERMISSION_DENIED])(
|
||||
'does not retry or mask metadata authentication status %s',
|
||||
async (code) => {
|
||||
requestRpc.mockRejectedValue({ code });
|
||||
|
||||
await expect(client.getInstrumentByUid('token', 'uid')).rejects.toThrow(
|
||||
'INVALID_TBANK_TOKEN',
|
||||
);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['getInstrumentByUid', 'getInstrumentByFigi'] as const)(
|
||||
'returns confirmed absence from %s without retrying',
|
||||
async (method) => {
|
||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||
|
||||
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps confirmed absence from ShareBy as a not-found error', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||
|
||||
await expect(client.getShare('token', 'MISSING')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not retry invalid metadata arguments', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.INVALID_ARGUMENT });
|
||||
|
||||
await expect(
|
||||
client.getInstrumentByUid('token', 'uid'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps other RPCs failing immediately when the queue is full', async () => {
|
||||
queue.size = 50;
|
||||
|
||||
await expect(
|
||||
client.getPortfolio('token', 'account'),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
import {
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientUnaryCall,
|
||||
loadPackageDefinition,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
status,
|
||||
} from '@grpc/grpc-js';
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankCandle,
|
||||
TbankCoupon,
|
||||
TbankFundamentals,
|
||||
TbankLastPrice,
|
||||
TbankShare,
|
||||
} from './tbank-market.types';
|
||||
|
||||
const requestDeadlineMs = 15_000;
|
||||
|
||||
type Unary<Request, Response> = (
|
||||
request: Request,
|
||||
metadata: Metadata,
|
||||
options: { deadline: Date },
|
||||
callback: (error: ServiceError | null, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
export type TbankDecimal = { units?: string | number; nano?: string | number };
|
||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
openedDate?: { seconds?: string | number };
|
||||
};
|
||||
export type TbankPortfolio = {
|
||||
positions?: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
quantity?: TbankDecimal;
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
}[];
|
||||
};
|
||||
export type TbankPositions = { money?: TbankMoney[] };
|
||||
export type TbankInstrument = {
|
||||
ticker?: string;
|
||||
name?: string;
|
||||
isin?: string;
|
||||
brand?: { logoName?: string | null };
|
||||
};
|
||||
export type TbankDividend = {
|
||||
dividendNet?: TbankMoney;
|
||||
recordDate?: TbankTimestamp;
|
||||
paymentDate?: TbankTimestamp;
|
||||
lastBuyDate?: TbankTimestamp;
|
||||
dividendType?: string;
|
||||
};
|
||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||
export type TbankOperationItem = {
|
||||
cursor?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
date?: TbankTimestamp;
|
||||
type?: string;
|
||||
description?: string;
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
ticker?: string;
|
||||
payment?: TbankMoney;
|
||||
price?: TbankMoney;
|
||||
commission?: TbankMoney;
|
||||
accruedInt?: TbankMoney;
|
||||
quantity?: string | number;
|
||||
quantityDone?: string | number;
|
||||
};
|
||||
export type TbankOperationsByCursorRequest = {
|
||||
accountId: string;
|
||||
instrumentId?: string;
|
||||
from?: TbankTimestamp;
|
||||
to?: TbankTimestamp;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
hasNext?: boolean;
|
||||
nextCursor?: string;
|
||||
items?: TbankOperationItem[];
|
||||
};
|
||||
type TbankNamespace = {
|
||||
tinkoff: {
|
||||
public: { invest: { api: { contract: { v1: Record<string, unknown> } } } };
|
||||
};
|
||||
};
|
||||
|
||||
class RateLimiter {
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
private starts = 0;
|
||||
private windowStartedAt = Date.now();
|
||||
|
||||
constructor(private readonly perSecond: number) {}
|
||||
|
||||
get size(): number {
|
||||
return this.waiting.length;
|
||||
}
|
||||
|
||||
async run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiting.push(resolve);
|
||||
this.drain();
|
||||
});
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
this.drain();
|
||||
}
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.windowStartedAt >= 1000) {
|
||||
this.windowStartedAt = now;
|
||||
this.starts = 0;
|
||||
}
|
||||
while (this.starts < this.perSecond && this.waiting.length) {
|
||||
this.starts += 1;
|
||||
this.waiting.shift()?.();
|
||||
}
|
||||
if (this.waiting.length && this.starts === this.perSecond) {
|
||||
setTimeout(
|
||||
() => this.drain(),
|
||||
Math.max(1, 1000 - (now - this.windowStartedAt)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TbankClient {
|
||||
private readonly queue: RateLimiter;
|
||||
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>,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {
|
||||
const sourceProtoRoot = join(
|
||||
process.cwd(),
|
||||
'src/integrations/tbank/proto/contracts',
|
||||
);
|
||||
const protoRoot = existsSync(sourceProtoRoot)
|
||||
? sourceProtoRoot
|
||||
: join(process.cwd(), 'dist/integrations/tbank/proto/contracts');
|
||||
const definition = loadSync(
|
||||
[
|
||||
'users.proto',
|
||||
'operations.proto',
|
||||
'instruments.proto',
|
||||
'marketdata.proto',
|
||||
].map((file) => join(protoRoot, file)),
|
||||
{
|
||||
includeDirs: [protoRoot],
|
||||
keepCase: false,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
},
|
||||
);
|
||||
const services = (
|
||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||
const credentials = caPath
|
||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||
: ChannelCredentials.createSsl();
|
||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||
const create = (name: string) =>
|
||||
new (
|
||||
services[name] as new (
|
||||
address: string,
|
||||
credentials: ChannelCredentials,
|
||||
) => TbankClientService
|
||||
)(endpoint, credentials);
|
||||
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'),
|
||||
);
|
||||
this.logger.info('tbank.client.configured');
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<void> {
|
||||
await this.getOpenAccounts(token);
|
||||
}
|
||||
|
||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||
return this.call<{ accounts?: TbankAccount[] }>(
|
||||
token,
|
||||
this.users,
|
||||
'getAccounts',
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
).then((response) => response.accounts ?? []);
|
||||
}
|
||||
|
||||
getPortfolio(token: string, accountId: string): Promise<TbankPortfolio> {
|
||||
return this.call(token, this.operations, 'getPortfolio', { accountId });
|
||||
}
|
||||
|
||||
getPositions(token: string, accountId: string): Promise<TbankPositions> {
|
||||
return this.call(token, this.operations, 'getPositions', { accountId });
|
||||
}
|
||||
|
||||
getOperationsByCursor(
|
||||
token: string,
|
||||
request: TbankOperationsByCursorRequest,
|
||||
): Promise<TbankOperationsByCursorResponse> {
|
||||
return this.call(token, this.operations, 'getOperationsByCursor', request);
|
||||
}
|
||||
|
||||
getInstrumentByFigi(
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
}
|
||||
|
||||
getDividends(
|
||||
token: string,
|
||||
instrumentId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<TbankDividend[]> {
|
||||
return this.call<{ dividends?: TbankDividend[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'getDividends',
|
||||
{
|
||||
instrumentId,
|
||||
from: { seconds: String(utcSeconds(from)) },
|
||||
to: { seconds: String(utcSeconds(to)) },
|
||||
},
|
||||
).then((response) => response.dividends ?? []);
|
||||
}
|
||||
|
||||
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 ?? []);
|
||||
}
|
||||
|
||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
getBond(token: string, uid: string): Promise<TbankBond | null> {
|
||||
return this.call<{ instrument?: TbankBond }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
}
|
||||
getBondCoupons(
|
||||
token: string,
|
||||
uid: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<TbankCoupon[]> {
|
||||
return this.call<{ events?: TbankCoupon[] }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'getBondCoupons',
|
||||
{
|
||||
instrumentId: uid,
|
||||
from: { seconds: String(utcSeconds(from)) },
|
||||
to: { seconds: String(utcSeconds(to)) },
|
||||
},
|
||||
).then((response) => response.events ?? []);
|
||||
}
|
||||
|
||||
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 getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
id: string,
|
||||
): Promise<TbankInstrument | null> {
|
||||
try {
|
||||
const response = await this.call<{ instrument?: TbankInstrument }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'getInstrumentBy',
|
||||
{ idType, id },
|
||||
);
|
||||
return response.instrument ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
const maxAttempts = metadataCall ? 3 : 2;
|
||||
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= 50;
|
||||
try {
|
||||
if (queueFull)
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||
operation: method,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull
|
||||
? { code: 'TBANK_UNAVAILABLE' }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
: [
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < maxAttempts) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, metadataCall ? attempts * 2000 : 1000),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts: queueFull && !metadataCall ? 0 : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode)
|
||||
)
|
||||
throw new Error('INVALID_TBANK_TOKEN');
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
(method === 'shareBy' || metadataCall)
|
||||
)
|
||||
throw new NotFoundException();
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
}
|
||||
|
||||
private request<Response>(
|
||||
token: string,
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||
(error, response) => (error ? reject(error) : resolve(response)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function utcSeconds(date: string): number {
|
||||
return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
describe('TbankCrypto', () => {
|
||||
it('round-trips a token and rejects a malformed key', () => {
|
||||
const crypto = TbankCrypto.fromBase64(
|
||||
'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
|
||||
);
|
||||
const encrypted = crypto.encrypt('secret-token');
|
||||
const replacement = crypto.encrypt('secret-token');
|
||||
|
||||
expect(encrypted).not.toContain('secret-token');
|
||||
expect(replacement).not.toBe(encrypted);
|
||||
expect(crypto.decrypt(encrypted)).toBe('secret-token');
|
||||
expect(crypto.decrypt(replacement)).toBe('secret-token');
|
||||
expect(() => TbankCrypto.fromBase64('invalid')).toThrow(
|
||||
'Invalid token encryption key',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const VERSION = 1;
|
||||
const NONCE_SIZE = 12;
|
||||
const TAG_SIZE = 16;
|
||||
|
||||
export class TbankCrypto {
|
||||
constructor(private readonly key: Buffer) {}
|
||||
|
||||
static fromBase64(key: string): TbankCrypto {
|
||||
const decoded = Buffer.from(key, 'base64');
|
||||
if (decoded.length !== 32) throw new Error('Invalid token encryption key');
|
||||
return new TbankCrypto(decoded);
|
||||
}
|
||||
|
||||
encrypt(value: string): string {
|
||||
const nonce = randomBytes(NONCE_SIZE);
|
||||
const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(value, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([VERSION]),
|
||||
nonce,
|
||||
ciphertext,
|
||||
cipher.getAuthTag(),
|
||||
]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(payload: string): string {
|
||||
const bytes = Buffer.from(payload, 'base64');
|
||||
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) {
|
||||
throw new Error('Invalid encrypted token');
|
||||
}
|
||||
const nonce = bytes.subarray(1, 1 + NONCE_SIZE);
|
||||
const tag = bytes.subarray(-TAG_SIZE);
|
||||
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([
|
||||
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
InstrumentMetadataService,
|
||||
],
|
||||
exports: [
|
||||
TbankClient,
|
||||
TbankTokenService,
|
||||
TbankAccessService,
|
||||
InstrumentMetadataService,
|
||||
],
|
||||
})
|
||||
export class TbankModule {}
|
||||
Reference in New Issue
Block a user