import { Injectable, NotFoundException } from '@nestjs/common'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol'; import { metadataKey, TbankClient, type TbankPortfolio } from '../../../../integrations/tbank/tbank.api'; import { PositionClassificationRepository } from '../infrastructure/position-classification.repository'; export type Classification = { issuer: null | { id: string; name: string }; sector: null | string }; type Position = NonNullable[number]; type Instrument = { assetUid?: string; figi?: string; sector?: string; uid?: string }; type Row = NonNullable>>; const dayMs = MILLISECONDS_PER_DAY; type Update = Parameters[1]; @Injectable() export class PositionClassificationService { private readonly instruments = new Map>(); private readonly assets = new Map>(); constructor( private readonly tbank: TbankClient, private readonly repository: PositionClassificationRepository, private readonly logger: StructuredLogger, ) {} async refresh(token: string, positions: Position[], walkStartedAt: Date): Promise> { const result = new Map(); const assets = new Map>(); for (const position of positions) { if (!['bond', 'etf', 'share'].includes(position.instrumentType ?? '')) continue; const key = metadataKey(position); if (result.has(key)) continue; result.set(key, await this.refreshOne(token, position, key, walkStartedAt, assets)); } return result; } private async refreshOne(token: string, position: Position, key: string, startedAt: Date, assets: Map>): Promise { let saved: null | Row = null; try { saved = await this.repository.read(key); const update: Update = {}; const due = (at: Date | null | undefined, value: null | string | undefined) => !value || !at || startedAt.valueOf() - at.valueOf() >= dayMs; const sectorDue = due(saved?.sectorFetchedAt, saved?.sector); const brandDue = due(saved?.brandFetchedAt, saved?.brandUid && saved.brandName); const assetUid = sectorDue || (brandDue && !saved?.assetUid) ? await this.refreshInstrument(token, position, key, sectorDue, saved?.assetUid ?? null, update) : saved?.assetUid ?? null; const assetChanged = 'assetUid' in update && update.assetUid !== saved?.assetUid; if (assetChanged) { update.brandUid = null; update.brandName = null; update.brandFetchedAt = new Date(); } if (brandDue || assetChanged) { await this.refreshBrand(token, assetUid, update, assets); } if (Object.keys(update).length) saved = await this.repository.save(key, update); } catch (error) { if (this.invalidToken(error)) throw error; this.failed(); } return { issuer: saved?.brandUid && saved.brandName ? { id: saved.brandUid, name: saved.brandName } : null, sector: saved?.sector ?? null, }; } private async refreshInstrument(token: string, position: Position, key: string, sectorDue: boolean, savedUid: null | string, update: Update) { try { const lookup = await this.instrument(token, position, key); if (lookup && !this.matches(lookup)) return savedUid; if (lookup) return this.acceptInstrument(lookup.found, savedUid, sectorDue, update); if (sectorDue) this.clearSector(update); } catch (error) { if (this.invalidToken(error)) throw error; if (error instanceof NotFoundException) { if (sectorDue) this.clearSector(update); } else this.failed(); } return savedUid; } private clearSector(update: Update) { update.sector = null; update.sectorFetchedAt = new Date(); } private acceptInstrument(found: Instrument, savedUid: null | string, sectorDue: boolean, update: Update) { const assetUid = found.assetUid?.trim() || null; if (assetUid !== savedUid) update.assetUid = assetUid; if (sectorDue) this.setSector(update, found.sector); return assetUid; } private setSector(update: Update, sector: string | undefined) { update.sector = sector?.trim() || null; update.sectorFetchedAt = new Date(); } private async refreshBrand(token: string, assetUid: null | string, update: Update, assets: Map>) { if (!assetUid) { if ('assetUid' in update || 'sectorFetchedAt' in update) this.clearBrand(update); return; } try { const asset = await this.cachedAsset(token, assetUid, assets); if (asset && asset.assetUid !== assetUid) return; const uid = asset?.brand?.uid?.trim() || null; const name = asset?.brand?.name?.trim() || null; update.brandUid = uid && name ? uid : null; update.brandName = uid && name ? name : null; update.brandFetchedAt = new Date(); } catch (error) { if (this.invalidToken(error)) throw error; if (error instanceof NotFoundException) this.clearBrand(update); else this.failed(); } } private clearBrand(update: Update) { update.brandUid = null; update.brandName = null; update.brandFetchedAt = new Date(); } private matches(lookup: { found: Instrument; id: string; idType: InstrumentIdType }) { return (lookup.idType === InstrumentIdType.Uid ? lookup.found.uid : lookup.found.figi) === lookup.id; } private invalidToken(error: unknown) { return error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken); } private async instrument(token: string, position: Position, key: string) { return this.inFlight(this.instruments, key, async () => { const lookup = (id: string, type: InstrumentIdType) => { switch (position.instrumentType) { case 'bond': return this.tbank.getBond(token, id, type, true); case 'share': return this.tbank.getShare(token, id, type, true); default: return this.tbank.getEtf(token, id, type, true); } }; const uid = position.instrumentUid?.trim(); const figi = position.figi?.trim(); if (uid) { try { const found = await lookup(uid, InstrumentIdType.Uid); if (found) return { found, id: uid, idType: InstrumentIdType.Uid }; } catch (error) { if (!(error instanceof NotFoundException)) throw error; } } if (figi) { const found = await lookup(figi, InstrumentIdType.Figi); return found ? { found, id: figi, idType: InstrumentIdType.Figi } : null; } return null; }); } private asset(token: string, uid: string) { return this.inFlight(this.assets, uid, () => this.tbank.getAssetBrand(token, uid)); } private cachedAsset(token: string, uid: string, assets: Map>) { let pending = assets.get(uid); if (!pending) { pending = this.asset(token, uid); assets.set(uid, pending); } return pending; } private inFlight(map: Map>, key: string, run: () => Promise): Promise { const pending = map.get(key); if (pending) return pending; const promise = run().finally(() => map.delete(key)); map.set(key, promise); return promise; } private failed() { this.logger.event('warn', 'portfolio.classification.failed', { code: TbankErrorCode.Unavailable }); } }