328 lines
9.9 KiB
TypeScript
328 lines
9.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
add,
|
|
compare,
|
|
decimalFromString,
|
|
formatDecimal,
|
|
subtract,
|
|
} from '../../../common/decimal';
|
|
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
|
import {
|
|
BrokerageOperationDto,
|
|
BrokerageOperationsService,
|
|
} from '../operations/operations.api';
|
|
import {
|
|
IncomeAnalyticsEntryDto,
|
|
IncomeAnalyticsResourceDto,
|
|
IncomeComparisonDto,
|
|
IncomeCurrencySummaryDto,
|
|
IncomeDependencyDto,
|
|
IncomeInstrumentDto,
|
|
IncomeSummaryDto,
|
|
IncomeSummaryResourceDto,
|
|
} from './income-analytics.dto';
|
|
import {
|
|
IncomeBucketGranularity,
|
|
planIncomePeriod,
|
|
rangeBuckets,
|
|
} from './income-period';
|
|
|
|
const STALE_FRESHNESS_MS = 0;
|
|
|
|
type Total = {
|
|
coupon: ReturnType<typeof decimalFromString>;
|
|
dividend: ReturnType<typeof decimalFromString>;
|
|
};
|
|
|
|
@Injectable()
|
|
export class IncomeAnalyticsService {
|
|
constructor(private readonly operations: BrokerageOperationsService) {}
|
|
|
|
async summary(
|
|
userId: string,
|
|
accountId: string,
|
|
observe = false,
|
|
): Promise<IncomeSummaryResourceDto> {
|
|
let plan = planIncomePeriod(undefined, undefined);
|
|
const [selected] = await this.operations.collectRanges(
|
|
userId,
|
|
accountId,
|
|
(openedAt) => {
|
|
plan = planIncomePeriod(undefined, undefined, openedAt);
|
|
return [plan.selected];
|
|
},
|
|
observe,
|
|
);
|
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
|
assert(selected.meta.lastSuccessfulAt);
|
|
return {
|
|
data: summaryResponse(
|
|
accountId,
|
|
plan.selected,
|
|
qualifying(selected.items),
|
|
selected.meta.lastSuccessfulAt,
|
|
),
|
|
meta: selected.meta,
|
|
};
|
|
}
|
|
|
|
async analytics(
|
|
userId: string,
|
|
accountId: string,
|
|
from: string | undefined,
|
|
to: string | undefined,
|
|
observe = false,
|
|
): Promise<IncomeAnalyticsResourceDto> {
|
|
let plan = planIncomePeriod(from, to);
|
|
const periods = await this.operations.collectRanges(
|
|
userId,
|
|
accountId,
|
|
(openedAt) => {
|
|
plan = planIncomePeriod(from, to, openedAt);
|
|
return [
|
|
plan.selected,
|
|
plan.previous,
|
|
...(plan.yearAgo ? [plan.yearAgo] : []),
|
|
];
|
|
},
|
|
observe,
|
|
);
|
|
const [current, prior, year] = periods;
|
|
// Preserve the legacy order while consumers migrate to explicit roles.
|
|
const dependencies: IncomeDependencyDto[] = [
|
|
{ ...current.meta, role: 'selected' },
|
|
{ ...prior.meta, role: 'previous' },
|
|
{
|
|
...(plan.yearAgo && year
|
|
? year.meta
|
|
: investmentResourceMeta({
|
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
|
dataRevision: 'unavailable',
|
|
availability: 'missing',
|
|
fetchedAt: null,
|
|
freshForMs: 0,
|
|
work: null,
|
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
|
})),
|
|
role: 'year',
|
|
},
|
|
];
|
|
const states = periods.map((period) => period.meta.sync);
|
|
const work =
|
|
states.find((state) => state.state === 'running') ??
|
|
states.find((state) => state.state === 'failed');
|
|
const meta = {
|
|
...investmentResourceMeta({
|
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
|
dataRevision: current.meta.dataRevision,
|
|
availability:
|
|
current.items === null
|
|
? 'temporarily-unavailable'
|
|
: 'available',
|
|
fetchedAt: current.meta.lastSuccessfulAt
|
|
? new Date(current.meta.lastSuccessfulAt)
|
|
: null,
|
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
|
work: work?.id ? { ...work, id: work.id } : null,
|
|
coverage: current.meta.coverage,
|
|
dependencies,
|
|
}),
|
|
dependencies,
|
|
};
|
|
if (current.items === null) return { data: null, meta };
|
|
assert(current.meta.lastSuccessfulAt);
|
|
const selected = qualifying(current.items).map((entry) => ({
|
|
...entry,
|
|
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
|
}));
|
|
return {
|
|
data: {
|
|
...summaryResponse(
|
|
accountId,
|
|
plan.selected,
|
|
selected,
|
|
current.meta.lastSuccessfulAt,
|
|
),
|
|
previous:
|
|
prior.items === null
|
|
? null
|
|
: comparison(selected, plan.previous, qualifying(prior.items)),
|
|
yearComparison:
|
|
plan.yearAgo && year?.items !== null && year
|
|
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
|
: null,
|
|
bucketGranularity: plan.granularity,
|
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
|
instruments: instruments(selected),
|
|
entries: selected,
|
|
},
|
|
meta,
|
|
};
|
|
}
|
|
}
|
|
|
|
function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
|
|
return entries.filter(
|
|
(entry) =>
|
|
Boolean(entry.payment) &&
|
|
[TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
|
|
);
|
|
}
|
|
|
|
function summaryResponse(
|
|
accountId: string,
|
|
period: DateOnlyRange,
|
|
entries: BrokerageOperationDto[],
|
|
calculatedAt: string,
|
|
): IncomeSummaryDto {
|
|
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
|
|
}
|
|
|
|
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
|
const values = new Map<string, Total>();
|
|
for (const entry of entries) {
|
|
if (!entry.payment) continue;
|
|
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
|
const value = decimalFromString(entry.payment.value);
|
|
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
|
total.coupon = add(total.coupon, value);
|
|
} else total.dividend = add(total.dividend, value);
|
|
values.set(entry.payment.currency, total);
|
|
}
|
|
return [...values]
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([currency, total]) => presentTotal(currency, total));
|
|
}
|
|
|
|
function comparison(
|
|
selectedEntries: BrokerageOperationDto[],
|
|
comparedPeriod: DateOnlyRange,
|
|
comparedEntries: BrokerageOperationDto[],
|
|
): IncomeComparisonDto {
|
|
const selected = new Map(
|
|
totals(selectedEntries).map((total) => [total.currency, total]),
|
|
);
|
|
const compared = new Map(
|
|
totals(comparedEntries).map((total) => [total.currency, total]),
|
|
);
|
|
const currencies = [
|
|
...new Set([...selected.keys(), ...compared.keys()]),
|
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
].sort();
|
|
return {
|
|
period: comparedPeriod,
|
|
totalsByCurrency: currencies.map((currency) => {
|
|
const current =
|
|
selected.get(currency) ?? presentTotal(currency, zeroTotal());
|
|
const prior =
|
|
compared.get(currency) ?? presentTotal(currency, zeroTotal());
|
|
return {
|
|
currency,
|
|
selected: current,
|
|
compared: prior,
|
|
difference: difference(current, prior),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
function buckets(
|
|
range: DateOnlyRange,
|
|
granularity: IncomeBucketGranularity,
|
|
entries: BrokerageOperationDto[],
|
|
) {
|
|
const datedEntries = entries.map((entry) => ({
|
|
entry,
|
|
date: moscowDate(new Date(entry.occurredAt)),
|
|
}));
|
|
return rangeBuckets(range, granularity).map((period) => ({
|
|
period,
|
|
totalsByCurrency: totals(
|
|
datedEntries
|
|
.filter(({ date }) => date >= period.from && date <= period.to)
|
|
.map(({ entry }) => entry),
|
|
),
|
|
}));
|
|
}
|
|
|
|
function instruments(
|
|
entries: IncomeAnalyticsEntryDto[],
|
|
): IncomeInstrumentDto[] {
|
|
const groups = new Map<string, IncomeAnalyticsEntryDto[]>();
|
|
for (const entry of entries) {
|
|
const group = groups.get(entry.instrumentKey);
|
|
if (group) group.push(entry);
|
|
else groups.set(entry.instrumentKey, [entry]);
|
|
}
|
|
return [...groups]
|
|
.map(([key, group]) => {
|
|
const first = group[0];
|
|
const [total] = totals(group);
|
|
return {
|
|
key,
|
|
figi: first.figi,
|
|
instrumentUid: first.instrumentUid,
|
|
ticker: first.ticker,
|
|
instrumentType: first.instrumentType,
|
|
displayName: first.ticker ?? (first.description || first.type),
|
|
coupon: total.coupon,
|
|
dividend: total.dividend,
|
|
total: total.total,
|
|
receiptCount: group.length,
|
|
};
|
|
})
|
|
.sort(
|
|
(left, right) =>
|
|
compare(
|
|
decimalFromString(right.total.value),
|
|
decimalFromString(left.total.value),
|
|
) || left.key.localeCompare(right.key),
|
|
);
|
|
}
|
|
|
|
function instrumentKey(entry: BrokerageOperationDto): string {
|
|
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
|
|
if (entry.figi) return `figi:${entry.figi}`;
|
|
if (entry.ticker || entry.instrumentType) {
|
|
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
|
|
}
|
|
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
|
}
|
|
|
|
function difference(
|
|
left: IncomeCurrencySummaryDto,
|
|
right: IncomeCurrencySummaryDto,
|
|
): IncomeCurrencySummaryDto {
|
|
return presentTotal(left.currency, {
|
|
coupon: subtract(
|
|
decimalFromString(left.coupon.value),
|
|
decimalFromString(right.coupon.value),
|
|
),
|
|
dividend: subtract(
|
|
decimalFromString(left.dividend.value),
|
|
decimalFromString(right.dividend.value),
|
|
),
|
|
});
|
|
}
|
|
|
|
function zeroTotal(): Total {
|
|
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
|
}
|
|
|
|
function presentTotal(
|
|
currency: string,
|
|
value: Total,
|
|
): IncomeCurrencySummaryDto {
|
|
const total = add(value.coupon, value.dividend);
|
|
return {
|
|
currency,
|
|
coupon: { currency, value: formatDecimal(value.coupon) },
|
|
dividend: { currency, value: formatDecimal(value.dividend) },
|
|
total: { currency, value: formatDecimal(total) },
|
|
};
|
|
}
|