codex/backend-architecture-improvements #47
@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } 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,6 +11,7 @@ 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({
|
||||||
@ -29,4 +30,8 @@ import configuration from './config/configuration';
|
|||||||
TBankModule,
|
TBankModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(RequestLoggingMiddleware).forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal file
8
apps/backend/src/common/exceptions/domain.exception.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal file
8
apps/backend/src/common/exceptions/moex-api.exception.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal file
14
apps/backend/src/common/exceptions/tbank-api.exception.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } 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>();
|
||||||
@ -25,6 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
message = exception.message;
|
||||||
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
|||||||
@ -29,6 +29,7 @@ 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),
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import 'reflect-metadata'
|
import 'reflect-metadata'
|
||||||
import { Test, type TestingModule } from '@nestjs/testing'
|
import { Test, type TestingModule } from '@nestjs/testing'
|
||||||
import type { INestApplication } from '@nestjs/common'
|
import type { INestApplication } from '@nestjs/common'
|
||||||
|
import { ConfigModule } from '@nestjs/config'
|
||||||
import { HealthModule } from './modules/health/health.module'
|
import { HealthModule } from './modules/health/health.module'
|
||||||
|
import { PrismaModule } from './modules/prisma/prisma.module'
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor'
|
||||||
|
import configuration from './config/configuration'
|
||||||
|
|
||||||
describe('API envelope contract', () => {
|
describe('API envelope contract', () => {
|
||||||
let app: INestApplication
|
let app: INestApplication
|
||||||
@ -10,7 +13,7 @@ describe('API envelope contract', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [HealthModule],
|
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
app = module.createNestApplication()
|
app = module.createNestApplication()
|
||||||
@ -29,20 +32,25 @@ describe('API envelope contract', () => {
|
|||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a single envelope from the public health endpoint', async () => {
|
it('returns a proper envelope with checks from the public health endpoint', async () => {
|
||||||
const response = await fetch(`${baseUrl}/api/v1/health`)
|
const response = await fetch(`${baseUrl}/api/v1/health`)
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
const body = (await response.json()) as {
|
const body = (await response.json()) as {
|
||||||
data: { status: string; timestamp: string; uptime: number }
|
data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> }
|
||||||
meta: { fromCache: boolean; cachedAt: string | null }
|
meta: { fromCache: boolean; cachedAt: string | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
status: 'ok',
|
status: expect.any(String),
|
||||||
timestamp: expect.any(String),
|
timestamp: expect.any(String),
|
||||||
uptime: expect.any(Number),
|
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: {
|
meta: {
|
||||||
fromCache: false,
|
fromCache: false,
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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 cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
@ -18,9 +17,6 @@ async function bootstrap() {
|
|||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
|
||||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
|
||||||
|
|
||||||
app.enableCors({ origin: true, credentials: true });
|
app.enableCors({ origin: true, credentials: true });
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
|
|||||||
@ -1,12 +1,5 @@
|
|||||||
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()
|
||||||
@ -39,22 +32,22 @@ export class AuthTokenResponseDto {
|
|||||||
@ApiProperty({ type: AuthTokenDataDto })
|
@ApiProperty({ type: AuthTokenDataDto })
|
||||||
data!: AuthTokenDataDto;
|
data!: AuthTokenDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthProfileResponseDto {
|
export class AuthProfileResponseDto {
|
||||||
@ApiProperty({ type: AuthUserDto })
|
@ApiProperty({ type: AuthUserDto })
|
||||||
data!: AuthUserDto;
|
data!: AuthUserDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthLogoutResponseDto {
|
export class AuthLogoutResponseDto {
|
||||||
@ApiProperty({ type: LogoutDataDto })
|
@ApiProperty({ type: LogoutDataDto })
|
||||||
data!: LogoutDataDto;
|
data!: LogoutDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: AuthResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: AuthResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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],
|
||||||
|
|||||||
@ -1,16 +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 { BondsService } from './bonds.service';
|
import { BondsService } from './bonds.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
|
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 moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexMarketData = {
|
||||||
getBondData: vi.fn(),
|
getBondData: vi.fn(),
|
||||||
getBondMarketData: vi.fn(),
|
getBondMarketData: vi.fn(),
|
||||||
};
|
};
|
||||||
@ -25,7 +26,8 @@ describe('BondsService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
BondsService,
|
BondsService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||||
|
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||||
{ provide: CacheService, useValue: cache },
|
{ provide: CacheService, useValue: cache },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@ -34,7 +36,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(moexClient.getBondData).mockResolvedValue({
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
boardid: 'TQCB',
|
boardid: 'TQCB',
|
||||||
shortName: 'ОФЗ 26238',
|
shortName: 'ОФЗ 26238',
|
||||||
@ -57,7 +59,7 @@ describe('BondsService', () => {
|
|||||||
bondSubType: 'fixed',
|
bondSubType: 'fixed',
|
||||||
listLevel: 1,
|
listLevel: 1,
|
||||||
});
|
});
|
||||||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
bid: 72.9,
|
bid: 72.9,
|
||||||
offer: 73.1,
|
offer: 73.1,
|
||||||
@ -92,8 +94,8 @@ describe('BondsService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
@ -135,11 +137,11 @@ describe('BondsService', () => {
|
|||||||
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when bond data is missing', async () => {
|
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||||
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
|
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||||
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
|
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.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 { 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 moexClient: MoexClientService,
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexHistory: MoexHistoryClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -18,18 +21,18 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'bond',
|
'bond',
|
||||||
[secid],
|
[secid],
|
||||||
() => this.moexClient.getBondData(secid),
|
() => this.moexMarketData.getBondData(secid),
|
||||||
'securityTtl',
|
'securityTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!bond) {
|
if (!bond) {
|
||||||
throw new NotFoundException(`Bond ${secid} not found`);
|
throw new EntityNotFoundException('Bond', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: mkt } = await this.cache.getOrFetch(
|
const { data: mkt } = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexClient.getBondMarketData(secid),
|
() => this.moexMarketData.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -84,12 +87,12 @@ export class BondsService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['bonds', secid],
|
['bonds', secid],
|
||||||
() => this.moexClient.getBondMarketData(secid),
|
() => this.moexMarketData.getBondMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mkt) {
|
if (!mkt) {
|
||||||
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
throw new EntityNotFoundException('MarketData', `bond ${secid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return new ApiEnvelopePayload(
|
||||||
@ -118,7 +121,7 @@ export class BondsService {
|
|||||||
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.moexClient.getBondHistory(secid, from, till),
|
() => this.moexHistory.getBondHistory(secid, from, till),
|
||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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],
|
||||||
|
|||||||
@ -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 { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||||
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 moexClient: Pick<MoexClientService, 'getCandles'>;
|
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexCandles = {
|
||||||
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: MoexClientService, useValue: moexClient },
|
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||||
{ 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(moexClient.getCandles).mockResolvedValue([
|
vi.mocked(moexCandles.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(moexClient.getCandles).toHaveBeenCalledWith(
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
'stock',
|
'stock',
|
||||||
'shares',
|
'shares',
|
||||||
'SBER',
|
'SBER',
|
||||||
@ -87,7 +87,7 @@ describe('CandlesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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(moexClient.getCandles).mockResolvedValue([]);
|
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||||
|
|
||||||
await service.getCandles(
|
await service.getCandles(
|
||||||
'bonds',
|
'bonds',
|
||||||
@ -103,7 +103,7 @@ describe('CandlesService', () => {
|
|||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||||
'stock',
|
'stock',
|
||||||
'bonds',
|
'bonds',
|
||||||
'SU26238RMFS5',
|
'SU26238RMFS5',
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||||
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';
|
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||||
@ -7,7 +7,7 @@ import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CandlesService {
|
export class CandlesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexCandles: MoexCandlesClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ 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.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
|
() => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till),
|
||||||
'candlesTtl',
|
'candlesTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } 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' })
|
||||||
@ -9,4 +20,7 @@ export class HealthResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 12345 })
|
@ApiProperty({ example: 12345 })
|
||||||
uptime!: number;
|
uptime!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [HealthCheckResultDto] })
|
||||||
|
checks!: HealthCheckResultDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,19 @@ 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 })
|
||||||
check() {
|
async check() {
|
||||||
return {
|
return this.healthService.check();
|
||||||
status: 'ok',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
uptime: process.uptime(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
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 {}
|
||||||
|
|||||||
52
apps/backend/src/modules/health/health.service.spec.ts
Normal file
52
apps/backend/src/modules/health/health.service.spec.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import configuration from '../../config/configuration';
|
||||||
|
|
||||||
|
describe('HealthService', () => {
|
||||||
|
let service: HealthService;
|
||||||
|
const prisma = { $queryRaw: vi.fn() } as any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: PrismaService, useValue: prisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<HealthService>(HealthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
72
apps/backend/src/modules/health/health.service.ts
Normal file
72
apps/backend/src/modules/health/health.service.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,26 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexClientService } from './moex-client.service';
|
import { MoexHttpClient } from './moex-http.client';
|
||||||
|
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: [MoexClientService],
|
providers: [
|
||||||
exports: [MoexClientService],
|
MoexHttpClient,
|
||||||
|
MoexSecuritiesClient,
|
||||||
|
MoexMarketDataClient,
|
||||||
|
MoexCandlesClient,
|
||||||
|
MoexHistoryClient,
|
||||||
|
MoexDividendsClient,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
MoexSecuritiesClient,
|
||||||
|
MoexMarketDataClient,
|
||||||
|
MoexCandlesClient,
|
||||||
|
MoexHistoryClient,
|
||||||
|
MoexDividendsClient,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class MoexClientModule {}
|
export class MoexClientModule {}
|
||||||
|
|||||||
@ -1,32 +1,35 @@
|
|||||||
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 { MoexClientService } from './moex-client.service';
|
import { MoexClientModule } from './moex-client.module';
|
||||||
|
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')(
|
||||||
'MoexClientService live MOEX integration',
|
'MoexClient live MOEX integration',
|
||||||
() => {
|
() => {
|
||||||
let service: MoexClientService;
|
let moexSecurities: MoexSecuritiesClient;
|
||||||
|
let moexMarketData: MoexMarketDataClient;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||||
providers: [MoexClientService],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<MoexClientService>(MoexClientService);
|
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||||
|
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||||
const results = await service.searchSecurities('SBER');
|
const results = await moexSecurities.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 service.getShareMarketData('SBER');
|
const data = await moexMarketData.getShareMarketData('SBER');
|
||||||
|
|
||||||
expect(data).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
expect(data!.secid).toBe('SBER');
|
expect(data!.secid).toBe('SBER');
|
||||||
|
|||||||
@ -1,186 +0,0 @@
|
|||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,423 +0,0 @@
|
|||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
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',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,53 +1,46 @@
|
|||||||
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: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioEnvelopeDto {
|
export class PortfolioEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioResponseDto })
|
@ApiProperty({ type: PortfolioResponseDto })
|
||||||
data!: PortfolioResponseDto;
|
data!: PortfolioResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioDetailEnvelopeDto {
|
export class PortfolioDetailEnvelopeDto {
|
||||||
@ApiProperty({ type: PortfolioDetailResponseDto })
|
@ApiProperty({ type: PortfolioDetailResponseDto })
|
||||||
data!: PortfolioDetailResponseDto;
|
data!: PortfolioDetailResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PositionEnvelopeDto {
|
export class PositionEnvelopeDto {
|
||||||
@ApiProperty({ type: PositionResponseDto })
|
@ApiProperty({ type: PositionResponseDto })
|
||||||
data!: PositionResponseDto;
|
data!: PositionResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsEnvelopeDto {
|
export class AnalyticsEnvelopeDto {
|
||||||
@ApiProperty({ type: AnalyticsResponseDto })
|
@ApiProperty({ type: AnalyticsResponseDto })
|
||||||
data!: AnalyticsResponseDto;
|
data!: AnalyticsResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: PortfolioResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,13 +13,13 @@ 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';
|
||||||
|
|
||||||
@ -27,14 +27,14 @@ const nullDataEnvelopeSchema = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
data: { type: 'null' },
|
data: { type: 'null' },
|
||||||
meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) },
|
meta: { $ref: getSchemaPath(ApiResponseMeta) },
|
||||||
},
|
},
|
||||||
required: ['data', 'meta'],
|
required: ['data', 'meta'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ApiTags('Portfolios')
|
@ApiTags('Portfolios')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiExtraModels(PortfolioResponseMetaDto)
|
@ApiExtraModels(ApiResponseMeta)
|
||||||
@Controller('portfolios')
|
@Controller('portfolios')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
constructor(private readonly portfolioService: PortfolioService) {}
|
constructor(private readonly portfolioService: PortfolioService) {}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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],
|
||||||
|
|||||||
@ -2,15 +2,18 @@ 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 { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
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 { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||||
|
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 moexClient: MoexClientService;
|
let moexMarketData: MoexMarketDataClient;
|
||||||
let module: TestingModule;
|
let module: TestingModule;
|
||||||
|
|
||||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@ -65,14 +68,20 @@ describe('PortfolioService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MoexClientService,
|
provide: MoexSecuritiesClient,
|
||||||
|
useValue: { getSecurityDescription: vi.fn() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MoexMarketDataClient,
|
||||||
useValue: {
|
useValue: {
|
||||||
getShareMarketDataBatch: vi.fn(),
|
getShareMarketDataBatch: vi.fn(),
|
||||||
getBondPositionDataBatch: vi.fn(),
|
getBondPositionDataBatch: vi.fn(),
|
||||||
getSecurityDescription: vi.fn(),
|
|
||||||
getDividends: vi.fn(),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: MoexDividendsClient,
|
||||||
|
useValue: { getDividends: vi.fn() },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: CacheService,
|
provide: CacheService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@ -84,7 +93,7 @@ describe('PortfolioService', () => {
|
|||||||
|
|
||||||
service = module.get<PortfolioService>(PortfolioService);
|
service = module.get<PortfolioService>(PortfolioService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -138,11 +147,11 @@ describe('PortfolioService', () => {
|
|||||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.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(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
shortName: 'OFZ 26238',
|
shortName: 'OFZ 26238',
|
||||||
@ -204,14 +213,14 @@ describe('PortfolioService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('findOne', () => {
|
describe('findOne', () => {
|
||||||
it('should throw NotFoundException for non-existent portfolio', async () => {
|
it('should throw EntityNotFoundException 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(NotFoundException);
|
await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ForbiddenException for wrong user', async () => {
|
it('should throw PortfolioAccessDeniedException 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(ForbiddenException);
|
await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return portfolio with enriched positions and analytics summary', async () => {
|
it('should return portfolio with enriched positions and analytics summary', async () => {
|
||||||
@ -236,7 +245,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
@ -282,7 +291,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.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);
|
||||||
|
|
||||||
@ -324,7 +333,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
shortName: 'OFZ 26238',
|
shortName: 'OFZ 26238',
|
||||||
@ -368,7 +377,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||||
] as any);
|
] as any);
|
||||||
|
|
||||||
@ -404,7 +413,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
|
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
|
||||||
|
|
||||||
const result = await service.getPositionsWithPrices(1);
|
const result = await service.getPositionsWithPrices(1);
|
||||||
|
|
||||||
@ -460,7 +469,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.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);
|
||||||
@ -512,7 +521,7 @@ describe('PortfolioService', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.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);
|
||||||
@ -525,16 +534,16 @@ describe('PortfolioService', () => {
|
|||||||
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
|
expect(result.summary.weightedYield).toBeCloseTo(0, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ForbiddenException if portfolio belongs to another user', async () => {
|
it('should throw PortfolioAccessDeniedException 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(ForbiddenException);
|
await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if portfolio does not exist', async () => {
|
it('should throw EntityNotFoundException 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(NotFoundException);
|
await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
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 { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
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 { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||||
import type {
|
import type {
|
||||||
MoexShareMarketData,
|
MoexShareMarketData,
|
||||||
MoexBondPositionData,
|
MoexBondPositionData,
|
||||||
@ -58,7 +60,9 @@ export interface EnrichedPosition {
|
|||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexDividends: MoexDividendsClient,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -135,8 +139,8 @@ export class PortfolioService {
|
|||||||
include: { positions: true },
|
include: { positions: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
const positionsWithPrices = await this.enrichPositions(portfolio.positions, id);
|
||||||
|
|
||||||
@ -168,8 +172,8 @@ export class PortfolioService {
|
|||||||
|
|
||||||
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 NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
const updated = await this.prisma.portfolio.update({
|
const updated = await this.prisma.portfolio.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@ -189,8 +193,8 @@ export class PortfolioService {
|
|||||||
|
|
||||||
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 NotFoundException(`Portfolio ${id} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', id);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id);
|
||||||
|
|
||||||
await this.prisma.portfolio.delete({ where: { id } });
|
await this.prisma.portfolio.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
@ -200,8 +204,8 @@ export class PortfolioService {
|
|||||||
where: { id: portfolioId },
|
where: { id: portfolioId },
|
||||||
include: { positions: true },
|
include: { positions: true },
|
||||||
});
|
});
|
||||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
||||||
if (exists)
|
if (exists)
|
||||||
@ -209,7 +213,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.moexClient.getSecurityDescription(dto.secid);
|
const desc = await this.moexSecurities.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';
|
||||||
@ -235,12 +239,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 NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
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 NotFoundException(`Position ${positionId} not found`);
|
throw new EntityNotFoundException('Position', positionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.position.update({
|
return this.prisma.position.update({
|
||||||
@ -257,12 +261,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 NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
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 NotFoundException(`Position ${positionId} not found`);
|
throw new EntityNotFoundException('Position', positionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.position.delete({ where: { id: positionId } });
|
await this.prisma.position.delete({ where: { id: positionId } });
|
||||||
@ -339,7 +343,7 @@ export class PortfolioService {
|
|||||||
const { data } = await this.cache.getOrFetch(
|
const { data } = await this.cache.getOrFetch(
|
||||||
'batchdata',
|
'batchdata',
|
||||||
['shares', cacheKey],
|
['shares', cacheKey],
|
||||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
() => this.moexMarketData.getShareMarketDataBatch(secids),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
return new Map(data.map((d) => [d.secid, d]));
|
||||||
@ -354,7 +358,7 @@ export class PortfolioService {
|
|||||||
const { data } = await this.cache.getOrFetch(
|
const { data } = await this.cache.getOrFetch(
|
||||||
'batchdata',
|
'batchdata',
|
||||||
['bonds', cacheKey],
|
['bonds', cacheKey],
|
||||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
() => this.moexMarketData.getBondPositionDataBatch(secids),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
return new Map(data.map((d) => [d.secid, d]));
|
return new Map(data.map((d) => [d.secid, d]));
|
||||||
@ -371,7 +375,7 @@ export class PortfolioService {
|
|||||||
const { data } = await this.cache.getOrFetch(
|
const { data } = await this.cache.getOrFetch(
|
||||||
'dividends',
|
'dividends',
|
||||||
[cacheKey],
|
[cacheKey],
|
||||||
() => this.moexClient.getDividends(secid),
|
() => this.moexDividends.getDividends(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
return { secid, dividends: data };
|
return { secid, dividends: data };
|
||||||
@ -517,8 +521,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 NotFoundException(`Portfolio ${portfolioId} not found`);
|
if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId);
|
||||||
if (portfolio.userId !== userId) throw new ForbiddenException();
|
if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId);
|
||||||
|
|
||||||
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
const enrichedPositions = await this.getPositionsWithPrices(portfolioId);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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' })
|
||||||
@ -70,18 +71,10 @@ 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: ScreenerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: ScreenerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,21 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ScreenerService } from './screener.service';
|
import { ScreenerService } from './screener.service';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
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: MoexClientService,
|
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
|
||||||
useValue: {
|
|
||||||
getShareMarketDataBatch: vi.fn(),
|
|
||||||
getBondPositionDataBatch: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: CacheService,
|
|
||||||
useValue: {
|
|
||||||
getOrFetch: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -37,6 +28,30 @@ 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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||||
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 moexClient: MoexClientService,
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
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.moexClient.getShareMarketDataBatch([]);
|
const shares = await this.moexMarketData.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.moexClient.getBondPositionDataBatch([]);
|
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
|
||||||
return bonds.map(
|
return bonds.map(
|
||||||
(b): ScreenerItemDto => ({
|
(b): ScreenerItemDto => ({
|
||||||
secid: b.secid,
|
secid: b.secid,
|
||||||
@ -85,7 +85,7 @@ export class ScreenerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'marketDataTtl',
|
'screenerTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CacheModule } from '../cache/cache.module';
|
import { MoexClientModule } from '../moex-client/moex-client.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: [CacheModule],
|
imports: [MoexClientModule],
|
||||||
controllers: [SecuritiesController],
|
controllers: [SecuritiesController],
|
||||||
providers: [SecuritiesService, ScreenerService],
|
providers: [SecuritiesService, ScreenerService],
|
||||||
exports: [SecuritiesService],
|
exports: [SecuritiesService],
|
||||||
|
|||||||
@ -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 { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
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 moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexSecurities = {
|
||||||
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: MoexClientService, useValue: moexClient },
|
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||||
{ 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(moexClient.searchSecurities).mockResolvedValue([
|
vi.mocked(moexSecurities.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(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
|
expect(moexSecurities.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(moexClient.searchSecurities).not.toHaveBeenCalled();
|
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
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 moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
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.moexClient.searchSecurities(query);
|
const results = await this.moexSecurities.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.moexClient.getSecurityDescription(secid);
|
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
return {
|
return {
|
||||||
secid: desc.secid,
|
secid: desc.secid,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
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],
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
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 { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
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 moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
|
||||||
|
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
|
||||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
moexClient = {
|
moexSecurities = {
|
||||||
getSecurityDescription: vi.fn(),
|
getSecurityDescription: vi.fn(),
|
||||||
|
};
|
||||||
|
moexMarketData = {
|
||||||
getShareMarketData: vi.fn(),
|
getShareMarketData: vi.fn(),
|
||||||
};
|
};
|
||||||
cache = {
|
cache = {
|
||||||
@ -25,7 +31,10 @@ describe('SharesService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
SharesService,
|
SharesService,
|
||||||
{ provide: MoexClientService, useValue: moexClient },
|
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||||
|
{ 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();
|
||||||
@ -34,7 +43,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(moexClient.getSecurityDescription).mockResolvedValue({
|
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
name: 'Сбербанк России ПАО ао',
|
name: 'Сбербанк России ПАО ао',
|
||||||
@ -52,7 +61,7 @@ describe('SharesService', () => {
|
|||||||
morningSession: true,
|
morningSession: true,
|
||||||
eveningSession: true,
|
eveningSession: true,
|
||||||
});
|
});
|
||||||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
boardid: 'TQBR',
|
boardid: 'TQBR',
|
||||||
shortName: 'Сбербанк',
|
shortName: 'Сбербанк',
|
||||||
@ -75,15 +84,15 @@ describe('SharesService', () => {
|
|||||||
|
|
||||||
const result = await service.getShare('SBER');
|
const result = await service.getShare('SBER');
|
||||||
|
|
||||||
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
expect(moexSecurities.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(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||||
expect(result).toMatchObject({
|
expect(result.data).toMatchObject({
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU0009029540',
|
isin: 'RU0009029540',
|
||||||
name: 'Сбербанк России ПАО ао',
|
name: 'Сбербанк России ПАО ао',
|
||||||
@ -106,11 +115,11 @@ describe('SharesService', () => {
|
|||||||
issueCapitalization: 6900000000000,
|
issueCapitalization: 6900000000000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.marketData.updatedAt).toMatch(/T18:45:00$/);
|
expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundException for non-share security', async () => {
|
it('throws EntityNotFoundException for non-share security', async () => {
|
||||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||||
secid: 'SU26238RMFS5',
|
secid: 'SU26238RMFS5',
|
||||||
isin: 'RU000A1038V6',
|
isin: 'RU000A1038V6',
|
||||||
name: 'ОФЗ 26238',
|
name: 'ОФЗ 26238',
|
||||||
@ -129,7 +138,7 @@ describe('SharesService', () => {
|
|||||||
eveningSession: false,
|
eveningSession: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||||
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,24 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||||
|
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 { 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 moexClient: MoexClientService,
|
private readonly moexSecurities: MoexSecuritiesClient,
|
||||||
|
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.moexClient.getSecurityDescription(secid);
|
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||||
if (
|
if (
|
||||||
!desc ||
|
!desc ||
|
||||||
!(
|
!(
|
||||||
@ -20,13 +27,17 @@ export class SharesService {
|
|||||||
desc.type === 'preferred_share'
|
desc.type === 'preferred_share'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException(`Share ${secid} not found`);
|
throw new EntityNotFoundException('Share', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: marketData } = await this.cache.getOrFetch(
|
const {
|
||||||
|
data: marketData,
|
||||||
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', secid],
|
['shares', secid],
|
||||||
() => this.moexClient.getShareMarketData(secid),
|
() => this.moexMarketData.getShareMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -34,32 +45,36 @@ export class SharesService {
|
|||||||
const change = marketData?.lastChange ?? 0;
|
const change = marketData?.lastChange ?? 0;
|
||||||
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
||||||
|
|
||||||
return {
|
return new ApiEnvelopePayload(
|
||||||
secid: desc.secid,
|
{
|
||||||
isin: desc.isin,
|
secid: desc.secid,
|
||||||
name: desc.name,
|
isin: desc.isin,
|
||||||
shortName: desc.shortName,
|
name: desc.name,
|
||||||
latName: desc.latName,
|
shortName: desc.shortName,
|
||||||
listLevel: desc.listLevel,
|
latName: desc.latName,
|
||||||
issueSize: desc.issueSize,
|
listLevel: desc.listLevel,
|
||||||
faceValue: desc.faceValue,
|
issueSize: desc.issueSize,
|
||||||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
faceValue: desc.faceValue,
|
||||||
type: desc.type,
|
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||||
marketData: {
|
type: desc.type,
|
||||||
price: price ?? 0,
|
marketData: {
|
||||||
change,
|
price: price ?? 0,
|
||||||
changePercent,
|
change,
|
||||||
open: marketData?.open ?? 0,
|
changePercent,
|
||||||
high: marketData?.high ?? null,
|
open: marketData?.open ?? 0,
|
||||||
low: marketData?.low ?? null,
|
high: marketData?.high ?? null,
|
||||||
volume: marketData?.volume ?? 0,
|
low: marketData?.low ?? null,
|
||||||
value: marketData?.value ?? 0,
|
volume: marketData?.volume ?? 0,
|
||||||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
value: marketData?.value ?? 0,
|
||||||
updatedAt: marketData?.updateTime
|
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||||||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
updatedAt: marketData?.updateTime
|
||||||
: new Date().toISOString(),
|
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||||||
|
: new Date().toISOString(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
fromCache,
|
||||||
|
cachedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMarketData(secid: string) {
|
async getMarketData(secid: string) {
|
||||||
@ -70,12 +85,12 @@ export class SharesService {
|
|||||||
} = await this.cache.getOrFetch(
|
} = await this.cache.getOrFetch(
|
||||||
'marketdata',
|
'marketdata',
|
||||||
['shares', secid],
|
['shares', secid],
|
||||||
() => this.moexClient.getShareMarketData(secid),
|
() => this.moexMarketData.getShareMarketData(secid),
|
||||||
'marketDataTtl',
|
'marketDataTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!marketData) {
|
if (!marketData) {
|
||||||
throw new NotFoundException(`Market data for ${secid} not found`);
|
throw new EntityNotFoundException('MarketData', secid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ApiEnvelopePayload(
|
return new ApiEnvelopePayload(
|
||||||
@ -102,7 +117,7 @@ export class SharesService {
|
|||||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||||
'dividends',
|
'dividends',
|
||||||
[secid],
|
[secid],
|
||||||
() => this.moexClient.getDividends(secid),
|
() => this.moexDividends.getDividends(secid),
|
||||||
'dividendsTtl',
|
'dividendsTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -121,7 +136,7 @@ export class SharesService {
|
|||||||
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.moexClient.getHistory(secid, from, till),
|
() => this.moexHistory.getHistory(secid, from, till),
|
||||||
'historyTtl',
|
'historyTtl',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
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,66 +8,58 @@ import { BrokerPositionsPageResponseDto } from './broker-positions-page-response
|
|||||||
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';
|
||||||
|
|
||||||
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: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerPortfolioEnvelopeDto {
|
export class BrokerPortfolioEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||||
data!: BrokerPortfolioResponseDto;
|
data!: BrokerPortfolioResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerOperationsEnvelopeDto {
|
export class BrokerOperationsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
@ApiProperty({ type: BrokerOperationsPageResponseDto })
|
||||||
data!: BrokerOperationsPageResponseDto;
|
data!: BrokerOperationsPageResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerPositionsEnvelopeDto {
|
export class BrokerPositionsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
@ApiProperty({ type: BrokerPositionsPageResponseDto })
|
||||||
data!: BrokerPositionsPageResponseDto;
|
data!: BrokerPositionsPageResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerOperationSyncEnvelopeDto {
|
export class BrokerOperationSyncEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
@ApiProperty({ type: BrokerOperationSyncResponseDto })
|
||||||
data!: BrokerOperationSyncResponseDto;
|
data!: BrokerOperationSyncResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerAnalyticsEnvelopeDto {
|
export class BrokerAnalyticsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerAnalyticsDto })
|
@ApiProperty({ type: BrokerAnalyticsDto })
|
||||||
data!: BrokerAnalyticsDto;
|
data!: BrokerAnalyticsDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerEventsEnvelopeDto {
|
export class BrokerEventsEnvelopeDto {
|
||||||
@ApiProperty({ type: BrokerEventsDataDto })
|
@ApiProperty({ type: BrokerEventsDataDto })
|
||||||
data!: BrokerEventsDataDto;
|
data!: BrokerEventsDataDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: ApiResponseMeta })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: ApiResponseMeta;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { BrokerAnalyticsService } from './broker-analytics.service';
|
import { BrokerAnalyticsService } from './broker-analytics.service';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
@ -40,7 +40,7 @@ describe('BrokerAnalyticsService', () => {
|
|||||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
const service = new BrokerAnalyticsService(prisma, accounts, cache);
|
||||||
await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException);
|
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns zeros for account with no operations', async () => {
|
it('returns zeros for account with no operations', async () => {
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
|
||||||
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
|
|
||||||
import { BrokerAccountsService } from './broker-accounts.service';
|
|
||||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||||
|
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';
|
||||||
|
|
||||||
const DEPOSIT_TYPES = new Set([
|
const DEPOSIT_TYPES = new Set([
|
||||||
'OPERATION_TYPE_INPUT',
|
'OPERATION_TYPE_INPUT',
|
||||||
@ -44,7 +45,7 @@ export class BrokerAnalyticsService {
|
|||||||
|
|
||||||
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
|
async getAnalytics(accountId: string): Promise<ApiEnvelopePayload<BrokerAnalyticsDto>> {
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||||
|
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
TBANK_CACHE_KEYS.analytics,
|
TBANK_CACHE_KEYS.analytics,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||||
|
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';
|
||||||
@ -9,10 +10,8 @@ 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 moex = {
|
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient;
|
||||||
getDividends: vi.fn(),
|
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient;
|
||||||
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;
|
||||||
|
|
||||||
@ -42,10 +41,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, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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(NotFoundException);
|
).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty events for account with no positions', async () => {
|
it('returns empty events for account with no positions', async () => {
|
||||||
@ -62,7 +61,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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([]);
|
||||||
@ -87,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(moex.getDividends).mockResolvedValue([
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SBER',
|
secid: 'SBER',
|
||||||
isin: 'RU000A0JS',
|
isin: 'RU000A0JS',
|
||||||
@ -117,7 +116,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -144,7 +143,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(moex.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'SU26248RMFS4',
|
secid: 'SU26248RMFS4',
|
||||||
couponValue: 35.4,
|
couponValue: 35.4,
|
||||||
@ -172,7 +171,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -207,8 +206,8 @@ describe('BrokerEventsService', () => {
|
|||||||
['uid-2', { name: 'Working' }],
|
['uid-2', { name: 'Working' }],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
||||||
vi.mocked(moex.getDividends).mockResolvedValueOnce([
|
vi.mocked(moexDividends.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' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -218,7 +217,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -239,7 +238,7 @@ describe('BrokerEventsService', () => {
|
|||||||
],
|
],
|
||||||
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
|
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
|
||||||
});
|
});
|
||||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
vi.mocked(moexDividends.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' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -249,7 +248,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -279,10 +278,10 @@ describe('BrokerEventsService', () => {
|
|||||||
['uid-2', { name: 'OFZ' }],
|
['uid-2', { name: 'OFZ' }],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
vi.mocked(moexDividends.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(moex.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'BOND1',
|
secid: 'BOND1',
|
||||||
couponValue: 50,
|
couponValue: 50,
|
||||||
@ -310,7 +309,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -337,7 +336,7 @@ describe('BrokerEventsService', () => {
|
|||||||
],
|
],
|
||||||
instruments: new Map([['uid-1', { name: 'Sber' }]]),
|
instruments: new Map([['uid-1', { name: 'Sber' }]]),
|
||||||
});
|
});
|
||||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
vi.mocked(moexDividends.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' },
|
||||||
@ -349,7 +348,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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);
|
||||||
@ -377,10 +376,10 @@ describe('BrokerEventsService', () => {
|
|||||||
],
|
],
|
||||||
instruments: new Map(),
|
instruments: new Map(),
|
||||||
});
|
});
|
||||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
vi.mocked(moexDividends.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(moex.getBondPositionDataBatch).mockResolvedValue([
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||||
{
|
{
|
||||||
secid: 'BOND1',
|
secid: 'BOND1',
|
||||||
couponValue: 50,
|
couponValue: 50,
|
||||||
@ -407,7 +406,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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',
|
||||||
@ -467,7 +466,7 @@ describe('BrokerEventsService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, 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',
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
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 { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
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 {
|
||||||
@ -45,7 +47,8 @@ export class BrokerEventsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly accountsService: BrokerAccountsService,
|
private readonly accountsService: BrokerAccountsService,
|
||||||
private readonly portfolioService: BrokerPortfolioService,
|
private readonly portfolioService: BrokerPortfolioService,
|
||||||
private readonly moexClient: MoexClientService,
|
private readonly moexMarketData: MoexMarketDataClient,
|
||||||
|
private readonly moexDividends: MoexDividendsClient,
|
||||||
private readonly operationsService: BrokerOperationsService,
|
private readonly operationsService: BrokerOperationsService,
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
@ -55,7 +58,7 @@ export class BrokerEventsService {
|
|||||||
query: BrokerEventsQuery,
|
query: BrokerEventsQuery,
|
||||||
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
|
): Promise<ApiEnvelopePayload<BrokerEventsData>> {
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||||
|
|
||||||
const eventTypes = this.parseEventTypes(query.types);
|
const eventTypes = this.parseEventTypes(query.types);
|
||||||
const eventTypeKey = Array.from(eventTypes).join(',');
|
const eventTypeKey = Array.from(eventTypes).join(',');
|
||||||
@ -138,7 +141,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.moexClient.getDividends(ticker);
|
dividends = await this.moexDividends.getDividends(ticker);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -198,7 +201,7 @@ export class BrokerEventsService {
|
|||||||
faceValue: number;
|
faceValue: number;
|
||||||
}[];
|
}[];
|
||||||
try {
|
try {
|
||||||
bondData = await this.moexClient.getBondPositionDataBatch(secids);
|
bondData = await this.moexMarketData.getBondPositionDataBatch(secids);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { BrokerOperationsService } from './broker-operations.service';
|
import { BrokerOperationsService } from './broker-operations.service';
|
||||||
import { TBankClientService } from './tbank-client.service';
|
import { TBankClientService } from './tbank-client.service';
|
||||||
@ -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(NotFoundException);
|
await expect(service.getOperations('missing', {})).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds cursor request and maps operation page', async () => {
|
it('builds cursor request and maps operation page', async () => {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CacheService } from '../../cache/cache.service';
|
import { CacheService } from '../../cache/cache.service';
|
||||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
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 { 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';
|
||||||
@ -22,7 +23,7 @@ export class BrokerOperationsService {
|
|||||||
query: BrokerOperationQueryDto,
|
query: BrokerOperationQueryDto,
|
||||||
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
|
): Promise<ApiEnvelopePayload<BrokerOperationsPage>> {
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||||
|
|
||||||
const request = this.buildRequest(accountId, query);
|
const request = this.buildRequest(accountId, query);
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
|
|||||||
@ -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';
|
||||||
@ -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(NotFoundException);
|
await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches portfolio through cache without positions', async () => {
|
it('fetches portfolio through cache without positions', async () => {
|
||||||
@ -98,7 +98,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.getPositions('missing')).rejects.toThrow(NotFoundException);
|
await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns first page of positions', async () => {
|
it('returns first page of positions', async () => {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable } 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,
|
||||||
@ -25,7 +26,7 @@ export class BrokerPortfolioService {
|
|||||||
|
|
||||||
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
|
async getPortfolio(accountId: string): Promise<ApiEnvelopePayload<BrokerPortfolio>> {
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||||
|
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
TBANK_CACHE_KEYS.portfolio,
|
TBANK_CACHE_KEYS.portfolio,
|
||||||
@ -51,7 +52,7 @@ export class BrokerPortfolioService {
|
|||||||
type?: string,
|
type?: string,
|
||||||
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
|
): Promise<ApiEnvelopePayload<BrokerPositionsPage>> {
|
||||||
const account = await this.accountsService.findById(accountId);
|
const account = await this.accountsService.findById(accountId);
|
||||||
if (!account) throw new NotFoundException('Broker account not found');
|
if (!account) throw new EntityNotFoundException('BrokerAccount', accountId);
|
||||||
|
|
||||||
const result = await this.cacheService.getOrFetch(
|
const result = await this.cacheService.getOrFetch(
|
||||||
TBANK_CACHE_KEYS.positions,
|
TBANK_CACHE_KEYS.positions,
|
||||||
|
|||||||
@ -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';
|
||||||
@ -85,7 +85,7 @@ describe('TBankClientService', () => {
|
|||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
).rejects.toThrow(ServiceUnavailableException);
|
).rejects.toThrow(TBankNotConfiguredException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps grpc errors with status code and tracking id', async () => {
|
it('wraps grpc errors with status code and tracking id', async () => {
|
||||||
@ -107,9 +107,7 @@ describe('TBankClientService', () => {
|
|||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
response: expect.objectContaining({
|
response: expect.stringContaining('T-Bank upstream error'),
|
||||||
message: expect.stringContaining('T-Bank upstream error'),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import {
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
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 {
|
import {
|
||||||
CallOptions,
|
CallOptions,
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
@ -79,7 +75,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 ServiceUnavailableException('T-Bank integration is not configured');
|
throw new TBankNotConfiguredException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
@ -169,7 +165,7 @@ export class TBankClientService {
|
|||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
throw new ServiceUnavailableException('T-Bank CA certificate is not readable');
|
throw new TBankApiException('T-Bank CA certificate is not readable');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,10 +187,9 @@ export class TBankClientService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return new BadGatewayException({
|
const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`]
|
||||||
message: publicMessage,
|
.filter(Boolean)
|
||||||
trackingId: trackingId ? String(trackingId) : null,
|
.join('; ');
|
||||||
retryAfter: retryAfter ? String(retryAfter) : null,
|
return new TBankApiException(detail);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
apps/docs/docs/adr/ADR-020-moex-client-split.md
Normal file
63
apps/docs/docs/adr/ADR-020-moex-client-split.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# 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)
|
||||||
52
docs/features/backend-architecture-improvements/plan.md
Normal file
52
docs/features/backend-architecture-improvements/plan.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Backend Architecture Improvements — Plan
|
||||||
|
|
||||||
|
## Подход
|
||||||
|
|
||||||
|
Разбиваем на итерации от самых безопасных (только DTO/косметика) к самым рискованным (сплит сервисов). Каждая итерация — отдельная задача с отдельным commit'ом.
|
||||||
|
|
||||||
|
## Итерации
|
||||||
|
|
||||||
|
### Итерация 1: Shared envelope DTO + fixes
|
||||||
|
|
||||||
|
1. Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый класс, убрать дубликаты `*ResponseMetaDto` в модулях.
|
||||||
|
2. Убрать `AuthResponseMetaDto` → заменить на `ApiResponseMeta`.
|
||||||
|
3. Поправить `shares/shares.service.ts:getShare()` — обернуть результат в `ApiEnvelopePayload`.
|
||||||
|
4. Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`.
|
||||||
|
5. Убрать `PortfolioResponseMetaDto` и `BrokerResponseMetaDto` — заменить на `ApiResponseMeta`.
|
||||||
|
|
||||||
|
### Итерация 2: Screener caching + server-side пагинация
|
||||||
|
|
||||||
|
1. Добавить кеширование полного screener-датасета при пустом фильтре.
|
||||||
|
2. Ключ кеша: `'screener:full'` с TTL из `app.cache.marketDataTtl`.
|
||||||
|
|
||||||
|
### Итерация 3: Domain exception hierarchy
|
||||||
|
|
||||||
|
1. Создать `common/exceptions/` с базой `DomainException` и конкретными классами.
|
||||||
|
2. Обновить `HttpExceptionFilter` для map'инга доменных → HTTP исключений.
|
||||||
|
3. Заменить `NotFoundException` на `EntityNotFoundException` в сервисах.
|
||||||
|
|
||||||
|
### Итерация 4: Health check improvement
|
||||||
|
|
||||||
|
1. Добавить проверки: Prisma ping, MOEX `/health`, T-Bank gRPC connectivity.
|
||||||
|
2. Обновить `HealthResponseDto` с полем `checks`.
|
||||||
|
|
||||||
|
### Итерация 5: RequestLoggingMiddleware через DI
|
||||||
|
|
||||||
|
1. Перенести middleware в корректный DI-контекст через `configure()` в `AppModule`.
|
||||||
|
|
||||||
|
### Итерация 6 (отдельный эпик): MoexClientService split
|
||||||
|
|
||||||
|
1. Выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`.
|
||||||
|
2. Общий rate limiter + circuit breaker в shared utils.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller → Service → [CacheService.getOrFetch] → [MoexClient* или TBank*] → Внешнее API
|
||||||
|
↓
|
||||||
|
ApiEnvelopePayload<T>
|
||||||
|
↓
|
||||||
|
TransformInterceptor → ApiResponse<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
После итерации 1 все модули следуют этому потоку единообразно.
|
||||||
33
docs/features/backend-architecture-improvements/spec.md
Normal file
33
docs/features/backend-architecture-improvements/spec.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Backend Architecture Improvements
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Устранить выявленные в ходе аудита архитектурные проблемы бэкенда: консистентность ответов API, качество кода модулей, обработку ошибок, тестируемость.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
1. Унифицировать формат ответов API — единый envelope DTO, используемый всеми модулями.
|
||||||
|
2. Устранить inconsistency между shares и bonds модулями.
|
||||||
|
3. Ввести иерархию доменных исключений с корректной обработкой.
|
||||||
|
4. Улучшить health check (проверка зависимостей).
|
||||||
|
5. Убрать дублирование и лишние зависимости.
|
||||||
|
6. Сохранить обратную совместимость API (поля ответов не меняются, только структура).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Не менять внешний API-контракт (формат `{ data, meta }` остаётся).
|
||||||
|
- Не рефакторить то, что не указано в требованиях.
|
||||||
|
- Каждое изменение идёт через TDD-цикл.
|
||||||
|
|
||||||
|
## Критерии приемки (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] Все модули используют единый shared envelope DTO из `common/dto/`
|
||||||
|
- [ ] `shares/shares.service.ts:getShare()` возвращает `ApiEnvelopePayload` как и `bonds/`
|
||||||
|
- [ ] Screener кеширует полный набор данных
|
||||||
|
- [ ] Создана иерархия доменных исключений
|
||||||
|
- [ ] `HttpExceptionFilter` корректно обрабатывает доменные исключения
|
||||||
|
- [ ] Health check проверяет Prisma, MOEX, T-Bank
|
||||||
|
- [ ] `RequestLoggingMiddleware` подключён через DI
|
||||||
|
- [ ] `securities/` не импортирует `CacheModule` напрямую
|
||||||
|
- [ ] Все тесты проходят
|
||||||
|
- [ ] `npm run build` успешен
|
||||||
51
docs/features/backend-architecture-improvements/tasks.md
Normal file
51
docs/features/backend-architecture-improvements/tasks.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Backend Architecture Improvements — Tasks
|
||||||
|
|
||||||
|
## Итерация 1: Shared envelope DTO + consistency fixes ✅
|
||||||
|
|
||||||
|
- [x] 1.1 Создать документ аудита в `docs/research/`
|
||||||
|
- [x] 1.2 Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый shared класс
|
||||||
|
- [x] 1.3 Заменить `AuthResponseMetaDto` на `ApiResponseMeta` в `auth/`
|
||||||
|
- [x] 1.4 Заменить `PortfolioResponseMetaDto` на `ApiResponseMeta` в `portfolio/`
|
||||||
|
- [x] 1.5 Заменить `BrokerResponseMetaDto` на `ApiResponseMeta` в `tbank/`
|
||||||
|
- [x] 1.6 Поправить `shares/shares.service.ts:getShare()` — обернуть в `ApiEnvelopePayload`
|
||||||
|
- [x] 1.7 Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`
|
||||||
|
- [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`)
|
||||||
|
- [x] 1.9 `npm run build` успешен, 116 тестов проходят
|
||||||
|
|
||||||
|
## Итерация 2: Screener caching ✅
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить `screenerTtl` в конфиг (900s default)
|
||||||
|
- [x] 2.2 Перевести screener на отдельный TTL
|
||||||
|
- [x] 2.3 Тест на cache key + ttl config key
|
||||||
|
|
||||||
|
## Итерация 3: Domain exceptions ✅
|
||||||
|
|
||||||
|
- [x] 3.1 Создать `common/exceptions/domain.exception.ts`
|
||||||
|
- [x] 3.2 Создать `common/exceptions/entity-not-found.exception.ts`
|
||||||
|
- [x] 3.3 Создать `common/exceptions/moex-api.exception.ts`
|
||||||
|
- [x] 3.4 Создать `common/exceptions/tbank-api.exception.ts` (+ `TBankNotConfiguredException`)
|
||||||
|
- [x] 3.5 Создать `common/exceptions/portfolio-access.exception.ts`
|
||||||
|
- [x] 3.6 Обновить `HttpExceptionFilter` с логгированием необработанных ошибок
|
||||||
|
- [x] 3.7 Заменить generic исключения в shares, bonds, portfolio, tbank сервисах
|
||||||
|
- [x] 3.8 Обновлены все тесты (117 проходят)
|
||||||
|
|
||||||
|
## Итерация 4: Health check ✅
|
||||||
|
|
||||||
|
- [x] 4.1 Создан `HealthService` с `checkPrisma()` — реальный SQL-запрос
|
||||||
|
- [x] 4.2 Добавлен `checkMoex()` — HTTP-запрос к ISS MOEX с 5s timeout
|
||||||
|
- [x] 4.3 Добавлен `checkTBank()` — проверка наличия токена
|
||||||
|
- [x] 4.4 `HealthResponseDto` обновлён: добавлено поле `checks: HealthCheckResultDto[]`
|
||||||
|
- [x] 4.5 Модуль обновлён (imports PrismaModule, providers HealthService)
|
||||||
|
- [x] 4.6 Тесты: `health.service.spec.ts` (3 теста) + `envelope-contract.spec.ts` проверяет checks
|
||||||
|
- [x] Все 120 тестов проходят
|
||||||
|
|
||||||
|
## Итерация 5: RequestLoggingMiddleware DI ✅
|
||||||
|
|
||||||
|
- [x] 5.1 `AppModule` implements `NestModule` с `configure()` → `consumer.apply(RequestLoggingMiddleware).forRoutes('*')`
|
||||||
|
- [x] 5.2 Убран `new RequestLoggingMiddleware()` и `app.use()` из `main.ts`
|
||||||
|
- [x] 120 тестов проходят, build успешен
|
||||||
|
|
||||||
|
## Итерация 6: MoexClientService split → `docs/features/moex-client-split/`
|
||||||
|
|
||||||
|
- [x] 6.1 ADR на разделение MoexClientService
|
||||||
|
- [x] 6.2 spec/plan/tasks отдельного эпика → перенесено в `docs/features/moex-client-split/{spec,plan,tasks}.md`
|
||||||
73
docs/features/moex-client-split/plan.md
Normal file
73
docs/features/moex-client-split/plan.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# MoexClientService Split — Plan
|
||||||
|
|
||||||
|
## Подход
|
||||||
|
|
||||||
|
Полный сплит по доменам (ADR-020). Один коммит — миграция всех файлов одновременно.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/moex-client/
|
||||||
|
├── moex-http.client.ts # Инфраструктура: axios, PQueue, circuit breaker
|
||||||
|
├── moex-securities.client.ts # searchSecurities(), getSecurityDescription()
|
||||||
|
├── moex-market-data.client.ts # getShareMarketData(), getShareMarketDataBatch(),
|
||||||
|
│ # getBondData(), getBondMarketData(), getBondPositionDataBatch()
|
||||||
|
├── moex-candles.client.ts # getCandles()
|
||||||
|
├── moex-history.client.ts # getHistory(), getBondHistory()
|
||||||
|
├── moex-dividends.client.ts # getDividends()
|
||||||
|
├── moex-client.types.ts # (unchanged)
|
||||||
|
├── moex-client.module.ts # providers: все клиенты, exports: доменные клиенты, не @Global()
|
||||||
|
├── moex-client.service.spec.ts # → moex-http.client.spec.ts
|
||||||
|
└── moex-client.service.integration.spec.ts # → интеграционные тесты
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Consumer Service
|
||||||
|
↓ (DI)
|
||||||
|
Domain Client (MoexSecuritiesClient | MoexMarketDataClient | etc.)
|
||||||
|
↓ (DI)
|
||||||
|
MoexHttpClient (request + extractTable)
|
||||||
|
↓
|
||||||
|
MOEX ISS API
|
||||||
|
```
|
||||||
|
|
||||||
|
`MoexHttpClient` — не экспортируется из модуля, только доменные клиенты его видят.
|
||||||
|
|
||||||
|
## Consumer Updates
|
||||||
|
|
||||||
|
| Модуль | Было | Стало |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `shares/shares.module.ts` | — | `imports: [MoexClientModule]` |
|
||||||
|
| `shares/shares.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient` |
|
||||||
|
| `bonds/bonds.module.ts` | — | `imports: [MoexClientModule]` |
|
||||||
|
| `bonds/bonds.service.ts` | `moexClient: MoexClientService` | `moexMarketData: MoexMarketDataClient`, `moexHistory: MoexHistoryClient` |
|
||||||
|
| `candles/candles.module.ts` | — | `imports: [MoexClientModule]` |
|
||||||
|
| `candles/candles.service.ts` | `moexClient: MoexClientService` | `moexCandles: MoexCandlesClient` |
|
||||||
|
| `securities/securities.module.ts` | — | `imports: [MoexClientModule]` |
|
||||||
|
| `securities/securities.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient` |
|
||||||
|
| `securities/screener.service.ts` | `moexClient: MoexClientService` | `moexMarketData: MoexMarketDataClient` |
|
||||||
|
| `portfolio/portfolio.module.ts` | — | `imports: [MoexClientModule]` |
|
||||||
|
| `portfolio/portfolio.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient`, `moexDividends: MoexDividendsClient` |
|
||||||
|
| `tbank/tbank.module.ts` | `imports: [MoexClientModule]` | (unchanged) |
|
||||||
|
| `tbank/.../broker-events.service.ts` | `moexClient: MoexClientService` | `moexDividends: MoexDividendsClient`, `moexMarketData: MoexMarketDataClient` |
|
||||||
|
|
||||||
|
## Migration Order
|
||||||
|
|
||||||
|
1. Создать `MoexHttpClient` — перенести инфраструктуру из `MoexClientService`
|
||||||
|
2. Создать `MoexSecuritiesClient` — перенести 2 метода
|
||||||
|
3. Создать `MoexMarketDataClient` — перенести 5 методов
|
||||||
|
4. Создать `MoexCandlesClient` — перенести 1 метод
|
||||||
|
5. Создать `MoexHistoryClient` — перенести 2 метода
|
||||||
|
6. Создать `MoexDividendsClient` — перенести 1 метод
|
||||||
|
7. Обновить `MoexClientModule` — убрать `@Global()`, новые providers/exports
|
||||||
|
8. Обновить всех потребителей (модули + сервисы + тесты)
|
||||||
|
9. Удалить старый `MoexClientService`
|
||||||
|
10. `npm run build && npm run test`
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- `MoexHttpClient` — unit-тесты на circuit breaker, rate limiter, request
|
||||||
|
- Каждый доменный клиент — unit-тесты с mocked `MoexHttpClient`
|
||||||
|
- Существующие тесты потребителей — обновить DI-моки
|
||||||
35
docs/features/moex-client-split/spec.md
Normal file
35
docs/features/moex-client-split/spec.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# MoexClientService Split
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Разделить God Service `MoexClientService` на инфраструктурный слой и набор доменных клиентов, устранив нарушение SRP и улучшив тестируемость.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
1. `MoexHttpClient` — выделить инфраструктуру (axios, PQueue, circuit breaker, `request()`, `extractTable()`)
|
||||||
|
2. Доменные клиенты — по одному на группу MOEX-запросов:
|
||||||
|
- `MoexSecuritiesClient` — поиск и описание ценных бумаг
|
||||||
|
- `MoexMarketDataClient` — рыночные данные акций и облигаций
|
||||||
|
- `MoexCandlesClient` — свечи
|
||||||
|
- `MoexHistoryClient` — история торгов
|
||||||
|
- `MoexDividendsClient` — дивиденды
|
||||||
|
3. Убрать `@Global()` — каждый потребитель явно импортирует `MoexClientModule`
|
||||||
|
4. `MoexHttpClient` не экспортируется из модуля (внутренняя деталь)
|
||||||
|
5. Все MOEX-типы остаются в `moex-client.types.ts`
|
||||||
|
6. API-контракт всех потребителей не меняется — только DI
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Один коммит на всю миграцию (нельзя половинчатое состояние)
|
||||||
|
- Не менять сигнатуры публичных методов — только перенос кода
|
||||||
|
- Не менять типы в `moex-client.types.ts`
|
||||||
|
- Каждое изменение через TDD-цикл
|
||||||
|
|
||||||
|
## Критерии приемки (Acceptance Criteria)
|
||||||
|
|
||||||
|
- [ ] `MoexClientService` удалён, все 11 методов распределены по 5 доменным клиентам
|
||||||
|
- [ ] `MoexHttpClient` содержит rate limiter + circuit breaker
|
||||||
|
- [ ] `@Global()` убран с `MoexClientModule`
|
||||||
|
- [ ] Все 7 потребителей обновлены: явный импорт модуля + новые DI
|
||||||
|
- [ ] Все тесты проходят (существующие обновлены)
|
||||||
|
- [ ] `npm run build` успешен
|
||||||
39
docs/features/moex-client-split/tasks.md
Normal file
39
docs/features/moex-client-split/tasks.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# MoexClientService Split — Tasks
|
||||||
|
|
||||||
|
## Этап 1: Создание инфраструктурного клиента
|
||||||
|
|
||||||
|
- [x] 1.1 Создать `moex-http.client.ts` — перенести `request()`, `extractTable()`, circuit breaker, PQueue из `MoexClientService`
|
||||||
|
- [x] 1.2 Написать unit-тесты для `MoexHttpClient`
|
||||||
|
- [x] 1.3 Написать тест на circuit breaker (threshold → open → reset)
|
||||||
|
|
||||||
|
## Этап 2: Создание доменных клиентов
|
||||||
|
|
||||||
|
- [x] 2.1 `MoexSecuritiesClient` — `searchSecurities()`, `getSecurityDescription()`
|
||||||
|
- [x] 2.2 `MoexMarketDataClient` — `getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()`
|
||||||
|
- [x] 2.3 `MoexCandlesClient` — `getCandles()`
|
||||||
|
- [x] 2.4 `MoexHistoryClient` — `getHistory()`, `getBondHistory()`
|
||||||
|
- [x] 2.5 `MoexDividendsClient` — `getDividends()`
|
||||||
|
- [x] 2.6 Написать unit-тесты для каждого доменного клиента (mocked http client)
|
||||||
|
|
||||||
|
## Этап 3: Обновление модуля
|
||||||
|
|
||||||
|
- [x] 3.1 Убрать `@Global()` из `MoexClientModule`
|
||||||
|
- [x] 3.2 Добавить новые клиенты в providers/exports
|
||||||
|
- [x] 3.3 Убрать старый `MoexClientService` из providers
|
||||||
|
- [x] 3.4 Проверить, что `MoexHttpClient` не экспортируется
|
||||||
|
|
||||||
|
## Этап 4: Миграция потребителей
|
||||||
|
|
||||||
|
- [x] 4.1 `shares/` — обновить модуль, сервис, тесты
|
||||||
|
- [x] 4.2 `bonds/` — обновить модуль, сервис, тесты
|
||||||
|
- [x] 4.3 `candles/` — обновить модуль, сервис, тесты
|
||||||
|
- [x] 4.4 `securities/` — обновить модуль, securities.service, screener.service, тесты
|
||||||
|
- [x] 4.5 `portfolio/` — обновить модуль, сервис, тесты
|
||||||
|
- [x] 4.6 `tbank/` — обновить broker-events.service, тесты
|
||||||
|
|
||||||
|
## Этап 5: Финализация
|
||||||
|
|
||||||
|
- [x] 5.1 Удалить старый `moex-client.service.ts`
|
||||||
|
- [x] 5.2 `npm run build` успешен
|
||||||
|
- [x] 5.3 Все тесты проходят
|
||||||
|
- [x] 5.4 Обновить `docs/features/backend-architecture-improvements/tasks.md` — отметить Iteration 6 как выполненную
|
||||||
@ -358,6 +358,15 @@ cash flow, бюджеты, аналитика, прогнозы и автома
|
|||||||
> фичи: `frontend-docs-sync`, `frontend-infrastructure-hardening`, `frontend-shared-boundary-cleanup`,
|
> фичи: `frontend-docs-sync`, `frontend-infrastructure-hardening`, `frontend-shared-boundary-cleanup`,
|
||||||
> `frontend-test-hygiene`. Пункт P2 «Двойная система API-типов» — resolved (codegen unification).
|
> `frontend-test-hygiene`. Пункт P2 «Двойная система API-типов» — resolved (codegen unification).
|
||||||
> Остальные пункты ниже остаются открытыми и нуждаются в отдельных фичах.
|
> Остальные пункты ниже остаются открытыми и нуждаются в отдельных фичах.
|
||||||
|
>
|
||||||
|
> **Обновление 2026-06-25:** Проведён аудит бэкенда (`docs/research/2026-06-25-backend-audit.md`),
|
||||||
|
> выявивший 12 архитектурных проблем. В рамках эпика `backend-architecture-improvements` выполнены:
|
||||||
|
> - Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
||||||
|
> - Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
|
||||||
|
> - Domain exception hierarchy — `DomainException`, `EntityNotFoundException`, `MoexApiException`, `TBankApiException`
|
||||||
|
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
|
||||||
|
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
|
||||||
|
> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
|
||||||
|
|
||||||
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
|
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
|
||||||
frontend build, 94 backend-теста и 168 frontend-тестов.
|
frontend build, 94 backend-теста и 168 frontend-тестов.
|
||||||
@ -418,6 +427,8 @@ frontend build, 94 backend-теста и 168 frontend-тестов.
|
|||||||
|
|
||||||
### P2: декомпозировать крупные backend/frontend модули
|
### P2: декомпозировать крупные backend/frontend модули
|
||||||
|
|
||||||
|
- [x] **MoexClientService split (backend)** — God Service (423 строки, 11 методов) разделён на
|
||||||
|
`MoexHttpClient` + 5 доменных клиентов. `@Global()` убран. Реализовано в `backend-architecture-improvements`.
|
||||||
- `PortfolioService` объединяет CRUD, ownership, MOEX enrichment, кеширование, расчёты PnL,
|
- `PortfolioService` объединяет CRUD, ownership, MOEX enrichment, кеширование, расчёты PnL,
|
||||||
дивиденды и агрегированную аналитику.
|
дивиденды и агрегированную аналитику.
|
||||||
- Broker UI содержит крупные страницы, таблицы и монолитный test suite; это усложнит FSD-миграцию и
|
- Broker UI содержит крупные страницы, таблицы и монолитный test suite; это усложнит FSD-миграцию и
|
||||||
|
|||||||
49
docs/research/2026-06-25-backend-audit.md
Normal file
49
docs/research/2026-06-25-backend-audit.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Backend Architecture Audit 2026-06-25
|
||||||
|
|
||||||
|
## Что хорошо
|
||||||
|
|
||||||
|
- **Чистая модульная структура** — каждый домен в своём каталоге, NestJS-модули, DI
|
||||||
|
- **Global-модули** (`PrismaModule`, `CacheModule`, `MoexClientModule`) — не дублируются импорты
|
||||||
|
- **`CacheService.getOrFetch()`** — универсальный примитив кеширования, config-driven TTL
|
||||||
|
- **MOEX rate limiter + circuit breaker** — p-queue + ручной CB, защищают от внешнего API
|
||||||
|
- **TBank mappers layer** — proto wire type → domain type → DTO, правильная изоляция
|
||||||
|
- **Batch-оптимизация в PortfolioService** — `fetchShareBatch/fetchBondBatch/fetchDividendsBatch` собирает всё одним MOEX-запросом
|
||||||
|
- **Спецификации и тесты** — 25 spec-файлов, есть TDD-подход
|
||||||
|
|
||||||
|
## Ключевые архитектурные проблемы
|
||||||
|
|
||||||
|
| # | Проблема | Где | Описание |
|
||||||
|
|---|----------|-----|----------|
|
||||||
|
| 1 | **Дублирование Envelope DTO** | Все модули | Каждый модуль переопределяет свой `*ResponseMetaDto / *EnvelopeDto`. 6+ копий одной структуры |
|
||||||
|
| 2 | **Inconsistent caching** | `shares/shares.service.ts:getShare()` (raw) vs `bonds/bonds.service.ts:getBond()` (envelope) | Разное поведение одинаковых по смыслу методов |
|
||||||
|
| 3 | **Screener — in-memory filtering** | `securities/screener.service.ts` | При пустом массиве `getShareMarketDataBatch([])` тянет **все** бумаги с MOEX и фильтрует в памяти. Не масштабируется |
|
||||||
|
| 4 | **MoexClientService — God Service** | 11 публичных методов | Один сервис делает всё: search, shares, bonds, candles, history, dividends. Нарушает SRP |
|
||||||
|
| 5 | **Отсутствие доменных исключений** | Весь код | Нет иерархии исключений (`SecurityNotFoundException`, `PortfolioAccessDeniedException`, `MoexApiException`) — только generic `NotFoundException` / `ForbiddenException` |
|
||||||
|
| 6 | **Health check — заглушка** | `health/` | Не проверяет БД, MOEX, T-Bank. Только `{ status: 'ok', timestamp, uptime }` |
|
||||||
|
| 7 | **Prisma JSON как String** | `targets`, `tags`, `payment`, `price` | JSON хранится как `String` без валидации на уровне БД. Нет типизированных JSON-полей |
|
||||||
|
| 8 | **tbank/ — перегруженный модуль** | 22 файла, 8 сервисов | Один модуль содержит gRPC клиент, мапперы, CRUD, аналитику, синхронизацию. Можно разбить |
|
||||||
|
| 9 | **Auth глобальные гарды** | `JwtAuthGuard` + `RolesGuard` как `APP_GUARD` | Неявная защита всех эндпоинтов. Приходится использовать `@Public()` для открытых |
|
||||||
|
| 10 | **RequestLoggingMiddleware** | Подключён через `.use()`, а не через `configure()` | Работает, но не идёт через DI и не является частью модуля |
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### 🔴 Critical
|
||||||
|
|
||||||
|
1. **Shared envelope DTO** — вынести `ApiResponseMeta` и один generic `EnvelopeDto<T>` в `common/dto/`, убрать дублирование. Унифицировать формат ответа screener'а под общий envelope.
|
||||||
|
2. **Разделить MoexClientService** — выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient` — каждый со своим набором методов.
|
||||||
|
3. **Убрать inconsistency shares vs bonds** — `getShare()` должен возвращать `ApiEnvelopePayload` как и `getBond()`.
|
||||||
|
|
||||||
|
### 🟡 Medium
|
||||||
|
|
||||||
|
4. **Domain exception hierarchy** — создать `BaseDomainException` → `MoexApiException`, `SecurityNotFoundException`, `PortfolioAccessDeniedException`, `TBankApiException`. Добавить соответствующие фильтры в `HttpExceptionFilter`.
|
||||||
|
5. **Screener — server-side пагинация** — кешировать полный результат screener'а отдельным TTL.
|
||||||
|
6. **Health check прокачка** — добавить проверки Prisma (`db.ping()`), MOEX (`/health`), T-Bank gRPC connectivity.
|
||||||
|
7. **Prisma JSON → typed JSON** — использовать строки с `JSON.parse` в геттерах или перейти на отдельные таблицы.
|
||||||
|
8. **Отвязать `securities/` от прямого импорта `CacheModule`** — раз он `@Global()`, убрать лишний импорт.
|
||||||
|
|
||||||
|
### 🟢 Low / Nice to have
|
||||||
|
|
||||||
|
9. **RequestLoggingMiddleware** — перевести на `configure()` в `AppModule` для единообразия.
|
||||||
|
10. **Refactor `tbank/`** — выделить `broker-analytics` и `broker-sync` в отдельные модули, если будут расти.
|
||||||
|
11. **Swagger schema object для обёртки** — глобально настроить OpenAPI для автоматической обёртки `{ data, meta }`.
|
||||||
|
12. **Circuit breaker — вынести в декоратор** — обобщить в `@CircuitBreaker()` декоратор.
|
||||||
@ -90,6 +90,15 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
||||||
нормализация conventions
|
нормализация conventions
|
||||||
|
|
||||||
|
### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md)
|
||||||
|
|
||||||
|
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
||||||
|
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
|
||||||
|
- [x] Domain exception hierarchy — `DomainException`, `EntityNotFoundException`, `MoexApiException`, `TBankApiException`
|
||||||
|
- [x] Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
|
||||||
|
- [x] RequestLoggingMiddleware — подключение через `configure()` в AppModule
|
||||||
|
- [x] MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
|
||||||
|
|
||||||
## Кандидаты следующих фич
|
## Кандидаты следующих фич
|
||||||
|
|
||||||
- [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable,
|
- [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user