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); } } }