/* ========================================================================== FondAnalyse Pro - React frontend ========================================================================== Tous les composants et charts (SVG fait main) dans ce fichier. Charge via Babel-standalone (CDN) - transpilation au runtime. ========================================================================== */ const { useState, useEffect, useMemo, useCallback, useRef } = React; /* ========== CONSTANTS ==================================================== */ const COLOR = { bull: '#34d6a5', bear: '#ff4757', neut: '#f5c542' }; const TXT = { bull: 'text-bull', bear: 'text-bear', neut: 'text-neut' }; const BORDER = { bull: 'border-bull', bear: 'border-bear', neut: 'border-neut' }; const ARROW = { bull: '↑', bear: '↓', neut: '—' }; const TREND_CLS = { up: 'text-bull', down: 'text-bear', bull: 'text-bull', bear: 'text-bear', neut: 'text-ink', flat: 'text-ink' }; const fmt = (v, suffix = '') => (v === null || v === undefined) ? 'n/a' : `${v}${suffix}`; /* ========== CHARTS ======================================================= */ function Sparkline({ data, color, width = 80, height = 28 }) { const { points, lastX, lastY } = useMemo(() => { if (!data || data.length === 0) return { points: '', lastX: 0, lastY: 0 }; const min = Math.min(...data), max = Math.max(...data), range = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * width; const y = height - ((v - min) / range) * (height - 4) - 2; return [x, y]; }); return { points: pts.map(p => p.join(',')).join(' '), lastX: pts[pts.length - 1][0], lastY: pts[pts.length - 1][1], }; }, [data, width, height]); if (!data || data.length === 0) return
; return ( ); } function AreaChart({ values, height = 180 }) { if (!values || values.length === 0) return
Données indisponibles
; const width = 800, padX = 8, padTop = 16, padBottom = 24; const min = Math.min(...values), max = Math.max(...values), range = max - min || 1; const pts = values.map((v, i) => { const x = padX + (i / (values.length - 1)) * (width - padX * 2); const y = padTop + (1 - (v - min) / range) * (height - padTop - padBottom); return [x, y]; }); const linePath = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const areaPath = `${linePath} L ${pts[pts.length-1][0].toFixed(1)} ${height - padBottom} L ${pts[0][0].toFixed(1)} ${height - padBottom} Z`; const lastX = pts[pts.length-1][0], lastY = pts[pts.length-1][1]; const gridY = [0.25, 0.5, 0.75].map(r => padTop + r * (height - padTop - padBottom)); return ( {gridY.map((y, i) => ( ))} ); } function BarChart({ values, labels, height = 240 }) { if (!values || values.length === 0) return
Pas de données
; const width = 800, padX = 36, padTop = 20, padBottom = 40; const max = Math.max(...values, 0), min = Math.min(...values, 0); const range = max - min || 1; const zero = padTop + (max / range) * (height - padTop - padBottom); const barW = (width - padX * 2) / values.length * 0.6; const gap = (width - padX * 2) / values.length; return ( {values.map((v, i) => { const x = padX + gap * i + (gap - barW) / 2; const barH = Math.abs((v / range) * (height - padTop - padBottom)); const y = v >= 0 ? zero - barH : zero; return ( {labels?.[i] || ''} {v.toFixed(1)} ); })} ); } function DualLineChart({ labels, seriesA, seriesB, nameA, nameB, colorA = '#f4d06f', colorB = '#34d6a5', height = 260 }) { if (!seriesA?.length || !seriesB?.length) return
Pas de données
; const width = 800, padX = 40, padTop = 28, padBottom = 40; const all = [...seriesA, ...seriesB]; const min = Math.min(...all), max = Math.max(...all), range = max - min || 1; const mk = (s) => s.map((v, i) => [ padX + (i / (s.length - 1)) * (width - padX * 2), padTop + (1 - (v - min) / range) * (height - padTop - padBottom), ]); const pathA = mk(seriesA).map((p, i) => `${i===0?'M':'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const pathB = mk(seriesB).map((p, i) => `${i===0?'M':'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const gridY = [0, 0.25, 0.5, 0.75, 1].map(r => { const y = padTop + r * (height - padTop - padBottom); const val = max - r * range; return { y, val }; }); return ( {gridY.map((g, i) => ( {g.val.toFixed(1)} ))} {(labels || []).map((l, i) => { const x = padX + (i / (labels.length - 1)) * (width - padX * 2); return {l}; })} {nameA} {nameB} ); } function HoverLineChart({ points, unit = '', height = 260 }) { const [hover, setHover] = useState(null); if (!points || points.length === 0) { return
Données indisponibles
; } const width = 720, padX = 56, padTop = 30, padBottom = 50; const values = points.map(p => p.value); const minV = Math.min(...values), maxV = Math.max(...values); const span = maxV - minV || Math.abs(maxV) || 1; const yPad = span * 0.15; const ymin = minV - yPad, ymax = maxV + yPad; const yrange = (ymax - ymin) || 1; const xs = points.map((_, i) => points.length === 1 ? width / 2 : padX + (i / (points.length - 1)) * (width - padX * 2) ); const ys = values.map(v => padTop + (1 - (v - ymin) / yrange) * (height - padTop - padBottom)); const linePath = xs.map((x, i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${ys[i].toFixed(1)}`).join(' '); const areaPath = `${linePath} L ${xs[xs.length-1].toFixed(1)} ${height - padBottom} L ${xs[0].toFixed(1)} ${height - padBottom} Z`; const ticks = [0, 0.25, 0.5, 0.75, 1].map(r => { const y = padTop + r * (height - padTop - padBottom); return { y, v: ymax - r * yrange }; }); const MONTHS = ['Jan','Fev','Mar','Avr','Mai','Juin','Juil','Aou','Sep','Oct','Nov','Dec']; const fmtDate = d => { if (!d) return ''; const parts = d.split('-'); if (parts.length < 2) return d; const m = parseInt(parts[1], 10) - 1; const yy = parts[0].slice(2); return `${MONTHS[m] || ''} ${yy}`; }; const fmtVal = v => { const sign = v > 0 && (unit === '%' || unit === 'K') ? '+' : ''; const num = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2); return `${sign}${num}${unit || ''}`; }; return ( setHover(null)}> {ticks.map((t, i) => ( {Math.abs(t.v) >= 100 ? t.v.toFixed(0) : t.v.toFixed(1)} ))} {points.map((p, i) => ( {fmtDate(p.date)} ))} {points.map((p, i) => { const isH = hover === i; return ( setHover(i)} style={{cursor:'crosshair'}}/> ); })} {hover !== null && (() => { const p = points[hover]; const tx = xs[hover], ty = ys[hover]; const left = tx > width * 0.6; const tw = 110, th = 38; const bx = left ? tx - tw - 12 : tx + 12; const by = Math.max(padTop, ty - th / 2); return ( {fmtDate(p.date).toUpperCase()} {fmtVal(p.value)} ); })()} ); } function IndicatorModal({ ind, onClose }) { useEffect(() => { if (!ind) return; const h = e => e.key === 'Escape' && onClose(); window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [ind, onClose]); if (!ind) return null; const fmtMain = v => { if (v == null) return 'n/a'; const sign = v > 0 && (ind.unit === '%' || ind.unit === 'K') ? '+' : ''; const num = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2); return `${sign}${num}${ind.unit || ''}`; }; const delta = (ind.value != null && ind.prev != null) ? (ind.value - ind.prev) : null; return (
e.stopPropagation()}>
Historique · {ind.series?.length || 0} derniers points

{ind.name}

{fmtMain(ind.value)} {ind.prev != null && ( Préc. {fmtMain(ind.prev)} {delta != null && ( 0 ? 'text-bull' : delta < 0 ? 'text-bear' : 'text-neut'}`}> ({delta > 0 ? '+' : ''}{delta.toFixed(2)}) )} )} {ind.impact_label}
Survolez les points pour voir date et valeur · Échap pour fermer
); } function HProbaBar({ segments }) { return (
{segments.map((s, i) => (
))}
{segments.map((s, i) => (
{s.label} {s.value}%
))}
); } /* ========== ATOMS ======================================================== */ const SUPPORTED_COUNTRIES = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD', 'CAD', 'NZD']; const VIEW_PRINCIPAL = 'PRINCIPALE'; function Header({ activeView, onView, onRefresh, refreshing }) { const Item = ({ id, label }) => ( ); return (
FondAnalyse
); } function PageTitle({ title, sub }) { return ( <>

{title}

{sub ? (
{sub}
) : (
)} ); } const COUNTRY_TABS = ['Général', 'Politique Monétaire', 'Données', 'Speech']; const PRINCIPAL_TABS = ['Direction du marché', 'Calendrier', 'Entreprise', 'Calculateur', 'Corrélations', 'Sentiment Retail']; function Tabs({ tabs, active, onChange }) { return (
{tabs.map(t => ( ))}
); } function SectionHead({ title, meta }) { return (

{title}

{meta &&
{meta}
}
); } function Wip({ label = "En cours de code", note, compact = false }) { return (
🚧
{label}
{note &&
{note}
}
); } function Pillar(props) { if (props.wip) { return (
{props.name}
🚧
En cours de code
); } const { name, value, unit, verdict, status, spark, lines } = props; return (
{name}
{ARROW[status]}
{value}{unit}
{verdict}
{(lines || []).map((ln, i) => (
0 ? 'border-t border-gold/[0.08]' : ''}`}> {ln.l} {ln.v}
))}
); } function ThemeBanner({ theme }) { const values = theme.chart?.values || []; const last = values[values.length - 1]; const first = values[0]; const delta = (first && last) ? ((last - first) / first * 100) : 0; const isUp = delta >= 0; return (
{ARROW[theme.status]}
{theme.label}
{theme.headline}
{theme.detail}
{theme.badge}
{theme.chart?.title}
{last && first && (
{isUp ? '+' : ''}{delta.toFixed(2)}%
)}
{last && (
{last.toFixed(2)}
DXY
)}
); } const TONE_BADGE = { bull: { cls: 'text-bull', label: 'Détente' }, bear: { cls: 'text-bear', label: 'Tension' }, warn: { cls: 'text-bear', label: 'Stress' }, neut: { cls: 'text-ink-soft', label: 'Stable' }, }; function GaugePanel({ num, suffix, label, statBlocks, position, footnote }) { return (
{num}{suffix}
{label}
{statBlocks.map((s, i) => (
{s.l} {s.v}
))}
{footnote && (
{TONE_BADGE[footnote.tone]?.label || 'Lecture'} Lecture obligataire
{footnote.text}
)}
); } function PositionsCard({ retail, cot }) { const Block = ({ title, data, hint }) => (
{title}
{data && data.long != null ? ( <>
{data.long}% L
{data.short}% S
{hint}
) : (
Indisponible
)}
); return (
Positionnement marché
); } function StatusBanner({ status, label, headline, detail, badge }) { return (
{ARROW[status]}
{label}
{headline}
{detail}
{badge}
); } /* ========== CALENDAR ===================================================== */ const IMPACT_COLOR = { High: '#ff4757', Medium: '#f5c542', Low: '#5f5a4d', Holiday:'#5f5a4d', }; function ImpactDot({ impact }) { const c = IMPACT_COLOR[impact] || '#5f5a4d'; return ( ); } function WeeklyHighlights({ cal }) { if (!cal?.days?.length) { return ( <>
Aucun événement chargé.
); } const highDays = cal.days .map(d => ({ ...d, events: d.events.filter(e => e.impact === 'High') })) .filter(d => d.events.length); const totalHigh = highDays.reduce((acc, d) => acc + d.events.length, 0); return ( <> 1 ? 's' : ''} High · Pour le détail → onglet Calendrier (page Principale)`} />
{totalHigh === 0 ? (
Aucun événement High cette semaine pour {cal.country}.
) : (
{highDays.map(day => (
{day.label} {day.events.length} high
{day.events.map((e, i) => (
{e.time} {e.title}
))}
))}
)}
); } /* ========== SECTIONS ===================================================== */ function TabGeneral({ state }) { const s = state.sentiment; const discourse = state.discourse; const country = state.meta?.country || ''; return ( <>
{discourse?.wip ? ( ) : (
Mise à jour : {discourse.date}
{discourse.text}
)}
{state.pillars.map((p, i) => )}
{state.theme?.wip ? : }
{state.calendar && (
)}
{s?.wip ? ( ) : (
= 0 ? 'up' : 'down' }, { l: 'Yield 2Y', v: s.yield2 != null ? `${s.yield2}%` : 'n/a', t: s.yield2Change >= 0 ? 'up' : 'down' }, ]} footnote={s.yieldsNarrative} />
)}
{state.synth?.wip ? ( ) : ( <>

Forces

{state.synth.forces.map((f, i) => (
{f}
))}

Risques

{state.synth.risques.map((r, i) => (
{r}
))}
Recommandation : {state.synth.reco.dir} — {state.synth.reco.text}
)}
); } function TabPolitique({ state }) { const f = state.fed; if (f?.wip) { return (
); } const cbShort = state.meta?.central_bank ? (state.meta.central_bank.match(/\(([^)]+)\)/)?.[1] || 'BC') : (state.meta?.countryCode === 'USD' ? 'Fed' : 'BC'); const ccyCode = state.meta?.countryCode || 'USD'; const strengthTitle = f.usd_strength?.title || (ccyCode === 'USD' ? 'Force du Dollar (panier DXY)' : `Force ${ccyCode} (panier)`); const componentsTitle = ccyCode === 'USD' ? 'Composantes (poids DXY)' : 'Composantes du panier'; const probaTitle = `Probabilité prochaine décision ${cbShort}`; const probaUnavailable = f.proba_unavailable === true; return ( <>
Paramètres {cbShort}
{f.params.map((p, i) => (
0 ? 'border-t border-gold/[0.08]' : ''}`}> {p.label} {p.value}
))}
{probaTitle}
{probaUnavailable ? (
Pas de fed-watch équivalent public pour la {cbShort}. Le sentiment monétaire s'apprécie via la stance, l'évolution récente du taux directeur et les rendements obligataires.
) : ( <>
{f.proba.baisse >= 50 ? ( <>Le marché price {f.proba.baisse}% de baisse de taux à la prochaine réunion {cbShort}. ) : f.proba.hausse >= 50 ? ( <>Le marché price {f.proba.hausse}% de hausse de taux à la prochaine réunion. ) : ( <>La majorité anticipe un maintien ({f.proba.maintien}%) à court terme. )}
)}
{strengthTitle}
{f.usd_strength.score}/100
{f.usd_strength.label}
{f.usd_strength.stats.map((st, i) => (
{st.l} {st.v}
))}
{f.usd_strength.components?.length > 0 && (
{componentsTitle}
{f.usd_strength.components.map((c, i) => (
{c.pair} ({c.weight}%) = 0 ? 'text-bull' : 'text-bear'}> {ccyCode} {c.usd_impact >= 0 ? '+' : ''}{c.usd_impact}%
))}
)}
{f.inflation_chart.title}
{f.proba_details?.length > 0 && (
{f.proba_details.map((d, i) => (
{d.range}%
{d.proba}%
))}
)} {f.communications?.length > 0 && (
{f.communications.map((c, i) => (
{ARROW[c.market_impact]} {c.title} — {c.speaker} {c.date}

Impact marché : {c.market_impact_label}

    {c.key_points.map((pt, j) =>
  • {pt}
  • )}
))}
)} ); } function TabDonnees({ state }) { const d = state.data; const [modalInd, setModalInd] = useState(null); const IndicatorRow = ({ ind }) => { const clickable = !!(ind.series && ind.series.length); return ( ); }; const TableHeader = () => (
IndicateurActuelPrécédentAttenduImpact
); const hasIndEco = Array.isArray(d.indicators_economic) && d.indicators_economic.length > 0; const hasIndInf = Array.isArray(d.indicators_inflation) && d.indicators_inflation.length > 0; return ( <>
{d.sectors?.wip ? ( ) : (
{d.sectors.map((sec, i) => (
{sec.name}
{sec.value} {ARROW[sec.status]}
))}
)}
{hasIndEco ? (
{d.indicators_economic.map((ind, i) => )}
) : ( )}
{hasIndInf ? (
{d.indicators_inflation.map((ind, i) => )}
) : ( )}
{d.macro_cards?.wip ? ( ) : (
{d.macro_cards.map((c, i) => (
{c.label}
{c.value}
{ARROW[c.trend === 'up' || c.trend === 'bull' ? 'bull' : c.trend === 'down' || c.trend === 'bear' ? 'bear' : 'neut']} {c.delta}
))}
)}
{d.gdp_chart?.title || 'PIB'}
{(d.gdp_chart?.values?.length) ? : }
{d.dxy_chart?.wip ? ( ) : ( <>
{d.dxy_chart.title}
)}
setModalInd(null)} /> ); } /* ========== CORRELATION ================================================== */ function corrColor(r) { if (r === null || r === undefined) return '#5f5a4d'; if (r >= 0.7) return '#34d6a5'; if (r >= 0.3) return '#7ad9b3'; if (r > -0.3) return '#f5c542'; if (r > -0.7) return '#ff8b7a'; return '#ff4757'; } function corrLabel(r) { const a = Math.abs(r); if (a >= 0.8) return 'Très forte'; if (a >= 0.6) return 'Forte'; if (a >= 0.4) return 'Modérée'; if (a >= 0.2) return 'Faible'; return 'Négligeable'; } function CorrelationGauge({ value }) { if (value === null || value === undefined) return null; const color = corrColor(value); const angle = Math.max(-1, Math.min(1, value)) * 90; // -90 to +90 deg const cx = 200, cy = 180, r = 140; return ( {/* Arc fond */} {/* Aiguille */} {/* Labels */} -1.0 0.0 +1.0 {/* Valeur */} {value >= 0 ? '+' : ''}{value.toFixed(2)} ); } function ConstellationMap({ instruments, matrix, labels, selectedA, selectedB, onSelect }) { // Place tous les instruments en cercle, regroupes par categorie // Affiche les liens depuis selectedA vers tous les autres avec couleur=correlation, epaisseur=|r| if (!instruments?.length || !matrix) return null; const n = instruments.length; const cx = 350, cy = 350, R = 280; const idx = labels.indexOf(selectedA); const idxB = labels.indexOf(selectedB); // Position de chaque noeud const nodes = instruments.map((it, i) => { const angle = (i / n) * 2 * Math.PI - Math.PI / 2; return { ...it, x: cx + Math.cos(angle) * R, y: cy + Math.sin(angle) * R, angle, i, }; }); return ( {/* Glow de fond */} {/* Liens depuis selectedA vers tous les autres */} {idx >= 0 && nodes.map((n2, j) => { if (j === idx) return null; const r = matrix[idx]?.[j]; if (r === null || r === undefined) return null; const isSelected = j === idxB; const opacity = isSelected ? 0.95 : Math.max(0.08, Math.abs(r) * 0.55); const stroke = isSelected ? '#f4d06f' : corrColor(r); const sw = isSelected ? 3 : Math.max(0.5, Math.abs(r) * 2.2); return ( ); })} {/* Noeuds */} {nodes.map((n2, j) => { const isA = j === idx; const isB = j === idxB; const r = idx >= 0 ? matrix[idx]?.[j] : null; const fill = isA ? '#f4d06f' : isB ? '#fff' : corrColor(r); const size = isA ? 9 : isB ? 8 : 5; // Position du label (offset radial) const lx = cx + Math.cos(n2.angle) * (R + 25); const ly = cy + Math.sin(n2.angle) * (R + 25); const anchor = Math.cos(n2.angle) > 0.2 ? 'start' : Math.cos(n2.angle) < -0.2 ? 'end' : 'middle'; return ( onSelect(n2.label)}> {n2.label} ); })} {/* Centre : libelle de A */} {idx >= 0 && ( {selectedA} → all )} ); } function PairOverlay({ data }) { if (!data?.series_a?.length) return null; const sa = data.series_a, sb = data.series_b; const width = 800, height = 200, padX = 40, padTop = 20, padBottom = 30; const allMin = 0, allMax = 100, range = 100; const mk = s => s.map((v, i) => [ padX + (i / (s.length - 1)) * (width - padX * 2), padTop + (1 - (v - allMin) / range) * (height - padTop - padBottom), ]); const pa = mk(sa).map((p,i) => `${i===0?'M':'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); const pb = mk(sb).map((p,i) => `${i===0?'M':'L'} ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); return ( {[0.25, 0.5, 0.75].map((r, i) => ( ))} {data.a} {data.b} {data.days}j normalisé 0-100 ); } function CategoryDropdown({ value, onChange, instruments, label }) { const grouped = useMemo(() => { const g = {}; (instruments || []).forEach(it => { if (!g[it.category]) g[it.category] = []; g[it.category].push(it); }); return g; }, [instruments]); return (
{label}
); } function MatchRow({ a, b, data, onRemove, onPromote, onSwap, isPrimary }) { if (!data) { return (
{b}
); } const r = data.correlation; const color = corrColor(r); const pct = Math.min(100, Math.abs(r) * 100); return (
{isPrimary && } {b} {r >= 0 ? '+' : ''}{r.toFixed(2)}
{data.strength} {r >= 0 ? 'positive' : 'négative'}
{!isPrimary && ( )}
); } function TabCorrelation() { const [instruments, setInstruments] = useState([]); const [matrixData, setMatrixData] = useState(null); const [pairA, setPairA] = useState('EUR/USD'); const [pairsB, setPairsB] = useState(['GBP/USD']); const [pendingB, setPendingB] = useState('USD/JPY'); const [pairsData, setPairsData] = useState({}); // { "GBP/USD": {...}, ... } const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [mode, setMode] = useState('1vAll'); // '1v1' | '1vAll' | 'setup' // ===== Mode "setup" : analyse d'un panier d'idees de trade ================= const [setupPairs, setSetupPairs] = useState(['EUR/USD', 'GBP/USD', 'USD/JPY']); const [setupPending, setSetupPending] = useState('AUD/USD'); const [setupResult, setSetupResult] = useState(null); const [setupLoading, setSetupLoading] = useState(false); const [setupError, setSetupError] = useState(null); const addSetupPair = () => { if (!setupPending || setupPairs.includes(setupPending)) return; setSetupPairs([...setupPairs, setupPending]); setSetupResult(null); }; const removeSetupPair = (p) => { setSetupPairs(setupPairs.filter(x => x !== p)); setSetupResult(null); }; const runSetupAnalysis = () => { if (setupPairs.length < 2) { setSetupError('Ajoute au moins 2 paires pour analyser le setup.'); return; } setSetupLoading(true); setSetupError(null); const url = `/api/correlation/basket?pairs=${encodeURIComponent(setupPairs.join(','))}`; fetch(url) .then(r => r.ok ? r.json() : r.json().then(j => Promise.reject(j.detail || 'Erreur API'))) .then(d => { setSetupResult(d); setSetupLoading(false); }) .catch(e => { setSetupError(typeof e === 'string' ? e : 'Erreur lors de l\'analyse'); setSetupLoading(false); }); }; // Init: charge instruments + matrice useEffect(() => { Promise.all([ fetch('/api/correlation/instruments').then(r => r.json()), fetch('/api/correlation/matrix').then(r => r.json()), ]).then(([insts, mat]) => { setInstruments(insts.instruments); setMatrixData(mat); setLoading(false); }).catch(e => { setError(e.message); setLoading(false); }); }, []); // (Re)charge les correlations A vs chaque B quand A ou la liste change useEffect(() => { if (!pairA) return; const targets = pairsB.filter(b => b && b !== pairA); targets.forEach(b => { const key = `${pairA}::${b}`; if (pairsData[key]) return; // deja en cache fetch(`/api/correlation/pair?a=${encodeURIComponent(pairA)}&b=${encodeURIComponent(b)}`) .then(r => r.ok ? r.json() : null) .then(d => { if (!d) return; setPairsData(prev => ({ ...prev, [key]: d })); }) .catch(() => {}); }); }, [pairA, pairsB]); // Quand A change, on vide le cache (toutes les correlations sont obsoletes) useEffect(() => { setPairsData({}); }, [pairA]); // Mode 1v1 : un seul B autorisé ; on tronque si on bascule depuis 1vAll. useEffect(() => { if (mode === '1v1' && pairsB.length > 1) { setPairsB(pairsB.slice(0, 1)); } }, [mode]); if (loading) return
Chargement des corrélations...
; if (error) return
Erreur : {error}
; const primaryB = pairsB[0]; const primaryData = primaryB ? pairsData[`${pairA}::${primaryB}`] : null; const addPair = () => { if (!pendingB || pendingB === pairA || pairsB.includes(pendingB)) return; setPairsB([...pairsB, pendingB]); }; const removePair = (b) => { const next = pairsB.filter(x => x !== b); setPairsB(next.length ? next : [pendingB && pendingB !== pairA ? pendingB : 'GBP/USD']); }; const promotePair = (b) => { setPairsB([b, ...pairsB.filter(x => x !== b)]); }; // Swap : la paire B cliquée devient A ; A prend la place de B dans la liste. const swapWith = (b) => { if (!b || b === pairA) return; setPairsB(prev => prev.map(x => x === b ? pairA : x)); setPairA(b); }; const onSelectFromMap = (label) => { if (label === pairA) return; if (pairsB.includes(label)) { promotePair(label); } else { setPairsB([label, ...pairsB]); } }; // Pre-filtre pour le dropdown d'ajout : exclure A et celles deja matchees const availableForAdd = instruments.filter(it => it.label !== pairA && !pairsB.includes(it.label)); return ( <>
1?'s':''}` : `1 vs all — ${instruments.length} instruments · ${pairsB.length} matchés`} /> {/* Mode toggle */}
Mode
{[ { k: '1v1', label: '1 vs 1', sub: 'duel' }, { k: '1vAll', label: '1 vs all', sub: 'multi-match' }, { k: 'setup', label: 'Setup', sub: 'panier d\'idées' }, ].map(({ k, label, sub }) => ( ))}
{mode === 'setup' ? ( // ===== MODE SETUP (panier d'idees de trade) =================== ) : mode === '1v1' ? ( // ===== MODE 1 vs 1 ===========================================
v && v !== pairA && setPairsB([v])} instruments={instruments.filter(it => it.label !== pairA)} label="Paire B" />
) : ( // ===== MODE 1 vs ALL ========================================= <>
Matches actuels ({pairA} ↔ …)
Instrument B r Intensité Force Actions
{pairsB.map((b, i) => ( ))} {pairsB.length === 0 && (
Aucune paire à matcher — ajoutez-en une via le sélecteur ci-dessus.
)}
)}
{/* ===== Resultats Setup ====================================== */} {mode === 'setup' && ( )} {mode !== 'setup' && primaryData && primaryB !== pairA && (
{primaryData.strength} {primaryData.correlation >= 0 ? '· positive' : '· négative'}
Interprétation

{primaryData.interpretation}

{primaryData.a}
{primaryData.raw_a_last}
{primaryData.b}
{primaryData.raw_b_last}
Évolution conjointe (normalisée 0-100)
)} {mode !== 'setup' && (
{[ {l:'Forte +', c:'#34d6a5'}, {l:'Modérée +',c:'#7ad9b3'}, {l:'Faible', c:'#f5c542'}, {l:'Modérée -',c:'#ff8b7a'}, {l:'Forte -', c:'#ff4757'}, ].map((s,i) => (
{s.l}
))}
)} ); } /* ========== SETUP (panier d'idees de trade) ============================== */ function SetupPanel({ instruments, setupPairs, setupPending, setSetupPending, addSetupPair, removeSetupPair, runSetupAnalysis, setupLoading }) { const available = instruments.filter(it => !setupPairs.includes(it.label)); return ( <>
Sélectionne tes idées de trade (2 à 10 paires). L'analyse te dit lesquelles sont des setups distincts et lesquelles sont redondantes (à arbitrer).
Idées sélectionnées
{setupPairs.length === 0 && ( Aucune idée sélectionnée — ajoutes-en au moins 2. )} {setupPairs.map((p, i) => ( {i+1} {p} ))}
Pearson · 90 jours · indépendant si |r| ≤ 30%, redondant si |r| {'>'} 60%
); } function SetupResult({ result, loading, error, pairs }) { if (loading) { return (
Calcul des corrélations…
); } if (error) { return (
⚠ {error}
); } if (!result) { return (
Configure tes idées et clique sur Analyser le setup.
); } if (result.error) { return (
⚠ {result.error}
); } const RISK_COLOR = { Vert: '#34d6a5', Orange: '#f5c542', Rouge: '#ff4757' }; const riskColor = RISK_COLOR[result.risk] || '#f5c542'; const VERDICT_LABEL = { OK: 'Setup OK · diversification réelle', Mixte: 'Setup mixte · arbitrage requis', Concentre: 'Setup concentré · redondance forte', }; const bucketStyle = (bk) => { if (bk === 'high') return { color: '#ff4757', label: 'Redondant' }; if (bk === 'moderate') return { color: '#f5c542', label: 'À surveiller' }; return { color: '#34d6a5', label: 'Indépendant' }; }; return ( <> {/* === Verdict + reco === */}
1?'s':''} distinct${result.n_distinct_setups>1?'s':''}`} />
Risque
{result.risk}
{VERDICT_LABEL[result.verdict] || result.verdict}

{result.reco}

{result.missing && result.missing.length > 0 && (
Données indisponibles pour : {result.missing.join(', ')}
)}
{/* === Clusters === */}
{result.independent_setups.length > 0 && (
✓ Idées indépendantes — tradables ensemble
{result.independent_setups.map(p => ( {p} ))}
)} {result.cluster_groups.map((group, i) => (
⚠ Groupe {String.fromCharCode(65+i)} — choisis-en UNE seule (les {group.length} sont fortement liées)
{group.map(p => ( {p} ))}
))}
{/* === Détail des couples === */}
Paire A Paire B r Intensité Statut
{result.pairs .slice() .sort((a, b) => b.abs_r - a.abs_r) .map((p, i) => { const st = bucketStyle(p.bucket); const pct = Math.min(100, p.abs_r * 100); return (
{p.a} {p.b} {p.r >= 0 ? '+' : ''}{p.r.toFixed(2)}
{st.label}
); })}
); } /* ========== SENTIMENT RETAIL (MYFXBOOK) ================================== */ function SentimentBar({ longPct, shortPct, height = 16 }) { // Barre horizontale long/short avec gradient et texte centre return (
{longPct >= 12 && {longPct}%}
{shortPct >= 12 && {shortPct}%}
); } function SentimentWheel({ longPct, shortPct, size = 260 }) { // Donut SVG : arc long (vert) + arc short (rouge), texte central if (longPct == null || shortPct == null) return null; const cx = size/2, cy = size/2; const radius = size/2 - 18; const stroke = 22; const C = 2 * Math.PI * radius; const longLen = (longPct/100) * C; const dominant = longPct >= shortPct ? 'long' : 'short'; const dominantPct = Math.max(longPct, shortPct); return ( {/* Track de fond */} {/* Arc short (rouge) - dessous */} {/* Arc long (vert) - dessus */} {/* Texte central */} {dominantPct}% {dominant === 'long' ? 'LONG' : 'SHORT'} ); } function SentimentSelector({ value, onChange, pairs }) { return (
Paire
); } function ContrarianHint({ longPct }) { // Le sentiment retail est traditionnellement contrarien (>70% = signal de retournement) if (longPct == null) return null; let txt, color, label; if (longPct >= 75) { txt = "Foule massivement longue — risque de retournement baissier élevé. Position contrarian : SHORT."; color = '#ff4757'; label = 'CONTRARIAN SHORT'; } else if (longPct >= 60) { txt = "Retail majoritairement long. Lecture contrarian modérée : prudence sur les achats."; color = '#ff8b7a'; label = 'BIAIS BAISSIER'; } else if (longPct <= 25) { txt = "Foule massivement short — risque de squeeze haussier. Position contrarian : LONG."; color = '#34d6a5'; label = 'CONTRARIAN LONG'; } else if (longPct <= 40) { txt = "Retail majoritairement short. Lecture contrarian modérée : surveiller les achats."; color = '#7ad9b3'; label = 'BIAIS HAUSSIER'; } else { txt = "Sentiment équilibré. Pas de signal contrarian fort."; color = '#f5c542'; label = 'NEUTRE'; } return (
Lecture contrarian
{label}
{txt}
); } function SentimentRanking({ pairs, selected, onSelect }) { // Tri par % long decroissant const sorted = [...pairs].sort((a,b) => b.long_pct - a.long_pct); return (
{sorted.map(p => { const isSel = p.symbol === selected; return ( ); })}
); } function PositionsBlock({ pair }) { if (!pair) return null; const fmtPrice = v => v == null ? '—' : (v < 100 ? v.toFixed(4) : v.toFixed(2)); const fmtVol = v => { if (v == null) return '—'; if (v >= 1_000_000) return `${(v/1_000_000).toFixed(1)}M`; if (v >= 1_000) return `${(v/1_000).toFixed(1)}K`; return v.toFixed(0); }; const fmtPos = v => v == null ? '—' : v.toLocaleString('fr-FR'); const stats = [ { l:'Volume long', v: fmtVol(pair.long_volume), c:'#34d6a5'}, { l:'Volume short', v: fmtVol(pair.short_volume), c:'#ff4757'}, { l:'Positions long', v: fmtPos(pair.long_positions), c:'#34d6a5'}, { l:'Positions short', v: fmtPos(pair.short_positions), c:'#ff4757'}, { l:'Prix moy. long', v: fmtPrice(pair.avg_long_price), c:'#34d6a5'}, { l:'Prix moy. short', v: fmtPrice(pair.avg_short_price), c:'#ff4757'}, ]; return (
{stats.map((s,i) => (
{s.l} {s.v}
))}
); } /* ========== SPEECH ======================================================= */ function TrumpCard({ post }) { return (
DT
Donald Trump
@realDonaldTrump · Truth Social
{post.date}
{post.is_repost && (
↻ Repost
)} {post.text && (
{post.text}
)} {post.media?.length > 0 && (
{post.media.map((m, i) => ( ))}
)}
♻ {post.stats.reposts} ♥ {post.stats.likes} 💬 {post.stats.replies} {post.url && ( Voir → )}
); } function PressCard({ item }) { return (
{item.title}
{item.date}
{item.summary && (
{item.summary}
)}
); } function TabSpeech() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch('/api/speech') .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(d => { setData(d); setLoading(false); }) .catch(e => { setError(e.message); setLoading(false); }); }, []); if (loading) return
Chargement des discours et tweets...
; if (error) return
Erreur : {error}
; const trumpCount = data?.trump?.length || 0; const fedCount = data?.fed?.length || 0; const ecbCount = data?.ecb?.length || 0; return ( <>
{trumpCount === 0 ? (
Flux Truth Social temporairement injoignable.
) : (
{data.trump.map((p, i) => )}
)}
{fedCount === 0 ? (
Flux Fed indisponible.
) : ( data.fed.map((it, i) => ) )}
{ecbCount === 0 ? (
Flux ECB indisponible.
) : ( data.ecb.map((it, i) => ) )}
Mise à jour {data.updated_at}
); } /* ========== SENTIMENT ==================================================== */ function TabSentiment() { const [data, setData] = useState(null); const [selected, setSelected] = useState('EURUSD'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch('/api/sentiment/myfxbook') .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(d => { setData(d); setLoading(false); if (d.pairs?.length && !d.pairs.find(p => p.symbol === 'EURUSD')) { setSelected(d.pairs[0].symbol); } }) .catch(e => { setError(e.message); setLoading(false); }); }, []); if (loading) return
Chargement du sentiment retail...
; if (error) return
Erreur : {error}
Première requête : ~10s.
; if (!data?.pairs?.length) return
Aucune donnée disponible.
; const pair = data.pairs.find(p => p.symbol === selected) || data.pairs[0]; return ( <>
Biais USD retail
{data.usd_retail_long != null ? ( <>
Long USD {data.usd_retail_long}%
Short USD {data.usd_retail_short}%
Moyenne pondérée du positionnement retail sur les majors USD.
) : (
Biais USD non calculable
)}
Classement par % long
Long
{pair.long_pct}%
Short
{pair.short_pct}%
Volumes & positions
Données rafraîchies toutes les 5 min
); } /* ========== TAB CALENDAR ================================================= */ const CAL_COUNTRIES = ['USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'NZD', 'CNY']; const CAL_LABELS = { USD: 'États-Unis', EUR: 'Zone Euro', GBP: 'Royaume-Uni', JPY: 'Japon', AUD: 'Australie', CAD: 'Canada', CHF: 'Suisse', NZD: 'Nouvelle-Zélande', CNY: 'Chine', }; function CountryBadge({ code, dim = false }) { return ( {code} ); } function ImpactBar({ impact }) { const c = IMPACT_COLOR[impact] || '#5f5a4d'; const bars = impact === 'High' ? 3 : impact === 'Medium' ? 2 : 1; return ( {[1, 2, 3].map(i => ( ))} ); } function CalCountryChip({ code, active, count, onToggle }) { return ( ); } function UpcomingStrip({ events }) { if (!events.length) return null; return (
Prochains événements à fort impact
{events.length} à venir
{events.slice(0, 5).map((e, i) => (
{e.title}
{e.weekday} · {e.time}
dans {e.in_human}
))}
); } /* ========== DIRECTION DU MARCHE ========================================== */ const VERDICT_BG = { bull: 'rgba(52,214,165,0.10)', bear: 'rgba(255,71,87,0.10)', warn: 'rgba(245,197,66,0.12)', neut: 'rgba(212,175,55,0.06)', }; const VERDICT_BORDER = { bull: '#34d6a5', bear: '#ff4757', warn: '#f5c542', neut: '#d4af37', }; const VERDICT_TXT = { bull: 'text-bull', bear: 'text-bear', warn: 'text-gold-hi', neut: 'gold-text', }; function VerdictBanner({ horizon, verdict }) { if (!verdict) return null; const tone = verdict.tone || 'neut'; return (
{horizon}
{verdict.label}
{verdict.detail}
{verdict.drivers && verdict.drivers.length > 0 && (
{verdict.drivers.map((d, i) => ( {d} ))}
)}
); } function ScoreBar({ score, max = 3 }) { const pct = ((score + max) / (2 * max)) * 100; const color = score > 0 ? '#34d6a5' : score < 0 ? '#ff4757' : '#5f5a4d'; return (
= 0 ? '50%' : `${pct}%`, width: score >= 0 ? `${pct - 50}%` : `${50 - pct}%`, boxShadow: `0 0 8px ${color}`, }}/>
{score > 0 ? `+${score}` : score}
); } function RankingTable({ ranking }) { return (
# Devise Court terme Long terme Total
{ranking.map((r, i) => (
#{i + 1}
{r.code}
{r.name}
0 ? 'text-bull' : r.scoreTotal < 0 ? 'text-bear' : 'text-ink-mute' }`}>{r.scoreTotal > 0 ? `+${r.scoreTotal}` : r.scoreTotal}
Drivers court terme
{r.driversShort?.length ? (
    {r.driversShort.map((d, j) => (
  • {d}
  • ))}
) :
Aucun signal CT
}
Drivers long terme
{r.driversLong?.length ? (
    {r.driversLong.map((d, j) => (
  • {d}
  • ))}
) :
Aucun signal LT
}
))}
); } function TradeIdeaCard({ idea }) { const isLong = idea.direction === 'long'; const color = isLong ? '#34d6a5' : '#ff4757'; return (
{isLong ? '↑' : '↓'}
{idea.pair} {idea.direction} {idea.horizon}
{idea.rationale}
); } function TabDirection() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch('/api/direction') .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(d => { setData(d); setLoading(false); }) .catch(e => { setError(e.message); setLoading(false); }); }, []); if (loading) return (
Agrégation de toutes les devises…
Premier calcul ~30s (8 devises × providers). Ensuite cache 15 min.
); if (error) return
Erreur : {error}
; if (!data) return
Aucune donnée.
; return ( <>
Court terme
{(data.tradeIdeasShort || []).length === 0 ? (
Aucune idée CT (écarts trop faibles).
) : (
{data.tradeIdeasShort.map((idea, i) => )}
)}
Long terme
{(data.tradeIdeasLong || []).length === 0 ? (
Aucune idée LT (écarts trop faibles).
) : (
{data.tradeIdeasLong.map((idea, i) => )}
)}
{data.ranking?.length || 0} devises agrégées
); } /* ========== ECO ALERTS (push tel) ======================================== */ const eventClientKey = (e) => `${e.datetime_iso}|${e.country}|${e.title}`; function useAlertsApi() { const [alerts, setAlerts] = useState({}); // map keyed by eventClientKey const [config, setConfig] = useState(null); const reload = useCallback(async () => { try { const [aRes, cRes] = await Promise.all([ fetch('/api/alerts'), fetch('/api/alerts/config'), ]); const aData = aRes.ok ? await aRes.json() : { alerts: [] }; const cData = cRes.ok ? await cRes.json() : null; const map = {}; for (const a of aData.alerts || []) { map[`${a.datetime_iso}|${a.country}|${a.title}`] = a; } setAlerts(map); if (cData) setConfig(cData); } catch (e) { console.warn('alerts reload failed', e); } }, []); useEffect(() => { reload(); }, [reload]); const upsert = async (payload) => { const r = await fetch('/api/alerts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); await reload(); }; const remove = async (id) => { const r = await fetch(`/api/alerts/${id}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); await reload(); }; const saveConfig = async (payload) => { const r = await fetch('/api/alerts/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const next = await r.json(); setConfig(next); }; const testNotif = async () => { const r = await fetch('/api/alerts/test', { method: 'POST' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }; return { alerts, config, reload, upsert, remove, saveConfig, testNotif }; } function BellIcon({ active }) { return ( {active && } ); } function AlertPopover({ event, existing, config, onSave, onRemove, onClose }) { const dflt = config || { default_lead_min: 5, default_channels: 'ntfy,telegram' }; const [lead, setLead] = useState(existing?.lead_min ?? dflt.default_lead_min); const [channels, setChannels] = useState(() => new Set( (existing?.channels || dflt.default_channels).split(',').map(s => s.trim()).filter(Boolean) )); const [customMsg, setCustomMsg] = useState(existing?.custom_msg || ''); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); const toggleChan = (c) => { const n = new Set(channels); if (n.has(c)) n.delete(c); else n.add(c); setChannels(n); }; const handleSave = async () => { if (channels.size === 0) { setErr('Sélectionne au moins un canal'); return; } setSaving(true); setErr(null); try { await onSave({ datetime_iso: event.datetime_iso, title: event.title, country: event.country, impact: event.impact, forecast: event.forecast || '', previous: event.previous || '', lead_min: Number(lead) || 5, channels: [...channels].join(','), custom_msg: customMsg.trim(), enabled: true, }); onClose(); } catch (e) { setErr(e.message); } finally { setSaving(false); } }; const handleRemove = async () => { if (!existing) return; setSaving(true); try { await onRemove(existing.id); onClose(); } catch (e) { setErr(e.message); } finally { setSaving(false); } }; return (
e.stopPropagation()}>
Alerte push
{event.title}
{event.country} · {event.impact} · {new Date(event.datetime_iso).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })}
Délai avant l'event
{[5, 15, 30, 60].map(v => ( ))} setLead(e.target.value)} className="w-16 bg-transparent border border-gold/15 text-ink-soft text-[0.82rem] px-2 py-2 text-center focus:outline-none focus:border-gold/40"/>
Canaux
{[ { k: 'ntfy', label: 'ntfy.sh' }, { k: 'telegram', label: 'Telegram' }, ].map(({ k, label }) => { const on = channels.has(k); return ( ); })}
Message custom (vide = template par défaut)