feat(income): add account income analytics API
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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) },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user