Compare commits

..

No commits in common. "a13b5145f70c8bf3c0ef0173447625d781b0dbd3" and "cfadb2adbe149b4bf7b7414e251f7bf6d6d168e8" have entirely different histories.

124 changed files with 1089 additions and 1644 deletions

View File

@ -26,6 +26,9 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Format check
run: npm run format:check
- name: Test backend - name: Test backend
run: npm run test:backend run: npm run test:backend

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
apps/frontend/

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class ApiResponseMeta { export class ApiResponseMeta {
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
cachedAt: string | null; cachedAt: string | null;
@ApiProperty() @ApiProperty()

View File

@ -1,32 +1,26 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
@ApiTags('Bonds') @ApiTags('Bonds')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/bonds') @Controller('securities/bonds')
export class BondsController { export class BondsController {
constructor(private readonly bondsService: BondsService) {} constructor(private readonly bondsService: BondsService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию облигации' }) @ApiOperation({ summary: 'Получить спецификацию облигации' })
@ApiOkResponse({ type: BondEnvelopeDto })
async getBond(@Param('secid') secid: string) { async getBond(@Param('secid') secid: string) {
return this.bondsService.getBond(secid); return this.bondsService.getBond(secid);
} }
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные облигации' }) @ApiOperation({ summary: 'Получить рыночные данные облигации' })
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.bondsService.getMarketData(secid); return this.bondsService.getMarketData(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) @ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -1,28 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BondMarketDataDto, BondResponseDto } from './bond-response.dto';
import { BondHistoryItemDto } from './history-item.dto';
export class BondEnvelopeDto {
@ApiProperty({ type: BondResponseDto })
data!: BondResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondMarketDataEnvelopeDto {
@ApiProperty({ type: BondMarketDataDto })
data!: BondMarketDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class BondHistoryEnvelopeDto {
@ApiProperty({ type: [BondHistoryItemDto] })
data!: BondHistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,15 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BondHistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 100.45 })
closePrice!: number;
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
yieldClose!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
duration!: number | null;
}

View File

@ -1,19 +1,15 @@
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { CandlesQueryDto } from './dto/candles-query.dto'; import { CandlesQueryDto } from './dto/candles-query.dto';
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
@ApiTags('Candles') @ApiTags('Candles')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class CandlesController { export class CandlesController {
constructor(private readonly candlesService: CandlesService) {} constructor(private readonly candlesService: CandlesService) {}
@Get('shares/:secid/candles') @Get('shares/:secid/candles')
@ApiOperation({ summary: 'Получить свечи акции' }) @ApiOperation({ summary: 'Получить свечи акции' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getShareCandles( async getShareCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,
@ -23,7 +19,6 @@ export class CandlesController {
@Get('bonds/:secid/candles') @Get('bonds/:secid/candles')
@ApiOperation({ summary: 'Получить свечи облигации' }) @ApiOperation({ summary: 'Получить свечи облигации' })
@ApiOkResponse({ type: CandleEnvelopeDto })
async getBondCandles( async getBondCandles(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto, @Query(ValidationPipe) query: CandlesQueryDto,

View File

@ -1,27 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class CandleItemDto {
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
@ApiProperty({ example: '2026-06-01T10:00:00' })
begin!: string;
@ApiProperty({ example: '2026-06-01T10:59:00' })
end!: string;
}

View File

@ -1,11 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { CandleItemDto } from './candle-item.dto';
export class CandleEnvelopeDto {
@ApiProperty({ type: [CandleItemDto] })
data!: CandleItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,11 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { HealthResponseDto } from './health-response.dto';
export class HealthEnvelopeDto {
@ApiProperty({ type: HealthResponseDto })
data!: HealthResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class HealthResponseDto {
@ApiProperty({ example: 'ok' })
status!: string;
@ApiProperty({ example: '2026-06-23T06:00:00.000Z' })
timestamp!: string;
@ApiProperty({ example: 12345 })
uptime!: number;
}

View File

@ -1,17 +1,13 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
@ApiTags('Health') @ApiTags('Health')
@ApiExtraModels(ApiResponseMeta)
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' }) @ApiOperation({ summary: 'Проверка состояния сервиса' })
@ApiOkResponse({ type: HealthEnvelopeDto })
check() { check() {
return { return {
status: 'ok', status: 'ok',

View File

@ -1,33 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class SearchResultItemDto {
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiProperty({ example: 'RU0009029540' })
isin!: string;
@ApiProperty({ example: 'Сбербанк' })
shortName!: string;
@ApiProperty({ enum: ['share', 'bond'] })
type!: 'share' | 'bond';
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiPropertyOptional({ type: String, nullable: true, example: 'RUB' })
currency!: string | null;
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
price!: number | null;
}
export class SearchEnvelopeDto {
@ApiProperty({ type: [SearchResultItemDto] })
data!: SearchResultItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,15 +1,12 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto';
import { ScreenerResponseDto } from './dto/screener-response.dto'; import { ScreenerResponseDto } from './dto/screener-response.dto';
import { SearchEnvelopeDto } from './dto/search-response.dto';
@ApiTags('Securities') @ApiTags('Securities')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities') @Controller('securities')
export class SecuritiesController { export class SecuritiesController {
constructor( constructor(
@ -19,7 +16,6 @@ export class SecuritiesController {
@Get('search') @Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) { async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search( const results = await this.securitiesService.search(
query.q, query.q,

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class DividendItemDto {
@ApiProperty({ example: '2026-05-15' })
registryCloseDate!: string;
@ApiProperty({ example: 33.47 })
value!: number;
@ApiProperty({ example: 'RUB' })
currency!: string;
}

View File

@ -1,24 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
export class HistoryItemDto {
@ApiProperty({ example: '2026-06-01' })
date!: string;
@ApiProperty({ example: 321.3 })
open!: number;
@ApiProperty({ example: 322.66 })
high!: number;
@ApiProperty({ example: 321.2 })
low!: number;
@ApiProperty({ example: 322.35 })
close!: number;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
}

View File

@ -1,38 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { ShareResponseDto } from './share-response.dto';
import { ShareMarketDataResponseDto } from './share-marketdata-response.dto';
import { HistoryItemDto } from './history-item.dto';
import { DividendItemDto } from './dividend-item.dto';
export class ShareEnvelopeDto {
@ApiProperty({ type: ShareResponseDto })
data!: ShareResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareMarketDataEnvelopeDto {
@ApiProperty({ type: ShareMarketDataResponseDto })
data!: ShareMarketDataResponseDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class DividendsEnvelopeDto {
@ApiProperty({ type: [DividendItemDto] })
data!: DividendItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}
export class ShareHistoryEnvelopeDto {
@ApiProperty({ type: [HistoryItemDto] })
data!: HistoryItemDto[];
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
}

View File

@ -1,23 +1,14 @@
import { Controller, Get, Param, Query } from '@nestjs/common'; import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import {
ShareEnvelopeDto,
ShareMarketDataEnvelopeDto,
DividendsEnvelopeDto,
ShareHistoryEnvelopeDto,
} from './dto/shares-envelope.dto';
@ApiTags('Shares') @ApiTags('Shares')
@ApiExtraModels(ApiResponseMeta)
@Controller('securities/shares') @Controller('securities/shares')
export class SharesController { export class SharesController {
constructor(private readonly sharesService: SharesService) {} constructor(private readonly sharesService: SharesService) {}
@Get(':secid') @Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию акции' }) @ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) { async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid); const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } }; return { data: share, meta: { cachedAt: null, fromCache: false } };
@ -25,21 +16,18 @@ export class SharesController {
@Get(':secid/marketdata') @Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные акции' }) @ApiOperation({ summary: 'Получить рыночные данные акции' })
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
async getMarketData(@Param('secid') secid: string) { async getMarketData(@Param('secid') secid: string) {
return this.sharesService.getMarketData(secid); return this.sharesService.getMarketData(secid);
} }
@Get(':secid/dividends') @Get(':secid/dividends')
@ApiOperation({ summary: 'Получить дивиденды' }) @ApiOperation({ summary: 'Получить дивиденды' })
@ApiOkResponse({ type: DividendsEnvelopeDto })
async getDividends(@Param('secid') secid: string) { async getDividends(@Param('secid') secid: string) {
return this.sharesService.getDividends(secid); return this.sharesService.getDividends(secid);
} }
@Get(':secid/history') @Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов акции' }) @ApiOperation({ summary: 'Получить дневную историю торгов акции' })
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
async getHistory( async getHistory(
@Param('secid') secid: string, @Param('secid') secid: string,
@Query('from') from: string, @Query('from') from: string,

View File

@ -21,37 +21,37 @@ export class BrokerEventItemDto {
@ApiProperty() @ApiProperty()
eventDate!: string; eventDate!: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
paymentDate!: string | null; paymentDate!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
ticker!: string | null; ticker!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
name!: string | null; name!: string | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
instrumentUid!: string | null; instrumentUid!: string | null;
@ApiProperty({ enum: instrumentTypes }) @ApiProperty({ enum: instrumentTypes })
instrumentType!: string; instrumentType!: string;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
quantitySnapshot!: number | null; quantitySnapshot!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
payoutPerUnit!: number | null; payoutPerUnit!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
estimatedAmount!: number | null; estimatedAmount!: number | null;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ nullable: true })
actualAmount!: number | null; actualAmount!: number | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
currency!: string | null; currency!: string | null;
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] }) @ApiProperty({ nullable: true })
estimateMode!: 'current_position' | null; estimateMode!: 'current_position' | null;
} }
@ -59,7 +59,7 @@ export class BrokerEventsSummaryDto {
@ApiProperty({ minimum: 0 }) @ApiProperty({ minimum: 0 })
eventCount!: number; eventCount!: number;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ nullable: true })
nearestEventDate!: string | null; nearestEventDate!: string | null;
@ApiProperty() @ApiProperty()

View File

@ -38,7 +38,7 @@
## Решение ## Решение
Мигрировать на **TanStack Router** (code-first approach). Мигрировать на **TanStack Router**.
Причины: Причины:
1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params 1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params
@ -48,8 +48,6 @@
5. **Search params** — типизированная валидация вместо строковых `useSearchParams` 5. **Search params** — типизированная валидация вместо строковых `useSearchParams`
6. **Меньший размер** — 3-4KB vs 8KB react-router-dom 6. **Меньший размер** — 3-4KB vs 8KB react-router-dom
> **Примечание о реализации:** Изначально планировался file-based подход с `@tanstack/router-plugin`, но плагин не генерировал `routeTree.gen.ts` корректно на текущей версии Vite/плагина. Принято решение использовать code-first подход — все маршруты определяются вручную в `routeTree.tsx` через `createRootRoute` + `createRoute`. Это даёт тот же функционал (типобезопасность, guard'ы, код-сплиттинг) без зависимости от Vite-плагина.
## Последствия ## Последствия
### Положительные ### Положительные
@ -60,17 +58,14 @@
### Риски ### Риски
- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов - Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов
- `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router - `MemoryRouter` в тестах заменяется на `createMemoryRouter` из TanStack Router
- Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx` - Файловая структура роутов меняется — `src/app/routes/` с Route Tree generation
- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat`
- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')`
- Learning curve для команды - Learning curve для команды
### Миграция ### Миграция
- Все маршруты определены в одном `routeTree.tsx` (code-first) - Каждый роут переносится по одному
- Старый `AppRoutes.tsx` удалён после прохождения тестов - Старый `AppRoutes.tsx` сохраняется до полного прохождения тестов
- `react-router-dom` удалён из зависимостей после верификации - `react-router-dom` удаляется только после верификации
- Все 125 тестов проходят, сборка зелёная
## Связанные документы ## Связанные документы
- `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md` - `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md`

View File

@ -1,11 +0,0 @@
export const mockUser = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
}
export const mockAuthResponse = {
user: mockUser,
accessToken: 'mock-access-token',
}

View File

@ -1,40 +0,0 @@
export const mockBond = {
secid: 'SU26238RMFS5',
isin: 'RU000A101XU7',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 500000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2027-05-15',
couponValue: 36.9,
couponPercent: 7.5,
couponPeriod: 182,
nextCoupon: '2024-07-15',
accruedInt: 8.45,
bondType: 'ОФЗ',
bondSubType: 'ОФЗ-ПД',
offerDate: null,
buybackDate: null,
marketData: {
price: 98.5,
yieldToMaturity: 8.2,
duration: 3.5,
accruedInt: 8.45,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
open: 98.0,
high: 99.0,
low: 97.5,
volume: 1000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
export const mockBondHistory = [
{ date: '2024-01-15', closePrice: 98.5, yieldClose: 8.2, duration: 3.5 },
{ date: '2024-01-14', closePrice: 98.2, yieldClose: 8.3, duration: 3.5 },
]

View File

@ -1,189 +0,0 @@
export const mockBrokerAccounts = [
{
id: '2084014113',
type: 'brokerage',
name: 'Т-Инвестиции',
status: 'open',
openedAt: null,
accessLevel: null,
},
]
export const mockBrokerPortfolio = {
account: mockBrokerAccounts[0],
positionCounts: { shares: 3, bonds: 2, etf: 0, other: 0 },
totals: {
shares: { currency: 'RUB', units: '240000', nano: 500000000, value: 240000.5 },
bonds: { currency: 'RUB', units: '45000', nano: 0, value: 45000 },
etf: null,
currencies: null,
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: { currency: 'RUB', units: '285000', nano: 500000000, value: 285000.5 },
},
yields: {
expectedPercent: null,
daily: { currency: 'RUB', units: '1200', nano: 0, value: 1200 },
dailyPercent: null,
},
cash: [{ currency: 'RUB', units: '15000', nano: 0, value: 15000 }],
blockedCash: [],
asOf: '2024-06-01T10:00:00Z',
}
export const mockBrokerPositions = [
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
name: 'Сбер Банк',
quantity: { currency: '', units: '100', nano: 0, value: 100 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '289', nano: 500000000, value: 289.5 },
currentValue: { currency: 'RUB', units: '28950', nano: 0, value: 28950 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'VTBR',
classCode: null,
instrumentType: 'share',
name: 'ВТБ',
quantity: { currency: '', units: '5000', nano: 0, value: 5000 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '0', nano: 23400000, value: 0.0234 },
currentValue: { currency: 'RUB', units: '117', nano: 0, value: 117 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
{
figi: null,
instrumentUid: null,
positionUid: null,
ticker: 'SU26238RMFS5',
classCode: null,
instrumentType: 'bond',
name: 'ОФЗ 26238',
quantity: { currency: '', units: '10', nano: 0, value: 10 },
blockedLots: null,
currentPrice: { currency: 'RUB', units: '985', nano: 0, value: 985 },
currentValue: { currency: 'RUB', units: '9850', nano: 0, value: 9850 },
averagePositionPrice: null,
expectedYieldPercent: null,
dailyYield: null,
},
]
export const mockBrokerOperations = [
{
cursor: null,
accountId: '2084014113',
id: 'op-1',
parentOperationId: null,
date: '2024-06-01T10:00:00Z',
type: 'buy',
category: 'trade',
description: null,
name: 'Покупка SBER',
state: 'executed',
instrumentUid: null,
figi: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
payment: { currency: 'RUB', units: '-27500', nano: 0, value: -27500 },
price: { currency: 'RUB', units: '275', nano: 0, value: 275 },
commission: { currency: 'RUB', units: '-55', nano: 0, value: -55 },
yield: null,
accruedInt: null,
quantity: { currency: '', units: '100', nano: 0, value: 100 },
quantityDone: { currency: '', units: '100', nano: 0, value: 100 },
},
{
cursor: null,
accountId: '2084014113',
id: 'op-2',
parentOperationId: null,
date: '2024-05-20T14:30:00Z',
type: 'dividend',
category: 'income',
description: null,
name: 'Дивиденды Сбер',
state: 'executed',
instrumentUid: null,
figi: null,
ticker: 'SBER',
classCode: null,
instrumentType: 'share',
payment: { currency: 'RUB', units: '3500', nano: 0, value: 3500 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: null,
quantityDone: null,
},
]
export const mockBrokerEvents = [
{
id: 'ev-1',
type: 'dividend',
source: 'forecast',
category: 'cashflow',
eventDate: '2024-07-10',
paymentDate: null,
ticker: 'SBER',
name: 'Сбер Банк',
instrumentUid: 'uid-sber',
instrumentType: 'share',
quantitySnapshot: 100,
payoutPerUnit: 35,
estimatedAmount: 3500,
actualAmount: null,
currency: 'RUB',
estimateMode: 'current_position',
},
{
id: 'ev-2',
type: 'coupon',
source: 'forecast',
category: 'cashflow',
eventDate: '2024-07-15',
paymentDate: null,
ticker: 'SU26238RMFS5',
name: 'ОФЗ 26238',
instrumentUid: 'uid-bond',
instrumentType: 'bond',
quantitySnapshot: 10,
payoutPerUnit: 36.9,
estimatedAmount: 369,
actualAmount: null,
currency: 'RUB',
estimateMode: 'current_position',
},
]
export const mockEventsSummary = {
eventCount: 2,
nearestEventDate: '2024-07-10',
totalEstimatedCashflow: 3869,
actualCashflow: 0,
forecastEstimatedCashflow: 3869,
dividendsTotal: 3500,
couponsTotal: 369,
principalRepaymentTotal: 0,
actualDividendsTotal: 0,
actualCouponsTotal: 0,
actualPrincipalRepaymentTotal: 0,
}

View File

@ -1,22 +0,0 @@
export const mockCandles = [
{
open: 280,
high: 290,
low: 278,
close: 289.5,
volume: 1000000,
value: 280000000,
begin: '2024-01-15T10:00:00Z',
end: '2024-01-15T18:00:00Z',
},
{
open: 289,
high: 292,
low: 285,
close: 288,
volume: 800000,
value: 231200000,
begin: '2024-01-16T10:00:00Z',
end: '2024-01-16T18:00:00Z',
},
]

View File

@ -1,15 +0,0 @@
export { mockAuthResponse, mockUser } from './auth'
export { mockBond, mockBondHistory } from './bonds'
export {
mockBrokerAccounts,
mockBrokerEvents,
mockBrokerOperations,
mockBrokerPortfolio,
mockBrokerPositions,
mockEventsSummary,
} from './broker'
export { mockCandles } from './candles'
export { mockAnalytics, mockPortfolioDetail, mockPortfolios, mockPositions } from './portfolios'
export { mockScreenerItems } from './screener'
export { mockSearchResults } from './search'
export { mockDividends, mockShare, mockShareHistory } from './shares'

View File

@ -1,138 +0,0 @@
export const mockPortfolios = [
{
id: 1,
name: 'Основной портфель',
currency: 'RUB',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
totalValue: 285000,
positionCount: 5,
shareCount: 3,
bondCount: 2,
description: null,
},
{
id: 2,
name: 'ИИС',
currency: 'RUB',
createdAt: '2024-03-15T00:00:00Z',
updatedAt: '2024-06-10T00:00:00Z',
totalValue: 150000,
positionCount: 3,
shareCount: 2,
bondCount: 1,
description: 'Индивидуальный инвестиционный счёт',
},
]
export const mockPositions = [
{
id: 1,
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
quantity: 100,
buyPrice: 275,
currentPrice: 289.5,
totalCost: 27500,
currentValue: 28950,
weightPercent: 10.2,
pnl: 1450,
pnlPercent: 5.27,
dividendIncome: 3500,
totalReturn: 4950,
totalReturnPercent: 18,
change: 14.5,
changePercent: 5.27,
notes: null,
tags: ['DIVIDEND', 'GROWTH'],
},
{
id: 2,
secid: 'VTBR',
shortName: 'ВТБ',
type: 'share',
quantity: 5000,
buyPrice: 0.021,
currentPrice: 0.0234,
totalCost: 105,
currentValue: 117,
weightPercent: 0.04,
pnl: 12,
pnlPercent: 11.43,
dividendIncome: 0,
totalReturn: 12,
totalReturnPercent: 11.43,
change: 0.0024,
changePercent: 11.43,
notes: null,
tags: ['SPECULATIVE'],
},
{
id: 3,
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
quantity: 10,
buyPrice: 97,
currentPrice: 98.5,
totalCost: 9700,
currentValue: 9850,
weightPercent: 3.5,
pnl: 150,
pnlPercent: 1.55,
dividendIncome: 0,
totalReturn: 150,
totalReturnPercent: 1.55,
change: 1.5,
changePercent: 1.55,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
matDate: '2027-05-15',
accruedInt: 8.45,
bid: 98.3,
offer: 98.6,
couponPeriod: 182,
bondType: 'ОФЗ',
},
]
export const mockPortfolioDetail = {
id: 1,
name: 'Основной портфель',
currency: 'RUB',
description: null,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-06-01T00:00:00Z',
positions: mockPositions,
totalValue: 285000,
analytics: {
totalInvested: 250000,
totalValue: 285000,
totalPnl: 35000,
totalPnlPercent: 14,
totalDividends: 5000,
totalReturn: 40000,
totalReturnPercent: 16,
positionCount: 5,
weightedYield: null,
},
}
export const mockAnalytics = {
positions: mockPositions,
summary: {
totalInvested: 250000,
totalValue: 285000,
totalPnl: 35000,
totalPnlPercent: 14,
totalDividends: 5000,
totalReturn: 40000,
totalReturnPercent: 16,
positionCount: 5,
weightedYield: 7.5,
},
}

View File

@ -1,38 +0,0 @@
export const mockScreenerItems = [
{
secid: 'SBER',
shortName: 'Сбер',
isin: 'RU0009029540',
type: 'share',
price: 289.5,
change: 2.5,
changePercent: 0.87,
volume: 15000000,
listLevel: 1,
capitalization: 6250000000000,
},
{
secid: 'VTBR',
shortName: 'ВТБ',
isin: 'RU000A0JP5V6',
type: 'share',
price: 0.0234,
change: 0.0002,
changePercent: 0.86,
volume: 50000000,
listLevel: 1,
capitalization: 30000000000,
},
{
secid: 'GAZP',
shortName: 'Газпром',
isin: 'RU0007661625',
type: 'share',
price: 198.5,
change: -1.5,
changePercent: -0.75,
volume: 8000000,
listLevel: 1,
capitalization: 4700000000000,
},
]

View File

@ -1,20 +0,0 @@
export const mockSearchResults = [
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбер',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 289.5,
},
{
secid: 'VTBR',
isin: 'RU000A0JP5V6',
shortName: 'ВТБ',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 0.0234,
},
]

View File

@ -1,50 +0,0 @@
export const mockShare = {
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбер Банк',
shortName: 'Сбер',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586900000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 289.5,
change: 2.5,
changePercent: 0.87,
open: 287,
high: 291,
low: 286.5,
volume: 15000000,
value: 4350000000,
issueCapitalization: 6250000000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
export const mockDividends = [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
{ registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
]
export const mockShareHistory = [
{
date: '2024-01-15',
open: 287,
high: 291,
low: 286.5,
close: 289.5,
volume: 15000000,
value: 4350000000,
},
{
date: '2024-01-14',
open: 285,
high: 288,
low: 284,
close: 287,
volume: 12000000,
value: 3440000000,
},
]

View File

@ -1,151 +0,0 @@
import { HttpResponse, http } from 'msw'
import {
mockAnalytics,
mockAuthResponse,
mockBond,
mockBondHistory,
mockBrokerAccounts,
mockBrokerEvents,
mockBrokerOperations,
mockBrokerPortfolio,
mockBrokerPositions,
mockCandles,
mockDividends,
mockEventsSummary,
mockPortfolioDetail,
mockPortfolios,
mockPositions,
mockScreenerItems,
mockSearchResults,
mockShare,
mockShareHistory,
mockUser,
} from './data'
import { envelope } from './utils'
const API = '/api/v1'
export const handlers = [
http.get(`${API}/health`, () =>
HttpResponse.json(
envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }),
),
),
http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(mockUser))),
http.patch(`${API}/auth/me`, () => HttpResponse.json(envelope({ ...mockUser, name: 'Updated' }))),
http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(mockAuthResponse))),
http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))),
http.get(`${API}/securities/search`, ({ request }) => {
const url = new URL(request.url)
const q = url.searchParams.get('q') || ''
if (q.length < 2) return HttpResponse.json(envelope([]))
const filtered = mockSearchResults.filter(
(r) =>
r.secid.toLowerCase().includes(q.toLowerCase()) ||
r.shortName.toLowerCase().includes(q.toLowerCase()),
)
return HttpResponse.json(envelope(filtered))
}),
http.get(`${API}/securities/shares/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockShare, secid }))
}),
http.get(`${API}/securities/shares/:secid/marketdata`, () =>
HttpResponse.json(envelope(mockShare.marketData)),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json(envelope(mockDividends)),
),
http.get(`${API}/securities/shares/:secid/history`, () =>
HttpResponse.json(envelope(mockShareHistory)),
),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockBond, secid }))
}),
http.get(`${API}/securities/bonds/:secid/marketdata`, () =>
HttpResponse.json(envelope(mockBond.marketData)),
),
http.get(`${API}/securities/bonds/:secid/history`, () =>
HttpResponse.json(envelope(mockBondHistory)),
),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/screener`, () =>
HttpResponse.json(
envelope({
items: mockScreenerItems,
total: mockScreenerItems.length,
page: 1,
pageSize: 20,
totalPages: 1,
}),
),
),
http.get(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios))),
http.post(`${API}/portfolios`, () => HttpResponse.json(envelope(mockPortfolios[0]))),
http.get(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolioDetail))),
http.patch(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(mockPortfolios[0]))),
http.delete(`${API}/portfolios/:id`, () => HttpResponse.json(envelope(null))),
http.post(`${API}/portfolios/:id/positions`, () => HttpResponse.json(envelope(mockPositions[0]))),
http.patch(`${API}/portfolios/:id/positions/:positionId`, () =>
HttpResponse.json(envelope(mockPositions[0])),
),
http.delete(`${API}/portfolios/:id/positions/:positionId`, () =>
HttpResponse.json(envelope(null)),
),
http.get(`${API}/portfolios/:id/analytics`, () => HttpResponse.json(envelope(mockAnalytics))),
http.get(`${API}/broker/accounts`, () => HttpResponse.json(envelope(mockBrokerAccounts))),
http.get(`${API}/broker/accounts/:accountId/portfolio`, () =>
HttpResponse.json(envelope(mockBrokerPortfolio)),
),
http.get(`${API}/broker/accounts/:accountId/positions`, () =>
HttpResponse.json(
envelope({
accountId: '2084014113',
items: mockBrokerPositions,
nextCursor: null,
hasNext: false,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.get(`${API}/broker/accounts/:accountId/operations`, () =>
HttpResponse.json(
envelope({
accountId: '2084014113',
items: mockBrokerOperations,
nextCursor: null,
hasNext: false,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.get(`${API}/broker/accounts/:accountId/events`, () =>
HttpResponse.json(
envelope({
items: mockBrokerEvents,
summary: mockEventsSummary,
asOf: '2024-06-01T10:00:00Z',
}),
),
),
http.post(`${API}/broker/accounts/:accountId/operations/sync`, () =>
HttpResponse.json(envelope({ upserted: 5 })),
),
]

View File

@ -1,5 +0,0 @@
export function envelope(data: unknown) {
return {
data: { data, meta: { fromCache: false, cachedAt: null } },
}
}

View File

@ -8,7 +8,7 @@
"build": "vite build", "build": "vite build",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"preview": "vite preview", "preview": "vite preview",
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/shared/api/types.ts", "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
"lint": "biome check src/", "lint": "biome check src/",
"lint:fix": "biome check --write src/", "lint:fix": "biome check --write src/",
"format": "biome format --write src/", "format": "biome format --write src/",

View File

@ -1,4 +1,3 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
@ -6,6 +5,7 @@ import { HttpResponse, http } from 'msw'
import { useContext } from 'react' import { useContext } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { SessionContext } from '@/entities/session' import { SessionContext } from '@/entities/session'
import { server } from '@/shared/lib/test/server'
import { SessionProvider } from './SessionProvider' import { SessionProvider } from './SessionProvider'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,13 +1,13 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react' import { type ReactNode, useCallback, useEffect, useState } from 'react'
import * as sessionApi from '@/entities/session' import * as sessionApi from '@/entities/session'
import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session' import { SessionContext, type SessionContextValue } from '@/entities/session'
import { import {
getAccessToken, getAccessToken,
handleUnauthorized, handleUnauthorized,
setOnUnauthorized, setOnUnauthorized,
} from '@/entities/session/api/tokenManager' } from '@/entities/session/api/tokenManager'
import type { UserResponse } from '@/shared/api'
import { configureKyAuth } from '@/shared/api/kyClient' import { configureKyAuth } from '@/shared/api/kyClient'
import type { UserResponse } from '@/shared/api/responses'
export function SessionProvider({ children }: { children: ReactNode }) { export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(null) const [user, setUser] = useState<UserResponse | null>(null)
@ -18,13 +18,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => { const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
setUser(authData.user) setUser(authData.user)
setAccessTokenState(authData.accessToken) setAccessTokenState(authData.accessToken)
useSessionStore.getState().setSession(authData)
}, []) }, [])
const clearSession = useCallback(() => { const clearSession = useCallback(() => {
setUser(null) setUser(null)
setAccessTokenState(null) setAccessTokenState(null)
useSessionStore.getState().clearSession()
}, []) }, [])
const login = useCallback( const login = useCallback(

View File

@ -1,11 +1,11 @@
import { request } from '@/shared/api/kyClient'
import type { import type {
ApiResponseMeta, ApiResponseMeta,
BondHistoryItem, BondHistoryItem,
BondMarketData, BondMarketData,
BondResponse, BondResponse,
CandleItem, CandleItem,
} from '@/shared/api' } from '@/shared/api/responses'
import { request } from '@/shared/api/kyClient'
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> { export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`) return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`)

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { BondResponse } from '@/shared/api' import type { BondResponse } from '@/shared/api/responses'
import { getBond } from '../api/bondApi' import { getBond } from '../api/bondApi'
export function useBond(secid: string) { export function useBond(secid: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useBondCandles } from './useBondCandles' import { useBondCandles } from './useBondCandles'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { CandleItem } from '@/shared/api' import type { CandleItem } from '@/shared/api/responses'
import { getBondCandles } from '../api/bondApi' import { getBondCandles } from '../api/bondApi'
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses'
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview' import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview'
function portfolio( function portfolio(

View File

@ -1,4 +1,4 @@
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
export interface BrokerCurrencyAllocationSummary { export interface BrokerCurrencyAllocationSummary {
shares: number shares: number

View File

@ -1,5 +1,5 @@
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
import { getBrokerPortfolio } from '../api/brokerAccountApi' import { getBrokerPortfolio } from '../api/brokerAccountApi'
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { BrokerAccount } from '@/shared/api' import type { BrokerAccount } from '@/shared/api/responses'
import { getBrokerAccounts } from '../api/brokerAccountApi' import { getBrokerAccounts } from '../api/brokerAccountApi'
export function useBrokerAccounts() { export function useBrokerAccounts() {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses'
import { getBrokerPortfolio } from '../api/brokerAccountApi' import { getBrokerPortfolio } from '../api/brokerAccountApi'
export function useBrokerPortfolio(accountId: string | undefined) { export function useBrokerPortfolio(accountId: string | undefined) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'
export type BrokerEventsQuery = { export type BrokerEventsQuery = {
from: string from: string

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { BrokerEventsData } from '@/shared/api' import type { BrokerEventsData } from '@/shared/api/responses'
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi' import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) { export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'
export type BrokerOperationQuery = { export type BrokerOperationQuery = {
from?: string from?: string

View File

@ -1,4 +1,4 @@
import type { BrokerOperation } from '@/shared/api' import type { BrokerOperation } from '@/shared/api/responses'
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown' export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'

View File

@ -1,5 +1,5 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query' import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type { BrokerOperationsPage } from '@/shared/api' import type { BrokerOperationsPage } from '@/shared/api/responses'
import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi' import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi'
export function useBrokerOperations( export function useBrokerOperations(

View File

@ -1,5 +1,5 @@
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'
export function getBrokerPositions( export function getBrokerPositions(
accountId: string, accountId: string,

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api' import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
import { buildBrokerAllocation } from './brokerAllocation' import { buildBrokerAllocation } from './brokerAllocation'
function money(value: number): BrokerMoney { function money(value: number): BrokerMoney {

View File

@ -1,4 +1,4 @@
import type { BrokerPortfolio } from '@/shared/api' import type { BrokerPortfolio } from '@/shared/api/responses'
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other' export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'

View File

@ -5,7 +5,7 @@ import {
getBrokerOperationTypeLabel, getBrokerOperationTypeLabel,
isBrokerOperationType, isBrokerOperationType,
} from '@/entities/broker-operation' } from '@/entities/broker-operation'
import type { BrokerOperation, BrokerPosition } from '@/shared/api' import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses'
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay' import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'
function position(input: Partial<BrokerPosition>): BrokerPosition { function position(input: Partial<BrokerPosition>): BrokerPosition {

View File

@ -1,4 +1,4 @@
import type { BrokerPosition } from '@/shared/api' import type { BrokerPosition } from '@/shared/api/responses'
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other' export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'

View File

@ -1,5 +1,5 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query' import { keepPreviousData, useQuery } from '@tanstack/react-query'
import type { BrokerPositionsPage } from '@/shared/api' import type { BrokerPositionsPage } from '@/shared/api/responses'
import { getBrokerPositions } from '../api/brokerPositionApi' import { getBrokerPositions } from '../api/brokerPositionApi'
export function useBrokerPositions( export function useBrokerPositions(

View File

@ -1,5 +1,10 @@
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type {
AnalyticsResponse,
Portfolio,
PortfolioDetail,
Position,
} from '@/shared/api/responses'
export function getPortfolios(): Promise<{ export function getPortfolios(): Promise<{
data: Portfolio[] data: Portfolio[]

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { PortfolioDetail } from '@/shared/api' import type { PortfolioDetail } from '@/shared/api/responses'
import { getPortfolio } from '../api/portfolioApi' import { getPortfolio } from '../api/portfolioApi'
export function usePortfolio(id: number) { export function usePortfolio(id: number) {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { AnalyticsResponse } from '@/shared/api' import type { AnalyticsResponse } from '@/shared/api/responses'
import { getPortfolioAnalytics } from '../api/portfolioApi' import { getPortfolioAnalytics } from '../api/portfolioApi'
export function usePortfolioAnalytics(portfolioId: number) { export function usePortfolioAnalytics(portfolioId: number) {

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { Portfolio } from '@/shared/api' import type { Portfolio } from '@/shared/api/responses'
import { getPortfolios } from '../api/portfolioApi' import { getPortfolios } from '../api/portfolioApi'
export function usePortfolios() { export function usePortfolios() {

View File

@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { PortfolioDetail } from '@/shared/api' import type { PortfolioDetail } from '@/shared/api/responses'
import { addPosition, removePosition, updatePosition } from '../api/portfolioApi' import { addPosition, removePosition, updatePosition } from '../api/portfolioApi'
export function usePositionMutations(portfolioId: number) { export function usePositionMutations(portfolioId: number) {

View File

@ -1,5 +1,5 @@
import type { SearchResultItem } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { SearchResultItem } from '@/shared/api/responses'
export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) { export function searchSecurities(q: string, type: 'all' | 'share' | 'bond' = 'all', limit = 20) {
return request<SearchResultItem[]>('/api/v1/securities/search', { return request<SearchResultItem[]>('/api/v1/securities/search', {

View File

@ -1,10 +1,10 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { useSearch } from '@/entities/search' import { useSearch } from '@/entities/search'
import { server } from '@/shared/lib/test/server'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { SearchResultItem } from '@/shared/api' import type { SearchResultItem } from '@/shared/api/responses'
import { searchSecurities } from '../api/searchApi' import { searchSecurities } from '../api/searchApi'
export function useSearch(query: string) { export function useSearch(query: string) {

View File

@ -1,6 +1,6 @@
import { server } from '@mocks/server'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi' import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi'
import { getAccessToken, setAccessToken } from './tokenManager' import { getAccessToken, setAccessToken } from './tokenManager'

View File

@ -1,5 +1,5 @@
import type { AuthResponse, UserResponse } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { AuthResponse, UserResponse } from '@/shared/api/responses'
import { setAccessToken } from './tokenManager' import { setAccessToken } from './tokenManager'
export async function login(email: string, password: string) { export async function login(email: string, password: string) {

View File

@ -1,5 +1,5 @@
import type { AuthResponse } from '@/shared/api'
import { normalizeEnvelope } from '@/shared/api/kyClient' import { normalizeEnvelope } from '@/shared/api/kyClient'
import type { AuthResponse } from '@/shared/api/responses'
let accessToken: string | null = null let accessToken: string | null = null
let onUnauthorized: (() => void) | null = null let onUnauthorized: (() => void) | null = null

View File

@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { UserResponse } from '@/shared/api' import type { UserResponse } from '@/shared/api/responses'
interface SessionState { interface SessionState {
user: UserResponse | null user: UserResponse | null

View File

@ -1,3 +1,4 @@
import { request } from '@/shared/api/kyClient'
import type { import type {
ApiResponseMeta, ApiResponseMeta,
CandleItem, CandleItem,
@ -5,8 +6,7 @@ import type {
ShareHistoryItem, ShareHistoryItem,
ShareResponse, ShareResponse,
StockMarketData, StockMarketData,
} from '@/shared/api' } from '@/shared/api/responses'
import { request } from '@/shared/api/kyClient'
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> { export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`) return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`)

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { ShareResponse } from '@/shared/api' import type { ShareResponse } from '@/shared/api/responses'
import { getShare } from '../api/stockApi' import { getShare } from '../api/stockApi'
export function useStock(secid: string) { export function useStock(secid: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useStockCandles } from './useStockCandles' import { useStockCandles } from './useStockCandles'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { CandleItem } from '@/shared/api' import type { CandleItem } from '@/shared/api/responses'
import { getShareCandles } from '../api/stockApi' import { getShareCandles } from '../api/stockApi'
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {

View File

@ -1,9 +1,9 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { useStockDividends } from './useStockDividends' import { useStockDividends } from './useStockDividends'
const API = '/api/v1' const API = '/api/v1'

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { DividendItem } from '@/shared/api' import type { DividendItem } from '@/shared/api/responses'
import { getShareDividends } from '../api/stockApi' import { getShareDividends } from '../api/stockApi'
export function useStockDividends(secid: string) { export function useStockDividends(secid: string) {

View File

@ -1,5 +1,5 @@
import type { ScreenerResult } from '@/shared/api'
import { request } from '@/shared/api/kyClient' import { request } from '@/shared/api/kyClient'
import type { ScreenerResult } from '@/shared/api/responses'
export interface ScreenerQuery { export interface ScreenerQuery {
type: 'share' | 'bond' type: 'share' | 'bond'

View File

@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import type { ScreenerResult } from '@/shared/api' import type { ScreenerResult } from '@/shared/api/responses'
interface Props { interface Props {
result: ScreenerResult result: ScreenerResult

View File

@ -7,7 +7,7 @@ import { env } from './shared/config/env'
async function startApp() { async function startApp() {
if (env.VITE_API_MOCK) { if (env.VITE_API_MOCK) {
const { worker } = await import('../mocks/browser') const { worker } = await import('./shared/lib/test/browser')
await worker.start({ onUnhandledRequest: 'bypass' }) await worker.start({ onUnhandledRequest: 'bypass' })
} }

View File

@ -1,4 +1,3 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
createMemoryHistory, createMemoryHistory,
@ -11,6 +10,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
import { LoginPage } from './ui/LoginPage' import { LoginPage } from './ui/LoginPage'

View File

@ -9,7 +9,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary'
import { SharePositionTable } from '@/widgets/share-positions-table' import { SharePositionTable } from '@/widgets/share-positions-table'
export function PortfolioDetailPage() { export function PortfolioDetailPage() {
const { id } = useParams({ from: '/portfolios/$id' }) const { id } = useParams<{ id: string }>()
const portfolioId = parseInt(id!, 10) const portfolioId = parseInt(id!, 10)
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId) const { data: portfolio, isLoading, error } = usePortfolio(portfolioId)

View File

@ -1,8 +1,8 @@
import { server } from '@mocks/server'
import { screen, waitFor } from '@testing-library/react' import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { renderWithProviders } from '@/shared/lib/test/test-utils' import { renderWithProviders } from '@/shared/lib/test/test-utils'
import { ProfilePage } from './ui/ProfilePage' import { ProfilePage } from './ui/ProfilePage'

View File

@ -1,4 +1,3 @@
import { server } from '@mocks/server'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { import {
createMemoryHistory, createMemoryHistory,
@ -11,6 +10,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { server } from '@/shared/lib/test/server'
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider' import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
import { RegisterPage } from './ui/RegisterPage' import { RegisterPage } from './ui/RegisterPage'

View File

@ -5,7 +5,7 @@ import { PriceChart } from '@/widgets/price-chart'
import { StockDetails } from '@/widgets/stock-details' import { StockDetails } from '@/widgets/stock-details'
export function StockPage() { export function StockPage() {
const { secid } = useParams({ from: '/stocks/$secid' }) const { secid } = useParams<{ secid: string }>()
const { data: stock, isLoading, error } = useStock(secid!) const { data: stock, isLoading, error } = useStock(secid!)
const till = new Date().toISOString().split('T')[0] const till = new Date().toISOString().split('T')[0]
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]

View File

@ -0,0 +1,163 @@
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import {
getAccessToken,
handleUnauthorized,
setAccessToken,
setOnUnauthorized,
} from '@/entities/session/api/tokenManager'
import { server } from '@/shared/lib/test/server'
import { configureKyAuth, request } from './kyClient'
const API = '/api/v1'
beforeEach(() => {
setAccessToken(null)
configureKyAuth({
getAccessToken,
handleUnauthorized,
})
})
describe('request', () => {
it('makes GET request and returns data', async () => {
const result = await request<{ status: string; timestamp: string; uptime: number }>(
'/api/v1/health',
)
expect(result.data.status).toBe('ok')
})
it('supports the single API envelope shape documented by Swagger', async () => {
server.use(
http.get(`${API}/test-single-envelope`, () =>
HttpResponse.json({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
}),
),
)
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope')
expect(result).toEqual({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
})
})
it('includes Authorization header when token is set', async () => {
setAccessToken('test-token')
let capturedAuth: string | null = null
server.use(
http.get(`${API}/test-auth`, ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-auth')
expect(capturedAuth).toBe('Bearer test-token')
})
it('retries on 401 and succeeds after refresh', async () => {
setAccessToken('expired-token')
let attempts = 0
server.use(
http.get(`${API}/test-retry`, ({ request }) => {
attempts++
const auth = request.headers.get('Authorization')
if (auth === 'Bearer expired-token') {
return new HttpResponse(null, { status: 401 })
}
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
http.post(`${API}/auth/refresh`, () =>
HttpResponse.json({
data: {
data: {
user: { id: 1, email: 'user@test.com', name: null, role: 'user' },
accessToken: 'new-token',
},
meta: { fromCache: false, cachedAt: null },
},
}),
),
)
const result = await request<{ ok: boolean }>('/api/v1/test-retry')
expect(attempts).toBe(2)
expect(result.data).toEqual({ ok: true })
expect(getAccessToken()).toBe('new-token')
})
it('throws on persistent 401 and clears token', async () => {
setAccessToken('expired-token')
let unauthorizedCalled = false
setOnUnauthorized(() => {
unauthorizedCalled = true
})
server.use(
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
)
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла')
expect(getAccessToken()).toBeNull()
expect(unauthorizedCalled).toBe(true)
})
it('throws on non-ok response with status text', async () => {
server.use(
http.get(
`${API}/test-error`,
() => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }),
),
)
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404')
})
it('sends JSON body for POST requests', async () => {
let capturedBody: string | null = null
server.use(
http.post(`${API}/test-post`, async ({ request }) => {
capturedBody = await request.text()
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } })
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }))
})
it('does not send auth header when skipAuth is true', async () => {
setAccessToken('test-token')
let capturedAuth: string | null = null
server.use(
http.get(`${API}/test-skip`, ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-skip', undefined, { skipAuth: true })
expect(capturedAuth).toBeNull()
})
it('sets query params correctly', async () => {
let capturedUrl = ''
server.use(
http.get(`${API}/test-params`, ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
})
}),
)
await request('/api/v1/test-params', { q: 'sber', type: 'share' })
expect(capturedUrl).toContain('q=sber')
expect(capturedUrl).toContain('type=share')
})
})

View File

@ -1,62 +1,33 @@
import type { components } from './types'
export { configureKyAuth, getHealth, request } from './kyClient' export { configureKyAuth, getHealth, request } from './kyClient'
export type {
export type ApiResponseMeta = components['schemas']['ApiResponseMeta'] AnalyticsResponse,
ApiEnvelope,
export interface ApiEnvelope<T> { ApiResponseMeta,
data: T AuthResponse,
meta: ApiResponseMeta BondHistoryItem,
} BondMarketData,
BondResponse,
// Auth BrokerAccount,
export type AuthResponse = components['schemas']['AuthTokenDataDto'] BrokerMoney,
export type UserResponse = components['schemas']['AuthUserDto'] BrokerOperation,
BrokerOperationCategory,
// Shares BrokerOperationsPage,
export type ShareResponse = components['schemas']['ShareResponseDto'] BrokerPortfolio,
export type StockMarketData = components['schemas']['StockMarketDataDto'] BrokerPosition,
export type DividendItem = components['schemas']['DividendItemDto'] BrokerPositionsPage,
export type ShareHistoryItem = components['schemas']['HistoryItemDto'] CandleItem,
DividendItem,
// Bonds HealthResponse,
export type BondResponse = components['schemas']['BondResponseDto'] Portfolio,
export type BondMarketData = components['schemas']['BondMarketDataDto'] PortfolioDetail,
export type BondHistoryItem = components['schemas']['BondHistoryItemDto'] PortfolioSummary,
Position,
// Candles PositionWithPrice,
export type CandleItem = components['schemas']['CandleItemDto'] ScreenerItem,
ScreenerResult,
// Search SearchResultItem,
export type SearchResultItem = components['schemas']['SearchResultItemDto'] ShareHistoryItem,
ShareResponse,
// Screener StockMarketData,
export type ScreenerResult = components['schemas']['ScreenerResultDto'] UserResponse,
export type ScreenerItem = components['schemas']['ScreenerItemDto'] } from './responses'
// Portfolio
export type Portfolio = components['schemas']['PortfolioListResponseDto']
export type PortfolioDetail = components['schemas']['PortfolioDetailResponseDto']
export type Position = components['schemas']['PositionResponseDto']
export type PositionWithPrice = components['schemas']['PositionWithPriceDto']
export type PortfolioSummary = components['schemas']['PortfolioSummaryDto']
export type AnalyticsResponse = components['schemas']['AnalyticsResponseDto']
// Health
export type HealthResponse = components['schemas']['HealthResponseDto']
// Broker
export type BrokerAccount = components['schemas']['BrokerAccountResponseDto']
export type BrokerPortfolio = components['schemas']['BrokerPortfolioResponseDto']
export type BrokerMoney = components['schemas']['BrokerMoneyDto']
export type BrokerPosition = components['schemas']['BrokerPositionResponseDto']
export type BrokerOperation = components['schemas']['BrokerOperationResponseDto']
export type BrokerOperationsPage = components['schemas']['BrokerOperationsPageResponseDto']
export type BrokerPositionsPage = components['schemas']['BrokerPositionsPageResponseDto']
export type BrokerOperationCategory =
components['schemas']['BrokerOperationResponseDto']['category']
// Broker events
export type BrokerEventItem = components['schemas']['BrokerEventItemDto']
export type BrokerEventsData = components['schemas']['BrokerEventsDataDto']
export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto']

View File

@ -0,0 +1,391 @@
export interface ApiResponseMeta {
cachedAt: string | null
fromCache: boolean
}
export interface ApiEnvelope<T> {
data: T
meta: ApiResponseMeta
}
export interface StockMarketData {
price: number | null
change: number | null
changePercent: number | null
open: number | null
high: number | null
low: number | null
volume: number
value: number
issueCapitalization: number | null
updatedAt: string
}
export interface ShareResponse {
secid: string
isin: string
name: string
shortName: string
latName: string | null
listLevel: number
issueSize: number
faceValue: number
faceUnit: string
type: string
marketData: StockMarketData
}
export interface DividendItem {
registryCloseDate: string
value: number
currency: string
}
export interface ShareHistoryItem {
date: string
open: number
high: number
low: number
close: number
volume: number
value: number
}
export interface BondMarketData {
price: number | null
yieldToMaturity: number | null
duration: number | null
accruedInt: number | null
couponValue: number | null
couponPercent: number | null
nextCouponDate: string | null
open: number
high: number | null
low: number | null
volume: number
updatedAt: string
}
export interface BondResponse {
secid: string
isin: string
name: string
shortName: string
latName: string | null
listLevel: number
issueSize: number
faceValue: number
faceUnit: string
matDate: string
couponValue: number
couponPercent: number | null
couponPeriod: number
nextCoupon: string | null
accruedInt: number
bondType: string
bondSubType: string
offerDate: string | null
buybackDate: string | null
marketData: BondMarketData
}
export interface BondHistoryItem {
date: string
closePrice: number
yieldClose: number | null
duration: number | null
}
export interface CandleItem {
open: number
high: number
low: number
close: number
volume: number
value: number
begin: string
end: string
}
export interface SearchResultItem {
secid: string
isin: string
shortName: string
type: 'share' | 'bond'
listLevel: number
currency: string | null
price: number | null
}
export interface HealthResponse {
status: string
timestamp: string
uptime: number
}
export interface UserResponse {
id: number
email: string
name: string | null
role: string
}
export interface AuthResponse {
user: UserResponse
accessToken: string
}
export interface Portfolio {
id: number
name: string
description: string | null
currency: string
createdAt: string
updatedAt: string
totalValue: number
positionCount: number
shareCount: number
bondCount: number
}
export interface PositionWithPrice {
id: number
portfolioId: number
secid: string
shortName: string | null
type: 'share' | 'bond'
quantity: number
notes: string | null
tags: string[] | null
currentPrice: number | null
buyPrice: number | null
buyDate: string | null
totalCost: number | null
currentValue: number | null
pnl: number | null
pnlPercent: number | null
dividendIncome: number | null
totalReturn: number | null
totalReturnPercent: number | null
weightPercent: number
change?: number | null
changePercent?: number | null
yieldToMaturity?: number | null
duration?: number | null
couponValue?: number | null
couponPercent?: number | null
nextCouponDate?: string | null
matDate?: string | null
accruedInt?: number | null
bid?: number | null
offer?: number | null
couponPeriod?: number | null
bondType?: string | null
offerDate?: string | null
}
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[]
totalValue: number
analytics: PortfolioSummary
}
export interface Position {
id: number
secid: string
quantity: number
notes: string | null
tags: string[] | null
portfolioId: number
createdAt: string
updatedAt: string
}
export interface PortfolioSummary {
totalInvested: number
totalValue: number
totalPnl: number
totalPnlPercent: number | null
totalDividends: number
totalReturn: number
totalReturnPercent: number | null
positionCount: number
weightedYield: number | null
}
export interface AnalyticsResponse {
positions: PositionWithPrice[]
summary: PortfolioSummary
}
export interface ScreenerItem {
secid: string
shortName: string
isin: string
type: 'share' | 'bond'
price: number | null
change: number | null
changePercent: number | null
volume: number
listLevel: number
capitalization: number | null
yieldToMaturity: number | null
duration: number | null
couponValue: number | null
couponPercent: number | null
accruedInt: number | null
matDate: string | null
bondType: string | null
}
export interface ScreenerResult {
items: ScreenerItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface BrokerMoney {
currency: string
units: string
nano: number
value: number
}
export interface BrokerAccount {
id: string
type: 'brokerage' | 'iis'
name: string
status: string
openedAt: string | null
accessLevel: string | null
}
export interface BrokerPosition {
figi: string | null
instrumentUid: string | null
positionUid: string | null
ticker: string | null
classCode: string | null
instrumentType: string | null
name: string | null
quantity: number | null
blockedLots: number | null
currentPrice: BrokerMoney | null
currentValue: BrokerMoney | null
averagePositionPrice: BrokerMoney | null
expectedYieldPercent: number | null
dailyYield: BrokerMoney | null
}
export interface BrokerPortfolio {
account: BrokerAccount
positionCounts: {
shares: number
bonds: number
etf: number
other: number
}
totals: {
shares: BrokerMoney | null
bonds: BrokerMoney | null
etf: BrokerMoney | null
currencies: BrokerMoney | null
futures: BrokerMoney | null
options: BrokerMoney | null
structuredProducts: BrokerMoney | null
dfa: BrokerMoney | null
portfolio: BrokerMoney | null
}
yields: {
expectedPercent: number | null
daily: BrokerMoney | null
dailyPercent: number | null
}
cash: BrokerMoney[]
blockedCash: BrokerMoney[]
asOf: string
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other'
export interface BrokerOperation {
cursor: string | null
accountId: string
id: string | null
parentOperationId: string | null
date: string | null
type: string
category: BrokerOperationCategory
description: string | null
name: string | null
state: string | null
instrumentUid: string | null
figi: string | null
ticker: string | null
classCode: string | null
instrumentType: string | null
payment: BrokerMoney | null
price: BrokerMoney | null
commission: BrokerMoney | null
yield: BrokerMoney | null
accruedInt: BrokerMoney | null
quantity: number | null
quantityDone: number | null
}
export interface BrokerOperationsPage {
accountId: string
items: BrokerOperation[]
nextCursor: string | null
hasNext: boolean
asOf: string
}
export interface BrokerPositionsPage {
accountId: string
items: BrokerPosition[]
nextCursor: string | null
hasNext: boolean
asOf: string
}
export interface BrokerPortfolioEvent {
id: string
type: 'dividend' | 'coupon' | 'maturity' | 'offer'
source: 'forecast' | 'actual'
category: 'cashflow' | 'corporate'
eventDate: string
paymentDate: string | null
ticker: string | null
name: string | null
instrumentUid: string | null
instrumentType: 'share' | 'bond' | 'other'
quantitySnapshot: number | null
payoutPerUnit: number | null
estimatedAmount: number | null
actualAmount: number | null
currency: string | null
estimateMode: 'current_position' | null
}
export interface BrokerEventsSummary {
eventCount: number
nearestEventDate: string | null
totalEstimatedCashflow: number
actualCashflow: number
forecastEstimatedCashflow: number
dividendsTotal: number
couponsTotal: number
principalRepaymentTotal: number
actualDividendsTotal: number
actualCouponsTotal: number
actualPrincipalRepaymentTotal: number
}
export interface BrokerEventsData {
items: BrokerPortfolioEvent[]
summary: BrokerEventsSummary
asOf: string
}

View File

@ -451,23 +451,6 @@ export interface paths {
patch?: never patch?: never
trace?: never trace?: never
} }
'/api/v1/broker/accounts/{accountId}/events': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Get broker account calendar events and cashflow */
get: operations['TBankController_getEvents']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/api/v1/broker/accounts/{accountId}/operations/sync': { '/api/v1/broker/accounts/{accountId}/operations/sync': {
parameters: { parameters: {
query?: never query?: never
@ -489,22 +472,6 @@ export interface paths {
export type webhooks = Record<string, never> export type webhooks = Record<string, never>
export interface components { export interface components {
schemas: { schemas: {
ApiResponseMeta: {
cachedAt: string | null
fromCache: boolean
}
HealthResponseDto: {
/** @example ok */
status: string
/** @example 2026-06-23T06:00:00.000Z */
timestamp: string
/** @example 12345 */
uptime: number
}
HealthEnvelopeDto: {
data: components['schemas']['HealthResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
RegisterDto: { RegisterDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string email: string
@ -552,26 +519,6 @@ export interface components {
/** @example John Doe */ /** @example John Doe */
name?: string name?: string
} }
SearchResultItemDto: {
/** @example SBER */
secid: string
/** @example RU0009029540 */
isin: string
/** @example Сбербанк */
shortName: string
/** @enum {string} */
type: 'share' | 'bond'
/** @example 1 */
listLevel: number
/** @example RUB */
currency?: string | null
/** @example 322.35 */
price?: number | null
}
SearchEnvelopeDto: {
data: components['schemas']['SearchResultItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
ScreenerItemDto: { ScreenerItemDto: {
/** @example SBER */ /** @example SBER */
secid: string secid: string
@ -623,215 +570,6 @@ export interface components {
data: components['schemas']['ScreenerResultDto'] data: components['schemas']['ScreenerResultDto']
meta: components['schemas']['ScreenerResponseMetaDto'] meta: components['schemas']['ScreenerResponseMetaDto']
} }
StockMarketDataDto: {
/** @example 322.35 */
price: number
/** @example 1.15 */
change: number
/** @example 0.36 */
changePercent: number
/** @example 321.3 */
open: number
/** @example 322.66 */
high?: Record<string, never>
/** @example 321.2 */
low?: Record<string, never>
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 6958336818320 */
issueCapitalization?: Record<string, never>
updatedAt: string
}
ShareResponseDto: {
/** @example SBER */
secid: string
/** @example RU0009029540 */
isin: string
/** @example Сбербанк России ПАО ао */
name: string
/** @example Сбербанк */
shortName: string
latName?: Record<string, never>
/** @example 1 */
listLevel: number
/** @example 21586948000 */
issueSize: number
/** @example 3 */
faceValue: number
/** @example RUB */
faceUnit: string
/** @example common_share */
type: string
marketData: components['schemas']['StockMarketDataDto']
}
ShareEnvelopeDto: {
data: components['schemas']['ShareResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
ShareMarketDataResponseDto: {
/** @example 322.35 */
price: number
/** @example 1.15 */
change: number
/** @example 0.36 */
changePercent: number
/** @example 321.3 */
open: number
/** @example 322.66 */
high?: Record<string, never>
/** @example 321.2 */
low?: Record<string, never>
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 6958336818320 */
issueCapitalization?: Record<string, never>
updatedAt: string
}
ShareMarketDataEnvelopeDto: {
data: components['schemas']['ShareMarketDataResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
DividendItemDto: {
/** @example 2026-05-15 */
registryCloseDate: string
/** @example 33.47 */
value: number
/** @example RUB */
currency: string
}
DividendsEnvelopeDto: {
data: components['schemas']['DividendItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
HistoryItemDto: {
/** @example 2026-06-01 */
date: string
/** @example 321.3 */
open: number
/** @example 322.66 */
high: number
/** @example 321.2 */
low: number
/** @example 322.35 */
close: number
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
}
ShareHistoryEnvelopeDto: {
data: components['schemas']['HistoryItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
BondMarketDataDto: {
/**
* @description Цена в % от номинала
* @example 100.45
*/
price: number
/** @example 12.71 */
yieldToMaturity?: Record<string, never>
duration?: Record<string, never>
/** @example 29.48 */
accruedInt: number
/** @example 40.64 */
couponValue: number
/** @example 8.15 */
couponPercent?: Record<string, never>
/** @example 2026-08-05 */
nextCouponDate?: Record<string, never>
open: number
high?: Record<string, never>
low?: Record<string, never>
volume: number
updatedAt: string
}
BondResponseDto: {
/** @example SU26207RMFS9 */
secid: string
/** @example RU000A0JS3W6 */
isin: string
/** @example ОФЗ-ПД 26207 03/02/27 */
name: string
/** @example ОФЗ 26207 */
shortName: string
latName?: Record<string, never>
/** @example 1 */
listLevel: number
/** @example 370200604 */
issueSize: number
/** @example 1000 */
faceValue: number
/** @example RUB */
faceUnit: string
/** @example 2027-02-03 */
matDate: string
/** @example 40.64 */
couponValue: number
/** @example 8.15 */
couponPercent?: Record<string, never>
/** @example 182 */
couponPeriod: number
/** @example 2026-08-05 */
nextCoupon: Record<string, never>
/** @example 29.48 */
accruedInt: number
/** @example Фикс с известным купоном */
bondType: string
/** @example До погашения */
bondSubType: string
offerDate?: Record<string, never>
buybackDate?: Record<string, never>
marketData: components['schemas']['BondMarketDataDto']
}
BondEnvelopeDto: {
data: components['schemas']['BondResponseDto']
meta: components['schemas']['ApiResponseMeta']
}
BondMarketDataEnvelopeDto: {
data: components['schemas']['BondMarketDataDto']
meta: components['schemas']['ApiResponseMeta']
}
BondHistoryItemDto: {
/** @example 2026-06-01 */
date: string
/** @example 100.45 */
closePrice: number
/** @example 12.71 */
yieldClose?: number | null
/** @example 4.5 */
duration?: number | null
}
BondHistoryEnvelopeDto: {
data: components['schemas']['BondHistoryItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
CandleItemDto: {
/** @example 321.3 */
open: number
/** @example 322.66 */
high: number
/** @example 321.2 */
low: number
/** @example 322.35 */
close: number
/** @example 1925163 */
volume: number
/** @example 620184479 */
value: number
/** @example 2026-06-01T10:00:00 */
begin: string
/** @example 2026-06-01T10:59:00 */
end: string
}
CandleEnvelopeDto: {
data: components['schemas']['CandleItemDto'][]
meta: components['schemas']['ApiResponseMeta']
}
PortfolioResponseMetaDto: { PortfolioResponseMetaDto: {
cachedAt: string | null cachedAt: string | null
fromCache: boolean fromCache: boolean
@ -1157,51 +895,6 @@ export interface components {
data: components['schemas']['BrokerOperationsPageResponseDto'] data: components['schemas']['BrokerOperationsPageResponseDto']
meta: components['schemas']['BrokerResponseMetaDto'] meta: components['schemas']['BrokerResponseMetaDto']
} }
BrokerEventItemDto: {
id: string
/** @enum {string} */
type: 'dividend' | 'coupon' | 'maturity' | 'offer'
/** @enum {string} */
source: 'forecast' | 'actual'
/** @enum {string} */
category: 'cashflow' | 'corporate'
eventDate: string
paymentDate: string | null
ticker: string | null
name: string | null
instrumentUid: string | null
/** @enum {string} */
instrumentType: 'share' | 'bond' | 'other'
quantitySnapshot: number | null
payoutPerUnit: number | null
estimatedAmount: number | null
actualAmount: number | null
currency: string | null
/** @enum {string|null} */
estimateMode: 'current_position' | null
}
BrokerEventsSummaryDto: {
eventCount: number
nearestEventDate: string | null
totalEstimatedCashflow: number
actualCashflow: number
forecastEstimatedCashflow: number
dividendsTotal: number
couponsTotal: number
principalRepaymentTotal: number
actualDividendsTotal: number
actualCouponsTotal: number
actualPrincipalRepaymentTotal: number
}
BrokerEventsDataDto: {
items: components['schemas']['BrokerEventItemDto'][]
summary: components['schemas']['BrokerEventsSummaryDto']
asOf: string
}
BrokerEventsEnvelopeDto: {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto']
}
BrokerOperationSyncResponseDto: { BrokerOperationSyncResponseDto: {
/** @example 42 */ /** @example 42 */
upserted: number upserted: number
@ -1232,9 +925,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['HealthEnvelopeDto']
}
} }
} }
} }
@ -1382,9 +1073,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['SearchEnvelopeDto']
}
} }
} }
} }
@ -1446,9 +1135,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['ShareEnvelopeDto']
}
} }
} }
} }
@ -1467,9 +1154,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['ShareMarketDataEnvelopeDto']
}
} }
} }
} }
@ -1488,9 +1173,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['DividendsEnvelopeDto']
}
} }
} }
} }
@ -1512,9 +1195,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['ShareHistoryEnvelopeDto']
}
} }
} }
} }
@ -1533,9 +1214,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['BondEnvelopeDto']
}
} }
} }
} }
@ -1554,9 +1233,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['BondMarketDataEnvelopeDto']
}
} }
} }
} }
@ -1578,9 +1255,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['BondHistoryEnvelopeDto']
}
} }
} }
} }
@ -1603,9 +1278,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['CandleEnvelopeDto']
}
} }
} }
} }
@ -1628,9 +1301,7 @@ export interface operations {
headers: { headers: {
[name: string]: unknown [name: string]: unknown
} }
content: { content?: never
'application/json': components['schemas']['CandleEnvelopeDto']
}
} }
} }
} }
@ -1939,34 +1610,6 @@ export interface operations {
} }
} }
} }
TBankController_getEvents: {
parameters: {
query: {
/** @description Start date inclusive (YYYY-MM-DD) */
from: string
/** @description End date inclusive (YYYY-MM-DD) */
to: string
/** @description Comma-separated event types to include */
types?: string
}
header?: never
path: {
accountId: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BrokerEventsEnvelopeDto']
}
}
}
}
TBankController_syncOperations: { TBankController_syncOperations: {
parameters: { parameters: {
query: { query: {

View File

@ -1,4 +1,4 @@
import type { BrokerMoney } from '@/shared/api' import type { BrokerMoney } from '@/shared/api/responses'
export function formatBrokerCurrencyValue(currency: string, value: number): string { export function formatBrokerCurrencyValue(currency: string, value: number): string {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {

View File

@ -1,5 +1,5 @@
import { createContext } from 'react' import { createContext } from 'react'
import type { UserResponse } from '@/shared/api' import type { UserResponse } from '@/shared/api/responses'
export interface SessionContextValue { export interface SessionContextValue {
user: UserResponse | null user: UserResponse | null

View File

@ -8,7 +8,7 @@ import type {
ShareResponse, ShareResponse,
StockMarketData, StockMarketData,
UserResponse, UserResponse,
} from '@/shared/api' } from '@/shared/api/responses'
export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData { export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData {
return { return {

View File

@ -0,0 +1,195 @@
import { HttpResponse, http } from 'msw'
import type {
BondResponse,
CandleItem,
SearchResultItem,
ShareResponse,
} from '@/shared/api/responses'
const API = '/api/v1'
const mockShare: ShareResponse = {
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбер Банк',
shortName: 'Сбер',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586900000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 289.5,
change: 2.5,
changePercent: 0.87,
open: 287,
high: 291,
low: 286.5,
volume: 15000000,
value: 4350000000,
issueCapitalization: 6250000000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
const mockBond: BondResponse = {
secid: 'SU26238RMFS5',
isin: 'RU000A101XU7',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 500000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2027-05-15',
couponValue: 36.9,
couponPercent: 7.5,
couponPeriod: 182,
nextCoupon: '2024-07-15',
accruedInt: 8.45,
bondType: 'ОФЗ',
bondSubType: 'ОФЗ-ПД',
offerDate: null,
buybackDate: null,
marketData: {
price: 98.5,
yieldToMaturity: 8.2,
duration: 3.5,
accruedInt: 8.45,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
open: 98.0,
high: 99.0,
low: 97.5,
volume: 1000000,
updatedAt: '2024-01-15T10:00:00Z',
},
}
const mockCandles: CandleItem[] = [
{
open: 280,
high: 290,
low: 278,
close: 289.5,
volume: 1000000,
value: 280000000,
begin: '2024-01-15T10:00:00Z',
end: '2024-01-15T18:00:00Z',
},
{
open: 289,
high: 292,
low: 285,
close: 288,
volume: 800000,
value: 231200000,
begin: '2024-01-16T10:00:00Z',
end: '2024-01-16T18:00:00Z',
},
]
const mockDividends = [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
{ registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
]
const mockSearchResults: SearchResultItem[] = [
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбер',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 289.5,
},
{
secid: 'VTBR',
isin: 'RU000A0JP5V6',
shortName: 'ВТБ',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: 0.0234,
},
]
const userResponse = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
}
const authResponse = {
user: userResponse,
accessToken: 'mock-access-token',
}
const envelope = (data: unknown) => ({
data: { data, meta: { fromCache: false, cachedAt: null } },
})
export const handlers = [
http.get(`${API}/securities/search`, ({ request }) => {
const url = new URL(request.url)
const q = url.searchParams.get('q') || ''
if (q.length < 2) {
return HttpResponse.json(envelope([]))
}
const filtered = mockSearchResults.filter(
(r) =>
r.secid.toLowerCase().includes(q.toLowerCase()) ||
r.shortName.toLowerCase().includes(q.toLowerCase()),
)
return HttpResponse.json(envelope(filtered))
}),
http.get(`${API}/securities/shares/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockShare, secid } as ShareResponse))
}),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json(envelope(mockDividends)),
),
http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
const { secid } = params
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 })
return HttpResponse.json(envelope({ ...mockBond, secid } as BondResponse))
}),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json(envelope(mockCandles)),
),
http.get(`${API}/auth/me`, () => HttpResponse.json(envelope(userResponse))),
http.post(`${API}/auth/login`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/register`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/refresh`, () => HttpResponse.json(envelope(authResponse))),
http.post(`${API}/auth/logout`, () => HttpResponse.json(envelope({ message: 'Logged out' }))),
http.patch(`${API}/auth/me`, () =>
HttpResponse.json(envelope({ ...userResponse, name: 'Updated' })),
),
http.get(`${API}/health`, () =>
HttpResponse.json(
envelope({ status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }),
),
),
]

View File

@ -1,5 +1,5 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { server } from '@mocks/server' import { server } from './server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) afterEach(() => server.resetHandlers())

View File

@ -1,4 +1,4 @@
import type { BondResponse } from '@/shared/api' import type { BondResponse } from '@/shared/api/responses'
interface BondDetailsProps { interface BondDetailsProps {
bond: BondResponse bond: BondResponse

View File

@ -1,6 +1,6 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
import type { PositionWithPrice } from '@/shared/api' import type { PositionWithPrice } from '@/shared/api/responses'
interface Props { interface Props {
position: PositionWithPrice position: PositionWithPrice

View File

@ -1,4 +1,4 @@
import type { PositionWithPrice } from '@/shared/api' import type { PositionWithPrice } from '@/shared/api/responses'
import { BondPositionRow } from './BondPositionRow' import { BondPositionRow } from './BondPositionRow'
interface Props { interface Props {

View File

@ -2,7 +2,7 @@ import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system
import { Box } from '@mui/material' import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { buildBrokerAllocation } from '@/entities/broker-position' import { buildBrokerAllocation } from '@/entities/broker-position'
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api' import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
import { import {
formatBrokerDate, formatBrokerDate,
formatBrokerMoney, formatBrokerMoney,

View File

@ -1,3 +1,3 @@
export { useBrokerAccountContext } from './lib/useBrokerAccountContext' export { useBrokerAccountContext } from './lib/useBrokerAccountContext'
export type { BrokerAccountContextValue } from './ui/BrokerAccountLayout' export type { BrokerAccountContext } from './ui/BrokerAccountLayout'
export { BrokerAccountLayout } from './ui/BrokerAccountLayout' export { BrokerAccountLayout } from './ui/BrokerAccountLayout'

View File

@ -1,10 +1,6 @@
import { useContext } from 'react' import { useParams } from '@tanstack/react-router'
import { BrokerAccountContext } from '../ui/BrokerAccountLayout'
export function useBrokerAccountContext() { export function useBrokerAccountContext() {
const context = useContext(BrokerAccountContext) const { accountId = '' } = useParams({ from: '/broker/$accountId' })
if (!context) { return { accountId }
throw new Error('useBrokerAccountContext must be used within BrokerAccountLayout')
}
return context
} }

View File

@ -1,10 +1,8 @@
import { Heading } from '@moex-vibe/design-system' import { Heading } from '@moex-vibe/design-system'
import { Box } from '@mui/material' import { Box } from '@mui/material'
import type { UseQueryResult } from '@tanstack/react-query'
import { Link, useParams } from '@tanstack/react-router' import { Link, useParams } from '@tanstack/react-router'
import { createContext, type ReactNode } from 'react' import type { ReactNode } from 'react'
import { useBrokerPortfolio } from '@/entities/broker-account' import { useBrokerPortfolio } from '@/entities/broker-account'
import type { BrokerPortfolio } from '@/shared/api'
const baseLinkStyle: React.CSSProperties = { const baseLinkStyle: React.CSSProperties = {
padding: '10px 12px', padding: '10px 12px',
@ -19,79 +17,69 @@ const baseLinkStyle: React.CSSProperties = {
} }
const links = [ const links = [
{ to: '', label: 'Обзор' }, { to: '.', label: 'Обзор' },
{ to: '/shares', label: 'Акции' }, { to: '/shares', label: 'Акции' },
{ to: '/bonds', label: 'Облигации' }, { to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' }, { to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' }, { to: '/events', label: 'События' },
] ]
export interface BrokerAccountContextValue {
accountId: string
portfolio: UseQueryResult<BrokerPortfolio>
}
export const BrokerAccountContext = createContext<BrokerAccountContextValue | null>(null)
export function BrokerAccountLayout({ children }: { children: ReactNode }) { export function BrokerAccountLayout({ children }: { children: ReactNode }) {
const { accountId = '' } = useParams({ from: '/broker/$accountId' }) const { accountId = '' } = useParams({ from: '/broker/$accountId' })
const portfolio = useBrokerPortfolio(accountId) const portfolio = useBrokerPortfolio(accountId)
const basePath = `/broker/${encodeURIComponent(accountId)}` const basePath = `/broker/${encodeURIComponent(accountId)}`
return ( return (
<BrokerAccountContext.Provider value={{ accountId, portfolio }}> <Box sx={{ display: 'grid', gap: 3 }}>
<Box sx={{ display: 'grid', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}> <Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading> </Box>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 2,
'@media (min-width: 720px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
gap: 3,
},
}}
>
<Box <Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{ sx={{
display: 'grid', display: 'flex',
gridTemplateColumns: '1fr', flexDirection: 'column',
gap: 2, gap: 0.5,
'@media (min-width: 720px)': { '@media (max-width: 719px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)', flexDirection: 'row',
gap: 3, overflowX: 'auto',
scrollbarWidth: 'thin',
}, },
}} }}
> >
<Box {links.map((link) => (
component="nav" <Link
aria-label="Разделы брокерского счёта" key={link.to}
sx={{ to={`${basePath}${link.to}`}
display: 'flex', style={baseLinkStyle}
flexDirection: 'column', activeProps={{
gap: 0.5, style: {
'@media (max-width: 719px)': { ...baseLinkStyle,
flexDirection: 'row', color: 'var(--color-primary)',
overflowX: 'auto', fontWeight: 700,
scrollbarWidth: 'thin', },
}, }}
}} >
> {link.label}
{links.map((link) => ( </Link>
<Link ))}
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box> </Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box> </Box>
</BrokerAccountContext.Provider> </Box>
) )
} }

Some files were not shown because too many files have changed in this diff Show More