feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -1,185 +1,87 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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;
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
StockDto,
|
||||
StockResourceDto,
|
||||
StockSearchResourceDto,
|
||||
} from '../presentation/stock.dto';
|
||||
|
||||
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);
|
||||
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<StockSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'share', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...(await this.get(userId, match.uid)),
|
||||
slug: ticker.trim().toUpperCase(),
|
||||
...result,
|
||||
data:
|
||||
result.data?.map((item) => {
|
||||
const short = stock(item);
|
||||
const detail = item.detail
|
||||
? stock({ ...item.detail, uid: item.uid })
|
||||
: short;
|
||||
const last = item.price;
|
||||
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),
|
||||
};
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
private tickerMatch(
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...stock(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
async getByTicker(
|
||||
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];
|
||||
});
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'share',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
? { ...stock(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user