259 lines
7.5 KiB
TypeScript
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) },
|
|
};
|
|
}
|