203 lines
6.9 KiB
TypeScript
203 lines
6.9 KiB
TypeScript
import {
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { LRUCache } from 'lru-cache';
|
|
import { formatDecimal } from '../../../../../common/decimal';
|
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
|
import {
|
|
TbankAccessService,
|
|
TbankClient,
|
|
} from '../../../../../integrations/tbank/tbank.api';
|
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
import { optional, timestamp } from '../../application/instrument-market';
|
|
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
|
|
|
|
const SEARCH_RESULTS_LIMIT = 30;
|
|
|
|
type IdentifiedShare = TbankShare & { uid: string };
|
|
|
|
@Injectable()
|
|
export class StockCatalogService {
|
|
private readonly cache = new LRUCache<string, Promise<object>>({
|
|
max: 300,
|
|
ttl: 30_000,
|
|
});
|
|
constructor(
|
|
private readonly access: TbankAccessService,
|
|
private readonly tbank: TbankClient,
|
|
) {}
|
|
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
|
|
return this.cached(userId, `search:${query}`, async (token) => {
|
|
const found = await this.tbank.findShares(token, query);
|
|
const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
|
|
if (!shares.length) return [];
|
|
// One market-data request for the list; failed enrichment must not hide a catalog result.
|
|
const [details, prices] = await Promise.all([
|
|
Promise.all(
|
|
shares.map(
|
|
async (share) =>
|
|
(await optional(
|
|
() =>
|
|
this.get(userId, share.uid).catch((error: unknown) => {
|
|
if (error instanceof NotFoundException) return stock(share);
|
|
throw error;
|
|
}),
|
|
'details',
|
|
[],
|
|
)) ?? stock(share),
|
|
),
|
|
),
|
|
optional(
|
|
() =>
|
|
this.tbank.getLastPrices(
|
|
token,
|
|
shares.map((share) => share.uid),
|
|
),
|
|
'prices',
|
|
[],
|
|
),
|
|
]);
|
|
return details.map((detail, index) => {
|
|
const short = stock(shares[index]);
|
|
const last = prices?.find(
|
|
(price) => price.instrumentUid === detail.instrumentUid,
|
|
);
|
|
return {
|
|
...detail,
|
|
classCode: detail.classCode ?? short.classCode,
|
|
isin: detail.isin ?? short.isin,
|
|
apiTradeAvailable: short.apiTradeAvailable,
|
|
price:
|
|
last?.price && detail.currency
|
|
? {
|
|
currency: detail.currency,
|
|
value: formatDecimal(decimal(last.price)),
|
|
}
|
|
: null,
|
|
priceAt: timestamp(last?.time),
|
|
};
|
|
});
|
|
});
|
|
}
|
|
get(userId: string, uid: string): Promise<StockDto> {
|
|
return this.cached(userId, `share:${uid}`, async (token) => {
|
|
const result = stock(await this.share(token, uid));
|
|
if (
|
|
!result.ticker ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker)
|
|
)
|
|
return result;
|
|
try {
|
|
const match = await this.tickerMatch(userId, result.ticker);
|
|
if (match.uid === uid) result.slug = result.ticker.toUpperCase();
|
|
} catch (error) {
|
|
// A readable alias is optional for an exact UID URL, never a reason to substitute another listing.
|
|
if (
|
|
!(error instanceof NotFoundException) &&
|
|
!(error instanceof ConflictException) &&
|
|
!(error instanceof ServiceUnavailableException)
|
|
)
|
|
throw error;
|
|
}
|
|
return result;
|
|
});
|
|
}
|
|
async getByTicker(userId: string, ticker: string): Promise<StockDto> {
|
|
const match = await this.tickerMatch(userId, ticker);
|
|
return {
|
|
...(await this.get(userId, match.uid)),
|
|
slug: ticker.trim().toUpperCase(),
|
|
};
|
|
}
|
|
private tickerMatch(
|
|
userId: string,
|
|
ticker: string,
|
|
): Promise<IdentifiedShare> {
|
|
const normalized = ticker.trim().toUpperCase();
|
|
return this.cached(userId, `ticker:${normalized}`, async (token) => {
|
|
const found = await this.tbank.findShares(token, normalized);
|
|
const matches = selectShares(
|
|
found.filter(
|
|
(item) => item.ticker?.trim().toUpperCase() === normalized,
|
|
),
|
|
normalized,
|
|
);
|
|
if (!matches.length) throw new NotFoundException();
|
|
if (matches.length > 1)
|
|
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
|
|
return matches[0];
|
|
});
|
|
}
|
|
async share(token: string, uid: string): Promise<IdentifiedShare> {
|
|
const share = await this.tbank.getShare(token, uid);
|
|
if (!share || share.uid !== uid) throw new NotFoundException();
|
|
return { ...share, uid };
|
|
}
|
|
cached<T extends object>(
|
|
userId: string,
|
|
key: string,
|
|
operation: (token: string) => Promise<T>,
|
|
): Promise<T> {
|
|
return this.access.withConnection(userId, ({ token, generation }) => {
|
|
const cacheKey = JSON.stringify([userId, generation, key]);
|
|
const cached = this.cache.get(cacheKey);
|
|
if (cached) return cached as Promise<T>;
|
|
const pending = operation(token);
|
|
this.cache.set(cacheKey, pending);
|
|
return pending.catch((error) => {
|
|
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
|
|
throw error;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
|
|
const seen = new Set<string>();
|
|
return found
|
|
.filter(
|
|
(share): share is IdentifiedShare =>
|
|
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
|
|
)
|
|
.sort(
|
|
(a, b) =>
|
|
Number(b.apiTradeAvailableFlag === true) -
|
|
Number(a.apiTradeAvailableFlag === true) ||
|
|
Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
|
|
Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
|
|
Number(b.classCode === 'TQBR') - Number(a.classCode === 'TQBR') ||
|
|
a.uid.localeCompare(b.uid),
|
|
)
|
|
.filter((share) => {
|
|
// A missing ISIN must not collapse unrelated catalog instruments.
|
|
const key = share.isin?.trim()
|
|
? `isin:${share.isin.trim().toUpperCase()}`
|
|
: `uid:${share.uid}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function stock(share: IdentifiedShare): StockDto {
|
|
return {
|
|
instrumentUid: share.uid,
|
|
slug: share.uid,
|
|
name: share.name?.trim() || share.ticker || share.uid,
|
|
ticker: share.ticker?.trim() || null,
|
|
isin: share.isin?.trim() || null,
|
|
classCode: share.classCode?.trim() || null,
|
|
realExchange: share.realExchange || null,
|
|
apiTradeAvailable: share.apiTradeAvailableFlag ?? null,
|
|
logoName: share.brand?.logoName?.trim() || null,
|
|
currency: share.currency?.toUpperCase() || null,
|
|
shareType: share.shareType || null,
|
|
sector: share.sector || null,
|
|
lot: share.lot && share.lot > 0 ? share.lot : null,
|
|
historyFrom: timestamp(share.first_1dayCandleDate),
|
|
};
|
|
}
|