Compare commits
10 Commits
cfadb2adbe
...
a13b5145f7
| Author | SHA1 | Date | |
|---|---|---|---|
| a13b5145f7 | |||
| b26021016c | |||
| 76cffc061d | |||
| aea74bda3b | |||
| 9932278b64 | |||
| 063e80c375 | |||
| 5b794c0419 | |||
| cbb5d09bc3 | |||
| c7a8993b4a | |||
| 7e10b4b8aa |
@ -26,9 +26,6 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Test backend
|
||||
run: npm run test:backend
|
||||
|
||||
|
||||
@ -1 +0,0 @@
|
||||
apps/frontend/
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApiResponseMeta {
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
cachedAt: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@ -1,26 +1,32 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { BondEnvelopeDto, BondMarketDataEnvelopeDto, BondHistoryEnvelopeDto } from './dto/bonds-envelope.dto';
|
||||
|
||||
@ApiTags('Bonds')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities/bonds')
|
||||
export class BondsController {
|
||||
constructor(private readonly bondsService: BondsService) {}
|
||||
|
||||
@Get(':secid')
|
||||
@ApiOperation({ summary: 'Получить спецификацию облигации' })
|
||||
@ApiOkResponse({ type: BondEnvelopeDto })
|
||||
async getBond(@Param('secid') secid: string) {
|
||||
return this.bondsService.getBond(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/marketdata')
|
||||
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
|
||||
@ApiOkResponse({ type: BondMarketDataEnvelopeDto })
|
||||
async getMarketData(@Param('secid') secid: string) {
|
||||
return this.bondsService.getMarketData(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/history')
|
||||
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
|
||||
@ApiOkResponse({ type: BondHistoryEnvelopeDto })
|
||||
async getHistory(
|
||||
@Param('secid') secid: string,
|
||||
@Query('from') from: string,
|
||||
|
||||
28
apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts
Normal file
28
apps/backend/src/modules/bonds/dto/bonds-envelope.dto.ts
Normal file
@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
15
apps/backend/src/modules/bonds/dto/history-item.dto.ts
Normal file
15
apps/backend/src/modules/bonds/dto/history-item.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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;
|
||||
}
|
||||
@ -1,15 +1,19 @@
|
||||
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { CandlesService } from './candles.service';
|
||||
import { CandlesQueryDto } from './dto/candles-query.dto';
|
||||
import { CandleEnvelopeDto } from './dto/candles-envelope.dto';
|
||||
|
||||
@ApiTags('Candles')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities')
|
||||
export class CandlesController {
|
||||
constructor(private readonly candlesService: CandlesService) {}
|
||||
|
||||
@Get('shares/:secid/candles')
|
||||
@ApiOperation({ summary: 'Получить свечи акции' })
|
||||
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||
async getShareCandles(
|
||||
@Param('secid') secid: string,
|
||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||
@ -19,6 +23,7 @@ export class CandlesController {
|
||||
|
||||
@Get('bonds/:secid/candles')
|
||||
@ApiOperation({ summary: 'Получить свечи облигации' })
|
||||
@ApiOkResponse({ type: CandleEnvelopeDto })
|
||||
async getBondCandles(
|
||||
@Param('secid') secid: string,
|
||||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||||
|
||||
27
apps/backend/src/modules/candles/dto/candle-item.dto.ts
Normal file
27
apps/backend/src/modules/candles/dto/candle-item.dto.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
11
apps/backend/src/modules/candles/dto/candles-envelope.dto.ts
Normal file
11
apps/backend/src/modules/candles/dto/candles-envelope.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
11
apps/backend/src/modules/health/dto/health-envelope.dto.ts
Normal file
11
apps/backend/src/modules/health/dto/health-envelope.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
12
apps/backend/src/modules/health/dto/health-response.dto.ts
Normal file
12
apps/backend/src/modules/health/dto/health-response.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
@ -1,13 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { HealthEnvelopeDto } from './dto/health-envelope.dto';
|
||||
|
||||
@ApiTags('Health')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||
@ApiOkResponse({ type: HealthEnvelopeDto })
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
@ -1,12 +1,15 @@
|
||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
||||
import { ScreenerResponseDto } from './dto/screener-response.dto';
|
||||
import { SearchEnvelopeDto } from './dto/search-response.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities')
|
||||
export class SecuritiesController {
|
||||
constructor(
|
||||
@ -16,6 +19,7 @@ export class SecuritiesController {
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
@ApiOkResponse({ type: SearchEnvelopeDto })
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
const results = await this.securitiesService.search(
|
||||
query.q,
|
||||
|
||||
12
apps/backend/src/modules/shares/dto/dividend-item.dto.ts
Normal file
12
apps/backend/src/modules/shares/dto/dividend-item.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
24
apps/backend/src/modules/shares/dto/history-item.dto.ts
Normal file
24
apps/backend/src/modules/shares/dto/history-item.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
38
apps/backend/src/modules/shares/dto/shares-envelope.dto.ts
Normal file
38
apps/backend/src/modules/shares/dto/shares-envelope.dto.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
@ -1,14 +1,23 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/swagger';
|
||||
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
|
||||
import { SharesService } from './shares.service';
|
||||
import {
|
||||
ShareEnvelopeDto,
|
||||
ShareMarketDataEnvelopeDto,
|
||||
DividendsEnvelopeDto,
|
||||
ShareHistoryEnvelopeDto,
|
||||
} from './dto/shares-envelope.dto';
|
||||
|
||||
@ApiTags('Shares')
|
||||
@ApiExtraModels(ApiResponseMeta)
|
||||
@Controller('securities/shares')
|
||||
export class SharesController {
|
||||
constructor(private readonly sharesService: SharesService) {}
|
||||
|
||||
@Get(':secid')
|
||||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||||
@ApiOkResponse({ type: ShareEnvelopeDto })
|
||||
async getShare(@Param('secid') secid: string) {
|
||||
const share = await this.sharesService.getShare(secid);
|
||||
return { data: share, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -16,18 +25,21 @@ export class SharesController {
|
||||
|
||||
@Get(':secid/marketdata')
|
||||
@ApiOperation({ summary: 'Получить рыночные данные акции' })
|
||||
@ApiOkResponse({ type: ShareMarketDataEnvelopeDto })
|
||||
async getMarketData(@Param('secid') secid: string) {
|
||||
return this.sharesService.getMarketData(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/dividends')
|
||||
@ApiOperation({ summary: 'Получить дивиденды' })
|
||||
@ApiOkResponse({ type: DividendsEnvelopeDto })
|
||||
async getDividends(@Param('secid') secid: string) {
|
||||
return this.sharesService.getDividends(secid);
|
||||
}
|
||||
|
||||
@Get(':secid/history')
|
||||
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
|
||||
@ApiOkResponse({ type: ShareHistoryEnvelopeDto })
|
||||
async getHistory(
|
||||
@Param('secid') secid: string,
|
||||
@Query('from') from: string,
|
||||
|
||||
@ -21,37 +21,37 @@ export class BrokerEventItemDto {
|
||||
@ApiProperty()
|
||||
eventDate!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
paymentDate!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
ticker!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
instrumentUid!: string | null;
|
||||
|
||||
@ApiProperty({ enum: instrumentTypes })
|
||||
instrumentType!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
quantitySnapshot!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
payoutPerUnit!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
estimatedAmount!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
actualAmount!: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
currency!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true, enum: ['current_position'] })
|
||||
estimateMode!: 'current_position' | null;
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ export class BrokerEventsSummaryDto {
|
||||
@ApiProperty({ minimum: 0 })
|
||||
eventCount!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
nearestEventDate!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
|
||||
## Решение
|
||||
|
||||
Мигрировать на **TanStack Router**.
|
||||
Мигрировать на **TanStack Router** (code-first approach).
|
||||
|
||||
Причины:
|
||||
1. **Типобезопасность** — RouteTree generation исключает опечатки в путях и невалидные search params
|
||||
@ -48,6 +48,8 @@
|
||||
5. **Search params** — типизированная валидация вместо строковых `useSearchParams`
|
||||
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-плагина.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
@ -58,14 +60,17 @@
|
||||
|
||||
### Риски
|
||||
- Переписывание всех роутов, компонентов навигации (`Link`, `useNavigate`) и тестов
|
||||
- `MemoryRouter` в тестах заменяется на `createMemoryRouter` из TanStack Router
|
||||
- Файловая структура роутов меняется — `src/app/routes/` с Route Tree generation
|
||||
- `MemoryRouter` в тестах заменяется на `createMemoryHistory` + `RouterProvider` из TanStack Router
|
||||
- Файловая структура роутов меняется — `src/app/routing/` с code-first определением в `routeTree.tsx`
|
||||
- TanStack Router не экспортирует `useSearchParams` — потребовалась обёртка `useSearchParamsCompat`
|
||||
- `useNavigate` использует объектный синтаксис: `navigate({ to: '...' })` вместо строкового `navigate('...')`
|
||||
- Learning curve для команды
|
||||
|
||||
### Миграция
|
||||
- Каждый роут переносится по одному
|
||||
- Старый `AppRoutes.tsx` сохраняется до полного прохождения тестов
|
||||
- `react-router-dom` удаляется только после верификации
|
||||
- Все маршруты определены в одном `routeTree.tsx` (code-first)
|
||||
- Старый `AppRoutes.tsx` удалён после прохождения тестов
|
||||
- `react-router-dom` удалён из зависимостей после верификации
|
||||
- Все 125 тестов проходят, сборка зелёная
|
||||
|
||||
## Связанные документы
|
||||
- `docs/research/frontend-infrastructure-tooling/react-router-vs-tanstack-router.md`
|
||||
|
||||
11
apps/frontend/mocks/data/auth.ts
Normal file
11
apps/frontend/mocks/data/auth.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const mockUser = {
|
||||
id: 1,
|
||||
email: 'user@test.com',
|
||||
name: 'Test User',
|
||||
role: 'user',
|
||||
}
|
||||
|
||||
export const mockAuthResponse = {
|
||||
user: mockUser,
|
||||
accessToken: 'mock-access-token',
|
||||
}
|
||||
40
apps/frontend/mocks/data/bonds.ts
Normal file
40
apps/frontend/mocks/data/bonds.ts
Normal file
@ -0,0 +1,40 @@
|
||||
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 },
|
||||
]
|
||||
189
apps/frontend/mocks/data/broker.ts
Normal file
189
apps/frontend/mocks/data/broker.ts
Normal file
@ -0,0 +1,189 @@
|
||||
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,
|
||||
}
|
||||
22
apps/frontend/mocks/data/candles.ts
Normal file
22
apps/frontend/mocks/data/candles.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
]
|
||||
15
apps/frontend/mocks/data/index.ts
Normal file
15
apps/frontend/mocks/data/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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'
|
||||
138
apps/frontend/mocks/data/portfolios.ts
Normal file
138
apps/frontend/mocks/data/portfolios.ts
Normal file
@ -0,0 +1,138 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
38
apps/frontend/mocks/data/screener.ts
Normal file
38
apps/frontend/mocks/data/screener.ts
Normal file
@ -0,0 +1,38 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
20
apps/frontend/mocks/data/search.ts
Normal file
20
apps/frontend/mocks/data/search.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
50
apps/frontend/mocks/data/shares.ts
Normal file
50
apps/frontend/mocks/data/shares.ts
Normal file
@ -0,0 +1,50 @@
|
||||
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,
|
||||
},
|
||||
]
|
||||
151
apps/frontend/mocks/handlers.ts
Normal file
151
apps/frontend/mocks/handlers.ts
Normal file
@ -0,0 +1,151 @@
|
||||
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 })),
|
||||
),
|
||||
]
|
||||
5
apps/frontend/mocks/utils.ts
Normal file
5
apps/frontend/mocks/utils.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function envelope(data: unknown) {
|
||||
return {
|
||||
data: { data, meta: { fromCache: false, cachedAt: null } },
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"preview": "vite preview",
|
||||
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts",
|
||||
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/shared/api/types.ts",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check --write src/",
|
||||
"format": "biome format --write src/",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -5,7 +6,6 @@ import { HttpResponse, http } from 'msw'
|
||||
import { useContext } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionContext } from '@/entities/session'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { SessionProvider } from './SessionProvider'
|
||||
|
||||
const API = '/api/v1'
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react'
|
||||
import * as sessionApi from '@/entities/session'
|
||||
import { SessionContext, type SessionContextValue } from '@/entities/session'
|
||||
import { SessionContext, type SessionContextValue, useSessionStore } from '@/entities/session'
|
||||
import {
|
||||
getAccessToken,
|
||||
handleUnauthorized,
|
||||
setOnUnauthorized,
|
||||
} from '@/entities/session/api/tokenManager'
|
||||
import type { UserResponse } from '@/shared/api'
|
||||
import { configureKyAuth } from '@/shared/api/kyClient'
|
||||
import type { UserResponse } from '@/shared/api/responses'
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserResponse | null>(null)
|
||||
@ -18,11 +18,13 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const updateSession = useCallback((authData: { user: UserResponse; accessToken: string }) => {
|
||||
setUser(authData.user)
|
||||
setAccessTokenState(authData.accessToken)
|
||||
useSessionStore.getState().setSession(authData)
|
||||
}, [])
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
setUser(null)
|
||||
setAccessTokenState(null)
|
||||
useSessionStore.getState().clearSession()
|
||||
}, [])
|
||||
|
||||
const login = useCallback(
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type {
|
||||
ApiResponseMeta,
|
||||
BondHistoryItem,
|
||||
BondMarketData,
|
||||
BondResponse,
|
||||
CandleItem,
|
||||
} from '@/shared/api/responses'
|
||||
} from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
|
||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BondResponse } from '@/shared/api/responses'
|
||||
import type { BondResponse } from '@/shared/api'
|
||||
import { getBond } from '../api/bondApi'
|
||||
|
||||
export function useBond(secid: string) {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { useBondCandles } from './useBondCandles'
|
||||
|
||||
const API = '/api/v1'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { CandleItem } from '@/shared/api/responses'
|
||||
import type { CandleItem } from '@/shared/api'
|
||||
import { getBondCandles } from '../api/bondApi'
|
||||
|
||||
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
|
||||
|
||||
export type BrokerOperationQuery = {
|
||||
from?: string
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { aggregateBrokerAccounts } from '../model/brokerAccountsOverview'
|
||||
|
||||
function portfolio(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api'
|
||||
|
||||
export interface BrokerCurrencyAllocationSummary {
|
||||
shares: number
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api'
|
||||
import { getBrokerPortfolio } from '../api/brokerAccountApi'
|
||||
|
||||
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerAccount } from '@/shared/api/responses'
|
||||
import type { BrokerAccount } from '@/shared/api'
|
||||
import { getBrokerAccounts } from '../api/brokerAccountApi'
|
||||
|
||||
export function useBrokerAccounts() {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
import { getBrokerPortfolio } from '../api/brokerAccountApi'
|
||||
|
||||
export function useBrokerPortfolio(accountId: string | undefined) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { ApiResponseMeta, BrokerEventsData } from '@/shared/api/responses'
|
||||
|
||||
export type BrokerEventsQuery = {
|
||||
from: string
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerEventsData } from '@/shared/api/responses'
|
||||
import type { BrokerEventsData } from '@/shared/api'
|
||||
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
|
||||
|
||||
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api/responses'
|
||||
|
||||
export type BrokerOperationQuery = {
|
||||
from?: string
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BrokerOperation } from '@/shared/api/responses'
|
||||
import type { BrokerOperation } from '@/shared/api'
|
||||
|
||||
export type BrokerOperationImpact = 'adds' | 'reduces' | 'neutral' | 'unknown'
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerOperationsPage } from '@/shared/api/responses'
|
||||
import type { BrokerOperationsPage } from '@/shared/api'
|
||||
import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOperationApi'
|
||||
|
||||
export function useBrokerOperations(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { ApiResponseMeta, BrokerPositionsPage } from '@/shared/api/responses'
|
||||
|
||||
export function getBrokerPositions(
|
||||
accountId: string,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerMoney, BrokerPortfolio } from '@/shared/api'
|
||||
import { buildBrokerAllocation } from './brokerAllocation'
|
||||
|
||||
function money(value: number): BrokerMoney {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
|
||||
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
getBrokerOperationTypeLabel,
|
||||
isBrokerOperationType,
|
||||
} from '@/entities/broker-operation'
|
||||
import type { BrokerOperation, BrokerPosition } from '@/shared/api/responses'
|
||||
import type { BrokerOperation, BrokerPosition } from '@/shared/api'
|
||||
import { getBrokerInstrumentPath, getBrokerPositionGroup } from './brokerDisplay'
|
||||
|
||||
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BrokerPosition } from '@/shared/api/responses'
|
||||
import type { BrokerPosition } from '@/shared/api'
|
||||
|
||||
export type BrokerPositionGroup = 'shares' | 'bonds' | 'other'
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import type { BrokerPositionsPage } from '@/shared/api/responses'
|
||||
import type { BrokerPositionsPage } from '@/shared/api'
|
||||
import { getBrokerPositions } from '../api/brokerPositionApi'
|
||||
|
||||
export function useBrokerPositions(
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
import type { AnalyticsResponse, Portfolio, PortfolioDetail, Position } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type {
|
||||
AnalyticsResponse,
|
||||
Portfolio,
|
||||
PortfolioDetail,
|
||||
Position,
|
||||
} from '@/shared/api/responses'
|
||||
|
||||
export function getPortfolios(): Promise<{
|
||||
data: Portfolio[]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { PortfolioDetail } from '@/shared/api/responses'
|
||||
import type { PortfolioDetail } from '@/shared/api'
|
||||
import { getPortfolio } from '../api/portfolioApi'
|
||||
|
||||
export function usePortfolio(id: number) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { AnalyticsResponse } from '@/shared/api/responses'
|
||||
import type { AnalyticsResponse } from '@/shared/api'
|
||||
import { getPortfolioAnalytics } from '../api/portfolioApi'
|
||||
|
||||
export function usePortfolioAnalytics(portfolioId: number) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Portfolio } from '@/shared/api/responses'
|
||||
import type { Portfolio } from '@/shared/api'
|
||||
import { getPortfolios } from '../api/portfolioApi'
|
||||
|
||||
export function usePortfolios() {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PortfolioDetail } from '@/shared/api/responses'
|
||||
import type { PortfolioDetail } from '@/shared/api'
|
||||
import { addPosition, removePosition, updatePosition } from '../api/portfolioApi'
|
||||
|
||||
export function usePositionMutations(portfolioId: number) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SearchResultItem } from '@/shared/api'
|
||||
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) {
|
||||
return request<SearchResultItem[]>('/api/v1/securities/search', {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { useSearch } from '@/entities/search'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
|
||||
const API = '/api/v1'
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { SearchResultItem } from '@/shared/api/responses'
|
||||
import type { SearchResultItem } from '@/shared/api'
|
||||
import { searchSecurities } from '../api/searchApi'
|
||||
|
||||
export function useSearch(query: string) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { getMe, login, logout, refresh, register, updateProfile } from './sessionApi'
|
||||
import { getAccessToken, setAccessToken } from './tokenManager'
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { AuthResponse, UserResponse } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { AuthResponse, UserResponse } from '@/shared/api/responses'
|
||||
import { setAccessToken } from './tokenManager'
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { AuthResponse } from '@/shared/api'
|
||||
import { normalizeEnvelope } from '@/shared/api/kyClient'
|
||||
import type { AuthResponse } from '@/shared/api/responses'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { UserResponse } from '@/shared/api/responses'
|
||||
import type { UserResponse } from '@/shared/api'
|
||||
|
||||
interface SessionState {
|
||||
user: UserResponse | null
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type {
|
||||
ApiResponseMeta,
|
||||
CandleItem,
|
||||
@ -6,7 +5,8 @@ import type {
|
||||
ShareHistoryItem,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
} from '@/shared/api/responses'
|
||||
} from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
|
||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ShareResponse } from '@/shared/api/responses'
|
||||
import type { ShareResponse } from '@/shared/api'
|
||||
import { getShare } from '../api/stockApi'
|
||||
|
||||
export function useStock(secid: string) {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { useStockCandles } from './useStockCandles'
|
||||
|
||||
const API = '/api/v1'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { CandleItem } from '@/shared/api/responses'
|
||||
import type { CandleItem } from '@/shared/api'
|
||||
import { getShareCandles } from '../api/stockApi'
|
||||
|
||||
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { useStockDividends } from './useStockDividends'
|
||||
|
||||
const API = '/api/v1'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { DividendItem } from '@/shared/api/responses'
|
||||
import type { DividendItem } from '@/shared/api'
|
||||
import { getShareDividends } from '../api/stockApi'
|
||||
|
||||
export function useStockDividends(secid: string) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ScreenerResult } from '@/shared/api'
|
||||
import { request } from '@/shared/api/kyClient'
|
||||
import type { ScreenerResult } from '@/shared/api/responses'
|
||||
|
||||
export interface ScreenerQuery {
|
||||
type: 'share' | 'bond'
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { ScreenerResult } from '@/shared/api/responses'
|
||||
import type { ScreenerResult } from '@/shared/api'
|
||||
|
||||
interface Props {
|
||||
result: ScreenerResult
|
||||
|
||||
@ -7,7 +7,7 @@ import { env } from './shared/config/env'
|
||||
|
||||
async function startApp() {
|
||||
if (env.VITE_API_MOCK) {
|
||||
const { worker } = await import('./shared/lib/test/browser')
|
||||
const { worker } = await import('../mocks/browser')
|
||||
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
@ -10,7 +11,6 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
|
||||
import { LoginPage } from './ui/LoginPage'
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import { PortfolioSummary } from '@/widgets/portfolio-summary'
|
||||
import { SharePositionTable } from '@/widgets/share-positions-table'
|
||||
|
||||
export function PortfolioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { id } = useParams({ from: '/portfolios/$id' })
|
||||
const portfolioId = parseInt(id!, 10)
|
||||
|
||||
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId)
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { renderWithProviders } from '@/shared/lib/test/test-utils'
|
||||
import { ProfilePage } from './ui/ProfilePage'
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { server } from '@mocks/server'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import {
|
||||
createMemoryHistory,
|
||||
@ -10,7 +11,6 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { server } from '@/shared/lib/test/server'
|
||||
import { TestSessionProvider } from '@/shared/lib/test/TestSessionProvider'
|
||||
import { RegisterPage } from './ui/RegisterPage'
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { PriceChart } from '@/widgets/price-chart'
|
||||
import { StockDetails } from '@/widgets/stock-details'
|
||||
|
||||
export function StockPage() {
|
||||
const { secid } = useParams<{ secid: string }>()
|
||||
const { secid } = useParams({ from: '/stocks/$secid' })
|
||||
const { data: stock, isLoading, error } = useStock(secid!)
|
||||
const till = new Date().toISOString().split('T')[0]
|
||||
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
@ -1,163 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@ -1,33 +1,62 @@
|
||||
import type { components } from './types'
|
||||
|
||||
export { configureKyAuth, getHealth, request } from './kyClient'
|
||||
export type {
|
||||
AnalyticsResponse,
|
||||
ApiEnvelope,
|
||||
ApiResponseMeta,
|
||||
AuthResponse,
|
||||
BondHistoryItem,
|
||||
BondMarketData,
|
||||
BondResponse,
|
||||
BrokerAccount,
|
||||
BrokerMoney,
|
||||
BrokerOperation,
|
||||
BrokerOperationCategory,
|
||||
BrokerOperationsPage,
|
||||
BrokerPortfolio,
|
||||
BrokerPosition,
|
||||
BrokerPositionsPage,
|
||||
CandleItem,
|
||||
DividendItem,
|
||||
HealthResponse,
|
||||
Portfolio,
|
||||
PortfolioDetail,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
PositionWithPrice,
|
||||
ScreenerItem,
|
||||
ScreenerResult,
|
||||
SearchResultItem,
|
||||
ShareHistoryItem,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
UserResponse,
|
||||
} from './responses'
|
||||
|
||||
export type ApiResponseMeta = components['schemas']['ApiResponseMeta']
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T
|
||||
meta: ApiResponseMeta
|
||||
}
|
||||
|
||||
// Auth
|
||||
export type AuthResponse = components['schemas']['AuthTokenDataDto']
|
||||
export type UserResponse = components['schemas']['AuthUserDto']
|
||||
|
||||
// Shares
|
||||
export type ShareResponse = components['schemas']['ShareResponseDto']
|
||||
export type StockMarketData = components['schemas']['StockMarketDataDto']
|
||||
export type DividendItem = components['schemas']['DividendItemDto']
|
||||
export type ShareHistoryItem = components['schemas']['HistoryItemDto']
|
||||
|
||||
// Bonds
|
||||
export type BondResponse = components['schemas']['BondResponseDto']
|
||||
export type BondMarketData = components['schemas']['BondMarketDataDto']
|
||||
export type BondHistoryItem = components['schemas']['BondHistoryItemDto']
|
||||
|
||||
// Candles
|
||||
export type CandleItem = components['schemas']['CandleItemDto']
|
||||
|
||||
// Search
|
||||
export type SearchResultItem = components['schemas']['SearchResultItemDto']
|
||||
|
||||
// Screener
|
||||
export type ScreenerResult = components['schemas']['ScreenerResultDto']
|
||||
export type ScreenerItem = components['schemas']['ScreenerItemDto']
|
||||
|
||||
// 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']
|
||||
|
||||
@ -1,391 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -451,6 +451,23 @@ export interface paths {
|
||||
patch?: 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': {
|
||||
parameters: {
|
||||
query?: never
|
||||
@ -472,6 +489,22 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>
|
||||
export interface components {
|
||||
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: {
|
||||
/** @example user@example.com */
|
||||
email: string
|
||||
@ -519,6 +552,26 @@ export interface components {
|
||||
/** @example John Doe */
|
||||
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: {
|
||||
/** @example SBER */
|
||||
secid: string
|
||||
@ -570,6 +623,215 @@ export interface components {
|
||||
data: components['schemas']['ScreenerResultDto']
|
||||
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: {
|
||||
cachedAt: string | null
|
||||
fromCache: boolean
|
||||
@ -895,6 +1157,51 @@ export interface components {
|
||||
data: components['schemas']['BrokerOperationsPageResponseDto']
|
||||
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: {
|
||||
/** @example 42 */
|
||||
upserted: number
|
||||
@ -925,7 +1232,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['HealthEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1073,7 +1382,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['SearchEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1135,7 +1446,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['ShareEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1154,7 +1467,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['ShareMarketDataEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1173,7 +1488,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['DividendsEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1195,7 +1512,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['ShareHistoryEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1214,7 +1533,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['BondEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1233,7 +1554,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['BondMarketDataEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1255,7 +1578,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['BondHistoryEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1278,7 +1603,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['CandleEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1301,7 +1628,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content?: never
|
||||
content: {
|
||||
'application/json': components['schemas']['CandleEnvelopeDto']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1610,6 +1939,34 @@ 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: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BrokerMoney } from '@/shared/api/responses'
|
||||
import type { BrokerMoney } from '@/shared/api'
|
||||
|
||||
export function formatBrokerCurrencyValue(currency: string, value: number): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createContext } from 'react'
|
||||
import type { UserResponse } from '@/shared/api/responses'
|
||||
import type { UserResponse } from '@/shared/api'
|
||||
|
||||
export interface SessionContextValue {
|
||||
user: UserResponse | null
|
||||
|
||||
@ -8,7 +8,7 @@ import type {
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
UserResponse,
|
||||
} from '@/shared/api/responses'
|
||||
} from '@/shared/api'
|
||||
|
||||
export function createMockMarketData(overrides: Partial<StockMarketData> = {}): StockMarketData {
|
||||
return {
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
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 }),
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,5 +1,5 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { server } from './server'
|
||||
import { server } from '@mocks/server'
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { BondResponse } from '@/shared/api/responses'
|
||||
import type { BondResponse } from '@/shared/api'
|
||||
|
||||
interface BondDetailsProps {
|
||||
bond: BondResponse
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import type { PositionWithPrice } from '@/shared/api/responses'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
|
||||
interface Props {
|
||||
position: PositionWithPrice
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { PositionWithPrice } from '@/shared/api/responses'
|
||||
import type { PositionWithPrice } from '@/shared/api'
|
||||
import { BondPositionRow } from './BondPositionRow'
|
||||
|
||||
interface Props {
|
||||
|
||||
@ -2,7 +2,7 @@ import { Alert, Button, Heading, Skeleton, Text } from '@moex-vibe/design-system
|
||||
import { Box } from '@mui/material'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { buildBrokerAllocation } from '@/entities/broker-position'
|
||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api/responses'
|
||||
import type { BrokerAccount, BrokerPortfolio } from '@/shared/api'
|
||||
import {
|
||||
formatBrokerDate,
|
||||
formatBrokerMoney,
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export { useBrokerAccountContext } from './lib/useBrokerAccountContext'
|
||||
export type { BrokerAccountContext } from './ui/BrokerAccountLayout'
|
||||
export type { BrokerAccountContextValue } from './ui/BrokerAccountLayout'
|
||||
export { BrokerAccountLayout } from './ui/BrokerAccountLayout'
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useContext } from 'react'
|
||||
import { BrokerAccountContext } from '../ui/BrokerAccountLayout'
|
||||
|
||||
export function useBrokerAccountContext() {
|
||||
const { accountId = '' } = useParams({ from: '/broker/$accountId' })
|
||||
return { accountId }
|
||||
const context = useContext(BrokerAccountContext)
|
||||
if (!context) {
|
||||
throw new Error('useBrokerAccountContext must be used within BrokerAccountLayout')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Heading } from '@moex-vibe/design-system'
|
||||
import { Box } from '@mui/material'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createContext, type ReactNode } from 'react'
|
||||
import { useBrokerPortfolio } from '@/entities/broker-account'
|
||||
import type { BrokerPortfolio } from '@/shared/api'
|
||||
|
||||
const baseLinkStyle: React.CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
@ -17,69 +19,79 @@ const baseLinkStyle: React.CSSProperties = {
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ to: '.', label: 'Обзор' },
|
||||
{ to: '', label: 'Обзор' },
|
||||
{ to: '/shares', label: 'Акции' },
|
||||
{ to: '/bonds', label: 'Облигации' },
|
||||
{ to: '/operations', 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 }) {
|
||||
const { accountId = '' } = useParams({ from: '/broker/$accountId' })
|
||||
const portfolio = useBrokerPortfolio(accountId)
|
||||
const basePath = `/broker/${encodeURIComponent(accountId)}`
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||
</Box>
|
||||
<BrokerAccountContext.Provider value={{ accountId, portfolio }}>
|
||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Heading level={1}>{portfolio.data?.account.name || 'Брокерский счёт'}</Heading>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 2,
|
||||
'@media (min-width: 720px)': {
|
||||
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||
gap: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Разделы брокерского счёта"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
'@media (max-width: 719px)': {
|
||||
flexDirection: 'row',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr',
|
||||
gap: 2,
|
||||
'@media (min-width: 720px)': {
|
||||
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
|
||||
gap: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={`${basePath}${link.to}`}
|
||||
style={baseLinkStyle}
|
||||
activeProps={{
|
||||
style: {
|
||||
...baseLinkStyle,
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 700,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label="Разделы брокерского счёта"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
'@media (max-width: 719px)': {
|
||||
flexDirection: 'row',
|
||||
overflowX: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{links.map((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 sx={{ minWidth: 0 }}>{children}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</BrokerAccountContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user