Amélioration XIRR
This commit is contained in:
@@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx';
|
||||
import ConfirmModal from '../components/ConfirmModal.jsx';
|
||||
import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
|
||||
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
|
||||
const emptyForm = {
|
||||
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
|
||||
@@ -72,33 +73,6 @@ const STATUT_META = {
|
||||
cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* XIRR — taux de rendement interne annualisé sur flux datés.
|
||||
* cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }]
|
||||
* le premier flux doit être négatif (investissement initial).
|
||||
* Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent.
|
||||
*/
|
||||
function xirr(cashflows) {
|
||||
if (!cashflows || cashflows.length < 2) return null;
|
||||
const t0 = new Date(cashflows[0].date).getTime();
|
||||
const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5));
|
||||
const amounts = cashflows.map(cf => cf.amount);
|
||||
|
||||
const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0);
|
||||
const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0);
|
||||
|
||||
let rate = 0.1;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const f = npv(rate);
|
||||
const df = dnpv(rate);
|
||||
if (Math.abs(df) < 1e-12) break;
|
||||
const next = rate - f / df;
|
||||
if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null;
|
||||
rate = next;
|
||||
if (rate < -0.999) rate = -0.999; // garde-fou
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function InvestissementDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
@@ -7,7 +7,8 @@ import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import InvChart from '../components/InvChart.jsx';
|
||||
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
|
||||
@@ -597,6 +598,49 @@ export default function Plateformes() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedYear, filteredRows, allRembs, allReinvests, lastRembDateMap]);
|
||||
|
||||
/* ── XIRR agrégé (flux datés : investis + réinvestissements + remboursements + valorisation
|
||||
du capital restant dû pour les prêts non soldés, à la date de coupure) ── */
|
||||
const plateformeXirr = useMemo(() => {
|
||||
const cutoff = selectedYear ? `${selectedYear}-12-31` : today();
|
||||
const ids = new Set(chartRows.map(r => r.id));
|
||||
const flowsBrut = [];
|
||||
const flowsNet = [];
|
||||
for (const r of chartRows) {
|
||||
if (!r.date_souscription) continue;
|
||||
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
|
||||
}
|
||||
for (const rv of allReinvests) {
|
||||
if (!ids.has(rv.investissement_id)) continue;
|
||||
const d = rv.date_reinvestissement?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
|
||||
flowsNet.push({ amount: -(rv.montant || 0), date: d });
|
||||
}
|
||||
for (const rb of allRembs) {
|
||||
if (!ids.has(rb.investissement_id)) continue;
|
||||
const d = rb.date_remb?.slice(0, 10);
|
||||
if (!d || d > cutoff) continue;
|
||||
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
|
||||
flowsNet.push({ amount: rb.net_recu || 0, date: d });
|
||||
}
|
||||
let estime = false;
|
||||
for (const r of chartRows) {
|
||||
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
|
||||
const capRemb = capRembParInv[r.id] || 0;
|
||||
const capRestant = Math.max(0, capInv - capRemb);
|
||||
if (capRestant > 0.01) {
|
||||
estime = true;
|
||||
flowsBrut.push({ amount: capRestant, date: cutoff });
|
||||
flowsNet.push({ amount: capRestant, date: cutoff });
|
||||
}
|
||||
}
|
||||
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
|
||||
flowsBrut.sort(byDate);
|
||||
flowsNet.sort(byDate);
|
||||
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
|
||||
}, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]);
|
||||
|
||||
const netMode = displayMode === 'net';
|
||||
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
|
||||
|
||||
@@ -668,7 +712,7 @@ export default function Plateformes() {
|
||||
</div>}
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
|
||||
{/* 1 — Capital investi */}
|
||||
<div className="kpi">
|
||||
@@ -736,6 +780,50 @@ export default function Plateformes() {
|
||||
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6 — XIRR */}
|
||||
{(() => {
|
||||
const rendement = netMode ? plateformeXirr.net : plateformeXirr.brut;
|
||||
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
|
||||
const xirrExplication =
|
||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
||||
`des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
||||
`Chaque flux est pondéré par sa date exacte.\n\n` +
|
||||
(plateformeXirr.estime
|
||||
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.`
|
||||
: 'Tous les prêts de cette sélection sont soldés : taux définitif.');
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span>
|
||||
XIRR — {netMode ? 'Net' : 'Brut'}
|
||||
{plateformeXirr.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
|
||||
</span>
|
||||
<span
|
||||
className="cell-tooltip tooltip-down"
|
||||
data-tooltip={xirrExplication}
|
||||
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span
|
||||
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
|
||||
>
|
||||
{rendement !== null
|
||||
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
|
||||
: 'Aucun remboursement'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||
Valorisé au {cutoffLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>}
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* XIRR — taux de rendement interne annualisé sur flux datés.
|
||||
* cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }]
|
||||
* le premier flux doit être négatif (investissement initial).
|
||||
* Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent.
|
||||
*/
|
||||
export function xirr(cashflows) {
|
||||
if (!cashflows || cashflows.length < 2) return null;
|
||||
const t0 = new Date(cashflows[0].date).getTime();
|
||||
const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5));
|
||||
const amounts = cashflows.map(cf => cf.amount);
|
||||
|
||||
const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0);
|
||||
const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0);
|
||||
|
||||
let rate = 0.1;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const f = npv(rate);
|
||||
const df = dnpv(rate);
|
||||
if (Math.abs(df) < 1e-12) break;
|
||||
const next = rate - f / df;
|
||||
if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null;
|
||||
rate = next;
|
||||
if (rate < -0.999) rate = -0.999; // garde-fou
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user