Files
backend/src/tbank/income-analytics.service.ts
T

259 lines
7.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { BrokerageOperationDto } from './brokerage-operation.dto';
import { BrokerageOperationsService } from './brokerage-operations.service';
import {
add,
compare,
decimalFromString,
formatDecimal,
subtract,
} from './decimal';
import {
DateOnlyRange,
IncomeBucketGranularity,
moscowDate,
planIncomePeriod,
rangeBuckets,
} from './income-period';
import {
IncomeAnalyticsDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeInstrumentDto,
IncomeSummaryDto,
} from './income-analytics.dto';
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): Promise<IncomeSummaryDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(undefined, undefined, openedAt);
plan = planned;
return [planned.selected];
},
);
if (!plan) throw new Error('Income period was not planned');
return summaryResponse(
accountId,
plan.selected,
qualifying(collected.pages[0].flatMap((page) => page.items)),
new Date().toISOString(),
);
}
async analytics(
userId: string,
accountId: string,
from: string | undefined,
to: string | undefined,
): Promise<IncomeAnalyticsDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(from, to, openedAt);
plan = planned;
return [
planned.selected,
planned.previous,
...(planned.yearAgo ? [planned.yearAgo] : []),
];
},
);
if (!plan) throw new Error('Income period was not planned');
const selected = qualifying(
collected.pages[0].flatMap((page) => page.items),
);
const previous = qualifying(
collected.pages[1].flatMap((page) => page.items),
);
const yearAgo = plan.yearAgo
? qualifying(collected.pages[2].flatMap((page) => page.items))
: null;
const calculatedAt = new Date().toISOString();
const base = summaryResponse(
accountId,
plan.selected,
selected,
calculatedAt,
);
return {
...base,
previous: comparison(selected, plan.previous, previous),
yearComparison:
plan.yearAgo && yearAgo
? comparison(selected, plan.yearAgo, yearAgo)
: null,
bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected),
entries: selected,
};
}
}
function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter(
(entry) =>
Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.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 (entry.type === 'OPERATION_TYPE_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()]),
].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[],
) {
return rangeBuckets(range, granularity).map((period) => ({
period,
totalsByCurrency: totals(
entries.filter((entry) => {
const date = moscowDate(new Date(entry.occurredAt));
return date >= period.from && date <= period.to;
}),
),
}));
}
function instruments(entries: BrokerageOperationDto[]): IncomeInstrumentDto[] {
const groups = new Map<string, BrokerageOperationDto[]>();
for (const entry of entries) {
const key = `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`;
groups.set(key, [...(groups.get(key) ?? []), 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.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) },
};
}