Adopt RUB-only API contract; remove currency cards, selectors, screener currency filter and multi-currency branches. BREAKING CHANGE: requires the RUB-only backend contract.
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
import type { Performance } from "@/entities/performance";
|
||
|
||
import { formatDate, formatMoney, formatPercentagePoints, formatSignedPercent } from "@/shared/lib";
|
||
|
||
type TextValue = { tone: Tone; value: string };
|
||
|
||
type Tone
|
||
= | "negative"
|
||
| "neutral"
|
||
| "positive";
|
||
|
||
const reasonLabels: Record<string, string> = {
|
||
ACCOUNT_VALUATION_DATES_DIFFER: "Оценки счетов сделаны в разные дни",
|
||
AMBIGUOUS_RETURN: "У расчёта доходности несколько возможных решений",
|
||
HISTORY_START_UNKNOWN: "Начало истории счёта неизвестно",
|
||
INDEX_HISTORY_UNAVAILABLE: "История индекса временно недоступна",
|
||
NO_SOLUTION: "Расчёт доходности не сходится",
|
||
OPERATIONS_HISTORY_INCOMPLETE: "История операций ещё не получена полностью",
|
||
PORTFOLIO_VALUATION_INCOMPLETE: "Нет полной оценки портфеля",
|
||
UNCONFIRMED_TRANSFER: "Перевод между счетами не удалось подтвердить",
|
||
UNVALUED_SECURITIES_TRANSFER: "Бумаги переведены без денежной оценки",
|
||
};
|
||
|
||
const unknownReason = "Причину недоступности не удалось определить";
|
||
|
||
function performanceVm(data: Performance) {
|
||
return {
|
||
basis: data.return.basis,
|
||
comparison: data.outperformance
|
||
? {
|
||
money: signedMoney(data.outperformance.money),
|
||
points: valueText(
|
||
data.outperformance.percentagePoints,
|
||
formatPercentagePoints,
|
||
),
|
||
}
|
||
: null,
|
||
current: formatMoney(data.currentValue),
|
||
currentPoint: data.series.length > 0
|
||
? { date: formatDate(`${data.series.at(-1)?.date ?? data.period.to}T00:00:00Z`) }
|
||
: null,
|
||
days: data.period.days,
|
||
excluded: data.excludedAccounts.map(item => ({
|
||
id: item.accountId,
|
||
name: item.name,
|
||
reason: reasonText(item.reason),
|
||
})),
|
||
from: data.period.from,
|
||
indexReason: data.benchmark.unavailableReason
|
||
? reasonText(data.benchmark.unavailableReason)
|
||
: null,
|
||
indexReturn: valueText(data.benchmark.return.value, formatSignedPercent),
|
||
indexValue: data.benchmark.value ? formatMoney(data.benchmark.value) : "—",
|
||
invested: formatMoney(data.netInvested),
|
||
profit: signedMoney(data.profit),
|
||
returnReason: data.return.unavailableReason
|
||
? reasonText(data.return.unavailableReason)
|
||
: null,
|
||
returnValue: valueText(data.return.value, formatSignedPercent),
|
||
series: data.series.map(item => ({
|
||
benchmarkValue: Number(item.benchmarkValue),
|
||
date: item.date,
|
||
label: formatDate(`${item.date}T00:00:00Z`),
|
||
netInvested: Number(item.netInvested),
|
||
referenceValue: item.referenceValue === null
|
||
? null
|
||
: Number(item.referenceValue),
|
||
})),
|
||
to: data.period.to,
|
||
};
|
||
}
|
||
|
||
function reasonText(reason: string): string {
|
||
// eslint-disable-next-line security/detect-object-injection -- Lookup accepts only an API reason, with a safe fallback.
|
||
return reasonLabels[reason] ?? unknownReason;
|
||
}
|
||
|
||
function signedMoney(value: Performance["profit"]): TextValue {
|
||
return {
|
||
tone: tone(value.value),
|
||
value: formatMoney(value, { withSign: true }),
|
||
};
|
||
}
|
||
|
||
function tone(value: string): Tone {
|
||
const number = Number(value);
|
||
if (number > 0) return "positive";
|
||
|
||
return number < 0 ? "negative" : "neutral";
|
||
}
|
||
|
||
// Tone follows the displayed text, so "0,0 %" is never coloured.
|
||
function valueText(
|
||
value: null | string,
|
||
format: (value: string) => string,
|
||
): TextValue {
|
||
if (value === null) return { tone: "neutral", value: "—" };
|
||
const text = format(value);
|
||
if (text.startsWith("+")) return { tone: "positive", value: text };
|
||
|
||
return { tone: text.startsWith("−") ? "negative" : "neutral", value: text };
|
||
}
|
||
|
||
export { performanceVm, reasonText };
|