Expose stock search, market data, company information, dividends and portfolio history with exact instrument identity and verified ticker aliases. Include stock and bond position counts in account summaries.
550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
import {
|
|
Injectable,
|
|
ConflictException,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
import { LRUCache } from 'lru-cache';
|
|
import {
|
|
decimal,
|
|
decimalFromString,
|
|
divide,
|
|
formatDecimal,
|
|
multiply,
|
|
subtract,
|
|
} from './decimal';
|
|
import { PortfolioService } from './portfolio.service';
|
|
import { stockPosition } from './stock-position';
|
|
import { TbankAccessService } from './tbank-access.service';
|
|
import { TbankClient, TbankTimestamp } from './tbank.client';
|
|
import type {
|
|
TbankCandle,
|
|
TbankFundamentals,
|
|
TbankShare,
|
|
} from './tbank-market.types';
|
|
import {
|
|
StockCandlesDto,
|
|
StockCandlesQueryDto,
|
|
StockCompanyDto,
|
|
StockDividendDto,
|
|
StockDividendsDto,
|
|
StockDto,
|
|
StockMetricDto,
|
|
StockPositionDto,
|
|
StockQuoteDto,
|
|
StockSearchResultDto,
|
|
} from './stock.dto';
|
|
|
|
const dayMs = 86_400_000;
|
|
|
|
@Injectable()
|
|
export class StockService {
|
|
private readonly cache = new LRUCache<string, Promise<object>>({
|
|
max: 300,
|
|
ttl: 30_000,
|
|
});
|
|
constructor(
|
|
private readonly access: TbankAccessService,
|
|
private readonly tbank: TbankClient,
|
|
private readonly portfolios: PortfolioService,
|
|
) {}
|
|
|
|
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, 30);
|
|
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<TbankShare> {
|
|
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];
|
|
});
|
|
}
|
|
quote(userId: string, uid: string): Promise<StockQuoteDto> {
|
|
return this.cached(userId, `quote:${uid}`, async (token) => {
|
|
const share = await this.share(token, uid);
|
|
const unavailable: string[] = [];
|
|
const [last, tradingStatus] = await Promise.all([
|
|
optional(
|
|
() => this.tbank.getLastPrice(token, uid),
|
|
'price',
|
|
unavailable,
|
|
),
|
|
optional(
|
|
() => this.tbank.getTradingStatus(token, uid),
|
|
'status',
|
|
unavailable,
|
|
),
|
|
]);
|
|
const priceAt = timestamp(last?.time);
|
|
const currency = share.currency?.toUpperCase();
|
|
const price =
|
|
last?.price && currency
|
|
? { currency, value: formatDecimal(decimal(last.price)) }
|
|
: null;
|
|
const previous = priceAt
|
|
? await optional(
|
|
async () => {
|
|
const end = new Date(priceAt);
|
|
return previousClose(
|
|
await this.tbank.getCandles(
|
|
token,
|
|
uid,
|
|
new Date(end.valueOf() - 20 * dayMs),
|
|
end,
|
|
),
|
|
priceAt,
|
|
);
|
|
},
|
|
'comparison',
|
|
unavailable,
|
|
)
|
|
: null;
|
|
const previousPrice =
|
|
previous?.close && currency
|
|
? { currency, value: formatDecimal(decimal(previous.close)) }
|
|
: null;
|
|
const difference =
|
|
price && previousPrice
|
|
? subtract(
|
|
decimalFromString(price.value),
|
|
decimalFromString(previousPrice.value),
|
|
)
|
|
: null;
|
|
return {
|
|
price,
|
|
priceAt,
|
|
tradingStatus,
|
|
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
|
previousClose: previousPrice,
|
|
change:
|
|
difference && currency
|
|
? { currency, value: formatDecimal(difference) }
|
|
: null,
|
|
changePercent:
|
|
difference &&
|
|
previousPrice &&
|
|
decimalFromString(previousPrice.value).value > 0n
|
|
? formatDecimal(
|
|
divide(
|
|
multiply(difference, decimalFromString('100')),
|
|
decimalFromString(previousPrice.value),
|
|
6,
|
|
),
|
|
)
|
|
: null,
|
|
unavailable,
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
});
|
|
}
|
|
candles(
|
|
userId: string,
|
|
uid: string,
|
|
period: StockCandlesQueryDto['period'] = 'month',
|
|
): Promise<StockCandlesDto> {
|
|
return this.cached(userId, `candles:${uid}:${period}`, async (token) => {
|
|
const share = await this.share(token, uid);
|
|
const historyFrom = timestamp(share.first_1dayCandleDate);
|
|
const to = new Date();
|
|
const from = new Date(to);
|
|
from.setUTCMonth(
|
|
from.getUTCMonth() -
|
|
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
|
|
);
|
|
if (period === 'all') {
|
|
if (!historyFrom)
|
|
throw new ServiceUnavailableException({
|
|
code: 'STOCK_HISTORY_START_UNAVAILABLE',
|
|
});
|
|
from.setTime(Date.parse(historyFrom));
|
|
} else if (historyFrom && Date.parse(historyFrom) > from.valueOf())
|
|
from.setTime(Date.parse(historyFrom));
|
|
const collected = new Map<string, StockCandlesDto['candles'][number]>();
|
|
// Conservative one-year windows also work with older deployed RPC limits.
|
|
for (let start = from.valueOf(); start < to.valueOf();) {
|
|
const end = Math.min(start + 365 * dayMs, to.valueOf());
|
|
for (const candle of await this.tbank.getCandles(
|
|
token,
|
|
uid,
|
|
new Date(start),
|
|
new Date(end),
|
|
)) {
|
|
const time = timestamp(candle.time);
|
|
if (
|
|
time &&
|
|
candle.close &&
|
|
Date.parse(time) >= from.valueOf() - dayMs &&
|
|
Date.parse(time) <= to.valueOf()
|
|
)
|
|
collected.set(time, {
|
|
time,
|
|
close: formatDecimal(decimal(candle.close)),
|
|
isComplete: candle.isComplete === true,
|
|
});
|
|
}
|
|
start = end;
|
|
}
|
|
return {
|
|
candles: [...collected.values()].sort((a, b) =>
|
|
a.time.localeCompare(b.time),
|
|
),
|
|
currency: share.currency?.toUpperCase() || null,
|
|
historyFrom,
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
});
|
|
}
|
|
async position(userId: string, uid: string): Promise<StockPositionDto> {
|
|
return this.cached(userId, `position:${uid}`, async () =>
|
|
stockPosition(await this.portfolios.getAll(userId), uid),
|
|
);
|
|
}
|
|
company(userId: string, uid: string): Promise<StockCompanyDto> {
|
|
return this.cached(userId, `company:${uid}`, async (token) => {
|
|
const share = await this.share(token, uid);
|
|
const unavailable: string[] = [];
|
|
if (!share.assetUid)
|
|
return {
|
|
description: null,
|
|
metrics: [],
|
|
unavailable,
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
const [description, fundamentals] = await Promise.all([
|
|
optional(
|
|
() => this.tbank.getAssetDescription(token, share.assetUid!),
|
|
'description',
|
|
unavailable,
|
|
),
|
|
optional(
|
|
() => this.tbank.getFundamentals(token, share.assetUid!),
|
|
'fundamentals',
|
|
unavailable,
|
|
),
|
|
]);
|
|
return {
|
|
description,
|
|
metrics: metrics(fundamentals, share.sector),
|
|
unavailable,
|
|
observedAt: new Date().toISOString(),
|
|
};
|
|
});
|
|
}
|
|
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
|
|
return this.cached(userId, `dividends:${uid}`, async (token) => {
|
|
await this.share(token, uid);
|
|
const today = moscowDay(new Date().toISOString());
|
|
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
|
|
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
|
|
const unavailable: string[] = [];
|
|
const [events, position] = await Promise.all([
|
|
this.tbank.getDividends(token, uid, from, to),
|
|
optional(() => this.position(userId, uid), 'position', unavailable),
|
|
]);
|
|
const quantity =
|
|
position &&
|
|
position.accounts.length &&
|
|
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
|
|
? decimalFromString(position.quantity)
|
|
: null;
|
|
const dividends: StockDividendDto[] = events
|
|
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
|
.map((event) => {
|
|
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
|
const paymentDate =
|
|
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
|
const lastBuyDate =
|
|
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
|
const currency = event.dividendNet?.currency?.toUpperCase();
|
|
const perShare =
|
|
currency && event.dividendNet
|
|
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
|
: null;
|
|
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
|
const timing: StockDividendDto['timing'] =
|
|
(paymentDate ?? recordDate)
|
|
? (paymentDate ?? recordDate)! >= today
|
|
? 'upcoming'
|
|
: 'past'
|
|
: 'undated';
|
|
const estimatedAmount =
|
|
quantity &&
|
|
perShare &&
|
|
recordDate &&
|
|
recordDate >= today &&
|
|
timing === 'upcoming'
|
|
? {
|
|
currency: perShare.currency,
|
|
value: formatDecimal(
|
|
multiply(quantity, decimalFromString(perShare.value)),
|
|
),
|
|
}
|
|
: null;
|
|
return {
|
|
perShare,
|
|
recordDate,
|
|
lastBuyDate,
|
|
paymentDate,
|
|
timing,
|
|
estimatedAmount,
|
|
};
|
|
})
|
|
.sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? ''));
|
|
return {
|
|
dividends,
|
|
from,
|
|
to,
|
|
observedAt: new Date().toISOString(),
|
|
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
|
estimateUnavailable: unavailable.length > 0,
|
|
positionIsStale: position?.isStale ?? false,
|
|
};
|
|
});
|
|
}
|
|
private async share(token: string, uid: string): Promise<TbankShare> {
|
|
const share = await this.tbank.getShare(token, uid);
|
|
if (!share || share.uid !== uid) throw new NotFoundException();
|
|
return share;
|
|
}
|
|
private 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): TbankShare[] {
|
|
const seen = new Set<string>();
|
|
return found
|
|
.filter(
|
|
(share) => 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: TbankShare): 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),
|
|
};
|
|
}
|
|
function timestamp(value?: TbankTimestamp): string | null {
|
|
const seconds = Number(value?.seconds);
|
|
return Number.isFinite(seconds) && seconds > 0
|
|
? new Date(seconds * 1000).toISOString()
|
|
: null;
|
|
}
|
|
function moscowDay(iso: string): string {
|
|
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10);
|
|
}
|
|
export function previousClose(
|
|
candles: TbankCandle[],
|
|
priceAt: string,
|
|
): TbankCandle | null {
|
|
return (
|
|
candles
|
|
.filter((candle) => {
|
|
const time = timestamp(candle.time);
|
|
return (
|
|
candle.isComplete &&
|
|
candle.close &&
|
|
time &&
|
|
time.slice(0, 10) < moscowDay(priceAt)
|
|
);
|
|
})
|
|
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
|
|
null
|
|
);
|
|
}
|
|
async function optional<T>(
|
|
operation: () => Promise<T>,
|
|
name: string,
|
|
unavailable: string[],
|
|
): Promise<T | null> {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
if (!(error instanceof ServiceUnavailableException)) throw error;
|
|
unavailable.push(name);
|
|
return null;
|
|
}
|
|
}
|
|
function metrics(
|
|
values: TbankFundamentals | null,
|
|
sector?: string,
|
|
): StockMetricDto[] {
|
|
if (!values) return [];
|
|
const bank = sector?.toLowerCase() === 'financial';
|
|
const selection: [
|
|
keyof TbankFundamentals,
|
|
StockMetricDto['unit'],
|
|
StockMetricDto['period'],
|
|
][] = [
|
|
['marketCapitalization', 'money', null],
|
|
['peRatioTtm', 'ratio', 'TTM'],
|
|
...(bank
|
|
? ([
|
|
['priceToBookTtm', 'ratio', 'TTM'],
|
|
['roe', 'percent', null],
|
|
] as typeof selection)
|
|
: ([
|
|
['revenueTtm', 'money', 'TTM'],
|
|
['ebitdaTtm', 'money', 'TTM'],
|
|
] as typeof selection)),
|
|
['netIncomeTtm', 'money', 'TTM'],
|
|
...(!bank
|
|
? ([
|
|
['totalDebtMrq', 'money', 'MRQ'],
|
|
['netDebtToEbitda', 'ratio', null],
|
|
] as typeof selection)
|
|
: []),
|
|
];
|
|
return selection.flatMap(([key, unit, period]) => {
|
|
const value = values[key];
|
|
const currency = values.currency?.toUpperCase() || null;
|
|
return typeof value === 'number' &&
|
|
Number.isFinite(value) &&
|
|
(unit !== 'money' || currency)
|
|
? [
|
|
{
|
|
key,
|
|
value,
|
|
unit,
|
|
period,
|
|
currency: unit === 'money' ? currency : null,
|
|
},
|
|
]
|
|
: [];
|
|
});
|
|
}
|