243 lines
7.1 KiB
TypeScript
243 lines
7.1 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
add,
|
|
compare,
|
|
decimalFromString,
|
|
formatDecimal,
|
|
} 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,
|
|
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> {
|
|
const plan = planIncomePeriod(undefined, undefined);
|
|
const [selected] = await this.operations.collectRanges(
|
|
userId,
|
|
accountId,
|
|
() => [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> {
|
|
const plan = planIncomePeriod(from, to);
|
|
const [current] = await this.operations.collectRanges(
|
|
userId,
|
|
accountId,
|
|
() => [plan.selected],
|
|
observe,
|
|
);
|
|
const dependencies: IncomeDependencyDto[] = [
|
|
{ ...current.meta, role: 'selected' },
|
|
];
|
|
const work = current.meta.sync;
|
|
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
|
|
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
|
: null,
|
|
lastAttempt: current.meta.lastAttempt,
|
|
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,
|
|
),
|
|
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 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 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) },
|
|
};
|
|
}
|