Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
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 = {
|
|
local(userId: string): Promise<{ revision: string }>;
|
|
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
|
|
};
|
|
|
|
@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 { instruments, revision } = await this.access.online(userId, async (connection) => ({
|
|
instruments: await this.client.getAllShares(connection.token),
|
|
revision: connection.revision,
|
|
}));
|
|
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,
|
|
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);
|
|
}
|
|
}
|
|
}
|