From 7d10ef8adfce76ad2ed6368e1022fbd3f6847a23 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 11 Oct 2026 10:44:30 +0300 Subject: [PATCH 1/2] feat: add stock screener --- .../logging/structured-logger.service.spec.ts | 2 + .../logging/structured-logger.service.ts | 1 + .../tbank/tbank-access.service.ts | 12 ++ src/integrations/tbank/tbank-market.types.ts | 2 + src/integrations/tbank/tbank-protocol.ts | 4 + src/integrations/tbank/tbank.client.spec.ts | 138 +++++++++++++ src/integrations/tbank/tbank.client.ts | 30 +++ .../instruments/instruments.module.ts | 4 + .../stock-screener.service.spec.ts | 106 ++++++++++ .../application/stock-screener.service.ts | 140 +++++++++++++ .../stock-screener-selection.rules.spec.ts | 188 ++++++++++++++++++ .../domain/stock-screener-selection.rules.ts | 106 ++++++++++ .../stocks/presentation/stock.controller.ts | 40 +++- .../stocks/presentation/stock.dto.ts | 82 ++++++++ test/stocks.e2e-spec.ts | 47 +++++ 15 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts create mode 100644 src/modules/investments/instruments/stocks/application/stock-screener.service.ts create mode 100644 src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts create mode 100644 src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts diff --git a/src/common/logging/structured-logger.service.spec.ts b/src/common/logging/structured-logger.service.spec.ts index 11ffad3..887f0cd 100644 --- a/src/common/logging/structured-logger.service.spec.ts +++ b/src/common/logging/structured-logger.service.spec.ts @@ -14,6 +14,7 @@ describe('StructuredLogger', () => { const logger = new StructuredLogger(pino as never); logger.info('event', { + asOf: '2026-10-10T12:00:00.000Z', userId: 'user', brokerageAccountId: 'account', password: 'secret', @@ -21,6 +22,7 @@ describe('StructuredLogger', () => { expect(pino.info).toHaveBeenCalledWith( { + asOf: '2026-10-10T12:00:00.000Z', userId: 'user', brokerageAccountId: 'account', }, diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index a9ddb8a..f37dd47 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -5,6 +5,7 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn'; type LogContext = Record; const allowedFields = new Set([ + 'asOf', 'attempts', 'brokerageAccountId', 'clientRequestId', diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index cef2a28..7d77079 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -133,6 +133,18 @@ export class TbankAccessService { 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( attempt: TbankSyncAttempt, accounts: TbankAccount[], diff --git a/src/integrations/tbank/tbank-market.types.ts b/src/integrations/tbank/tbank-market.types.ts index e931ccb..4acd08a 100644 --- a/src/integrations/tbank/tbank-market.types.ts +++ b/src/integrations/tbank/tbank-market.types.ts @@ -8,7 +8,9 @@ import type { export type TbankShare = TbankInstrument & { apiTradeAvailableFlag?: boolean; assetUid?: string; + buyAvailableFlag?: boolean; classCode?: string; + countryOfRisk?: string; currency?: string; figi?: string; first_1dayCandleDate?: TbankTimestamp; diff --git a/src/integrations/tbank/tbank-protocol.ts b/src/integrations/tbank/tbank-protocol.ts index ebfbaf8..2bd49b4 100644 --- a/src/integrations/tbank/tbank-protocol.ts +++ b/src/integrations/tbank/tbank-protocol.ts @@ -5,6 +5,10 @@ export enum InstrumentIdType { Uid = 'INSTRUMENT_ID_TYPE_UID', } +export enum TbankInstrumentStatus { + All = 'INSTRUMENT_STATUS_ALL', +} + export enum TbankInstrumentType { Bond = 'INSTRUMENT_TYPE_BOND', Share = 'INSTRUMENT_TYPE_SHARE', diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index a31f9b6..1948672 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -282,3 +282,141 @@ describe('TbankClient metadata retries', () => { 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]>; + }; + + beforeEach(() => { + call = jest.fn(); + logger = { + event: jest.fn]>(), + }; + 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', + ); + }); +}); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 0edaa43..c2df304 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -34,6 +34,7 @@ import { InstrumentIdType, TbankAccountStatus, TbankCandleInterval, + TbankInstrumentStatus, TbankInstrumentType, TbankOperationState, } from './tbank-protocol'; @@ -325,6 +326,35 @@ export class TbankClient { ).then((response) => response.instruments ?? []); } + async getAllShares(token: string): Promise { + 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 { return this.call<{ instruments?: TbankBond[] }>( token, diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index 9ba931e..c0b5a0c 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.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 { StockIncomeService } from './stocks/application/stock-income.service'; import { StockMarketService } from './stocks/application/stock-market.service'; +import { StockScreenerService } from './stocks/application/stock-screener.service'; import { StockController } from './stocks/presentation/stock.controller'; @Module({ @@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller'; controllers: [StockController, BondController], exports: [StockIncomeService, BondIncomeService], providers: [ + { provide: 'TBANK_ACCESS', useExisting: TbankAccessService }, BondIncomeService, BondMarketService, GetStockCompanyUseCase, StockIncomeService, StockMarketService, StockCatalogService, + StockScreenerService, BondCatalogService, ], }) diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts new file mode 100644 index 0000000..f933873 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts @@ -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 { + 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' })]); + }); +}); diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts new file mode 100644 index 0000000..a566bb6 --- /dev/null +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts @@ -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(); + + constructor( + @Inject('TBANK_ACCESS') private readonly access: TbankAccess, + private readonly client: TbankClient, + private readonly logger: StructuredLogger, + ) {} + + async search(userId: string, conditions: StockScreenerConditions): Promise { + 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 { + 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); + } + } +} diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts new file mode 100644 index 0000000..4271a6e --- /dev/null +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts @@ -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 { + 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']); + }); +}); diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts new file mode 100644 index 0000000..d6a659d --- /dev/null +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts @@ -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(); + + 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) + ); +} diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index dafde05..3c5e11b 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -1,15 +1,21 @@ import type { Request } from 'express'; import { + BadRequestException, + Body, Controller, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Post, Query, Req, UseGuards, + ValidationPipe, } 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 { 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 { StockIncomeService } from '../application/stock-income.service'; import { StockMarketService } from '../application/stock-market.service'; +import { StockScreenerService } from '../application/stock-screener.service'; import { StockCandlesQueryDto, StockCandlesResourceDto, @@ -26,6 +33,9 @@ import { StockPositionResourceDto, StockQuoteResourceDto, StockResourceDto, + StockScreenerCriteriaDto, + StockScreenerPageDto, + StockScreenerPageQueryDto, StockSearchQueryDto, StockSearchResourceDto, StockTickerParamsDto, @@ -42,6 +52,7 @@ export class StockController { private readonly market: StockMarketService, private readonly income: StockIncomeService, private readonly companyInfo: GetStockCompanyUseCase, + private readonly screener: StockScreenerService, ) {} @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') @ApiOkResponse({ type: StockResourceDto }) get( diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index ef8ee5f..c4d47f4 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsIn, + IsInt, IsOptional, IsString, Matches, MaxLength, + Min, MinLength, } from 'class-validator'; @@ -21,6 +23,86 @@ import { import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; 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 { @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 6bd6e54..4e05cf5 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -15,6 +15,7 @@ import { configureApp } from '../src/main'; 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 { 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'; describe('Stock HTTP contract', () => { @@ -25,6 +26,7 @@ describe('Stock HTTP contract', () => { search: jest.fn(), candles: jest.fn(), }; + const screener = { page: jest.fn(), search: jest.fn() }; const token = new JwtService().sign( { sub: 'stock-reader', type: 'access' }, { secret: process.env.JWT_SECRET }, @@ -37,6 +39,8 @@ describe('Stock HTTP contract', () => { .useValue(stocks) .overrideProvider(StockIncomeService) .useValue(stocks) + .overrideProvider(StockScreenerService) + .useValue(screener) .compile(); app = module.createNestApplication(); configureApp(app); @@ -51,12 +55,55 @@ describe('Stock HTTP contract', () => { }); stocks.search.mockResolvedValue([]); 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()); it('requires authentication before invoking the use case', async () => { await request(app.getHttpServer()).get(`/api/v1/stocks/${uid}`).expect(401); 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([ '/stocks/not-a-uid', '/stocks/by-ticker/%20', From 0ad84ff4f0b4712abc7e69dc299856f14af6eadd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 11 Oct 2026 11:36:51 +0300 Subject: [PATCH 2/2] ci: skip checks on PR metadata edits --- .gitea/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index a9b18c4..c5fd66b 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -4,7 +4,7 @@ on: push: branches: [develop, main] pull_request: - types: [opened, synchronize, reopened, edited] + types: [opened, synchronize, reopened] schedule: - cron: '47 5 * * *'