feat: risk

This commit is contained in:
OpenSquared
2026-07-26 15:46:07 +02:00
parent 84ba8f10a2
commit 09a0203494
2 changed files with 286 additions and 66 deletions

View File

@@ -141,6 +141,8 @@ function ScenarioExposureCard() {
const concentration: any[] = exp.concentration ?? []
const scenarios: any[] = exp.scenarios ?? []
const positionDetails: any[] = exp.position_details ?? []
const redundant = exp.effective_n_positions != null && exp.effective_n_positions < exp.positions * 0.75
return (
<div className="card">
@@ -148,9 +150,29 @@ function ScenarioExposureCard() {
<Layers className="w-4 h-4 text-purple-400" /> Alignement du portefeuille sur le Régime Macro
</div>
<div className="text-[10px] text-slate-500 mb-3">
Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous les 8 scénarios du
gime macro révèle quand plusieurs positions différentes sont en réalité le même pari répété.
Vue distincte des Risk Factors ci-dessous (thème/classe d'actif touché, pas le sens du pari).
Une position ne compte pour un scénario que si elle y gagne réellement (repricing Black-Scholes
el, Saxo-first), répartie entre tous les scénarios elle est gagnante pas un vote unique
au scénario "le moins pire". Vue distincte des Risk Factors ci-dessous (thème touché, pas le sens du pari).
</div>
{/* Headline stats */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
<div className="text-sm font-bold font-mono text-slate-200">{exp.positions}</div>
<div className="text-[9px] text-slate-500">positions</div>
</div>
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
<div className={clsx('text-sm font-bold font-mono', redundant ? 'text-amber-400' : 'text-slate-200')}>
{exp.effective_n_positions ?? '—'}
</div>
<div className="text-[9px] text-slate-500">paris indépendants (N eff.)</div>
</div>
<div className="bg-dark-700/40 rounded px-2 py-1.5 text-center">
<div className={clsx('text-sm font-bold font-mono', exp.pct_unaligned >= 25 ? 'text-amber-400' : 'text-slate-200')}>
{exp.pct_unaligned}%
</div>
<div className="text-[9px] text-slate-500">non couvert par les 8 scénarios</div>
</div>
</div>
{exp.warning && (
@@ -164,25 +186,29 @@ function ScenarioExposureCard() {
{/* Concentration ranking */}
<div>
<div className="text-xs font-semibold text-slate-400 mb-2">
% du book dont c'est le scénario le plus favorable
% du capital à risque aligné avec chaque scénario
</div>
<div className="space-y-2.5">
{concentration.map((c: any) => (
<div key={c.key}>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-300 flex items-center gap-1.5">
<span>{c.emoji}</span>{c.label}
</span>
<span className="font-mono font-bold" style={{ color: c.color }}>
{c.pct_of_portfolio}%
</span>
{concentration.length === 0 ? (
<div className="text-xs text-slate-600">Aucune position ne gagne dans un des 8 scénarios.</div>
) : (
<div className="space-y-2.5">
{concentration.map((c: any) => (
<div key={c.key}>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-300 flex items-center gap-1.5">
<span>{c.emoji}</span>{c.label}
</span>
<span className="font-mono font-bold" style={{ color: c.color }}>
{c.pct_of_portfolio}%
</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%`, background: c.color }} />
</div>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%`, background: c.color }} />
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
{/* Sensitivity matrix */}
@@ -206,6 +232,39 @@ function ScenarioExposureCard() {
</div>
</div>
{/* Per-position transparency layer */}
{positionDetails.length > 0 && (
<div className="mt-4 pt-3 border-t border-slate-700/30">
<div className="text-xs font-semibold text-slate-400 mb-2">Détail par position</div>
<div className="space-y-1.5">
{positionDetails.map((p: any) => (
<div key={p.id} className="flex items-center gap-2 text-[11px] bg-dark-700/30 rounded px-2 py-1.5">
<span className="text-slate-200 font-medium w-40 truncate shrink-0">{p.title}</span>
<span className="text-slate-500 w-32 truncate shrink-0">{p.nature}</span>
<span className="text-slate-600 font-mono text-[10px] w-28 shrink-0">
Δ {p.delta_dollars != null ? p.delta_dollars.toFixed(0) : '—'} · v {p.vega_dollars != null ? p.vega_dollars.toFixed(0) : '—'}
</span>
{p.curve_regime && (
<span className="text-[9px] text-cyan-400/80 bg-cyan-900/20 rounded px-1.5 py-0.5 shrink-0">{p.curve_regime}</span>
)}
<span className="flex-1 flex items-center gap-1 justify-end flex-wrap">
{p.is_unaligned ? (
<span className="text-[9px] text-slate-600 italic">non aligné (perd dans les 8 scénarios)</span>
) : (
p.aligned_scenarios.map((a: any) => (
<span key={a.key} className="text-[9px] rounded px-1.5 py-0.5"
style={{ background: `${a.color}22`, color: a.color }}>
{a.emoji} {a.label} {a.share_of_position}%
</span>
))
)}
</span>
</div>
))}
</div>
</div>
)}
{exp.unpriced?.length > 0 && (
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}