codex/broker-operations-ui #19
@ -9,46 +9,34 @@ env:
|
|||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout repository'
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 'Setup dependencies'
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- run: npm ci
|
- name: Install dependencies
|
||||||
- run: npm run lint
|
run: npm ci
|
||||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
|
||||||
|
|
||||||
test:
|
- name: Lint
|
||||||
runs-on: ubuntu-latest
|
run: npm run lint
|
||||||
steps:
|
|
||||||
- name: 'Checkout repository'
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: 'Setup dependencies'
|
- name: Format check
|
||||||
uses: actions/setup-node@v4
|
run: npm run format:check
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- run: npm ci
|
- name: Test backend
|
||||||
- run: npm run test:backend
|
run: npm run test:backend
|
||||||
|
|
||||||
build:
|
- name: Test frontend
|
||||||
runs-on: ubuntu-latest
|
run: npm run test:frontend
|
||||||
steps:
|
|
||||||
- name: 'Checkout repository'
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: 'Setup dependencies'
|
- name: Build backend
|
||||||
uses: actions/setup-node@v4
|
run: npm run build:backend
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- run: npm ci
|
- name: Build frontend
|
||||||
- run: npm run build:backend
|
run: npm run build:frontend
|
||||||
- run: npm run build:frontend
|
|
||||||
|
|||||||
@ -59,7 +59,8 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
|||||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank |
|
| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Rate limiter для OperationsService и UsersService (запросов/с) |
|
||||||
|
| `T_BANK_INSTRUMENTS_RATE_LIMIT` | 20 | Rate limiter для InstrumentsService (запросов/с) |
|
||||||
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
|
| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) |
|
||||||
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
|
||||||
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export default registerAs('app', () => ({
|
|||||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||||
|
instrumentsRateLimitPerSecond: parseInt(process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10),
|
||||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
|
|||||||
@ -37,7 +37,6 @@ describe('portfolio.mapper', () => {
|
|||||||
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
|
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
|
||||||
securities: [],
|
securities: [],
|
||||||
},
|
},
|
||||||
instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.account.id).toBe('acc-1');
|
expect(result.account.id).toBe('acc-1');
|
||||||
|
|||||||
@ -16,7 +16,6 @@ type MapBrokerPortfolioInput = {
|
|||||||
account: BrokerAccount;
|
account: BrokerAccount;
|
||||||
portfolio: TBankPortfolioResponse;
|
portfolio: TBankPortfolioResponse;
|
||||||
positions: TBankPositionsResponse;
|
positions: TBankPositionsResponse;
|
||||||
instruments: Map<string, Partial<TBankInstrument>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
|
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
|
||||||
|
|||||||
@ -41,9 +41,14 @@ export class BrokerAccountsService {
|
|||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
Record<string, string>,
|
Record<string, string>,
|
||||||
TBankAccountsResponse
|
TBankAccountsResponse
|
||||||
>('UsersService/GetAccounts', usersClient.getAccounts.bind(usersClient), {
|
>(
|
||||||
status: 'ACCOUNT_STATUS_OPEN',
|
'UsersService/GetAccounts',
|
||||||
});
|
usersClient.getAccounts.bind(usersClient),
|
||||||
|
{
|
||||||
|
status: 'ACCOUNT_STATUS_OPEN',
|
||||||
|
},
|
||||||
|
'users',
|
||||||
|
);
|
||||||
|
|
||||||
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
|
return (response.accounts ?? []).filter(isSupportedBrokerAccount).map(mapAccount);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export class BrokerInstrumentsService {
|
|||||||
'InstrumentsService/GetInstrumentBy',
|
'InstrumentsService/GetInstrumentBy',
|
||||||
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
|
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
|
||||||
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
|
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
|
||||||
|
'instruments',
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.instrument ?? null;
|
return response.instrument ?? null;
|
||||||
|
|||||||
@ -89,6 +89,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
vi.mocked(instruments.findByInstrumentUid).mockResolvedValue(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('throws 404 for missing account', async () => {
|
it('throws 404 for missing account', async () => {
|
||||||
|
|||||||
@ -32,26 +32,12 @@ export class BrokerPortfolioService {
|
|||||||
TBANK_CACHE_KEYS.portfolio,
|
TBANK_CACHE_KEYS.portfolio,
|
||||||
[accountId],
|
[accountId],
|
||||||
async () => {
|
async () => {
|
||||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
|
||||||
const [portfolio, positions] = await Promise.all([
|
const [portfolio, positions] = await Promise.all([
|
||||||
this.tbankClient.callUnary<
|
this.fetchCachedPortfolio(accountId),
|
||||||
{ accountId: string; currency: string },
|
this.fetchPositions(accountId),
|
||||||
TBankPortfolioResponse
|
|
||||||
>(
|
|
||||||
'OperationsService/GetPortfolio',
|
|
||||||
operationsClient.getPortfolio.bind(operationsClient),
|
|
||||||
{ accountId, currency: 'RUB' },
|
|
||||||
),
|
|
||||||
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
|
||||||
'OperationsService/GetPositions',
|
|
||||||
operationsClient.getPositions.bind(operationsClient),
|
|
||||||
{ accountId },
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const instrumentMap = await this.buildInstrumentMap(portfolio);
|
return mapBrokerPortfolio({ account, portfolio, positions });
|
||||||
|
|
||||||
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
|
|
||||||
},
|
},
|
||||||
'tbankPortfolioTtl',
|
'tbankPortfolioTtl',
|
||||||
);
|
);
|
||||||
@ -78,14 +64,7 @@ export class BrokerPortfolioService {
|
|||||||
TBANK_CACHE_KEYS.positions,
|
TBANK_CACHE_KEYS.positions,
|
||||||
[accountId, cursor ?? '', String(limit), type ?? ''],
|
[accountId, cursor ?? '', String(limit), type ?? ''],
|
||||||
async () => {
|
async () => {
|
||||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
const portfolio = await this.fetchCachedPortfolio(accountId);
|
||||||
const portfolio = await this.tbankClient.callUnary<
|
|
||||||
{ accountId: string; currency: string },
|
|
||||||
TBankPortfolioResponse
|
|
||||||
>('OperationsService/GetPortfolio', operationsClient.getPortfolio.bind(operationsClient), {
|
|
||||||
accountId,
|
|
||||||
currency: 'RUB',
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredPositions = type
|
const filteredPositions = type
|
||||||
? (portfolio.positions ?? []).filter(
|
? (portfolio.positions ?? []).filter(
|
||||||
@ -121,9 +100,11 @@ export class BrokerPortfolioService {
|
|||||||
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
|
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
|
||||||
),
|
),
|
||||||
) as string[];
|
) as string[];
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
|
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = results.flatMap((result) =>
|
const entries = results.flatMap((result) =>
|
||||||
result.status === 'fulfilled' ? [result.value] : [],
|
result.status === 'fulfilled' ? [result.value] : [],
|
||||||
);
|
);
|
||||||
@ -132,4 +113,37 @@ export class BrokerPortfolioService {
|
|||||||
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
|
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchCachedPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
|
||||||
|
return this.cacheService
|
||||||
|
.getOrFetch(
|
||||||
|
'tbank:raw-portfolio',
|
||||||
|
[accountId],
|
||||||
|
async () => {
|
||||||
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
|
return this.tbankClient.callUnary<
|
||||||
|
{ accountId: string; currency: string },
|
||||||
|
TBankPortfolioResponse
|
||||||
|
>(
|
||||||
|
'OperationsService/GetPortfolio',
|
||||||
|
operationsClient.getPortfolio.bind(operationsClient),
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
currency: 'RUB',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
'tbankPortfolioTtl',
|
||||||
|
)
|
||||||
|
.then((r) => r.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
|
||||||
|
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
||||||
|
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
||||||
|
'OperationsService/GetPositions',
|
||||||
|
operationsClient.getPositions.bind(operationsClient),
|
||||||
|
{ accountId },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,20 +30,28 @@ type GrpcUnary<TRequest, TResponse> = (
|
|||||||
|
|
||||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
||||||
|
|
||||||
|
type QueueName = 'operations' | 'instruments' | 'users';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TBankClientService {
|
export class TBankClientService {
|
||||||
private readonly logger = new Logger(TBankClientService.name);
|
private readonly logger = new Logger(TBankClientService.name);
|
||||||
private readonly queue: PQueue;
|
private readonly queues: Record<QueueName, PQueue>;
|
||||||
private readonly requestTimeoutMs: number;
|
private readonly requestTimeoutMs: number;
|
||||||
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
|
private readonly packageDefinition: ReturnType<typeof loadPackageDefinition>;
|
||||||
private readonly clientCache = new Map<string, Client>();
|
private readonly clientCache = new Map<string, Client>();
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
|
this.requestTimeoutMs = this.configService.get<number>('app.tbank.requestTimeoutMs', 10000);
|
||||||
this.queue = new PQueue({
|
const operationsRate = this.configService.get<number>('app.tbank.rateLimitPerSecond', 5);
|
||||||
interval: 1000,
|
const instrumentsRate = this.configService.get<number>(
|
||||||
intervalCap: this.configService.get<number>('app.tbank.rateLimitPerSecond', 5),
|
'app.tbank.instrumentsRateLimitPerSecond',
|
||||||
});
|
20,
|
||||||
|
);
|
||||||
|
this.queues = {
|
||||||
|
operations: new PQueue({ interval: 1000, intervalCap: operationsRate }),
|
||||||
|
instruments: new PQueue({ interval: 1000, intervalCap: instrumentsRate }),
|
||||||
|
users: new PQueue({ interval: 1000, intervalCap: operationsRate }),
|
||||||
|
};
|
||||||
|
|
||||||
const protoRoot = this.resolveProtoRoot();
|
const protoRoot = this.resolveProtoRoot();
|
||||||
const definition = loadSync(
|
const definition = loadSync(
|
||||||
@ -120,8 +128,9 @@ export class TBankClientService {
|
|||||||
label: string,
|
label: string,
|
||||||
method: GrpcUnary<TRequest, TResponse>,
|
method: GrpcUnary<TRequest, TResponse>,
|
||||||
request: TRequest,
|
request: TRequest,
|
||||||
|
queueName: QueueName = 'operations',
|
||||||
): Promise<TResponse> {
|
): Promise<TResponse> {
|
||||||
return this.queue.add(
|
return this.queues[queueName].add(
|
||||||
() =>
|
() =>
|
||||||
new Promise<TResponse>((resolve, reject) => {
|
new Promise<TResponse>((resolve, reject) => {
|
||||||
const metadata = this.createMetadata();
|
const metadata = this.createMetadata();
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export class CacheModule {}
|
|||||||
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
|
| Дивиденды | `dividendsTtl` | 86400s (24 ч) | `CACHE_DIVIDENDS_TTL` |
|
||||||
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
|
| Счета T-Bank | `tbankAccountsTtl` | 3600s (1 ч) | `CACHE_TBANK_ACCOUNTS_TTL` |
|
||||||
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
|
| Портфель T-Bank | `tbankPortfolioTtl` | 60s (1 мин) | `CACHE_TBANK_PORTFOLIO_TTL` |
|
||||||
|
| Позиции T-Bank | `tbankPositionsTtl` | 60s (1 мин) | `CACHE_TBANK_POSITIONS_TTL` |
|
||||||
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
|
| Операции T-Bank | `tbankOperationsTtl` | 300s (5 мин) | `CACHE_TBANK_OPERATIONS_TTL` |
|
||||||
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
|
| Инструменты T-Bank | `tbankInstrumentTtl` | 86400s (24 ч) | `CACHE_TBANK_INSTRUMENT_TTL` |
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest |
|
||||||
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты |
|
||||||
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
|
||||||
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
|
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Rate limiter для OperationsService и UsersService (запросов/с) |
|
||||||
|
| `T_BANK_INSTRUMENTS_RATE_LIMIT` | `20` | Rate limiter для InstrumentsService (запросов/с) |
|
||||||
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
|
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
|
||||||
| `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
|
| `CACHE_MARKET_DATA_TTL` | `900` | TTL рыночных данных (секунды) |
|
||||||
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
|
| `CACHE_HISTORY_TTL` | `3600` | TTL истории торгов (секунды) |
|
||||||
@ -25,6 +26,7 @@
|
|||||||
| `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) |
|
| `CACHE_DIVIDENDS_TTL` | `86400` | TTL дивидендов (секунды) |
|
||||||
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) |
|
| `CACHE_TBANK_ACCOUNTS_TTL` | `3600` | TTL списка брокерских счетов T-Bank (секунды) |
|
||||||
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) |
|
| `CACHE_TBANK_PORTFOLIO_TTL` | `60` | TTL брокерского портфеля T-Bank (секунды) |
|
||||||
|
| `CACHE_TBANK_POSITIONS_TTL` | `60` | TTL страницы позиций T-Bank (секунды) |
|
||||||
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) |
|
| `CACHE_TBANK_OPERATIONS_TTL` | `300` | TTL страницы операций T-Bank (секунды) |
|
||||||
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) |
|
| `CACHE_TBANK_INSTRUMENT_TTL` | `86400` | TTL метаданных инструментов T-Bank (секунды) |
|
||||||
|
|
||||||
@ -49,6 +51,9 @@ registerAs('app', () => ({
|
|||||||
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
caCertPath: process.env.T_BANK_CA_CERT_PATH || '',
|
||||||
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
|
||||||
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
|
||||||
|
instrumentsRateLimitPerSecond: parseInt(
|
||||||
|
process.env.T_BANK_INSTRUMENTS_RATE_LIMIT || '20', 10,
|
||||||
|
),
|
||||||
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),
|
||||||
},
|
},
|
||||||
cache: {
|
cache: {
|
||||||
|
|||||||
@ -60,6 +60,7 @@ Direct-read endpoints используют короткий in-memory cache, ч
|
|||||||
|
|
||||||
- счета: `CACHE_TBANK_ACCOUNTS_TTL`
|
- счета: `CACHE_TBANK_ACCOUNTS_TTL`
|
||||||
- портфель: `CACHE_TBANK_PORTFOLIO_TTL`
|
- портфель: `CACHE_TBANK_PORTFOLIO_TTL`
|
||||||
|
- позиции: `CACHE_TBANK_POSITIONS_TTL`
|
||||||
- страницы операций: `CACHE_TBANK_OPERATIONS_TTL`
|
- страницы операций: `CACHE_TBANK_OPERATIONS_TTL`
|
||||||
- метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL`
|
- метаданные инструментов: `CACHE_TBANK_INSTRUMENT_TTL`
|
||||||
|
|
||||||
@ -73,6 +74,20 @@ Direct-read endpoints используют короткий in-memory cache, ч
|
|||||||
|
|
||||||
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
|
||||||
|
|
||||||
|
## Rate limiting
|
||||||
|
|
||||||
|
gRPC-запросы к T-Bank API распределяются по трём отдельным `p-queue` для изоляции групп методов:
|
||||||
|
|
||||||
|
| Очередь | Методы | Rate limit | Env-переменная |
|
||||||
|
|---|---|---|---|
|
||||||
|
| operations | `OperationsService`, `UsersService` | 5 req/s | `T_BANK_RATE_LIMIT_PER_SECOND` |
|
||||||
|
| instruments | `InstrumentsService` | 20 req/s | `T_BANK_INSTRUMENTS_RATE_LIMIT` |
|
||||||
|
| users | `UsersService` (отдельные вызовы) | 5 req/s | `T_BANK_RATE_LIMIT_PER_SECOND` |
|
||||||
|
|
||||||
|
Методы `UsersService/GetAccounts` маршрутизируются в очередь `users`, остальные методы
|
||||||
|
`UsersService` — в `operations`. Это предотвращает блокировку запросов портфеля
|
||||||
|
запросами инструментов и наоборот.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
|
Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
|
||||||
|
|||||||
@ -0,0 +1,318 @@
|
|||||||
|
# Broker Operations UI Improvements — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Clean up operations table badges, add "+" for positive amounts, smooth pagination with styled buttons.
|
||||||
|
|
||||||
|
**Architecture:** All changes are frontend-only (React components, TanStack Query hook, display helpers, tests). No backend changes.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, TanStack Query v5, Vitest + Testing Library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Remove `getBrokerOperationImpactLabel` from brokerDisplay
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/pages/broker/brokerDisplay.ts:165-176`
|
||||||
|
- Modify: `apps/frontend/src/pages/broker/brokerDisplay.test.ts:195-201`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove the test for impact labels**
|
||||||
|
|
||||||
|
In `brokerDisplay.test.ts`, delete the test block "provides Russian impact labels" (lines 195-201) and remove `getBrokerOperationImpactLabel` from the import.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import line changes from:
|
||||||
|
import {
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationImpactLabel,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
getBrokerPositionGroup,
|
||||||
|
} from './brokerDisplay';
|
||||||
|
// to:
|
||||||
|
import {
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
getBrokerPositionGroup,
|
||||||
|
} from './brokerDisplay';
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete the entire `it('provides Russian impact labels', ...)` block (lines 195-201).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify the test removal succeeds**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/frontend/src/pages/broker/brokerDisplay.test.ts`
|
||||||
|
Expected: PASS (1 less test)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove `getBrokerOperationImpactLabel` from source**
|
||||||
|
|
||||||
|
In `brokerDisplay.ts`, delete the `getBrokerOperationImpactLabel` function (lines 165-176) and remove its export.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/frontend/src/pages/broker/brokerDisplay.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/pages/broker/brokerDisplay.ts apps/frontend/src/pages/broker/brokerDisplay.test.ts
|
||||||
|
git commit -m "refactor: remove unused getBrokerOperationImpactLabel helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update BrokerPages.test.tsx for new expectations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update test to expect no badges and "+" prefix**
|
||||||
|
|
||||||
|
Remove lines 314-315 (badge checks):
|
||||||
|
```tsx
|
||||||
|
expect(screen.getByText('Пополняет')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Списывает')).toBeInTheDocument();
|
||||||
|
```
|
||||||
|
|
||||||
|
Update test name at line 225 from:
|
||||||
|
```
|
||||||
|
it('renders broker operations with Russian labels, linked instruments and impact badges', () => {
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```
|
||||||
|
it('renders broker operations with Russian labels, linked instruments and colored amounts', () => {
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a check for the "+" prefix on positive amounts after the existing check at line 312-313:
|
||||||
|
```tsx
|
||||||
|
expect(screen.getByText('Выплата купона')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Налог')).toBeInTheDocument();
|
||||||
|
// Add:
|
||||||
|
expect(screen.getByText(/\+120,00\s*₽/)).toBeInTheDocument();
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the old checks for "Страница 1" and "Страница 2" text (lines 424-437) and instead verify the pagination buttons exist. Update the pagination test block at line 322:
|
||||||
|
```tsx
|
||||||
|
it('requests broker operations by cursor with a page size of 10', async () => {
|
||||||
|
// ...setup stays the same...
|
||||||
|
|
||||||
|
// Replace these:
|
||||||
|
// expect(screen.getByText('Страница 1')).toBeInTheDocument();
|
||||||
|
// with check that page buttons exist:
|
||||||
|
const nextButton = screen.getByRole('button', { name: '→' });
|
||||||
|
const prevButton = screen.getByRole('button', { name: '←' });
|
||||||
|
expect(prevButton).toBeDisabled();
|
||||||
|
expect(nextButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(nextButton);
|
||||||
|
|
||||||
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', {
|
||||||
|
limit: 10,
|
||||||
|
cursor: 'cursor-page-2',
|
||||||
|
});
|
||||||
|
// expect(screen.getByText('Страница 2')).toBeInTheDocument(); // remove
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument(); // page number shown without label
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '←' }));
|
||||||
|
|
||||||
|
expect(operationsSpy).toHaveBeenLastCalledWith('acc-1', { limit: 10, cursor: undefined });
|
||||||
|
expect(screen.getByText('1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||||||
|
Expected: FAIL (buttons named '→' / '←' not found yet, "+" text not found)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
|
||||||
|
git commit -m "test: update broker page tests for new UI expectations"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Remove badges and add "+" prefix, style pagination buttons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove badge-related code**
|
||||||
|
|
||||||
|
In `BrokerOperationsTable.tsx`:
|
||||||
|
|
||||||
|
Delete the `impactStyles` object (lines 30-47).
|
||||||
|
|
||||||
|
Delete the `OperationType` component (lines 87-108).
|
||||||
|
|
||||||
|
Update imports — remove `getBrokerOperationImpactLabel` and `BrokerOperationImpact`:
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
getBrokerInstrumentPath,
|
||||||
|
getBrokerOperationImpact,
|
||||||
|
getBrokerOperationTypeLabel,
|
||||||
|
} from './brokerDisplay';
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the "Тип" column cell — replace `<OperationType operation={operation} />` with just:
|
||||||
|
```tsx
|
||||||
|
<span>{getBrokerOperationTypeLabel(operation)}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add "+" prefix to positive amounts**
|
||||||
|
|
||||||
|
Modify `formatMoney` function:
|
||||||
|
```tsx
|
||||||
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
|
if (!value) return '-';
|
||||||
|
const formatted = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: value.currency || 'RUB',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value.value);
|
||||||
|
return value.value > 0 ? `+${formatted}` : formatted;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Style pagination buttons**
|
||||||
|
|
||||||
|
Add button style constants before the component:
|
||||||
|
```tsx
|
||||||
|
const pagButtonStyle: React.CSSProperties = {
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagButtonDisabledStyle: React.CSSProperties = {
|
||||||
|
...pagButtonStyle,
|
||||||
|
opacity: 0.35,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update pagination controls — replace existing "Назад" / "Вперед" buttons and "Страница N" text:
|
||||||
|
```tsx
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
style={canGoBack ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
minWidth: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!canGoForward}
|
||||||
|
style={canGoForward ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:frontend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
|
||||||
|
git commit -m "feat: remove impact badges, add + prefix, style pagination buttons"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add keepPreviousData to operations query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/frontend/src/hooks/useBrokerOperations.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add keepPreviousData**
|
||||||
|
|
||||||
|
Update `useBrokerOperations.ts`:
|
||||||
|
```tsx
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function useBrokerOperations(
|
||||||
|
accountId: string | undefined,
|
||||||
|
query: BrokerOperationQuery = {},
|
||||||
|
) {
|
||||||
|
return useQuery<BrokerOperationsPage>({
|
||||||
|
queryKey: ['broker', 'operations', accountId, query],
|
||||||
|
enabled: Boolean(accountId),
|
||||||
|
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 300_000,
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:frontend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/frontend/src/hooks/useBrokerOperations.ts
|
||||||
|
git commit -m "feat: add keepPreviousData for smooth pagination"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Run lint and verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run lint**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: PASS (no lint errors)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full test suite**
|
||||||
|
|
||||||
|
Run: `npm run test:frontend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build**
|
||||||
|
|
||||||
|
Run: `npm run build:frontend`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit if any fixes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: fix lint issues"
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,88 @@
|
|||||||
|
# Broker API Performance Optimization
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
- `GET /api/v1/broker/accounts/:id/portfolio` — **~5s**
|
||||||
|
- `GET /api/v1/broker/accounts/:id/positions` — **1-3s**
|
||||||
|
|
||||||
|
## Диагностика
|
||||||
|
|
||||||
|
### 1. Мёртвый код в `getPortfolio`
|
||||||
|
|
||||||
|
`buildInstrumentMap` делает N gRPC вызовов `GetInstrumentBy` (по одному на каждый `instrumentUid` в портфеле), но результат **не используется** в `mapBrokerPortfolio`. Это чистое WASTE.
|
||||||
|
|
||||||
|
### 2. Блокирующий instrument enrichment в `getPositions`
|
||||||
|
|
||||||
|
`buildInstrumentMap` вызывает `findByInstrumentUid` для каждого инструмента через gRPC. Даже при `Promise.allSettled`, все вызовы проходят через единый `p-queue` с 5 req/s. Для 10-20 позиций = 2-4 секунды ожидания в очереди.
|
||||||
|
|
||||||
|
Из всех полей `GetInstrumentBy` `mapBrokerPosition` использует только `name` — `ticker`, `classCode`, `instrumentType` уже есть в ответе `PortfolioPosition` (proto fields 32, 33, 2).
|
||||||
|
|
||||||
|
### 3. Единый rate limiter
|
||||||
|
|
||||||
|
Один `p-queue` на 5 req/s для всех gRPC вызовов. Instrument lookups конкурируют за очередь с portfolio/operations запросами.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### Change 1: Убрать `buildInstrumentMap` из `getPortfolio`
|
||||||
|
|
||||||
|
**Файлы:** `broker-portfolio.service.ts`
|
||||||
|
|
||||||
|
Удалить вызов `buildInstrumentMap` и передачу `instruments` в `mapBrokerPortfolio`. Исключить `BrokerInstrumentsService` из зависимостей (если не используется больше нигде в сервисе).
|
||||||
|
|
||||||
|
### Change 2: Instrument enrichment из кэша без блокировки
|
||||||
|
|
||||||
|
**Файлы:** `broker-portfolio.service.ts`, `cache.service.ts`
|
||||||
|
|
||||||
|
- `buildInstrumentMap` пытается достать данные из кэша без триггера gRPC
|
||||||
|
- Если данных нет — возвращаем `null` для имени (не блокируем ответ)
|
||||||
|
- Новый метод `CacheService.getIfPresent(key)` — проверяет кэш без вызова fetchFn
|
||||||
|
|
||||||
|
### Change 3: Разделить rate limiter на 3 очереди
|
||||||
|
|
||||||
|
**Файлы:** `tbank-client.service.ts`
|
||||||
|
|
||||||
|
Заменить единый `p-queue` на:
|
||||||
|
|
||||||
|
| Очередь | Rate | Сервисы |
|
||||||
|
|---|---|---|
|
||||||
|
| `operationsQueue` | 5 req/s | OperationsService |
|
||||||
|
| `instrumentsQueue` | 20 req/s | InstrumentsService |
|
||||||
|
| `usersQueue` | 5 req/s | UsersService |
|
||||||
|
|
||||||
|
Метод `callUnary` принимает параметр `queueName`. Клиентские методы выбирают очередь по типу сервиса.
|
||||||
|
|
||||||
|
### Change 4: Увеличить rate limit по умолчанию
|
||||||
|
|
||||||
|
**Файлы:** `configuration.ts`
|
||||||
|
|
||||||
|
`rateLimitPerSecond` по умолчанию: 5 → 20.
|
||||||
|
|
||||||
|
### Change 5: Shared cache сырого `GetPortfolio`
|
||||||
|
|
||||||
|
**Файлы:** `broker-portfolio.service.ts`
|
||||||
|
|
||||||
|
Оба эндпоинта вызывают `GetPortfolio` с одинаковым `accountId`. Кэшировать сырой ответ отдельно (TTL 60s, ключ `tbank:raw-portfolio:{accountId}`), чтобы второй запрос в том же окне не дублировал вызов.
|
||||||
|
|
||||||
|
## Ожидаемый эффект
|
||||||
|
|
||||||
|
| Endpoint | До | После |
|
||||||
|
|---|---|---|
|
||||||
|
| Portfolio | ~5s | ~0.3-0.5s (2 параллельных gRPC, без instrument enrichment) |
|
||||||
|
| Positions | 1-3s | ~0.2-0.3s (1 gRPC GetPortfolio, name из кэша / null) |
|
||||||
|
|
||||||
|
## Этапы реализации (по порядку)
|
||||||
|
|
||||||
|
1. Убрать `buildInstrumentMap` из `getPortfolio`
|
||||||
|
2. `CacheService.getIfPresent()` для instrument enrichment в positions
|
||||||
|
3. Разделить rate limiter на очереди
|
||||||
|
4. Увеличить rate limit по умолчанию
|
||||||
|
5. Shared cache сырого GetPortfolio
|
||||||
|
|
||||||
|
Каждый этап отдельным коммитом.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. Portfolio endpoint < 1s при тёплом кэше account/instrument, < 1.5s при холодном
|
||||||
|
2. Positions endpoint < 0.5s при тёплом кэше, < 1s при холодном
|
||||||
|
3. Все существующие тесты проходят
|
||||||
|
4. Instrument name показывается если есть в кэше, иначе `null`
|
||||||
Loading…
x
Reference in New Issue
Block a user