feat: add stock screener #52
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [develop, main]
|
branches: [develop, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, edited]
|
types: [opened, synchronize, reopened]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '47 5 * * *'
|
- cron: '47 5 * * *'
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('StructuredLogger', () => {
|
|||||||
const logger = new StructuredLogger(pino as never);
|
const logger = new StructuredLogger(pino as never);
|
||||||
|
|
||||||
logger.info('event', {
|
logger.info('event', {
|
||||||
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
@@ -21,6 +22,7 @@ describe('StructuredLogger', () => {
|
|||||||
|
|
||||||
expect(pino.info).toHaveBeenCalledWith(
|
expect(pino.info).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
|||||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||||
|
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
|
'asOf',
|
||||||
'attempts',
|
'attempts',
|
||||||
'brokerageAccountId',
|
'brokerageAccountId',
|
||||||
'clientRequestId',
|
'clientRequestId',
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ export class TbankAccessService {
|
|||||||
return this.tokens.decrypt(connection.encryptedToken);
|
return this.tokens.decrypt(connection.encryptedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async current(userId: string): Promise<{ revision: string; token: string }> {
|
||||||
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||||
|
});
|
||||||
|
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||||
|
if (connection.invalidToken) {
|
||||||
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
|
}
|
||||||
|
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
|
||||||
|
}
|
||||||
|
|
||||||
async publishMembership(
|
async publishMembership(
|
||||||
attempt: TbankSyncAttempt,
|
attempt: TbankSyncAttempt,
|
||||||
accounts: TbankAccount[],
|
accounts: TbankAccount[],
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type {
|
|||||||
export type TbankShare = TbankInstrument & {
|
export type TbankShare = TbankInstrument & {
|
||||||
apiTradeAvailableFlag?: boolean;
|
apiTradeAvailableFlag?: boolean;
|
||||||
assetUid?: string;
|
assetUid?: string;
|
||||||
|
buyAvailableFlag?: boolean;
|
||||||
classCode?: string;
|
classCode?: string;
|
||||||
|
countryOfRisk?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
figi?: string;
|
figi?: string;
|
||||||
first_1dayCandleDate?: TbankTimestamp;
|
first_1dayCandleDate?: TbankTimestamp;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
|
|||||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentStatus {
|
||||||
|
All = 'INSTRUMENT_STATUS_ALL',
|
||||||
|
}
|
||||||
|
|
||||||
export enum TbankInstrumentType {
|
export enum TbankInstrumentType {
|
||||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||||
|
|||||||
@@ -282,3 +282,141 @@ describe('TbankClient metadata retries', () => {
|
|||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TbankClient share catalog', () => {
|
||||||
|
const token = 'token';
|
||||||
|
const catalogSize = 40;
|
||||||
|
let client: TbankClient;
|
||||||
|
let call: jest.Mock;
|
||||||
|
let logger: {
|
||||||
|
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
call = jest.fn();
|
||||||
|
logger = {
|
||||||
|
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||||
|
};
|
||||||
|
client = Object.assign(
|
||||||
|
Object.create(TbankClient.prototype) as TbankClient,
|
||||||
|
{ call, logger },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||||
|
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||||
|
call.mockResolvedValue({ instruments });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||||
|
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||||
|
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||||
|
const fitting = index === catalogSize - 1;
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: fitting,
|
||||||
|
buyAvailableFlag: fitting,
|
||||||
|
ticker: fitting ? 'FIT' : `T${index}`,
|
||||||
|
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
call.mockResolvedValue({ instruments: catalog });
|
||||||
|
|
||||||
|
const shares = await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(shares).toEqual(catalog);
|
||||||
|
expect(shares).toHaveLength(catalogSize);
|
||||||
|
expect(shares).toContainEqual({
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
ticker: 'FIT',
|
||||||
|
uid: 'uid-fit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||||
|
const error = new ServiceUnavailableException({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
call.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the response carries no instrument list', async () => {
|
||||||
|
call.mockResolvedValue({});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
const failure = await client
|
||||||
|
.getAllShares(token)
|
||||||
|
.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [
|
||||||
|
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||||
|
{ ticker: 'BROKEN' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'info',
|
||||||
|
'tbank.shares.catalog.fetched',
|
||||||
|
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'tbank.shares.catalog.unusable',
|
||||||
|
expect.objectContaining({ operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||||
|
'secret-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
InstrumentIdType,
|
InstrumentIdType,
|
||||||
TbankAccountStatus,
|
TbankAccountStatus,
|
||||||
TbankCandleInterval,
|
TbankCandleInterval,
|
||||||
|
TbankInstrumentStatus,
|
||||||
TbankInstrumentType,
|
TbankInstrumentType,
|
||||||
TbankOperationState,
|
TbankOperationState,
|
||||||
} from './tbank-protocol';
|
} from './tbank-protocol';
|
||||||
@@ -325,6 +326,35 @@ export class TbankClient {
|
|||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||||
|
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||||
|
token,
|
||||||
|
this.instruments,
|
||||||
|
'shares',
|
||||||
|
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||||
|
);
|
||||||
|
const { instruments } = response;
|
||||||
|
if (
|
||||||
|
!Array.isArray(instruments) ||
|
||||||
|
!instruments.length ||
|
||||||
|
instruments.some((item) => !item?.uid)
|
||||||
|
) {
|
||||||
|
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||||
|
asOf: new Date().toISOString(),
|
||||||
|
count: instruments.length,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
return instruments;
|
||||||
|
}
|
||||||
|
|
||||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||||
return this.call<{ instruments?: TbankBond[] }>(
|
return this.call<{ instruments?: TbankBond[] }>(
|
||||||
token,
|
token,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
|
|||||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||||
import { StockIncomeService } from './stocks/application/stock-income.service';
|
import { StockIncomeService } from './stocks/application/stock-income.service';
|
||||||
import { StockMarketService } from './stocks/application/stock-market.service';
|
import { StockMarketService } from './stocks/application/stock-market.service';
|
||||||
|
import { StockScreenerService } from './stocks/application/stock-screener.service';
|
||||||
import { StockController } from './stocks/presentation/stock.controller';
|
import { StockController } from './stocks/presentation/stock.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
exports: [StockIncomeService, BondIncomeService],
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
|
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
GetStockCompanyUseCase,
|
GetStockCompanyUseCase,
|
||||||
StockIncomeService,
|
StockIncomeService,
|
||||||
StockMarketService,
|
StockMarketService,
|
||||||
StockCatalogService,
|
StockCatalogService,
|
||||||
|
StockScreenerService,
|
||||||
BondCatalogService,
|
BondCatalogService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { StockScreenerService } from './stock-screener.service';
|
||||||
|
|
||||||
|
const token = 'secret-token';
|
||||||
|
const revision = 'revision-1';
|
||||||
|
const pageSize = 30;
|
||||||
|
|
||||||
|
function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
currency: 'RUB',
|
||||||
|
lot: 1,
|
||||||
|
name: `Share ${index}`,
|
||||||
|
ticker: `T${String(index).padStart(2, '0')}`,
|
||||||
|
uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StockScreenerService', () => {
|
||||||
|
const access = { current: jest.fn(), local: jest.fn() };
|
||||||
|
const client = { getAllShares: jest.fn() };
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
let service: StockScreenerService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
access.current.mockResolvedValue({ revision, token });
|
||||||
|
access.local.mockResolvedValue({ revision });
|
||||||
|
client.getAllShares.mockResolvedValue(
|
||||||
|
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
||||||
|
);
|
||||||
|
service = new StockScreenerService(access, client as never, logger as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
||||||
|
const first = await service.search('user-1', {});
|
||||||
|
const second = await service.page('user-1', first.searchId, 2);
|
||||||
|
|
||||||
|
expect(first.items).toHaveLength(pageSize);
|
||||||
|
expect(first.hasNext).toBe(true);
|
||||||
|
expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId });
|
||||||
|
expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]);
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a fresh result for every search and preserves a successful empty result', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]);
|
||||||
|
|
||||||
|
const first = await service.search('user-1', { sector: 'finance' });
|
||||||
|
const second = await service.search('user-1', { sector: 'finance' });
|
||||||
|
|
||||||
|
expect(first).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||||
|
expect(second).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||||
|
expect(second.searchId).not.toBe(first.searchId);
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not store a result when the catalog source fails', async () => {
|
||||||
|
client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException());
|
||||||
|
|
||||||
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => {
|
||||||
|
const first = await service.search('user-1', {});
|
||||||
|
access.local.mockResolvedValueOnce({ revision: 'revision-2' });
|
||||||
|
|
||||||
|
await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null-normalizes optional source fields and rejects malformed required fields', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([
|
||||||
|
share(1, {
|
||||||
|
countryOfRisk: undefined,
|
||||||
|
currency: undefined,
|
||||||
|
isin: undefined,
|
||||||
|
realExchange: undefined,
|
||||||
|
sector: undefined,
|
||||||
|
shareType: undefined,
|
||||||
|
ticker: undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const result = await service.search('user-1', {});
|
||||||
|
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
|
||||||
|
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||||
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the catalog logo name in screener results', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]);
|
||||||
|
|
||||||
|
const result = await service.search('user-1', {});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
||||||
|
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||||
|
import {
|
||||||
|
selectStockScreenerInstruments,
|
||||||
|
type StockScreenerConditions,
|
||||||
|
} from '../domain/stock-screener-selection.rules';
|
||||||
|
|
||||||
|
const MAX_SNAPSHOTS = 100;
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const MIN_PAGE = 1;
|
||||||
|
const MINIMUM_SNAPSHOT_COUNT = 1;
|
||||||
|
const SECONDS_PER_MINUTE = 60;
|
||||||
|
const SNAPSHOT_TTL_MINUTES = 5;
|
||||||
|
const MILLISECONDS_PER_SECOND = 1_000;
|
||||||
|
const SNAPSHOT_TTL_MS = SNAPSHOT_TTL_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
type StockScreenerItem = {
|
||||||
|
countryOfRisk: null | string;
|
||||||
|
currency: null | string;
|
||||||
|
instrumentUid: string;
|
||||||
|
isin: null | string;
|
||||||
|
logoName: null | string;
|
||||||
|
lot: null | number;
|
||||||
|
name: string;
|
||||||
|
realExchange: null | string;
|
||||||
|
sector: null | string;
|
||||||
|
shareType: null | string;
|
||||||
|
ticker: null | string;
|
||||||
|
};
|
||||||
|
type StockScreenerPage = {
|
||||||
|
asOf: string;
|
||||||
|
hasNext: boolean;
|
||||||
|
items: StockScreenerItem[];
|
||||||
|
page: number;
|
||||||
|
searchId: string;
|
||||||
|
};
|
||||||
|
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
||||||
|
type TbankAccess = {
|
||||||
|
current(userId: string): Promise<{ revision: string; token: string }>;
|
||||||
|
local(userId: string): Promise<{ revision: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StockScreenerService {
|
||||||
|
private readonly snapshots = new Map<string, Snapshot>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
|
||||||
|
private readonly client: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
|
||||||
|
const connection = await this.access.current(userId);
|
||||||
|
const instruments = await this.client.getAllShares(connection.token);
|
||||||
|
const asOf = new Date().toISOString();
|
||||||
|
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
|
||||||
|
.instruments.map((instrument) => this.item(instrument));
|
||||||
|
const searchId = randomUUID();
|
||||||
|
this.evict();
|
||||||
|
this.snapshots.set(searchId, {
|
||||||
|
asOf,
|
||||||
|
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
|
||||||
|
items: selected,
|
||||||
|
revision: connection.revision,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
|
||||||
|
return this.result(searchId, this.snapshots.get(searchId) as Snapshot, MIN_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async page(userId: string, searchId: string, page: number): Promise<StockScreenerPage> {
|
||||||
|
const snapshot = this.snapshots.get(searchId);
|
||||||
|
if (!snapshot || snapshot.expiresAt <= Date.now()) {
|
||||||
|
this.snapshots.delete(searchId);
|
||||||
|
this.logger.event('info', 'stock.screener.result.missing', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
if (snapshot.userId !== userId) {
|
||||||
|
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
const current = await this.access.local(userId);
|
||||||
|
if (snapshot.revision !== current.revision) {
|
||||||
|
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
return this.result(searchId, snapshot, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private result(searchId: string, snapshot: Snapshot, page: number): StockScreenerPage {
|
||||||
|
const start = (page - MIN_PAGE) * PAGE_SIZE;
|
||||||
|
if (!Number.isInteger(page) || page < MIN_PAGE || (start >= snapshot.items.length && !(page === MIN_PAGE && !snapshot.items.length))) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
const items = snapshot.items.slice(start, start + PAGE_SIZE);
|
||||||
|
return { asOf: snapshot.asOf, hasNext: start + PAGE_SIZE < snapshot.items.length, items, page, searchId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private item(instrument: TbankShare): StockScreenerItem {
|
||||||
|
if (!instrument.uid || !UUID_PATTERN.test(instrument.uid) || !instrument.name?.trim()) {
|
||||||
|
throw new ServiceUnavailableException();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
countryOfRisk: instrument.countryOfRisk ?? null,
|
||||||
|
currency: instrument.currency ?? null,
|
||||||
|
instrumentUid: instrument.uid,
|
||||||
|
isin: instrument.isin ?? null,
|
||||||
|
lot: instrument.lot ?? null,
|
||||||
|
logoName: instrument.brand?.logoName?.trim() || null,
|
||||||
|
name: instrument.name,
|
||||||
|
realExchange: instrument.realExchange ?? null,
|
||||||
|
sector: instrument.sector ?? null,
|
||||||
|
shareType: instrument.shareType ?? null,
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private evict(): void {
|
||||||
|
for (const [searchId, snapshot] of this.snapshots) {
|
||||||
|
if (snapshot.expiresAt <= Date.now()) this.snapshots.delete(searchId);
|
||||||
|
}
|
||||||
|
while (this.snapshots.size >= MAX_SNAPSHOTS) {
|
||||||
|
const searchId = this.snapshots.keys().next().value;
|
||||||
|
if (searchId && this.snapshots.size >= MINIMUM_SNAPSHOT_COUNT) this.snapshots.delete(searchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
||||||
|
|
||||||
|
const asOf = new Date('2026-10-10T12:00:00.000Z');
|
||||||
|
|
||||||
|
function share(overrides: Partial<TbankShare> = {}): TbankShare {
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
currency: 'RUB',
|
||||||
|
lot: 10,
|
||||||
|
name: 'North Star',
|
||||||
|
ticker: 'NSTR',
|
||||||
|
uid: 'uid-1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectStockScreenerInstruments', () => {
|
||||||
|
it('keeps only instruments matching every selected condition', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'A', sector: 'energy' }),
|
||||||
|
share({ currency: 'USD', isin: 'B', sector: 'energy', uid: 'uid-2' }),
|
||||||
|
share({ isin: 'C', name: 'Other', sector: 'energy', uid: 'uid-3' }),
|
||||||
|
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ currency: 'RUB', query: 'star', sector: 'energy' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||||
|
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||||
|
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
||||||
|
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||||
|
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||||
|
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||||
|
] as const)('excludes an unknown or unequal selected %s', (_condition, conditions, instrument) => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{ asOf, instruments: [instrument] },
|
||||||
|
conditions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an inclusive maximum lot and ignores lots when it is not selected', () => {
|
||||||
|
const instruments = [
|
||||||
|
share({ isin: 'equal', lot: 10, uid: 'equal' }),
|
||||||
|
share({ isin: 'large', lot: 11, uid: 'large' }),
|
||||||
|
share({ isin: 'unknown', lot: undefined, uid: 'unknown' }),
|
||||||
|
share({ isin: 'zero', lot: 0, uid: 'zero' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectStockScreenerInstruments({ asOf, instruments }, { maxLot: 10 }).instruments.map(
|
||||||
|
({ uid }) => uid,
|
||||||
|
),
|
||||||
|
).toEqual(['equal']);
|
||||||
|
expect(
|
||||||
|
selectStockScreenerInstruments({ asOf, instruments }, {}).instruments.map(({ uid }) => uid),
|
||||||
|
).toEqual(['equal', 'large', 'unknown', 'zero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the smallest matching uid as the complete representative of an ISIN', () => {
|
||||||
|
const chosen = share({ isin: ' same ', lot: 1, name: 'Chosen', uid: 'a' });
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'SAME', lot: 99, name: 'Sibling', uid: 'z' }), chosen],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([chosen]);
|
||||||
|
expect(result.instruments[0]).toMatchObject({ lot: 1, name: 'Chosen', uid: 'a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the sole matching mode of an issue', () => {
|
||||||
|
const matching = share({ isin: 'same', sector: 'energy', uid: 'matching' });
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'same', sector: 'finance', uid: 'smaller-uid' }), matching],
|
||||||
|
},
|
||||||
|
{ sector: 'energy' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([matching]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps different ISIN issues from one issuer separate', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'common', shareType: 'common', uid: 'common' }),
|
||||||
|
share({ isin: 'preferred', shareType: 'preferred', uid: 'preferred' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['common', 'preferred']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats instruments without an ISIN as separate issues', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: ' ', uid: 'first' }), share({ isin: undefined, uid: 'second' })],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves exact whitespace-differing UIDs as separate blank-ISIN issues', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: ' ', uid: 'uid' }),
|
||||||
|
share({ isin: undefined, uid: ' uid ' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual([' uid ', 'uid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the exact chosen uid for the later card link', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'same', uid: 'sibling' }), share({ isin: 'SAME', uid: 'chosen' })],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments[0]?.uid).toBe('chosen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[true, true, 1],
|
||||||
|
[true, false, 0],
|
||||||
|
[false, true, 0],
|
||||||
|
[false, false, 0],
|
||||||
|
[true, undefined, 0],
|
||||||
|
[undefined, true, 0],
|
||||||
|
] as const)(
|
||||||
|
'includes only confirmed buy and API availability: buy=%s api=%s',
|
||||||
|
(buyAvailableFlag, apiTradeAvailableFlag, expectedCount) => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{ asOf, instruments: [share({ apiTradeAvailableFlag, buyAvailableFlag })] },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toHaveLength(expectedCount);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('orders representatives by ticker and then uid before paging', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'c', ticker: 'B', uid: 'z' }),
|
||||||
|
share({ isin: 'a', ticker: 'A', uid: 'z' }),
|
||||||
|
share({ isin: 'b', ticker: 'A', uid: 'a' }),
|
||||||
|
share({ isin: 'empty', ticker: ' ', uid: 'empty' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['empty', 'a', 'z', 'z']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export type StockScreenerCatalog = {
|
||||||
|
asOf: Date;
|
||||||
|
instruments: TbankShare[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockScreenerConditions = {
|
||||||
|
countryOfRisk?: string;
|
||||||
|
currency?: string;
|
||||||
|
maxLot?: number;
|
||||||
|
query?: string;
|
||||||
|
realExchange?: string;
|
||||||
|
sector?: string;
|
||||||
|
shareType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IdentifiedShare = TbankShare & { uid: string };
|
||||||
|
const sortBefore = 0;
|
||||||
|
|
||||||
|
export function selectStockScreenerInstruments(
|
||||||
|
catalog: StockScreenerCatalog,
|
||||||
|
conditions: StockScreenerConditions,
|
||||||
|
): StockScreenerCatalog {
|
||||||
|
const representatives = new Map<string, IdentifiedShare>();
|
||||||
|
|
||||||
|
for (const instrument of catalog.instruments) {
|
||||||
|
if (!matches(instrument, conditions)) continue;
|
||||||
|
|
||||||
|
const { uid } = instrument;
|
||||||
|
const isin = instrument.isin?.trim().toUpperCase();
|
||||||
|
const issueKey = isin ? `isin:${isin}` : `uid:${uid}`;
|
||||||
|
const existing = representatives.get(issueKey);
|
||||||
|
if (!existing || uid.localeCompare(existing.uid) < sortBefore) {
|
||||||
|
representatives.set(issueKey, instrument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asOf: catalog.asOf,
|
||||||
|
instruments: [...representatives.values()].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(left.ticker?.trim() ?? '').localeCompare(right.ticker?.trim() ?? '') ||
|
||||||
|
left.uid.localeCompare(right.uid),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(
|
||||||
|
instrument: TbankShare,
|
||||||
|
conditions: StockScreenerConditions,
|
||||||
|
): instrument is IdentifiedShare {
|
||||||
|
if (
|
||||||
|
!instrument.uid?.trim() ||
|
||||||
|
instrument.buyAvailableFlag !== true ||
|
||||||
|
instrument.apiTradeAvailableFlag !== true
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
conditions.query !== undefined &&
|
||||||
|
!`${instrument.name ?? ''}\n${instrument.ticker ?? ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(conditions.query.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.sector !== undefined && instrument.sector !== conditions.sector) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.shareType !== undefined &&
|
||||||
|
instrument.shareType !== conditions.shareType
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.currency !== undefined &&
|
||||||
|
instrument.currency !== conditions.currency
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.realExchange !== undefined &&
|
||||||
|
instrument.realExchange !== conditions.realExchange
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.countryOfRisk !== undefined &&
|
||||||
|
instrument.countryOfRisk !== conditions.countryOfRisk
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumPositiveLot = 0;
|
||||||
|
return (
|
||||||
|
conditions.maxLot === undefined ||
|
||||||
|
(Number.isInteger(instrument.lot) &&
|
||||||
|
instrument.lot !== undefined &&
|
||||||
|
instrument.lot > minimumPositiveLot &&
|
||||||
|
instrument.lot <= conditions.maxLot)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||||
@@ -18,6 +24,7 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
|
|||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
import { StockIncomeService } from '../application/stock-income.service';
|
import { StockIncomeService } from '../application/stock-income.service';
|
||||||
import { StockMarketService } from '../application/stock-market.service';
|
import { StockMarketService } from '../application/stock-market.service';
|
||||||
|
import { StockScreenerService } from '../application/stock-screener.service';
|
||||||
import {
|
import {
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockCandlesResourceDto,
|
StockCandlesResourceDto,
|
||||||
@@ -26,6 +33,9 @@ import {
|
|||||||
StockPositionResourceDto,
|
StockPositionResourceDto,
|
||||||
StockQuoteResourceDto,
|
StockQuoteResourceDto,
|
||||||
StockResourceDto,
|
StockResourceDto,
|
||||||
|
StockScreenerCriteriaDto,
|
||||||
|
StockScreenerPageDto,
|
||||||
|
StockScreenerPageQueryDto,
|
||||||
StockSearchQueryDto,
|
StockSearchQueryDto,
|
||||||
StockSearchResourceDto,
|
StockSearchResourceDto,
|
||||||
StockTickerParamsDto,
|
StockTickerParamsDto,
|
||||||
@@ -42,6 +52,7 @@ export class StockController {
|
|||||||
private readonly market: StockMarketService,
|
private readonly market: StockMarketService,
|
||||||
private readonly income: StockIncomeService,
|
private readonly income: StockIncomeService,
|
||||||
private readonly companyInfo: GetStockCompanyUseCase,
|
private readonly companyInfo: GetStockCompanyUseCase,
|
||||||
|
private readonly screener: StockScreenerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -71,6 +82,33 @@ export class StockController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('screener/search')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ operationId: 'createStockScreenerSearch' })
|
||||||
|
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||||
|
searchScreener(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
||||||
|
criteria: StockScreenerCriteriaDto,
|
||||||
|
) {
|
||||||
|
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||||
|
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
return this.screener.search(request.user.sub, criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('screener/search/:searchId')
|
||||||
|
@ApiOperation({ operationId: 'readStockScreenerPage' })
|
||||||
|
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||||
|
pageScreener(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('searchId') searchId: string,
|
||||||
|
@Query() query: StockScreenerPageQueryDto,
|
||||||
|
) {
|
||||||
|
return this.screener.page(request.user.sub, searchId, query.page);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: StockResourceDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
get(
|
get(
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
@@ -21,6 +23,86 @@ import {
|
|||||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||||
|
|
||||||
const MIN_TICKER_LENGTH = 1;
|
const MIN_TICKER_LENGTH = 1;
|
||||||
|
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||||
|
const MIN_PAGE = 1;
|
||||||
|
|
||||||
|
export class StockScreenerCriteriaDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: MAX_SCREENER_QUERY_LENGTH })
|
||||||
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
|
value === '' ? undefined : value,
|
||||||
|
)
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
@MaxLength(MAX_SCREENER_QUERY_LENGTH)
|
||||||
|
query?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
sector?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
shareType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
realExchange?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
countryOfRisk?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: MIN_TICKER_LENGTH, type: 'integer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_TICKER_LENGTH)
|
||||||
|
maxLot?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerPageQueryDto {
|
||||||
|
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' })
|
||||||
|
@Transform(({ value }: { value: unknown }) => Number(value))
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_PAGE)
|
||||||
|
declare page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerItemDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
|
||||||
|
@ApiProperty() declare name: string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare ticker: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare isin: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare logoName: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare shareType: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare sector: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerPageDto {
|
||||||
|
@ApiProperty() declare searchId: string;
|
||||||
|
@ApiProperty({ format: 'date-time' }) declare asOf: string;
|
||||||
|
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
|
||||||
|
@ApiProperty() declare hasNext: boolean;
|
||||||
|
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { configureApp } from '../src/main';
|
|||||||
import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service';
|
import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service';
|
||||||
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
|
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
|
||||||
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
|
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
|
||||||
|
import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service';
|
||||||
|
|
||||||
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
|
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
|
||||||
describe('Stock HTTP contract', () => {
|
describe('Stock HTTP contract', () => {
|
||||||
@@ -25,6 +26,7 @@ describe('Stock HTTP contract', () => {
|
|||||||
search: jest.fn(),
|
search: jest.fn(),
|
||||||
candles: jest.fn(),
|
candles: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const screener = { page: jest.fn(), search: jest.fn() };
|
||||||
const token = new JwtService().sign(
|
const token = new JwtService().sign(
|
||||||
{ sub: 'stock-reader', type: 'access' },
|
{ sub: 'stock-reader', type: 'access' },
|
||||||
{ secret: process.env.JWT_SECRET },
|
{ secret: process.env.JWT_SECRET },
|
||||||
@@ -37,6 +39,8 @@ describe('Stock HTTP contract', () => {
|
|||||||
.useValue(stocks)
|
.useValue(stocks)
|
||||||
.overrideProvider(StockIncomeService)
|
.overrideProvider(StockIncomeService)
|
||||||
.useValue(stocks)
|
.useValue(stocks)
|
||||||
|
.overrideProvider(StockScreenerService)
|
||||||
|
.useValue(screener)
|
||||||
.compile();
|
.compile();
|
||||||
app = module.createNestApplication();
|
app = module.createNestApplication();
|
||||||
configureApp(app);
|
configureApp(app);
|
||||||
@@ -51,12 +55,55 @@ describe('Stock HTTP contract', () => {
|
|||||||
});
|
});
|
||||||
stocks.search.mockResolvedValue([]);
|
stocks.search.mockResolvedValue([]);
|
||||||
stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' });
|
stocks.getByTicker.mockResolvedValue({ instrumentUid: uid, slug: 'SBERP' });
|
||||||
|
screener.search.mockResolvedValue({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' });
|
||||||
});
|
});
|
||||||
afterAll(() => app.close());
|
afterAll(() => app.close());
|
||||||
it('requires authentication before invoking the use case', async () => {
|
it('requires authentication before invoking the use case', async () => {
|
||||||
await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401);
|
await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401);
|
||||||
expect(stocks.get).not.toHaveBeenCalled();
|
expect(stocks.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it.each([
|
||||||
|
[() => request(app.getHttpServer()).post('/api/v1/stocks/screener/search'), 'post'],
|
||||||
|
[() => request(app.getHttpServer()).get('/api/v1/stocks/screener/search/search-id?page=1'), 'get'],
|
||||||
|
])('requires authentication for screener route', async (send) => {
|
||||||
|
await send().expect(401);
|
||||||
|
expect(screener.search).not.toHaveBeenCalled();
|
||||||
|
expect(screener.page).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('validates screener input and returns raw snapshot pages', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/stocks/screener/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ query: ' ', unknown: 'value' })
|
||||||
|
.expect(400);
|
||||||
|
expect(screener.search).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/stocks/screener/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ query: '' })
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toEqual({ asOf: '2026-10-10T12:00:00.000Z', hasNext: false, items: [], page: 1, searchId: 'search-id' });
|
||||||
|
expect(screener.search).toHaveBeenCalledWith('stock-reader', {});
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'/api/v1/stocks/screener/search/search-id',
|
||||||
|
'/api/v1/stocks/screener/search/search-id?page=0',
|
||||||
|
'/api/v1/stocks/screener/search/search-id?page=one',
|
||||||
|
])('validates required screener page on %s', async (path) => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get(path)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(400);
|
||||||
|
expect(screener.page).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('reads a screener snapshot page with the verified identity', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/stocks/screener/search/search-id?page=2')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(screener.page).toHaveBeenCalledWith('stock-reader', 'search-id', 2);
|
||||||
|
});
|
||||||
it.each([
|
it.each([
|
||||||
'/stocks/not-a-uid',
|
'/stocks/not-a-uid',
|
||||||
'/stocks/by-ticker/%20',
|
'/stocks/by-ticker/%20',
|
||||||
|
|||||||
Reference in New Issue
Block a user