beAnalytic's picture
feat: sync main with feature/superbet-live-inplay
16c19b8 verified
Raw
History Blame Contribute Delete
11.9 kB
import type { SuperbetLiveAdvice } from "@/domain/entities";
import { formatPercent } from "@/presentation/theme";
function OverroundBadge({ overround }: { overround: number }) {
const pct = overround * 100;
const { cls, icon, tip } =
pct < 5
? {
cls: "bg-neon-green/15 text-neon-green border border-neon-green/30",
icon: "↓",
tip: "Margem baixa — favorável ao apostador",
}
: pct < 10
? {
cls: "bg-amber-500/15 text-amber-300 border border-amber-500/30",
icon: "~",
tip: "Margem típica",
}
: {
cls: "bg-red-500/15 text-red-300 border border-red-500/30",
icon: "↑",
tip: "Margem alta — desfavorável ao apostador",
};
return (
<span
className={`rounded-md px-2 py-0.5 font-mono text-[11px] font-semibold ${cls}`}
title={tip}
>
{icon} Margem {pct.toFixed(1)}%
</span>
);
}
interface ProbBarProps {
label: string;
prob: number;
color?: "green" | "amber" | "slate" | "blue";
title?: string;
}
function ProbBar({ label, prob, color = "slate", title }: ProbBarProps) {
const barColors: Record<string, string> = {
green: "bg-neon-green/70",
amber: "bg-amber-400/70",
blue: "bg-sky-400/70",
slate: "bg-slate-400/50",
};
const textColors: Record<string, string> = {
green: "text-neon-green",
amber: "text-amber-300",
blue: "text-sky-300",
slate: "text-slate-300",
};
const widthPct = Math.max(0, Math.min(100, prob * 100));
return (
<div className="flex items-center gap-2">
<p
className="w-28 shrink-0 truncate text-[11px] text-slate-400"
title={title ?? label}
>
{label}
</p>
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-white/8">
<div
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColors[color]}`}
style={{ width: `${widthPct}%` }}
/>
</div>
<span className={`w-10 text-right font-mono text-[11px] font-semibold ${textColors[color]}`}>
{formatPercent(prob)}
</span>
</div>
);
}
function h2hColor(key: "1" | "X" | "2", probs: { home: number; draw: number; away: number }): "green" | "amber" | "slate" {
const max = Math.max(probs.home, probs.draw, probs.away);
if (key === "1" && probs.home === max) return "green";
if (key === "2" && probs.away === max) return "green";
if (key === "X" && probs.draw === max) return "amber";
return "slate";
}
interface EdgeMiniCardProps {
label: string;
labelFull?: string;
odd: number | undefined;
implied: number | undefined;
model: number | undefined;
edge: number | undefined;
}
function EdgeMiniCard({ label, odd, implied, model, edge }: EdgeMiniCardProps) {
const edgePp = edge != null ? edge * 100 : null;
const edgeColor =
edgePp == null
? "text-slate-400"
: edgePp > 2
? "text-neon-green"
: edgePp < -2
? "text-red-400"
: "text-slate-300";
return (
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
<p className="truncate text-[10px] uppercase tracking-wider text-slate-500" title={label}>
{label}
</p>
{odd != null ? (
<>
<p className="font-mono text-sm font-semibold text-white">
{odd.toFixed(2)}
{implied != null && (
<span className="ml-1.5 text-xs text-slate-500">
({formatPercent(implied)})
</span>
)}
</p>
{model != null && edgePp != null && (
<p className={`mt-1 text-[11px] ${edgeColor}`}>
Modelo {formatPercent(model)} · edge {edgePp > 0 ? "+" : ""}
{edgePp.toFixed(1)} pp
</p>
)}
{edgePp != null && edgePp > 2 && (
<p className="mt-0.5 text-[10px] text-neon-green/70">Valor p/ apostador</p>
)}
{edgePp != null && edgePp < -2 && (
<p className="mt-0.5 text-[10px] text-red-400/70">Casa otimista</p>
)}
</>
) : (
<p className="text-xs text-slate-600"></p>
)}
</div>
);
}
interface LiveModelPanelProps {
data: SuperbetLiveAdvice;
}
export function LiveModelPanel({ data }: LiveModelPanelProps) {
const s = data.inplaySummary;
const probs = { home: s.probFinalHome, draw: s.probFinalDraw, away: s.probFinalAway };
function teamLabel(name: string, maxLen = 14): string {
const t = name.trim();
return t.length <= maxLen ? t : `${t.slice(0, maxLen - 1)}…`;
}
return (
<div className="space-y-5">
{/* Probabilidades de resultado final */}
<div className="rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-400">
Modelo in-play
</h3>
<div className="space-y-2.5">
<ProbBar
label={teamLabel(data.homeTeam)}
title={data.homeTeam}
prob={s.probFinalHome}
color={h2hColor("1", probs)}
/>
<ProbBar
label="Empate"
prob={s.probFinalDraw}
color={h2hColor("X", probs)}
/>
<ProbBar
label={teamLabel(data.awayTeam)}
title={data.awayTeam}
prob={s.probFinalAway}
color={h2hColor("2", probs)}
/>
</div>
{(s.over25 != null || s.btts != null) && (
<>
<div className="my-3 h-px bg-white/8" />
<div className="space-y-2">
{s.over25 != null && (
<ProbBar label="Over 2.5 gols" prob={s.over25} color="blue" />
)}
{s.btts != null && (
<ProbBar label="Ambos marcam" prob={s.btts} color="blue" />
)}
{s.probNextGoalHome != null && (
<ProbBar
label={`Próx. gol ${teamLabel(data.homeTeam, 9)}`}
title={`Próximo gol ${data.homeTeam}`}
prob={s.probNextGoalHome}
color="slate"
/>
)}
{s.probNextGoalAway != null && (
<ProbBar
label={`Próx. gol ${teamLabel(data.awayTeam, 9)}`}
title={`Próximo gol ${data.awayTeam}`}
prob={s.probNextGoalAway}
color="slate"
/>
)}
{s.probNoMoreGoals != null && (
<ProbBar label="Sem mais gols" prob={s.probNoMoreGoals} color="slate" />
)}
</div>
</>
)}
</div>
{/* Posição da casa — edge 1X2 */}
<div className="rounded-2xl border border-violet-500/15 bg-violet-500/[0.03] p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Posição da casa
</h3>
{data.h2hOverround != null && <OverroundBadge overround={data.h2hOverround} />}
</div>
<div className="grid grid-cols-3 gap-2">
{(["1", "X", "2"] as const).map((key) => {
const odd = data.h2hOdds[key];
const implied = data.h2hImplied[key];
const bench = data.marketBenchmark?.h2h?.[key];
const label =
key === "1"
? teamLabel(data.homeTeam)
: key === "2"
? teamLabel(data.awayTeam)
: "Empate";
const fullLabel =
key === "1" ? data.homeTeam : key === "2" ? data.awayTeam : "Empate";
return (
<EdgeMiniCard
key={key}
label={label}
labelFull={fullLabel}
odd={odd}
implied={implied}
model={bench?.model}
edge={bench?.edge}
/>
);
})}
</div>
{/* Totais de gols */}
{data.marketBenchmark?.totals &&
Object.keys(data.marketBenchmark.totals).length > 0 && (
<div className="mt-3">
<p className="mb-1.5 text-[10px] uppercase tracking-wider text-slate-600">
Totais de gols
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(data.marketBenchmark.totals).map(([line, row]) => {
const edgePp = row.edgeOver * 100;
return (
<span
key={line}
className="rounded-md border border-white/8 bg-white/[0.03] px-2 py-1 text-[10px] text-slate-400"
>
Over {line}{" "}
<span className={edgePp > 0 ? "text-neon-green" : "text-red-400"}>
{edgePp > 0 ? "+" : ""}
{edgePp.toFixed(1)} pp
</span>
</span>
);
})}
</div>
</div>
)}
{/* Generosity — prob real da casa (sem margem) */}
{Object.keys(data.generosityProbs).length > 0 && (
<div className="mt-4 rounded-xl border border-emerald-500/15 bg-emerald-500/[0.05] p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-emerald-400/70">
Probabilidade real da Superbet (sem margem)
</p>
<div className="flex flex-wrap items-center gap-4">
{data.generosityProbs.home != null && (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-slate-400">{teamLabel(data.homeTeam, 10)}</span>
<span className="font-mono text-sm font-semibold text-emerald-300">
{formatPercent(data.generosityProbs.home)}
</span>
</div>
)}
{data.generosityProbs.away != null && (
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-slate-400">{teamLabel(data.awayTeam, 10)}</span>
<span className="font-mono text-sm font-semibold text-emerald-300">
{formatPercent(data.generosityProbs.away)}
</span>
</div>
)}
</div>
<p className="mt-1.5 text-[10px] text-slate-500">
A casa já removeu a margem — esse é o "preço justo" que ela atribui a cada time.
Quando nosso modelo diverge muito daqui, há valor.
</p>
</div>
)}
{/* Confiança do modelo */}
{data.confidence && (
<div className="mt-4 rounded-xl border border-white/8 bg-white/[0.02] p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">
Confiança nos dados
</p>
<span
className={`rounded-md px-2 py-0.5 font-mono text-[11px] font-semibold ${
data.confidence.score >= 0.7
? "bg-emerald-500/15 text-emerald-300"
: data.confidence.score >= 0.3
? "bg-amber-500/15 text-amber-300"
: "bg-red-500/15 text-red-300"
}`}
>
{data.confidence.label} · {(data.confidence.score * 100).toFixed(0)}%
</span>
</div>
<p className="text-[11px] text-slate-400">{data.confidence.reason}</p>
</div>
)}
</div>
</div>
);
}