/* QUANTA · Detail enrichments + Alerts panel
- QAlertsPanel: slide-in side panel listing recent edge events
- QKellyCard: best-edge → Kelly/3 stake suggestion
- QEventLog: recent ticks/score events for a match
- QEvolutionChart: multi-bookmaker line chart per side
Loads after quanta-app.jsx and assigns components on window. */
const { useEffect: useEffect_d, useState: useState_d, useMemo: useMemo_d, useRef: useRef_d } = React;
/* ──────────────────────────────────────────────────────────
ALERTS PANEL
────────────────────────────────────────────────────────── */
function QAlertsPanel({ open, onClose, alerts, tick, onOpenMatch }) {
const closeRef = useRef_d(null);
// Lock body scroll while open
useEffect_d(() => {
if (open) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}
}, [open]);
// Close on Esc
useEffect_d(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Focus trap: transfer focus to the close button on open so keyboard
// users land inside the dialog instead of the trigger button outside.
useEffect_d(() => {
if (open && closeRef.current) closeRef.current.focus();
}, [open]);
return (
<>
>
);
}
/* ──────────────────────────────────────────────────────────
KELLY / 3 STAKE CARD
────────────────────────────────────────────────────────── */
function QKellyCard({ match, divisor = 3 }) {
// Find best positive edge across all BMs and both sides.
// Kelly fraction = edge / (odds - 1). We display Kelly/divisor (user-set).
const candidates = [];
for (const [name, b] of Object.entries(match.bm)) {
if (b.e1 != null && b.e1 > 0 && b.p1 != null) candidates.push({ bm: name, side: "p1", odds: b.p1, edge: b.e1 });
if (b.e2 != null && b.e2 > 0 && b.p2 != null) candidates.push({ bm: name, side: "p2", odds: b.p2, edge: b.e2 });
// Football 1X2 markets expose a draw side. Tennis dicts never carry
// eDraw so this branch is a natural no-op there.
if (b.eDraw != null && b.eDraw > 0 && b.draw != null) candidates.push({ bm: name, side: "draw", odds: b.draw, edge: b.eDraw });
}
candidates.sort((a, b) => b.edge - a.edge);
const best = candidates[0];
if (!best) {
return (
Kelly / {divisor} · stake suggéré
0%
Aucun edge positif disponible — ne rien parier.
Le système ne recommande de mise que lorsqu'un BM offre une cote au-dessus de la baseline Betfair LAY.
);
}
const pickName = best.side === "draw"
? "Match nul"
: (best.side === "p1" ? match.p1 : match.p2);
const pickShortName = best.side === "draw" ? "X (match nul)" : pickName.split(",")[0];
const kelly = best.edge / (best.odds - 1);
const kellyDiv = kelly / divisor;
const bfBaseline = match.bf ? (best.side === "draw" ? match.bf.draw : (best.side === "p1" ? match.bf.p1 : match.bf.p2)) : null;
// HelpTip lives on window (defined in quanta-app.jsx, assigned globally by babel).
const Help = (typeof HelpTip !== "undefined") ? HelpTip : (({children}) => children);
return (
Kelly / {divisor} · stake suggéré
{(kellyDiv * 100).toFixed(2)}%
de la bankroll · {pickShortName} @ {best.odds.toFixed(2)} chez {best.bm}
Edge
+{(best.edge * 100).toFixed(2)}%
Kelly full
{(kelly * 100).toFixed(2)}%
Sur 1000 €
{(kellyDiv * 1000).toFixed(2)} €
Profit attendu
+{(kellyDiv * 1000 * (best.odds - 1)).toFixed(2)} €
f* = edge / (odds − 1) · on prend f* / {divisor} (modifiable dans ton profil) · base : BF LAY {bfBaseline != null ? bfBaseline.toFixed(2) : "—"}
);
}
/* ──────────────────────────────────────────────────────────
EVENT LOG
────────────────────────────────────────────────────────── */
function QEventLog({ match, tick }) {
const events = match.events || [];
return (
Event log
{events.length === 0 ? (
—
) : events.map((ev, i) => {
const age = Math.max(0, Date.now() / 1000 - ev.ts);
const t = age < 60 ? `${Math.round(age)}s` : `${Math.round(age/60)}m`;
return (
{t}
{ev.kind || "tick"}
{ev.text}
);
})}
);
}
/* ──────────────────────────────────────────────────────────
EVOLUTION CHART (multi-bookmaker line chart per side)
────────────────────────────────────────────────────────── */
function QEvolutionChart({ match, side, height = 220 }) {
// Collect all series for this side; BF goes dashed as baseline.
const series = match.history[side];
const names = Object.keys(series);
if (!names.length) return null;
const allPts = names.flatMap(n => series[n]);
const min = Math.min(...allPts);
const max = Math.max(...allPts);
const pad = 0.04 * (max - min || 1);
const yMin = min - pad;
const yMax = max + pad;
const range = yMax - yMin;
const n = series[names[0]].length;
const w = 520, h = height;
const padding = { l: 38, r: 8, t: 8, b: 22 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const xFor = (i) => padding.l + (i / Math.max(1, n - 1)) * innerW;
const yFor = (v) => padding.t + innerH - ((v - yMin) / range) * innerH;
// y-axis ticks
const ySteps = 4;
const yTicks = [];
for (let k = 0; k <= ySteps; k++) {
const v = yMin + (range * k / ySteps);
yTicks.push({ v, y: yFor(v) });
}
const playerName = side === "p1" ? match.p1.split(",")[0] : match.p2.split(",")[0];
const currentVal = match.bf[side];
return (
{playerName} · cotes
BF {currentVal != null ? currentVal.toFixed(2) : "—"}
BF LAY
{Object.keys(match.bm).map(name => (
{name}
))}
);
}
function QChartPanel({ match }) {
const [chartSide, setChartSide] = useState_d("both"); // 'both' | 'p1' | 'p2'
return (
Évolution des cotes · 24 derniers ticks
{(chartSide === "both" || chartSide === "p1") && }
{(chartSide === "both" || chartSide === "p2") && }
);
}
Object.assign(window, { QAlertsPanel, QKellyCard, QEventLog, QEvolutionChart, QChartPanel });