feat: add hourly portfolio sync history
This commit is contained in:
@@ -154,7 +154,7 @@ export class CatalogEnrichmentService {
|
||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
availability: row ? 'complete' : 'missing',
|
||||
fetchedAt: row?.fetchedAt ?? null,
|
||||
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||
freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
|
||||
work: byScope.get(catalogScope(input)) ?? null,
|
||||
}),
|
||||
};
|
||||
@@ -186,14 +186,10 @@ export class CatalogEnrichmentService {
|
||||
(entry) =>
|
||||
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||
);
|
||||
if (
|
||||
!tbankSourceNeedsRefresh(
|
||||
sourcePolicy(input.kind),
|
||||
row?.fetchedAt ?? null,
|
||||
new Date(),
|
||||
force,
|
||||
)
|
||||
) {
|
||||
const policy = sourcePolicy(input.kind);
|
||||
const needsRefresh = force || !row?.fetchedAt ||
|
||||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
||||
if (!needsRefresh) {
|
||||
continue;
|
||||
}
|
||||
const reserved = await this.repository.reserve(
|
||||
@@ -234,6 +230,7 @@ export class CatalogEnrichmentService {
|
||||
return reservation;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
|
||||
async section(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
@@ -241,6 +238,34 @@ export class CatalogEnrichmentService {
|
||||
section: 'company' | 'metrics' | 'quote',
|
||||
observe = false,
|
||||
) {
|
||||
if (section === 'company' && !observe) {
|
||||
const token = await this.access.onlineToken(userId);
|
||||
const instrument =
|
||||
instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, uid)
|
||||
: await this.tbank.getBond(token, uid);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
const [description, fundamentals] = instrument.assetUid
|
||||
? await Promise.all([
|
||||
this.tbank.getAssetDescription(token, instrument.assetUid),
|
||||
this.tbank.getFundamentals(token, instrument.assetUid),
|
||||
])
|
||||
: [null, null];
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/company/${uid}`,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: null,
|
||||
});
|
||||
return {
|
||||
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
|
||||
meta,
|
||||
unavailable: [],
|
||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
@@ -287,10 +312,7 @@ export class CatalogEnrichmentService {
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs:
|
||||
section === 'company'
|
||||
? tbankSourceTtlMs.company
|
||||
: tbankSourceTtlMs.quote,
|
||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
@@ -308,6 +330,7 @@ export class CatalogEnrichmentService {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||
private async load(
|
||||
input: CatalogSource,
|
||||
token: string,
|
||||
@@ -323,7 +346,7 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
|
||||
}
|
||||
if (input.kind === 'trading-status') {
|
||||
return {
|
||||
@@ -470,6 +493,7 @@ export class CatalogEnrichmentService {
|
||||
throw new Error('Unsupported catalog source');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
||||
private async drain() {
|
||||
try {
|
||||
while (this.queue.length) {
|
||||
@@ -600,39 +624,6 @@ function sourceInputs(
|
||||
})),
|
||||
);
|
||||
}
|
||||
function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
return {
|
||||
uid: value.uid,
|
||||
figi: value.figi,
|
||||
assetUid: value.assetUid,
|
||||
name: value.name,
|
||||
ticker: value.ticker,
|
||||
isin: value.isin,
|
||||
classCode: value.classCode,
|
||||
realExchange: value.realExchange,
|
||||
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||
currency: value.currency,
|
||||
shareType: (value as TbankShare).shareType,
|
||||
sector: value.sector,
|
||||
lot: value.lot,
|
||||
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||
nominal: bond.nominal,
|
||||
initialNominal: bond.initialNominal,
|
||||
aciValue: bond.aciValue,
|
||||
maturityDate: bond.maturityDate,
|
||||
callDate: bond.callDate,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||
floatingCouponFlag: bond.floatingCouponFlag,
|
||||
perpetualFlag: bond.perpetualFlag,
|
||||
amortizationFlag: bond.amortizationFlag,
|
||||
subordinatedFlag: bond.subordinatedFlag,
|
||||
bondType: bond.bondType,
|
||||
riskLevel: bond.riskLevel,
|
||||
};
|
||||
}
|
||||
|
||||
const previousCloseLookbackDays = 20;
|
||||
const previousCloseLookbackMs =
|
||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||
@@ -651,6 +642,40 @@ export type CatalogPayload = {
|
||||
price?: null | TbankLastPrice;
|
||||
tradingStatus?: null | string;
|
||||
};
|
||||
|
||||
function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
const share = value as TbankShare;
|
||||
return {
|
||||
uid: value.uid,
|
||||
figi: value.figi,
|
||||
assetUid: value.assetUid,
|
||||
name: value.name,
|
||||
ticker: value.ticker,
|
||||
isin: value.isin,
|
||||
classCode: value.classCode,
|
||||
realExchange: value.realExchange,
|
||||
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||
currency: value.currency,
|
||||
shareType: share.shareType,
|
||||
sector: value.sector,
|
||||
lot: value.lot,
|
||||
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||
nominal: bond.nominal,
|
||||
initialNominal: bond.initialNominal,
|
||||
aciValue: bond.aciValue,
|
||||
maturityDate: bond.maturityDate,
|
||||
callDate: bond.callDate,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||
floatingCouponFlag: bond.floatingCouponFlag,
|
||||
perpetualFlag: bond.perpetualFlag,
|
||||
amortizationFlag: bond.amortizationFlag,
|
||||
subordinatedFlag: bond.subordinatedFlag,
|
||||
bondType: bond.bondType,
|
||||
riskLevel: bond.riskLevel,
|
||||
};
|
||||
}
|
||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
if (
|
||||
[
|
||||
@@ -663,9 +688,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
) {
|
||||
return 'quote';
|
||||
}
|
||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
||||
return 'metadata';
|
||||
return 'stable';
|
||||
}
|
||||
function producerKey(input: CatalogSource) {
|
||||
return input.instrumentType === 'bond' &&
|
||||
|
||||
Reference in New Issue
Block a user