Compare commits

..

No commits in common. "main" and "codex/broker-events-finish" have entirely different histories.

186 changed files with 1729 additions and 13279 deletions

View File

@ -507,19 +507,13 @@ roadmap.md и inbox.md никогда не являются основанием
## graphify ## graphify
This project has a knowledge graph in `graphify-out/` with god nodes, community structure, and cross-file relationships. The graph is a local artifact, not a tracked repo asset. This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else. When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules: Rules:
- Используй `graphify` в первую очередь, когда задача связана с архитектурой, границами модулей, кросс-файловым влиянием или трассировкой потока данных. - For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Для таких вопросов сначала запускай `graphify query "<question>"`, если существует `graphify-out/graph.json`. Для связей используй `graphify path "<A>" "<B>"`, для точечных концептов — `graphify explain "<concept>"`. Обычно это даёт гораздо более узкий подграф, чем `GRAPH_REPORT.md` или raw grep. - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- Предпочитай `graphify query` перед raw grep, когда нужен кратчайший путь между концептами, мост между комьюнити или трассировка того, как один подсистемный блок достигает другого. - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Для отладки багов начинай с симптома и спрашивай у graphify путь зависимости, bridge nodes или модули, которые могут объяснить неожиданное поведение. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- Если `graphify` возвращает только общую структуру, переходи к `serena` за символ-уровневыми фактами и затем повторяй `graphify` с более узким вопросом, где названы конкретные файлы, модули или сервисы. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
- Dirty `graphify-out/` после хуков или инкрементальных обновлений считаются нормой; грязные файлы графа не повод пропускать `graphify`. Пропускать его можно только если задача именно про устаревший или некорректный граф, либо если пользователь прямо попросил не использовать его.
- В новом `worktree` сначала заново создай локальный граф командой `graphify extract .`.
- После первой сборки в этом `worktree` обновляй граф командой `graphify update .`.
- Если существует `graphify-out/wiki/index.md`, используй его для широкого обзора вместо ручного просмотра исходников.
- `graphify-out/GRAPH_REPORT.md` читай только для широкого архитектурного обзора или когда `query/path/explain` не дают достаточно контекста.
- После изменений в коде запускай `graphify update .`, чтобы держать граф актуальным (только AST, без затрат на LLM).

View File

@ -125,8 +125,6 @@ docs/
| `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
`graphify-out/` — локальный артефакт знания, он не хранится в git. В новом `worktree` сначала собери его заново: `graphify extract .`; дальше обновляй инкрементально: `graphify update .`. Для вопросов по коду используй `graphify query "..."`.
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов. Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`. Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.

View File

@ -1,4 +1,4 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { CacheModule } from './modules/cache/cache.module'; import { CacheModule } from './modules/cache/cache.module';
import { MoexClientModule } from './modules/moex-client/moex-client.module'; import { MoexClientModule } from './modules/moex-client/moex-client.module';
@ -11,7 +11,6 @@ import { PortfolioModule } from './modules/portfolio/portfolio.module';
import { PrismaModule } from './modules/prisma/prisma.module'; import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { TBankModule } from './modules/tbank/tbank.module'; import { TBankModule } from './modules/tbank/tbank.module';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import configuration from './config/configuration'; import configuration from './config/configuration';
@Module({ @Module({
@ -30,8 +29,4 @@ import configuration from './config/configuration';
TBankModule, TBankModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule {}
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
}
}

View File

@ -13,14 +13,6 @@ export class ApiResponseMeta {
} }
} }
export class ApiEnvelopePayload<T> {
constructor(
public readonly data: T,
public readonly fromCache: boolean,
public readonly cachedAt: string | null,
) {}
}
export class ApiResponse<T> { export class ApiResponse<T> {
data: T; data: T;
meta: ApiResponseMeta; meta: ApiResponseMeta;

View File

@ -1,8 +0,0 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export abstract class DomainException extends HttpException {
constructor(message: string, status: HttpStatus) {
super(message, status);
this.name = this.constructor.name;
}
}

View File

@ -1,8 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class EntityNotFoundException extends DomainException {
constructor(entity: string, id: string | number) {
super(`${entity} ${id} not found`, HttpStatus.NOT_FOUND);
}
}

View File

@ -1,8 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class MoexApiException extends DomainException {
constructor(message: string) {
super(`MOEX API error: ${message}`, HttpStatus.BAD_GATEWAY);
}
}

View File

@ -1,8 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class PortfolioAccessDeniedException extends DomainException {
constructor(portfolioId: number) {
super(`Access denied to portfolio ${portfolioId}`, HttpStatus.FORBIDDEN);
}
}

View File

@ -1,14 +0,0 @@
import { HttpStatus } from '@nestjs/common';
import { DomainException } from './domain.exception';
export class TBankApiException extends DomainException {
constructor(message: string) {
super(`T-Bank API error: ${message}`, HttpStatus.BAD_GATEWAY);
}
}
export class TBankNotConfiguredException extends DomainException {
constructor() {
super('T-Bank integration is not configured', HttpStatus.SERVICE_UNAVAILABLE);
}
}

View File

@ -1,67 +0,0 @@
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
const createHost = () => {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
const host = {
switchToHttp: () => ({
getResponse: () => ({ status }),
getRequest: () => ({ url: '/api/v1/test' }),
}),
} as unknown as ArgumentsHost;
return { host, status, json };
};
it('does not expose internal Error.message for unhandled exceptions', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
path: '/api/v1/test',
}),
);
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
});
it('returns safe defaults for non-Error thrown values', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch('some string error', host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
}),
);
});
it('keeps HttpException response messages intact', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new BadRequestException('Invalid request'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid request',
error: 'Bad Request',
}),
);
});
});

View File

@ -1,10 +1,8 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) { catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
@ -26,9 +24,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
error = (r.error as string) || exception.name; error = (r.error as string) || exception.name;
} }
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); message = exception.message;
} else {
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
} }
response.status(status).json({ response.status(status).json({

View File

@ -1,7 +1,7 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiEnvelopePayload, ApiResponse } from '../dto/api-response.dto'; import { ApiResponse } from '../dto/api-response.dto';
@Injectable() @Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
@ -9,9 +9,6 @@ export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
if (data instanceof ApiResponse) return data; if (data instanceof ApiResponse) return data;
if (data instanceof ApiEnvelopePayload) {
return new ApiResponse(data.data, data.fromCache, data.cachedAt);
}
return new ApiResponse(data, false, null); return new ApiResponse(data, false, null);
}), }),
); );

View File

@ -1,76 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('backend runtime configuration', () => {
const OLD_ENV = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...OLD_ENV };
delete process.env.JWT_SECRET;
delete process.env.JWT_REFRESH_SECRET;
delete process.env.BACKEND_CORS_ORIGINS;
});
afterEach(() => {
process.env = OLD_ENV;
});
it('keeps dev auth defaults outside production', async () => {
const configuration = (await import('./configuration')).default;
expect(configuration().auth).toMatchObject({
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
});
});
it('parses backend CORS origins from comma-separated env', async () => {
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
const configuration = (await import('./configuration')).default;
expect(configuration().cors.origins).toEqual([
'https://app.example.com',
'http://localhost:5173',
]);
});
it('rejects production defaults for JWT secrets', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: ['https://app.example.com'],
}),
).toThrow('JWT_SECRET must be set to a non-default value in production');
});
it('rejects production credentialed CORS without explicit origins', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'custom-access-secret',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: [],
}),
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
});
it('allows development with reflected CORS', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('development', [])).toBe(true);
});
it('uses explicit production CORS origins', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
'https://app.example.com',
]);
});
});

View File

@ -1,14 +1,5 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
const parseCsv = (value: string | undefined): string[] =>
(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
export default registerAs('app', () => ({ export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10), port: parseInt(process.env.PORT || '3000', 10),
database: { database: {
@ -38,7 +29,6 @@ export default registerAs('app', () => ({
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
screenerTtl: parseInt(process.env.CACHE_SCREENER_TTL || '900', 10),
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10),
tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10),
@ -47,9 +37,6 @@ export default registerAs('app', () => ({
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10), tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
}, },
cors: {
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
},
auth: { auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production', jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',

View File

@ -1,63 +0,0 @@
import 'reflect-metadata'
import { Test, type TestingModule } from '@nestjs/testing'
import type { INestApplication } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { HealthModule } from './modules/health/health.module'
import { PrismaModule } from './modules/prisma/prisma.module'
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
import configuration from './config/configuration'
describe('API envelope contract', () => {
let app: INestApplication
let baseUrl: string
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule],
}).compile()
app = module.createNestApplication()
app.setGlobalPrefix('api/v1')
app.useGlobalInterceptors(new TransformInterceptor())
await app.init()
await app.listen(0)
const address = app.getHttpServer().address()
if (typeof address === 'object' && address && 'port' in address) {
baseUrl = `http://127.0.0.1:${address.port}`
}
})
afterAll(async () => {
await app.close()
})
it('returns a proper envelope with checks from the public health endpoint', async () => {
const response = await fetch(`${baseUrl}/api/v1/health`)
expect(response.status).toBe(200)
const body = (await response.json()) as {
data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> }
meta: { fromCache: boolean; cachedAt: string | null }
}
expect(body).toMatchObject({
data: {
status: expect.any(String),
timestamp: expect.any(String),
uptime: expect.any(Number),
checks: expect.arrayContaining([
expect.objectContaining({ name: 'prisma', status: expect.any(String) }),
expect.objectContaining({ name: 'moex', status: expect.any(String) }),
expect.objectContaining({ name: 'tbank', status: expect.any(String) }),
]),
},
meta: {
fromCache: false,
cachedAt: null,
},
})
expect(body.data).not.toHaveProperty('data')
expect(body.data).not.toHaveProperty('meta')
})
})

View File

@ -4,37 +4,9 @@ import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { DEV_JWT_SECRET, DEV_JWT_REFRESH_SECRET } from './config/configuration';
export type BackendRuntimeConfig = {
nodeEnv: string;
jwtSecret: string;
jwtRefreshSecret: string;
corsOrigins: string[];
};
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
if (config.nodeEnv !== 'production') return;
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must be set to a non-default value in production');
}
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
}
if (config.corsOrigins.length === 0) {
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
}
}
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
return nodeEnv === 'production' ? corsOrigins : true;
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -46,20 +18,10 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new TransformInterceptor());
app.use(cookieParser()); app.use(cookieParser());
const configService = app.get(ConfigService); const reqLogMiddleware = new RequestLoggingMiddleware();
const runtimeConfig: BackendRuntimeConfig = { app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
nodeEnv: process.env.NODE_ENV || 'development',
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
corsOrigins: configService.get<string[]>('app.cors.origins', []),
};
assertSafeProductionConfig(runtimeConfig); app.enableCors({ origin: true, credentials: true });
app.enableCors({
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
credentials: true,
});
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('MoexVibe API') .setTitle('MoexVibe API')
@ -74,7 +36,4 @@ async function bootstrap() {
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`); console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`); console.log(`Swagger docs: http://localhost:${port}/api/docs`);
} }
bootstrap();
if (process.env.NODE_ENV !== 'test') {
void bootstrap();
}

View File

@ -41,7 +41,13 @@ export class AuthController {
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto); const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@ -51,7 +57,13 @@ export class AuthController {
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto); const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Public() @Public()
@ -63,7 +75,13 @@ export class AuthController {
const token = req.cookies?.[REFRESH_COOKIE]; const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token); const result = await this.authService.refresh(token);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
return { user: result.user, accessToken: result.accessToken }; return {
data: {
user: result.user,
accessToken: result.accessToken,
},
meta: { fromCache: false, cachedAt: null },
};
} }
@Post('logout') @Post('logout')
@ -74,7 +92,10 @@ export class AuthController {
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub); await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
return { message: 'Logged out successfully' }; return {
data: { message: 'Logged out successfully' },
meta: { fromCache: false, cachedAt: null },
};
} }
@Get('me') @Get('me')
@ -82,7 +103,11 @@ export class AuthController {
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) { async getProfile(@CurrentUser() user: JwtPayload) {
return this.authService.getProfile(user.sub); const profile = await this.authService.getProfile(user.sub);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
@Patch('me') @Patch('me')
@ -90,6 +115,10 @@ export class AuthController {
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto }) @ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
return this.authService.updateProfile(user.sub, dto); const profile = await this.authService.updateProfile(user.sub, dto);
return {
data: profile,
meta: { fromCache: false, cachedAt: null },
};
} }
} }

View File

@ -1,5 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
class AuthResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
class AuthUserDto { class AuthUserDto {
@ApiProperty() @ApiProperty()
@ -32,22 +39,22 @@ export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto }) @ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto; data!: AuthTokenDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }
export class AuthProfileResponseDto { export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto }) @ApiProperty({ type: AuthUserDto })
data!: AuthUserDto; data!: AuthUserDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }
export class AuthLogoutResponseDto { export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto }) @ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto; data!: LogoutDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: AuthResponseMetaDto })
meta!: ApiResponseMeta; meta!: AuthResponseMetaDto;
} }

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { BondsController } from './bonds.controller'; import { BondsController } from './bonds.controller';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [BondsController], controllers: [BondsController],
providers: [BondsService], providers: [BondsService],
exports: [BondsService], exports: [BondsService],

View File

@ -1,17 +1,16 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
describe('BondsService', () => { describe('BondsService', () => {
let service: BondsService; let service: BondsService;
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>; let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexMarketData = { moexClient = {
getBondData: vi.fn(), getBondData: vi.fn(),
getBondMarketData: vi.fn(), getBondMarketData: vi.fn(),
}; };
@ -26,8 +25,7 @@ describe('BondsService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
BondsService, BondsService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, { provide: MoexClientService, useValue: moexClient },
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -36,7 +34,7 @@ describe('BondsService', () => {
}); });
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue({ vi.mocked(moexClient.getBondData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
boardid: 'TQCB', boardid: 'TQCB',
shortName: 'ОФЗ 26238', shortName: 'ОФЗ 26238',
@ -59,7 +57,7 @@ describe('BondsService', () => {
bondSubType: 'fixed', bondSubType: 'fixed',
listLevel: 1, listLevel: 1,
}); });
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({ vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
bid: 72.9, bid: 72.9,
offer: 73.1, offer: 73.1,
@ -94,8 +92,8 @@ describe('BondsService', () => {
expect.any(Function), expect.any(Function),
'marketDataTtl', 'marketDataTtl',
); );
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
expect(result).toMatchObject({ expect(result).toMatchObject({
data: { data: {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
@ -131,17 +129,19 @@ describe('BondsService', () => {
volume: 10000, volume: 10000,
}, },
}, },
fromCache: false, meta: {
cachedAt: '2026-06-15T00:00:00.000Z', fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws EntityNotFoundException when bond data is missing', async () => { it('throws NotFoundException when bond data is missing', async () => {
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null); vi.mocked(moexClient.getBondData).mockResolvedValue(null);
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
expect(cache.getOrFetch).toHaveBeenCalledTimes(1); expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled(); expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,15 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class BondsService { export class BondsService {
constructor( constructor(
private readonly moexMarketData: MoexMarketDataClient, private readonly moexClient: MoexClientService,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -21,23 +17,23 @@ export class BondsService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'bond', 'bond',
[secid], [secid],
() => this.moexMarketData.getBondData(secid), () => this.moexClient.getBondData(secid),
'securityTtl', 'securityTtl',
); );
if (!bond) { if (!bond) {
throw new EntityNotFoundException('Bond', secid); throw new NotFoundException(`Bond ${secid} not found`);
} }
const { data: mkt } = await this.cache.getOrFetch( const { data: mkt } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['bonds', secid], ['bonds', secid],
() => this.moexMarketData.getBondMarketData(secid), () => this.moexClient.getBondMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
return new ApiEnvelopePayload( return {
{ data: {
secid: bond.secid, secid: bond.secid,
isin: bond.isin, isin: bond.isin,
name: bond.shortName, name: bond.shortName,
@ -74,9 +70,8 @@ export class BondsService {
: new Date().toISOString(), : new Date().toISOString(),
}, },
}, },
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
@ -87,16 +82,16 @@ export class BondsService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['bonds', secid], ['bonds', secid],
() => this.moexMarketData.getBondMarketData(secid), () => this.moexClient.getBondMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
if (!mkt) { if (!mkt) {
throw new EntityNotFoundException('MarketData', `bond ${secid}`); throw new NotFoundException(`Market data for bond ${secid} not found`);
} }
return new ApiEnvelopePayload( return {
{ data: {
price: mkt.last ?? 0, price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null, yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null, duration: mkt.duration ?? null,
@ -112,28 +107,26 @@ export class BondsService {
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
async getHistory(secid: string, from: string, till: string) { async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history', 'history',
['bonds', secid, from, till], ['bonds', secid, from, till],
() => this.moexHistory.getBondHistory(secid, from, till), () => this.moexClient.getBondHistory(secid, from, till),
'historyTtl', 'historyTtl',
); );
return new ApiEnvelopePayload( return {
data.map((h) => ({ data: data.map((h) => ({
date: h.tradeDate, date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0, closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? null, yieldClose: h.yieldClose ?? null,
duration: h.duration ?? null, duration: h.duration ?? null,
})), })),
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
} }

View File

@ -1,65 +0,0 @@
import { ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
describe('CacheService', () => {
const configService = {
get: vi.fn((_key: string, fallback?: unknown) => fallback),
} as unknown as ConfigService;
const createCache = () => ({
get: vi.fn(),
set: vi.fn(),
});
it('stores data with cachedAt metadata on cache miss', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
const cache = createCache();
cache.get.mockResolvedValue(undefined);
const service = new CacheService(cache as never, configService);
try {
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: false,
cachedAt: '2026-06-25T10:00:00.000Z',
});
expect(cache.set).toHaveBeenCalledWith(
'prefix:a',
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
900,
);
} finally {
vi.useRealTimers();
}
});
it('returns cachedAt metadata on cache hit', async () => {
const cache = createCache();
cache.get.mockResolvedValue({
data: { value: 1 },
cachedAt: '2026-06-25T10:00:00.000Z',
});
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: true,
cachedAt: '2026-06-25T10:00:00.000Z',
});
});
it('supports legacy raw cache values during rollout', async () => {
const cache = createCache();
cache.get.mockResolvedValue({ value: 1 });
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
});
});

View File

@ -3,11 +3,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
@Injectable() @Injectable()
export class CacheService { export class CacheService {
constructor( constructor(
@ -23,16 +18,6 @@ export class CacheService {
await this.cacheManager.set(key, value, ttl); await this.cacheManager.set(key, value, ttl);
} }
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
return (
typeof value === 'object' &&
value !== null &&
'data' in value &&
'cachedAt' in value &&
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
);
}
private buildKey(...parts: string[]): string { private buildKey(...parts: string[]): string {
return parts.join(':'); return parts.join(':');
} }
@ -46,19 +31,14 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts); const key = this.buildKey(keyPrefix, ...keyParts);
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900); const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
const cached = await this.get<CacheEntry<T> | T>(key); const cached = await this.get<T>(key);
if (cached !== undefined) { if (cached !== undefined) {
if (this.isCacheEntry<T>(cached)) { return { data: cached, fromCache: true, cachedAt: null };
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
}
return { data: cached as T, fromCache: true, cachedAt: null };
} }
const data = await fetchFn(); const data = await fetchFn();
const cachedAt = new Date().toISOString(); await this.set(key, data, ttl);
await this.set(key, { data, cachedAt }, ttl);
return { data, fromCache: false, cachedAt }; return { data, fromCache: false, cachedAt: new Date().toISOString() };
} }
} }

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { CandlesController } from './candles.controller'; import { CandlesController } from './candles.controller';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [CandlesController], controllers: [CandlesController],
providers: [CandlesService], providers: [CandlesService],
exports: [CandlesService], exports: [CandlesService],

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { MoexCandlesClient } from '../moex-client/moex-candles.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => { describe('CandlesService', () => {
let service: CandlesService; let service: CandlesService;
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>; let moexClient: Pick<MoexClientService, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexCandles = { moexClient = {
getCandles: vi.fn(), getCandles: vi.fn(),
}; };
cache = { cache = {
@ -24,7 +24,7 @@ describe('CandlesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
CandlesService, CandlesService,
{ provide: MoexCandlesClient, useValue: moexCandles }, { provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -33,7 +33,7 @@ describe('CandlesService', () => {
}); });
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([ vi.mocked(moexClient.getCandles).mockResolvedValue([
{ {
open: 320, open: 320,
high: 325, high: 325,
@ -60,7 +60,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexCandles.getCandles).toHaveBeenCalledWith( expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'shares', 'shares',
'SBER', 'SBER',
@ -81,13 +81,15 @@ describe('CandlesService', () => {
end: '2026-05-01 23:59:59', end: '2026-05-01 23:59:59',
}, },
], ],
fromCache: false, meta: {
cachedAt: '2026-06-15T00:00:00.000Z', fromCache: false,
cachedAt: '2026-06-15T00:00:00.000Z',
},
}); });
}); });
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
vi.mocked(moexCandles.getCandles).mockResolvedValue([]); vi.mocked(moexClient.getCandles).mockResolvedValue([]);
await service.getCandles( await service.getCandles(
'bonds', 'bonds',
@ -103,7 +105,7 @@ describe('CandlesService', () => {
expect.any(Function), expect.any(Function),
'candlesTtl', 'candlesTtl',
); );
expect(moexCandles.getCandles).toHaveBeenCalledWith( expect(moexClient.getCandles).toHaveBeenCalledWith(
'stock', 'stock',
'bonds', 'bonds',
'SU26238RMFS5', 'SU26238RMFS5',

View File

@ -1,13 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexCandlesClient } from '../moex-client/moex-candles.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class CandlesService { export class CandlesService {
constructor( constructor(
private readonly moexCandles: MoexCandlesClient, private readonly moexClient: MoexClientService,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -26,12 +25,12 @@ export class CandlesService {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles', 'candles',
[market, secid, String(moexInterval), from, till], [market, secid, String(moexInterval), from, till],
() => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till), () => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
'candlesTtl', 'candlesTtl',
); );
return new ApiEnvelopePayload( return {
data.map((c) => ({ data: data.map((c) => ({
open: c.open, open: c.open,
high: c.high, high: c.high,
low: c.low, low: c.low,
@ -41,8 +40,7 @@ export class CandlesService {
begin: c.begin, begin: c.begin,
end: c.end, end: c.end,
})), })),
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
} }

View File

@ -1,15 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
class HealthCheckResultDto {
@ApiProperty({ example: 'prisma' })
name!: string;
@ApiProperty({ enum: ['ok', 'error'] })
status!: 'ok' | 'error';
@ApiPropertyOptional({ type: String, nullable: true })
error?: string;
}
export class HealthResponseDto { export class HealthResponseDto {
@ApiProperty({ example: 'ok' }) @ApiProperty({ example: 'ok' })
@ -20,7 +9,4 @@ export class HealthResponseDto {
@ApiProperty({ example: 12345 }) @ApiProperty({ example: 12345 })
uptime!: number; uptime!: number;
@ApiProperty({ type: [HealthCheckResultDto] })
checks!: HealthCheckResultDto[];
} }

View File

@ -3,19 +3,20 @@ import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/sw
import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { HealthEnvelopeDto } from './dto/health-envelope.dto'; import { HealthEnvelopeDto } from './dto/health-envelope.dto';
import { HealthService } from './health.service';
@ApiTags('Health') @ApiTags('Health')
@ApiExtraModels(ApiResponseMeta) @ApiExtraModels(ApiResponseMeta)
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get() @Get()
@Public() @Public()
@ApiOperation({ summary: 'Проверка состояния сервиса' }) @ApiOperation({ summary: 'Проверка состояния сервиса' })
@ApiOkResponse({ type: HealthEnvelopeDto }) @ApiOkResponse({ type: HealthEnvelopeDto })
async check() { check() {
return this.healthService.check(); return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
};
} }
} }

View File

@ -1,11 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({ @Module({
imports: [PrismaModule],
controllers: [HealthController], controllers: [HealthController],
providers: [HealthService],
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -1,68 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { HealthService } from './health.service';
import { PrismaService } from '../prisma/prisma.service';
describe('HealthService', () => {
let service: HealthService;
const prisma = { $queryRaw: vi.fn() } as any;
const config = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.tbank.token': 'token-1',
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const fetchMock = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
vi.stubGlobal('fetch', fetchMock);
fetchMock.mockResolvedValue({ ok: true, status: 200 });
const module: TestingModule = await Test.createTestingModule({
providers: [
HealthService,
{ provide: PrismaService, useValue: prisma },
{ provide: ConfigService, useValue: config },
],
}).compile();
service = module.get<HealthService>(HealthService);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('returns ok when all dependencies are healthy', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.status).toBe('ok');
expect(result.checks).toHaveLength(3);
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('ok');
});
it('returns degraded when prisma is down', async () => {
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
const result = await service.check();
expect(result.status).toBe('degraded');
expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('error');
});
it('includes timestamp and uptime', async () => {
prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]);
const result = await service.check();
expect(result.timestamp).toEqual(expect.any(String));
expect(result.uptime).toEqual(expect.any(Number));
});
});

View File

@ -1,72 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
export interface HealthCheckResult {
name: string;
status: 'ok' | 'error';
error?: string;
}
@Injectable()
export class HealthService {
private readonly logger = new Logger(HealthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
) {}
async check(): Promise<{ status: string; timestamp: string; uptime: number; checks: HealthCheckResult[] }> {
const checks = await Promise.all([
this.checkPrisma(),
this.checkMoex(),
this.checkTBank(),
]);
const allOk = checks.every((c) => c.status === 'ok');
return {
status: allOk ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks,
};
}
private async checkPrisma(): Promise<HealthCheckResult> {
try {
await this.prisma.$queryRaw`SELECT 1`;
return { name: 'prisma', status: 'ok' };
} catch {
return { name: 'prisma', status: 'error', error: 'Database unreachable' };
}
}
private async checkMoex(): Promise<HealthCheckResult> {
try {
const baseUrl = this.config.get<string>('app.moex.baseUrl', 'https://iss.moex.com/iss');
const res = await fetch(`${baseUrl}/engines/stock/quotes.json?iss.meta=off&limit=1`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return { name: 'moex', status: 'error', error: `HTTP ${res.status}` };
}
return { name: 'moex', status: 'ok' };
} catch (err) {
return { name: 'moex', status: 'error', error: 'MOEX API unreachable' };
}
}
private async checkTBank(): Promise<HealthCheckResult> {
try {
const token = this.config.get<string>('app.tbank.token', '');
if (!token) {
return { name: 'tbank', status: 'error', error: 'Not configured' };
}
return { name: 'tbank', status: 'ok' };
} catch {
return { name: 'tbank', status: 'error', error: 'T-Bank API unreachable' };
}
}
}

View File

@ -1,31 +0,0 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexCandlesClient } from './moex-candles.client';
describe('MoexCandlesClient', () => {
let client: MoexCandlesClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexCandlesClient({ request, extractTable } as unknown as MoexHttpClient);
});
it('возвращает свечи для заданного инструмента', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValue([
{ open: '320', close: '322', high: '323', low: '319', value: '100000', volume: '3000', begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
]);
const result = await client.getCandles('stock', 'shares', 'SBER', 60, '2025-01-10', '2025-01-11');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER/candles', {
interval: '60', from: '2025-01-10', till: '2025-01-11',
});
expect(result).toEqual([
{ open: 320, close: 322, high: 323, low: 319, value: 100000, volume: 3000, begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
]);
});
});

View File

@ -1,36 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexCandle } from './moex-client.types';
@Injectable()
export class MoexCandlesClient {
constructor(private readonly http: MoexHttpClient) {}
async getCandles(
engine: 'stock',
market: 'shares' | 'bonds',
secid: string,
interval: 1 | 10 | 60 | 24,
from: string,
till: string,
): Promise<MoexCandle[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
{
interval: String(interval),
from,
till,
},
);
return this.http.extractTable(data, 'candles').map((c) => ({
open: parseFloat(c.open as string),
close: parseFloat(c.close as string),
high: parseFloat(c.high as string),
low: parseFloat(c.low as string),
value: parseFloat(c.value as string),
volume: parseInt(c.volume as string, 10),
begin: c.begin as string,
end: c.end as string,
}));
}
}

View File

@ -1,26 +1,9 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client'; import { MoexClientService } from './moex-client.service';
import { MoexSecuritiesClient } from './moex-securities.client';
import { MoexMarketDataClient } from './moex-market-data.client';
import { MoexCandlesClient } from './moex-candles.client';
import { MoexHistoryClient } from './moex-history.client';
import { MoexDividendsClient } from './moex-dividends.client';
@Global()
@Module({ @Module({
providers: [ providers: [MoexClientService],
MoexHttpClient, exports: [MoexClientService],
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
exports: [
MoexSecuritiesClient,
MoexMarketDataClient,
MoexCandlesClient,
MoexHistoryClient,
MoexDividendsClient,
],
}) })
export class MoexClientModule {} export class MoexClientModule {}

View File

@ -1,35 +1,32 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { MoexClientModule } from './moex-client.module'; import { MoexClientService } from './moex-client.service';
import { MoexSecuritiesClient } from './moex-securities.client';
import { MoexMarketDataClient } from './moex-market-data.client';
import configuration from '../../config/configuration'; import configuration from '../../config/configuration';
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')( describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
'MoexClient live MOEX integration', 'MoexClientService live MOEX integration',
() => { () => {
let moexSecurities: MoexSecuritiesClient; let service: MoexClientService;
let moexMarketData: MoexMarketDataClient;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule], imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [MoexClientService],
}).compile(); }).compile();
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient); service = module.get<MoexClientService>(MoexClientService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
}); });
it('возвращает результаты поиска для SBER из live MOEX', async () => { it('возвращает результаты поиска для SBER из live MOEX', async () => {
const results = await moexSecurities.searchSecurities('SBER'); const results = await service.searchSecurities('SBER');
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined(); expect(results[0].secid).toBeDefined();
}, 15000); }, 15000);
it('возвращает рыночные данные SBER из live MOEX', async () => { it('возвращает рыночные данные SBER из live MOEX', async () => {
const data = await moexMarketData.getShareMarketData('SBER'); const data = await service.getShareMarketData('SBER');
expect(data).toBeDefined(); expect(data).toBeDefined();
expect(data!.secid).toBe('SBER'); expect(data!.secid).toBe('SBER');

View File

@ -0,0 +1,186 @@
import 'reflect-metadata';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { MoexClientService } from './moex-client.service';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexClientService', () => {
let service: MoexClientService;
let getMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
getMock = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
service = new MoexClientService({
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.moex.circuitBreakerThreshold': 5,
'app.moex.circuitBreakerResetSeconds': 30,
'app.moex.rateLimit': 10,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService);
});
it('создаётся с настроенным MOEX client', () => {
expect(service).toBeDefined();
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
});
it('нормализует результаты поиска из ISS table format', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: [
'secid',
'isin',
'name',
'shortName',
'latName',
'listLevel',
'issuesize',
'facevalue',
'faceunit',
'issuedate',
'typename',
'group',
'type',
'isqualifiedinvestors',
'morningsession',
'eveningsession',
],
data: [
[
'SBER',
'RU0009029540',
'Сбербанк России ПАО ао',
'Сбербанк',
'Sberbank',
'1',
'21586948000',
'3',
'SUR',
'2007-07-20',
'Акция обыкновенная',
'stock_shares',
'common_share',
'0',
'1',
'1',
],
],
},
},
});
const results = await service.searchSecurities('SBER');
expect(getMock).toHaveBeenCalledWith('/securities.json', {
params: { q: 'SBER', 'iss.meta': 'off' },
});
expect(results).toEqual([
{
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586948000,
faceValue: 3,
faceUnit: 'SUR',
issueDate: '2007-07-20',
typeName: 'Акция обыкновенная',
group: 'stock_shares',
type: 'common_share',
isQualifiedInvestors: false,
morningSession: true,
eveningSession: true,
},
]);
});
it('нормализует market data акции без live MOEX запроса', async () => {
getMock.mockResolvedValueOnce({
data: {
securities: {
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
},
marketdata: {
columns: [
'SECID',
'BOARDID',
'BID',
'OFFER',
'OPEN',
'LOW',
'HIGH',
'LAST',
'LASTCHANGE',
'LASTCHANGEPRCNT',
'VOLTODAY',
'VALTODAY',
'WAPRICE',
'NUMTRADES',
'ISSUECAPITALIZATION',
'TRADINGSTATUS',
'UPDATETIME',
],
data: [
[
'SBER',
'TQBR',
'321',
'322',
'320',
'319',
'323',
'322.35',
'1.15',
'0.36',
'1925163',
'620184479',
'321.9',
'12345',
'6958336818320',
'T',
'10:30:00',
],
],
},
},
});
const data = await service.getShareMarketData('SBER');
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
params: { boards: 'TQBR', 'iss.meta': 'off' },
});
expect(data).toMatchObject({
secid: 'SBER',
boardid: 'TQBR',
shortName: 'Сбербанк',
last: 322.35,
lastChange: 1.15,
lastChangePrcnt: 0.36,
volume: 1925163,
value: 620184479,
issueCapitalization: 6958336818320,
tradingStatus: 'T',
updateTime: '10:30:00',
});
});
});

View File

@ -0,0 +1,423 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import PQueue from 'p-queue';
import {
MoexSecurityDescription,
MoexShareMarketData,
MoexBondData,
MoexBondMarketData,
MoexBondPositionData,
MoexDividend,
MoexCandle,
MoexHistoryEntry,
MoexBondHistoryEntry,
} from './moex-client.types';
@Injectable()
export class MoexClientService {
private readonly logger = new Logger(MoexClientService.name);
private readonly client: AxiosInstance;
private readonly queue: PQueue;
private circuitOpen = false;
private circuitErrorCount = 0;
private readonly threshold: number;
private readonly resetMs: number;
constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
this.client = axios.create({
baseURL: baseUrl,
timeout: 10000,
paramsSerializer: { indexes: null },
});
this.queue = new PQueue({
interval: 1000,
intervalCap: rateLimit,
});
}
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
if (this.circuitOpen) {
throw new Error('Circuit breaker is open — MOEX requests paused');
}
return this.queue.add(async () => {
try {
const jsonPath = path + '.json';
const response = await this.client.get(jsonPath, {
params: { ...params, 'iss.meta': 'off' },
});
this.circuitErrorCount = 0;
return response.data as T;
} catch (error) {
this.circuitErrorCount++;
if (this.circuitErrorCount >= this.threshold) {
this.circuitOpen = true;
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
setTimeout(() => {
this.circuitOpen = false;
this.circuitErrorCount = 0;
this.logger.log('Circuit breaker reset');
}, this.resetMs);
}
throw error;
}
}) as Promise<T>;
}
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
const table = data[name] as Record<string, unknown> | undefined;
if (!table || !table.columns || !table.data) return [];
const columns = table.columns as string[];
const rows = table.data as unknown[][];
return rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
const data = await this.request<Record<string, unknown>>('/securities', {
q: query,
});
return this.extractTable(data, 'securities').map((s) => ({
secid: s.secid as string,
isin: s.isin as string,
name: s.name as string,
shortName: s.shortName as string,
latName: (s.latName as string) || null,
listLevel: parseInt(s.listLevel as string, 10) || 0,
issueSize: parseInt(s.issuesize as string, 10) || 0,
faceValue: parseFloat(s.facevalue as string) || 0,
faceUnit: (s.faceunit as string) || '',
issueDate: (s.issuedate as string) || '',
typeName: (s.typename as string) || '',
group: (s.group as string) || '',
type: (s.type as string) || '',
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
morningSession: (s.morningsession as string) === '1',
eveningSession: (s.eveningsession as string) === '1',
}));
}
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
const rows = this.extractTable(data, 'description');
if (rows.length === 0) return null;
const map = new Map(rows.map((r) => [r.name, r.value]));
return {
secid,
isin: (map.get('ISIN') as string) || '',
name: (map.get('NAME') as string) || '',
shortName: (map.get('SHORTNAME') as string) || '',
latName: (map.get('LATNAME') as string) || null,
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
faceUnit: (map.get('FACEUNIT') as string) || '',
issueDate: (map.get('ISSUEDATE') as string) || '',
typeName: (map.get('TYPENAME') as string) || '',
group: (map.get('GROUP') as string) || '',
type: (map.get('TYPE') as string) || '',
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
morningSession: (map.get('MORNINGSESSION') as string) === '1',
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
};
}
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ boards: boardId },
);
const rows = this.extractTable(data, 'securities');
const share = rows.find((r) => r.BOARDID === boardId);
if (!share) return null;
const mktRows = this.extractTable(data, 'marketdata');
const mkt = mktRows.find((r) => r.BOARDID === boardId);
return {
secid,
boardid: boardId,
shortName: (share?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
}
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((sec) => {
const secid = sec.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec?.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
});
}
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
params,
);
const securities = this.extractTable(data, 'securities');
const marketdata = this.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((bond) => {
const secid = bond.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: (bond.BOARDID as string) || boardId,
shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent:
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
matDate: (bond?.MATDATE as string) || null,
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
bondType: (bond?.BONDTYPE as string) || null,
offerDate: (bond?.OFFERDATE as string) || null,
};
});
}
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const rows = this.extractTable(data, 'securities');
const bond =
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
rows.find((r) => r.PREVWAPRICE != null) ||
rows[0];
if (!bond) return null;
return {
secid,
boardid: boardId,
shortName: (bond.SHORTNAME as string) || '',
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
nextCoupon: (bond.NEXTCOUPON as string) || null,
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
matDate: (bond.MATDATE as string) || '',
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
isin: (bond.ISIN as string) || '',
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
offerDate: (bond.OFFERDATE as string) || null,
buybackDate: (bond.BUYBACKDATE as string) || null,
bondType: (bond.BONDTYPE as string) || '',
bondSubType: (bond.BONDSUBTYPE as string) || '',
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
};
}
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const mktRows = this.extractTable(data, 'marketdata');
const mkt =
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
mktRows.find((r) => r.LAST != null) ||
mktRows.find((r) => r.SECID === secid);
if (!mkt) return null;
return {
secid,
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
value: parseFloat((mkt.VALTODAY as string) || '0'),
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
updateTime: (mkt.UPDATETIME as string) || '',
};
}
async getDividends(secid: string): Promise<MoexDividend[]> {
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
return this.extractTable(data, 'dividends').map((d) => ({
secid: d.secid as string,
isin: d.isin as string,
registryCloseDate: d.registryclosedate as string,
value: parseFloat(d.value as string),
currencyId: (d.currencyid as string) || 'RUB',
}));
}
async getCandles(
engine: 'stock',
market: 'shares' | 'bonds',
secid: string,
interval: 1 | 10 | 60 | 24,
from: string,
till: string,
): Promise<MoexCandle[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
{
interval: String(interval),
from,
till,
},
);
return this.extractTable(data, 'candles').map((c) => ({
open: parseFloat(c.open as string),
close: parseFloat(c.close as string),
high: parseFloat(c.high as string),
low: parseFloat(c.low as string),
value: parseFloat(c.value as string),
volume: parseInt(c.volume as string, 10),
begin: c.begin as string,
end: c.end as string,
}));
}
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
volume: parseInt((h.VOLUME as string) || '0', 10),
value: parseFloat((h.VALUE as string) || '0'),
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
}));
}
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
}));
}
}

View File

@ -1,29 +0,0 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexDividendsClient } from './moex-dividends.client';
describe('MoexDividendsClient', () => {
let client: MoexDividendsClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexDividendsClient({ request, extractTable } as unknown as MoexHttpClient);
});
it('возвращает дивиденды для бумаги', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValue([
{ secid: 'SBER', isin: 'RU0009029540', registryclosedate: '2025-07-10', value: '33.3', currencyid: 'RUB' },
]);
const result = await client.getDividends('SBER');
expect(request).toHaveBeenCalledWith('/securities/SBER/dividends');
expect(result).toEqual([
{ secid: 'SBER', isin: 'RU0009029540', registryCloseDate: '2025-07-10', value: 33.3, currencyId: 'RUB' },
]);
});
});

View File

@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexDividend } from './moex-client.types';
@Injectable()
export class MoexDividendsClient {
constructor(private readonly http: MoexHttpClient) {}
async getDividends(secid: string): Promise<MoexDividend[]> {
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
return this.http.extractTable(data, 'dividends').map((d) => ({
secid: d.secid as string,
isin: d.isin as string,
registryCloseDate: d.registryclosedate as string,
value: parseFloat(d.value as string),
currencyId: (d.currencyid as string) || 'RUB',
}));
}
}

View File

@ -1,49 +0,0 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexHistoryClient } from './moex-history.client';
describe('MoexHistoryClient', () => {
let client: MoexHistoryClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexHistoryClient({ request, extractTable } as unknown as MoexHttpClient);
});
describe('getHistory', () => {
it('возвращает историю торгов для акции', async () => {
request.mockResolvedValue({ history: { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '322']] } });
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '322' }]);
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { from: '2025-01-10', till: '2025-01-11' });
expect(result).toEqual([
{ tradeDate: '2025-01-10', open: null, low: null, high: null, close: 322, waprice: null, volume: 0, value: 0, numtrades: 0 },
]);
});
it('возвращает пустой массив если history таблица не найдена', async () => {
request.mockResolvedValue({});
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
expect(result).toEqual([]);
});
});
describe('getBondHistory', () => {
it('возвращает историю торгов для облигации', async () => {
request.mockResolvedValue({ 'history:': { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '98.5']] } });
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '98.5' }]);
const result = await client.getBondHistory('SU26238RMFS4', '2025-01-10', '2025-01-11');
expect(result).toEqual([
{ tradeDate: '2025-01-10', close: 98.5, legalClosePrice: null, waprice: null, yieldClose: null, duration: null, accruedInt: null },
]);
});
});
});

View File

@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexHistoryEntry, MoexBondHistoryEntry } from './moex-client.types';
@Injectable()
export class MoexHistoryClient {
constructor(private readonly http: MoexHttpClient) {}
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.http.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
volume: parseInt((h.VOLUME as string) || '0', 10),
value: parseFloat((h.VALUE as string) || '0'),
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
}));
}
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ from, till },
);
const tableName = Object.keys(data).find(
(k) => k.startsWith('history') && !k.includes('cursor'),
);
if (!tableName) return [];
return this.http.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
}));
}
}

View File

@ -1,132 +0,0 @@
import 'reflect-metadata';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { MoexHttpClient } from './moex-http.client';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexHttpClient', () => {
let client: MoexHttpClient;
let getMock: ReturnType<typeof vi.fn>;
const mockConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.moex.baseUrl': 'https://iss.moex.test/iss',
'app.moex.circuitBreakerThreshold': 5,
'app.moex.circuitBreakerResetSeconds': 30,
'app.moex.rateLimit': 10,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
beforeEach(() => {
vi.useFakeTimers();
getMock = vi.fn();
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
client = new MoexHttpClient(mockConfig);
});
afterEach(() => {
vi.useRealTimers();
});
describe('constructor', () => {
it('создаёт axios instance с параметрами из конфига', () => {
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
});
});
describe('request', () => {
it('выполняет GET запрос с .json суффиксом и iss.meta=off', async () => {
getMock.mockResolvedValueOnce({ data: { some: 'data' } });
const result = await client.request<{ some: string }>('/securities', { q: 'SBER' });
expect(getMock).toHaveBeenCalledWith('/securities.json', {
params: { q: 'SBER', 'iss.meta': 'off' },
});
expect(result).toEqual({ some: 'data' });
});
it('открывает circuit breaker после заданного числа ошибок', async () => {
getMock.mockRejectedValue(new Error('Network error'));
for (let i = 0; i < 5; i++) {
await expect(client.request('/test')).rejects.toThrow();
}
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
expect(getMock).toHaveBeenCalledTimes(5);
});
it('закрывает circuit breaker после resetMs', async () => {
getMock.mockRejectedValue(new Error('Network error'));
for (let i = 0; i < 5; i++) {
await expect(client.request('/test')).rejects.toThrow();
}
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
vi.advanceTimersByTime(30000);
getMock.mockResolvedValue({ data: 'ok' });
const result = await client.request('/test');
expect(result).toBe('ok');
});
it('сбрасывает errorCount при успешном запросе', async () => {
getMock
.mockRejectedValueOnce(new Error('fail'))
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ data: 'ok' });
await expect(client.request('/test')).rejects.toThrow('fail');
await expect(client.request('/test')).rejects.toThrow('fail');
const result = await client.request('/test');
expect(result).toBe('ok');
expect(getMock).toHaveBeenCalledTimes(3);
});
});
describe('extractTable', () => {
it('преобразует ISS columns/data формат в массив объектов', () => {
const data = {
securities: {
columns: ['secid', 'name'],
data: [
['SBER', 'Сбербанк'],
['VTBR', 'ВТБ'],
],
},
};
const result = client.extractTable(data as Record<string, unknown>, 'securities');
expect(result).toEqual([
{ secid: 'SBER', name: 'Сбербанк' },
{ secid: 'VTBR', name: 'ВТБ' },
]);
});
it('возвращает пустой массив если таблица не найдена', () => {
const result = client.extractTable({}, 'nonexistent');
expect(result).toEqual([]);
});
it('возвращает пустой массив если нет columns', () => {
const result = client.extractTable({ securities: { data: [] } } as unknown as Record<string, unknown>, 'securities');
expect(result).toEqual([]);
});
});
});

View File

@ -1,76 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import PQueue from 'p-queue';
@Injectable()
export class MoexHttpClient {
private readonly logger = new Logger(MoexHttpClient.name);
private readonly client: AxiosInstance;
private readonly queue: PQueue;
private circuitOpen = false;
private circuitErrorCount = 0;
private readonly threshold: number;
private readonly resetMs: number;
constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
this.client = axios.create({
baseURL: baseUrl,
timeout: 10000,
paramsSerializer: { indexes: null },
});
this.queue = new PQueue({
interval: 1000,
intervalCap: rateLimit,
});
}
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
if (this.circuitOpen) {
throw new Error('Circuit breaker is open — MOEX requests paused');
}
return this.queue.add(async () => {
try {
const jsonPath = path + '.json';
const response = await this.client.get(jsonPath, {
params: { ...params, 'iss.meta': 'off' },
});
this.circuitErrorCount = 0;
return response.data as T;
} catch (error) {
this.circuitErrorCount++;
if (this.circuitErrorCount >= this.threshold) {
this.circuitOpen = true;
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
setTimeout(() => {
this.circuitOpen = false;
this.circuitErrorCount = 0;
this.logger.log('Circuit breaker reset');
}, this.resetMs);
}
throw error;
}
}) as Promise<T>;
}
extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
const table = data[name] as Record<string, unknown> | undefined;
if (!table || !table.columns || !table.data) return [];
const columns = table.columns as string[];
const rows = table.data as unknown[][];
return rows.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((col, i) => {
obj[col] = row[i];
});
return obj;
});
}
}

View File

@ -1,118 +0,0 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexMarketDataClient } from './moex-market-data.client';
describe('MoexMarketDataClient', () => {
let client: MoexMarketDataClient;
let request: ReturnType<typeof vi.fn>;
let extractTable: ReturnType<typeof vi.fn>;
beforeEach(() => {
request = vi.fn();
extractTable = vi.fn();
client = new MoexMarketDataClient({ request, extractTable } as unknown as MoexHttpClient);
});
describe('getShareMarketData', () => {
it('возвращает рыночные данные акции из securities и marketdata таблиц', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }])
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', BID: '321', OFFER: '322', OPEN: '320', LOW: '319', HIGH: '323', LAST: '322.35', LASTCHANGE: '1.15', LASTCHANGEPRCNT: '0.36', VOLTODAY: '1925163', VALTODAY: '620184479', WAPRICE: '321.9', NUMTRADES: '12345', ISSUECAPITALIZATION: '6958336818320', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
const result = await client.getShareMarketData('SBER');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { boards: 'TQBR' });
expect(result).toMatchObject({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', last: 322.35, bid: 321, offer: 322 });
});
it('возвращает null если бумага не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]).mockReturnValueOnce([]);
const result = await client.getShareMarketData('INVALID');
expect(result).toBeNull();
});
});
describe('getShareMarketDataBatch', () => {
it('возвращает массив рыночных данных для нескольких бумаг', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([
{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' },
{ SECID: 'VTBR', BOARDID: 'TQBR', SHORTNAME: 'ВТБ', PREVPRICE: '50' },
])
.mockReturnValueOnce([
{ SECID: 'SBER', BOARDID: 'TQBR', LAST: '322', BID: '321', OFFER: '323' },
{ SECID: 'VTBR', BOARDID: 'TQBR', LAST: '50.5', BID: '50.1', OFFER: '50.8' },
]);
const results = await client.getShareMarketDataBatch(['SBER', 'VTBR']);
expect(results).toHaveLength(2);
expect(results[0].secid).toBe('SBER');
expect(results[1].secid).toBe('VTBR');
});
});
describe('getBondData', () => {
it('возвращает данные облигации из securities таблицы', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', PREVWAPRICE: '98.5', COUPONVALUE: '34.5', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
]);
const result = await client.getBondData('SU26238RMFS4');
expect(request).toHaveBeenCalledWith('/engines/stock/markets/bonds/securities/SU26238RMFS4', { boards: 'TQCB' });
expect(result).toMatchObject({ secid: 'SU26238RMFS4', shortName: 'ОФЗ 26238' });
});
it('возвращает null если облигация не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]);
const result = await client.getBondData('INVALID');
expect(result).toBeNull();
});
});
describe('getBondMarketData', () => {
it('возвращает рыночные данные облигации из marketdata таблицы', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', BID: '98', OFFER: '99', YIELD: '7.5', DURATION: '1500', VOLTODAY: '1000', VALTODAY: '98500', NUMTRADES: '50', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
const result = await client.getBondMarketData('SU26238RMFS4');
expect(result).toMatchObject({ secid: 'SU26238RMFS4', last: 98.5, bid: 98, offer: 99, yield: 7.5 });
});
it('возвращает null если marketdata не найдена', async () => {
request.mockResolvedValue({});
extractTable.mockReturnValueOnce([]);
const result = await client.getBondMarketData('INVALID');
expect(result).toBeNull();
});
});
describe('getBondPositionDataBatch', () => {
it('возвращает массив позиций по облигациям', async () => {
request.mockResolvedValue({});
extractTable
.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', COUPONVALUE: '34.5', COUPONPERCENT: '7', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
])
.mockReturnValueOnce([
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', YIELD: '7.5', DURATION: '1500', BID: '98', OFFER: '99' },
]);
const results = await client.getBondPositionDataBatch(['SU26238RMFS4']);
expect(results).toHaveLength(1);
expect(results[0].secid).toBe('SU26238RMFS4');
expect(results[0].price).toBe(98.5);
});
});
});

View File

@ -1,219 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import {
MoexShareMarketData,
MoexBondData,
MoexBondMarketData,
MoexBondPositionData,
} from './moex-client.types';
@Injectable()
export class MoexMarketDataClient {
constructor(private readonly http: MoexHttpClient) {}
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ boards: boardId },
);
const rows = this.http.extractTable(data, 'securities');
const share = rows.find((r) => r.BOARDID === boardId);
if (!share) return null;
const mktRows = this.http.extractTable(data, 'marketdata');
const mkt = mktRows.find((r) => r.BOARDID === boardId);
return {
secid,
boardid: boardId,
shortName: (share?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
}
async getShareMarketDataBatch(
secids: string[],
boardId = 'TQBR',
): Promise<MoexShareMarketData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities`,
params,
);
const securities = this.http.extractTable(data, 'securities');
const marketdata = this.http.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((sec) => {
const secid = sec.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: boardId,
shortName: (sec?.SHORTNAME as string) || '',
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((sec?.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
updateTime: (mkt?.UPDATETIME as string) || '',
};
});
}
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const rows = this.http.extractTable(data, 'securities');
const bond =
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
rows.find((r) => r.PREVWAPRICE != null) ||
rows[0];
if (!bond) return null;
return {
secid,
boardid: boardId,
shortName: (bond.SHORTNAME as string) || '',
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
nextCoupon: (bond.NEXTCOUPON as string) || null,
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
matDate: (bond.MATDATE as string) || '',
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
isin: (bond.ISIN as string) || '',
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
offerDate: (bond.OFFERDATE as string) || null,
buybackDate: (bond.BUYBACKDATE as string) || null,
bondType: (bond.BONDTYPE as string) || '',
bondSubType: (bond.BONDSUBTYPE as string) || '',
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
};
}
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ boards: boardId },
);
const mktRows = this.http.extractTable(data, 'marketdata');
const mkt =
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
mktRows.find((r) => r.LAST != null) ||
mktRows.find((r) => r.SECID === secid);
if (!mkt) return null;
return {
secid,
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
value: parseFloat((mkt.VALTODAY as string) || '0'),
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
updateTime: (mkt.UPDATETIME as string) || '',
};
}
async getBondPositionDataBatch(
secids: string[],
boardId = 'TQCB',
): Promise<MoexBondPositionData[]> {
const params: Record<string, string> = { boards: boardId };
if (secids.length > 0) {
params.securities = secids.join(',');
}
const data = await this.http.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities`,
params,
);
const securities = this.http.extractTable(data, 'securities');
const marketdata = this.http.extractTable(data, 'marketdata');
const secidSet = secids.length > 0 ? new Set(secids) : null;
const filteredSecurities = secidSet
? securities.filter((r) => secidSet.has(r.SECID as string))
: securities;
return filteredSecurities.map((bond) => {
const secid = bond.SECID as string;
const mkt =
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
marketdata.find((r) => r.SECID === secid);
return {
secid,
boardid: (bond.BOARDID as string) || boardId,
shortName: (bond?.SHORTNAME as string) || '',
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
couponPercent:
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
matDate: (bond?.MATDATE as string) || null,
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
bondType: (bond?.BONDTYPE as string) || null,
offerDate: (bond?.OFFERDATE as string) || null,
};
});
}
}

View File

@ -1,67 +0,0 @@
import 'reflect-metadata';
import { MoexHttpClient } from './moex-http.client';
import { MoexSecuritiesClient } from './moex-securities.client';
describe('MoexSecuritiesClient', () => {
let client: MoexSecuritiesClient;
let httpMock: { request: ReturnType<typeof vi.fn>; extractTable: ReturnType<typeof vi.fn> };
beforeEach(() => {
httpMock = {
request: vi.fn(),
extractTable: vi.fn(),
};
client = new MoexSecuritiesClient(httpMock as unknown as MoexHttpClient);
});
describe('searchSecurities', () => {
it('выполняет поиск по запросу и нормализует результаты', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([
{
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: '1', issuesize: '21586948000',
facevalue: '3', faceunit: 'SUR', issuedate: '2007-07-20', typename: 'Акция обыкновенная',
group: 'stock_shares', type: 'common_share', isqualifiedinvestors: '0',
morningsession: '1', eveningsession: '1',
},
]);
const results = await client.searchSecurities('SBER');
expect(httpMock.request).toHaveBeenCalledWith('/securities', { q: 'SBER' });
expect(results).toEqual([
{
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: 1, issueSize: 21586948000,
faceValue: 3, faceUnit: 'SUR', issueDate: '2007-07-20', typeName: 'Акция обыкновенная',
group: 'stock_shares', type: 'common_share', isQualifiedInvestors: false,
morningSession: true, eveningSession: true,
},
]);
});
});
describe('getSecurityDescription', () => {
it('возвращает описание бумаги из description таблицы', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([
{ name: 'ISIN', value: 'RU0009029540' },
{ name: 'SHORTNAME', value: 'Сбербанк' },
]);
const result = await client.getSecurityDescription('SBER');
expect(httpMock.request).toHaveBeenCalledWith('/securities/SBER');
expect(result).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк' });
});
it('возвращает null если description пуст', async () => {
httpMock.request.mockResolvedValue({});
httpMock.extractTable.mockReturnValue([]);
const result = await client.getSecurityDescription('INVALID');
expect(result).toBeNull();
});
});
});

View File

@ -1,55 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MoexHttpClient } from './moex-http.client';
import { MoexSecurityDescription } from './moex-client.types';
@Injectable()
export class MoexSecuritiesClient {
constructor(private readonly http: MoexHttpClient) {}
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
const data = await this.http.request<Record<string, unknown>>('/securities', { q: query });
return this.http.extractTable(data, 'securities').map((s) => ({
secid: s.secid as string,
isin: s.isin as string,
name: s.name as string,
shortName: s.shortName as string,
latName: (s.latName as string) || null,
listLevel: parseInt(s.listLevel as string, 10) || 0,
issueSize: parseInt(s.issuesize as string, 10) || 0,
faceValue: parseFloat(s.facevalue as string) || 0,
faceUnit: (s.faceunit as string) || '',
issueDate: (s.issuedate as string) || '',
typeName: (s.typename as string) || '',
group: (s.group as string) || '',
type: (s.type as string) || '',
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
morningSession: (s.morningsession as string) === '1',
eveningSession: (s.eveningsession as string) === '1',
}));
}
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}`);
const rows = this.http.extractTable(data, 'description');
if (rows.length === 0) return null;
const map = new Map(rows.map((r) => [r.name, r.value]));
return {
secid,
isin: (map.get('ISIN') as string) || '',
name: (map.get('NAME') as string) || '',
shortName: (map.get('SHORTNAME') as string) || '',
latName: (map.get('LATNAME') as string) || null,
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
faceUnit: (map.get('FACEUNIT') as string) || '',
issueDate: (map.get('ISSUEDATE') as string) || '',
typeName: (map.get('TYPENAME') as string) || '',
group: (map.get('GROUP') as string) || '',
type: (map.get('TYPE') as string) || '',
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
morningSession: (map.get('MORNINGSESSION') as string) === '1',
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
};
}
}

View File

@ -8,7 +8,6 @@ import {
IsIn, IsIn,
MaxLength, MaxLength,
MinLength, MinLength,
IsDateString,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -32,7 +31,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 }) @ApiProperty({ example: 10 })
@IsInt() @IsInt()
@Min(1) @Min(0)
quantity!: number; quantity!: number;
@ApiPropertyOptional({ example: 250.5 }) @ApiPropertyOptional({ example: 250.5 })
@ -42,7 +41,7 @@ export class AddPositionDto {
buyPrice?: number; buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' }) @ApiPropertyOptional({ example: '2026-06-01' })
@IsDateString() @IsString()
@IsOptional() @IsOptional()
buyDate?: string; buyDate?: string;

View File

@ -11,24 +11,6 @@ export class PortfolioSummaryDto {
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
@ApiProperty() positionCount!: number; @ApiProperty() positionCount!: number;
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
@ApiProperty({ type: Number, nullable: true })
targetSharesPercent?: number | null;
@ApiProperty({ type: Number, nullable: true })
targetBondsPercent?: number | null;
@ApiProperty()
actualSharesPercent!: number;
@ApiProperty()
actualBondsPercent!: number;
@ApiProperty({ type: Number, nullable: true })
sharesDeviation?: number | null;
@ApiProperty({ type: Number, nullable: true })
bondsDeviation?: number | null;
} }
export class AnalyticsResponseDto { export class AnalyticsResponseDto {

View File

@ -1,46 +1,53 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { AnalyticsResponseDto } from './analytics-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto'; import { PositionResponseDto } from './position-response.dto';
export class PortfolioResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class PortfolioListEnvelopeDto { export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] }) @ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[]; data!: PortfolioListResponseDto[];
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PortfolioEnvelopeDto { export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto }) @ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto; data!: PortfolioResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PortfolioDetailEnvelopeDto { export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto }) @ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto; data!: PortfolioDetailResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class PositionEnvelopeDto { export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto }) @ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto; data!: PositionResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }
export class AnalyticsEnvelopeDto { export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto }) @ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto; data!: AnalyticsResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: PortfolioResponseMetaDto })
meta!: ApiResponseMeta; meta!: PortfolioResponseMetaDto;
} }

View File

@ -9,16 +9,6 @@ export class PortfolioResponseDto {
@ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;
@ApiPropertyOptional({
type: 'object',
properties: {
sharesPercent: { type: 'number' },
bondsPercent: { type: 'number' },
},
nullable: true,
})
targets!: { sharesPercent: number; bondsPercent: number } | null;
} }
export class PortfolioDetailResponseDto extends PortfolioResponseDto { export class PortfolioDetailResponseDto extends PortfolioResponseDto {

View File

@ -1,43 +0,0 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AddPositionDto } from './add-position.dto';
import { UpdatePositionDto } from './update-position.dto';
describe('position DTO validation', () => {
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
validate(plainToInstance(cls, payload));
it('rejects zero quantity when adding a position', async () => {
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects zero quantity when updating a position', async () => {
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects invalid buyDate values', async () => {
const addErrors = await validateDto(AddPositionDto, {
secid: 'SBER',
quantity: 1,
buyDate: 'not-a-date',
});
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
});
it('accepts valid position payloads', async () => {
await expect(
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
).resolves.toHaveLength(0);
await expect(
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
).resolves.toHaveLength(0);
});
});

View File

@ -1,34 +1,8 @@
import { import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
IsString, import { ApiPropertyOptional } from '@nestjs/swagger';
IsOptional,
IsIn,
IsObject,
IsNumber,
MaxLength,
MinLength,
Min,
Max,
ValidateNested,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const; const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
export class PortfolioTargetsDto {
@ApiProperty({ example: 70 })
@IsNumber()
@Min(0)
@Max(100)
sharesPercent!: number;
@ApiProperty({ example: 30 })
@IsNumber()
@Min(0)
@Max(100)
bondsPercent!: number;
}
export class UpdatePortfolioDto { export class UpdatePortfolioDto {
@ApiPropertyOptional({ example: 'Мой портфель' }) @ApiPropertyOptional({ example: 'Мой портфель' })
@IsString() @IsString()
@ -48,11 +22,4 @@ export class UpdatePortfolioDto {
@IsIn(CURRENCIES) @IsIn(CURRENCIES)
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@ApiPropertyOptional({ example: { sharesPercent: 70, bondsPercent: 30 } })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => PortfolioTargetsDto)
targets?: PortfolioTargetsDto;
} }

View File

@ -7,7 +7,6 @@ import {
IsArray, IsArray,
IsIn, IsIn,
MaxLength, MaxLength,
IsDateString,
} from 'class-validator'; } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
@ -25,7 +24,7 @@ const TAGS = [
export class UpdatePositionDto { export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 }) @ApiPropertyOptional({ example: 15 })
@IsInt() @IsInt()
@Min(1) @Min(0)
@IsOptional() @IsOptional()
quantity?: number; quantity?: number;
@ -36,7 +35,7 @@ export class UpdatePositionDto {
buyPrice?: number; buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' }) @ApiPropertyOptional({ example: '2026-06-15' })
@IsDateString() @IsString()
@IsOptional() @IsOptional()
buyDate?: string; buyDate?: string;

View File

@ -13,28 +13,32 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { ApiResponseMeta } from '../../common/dto/api-response.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { import {
AnalyticsEnvelopeDto, AnalyticsEnvelopeDto,
PortfolioDetailEnvelopeDto, PortfolioDetailEnvelopeDto,
PortfolioEnvelopeDto, PortfolioEnvelopeDto,
PortfolioListEnvelopeDto, PortfolioListEnvelopeDto,
PortfolioResponseMetaDto,
PositionEnvelopeDto, PositionEnvelopeDto,
} from './dto/portfolio-envelope.dto'; } from './dto/portfolio-envelope.dto';
const nullDataEnvelopeSchema = { const nullDataEnvelopeSchema = {
type: 'object', type: 'object',
properties: { properties: {
data: { type: 'null' }, data: {
meta: { $ref: getSchemaPath(ApiResponseMeta) }, type: 'null',
},
meta: {
$ref: getSchemaPath(PortfolioResponseMetaDto),
},
}, },
required: ['data', 'meta'], required: ['data', 'meta'],
}; };
@ApiTags('Portfolios') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(ApiResponseMeta) @ApiExtraModels(PortfolioResponseMetaDto)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}
@ -43,21 +47,24 @@ export class PortfolioController {
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListEnvelopeDto }) @ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) { async findAll(@CurrentUser() user: { sub: number }) {
return this.portfolioService.findAll(user.sub); const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
} }
@Post() @Post()
@ApiOperation({ summary: 'Create a new portfolio' }) @ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto }) @ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
return this.portfolioService.create(user.sub, dto); const portfolio = await this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' }) @ApiOperation({ summary: 'Get portfolio details with positions and prices' })
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto }) @ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
return this.portfolioService.findOne(user.sub, id); const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id') @Patch(':id')
@ -68,7 +75,8 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePortfolioDto, @Body() dto: UpdatePortfolioDto,
) { ) {
return this.portfolioService.update(user.sub, id, dto); const portfolio = await this.portfolioService.update(user.sub, id, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id') @Delete(':id')
@ -76,7 +84,7 @@ export class PortfolioController {
@ApiOkResponse({ schema: nullDataEnvelopeSchema }) @ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id); await this.portfolioService.remove(user.sub, id);
return null; return { data: null, meta: { cachedAt: null, fromCache: false } };
} }
@Post(':id/positions') @Post(':id/positions')
@ -87,7 +95,8 @@ export class PortfolioController {
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Body() dto: AddPositionDto, @Body() dto: AddPositionDto,
) { ) {
return this.portfolioService.addPosition(user.sub, id, dto); const position = await this.portfolioService.addPosition(user.sub, id, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
} }
@Patch(':id/positions/:positionId') @Patch(':id/positions/:positionId')
@ -99,14 +108,16 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
@Body() dto: UpdatePositionDto, @Body() dto: UpdatePositionDto,
) { ) {
return this.portfolioService.updatePosition(user.sub, id, positionId, dto); const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
return { data: position, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':id/analytics') @Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' }) @ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto }) @ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
return this.portfolioService.getAnalytics(user.sub, id); const result = await this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
@Delete(':id/positions/:positionId') @Delete(':id/positions/:positionId')
@ -118,6 +129,6 @@ export class PortfolioController {
@Param('positionId', ParseIntPipe) positionId: number, @Param('positionId', ParseIntPipe) positionId: number,
) { ) {
await this.portfolioService.removePosition(user.sub, id, positionId); await this.portfolioService.removePosition(user.sub, id, positionId);
return null; return { data: null, meta: { cachedAt: null, fromCache: false } };
} }
} }

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [PortfolioService], providers: [PortfolioService],
exports: [PortfolioService], exports: [PortfolioService],

View File

@ -2,18 +2,15 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration'; import configuration from '../../config/configuration';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
describe('PortfolioService', () => { describe('PortfolioService', () => {
let service: PortfolioService; let service: PortfolioService;
let prisma: PrismaService; let prisma: PrismaService;
let moexMarketData: MoexMarketDataClient; let moexClient: MoexClientService;
let module: TestingModule; let module: TestingModule;
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({ const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
@ -68,20 +65,13 @@ describe('PortfolioService', () => {
}, },
}, },
{ {
provide: MoexSecuritiesClient, provide: MoexClientService,
useValue: { getSecurityDescription: vi.fn() },
},
{
provide: MoexMarketDataClient,
useValue: { useValue: {
getShareMarketDataBatch: vi.fn(), getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(),
getSecurityDescription: vi.fn(),
}, },
}, },
{
provide: MoexDividendsClient,
useValue: { getDividends: vi.fn() },
},
{ {
provide: CacheService, provide: CacheService,
useValue: { useValue: {
@ -93,7 +83,7 @@ describe('PortfolioService', () => {
service = module.get<PortfolioService>(PortfolioService); service = module.get<PortfolioService>(PortfolioService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient); moexClient = module.get<MoexClientService>(MoexClientService);
}); });
beforeEach(() => { beforeEach(() => {
@ -147,11 +137,11 @@ describe('PortfolioService', () => {
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
]); ]);
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -213,14 +203,14 @@ describe('PortfolioService', () => {
}); });
describe('findOne', () => { describe('findOne', () => {
it('should throw EntityNotFoundException for non-existent portfolio', async () => { it('should throw NotFoundException for non-existent portfolio', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException); await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
}); });
it('should throw PortfolioAccessDeniedException for wrong user', async () => { it('should throw ForbiddenException for wrong user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
}); });
it('should return portfolio with enriched positions and analytics summary', async () => { it('should return portfolio with enriched positions and analytics summary', async () => {
@ -245,7 +235,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -291,7 +281,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
] as any); ] as any);
@ -333,7 +323,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
shortName: 'OFZ 26238', shortName: 'OFZ 26238',
@ -377,7 +367,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250 }, { secid: 'SBER', shortName: 'Sberbank', last: 250 },
] as any); ] as any);
@ -413,7 +403,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any); vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
const result = await service.getPositionsWithPrices(1); const result = await service.getPositionsWithPrices(1);
@ -469,7 +459,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
] as any); ] as any);
@ -521,7 +511,7 @@ describe('PortfolioService', () => {
}), }),
); );
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
{ secid: 'SBER', shortName: 'Sberbank', last: 120 }, { secid: 'SBER', shortName: 'Sberbank', last: 120 },
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 }, { secid: 'GAZP', shortName: 'Gazprom', last: 180 },
] as any); ] as any);
@ -534,16 +524,16 @@ describe('PortfolioService', () => {
expect(result.summary.weightedYield).toBeCloseTo(0, 1); expect(result.summary.weightedYield).toBeCloseTo(0, 1);
}); });
it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => { it('should throw ForbiddenException if portfolio belongs to another user', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException);
}); });
it('should throw EntityNotFoundException if portfolio does not exist', async () => { it('should throw NotFoundException if portfolio does not exist', async () => {
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException); await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException);
}); });
}); });
}); });

View File

@ -1,19 +1,13 @@
import { import {
Injectable, Injectable,
NotFoundException,
BadRequestException, BadRequestException,
ForbiddenException,
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import type { MoexShareMarketData, MoexBondPositionData } from '../moex-client/moex-client.types';
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
import type {
MoexShareMarketData,
MoexBondPositionData,
MoexDividend,
} from '../moex-client/moex-client.types';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
@ -60,14 +54,12 @@ export interface EnrichedPosition {
export class PortfolioService { export class PortfolioService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly moexSecurities: MoexSecuritiesClient, private readonly moexClient: MoexClientService,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async create(userId: number, dto: CreatePortfolioDto) { async create(userId: number, dto: CreatePortfolioDto) {
const portfolio = await this.prisma.portfolio.create({ return this.prisma.portfolio.create({
data: { data: {
userId, userId,
name: dto.name, name: dto.name,
@ -75,8 +67,6 @@ export class PortfolioService {
currency: dto.currency ?? 'RUB', currency: dto.currency ?? 'RUB',
}, },
}); });
return { ...portfolio, targets: null };
} }
async findAll(userId: number) { async findAll(userId: number) {
@ -99,7 +89,6 @@ export class PortfolioService {
positionCount: 0, positionCount: 0,
shareCount: 0, shareCount: 0,
bondCount: 0, bondCount: 0,
targets: p.targets ? JSON.parse(p.targets) : null,
})); }));
} }
@ -128,7 +117,6 @@ export class PortfolioService {
positionCount: positions.length, positionCount: positions.length,
shareCount: positions.filter((pos) => pos.type === 'share').length, shareCount: positions.filter((pos) => pos.type === 'share').length,
bondCount: positions.filter((pos) => pos.type === 'bond').length, bondCount: positions.filter((pos) => pos.type === 'bond').length,
targets: p.targets ? JSON.parse(p.targets) : null,
}; };
}); });
} }
@ -139,8 +127,8 @@ export class PortfolioService {
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
@ -166,35 +154,28 @@ export class PortfolioService {
positions: positionsWithWeights, positions: positionsWithWeights,
totalValue: Math.round(totalValue * 100) / 100, totalValue: Math.round(totalValue * 100) / 100,
analytics: analytics.summary, analytics: analytics.summary,
targets: portfolio.targets ? JSON.parse(portfolio.targets) : null,
}; };
} }
async update(userId: number, id: number, dto: UpdatePortfolioDto) { async update(userId: number, id: number, dto: UpdatePortfolioDto) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const updated = await this.prisma.portfolio.update({ return this.prisma.portfolio.update({
where: { id }, where: { id },
data: { data: {
...(dto.name !== undefined && { name: dto.name }), ...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }), ...(dto.description !== undefined && { description: dto.description }),
...(dto.currency !== undefined && { currency: dto.currency }), ...(dto.currency !== undefined && { currency: dto.currency }),
...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }),
}, },
}); });
return {
...updated,
targets: updated.targets ? JSON.parse(updated.targets) : null,
};
} }
async remove(userId: number, id: number) { async remove(userId: number, id: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', id); if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
await this.prisma.portfolio.delete({ where: { id } }); await this.prisma.portfolio.delete({ where: { id } });
} }
@ -204,8 +185,8 @@ export class PortfolioService {
where: { id: portfolioId }, where: { id: portfolioId },
include: { positions: true }, include: { positions: true },
}); });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const exists = portfolio.positions.find((p) => p.secid === dto.secid); const exists = portfolio.positions.find((p) => p.secid === dto.secid);
if (exists) if (exists)
@ -213,7 +194,7 @@ export class PortfolioService {
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
const desc = await this.moexSecurities.getSecurityDescription(dto.secid); const desc = await this.moexClient.getSecurityDescription(dto.secid);
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`); if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
const type = desc.group === 'stock_bonds' ? 'bond' : 'share'; const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
@ -239,12 +220,12 @@ export class PortfolioService {
dto: UpdatePositionDto, dto: UpdatePositionDto,
) { ) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new EntityNotFoundException('Position', positionId); throw new NotFoundException(`Position ${positionId} not found`);
} }
return this.prisma.position.update({ return this.prisma.position.update({
@ -261,12 +242,12 @@ export class PortfolioService {
async removePosition(userId: number, portfolioId: number, positionId: number) { async removePosition(userId: number, portfolioId: number, positionId: number) {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
const position = await this.prisma.position.findUnique({ where: { id: positionId } }); const position = await this.prisma.position.findUnique({ where: { id: positionId } });
if (!position || position.portfolioId !== portfolioId) { if (!position || position.portfolioId !== portfolioId) {
throw new EntityNotFoundException('Position', positionId); throw new NotFoundException(`Position ${positionId} not found`);
} }
await this.prisma.position.delete({ where: { id: positionId } }); await this.prisma.position.delete({ where: { id: positionId } });
@ -291,10 +272,9 @@ export class PortfolioService {
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
const [shareDataBySecid, bondDataBySecid, dividendsBySecid] = await Promise.all([ const [shareDataBySecid, bondDataBySecid] = await Promise.all([
this.fetchShareBatch(shareSecids, portfolioId), this.fetchShareBatch(shareSecids, portfolioId),
this.fetchBondBatch(bondSecids, portfolioId), this.fetchBondBatch(bondSecids, portfolioId),
this.fetchDividendsBatch(shareSecids, portfolioId),
]); ]);
const enriched: EnrichedPosition[] = []; const enriched: EnrichedPosition[] = [];
@ -325,9 +305,7 @@ export class PortfolioService {
if (pos.type === 'bond') { if (pos.type === 'bond') {
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid))); enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
} else { } else {
enriched.push( enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid), dividendsBySecid.get(pos.secid)),
);
} }
} }
@ -343,7 +321,7 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['shares', cacheKey], ['shares', cacheKey],
() => this.moexMarketData.getShareMarketDataBatch(secids), () => this.moexClient.getShareMarketDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
@ -358,32 +336,12 @@ export class PortfolioService {
const { data } = await this.cache.getOrFetch( const { data } = await this.cache.getOrFetch(
'batchdata', 'batchdata',
['bonds', cacheKey], ['bonds', cacheKey],
() => this.moexMarketData.getBondPositionDataBatch(secids), () => this.moexClient.getBondPositionDataBatch(secids),
'marketDataTtl', 'marketDataTtl',
); );
return new Map(data.map((d) => [d.secid, d])); return new Map(data.map((d) => [d.secid, d]));
} }
private async fetchDividendsBatch(
secids: string[],
portfolioId?: number,
): Promise<Map<string, MoexDividend[]>> {
if (secids.length === 0) return new Map();
const results = await Promise.all(
secids.map(async (secid) => {
const cacheKey = portfolioId ? `pf:${portfolioId}:${secid}` : secid;
const { data } = await this.cache.getOrFetch(
'dividends',
[cacheKey],
() => this.moexDividends.getDividends(secid),
'marketDataTtl',
);
return { secid, dividends: data };
}),
);
return new Map(results.map((r) => [r.secid, r.dividends]));
}
private buildSharePosition( private buildSharePosition(
pos: { pos: {
id: number; id: number;
@ -394,16 +352,9 @@ export class PortfolioService {
}, },
base: EnrichedPosition, base: EnrichedPosition,
data: MoexShareMarketData | undefined, data: MoexShareMarketData | undefined,
dividends?: MoexDividend[],
): EnrichedPosition { ): EnrichedPosition {
const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null; const totalCost = pos.buyPrice !== null ? pos.buyPrice * pos.quantity : null;
let dividendIncome = 0; const dividendIncome = 0;
if (pos.buyDate && dividends && dividends.length > 0) {
const buyDateStr = pos.buyDate.toISOString().split('T')[0];
dividendIncome = dividends
.filter((d) => d.registryCloseDate >= buyDateStr)
.reduce((sum, d) => sum + d.value * pos.quantity, 0);
}
if (!data) { if (!data) {
return { return {
@ -521,8 +472,8 @@ export class PortfolioService {
async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> { async getAnalytics(userId: number, portfolioId: number): Promise<AnalyticsResponseDto> {
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); if (portfolio.userId !== userId) throw new ForbiddenException();
const enrichedPositions = await this.getPositionsWithPrices(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
@ -543,32 +494,6 @@ export class PortfolioService {
) )
: null; : null;
let targetSharesPercent: number | null = null;
let targetBondsPercent: number | null = null;
if (portfolio.targets) {
const targets = JSON.parse(portfolio.targets);
targetSharesPercent = targets.sharesPercent;
targetBondsPercent = targets.bondsPercent;
}
let actualSharesPercent = 0;
let actualBondsPercent = 0;
if (totalValue > 0) {
const shareValue = enrichedPositions
.filter((p) => p.type === 'share')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
const bondValue = enrichedPositions
.filter((p) => p.type === 'bond')
.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
actualSharesPercent = Math.round((shareValue / totalValue) * 10000) / 100;
actualBondsPercent = Math.round((bondValue / totalValue) * 10000) / 100;
}
const sharesDeviation =
targetSharesPercent !== null ? Math.round((actualSharesPercent - targetSharesPercent) * 100) / 100 : null;
const bondsDeviation =
targetBondsPercent !== null ? Math.round((actualBondsPercent - targetBondsPercent) * 100) / 100 : null;
const summary = { const summary = {
totalInvested, totalInvested,
totalValue, totalValue,
@ -579,12 +504,6 @@ export class PortfolioService {
totalReturnPercent, totalReturnPercent,
positionCount, positionCount,
weightedYield, weightedYield,
targetSharesPercent,
targetBondsPercent,
actualSharesPercent,
actualBondsPercent,
sharesDeviation,
bondsDeviation,
}; };
return { positions: enrichedPositions, summary }; return { positions: enrichedPositions, summary };

View File

@ -1,5 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
export class ScreenerItemDto { export class ScreenerItemDto {
@ApiProperty({ example: 'SBER' }) @ApiProperty({ example: 'SBER' })
@ -71,10 +70,18 @@ export class ScreenerResultDto {
totalPages!: number; totalPages!: number;
} }
class ScreenerResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class ScreenerResponseDto { export class ScreenerResponseDto {
@ApiProperty({ type: ScreenerResultDto }) @ApiProperty({ type: ScreenerResultDto })
data!: ScreenerResultDto; data!: ScreenerResultDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: ScreenerResponseMetaDto })
meta!: ApiResponseMeta; meta!: ScreenerResponseMetaDto;
} }

View File

@ -1,21 +1,30 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ScreenerType } from './dto/screener-query.dto'; import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => { describe('ScreenerService', () => {
let service: ScreenerService; let service: ScreenerService;
let cache: CacheService; let cache: CacheService;
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
ScreenerService, ScreenerService,
{ provide: MoexMarketDataClient, useValue: moexMarketData }, {
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } }, provide: MoexClientService,
useValue: {
getShareMarketDataBatch: vi.fn(),
getBondPositionDataBatch: vi.fn(),
},
},
{
provide: CacheService,
useValue: {
getOrFetch: vi.fn(),
},
},
], ],
}).compile(); }).compile();
@ -28,30 +37,6 @@ describe('ScreenerService', () => {
}); });
describe('screen', () => { describe('screen', () => {
it('should cache full dataset with screenerTtl config', async () => {
const mockShares = [{
secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000,
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
}];
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}));
await service.screen({ type: ScreenerType.SHARE });
expect(cache.getOrFetch).toHaveBeenCalledWith(
'screener',
[ScreenerType.SHARE],
expect.any(Function),
'screenerTtl',
);
});
it('should filter and sort shares', async () => { it('should filter and sort shares', async () => {
const mockShares = [ const mockShares = [
{ {

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'; import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'
@Injectable() @Injectable()
export class ScreenerService { export class ScreenerService {
constructor( constructor(
private readonly moexMarketData: MoexMarketDataClient, private readonly moexClient: MoexClientService,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -38,7 +38,7 @@ export class ScreenerService {
[type], [type],
async () => { async () => {
if (type === ScreenerType.SHARE) { if (type === ScreenerType.SHARE) {
const shares = await this.moexMarketData.getShareMarketDataBatch([]); const shares = await this.moexClient.getShareMarketDataBatch([]);
return shares.map( return shares.map(
(s): ScreenerItemDto => ({ (s): ScreenerItemDto => ({
secid: s.secid, secid: s.secid,
@ -61,7 +61,7 @@ export class ScreenerService {
}), }),
); );
} else { } else {
const bonds = await this.moexMarketData.getBondPositionDataBatch([]); const bonds = await this.moexClient.getBondPositionDataBatch([]);
return bonds.map( return bonds.map(
(b): ScreenerItemDto => ({ (b): ScreenerItemDto => ({
secid: b.secid, secid: b.secid,
@ -85,7 +85,7 @@ export class ScreenerService {
); );
} }
}, },
'screenerTtl', 'marketDataTtl',
); );
return data; return data;

View File

@ -47,7 +47,7 @@ describe('SecuritiesController', () => {
it('should return search results', async () => { it('should return search results', async () => {
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 }); const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
expect(result).toEqual(mockResults); expect(result.data).toEqual(mockResults);
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5); expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
}); });
}); });

View File

@ -21,17 +21,19 @@ export class SecuritiesController {
@ApiOperation({ summary: 'Поиск по инструментам' }) @ApiOperation({ summary: 'Поиск по инструментам' })
@ApiOkResponse({ type: SearchEnvelopeDto }) @ApiOkResponse({ type: SearchEnvelopeDto })
async search(@Query(ValidationPipe) query: SearchQueryDto) { async search(@Query(ValidationPipe) query: SearchQueryDto) {
return this.securitiesService.search( const results = await this.securitiesService.search(
query.q, query.q,
query.type || SecurityType.ALL, query.type || SecurityType.ALL,
query.limit || 20, query.limit || 20,
); );
return { data: results, meta: { cachedAt: null, fromCache: false } };
} }
@Get('screener') @Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResponseDto }) @ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
return this.screenerService.screen(query); const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } };
} }
} }

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module'; import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller'; import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
@Module({ @Module({
imports: [MoexClientModule], imports: [CacheModule],
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [SecuritiesService, ScreenerService], providers: [SecuritiesService, ScreenerService],
exports: [SecuritiesService], exports: [SecuritiesService],

View File

@ -1,16 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => { describe('SecuritiesService', () => {
let service: SecuritiesService; let service: SecuritiesService;
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>; let moexClient: Pick<MoexClientService, 'searchSecurities'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexSecurities = { moexClient = {
searchSecurities: vi.fn(), searchSecurities: vi.fn(),
}; };
cache = { cache = {
@ -24,7 +24,7 @@ describe('SecuritiesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SecuritiesService, SecuritiesService,
{ provide: MoexSecuritiesClient, useValue: moexSecurities }, { provide: MoexClientService, useValue: moexClient },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -33,7 +33,7 @@ describe('SecuritiesService', () => {
}); });
it('returns supported securities only and normalizes SUR currency to RUB', async () => { it('returns supported securities only and normalizes SUR currency to RUB', async () => {
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([ vi.mocked(moexClient.searchSecurities).mockResolvedValue([
{ {
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
@ -118,7 +118,7 @@ describe('SecuritiesService', () => {
expect.any(Function), expect.any(Function),
'searchTtl', 'searchTtl',
); );
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr'); expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
}); });
it('filters by type and applies limit without live MOEX dependency', async () => { it('filters by type and applies limit without live MOEX dependency', async () => {
@ -169,6 +169,6 @@ describe('SecuritiesService', () => {
price: null, price: null,
}, },
]); ]);
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled(); expect(moexClient.searchSecurities).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
@ -16,7 +16,7 @@ export interface SearchResultItem {
@Injectable() @Injectable()
export class SecuritiesService { export class SecuritiesService {
constructor( constructor(
private readonly moexSecurities: MoexSecuritiesClient, private readonly moexClient: MoexClientService,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
@ -25,7 +25,7 @@ export class SecuritiesService {
'search', 'search',
[query.toLowerCase()], [query.toLowerCase()],
async () => { async () => {
const results = await this.moexSecurities.searchSecurities(query); const results = await this.moexClient.searchSecurities(query);
return results return results
.map((s): SearchResultItem | null => { .map((s): SearchResultItem | null => {
const type = const type =
@ -64,7 +64,7 @@ export class SecuritiesService {
async getShareBrief(secid: string): Promise<SearchResultItem | null> { async getShareBrief(secid: string): Promise<SearchResultItem | null> {
try { try {
const desc = await this.moexSecurities.getSecurityDescription(secid); const desc = await this.moexClient.getSecurityDescription(secid);
if (!desc) return null; if (!desc) return null;
return { return {
secid: desc.secid, secid: desc.secid,

View File

@ -19,7 +19,8 @@ export class SharesController {
@ApiOperation({ summary: 'Получить спецификацию акции' }) @ApiOperation({ summary: 'Получить спецификацию акции' })
@ApiOkResponse({ type: ShareEnvelopeDto }) @ApiOkResponse({ type: ShareEnvelopeDto })
async getShare(@Param('secid') secid: string) { async getShare(@Param('secid') secid: string) {
return this.sharesService.getShare(secid); const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
} }
@Get(':secid/marketdata') @Get(':secid/marketdata')

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClientModule } from '../moex-client/moex-client.module';
import { SharesController } from './shares.controller'; import { SharesController } from './shares.controller';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
@Module({ @Module({
imports: [MoexClientModule],
controllers: [SharesController], controllers: [SharesController],
providers: [SharesService], providers: [SharesService],
exports: [SharesService], exports: [SharesService],

View File

@ -1,23 +1,17 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
describe('SharesService', () => { describe('SharesService', () => {
let service: SharesService; let service: SharesService;
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>; let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>; let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { beforeEach(async () => {
moexSecurities = { moexClient = {
getSecurityDescription: vi.fn(), getSecurityDescription: vi.fn(),
};
moexMarketData = {
getShareMarketData: vi.fn(), getShareMarketData: vi.fn(),
}; };
cache = { cache = {
@ -31,10 +25,7 @@ describe('SharesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SharesService, SharesService,
{ provide: MoexSecuritiesClient, useValue: moexSecurities }, { provide: MoexClientService, useValue: moexClient },
{ provide: MoexMarketDataClient, useValue: moexMarketData },
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
{ provide: CacheService, useValue: cache }, { provide: CacheService, useValue: cache },
], ],
}).compile(); }).compile();
@ -43,7 +34,7 @@ describe('SharesService', () => {
}); });
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао', name: 'Сбербанк России ПАО ао',
@ -61,7 +52,7 @@ describe('SharesService', () => {
morningSession: true, morningSession: true,
eveningSession: true, eveningSession: true,
}); });
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({ vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
secid: 'SBER', secid: 'SBER',
boardid: 'TQBR', boardid: 'TQBR',
shortName: 'Сбербанк', shortName: 'Сбербанк',
@ -84,15 +75,15 @@ describe('SharesService', () => {
const result = await service.getShare('SBER'); const result = await service.getShare('SBER');
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER'); expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'marketdata', 'marketdata',
['shares', 'SBER'], ['shares', 'SBER'],
expect.any(Function), expect.any(Function),
'marketDataTtl', 'marketDataTtl',
); );
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER'); expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
expect(result.data).toMatchObject({ expect(result).toMatchObject({
secid: 'SBER', secid: 'SBER',
isin: 'RU0009029540', isin: 'RU0009029540',
name: 'Сбербанк России ПАО ао', name: 'Сбербанк России ПАО ао',
@ -115,11 +106,11 @@ describe('SharesService', () => {
issueCapitalization: 6900000000000, issueCapitalization: 6900000000000,
}, },
}); });
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); expect(result.marketData.updatedAt).toMatch(/T18:45:00$/);
}); });
it('throws EntityNotFoundException for non-share security', async () => { it('throws NotFoundException for non-share security', async () => {
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
secid: 'SU26238RMFS5', secid: 'SU26238RMFS5',
isin: 'RU000A1038V6', isin: 'RU000A1038V6',
name: 'ОФЗ 26238', name: 'ОФЗ 26238',
@ -138,7 +129,7 @@ describe('SharesService', () => {
eveningSession: false, eveningSession: false,
}); });
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException); await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
expect(cache.getOrFetch).not.toHaveBeenCalled(); expect(cache.getOrFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@ -1,24 +1,16 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { MoexClientService } from '../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
import { MoexHistoryClient } from '../moex-client/moex-history.client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
@Injectable() @Injectable()
export class SharesService { export class SharesService {
constructor( constructor(
private readonly moexSecurities: MoexSecuritiesClient, private readonly moexClient: MoexClientService,
private readonly moexMarketData: MoexMarketDataClient,
private readonly moexDividends: MoexDividendsClient,
private readonly moexHistory: MoexHistoryClient,
private readonly cache: CacheService, private readonly cache: CacheService,
) {} ) {}
async getShare(secid: string) { async getShare(secid: string) {
const desc = await this.moexSecurities.getSecurityDescription(secid); const desc = await this.moexClient.getSecurityDescription(secid);
if ( if (
!desc || !desc ||
!( !(
@ -27,17 +19,13 @@ export class SharesService {
desc.type === 'preferred_share' desc.type === 'preferred_share'
) )
) { ) {
throw new EntityNotFoundException('Share', secid); throw new NotFoundException(`Share ${secid} not found`);
} }
const { const { data: marketData } = await this.cache.getOrFetch(
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexMarketData.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
@ -45,36 +33,32 @@ export class SharesService {
const change = marketData?.lastChange ?? 0; const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0;
return new ApiEnvelopePayload( return {
{ secid: desc.secid,
secid: desc.secid, isin: desc.isin,
isin: desc.isin, name: desc.name,
name: desc.name, shortName: desc.shortName,
shortName: desc.shortName, latName: desc.latName,
latName: desc.latName, listLevel: desc.listLevel,
listLevel: desc.listLevel, issueSize: desc.issueSize,
issueSize: desc.issueSize, faceValue: desc.faceValue,
faceValue: desc.faceValue, faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, type: desc.type,
type: desc.type, marketData: {
marketData: { price: price ?? 0,
price: price ?? 0, change,
change, changePercent,
changePercent, open: marketData?.open ?? 0,
open: marketData?.open ?? 0, high: marketData?.high ?? null,
high: marketData?.high ?? null, low: marketData?.low ?? null,
low: marketData?.low ?? null, volume: marketData?.volume ?? 0,
volume: marketData?.volume ?? 0, value: marketData?.value ?? 0,
value: marketData?.value ?? 0, issueCapitalization: marketData?.issueCapitalization ?? null,
issueCapitalization: marketData?.issueCapitalization ?? null, updatedAt: marketData?.updateTime
updatedAt: marketData?.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime : new Date().toISOString(),
: new Date().toISOString(),
},
}, },
fromCache, };
cachedAt,
);
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
@ -85,16 +69,16 @@ export class SharesService {
} = await this.cache.getOrFetch( } = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexMarketData.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),
'marketDataTtl', 'marketDataTtl',
); );
if (!marketData) { if (!marketData) {
throw new EntityNotFoundException('MarketData', secid); throw new NotFoundException(`Market data for ${secid} not found`);
} }
return new ApiEnvelopePayload( return {
{ data: {
price: marketData.last ?? 0, price: marketData.last ?? 0,
change: marketData.lastChange ?? 0, change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0, changePercent: marketData.lastChangePrcnt ?? 0,
@ -108,40 +92,38 @@ export class SharesService {
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(), : new Date().toISOString(),
}, },
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
async getDividends(secid: string) { async getDividends(secid: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'dividends', 'dividends',
[secid], [secid],
() => this.moexDividends.getDividends(secid), () => this.moexClient.getDividends(secid),
'dividendsTtl', 'dividendsTtl',
); );
return new ApiEnvelopePayload( return {
data.map((d) => ({ data: data.map((d) => ({
registryCloseDate: d.registryCloseDate, registryCloseDate: d.registryCloseDate,
value: d.value, value: d.value,
currency: d.currencyId, currency: d.currencyId,
})), })),
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
async getHistory(secid: string, from: string, till: string) { async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history', 'history',
['shares', secid, from, till], ['shares', secid, from, till],
() => this.moexHistory.getHistory(secid, from, till), () => this.moexClient.getHistory(secid, from, till),
'historyTtl', 'historyTtl',
); );
return new ApiEnvelopePayload( return {
data.map((h) => ({ data: data.map((h) => ({
date: h.tradeDate, date: h.tradeDate,
open: h.open ?? 0, open: h.open ?? 0,
high: h.high ?? 0, high: h.high ?? 0,
@ -150,8 +132,7 @@ export class SharesService {
volume: h.volume, volume: h.volume,
value: h.value, value: h.value,
})), })),
fromCache, meta: { fromCache, cachedAt },
cachedAt, };
);
} }
} }

View File

@ -19,12 +19,6 @@ export class BrokerAnalyticsDto {
@ApiProperty() @ApiProperty()
totalReceived!: number; totalReceived!: number;
@ApiProperty()
totalFees!: number;
@ApiProperty()
totalTaxesPaid!: number;
@ApiProperty({ type: Number, nullable: true }) @ApiProperty({ type: Number, nullable: true })
totalReturnPercent!: number | null; totalReturnPercent!: number | null;

View File

@ -1,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ApiResponseMeta } from '../../../common/dto/api-response.dto';
import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerEventsDataDto } from './broker-events-response.dto';
import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto';
@ -7,68 +6,67 @@ import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto
import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
import { BrokerPortfolioHistoryDataDto } from './broker-portfolio-history-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class BrokerAccountsEnvelopeDto { export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: [BrokerAccountResponseDto] }) @ApiProperty({ type: [BrokerAccountResponseDto] })
data!: BrokerAccountResponseDto[]; data!: BrokerAccountResponseDto[];
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerPortfolioEnvelopeDto { export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto }) @ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto; data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerOperationsEnvelopeDto { export class BrokerOperationsEnvelopeDto {
@ApiProperty({ type: BrokerOperationsPageResponseDto }) @ApiProperty({ type: BrokerOperationsPageResponseDto })
data!: BrokerOperationsPageResponseDto; data!: BrokerOperationsPageResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerPositionsEnvelopeDto { export class BrokerPositionsEnvelopeDto {
@ApiProperty({ type: BrokerPositionsPageResponseDto }) @ApiProperty({ type: BrokerPositionsPageResponseDto })
data!: BrokerPositionsPageResponseDto; data!: BrokerPositionsPageResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerOperationSyncEnvelopeDto { export class BrokerOperationSyncEnvelopeDto {
@ApiProperty({ type: BrokerOperationSyncResponseDto }) @ApiProperty({ type: BrokerOperationSyncResponseDto })
data!: BrokerOperationSyncResponseDto; data!: BrokerOperationSyncResponseDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerAnalyticsEnvelopeDto { export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto }) @ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto; data!: BrokerAnalyticsDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
} }
export class BrokerEventsEnvelopeDto { export class BrokerEventsEnvelopeDto {
@ApiProperty({ type: BrokerEventsDataDto }) @ApiProperty({ type: BrokerEventsDataDto })
data!: BrokerEventsDataDto; data!: BrokerEventsDataDto;
@ApiProperty({ type: ApiResponseMeta }) @ApiProperty({ type: BrokerResponseMetaDto })
meta!: ApiResponseMeta; meta!: BrokerResponseMetaDto;
}
export class BrokerPortfolioHistoryEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioHistoryDataDto })
data!: BrokerPortfolioHistoryDataDto;
@ApiProperty({ type: ApiResponseMeta })
meta!: ApiResponseMeta;
} }

View File

@ -40,9 +40,4 @@ export class BrokerOperationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
state?: string; state?: string;
@ApiPropertyOptional({ description: 'Comma-separated category filter: trade,income,tax,fee,transfer,other' })
@IsOptional()
@IsString()
categories?: string;
} }

View File

@ -1,24 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPortfolioHistoryPointDto {
@ApiProperty()
month!: string;
@ApiProperty()
label!: string;
@ApiProperty({ type: BrokerMoneyDto })
value!: BrokerMoneyDto;
}
export class BrokerPortfolioHistoryDataDto {
@ApiProperty()
accountId!: string;
@ApiProperty({ type: [BrokerPortfolioHistoryPointDto] })
points!: BrokerPortfolioHistoryPointDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => { describe('BrokerAccountsService', () => {
const client = { const client = {
getUsersClient: vi.fn(), getServiceClient: vi.fn(),
callUnary: vi.fn(), callUnary: vi.fn(),
} as unknown as TBankClientService; } as unknown as TBankClientService;
const cache = { const cache = {
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
cachedAt: '2026-06-16T02:30:00.000Z', cachedAt: '2026-06-16T02:30:00.000Z',
}), }),
); );
vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any); vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({ vi.mocked(client.callUnary).mockResolvedValue({
accounts: [ accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' }, { id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
@ -38,7 +38,7 @@ describe('BrokerAccountsService', () => {
expect(result.data).toHaveLength(2); expect(result.data).toHaveLength(2);
expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']); expect(result.data.map((account) => account.type)).toEqual(['brokerage', 'iis']);
expect(result.fromCache).toBe(false); expect(result.meta.fromCache).toBe(false);
expect(cache.getOrFetch).toHaveBeenCalledWith( expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:accounts', 'tbank:accounts',
['open-brokerage-iis'], ['open-brokerage-iis'],

View File

@ -5,7 +5,6 @@ import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types'; import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccountsResponse } from '../types/tbank-proto.types'; import type { TBankAccountsResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class BrokerAccountsService { export class BrokerAccountsService {
@ -14,7 +13,10 @@ export class BrokerAccountsService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async findAll(): Promise<ApiEnvelopePayload<BrokerAccount[]>> { async findAll(): Promise<{
data: BrokerAccount[];
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.accounts, TBANK_CACHE_KEYS.accounts,
['open-brokerage-iis'], ['open-brokerage-iis'],
@ -22,7 +24,10 @@ export class BrokerAccountsService {
'tbankAccountsTtl', 'tbankAccountsTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
async findById(accountId: string): Promise<BrokerAccount | null> { async findById(accountId: string): Promise<BrokerAccount | null> {
@ -32,9 +37,9 @@ export class BrokerAccountsService {
} }
private async fetchAccounts(): Promise<BrokerAccount[]> { private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getUsersClient(); const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
{ status: string }, Record<string, string>,
TBankAccountsResponse TBankAccountsResponse
>( >(
'UsersService/GetAccounts', 'UsersService/GetAccounts',

View File

@ -1,16 +1,12 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service'; import { BrokerAnalyticsService } from './broker-analytics.service';
import { TBankClientService } from './tbank-client.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
describe('BrokerAnalyticsService', () => { describe('BrokerAnalyticsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const tbankClient = { const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService;
getOperationsClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = { const acc1 = {
@ -22,8 +18,6 @@ describe('BrokerAnalyticsService', () => {
accessLevel: null, accessLevel: null,
}; };
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
function mockCachePassthrough() { function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation( vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({ async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
@ -34,55 +28,27 @@ describe('BrokerAnalyticsService', () => {
); );
} }
function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse { function makeOp(type: string, value: number, state?: string | null) {
const response: TBankPortfolioResponse = { accountId: 'acc-1' }; return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any;
if (expectedYield) response.expectedYield = expectedYield;
return response;
}
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
return {
type,
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
state,
id: `${type}-${value}`,
cursor: '',
brokerAccountId: 'acc-1',
};
}
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
return { items, hasNext, nextCursor };
}
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
throw new Error(`Unexpected call: ${label}`);
},
);
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
}); });
it('throws 404 for missing account', async () => { it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException);
}); });
it('returns zeros for account with no operations', async () => { it('returns zeros for account with no operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([]); vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data).toEqual({ expect(result.data).toEqual({
@ -93,8 +59,6 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0, totalCoupons: 0,
totalReceived: 0, totalReceived: 0,
totalReturnPercent: null, totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB', currency: 'RUB',
}); });
}); });
@ -102,17 +66,17 @@ describe('BrokerAnalyticsService', () => {
it('aggregates deposit types correctly', async () => { it('aggregates deposit types correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([ vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeItem('OPERATION_TYPE_INPUT', 1000), makeOp('OPERATION_TYPE_INPUT', 1000),
makeItem('OPERATION_TYPE_INPUT_SWIFT', 500), makeOp('OPERATION_TYPE_INPUT_SWIFT', 500),
makeItem('OPERATION_TYPE_INP_MULTI', 200), makeOp('OPERATION_TYPE_INP_MULTI', 200),
makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300), makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100), makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeItem('OPERATION_TYPE_TRANS_BS_BS', 50), makeOp('OPERATION_TYPE_TRANS_BS_BS', 50),
makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150), makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150),
]); ]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(2300); expect(result.data.totalDeposits).toBe(2300);
@ -123,15 +87,15 @@ describe('BrokerAnalyticsService', () => {
it('aggregates withdrawal types with absolute value', async () => { it('aggregates withdrawal types with absolute value', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([ vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeItem('OPERATION_TYPE_OUTPUT', -500), makeOp('OPERATION_TYPE_OUTPUT', -500),
makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200), makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100), makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeItem('OPERATION_TYPE_OUT_MULTI', -50), makeOp('OPERATION_TYPE_OUT_MULTI', -50),
makeItem('OPERATION_TYPE_INPUT', 1000), makeOp('OPERATION_TYPE_INPUT', 1000),
]); ]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000); expect(result.data.totalDeposits).toBe(1000);
@ -142,14 +106,14 @@ describe('BrokerAnalyticsService', () => {
it('aggregates dividend and coupon types', async () => { it('aggregates dividend and coupon types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([ vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeItem('OPERATION_TYPE_DIVIDEND', 300), makeOp('OPERATION_TYPE_DIVIDEND', 300),
makeItem('OPERATION_TYPE_DIV_EXT', 150), makeOp('OPERATION_TYPE_DIV_EXT', 150),
makeItem('OPERATION_TYPE_COUPON', 75), makeOp('OPERATION_TYPE_COUPON', 75),
makeItem('OPERATION_TYPE_COUPON', 25), makeOp('OPERATION_TYPE_COUPON', 25),
]); ]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDividends).toBe(450); expect(result.data.totalDividends).toBe(450);
@ -157,64 +121,82 @@ describe('BrokerAnalyticsService', () => {
expect(result.data.totalReceived).toBe(550); expect(result.data.totalReceived).toBe(550);
}); });
it('uses expectedYield from portfolio for totalReturnPercent', async () => { it('calculates totalReturnPercent correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks( vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
[ makeOp('OPERATION_TYPE_INPUT', 10000),
makeItem('OPERATION_TYPE_INPUT', 10000), makeOp('OPERATION_TYPE_DIVIDEND', 500),
makeItem('OPERATION_TYPE_DIVIDEND', 500), makeOp('OPERATION_TYPE_COUPON', 200),
makeItem('OPERATION_TYPE_COUPON', 200), ]);
],
mockPortfolio({ units: 7, nano: 0 }),
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(10000);
expect(result.data.totalReceived).toBe(700);
expect(result.data.totalReturnPercent).toBe(7); expect(result.data.totalReturnPercent).toBe(7);
}); });
it('returns null totalReturnPercent when portfolio has no expectedYield', async () => { it('returns null totalReturnPercent when netInvested <= 0', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]); vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeOp('OPERATION_TYPE_OUTPUT', -500),
makeOp('OPERATION_TYPE_DIVIDEND', 100),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(-500);
expect(result.data.totalReturnPercent).toBeNull(); expect(result.data.totalReturnPercent).toBeNull();
}); });
it('aggregates fee and tax categories from operations', async () => { it('handles malformed payment JSON gracefully', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([ vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeItem('OPERATION_TYPE_SERVICE_FEE', -100), { type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' },
makeItem('OPERATION_TYPE_BROKER_FEE', -50), { type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' },
makeItem('OPERATION_TYPE_TAX', -200), ] as any);
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000); expect(result.data.totalDeposits).toBe(500);
expect(result.data.totalFees).toBe(150); });
expect(result.data.totalTaxesPaid).toBe(230);
expect(result.data.netInvested).toBe(1000); it('ignores non-executed and non-null state operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
const service = new BrokerAnalyticsService(prisma, accounts, cache);
await service.getAnalytics('acc-1');
expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({
where: {
accountId: 'acc-1',
type: { in: expect.any(Array) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
},
select: { type: true, payment: true },
});
}); });
it('rounds all monetary values to 2 decimal places', async () => { it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
setupMocks([ vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
makeItem('OPERATION_TYPE_INPUT', 100.336), makeOp('OPERATION_TYPE_INPUT', 100.336),
makeItem('OPERATION_TYPE_DIVIDEND', 50.789), makeOp('OPERATION_TYPE_DIVIDEND', 50.789),
]); ]);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(100.34); expect(result.data.totalDeposits).toBe(100.34);
@ -234,47 +216,17 @@ describe('BrokerAnalyticsService', () => {
totalCoupons: 0, totalCoupons: 0,
totalReceived: 0, totalReceived: 0,
totalReturnPercent: null, totalReturnPercent: null,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB', currency: 'RUB',
}, },
fromCache: true, fromCache: true,
cachedAt: '2026-06-24T10:00:00.000Z', cachedAt: '2026-06-24T10:00:00.000Z',
}); });
const service = new BrokerAnalyticsService(accounts, tbankClient, cache); const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000); expect(result.data.netInvested).toBe(1000);
expect(result.fromCache).toBe(true); expect(result.meta.fromCache).toBe(true);
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z'); expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z');
});
it('paginates through multiple pages of operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
let callCount = 0;
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
if (label.includes('GetOperationsByCursor')) {
callCount++;
if (callCount === 1) {
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
}
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
}
throw new Error(`Unexpected call: ${label}`);
},
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalDividends).toBe(500);
expect(result.data.totalReceived).toBe(500);
expect(callCount).toBe(2);
}); });
}); });

View File

@ -1,14 +1,9 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerOperation } from '../types/broker.types';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
const DEPOSIT_TYPES = new Set([ const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT', 'OPERATION_TYPE_INPUT',
@ -31,21 +26,27 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']); const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const MAX_FETCH_PAGES = 50; const ANALYTICS_TYPES = new Set([
...DEPOSIT_TYPES,
...WITHDRAWAL_TYPES,
...DIVIDEND_TYPES,
...COUPON_TYPES,
]);
@Injectable() @Injectable()
export class BrokerAnalyticsService { export class BrokerAnalyticsService {
private readonly logger = new Logger(BrokerAnalyticsService.name);
constructor( constructor(
private readonly prisma: PrismaService,
private readonly accountsService: BrokerAccountsService, private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> { async getAnalytics(accountId: string): Promise<{
data: BrokerAnalyticsDto;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.analytics, TBANK_CACHE_KEYS.analytics,
@ -54,30 +55,41 @@ export class BrokerAnalyticsService {
'tbankAnalyticsTtl', 'tbankAnalyticsTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> { private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const [portfolio, allOperations] = await Promise.all([ const operations = await this.prisma.brokerOperation.findMany({
this.fetchPortfolio(accountId), where: {
this.fetchAllOperations(accountId), accountId,
]); type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
},
select: { type: true, payment: true },
});
let totalDeposits = 0; let totalDeposits = 0;
let totalWithdrawn = 0; let totalWithdrawn = 0;
let totalDividends = 0; let totalDividends = 0;
let totalCoupons = 0; let totalCoupons = 0;
let totalFees = 0;
let totalTaxesPaid = 0;
for (const op of allOperations) { for (const op of operations) {
const value = op.payment?.value ?? 0; let value = 0;
try {
const payment = JSON.parse(op.payment!);
value = payment.value ?? 0;
} catch {
continue;
}
if (op.category === 'fee') { if (DEPOSIT_TYPES.has(op.type)) {
totalFees += Math.abs(value);
} else if (op.category === 'tax') {
totalTaxesPaid += Math.abs(value);
} else if (DEPOSIT_TYPES.has(op.type)) {
totalDeposits += value; totalDeposits += value;
} else if (WITHDRAWAL_TYPES.has(op.type)) { } else if (WITHDRAWAL_TYPES.has(op.type)) {
totalWithdrawn += Math.abs(value); totalWithdrawn += Math.abs(value);
@ -90,11 +102,8 @@ export class BrokerAnalyticsService {
const netInvested = totalDeposits - totalWithdrawn; const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons; const totalReceived = totalDividends + totalCoupons;
const totalReturnPercent =
const portfolioYield = portfolio.expectedYield; netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null;
const expectedYieldPercent = portfolioYield
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
: null;
return { return {
totalDeposits: Math.round(totalDeposits * 100) / 100, totalDeposits: Math.round(totalDeposits * 100) / 100,
@ -103,66 +112,8 @@ export class BrokerAnalyticsService {
totalDividends: Math.round(totalDividends * 100) / 100, totalDividends: Math.round(totalDividends * 100) / 100,
totalCoupons: Math.round(totalCoupons * 100) / 100, totalCoupons: Math.round(totalCoupons * 100) / 100,
totalReceived: Math.round(totalReceived * 100) / 100, totalReceived: Math.round(totalReceived * 100) / 100,
totalFees: Math.round(totalFees * 100) / 100, totalReturnPercent,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent: expectedYieldPercent,
currency: 'RUB', currency: 'RUB',
}; };
} }
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
TBankPortfolioRequest,
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
);
return response;
}
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
const allOps: BrokerOperation[] = [];
let cursor: string | undefined;
let pageCount = 0;
do {
if (pageCount >= MAX_FETCH_PAGES) {
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
break;
}
const request: Record<string, unknown> = {
accountId,
state: 'OPERATION_STATE_EXECUTED',
limit: 1000,
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
if (cursor) request.cursor = cursor;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
const page = mapOperationsPage(accountId, response);
allOps.push(...page.items);
pageCount++;
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
} while (cursor);
return allOps;
}
} }

View File

@ -1,7 +1,6 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerEventsService } from './broker-events.service'; import { BrokerEventsService } from './broker-events.service';
import { BrokerOperationsService } from './broker-operations.service'; import { BrokerOperationsService } from './broker-operations.service';
@ -10,8 +9,10 @@ import { BrokerPortfolioService } from './broker-portfolio.service';
describe('BrokerEventsService', () => { describe('BrokerEventsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService; const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient; const moex = {
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient; getDividends: vi.fn(),
getBondPositionDataBatch: vi.fn(),
} as unknown as MoexClientService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
@ -41,10 +42,10 @@ describe('BrokerEventsService', () => {
it('throws 404 for missing account', async () => { it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
await expect( await expect(
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
).rejects.toThrow(EntityNotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it('returns empty events for account with no positions', async () => { it('returns empty events for account with no positions', async () => {
@ -57,11 +58,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' }); const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
expect(result.data.items).toEqual([]); expect(result.data.items).toEqual([]);
@ -86,7 +86,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]), instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ {
secid: 'SBER', secid: 'SBER',
isin: 'RU000A0JS', isin: 'RU000A0JS',
@ -112,11 +112,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -143,7 +142,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]), instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
}); });
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'SU26248RMFS4', secid: 'SU26248RMFS4',
couponValue: 35.4, couponValue: 35.4,
@ -167,11 +166,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(3); expect(result.data.items).toHaveLength(3);
@ -206,18 +204,17 @@ describe('BrokerEventsService', () => {
['uid-2', { name: 'Working' }], ['uid-2', { name: 'Working' }],
]), ]),
}); });
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error')); vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([ vi.mocked(moex.getDividends).mockResolvedValueOnce([
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' }, { secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -238,17 +235,16 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-1', { name: 'No Amount' }]]), instruments: new Map([['uid-1', { name: 'No Amount' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' }, { secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.items).toHaveLength(1); expect(result.data.items).toHaveLength(1);
@ -278,10 +274,10 @@ describe('BrokerEventsService', () => {
['uid-2', { name: 'OFZ' }], ['uid-2', { name: 'OFZ' }],
]), ]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]); ]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'BOND1', secid: 'BOND1',
couponValue: 50, couponValue: 50,
@ -305,11 +301,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
expect(result.data.summary.eventCount).toBe(3); expect(result.data.summary.eventCount).toBe(3);
@ -336,7 +331,7 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map([['uid-1', { name: 'Sber' }]]), instruments: new Map([['uid-1', { name: 'Sber' }]]),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
@ -344,11 +339,10 @@ describe('BrokerEventsService', () => {
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' }); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
expect(result.data.items).toHaveLength(2); expect(result.data.items).toHaveLength(2);
@ -376,10 +370,10 @@ describe('BrokerEventsService', () => {
], ],
instruments: new Map(), instruments: new Map(),
}); });
vi.mocked(moexDividends.getDividends).mockResolvedValue([ vi.mocked(moex.getDividends).mockResolvedValue([
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
]); ]);
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
{ {
secid: 'BOND1', secid: 'BOND1',
couponValue: 50, couponValue: 50,
@ -402,11 +396,10 @@ describe('BrokerEventsService', () => {
]); ]);
vi.mocked(operations.getOperations).mockResolvedValue({ vi.mocked(operations.getOperations).mockResolvedValue({
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' }, data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { const result = await service.getEvents('acc-1', {
from: '2026-06-20', from: '2026-06-20',
to: '2026-07-10', to: '2026-07-10',
@ -462,11 +455,10 @@ describe('BrokerEventsService', () => {
hasNext: false, hasNext: false,
asOf: '2026-06-19T00:00:00.000Z', asOf: '2026-06-19T00:00:00.000Z',
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
const result = await service.getEvents('acc-1', { const result = await service.getEvents('acc-1', {
from: '2026-06-15', from: '2026-06-15',
to: '2026-06-20', to: '2026-06-20',

View File

@ -1,9 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client'; import { MoexClientService } from '../../moex-client/moex-client.service';
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { mapQuotationToNumber } from '../mappers/money.mapper'; import { mapQuotationToNumber } from '../mappers/money.mapper';
import type { import type {
@ -47,8 +44,7 @@ export class BrokerEventsService {
constructor( constructor(
private readonly accountsService: BrokerAccountsService, private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService, private readonly portfolioService: BrokerPortfolioService,
private readonly moexMarketData: MoexMarketDataClient, private readonly moexClient: MoexClientService,
private readonly moexDividends: MoexDividendsClient,
private readonly operationsService: BrokerOperationsService, private readonly operationsService: BrokerOperationsService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
@ -56,9 +52,12 @@ export class BrokerEventsService {
async getEvents( async getEvents(
accountId: string, accountId: string,
query: BrokerEventsQuery, query: BrokerEventsQuery,
): Promise<ApiEnvelopePayload<BrokerEventsData>> { ): Promise<{
data: BrokerEventsData;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const eventTypes = this.parseEventTypes(query.types); const eventTypes = this.parseEventTypes(query.types);
const eventTypeKey = Array.from(eventTypes).join(','); const eventTypeKey = Array.from(eventTypes).join(',');
@ -69,7 +68,10 @@ export class BrokerEventsService {
'tbankPortfolioTtl', 'tbankPortfolioTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
private async buildEvents( private async buildEvents(
@ -141,7 +143,7 @@ export class BrokerEventsService {
let dividends: { registryCloseDate: string; value: number; currencyId: string }[]; let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
try { try {
dividends = await this.moexDividends.getDividends(ticker); dividends = await this.moexClient.getDividends(ticker);
} catch { } catch {
return []; return [];
} }
@ -201,7 +203,7 @@ export class BrokerEventsService {
faceValue: number; faceValue: number;
}[]; }[];
try { try {
bondData = await this.moexMarketData.getBondPositionDataBatch(secids); bondData = await this.moexClient.getBondPositionDataBatch(secids);
} catch { } catch {
return []; return [];
} }

View File

@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
} }
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> { private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getInstrumentsClient(); const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
{ idType: string; id: string }, { idType: string; id: string },
TBankInstrumentResponse TBankInstrumentResponse

View File

@ -48,13 +48,11 @@ describe('BrokerOperationSyncService', () => {
}, },
], ],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce({
data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] }, data: { accountId: 'acc-1', hasNext: false, nextCursor: null, asOf: 'now', items: [] },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);
@ -117,8 +115,7 @@ describe('BrokerOperationSyncService', () => {
}, },
], ],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);
@ -145,8 +142,7 @@ describe('BrokerOperationSyncService', () => {
asOf: '2026-06-16T00:00:00.000Z', asOf: '2026-06-16T00:00:00.000Z',
items: [], items: [],
}, },
fromCache: false, meta: { fromCache: false, cachedAt: null },
cachedAt: null,
}); });
const service = new BrokerOperationSyncService(operations, prisma); const service = new BrokerOperationSyncService(operations, prisma);

View File

@ -1,12 +1,12 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerOperationsService } from './broker-operations.service'; import { BrokerOperationsService } from './broker-operations.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => { describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => { beforeEach(() => {
@ -17,7 +17,7 @@ describe('BrokerOperationsService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerOperationsService(accounts, client, cache); const service = new BrokerOperationsService(accounts, client, cache);
await expect(service.getOperations('missing', {})).rejects.toThrow(EntityNotFoundException); await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException);
}); });
it('builds cursor request and maps operation page', async () => { it('builds cursor request and maps operation page', async () => {
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
cachedAt: null, cachedAt: null,
}), }),
); );
vi.mocked(client.getOperationsClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any); vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({ vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false, hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }], items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],

View File

@ -1,11 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto';
import { mapOperationsPage } from '../mappers/operation.mapper'; import { mapOperationsPage } from '../mappers/operation.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerOperationsPage } from '../types/broker.types';
import type { BrokerOperation, BrokerOperationsPage } from '../types/broker.types';
import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
@ -21,9 +19,12 @@ export class BrokerOperationsService {
async getOperations( async getOperations(
accountId: string, accountId: string,
query: BrokerOperationQueryDto, query: BrokerOperationQueryDto,
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> { ): Promise<{
data: BrokerOperationsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const request = this.buildRequest(accountId, query); const request = this.buildRequest(accountId, query);
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
@ -33,20 +34,10 @@ export class BrokerOperationsService {
'tbankOperationsTtl', 'tbankOperationsTtl',
); );
if (query.categories) { return {
const allowedCategories = query.categories data: result.data,
.split(',') meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
.map((c) => c.trim() as BrokerOperation['category']) };
.filter((c) => ['trade', 'income', 'tax', 'fee', 'transfer', 'other'].includes(c));
if (allowedCategories.length > 0) {
result.data.items = result.data.items.filter((item) =>
allowedCategories.includes(item.category),
);
}
}
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
} }
private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> { private buildRequest(accountId: string, query: BrokerOperationQueryDto): Record<string, unknown> {
@ -82,7 +73,7 @@ export class BrokerOperationsService {
accountId: string, accountId: string,
request: Record<string, unknown>, request: Record<string, unknown>,
): Promise<BrokerOperationsPage> { ): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const response = await this.tbankClient.callUnary< const response = await this.tbankClient.callUnary<
Record<string, unknown>, Record<string, unknown>,
TBankOperationsByCursorResponse TBankOperationsByCursorResponse

View File

@ -1,72 +0,0 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import type { BrokerPortfolioHistoryDataDto, BrokerPortfolioHistoryPointDto } from '../dto/broker-portfolio-history-response.dto';
import type { BrokerMoney } from '../types/broker.types';
const RUSSIAN_MONTHS = [
'Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн',
'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек',
];
@Injectable()
export class BrokerPortfolioHistoryService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly portfolioService: BrokerPortfolioService,
private readonly cacheService: CacheService,
) {}
async getHistory(
accountId: string,
months: number,
): Promise<ApiEnvelopePayload<BrokerPortfolioHistoryDataDto>> {
const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio,
[accountId, 'history', String(months)],
() => this.computeHistory(accountId, months),
'tbankPortfolioTtl',
);
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt);
}
private async computeHistory(
accountId: string,
months: number,
): Promise<BrokerPortfolioHistoryDataDto> {
const portfolioEnvelope = await this.portfolioService.getPortfolio(accountId);
const currentValue = portfolioEnvelope.data.totals.portfolio;
const defaultMoney: BrokerMoney = currentValue ?? {
currency: 'RUB',
units: '0',
nano: 0,
value: 0,
};
const now = new Date();
const points: BrokerPortfolioHistoryPointDto[] = [];
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = RUSSIAN_MONTHS[d.getMonth()];
points.push({ month, label, value: { ...defaultMoney } });
}
return {
accountId,
points,
asOf: new Date().toISOString(),
};
}
}

View File

@ -1,5 +1,5 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service'; import { BrokerPortfolioService } from './broker-portfolio.service';
@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => { describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService; const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService; const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => { beforeEach(() => {
@ -19,7 +19,7 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
}); });
it('fetches portfolio through cache without positions', async () => { it('fetches portfolio through cache without positions', async () => {
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
cachedAt: null, cachedAt: null,
}), }),
); );
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
getPositions: vi.fn(), getPositions: vi.fn(),
} as any); } as any);
@ -98,13 +98,13 @@ describe('BrokerPortfolioService', () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache); const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException);
}); });
it('returns first page of positions', async () => { it('returns first page of positions', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
it('paginates using cursor', async () => { it('paginates using cursor', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
it('returns last page with hasNext=false', async () => { it('returns last page with hasNext=false', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => { it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
it('filters by instrument type and caches with type in key', async () => { it('filters by instrument type and caches with type in key', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
it('returns empty items when type filter matches nothing', async () => { it('returns empty items when type filter matches nothing', async () => {
mockAccount(); mockAccount();
mockCache(); mockCache();
vi.mocked(client.getOperationsClient).mockReturnValue({ vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(), getPortfolio: vi.fn(),
} as any); } as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({ vi.mocked(client.callUnary).mockResolvedValueOnce({

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types';
import type { import type {
TBankInstrument, TBankInstrument,
@ -13,7 +12,6 @@ import type {
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service'; import { TBankClientService } from './tbank-client.service';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
@Injectable() @Injectable()
export class BrokerPortfolioService { export class BrokerPortfolioService {
@ -24,9 +22,12 @@ export class BrokerPortfolioService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> { async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.portfolio, TBANK_CACHE_KEYS.portfolio,
@ -42,7 +43,10 @@ export class BrokerPortfolioService {
'tbankPortfolioTtl', 'tbankPortfolioTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
async getPositions( async getPositions(
@ -50,9 +54,12 @@ export class BrokerPortfolioService {
cursor?: string, cursor?: string,
limit = 10, limit = 10,
type?: string, type?: string,
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> { ): Promise<{
data: BrokerPositionsPage;
meta: { fromCache: boolean; cachedAt: string | null };
}> {
const account = await this.accountsService.findById(accountId); const account = await this.accountsService.findById(accountId);
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); if (!account) throw new NotFoundException('Broker account not found');
const result = await this.cacheService.getOrFetch( const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.positions, TBANK_CACHE_KEYS.positions,
@ -80,7 +87,10 @@ export class BrokerPortfolioService {
'tbankPositionsTtl', 'tbankPositionsTtl',
); );
return new ApiEnvelopePayload(result.data, result.fromCache, result.cachedAt); return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
} }
private async buildInstrumentMap( private async buildInstrumentMap(
@ -120,7 +130,7 @@ export class BrokerPortfolioService {
'tbank:raw-portfolio', 'tbank:raw-portfolio',
[accountId], [accountId],
async () => { async () => {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
return this.tbankClient.callUnary< return this.tbankClient.callUnary<
{ accountId: string; currency: string }, { accountId: string; currency: string },
TBankPortfolioResponse TBankPortfolioResponse
@ -139,7 +149,7 @@ export class BrokerPortfolioService {
} }
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> { private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
const operationsClient = this.tbankClient.getOperationsClient(); const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>( return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions', 'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient), operationsClient.getPositions.bind(operationsClient),

View File

@ -1,5 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException } from '../../../common/exceptions/tbank-api.exception';
import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js';
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@ -44,16 +44,6 @@ describe('TBankClientService', () => {
expect(() => service.getServiceClient('UsersService')).not.toThrow(); expect(() => service.getServiceClient('UsersService')).not.toThrow();
}); });
it('exposes typed service-client facades for broker services', () => {
const service = new TBankClientService(config);
expect(service.getUsersClient()).toHaveProperty('getAccounts');
expect(service.getOperationsClient()).toHaveProperty('getPortfolio');
expect(service.getOperationsClient()).toHaveProperty('getPositions');
expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor');
expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy');
});
it('creates grpc SSL credentials with configured custom CA certificate', () => { it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem'); const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n'); writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
@ -95,7 +85,7 @@ describe('TBankClientService', () => {
}, },
{}, {},
), ),
).rejects.toThrow(TBankNotConfiguredException); ).rejects.toThrow(ServiceUnavailableException);
}); });
it('wraps grpc errors with status code and tracking id', async () => { it('wraps grpc errors with status code and tracking id', async () => {
@ -117,7 +107,9 @@ describe('TBankClientService', () => {
{}, {},
), ),
).rejects.toMatchObject({ ).rejects.toMatchObject({
response: expect.stringContaining('T-Bank upstream error'), response: expect.objectContaining({
message: expect.stringContaining('T-Bank upstream error'),
}),
}); });
}); });

View File

@ -1,13 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'; import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
import type {
TBankAccountsResponse,
TBankInstrumentResponse,
TBankOperationsByCursorResponse,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { import {
CallOptions, CallOptions,
ChannelCredentials, ChannelCredentials,
@ -33,25 +30,6 @@ type GrpcUnary<TRequest, TResponse> = (
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
type TBankAccountsRequest = { status: string };
export type TBankPortfolioRequest = { accountId: string; currency: string };
type TBankPositionsRequest = { accountId: string };
type TBankInstrumentRequest = { idType: string; id: string };
export type TBankUsersClient = Client & {
getAccounts: GrpcUnary<TBankAccountsRequest, TBankAccountsResponse>;
};
export type TBankOperationsClient = Client & {
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
};
export type TBankInstrumentsClient = Client & {
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
};
type QueueName = 'operations' | 'instruments' | 'users'; type QueueName = 'operations' | 'instruments' | 'users';
@Injectable() @Injectable()
@ -101,7 +79,7 @@ export class TBankClientService {
createMetadata(): Metadata { createMetadata(): Metadata {
const token = this.configService.get<string>('app.tbank.token', ''); const token = this.configService.get<string>('app.tbank.token', '');
if (!token) { if (!token) {
throw new TBankNotConfiguredException(); throw new ServiceUnavailableException('T-Bank integration is not configured');
} }
const metadata = new Metadata(); const metadata = new Metadata();
@ -146,18 +124,6 @@ export class TBankClientService {
return client; return client;
} }
getUsersClient(): TBankUsersClient {
return this.getServiceClient('UsersService') as TBankUsersClient;
}
getOperationsClient(): TBankOperationsClient {
return this.getServiceClient('OperationsService') as TBankOperationsClient;
}
getInstrumentsClient(): TBankInstrumentsClient {
return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient;
}
async callUnary<TRequest, TResponse>( async callUnary<TRequest, TResponse>(
label: string, label: string,
method: GrpcUnary<TRequest, TResponse>, method: GrpcUnary<TRequest, TResponse>,
@ -203,7 +169,7 @@ export class TBankClientService {
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`,
); );
throw new TBankApiException('T-Bank CA certificate is not readable'); throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
} }
} }
@ -225,9 +191,10 @@ export class TBankClientService {
}), }),
); );
const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`] return new BadGatewayException({
.filter(Boolean) message: publicMessage,
.join('; '); trackingId: trackingId ? String(trackingId) : null,
return new TBankApiException(detail); retryAfter: retryAfter ? String(retryAfter) : null,
});
} }
} }

View File

@ -1,12 +1,11 @@
import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { ApiResponse } from '../../common/dto/api-response.dto';
import { TBankController } from './tbank.controller'; import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
describe('TBankController', () => { describe('TBankController', () => {
@ -16,7 +15,6 @@ describe('TBankController', () => {
const events = { getEvents: vi.fn() } as unknown as BrokerEventsService; const events = { getEvents: vi.fn() } as unknown as BrokerEventsService;
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService; const sync = { syncAccount: vi.fn() } as unknown as BrokerOperationSyncService;
const portfolioHistory = { getHistory: vi.fn() } as unknown as BrokerPortfolioHistoryService;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -27,36 +25,32 @@ describe('TBankController', () => {
}); });
it('returns accounts in a single API envelope', async () => { it('returns accounts in a single API envelope', async () => {
vi.mocked(accounts.findAll).mockResolvedValueOnce( vi.mocked(accounts.findAll).mockResolvedValueOnce({
new ApiEnvelopePayload( data: [
[ {
{ id: 'acc-1',
id: 'acc-1', type: 'brokerage',
type: 'brokerage', name: 'Broker',
name: 'Broker', status: 'ACCOUNT_STATUS_OPEN',
status: 'ACCOUNT_STATUS_OPEN', openedAt: null,
openedAt: null, accessLevel: null,
accessLevel: null, },
}, ],
], meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
true, });
'2026-06-17T00:00:00.000Z',
),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.getAccounts(); const response = await controller.getAccounts();
expect(response).toBeInstanceOf(ApiEnvelopePayload); expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toHaveLength(1); expect(response.data).toHaveLength(1);
expect(response.fromCache).toBe(true); expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' });
expect(response.cachedAt).toBe('2026-06-17T00:00:00.000Z');
}); });
it('exposes a sync trigger for durable operation history', async () => { it('exposes a sync trigger for durable operation history', async () => {
vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.syncOperations('acc-1', { const response = await controller.syncOperations('acc-1', {
from: '2026-06-01T00:00:00.000Z', from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z',
@ -66,7 +60,7 @@ describe('TBankController', () => {
from: '2026-06-01T00:00:00.000Z', from: '2026-06-01T00:00:00.000Z',
to: '2026-06-17T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z',
}); });
expect(response).toEqual({ upserted: 2 }); expect(response.data).toEqual({ upserted: 2 });
}); });
it('exposes analytics endpoint through controller', async () => { it('exposes analytics endpoint through controller', async () => {
@ -78,83 +72,20 @@ describe('TBankController', () => {
totalCoupons: 50, totalCoupons: 50,
totalReceived: 200, totalReceived: 200,
totalReturnPercent: 25, totalReturnPercent: 25,
totalFees: 0,
totalTaxesPaid: 0,
currency: 'RUB', currency: 'RUB',
}; };
vi.mocked(analytics.getAnalytics).mockResolvedValueOnce( vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({
new ApiEnvelopePayload(analyticsData, false, null), data: analyticsData,
); meta: { fromCache: false, cachedAt: null },
});
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const response = await controller.getAnalytics('acc-1'); const response = await controller.getAnalytics('acc-1');
expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1'); expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1');
expect(response).toBeInstanceOf(ApiEnvelopePayload); expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(analyticsData); expect(response.data).toEqual(analyticsData);
expect(response.fromCache).toBe(false); expect(response.meta).toEqual({ fromCache: false, cachedAt: null });
expect(response.cachedAt).toBeNull();
});
it('returns portfolio history with correct shape and points', async () => {
const historyData = {
accountId: 'acc-1',
points: [
{ month: '2026-01', label: 'Янв', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
{ month: '2026-02', label: 'Фев', value: { currency: 'RUB', units: '100000', nano: 0, value: 100000 } },
],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, '2026-06-22T00:00:00.000Z'),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1', 6);
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response).toBeInstanceOf(ApiEnvelopePayload);
expect(response.data).toEqual(historyData);
expect(response.data.points).toHaveLength(2);
expect(response.data.points[0].month).toBe('2026-01');
expect(response.data.points[0].label).toBe('Янв');
});
it('passes categories filter query to operations service', async () => {
const pageData = {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(operations.getOperations).mockResolvedValueOnce(
new ApiEnvelopePayload(pageData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const query = { from: '2026-06-01', to: '2026-06-30', categories: 'fee,tax' };
const response = await controller.getOperations('acc-1', query);
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', query);
expect(response.data.accountId).toBe('acc-1');
});
it('uses default months parameter when not provided', async () => {
const historyData = {
accountId: 'acc-1',
points: [],
asOf: '2026-06-22T00:00:00.000Z',
};
vi.mocked(portfolioHistory.getHistory).mockResolvedValueOnce(
new ApiEnvelopePayload(historyData, false, null),
);
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory);
const response = await controller.getPortfolioHistory('acc-1');
expect(portfolioHistory.getHistory).toHaveBeenCalledWith('acc-1', 6);
expect(response.data.points).toHaveLength(0);
}); });
it('forwards events query and wraps response', async () => { it('forwards events query and wraps response', async () => {
@ -175,16 +106,17 @@ describe('TBankController', () => {
}, },
asOf: '2026-06-22T00:00:00.000Z', asOf: '2026-06-22T00:00:00.000Z',
}; };
vi.mocked(events.getEvents).mockResolvedValueOnce( vi.mocked(events.getEvents).mockResolvedValueOnce({
new ApiEnvelopePayload(eventsData, false, '2026-06-22T00:00:00.000Z'), data: eventsData,
); meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' },
});
const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics, portfolioHistory); const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics);
const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' }; const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' };
const response = await controller.getEvents('acc-1', query); const response = await controller.getEvents('acc-1', query);
expect(events.getEvents).toHaveBeenCalledWith('acc-1', query); expect(events.getEvents).toHaveBeenCalledWith('acc-1', query);
expect(response).toBeInstanceOf(ApiEnvelopePayload); expect(response).toBeInstanceOf(ApiResponse);
expect(response.data).toEqual(eventsData); expect(response.data).toEqual(eventsData);
}); });
}); });

View File

@ -1,5 +1,6 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common'; import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiResponse } from '../../common/dto/api-response.dto';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { import {
BrokerAccountsEnvelopeDto, BrokerAccountsEnvelopeDto,
@ -8,7 +9,6 @@ import {
BrokerOperationSyncEnvelopeDto, BrokerOperationSyncEnvelopeDto,
BrokerOperationsEnvelopeDto, BrokerOperationsEnvelopeDto,
BrokerPortfolioEnvelopeDto, BrokerPortfolioEnvelopeDto,
BrokerPortfolioHistoryEnvelopeDto,
BrokerPositionsEnvelopeDto, BrokerPositionsEnvelopeDto,
} from './dto/broker-envelope.dto'; } from './dto/broker-envelope.dto';
import { BrokerEventsQueryDto } from './dto/broker-events-query.dto'; import { BrokerEventsQueryDto } from './dto/broker-events-query.dto';
@ -16,11 +16,10 @@ import { BrokerPositionQueryDto } from './dto/broker-position-query.dto';
import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto'; import { BrokerOperationQueryDto } from './dto/broker-operation-query.dto';
import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto'; import { BrokerOperationSyncQueryDto } from './dto/broker-operation-sync-query.dto';
import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service'; import { BrokerAnalyticsService } from './services/broker-analytics.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker') @ApiTags('Broker')
@ -35,21 +34,22 @@ export class TBankController {
private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationsService: BrokerOperationsService,
private readonly brokerOperationSyncService: BrokerOperationSyncService, private readonly brokerOperationSyncService: BrokerOperationSyncService,
private readonly brokerAnalyticsService: BrokerAnalyticsService, private readonly brokerAnalyticsService: BrokerAnalyticsService,
private readonly brokerPortfolioHistoryService: BrokerPortfolioHistoryService,
) {} ) {}
@Get('accounts') @Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' }) @ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ApiOkResponse({ type: BrokerAccountsEnvelopeDto }) @ApiOkResponse({ type: BrokerAccountsEnvelopeDto })
async getAccounts() { async getAccounts() {
return this.brokerAccountsService.findAll(); const result = await this.brokerAccountsService.findAll();
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/portfolio') @Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' }) @ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto }) @ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) { async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId); const result = await this.brokerPortfolioService.getPortfolio(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/positions') @Get('accounts/:accountId/positions')
@ -59,12 +59,13 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerPositionQueryDto, @Query() query: BrokerPositionQueryDto,
) { ) {
return this.brokerPortfolioService.getPositions( const result = await this.brokerPortfolioService.getPositions(
accountId, accountId,
query.cursor, query.cursor,
query.limit, query.limit,
query.type, query.type,
); );
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/operations') @Get('accounts/:accountId/operations')
@ -74,31 +75,24 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerOperationQueryDto, @Query() query: BrokerOperationQueryDto,
) { ) {
return this.brokerOperationsService.getOperations(accountId, query); const result = await this.brokerOperationsService.getOperations(accountId, query);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Get('accounts/:accountId/events') @Get('accounts/:accountId/events')
@ApiOperation({ summary: 'Get broker account calendar events and cashflow' }) @ApiOperation({ summary: 'Get broker account calendar events and cashflow' })
@ApiOkResponse({ type: BrokerEventsEnvelopeDto }) @ApiOkResponse({ type: BrokerEventsEnvelopeDto })
async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) { async getEvents(@Param('accountId') accountId: string, @Query() query: BrokerEventsQueryDto) {
return this.brokerEventsService.getEvents(accountId, query); const result = await this.brokerEventsService.getEvents(accountId, query);
} return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
@Get('accounts/:accountId/portfolio/history')
@ApiOperation({ summary: 'Get portfolio value history for last N months' })
@ApiOkResponse({ type: BrokerPortfolioHistoryEnvelopeDto })
async getPortfolioHistory(
@Param('accountId') accountId: string,
@Query('months') months?: number,
) {
return this.brokerPortfolioHistoryService.getHistory(accountId, months ?? 6);
} }
@Get('accounts/:accountId/analytics') @Get('accounts/:accountId/analytics')
@ApiOperation({ summary: 'Get broker account profitability analytics' }) @ApiOperation({ summary: 'Get broker account profitability analytics' })
@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto }) @ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto })
async getAnalytics(@Param('accountId') accountId: string) { async getAnalytics(@Param('accountId') accountId: string) {
return this.brokerAnalyticsService.getAnalytics(accountId); const result = await this.brokerAnalyticsService.getAnalytics(accountId);
return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt);
} }
@Post('accounts/:accountId/operations/sync') @Post('accounts/:accountId/operations/sync')
@ -108,6 +102,7 @@ export class TBankController {
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: BrokerOperationSyncQueryDto, @Query() query: BrokerOperationSyncQueryDto,
) { ) {
return this.brokerOperationSyncService.syncAccount(accountId, query); const result = await this.brokerOperationSyncService.syncAccount(accountId, query);
return new ApiResponse(result);
} }
} }

View File

@ -7,7 +7,6 @@ import { BrokerInstrumentsService } from './services/broker-instruments.service'
import { BrokerEventsService } from './services/broker-events.service'; import { BrokerEventsService } from './services/broker-events.service';
import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service';
import { BrokerOperationsService } from './services/broker-operations.service'; import { BrokerOperationsService } from './services/broker-operations.service';
import { BrokerPortfolioHistoryService } from './services/broker-portfolio-history.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service'; import { TBankClientService } from './services/tbank-client.service';
@ -23,7 +22,6 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerOperationsService, BrokerOperationsService,
BrokerOperationSyncService, BrokerOperationSyncService,
BrokerAnalyticsService, BrokerAnalyticsService,
BrokerPortfolioHistoryService,
], ],
exports: [ exports: [
TBankClientService, TBankClientService,

View File

@ -1,27 +0,0 @@
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
describe('checked-in OpenAPI artifacts', () => {
const rootDir = resolve(process.cwd(), '../..');
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/shared/api/types.ts'), 'utf8');
const requiredPaths = [
'/api/v1/auth/register',
'/api/v1/auth/login',
'/api/v1/auth/refresh',
'/api/v1/auth/logout',
'/api/v1/auth/me',
'/api/v1/securities/screener',
'/api/v1/portfolios',
'/api/v1/portfolios/{id}',
'/api/v1/portfolios/{id}/positions',
'/api/v1/portfolios/{id}/positions/{positionId}',
'/api/v1/portfolios/{id}/analytics',
];
it('frontend generated types include current protected domains', () => {
for (const path of requiredPaths) {
expect(frontendTypes).toContain(`'${path}'`);
}
});
});

View File

@ -1,63 +0,0 @@
# ADR-020: Разделение MoexClientService на доменные клиенты
**Дата:** 2026-06-25
**Статус:** Принято
**Автор:** AI Agent (codex/backend-architecture-improvements)
## Контекст
`MoexClientService` в `apps/backend/src/modules/moex-client/moex-client.service.ts` (423 строки, 11 публичных методов) со временем стал God Service:
- Нарушает SRP — содержит логику работы с акциями, облигациями, свечами, историей, дивидендами и поиском в одном классе
- Инфраструктура (rate limiter, circuit breaker) смешана с бизнес-логикой
- Все 11 методов используют один шаблон: `request()``extractTable()` → map, но каждый с разными endpoint-ами и типами
- Потребители (shares, bonds, candles, portfolio, securities, screener, tbank/events) получают весь сервис целиком, а не только нужную функциональность
- Тестирование затруднено: любой тест одного метода тянет весь сервис
## Рассмотренные варианты
### A. Полный сплит по доменам (выбран)
Выделить инфраструктурный слой (`MoexHttpClient`) и 5 доменных клиентов — по одному на группу MOEX-запросов.
### B. Минимальный сплит
Вынести только `MoexHttpClient` (request + extractTable + circuit breaker + rate limiter), оставить все методы в одном сервисе с делегированием через DI.
Отклонён: не решает проблему God Service — один сервис всё ещё содержит всю доменную логику.
### C. Оставить как есть
Отклонён: противоречит результатам аудита, 423-строчный сервис ухудшает поддерживаемость.
## Решение
Выбран вариант A — полный сплит по доменам:
- `MoexHttpClient` — инфраструктура (axios, PQueue, circuit breaker, `request()`, `extractTable()`)
- `MoexSecuritiesClient``searchSecurities()`, `getSecurityDescription()`
- `MoexMarketDataClient``getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()`
- `MoexCandlesClient``getCandles()`
- `MoexHistoryClient``getHistory()`, `getBondHistory()`
- `MoexDividendsClient``getDividends()`
Модуль теряет `@Global()` — каждый потребитель явно импортирует `MoexClientModule`.
## Последствия
### Положительные
- Чёткое разделение ответственности — каждый клиент отвечает за один домен MOEX API
- Возможность мокать только нужный клиент в тестах потребителей
- `MoexHttpClient` — внутренняя деталь, не экспортируется из модуля
- Явные зависимости через imports модулей вместо одного God Service
### Риски
- Миграция всех 7 потребителей в одном коммите (нельзя оставить половинчатое состояние)
- Каждый потребитель должен импортировать `MoexClientModule` — больше boilerplate
- Необходимость обновить все тесты потребителей (DI-инъекция меняется)
## Связанные документы
- ADR-003: Стратегия rate limiting (остаётся актуальной, инфраструктура переносится в MoexHttpClient)
- ADR-004: Feature modules (принцип явных зависимостей)
- `docs/features/backend-architecture-improvements/plan.md` (Iteration 6)
- `docs/features/backend-architecture-improvements/tasks.md` (Iteration 6)

View File

@ -92,17 +92,9 @@
**Response:** **Response:**
```json ```json
{ {
"data": { "status": "ok",
"status": "ok", "timestamp": "2026-06-13T12:00:00.000Z",
"timestamp": "2026-06-25T12:00:00.000Z", "uptime": 1234.56
"uptime": 1234.56,
"checks": [
{ "name": "database", "status": "ok" },
{ "name": "moex", "status": "ok" },
{ "name": "tbank", "status": "ok" }
]
},
"meta": { "fromCache": false, "cachedAt": null }
} }
``` ```
@ -411,5 +403,5 @@ codegen types.
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные | | `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам | | `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) | | `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
| `/api/v1/broker/accounts/:accountId/operations/sync` | POST | Принудительная синхронизация операций из T-Bank | | `/api/v1/broker/accounts/:accountId/operations/refresh` | POST | Принудительная синхронизация операций из T-Bank |
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) | | `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |

View File

@ -25,20 +25,20 @@ flowchart TB
PrismaService["PrismaService"] PrismaService["PrismaService"]
CacheService["CacheService"] CacheService["CacheService"]
MoexHttpClient["MoexHttpClient"] MoexClientService["MoexClientService"]
TBankClientService["TBankClientService"] TBankClientService["TBankClientService"]
PrismaModule --> PrismaService PrismaModule --> PrismaService
CacheModule --> CacheService CacheModule --> CacheService
MoexClientModule --> MoexHttpClient MoexClientModule --> MoexClientService
AuthModule --> PrismaService AuthModule --> PrismaService
PortfolioModule --> PrismaService PortfolioModule --> PrismaService
PortfolioModule --> CacheService PortfolioModule --> CacheService
PortfolioModule --> MoexHttpClient PortfolioModule --> MoexClientService
MarketModules --> CacheService MarketModules --> CacheService
MarketModules --> MoexHttpClient MarketModules --> MoexClientService
TBankModule --> CacheService TBankModule --> CacheService
TBankModule --> PrismaService TBankModule --> PrismaService
@ -51,7 +51,7 @@ flowchart TB
|---|---|---|---| |---|---|---|---|
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite | | `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager | | `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
| `MoexClientModule` | Нет | `modules/moex-client/` | HTTP- и domain-клиенты MOEX ISS | | `MoexClientModule` | Да | `modules/moex-client/` | HTTP-клиент MOEX ISS |
| `HealthModule` | Нет | `modules/health/` | Health check endpoint | | `HealthModule` | Нет | `modules/health/` | Health check endpoint |
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards | | `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов | | `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
@ -79,17 +79,17 @@ flowchart TB
### MoexClientModule ### MoexClientModule
Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты. Глобальный HTTP-клиент для MOEX ISS.
- `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing. - Rate limiter: p-queue (10 req/s по умолчанию, настраивается через `MOEX_RATE_LIMIT`)
- Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`. - Circuit breaker: открывается после 5 ошибок, сбрасывается через 30s
- Все ответы нормализуются из табличного формата MOEX в доменные типы. - Все ответы нормализуются из табличного формата MOEX в доменные типы
### HealthModule ### HealthModule
Проверка состояния сервиса. Проверка состояния сервиса.
- `GET /api/v1/health``{ data: { status, timestamp, uptime, checks }, meta }` - `GET /api/v1/health``{ status: 'ok', timestamp, uptime }`
### AuthModule ### AuthModule

View File

@ -2,21 +2,9 @@
## Обзор ## Обзор
MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under `MoexClientService` (`apps/backend/src/modules/moex-client/moex-client.service.ts`) — HTTP-клиент для MOEX ISS API.
`apps/backend/src/modules/moex-client/`:
- `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing. ## Rate limiting
- `MoexSecuritiesClient` — security search and descriptions.
- `MoexMarketDataClient` — share/bond market data and batch position enrichment.
- `MoexCandlesClient` — candle history.
- `MoexHistoryClient` — share and bond history.
- `MoexDividendsClient` — dividend calendar.
## `MoexHttpClient`
Базовый HTTP-клиент, используемый всеми domain-клиентами. Реализован в `moex-http-client.service.ts`.
### Rate limiting
Использует `p-queue`: Использует `p-queue`:
@ -29,7 +17,7 @@ this.queue = new PQueue({
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду. Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
### Circuit breaker ## Circuit breaker
Состояние: закрыт → открыт → полуоткрыт (через таймаут). Состояние: закрыт → открыт → полуоткрыт (через таймаут).
@ -42,7 +30,7 @@ private circuitErrorCount = 0;
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"` - В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается - Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
### Метод request ## Метод request
```typescript ```typescript
private async request<T>(path: string, params?: Record<string, string>): Promise<T> private async request<T>(path: string, params?: Record<string, string>): Promise<T>
@ -52,7 +40,7 @@ private async request<T>(path: string, params?: Record<string, string>): Promise
- Устанавливает `iss.meta=off` (отключает метаданные) - Устанавливает `iss.meta=off` (отключает метаданные)
- Таймаут: 10s - Таймаут: 10s
### Разбор response ## Разбор response
MOEX возвращает данные в табличном формате: MOEX возвращает данные в табличном формате:
@ -67,17 +55,19 @@ MOEX возвращает данные в табличном формате:
Метод `extractTable` преобразует это в массив объектов по колонкам. Метод `extractTable` преобразует это в массив объектов по колонкам.
## Domain clients ## Доступные методы MOEX
Каждый domain-клиент использует `MoexHttpClient` для HTTP и предоставляет свои методы: | Метод | MOEX path | Описание |
|---|---|---|
| Client | Методы | | `searchSecurities` | `/securities?q=` | Поиск инструментов |
|---|---| | `getSecurityDescription` | `/securities/{secid}` | Спецификация |
| `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` | | `getShareMarketData` | `/engines/stock/markets/shares/securities/{secid}` | Рыночные данные акции (board: TQBR) |
| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` | | `getBondData` | `/engines/stock/markets/bonds/securities/{secid}` | Данные облигации (board: TQCB) |
| `MoexCandlesClient` | `getCandles` | | `getBondMarketData` | `/engines/stock/markets/bonds/securities/{secid}` | Рыночные данные облигации |
| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` | | `getDividends` | `/securities/{secid}/dividends` | Дивиденды |
| `MoexDividendsClient` | `getDividends` | | `getCandles` | `/engines/{engine}/markets/{market}/securities/{secid}/candles` | Свечи |
| `getHistory` | `/engines/stock/markets/shares/securities/{secid}` | История акций |
| `getBondHistory` | `/engines/stock/markets/bonds/securities/{secid}` | История облигаций |
## MOEX ISS types ## MOEX ISS types

View File

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

View File

@ -24,7 +24,6 @@
"@moex-vibe/design-system": "*", "@moex-vibe/design-system": "*",
"@mui/icons-material": "^6.5.0", "@mui/icons-material": "^6.5.0",
"@mui/material": "^6.5.0", "@mui/material": "^6.5.0",
"@mui/x-date-pickers": "^7.29.4",
"@tanstack/react-query": "^5.20.0", "@tanstack/react-query": "^5.20.0",
"@tanstack/react-router": "^1.170.16", "@tanstack/react-router": "^1.170.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
@ -44,6 +43,7 @@
"@biomejs/biome": "^2.5.0", "@biomejs/biome": "^2.5.0",
"@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@tanstack/router-devtools": "^1.167.0", "@tanstack/router-devtools": "^1.167.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",

View File

@ -18,72 +18,65 @@ export function AppLayout() {
background: 'var(--color-surface)', background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0', borderBottom: '1px solid #e0e0e0',
padding: '12px 24px', padding: '12px 24px',
display: 'grid', display: 'flex',
gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center',
gap: 12, flexWrap: 'wrap',
gap: 24,
}} }}
> >
<div style={{ display: 'grid', gap: 12 }}> <Link
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}> to="/"
<Link style={{
to="/" fontSize: 20,
style={{ fontWeight: 700,
fontSize: 20, color: 'var(--color-text)',
fontWeight: 700, textDecoration: 'none',
color: 'var(--color-text)', }}
textDecoration: 'none', >
}} MoexVibe
> </Link>
MoexVibe <div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
</Link> <SearchBar />
<div style={{ flex: '1 1 280px', minWidth: 220, maxWidth: 420 }}>
<SearchBar />
</div>
</div>
<nav
style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}
aria-label="Основная навигация"
>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
</nav>
</div> </div>
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<Link
to="/broker"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Брокер
</Link>
<Link
to="/screener"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Скринер
</Link>
<div <div
style={{ style={{
marginLeft: 'auto',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'flex-end',
gap: 12, gap: 12,
flexWrap: 'wrap', flexWrap: 'wrap',
}} }}

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