330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Heading,
|
||
Text,
|
||
SimpleGrid,
|
||
Card,
|
||
VStack,
|
||
HStack,
|
||
Badge,
|
||
Spinner,
|
||
Table,
|
||
} from "@chakra-ui/react";
|
||
import {
|
||
NativeSelectRoot,
|
||
NativeSelectField,
|
||
} from "@/components/ui/forms/native-select";
|
||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||
import { AnimatedCounter } from "@/components/motion";
|
||
import { useModelPerformance } from "@/lib/api/admin/use-hooks";
|
||
import type { ModelPerformanceMarketDto } from "@/lib/api/admin/types";
|
||
import { LuTarget, LuTrendingUp, LuCircleCheck, LuLayers } from "react-icons/lu";
|
||
import { useState } from "react";
|
||
|
||
const MARKET_LABELS: Record<string, string> = {
|
||
MS: "Maç Sonucu",
|
||
DC: "Çifte Şans",
|
||
OU15: "Üst/Alt 1.5",
|
||
OU25: "Üst/Alt 2.5",
|
||
OU35: "Üst/Alt 3.5",
|
||
BTTS: "Karşılıklı Gol",
|
||
HT: "İlk Yarı Sonucu",
|
||
HT_OU05: "İY Üst/Alt 0.5",
|
||
HT_OU15: "İY Üst/Alt 1.5",
|
||
HTFT: "İlk Yarı / Maç Sonu",
|
||
OE: "Tek / Çift",
|
||
CARDS: "Kart Üst/Alt",
|
||
HCAP: "Handikap",
|
||
};
|
||
|
||
const calibrationLabel = (c: string): { text: string; color: string } => {
|
||
if (c === "good") return { text: "Dürüst", color: "green" };
|
||
if (c === "overconfident") return { text: "Fazla iyimser", color: "red" };
|
||
return { text: "Düşük tahmin", color: "orange" };
|
||
};
|
||
|
||
const roiColor = (roi: number): string =>
|
||
roi > 3 ? "green" : roi < -3 ? "red" : "gray";
|
||
|
||
export default function ModelPerformanceContent() {
|
||
const [days, setDays] = useState(90);
|
||
const { data, isLoading } = useModelPerformance(days);
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.200", "gray.700");
|
||
const headBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
||
|
||
const perf = data?.data;
|
||
const markets = perf?.markets ?? [];
|
||
|
||
return (
|
||
<VStack align="stretch" gap={6}>
|
||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={3}>
|
||
<Box>
|
||
<Heading size="md">Model Performansı (İleri Test)</Heading>
|
||
<Text fontSize="sm" color="fg.muted">
|
||
Her market için "model %X dedi → gerçekte %Y oldu".
|
||
Sonuçlanmış gerçek maçlardan otomatik hesaplanır (lookahead yok).
|
||
</Text>
|
||
</Box>
|
||
<NativeSelectRoot maxW="180px">
|
||
<NativeSelectField
|
||
value={String(days)}
|
||
onChange={(e) => setDays(Number(e.target.value))}
|
||
>
|
||
<option value="30">Son 30 gün</option>
|
||
<option value="90">Son 90 gün</option>
|
||
<option value="180">Son 180 gün</option>
|
||
<option value="365">Son 1 yıl</option>
|
||
</NativeSelectField>
|
||
</NativeSelectRoot>
|
||
</Flex>
|
||
|
||
{isLoading ? (
|
||
<Flex justify="center" py={12}>
|
||
<Spinner size="lg" />
|
||
</Flex>
|
||
) : !perf || markets.length === 0 ? (
|
||
<Card.Root bg={cardBg} borderColor={borderColor}>
|
||
<Card.Body>
|
||
<VStack gap={2} py={8}>
|
||
<Text fontWeight="semibold">Henüz yeterli sonuçlanmış veri yok</Text>
|
||
<Text fontSize="sm" color="fg.muted" textAlign="center">
|
||
Tahminler maçlar oynandıkça sonuçlanır. Güvenilir kalibrasyon
|
||
için market başına ~100 sonuçlanmış tahmin gerekir; bu birkaç
|
||
hafta içinde birikir.
|
||
</Text>
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
) : (
|
||
<>
|
||
{/* Summary cards */}
|
||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4}>
|
||
<StatCard
|
||
icon={<LuLayers />}
|
||
label="Sonuçlanan tahmin"
|
||
value={perf.settled_runs}
|
||
bg={cardBg}
|
||
border={borderColor}
|
||
/>
|
||
<StatCard
|
||
icon={<LuTarget />}
|
||
label="Sonuçlanan market"
|
||
value={perf.settled_markets}
|
||
bg={cardBg}
|
||
border={borderColor}
|
||
/>
|
||
<StatCard
|
||
icon={<LuCircleCheck />}
|
||
label="Dürüst market"
|
||
value={markets.filter((m) => m.calibration === "good").length}
|
||
bg={cardBg}
|
||
border={borderColor}
|
||
/>
|
||
<StatCard
|
||
icon={<LuTrendingUp />}
|
||
label="Kârlı market (BET)"
|
||
value={markets.filter((m) => m.bet_roi_pct > 3).length}
|
||
bg={cardBg}
|
||
border={borderColor}
|
||
/>
|
||
</SimpleGrid>
|
||
|
||
{/* Calibration + ROI table */}
|
||
<Card.Root bg={cardBg} borderColor={borderColor}>
|
||
<Card.Body p={0}>
|
||
<Box overflowX="auto">
|
||
<Table.Root size="sm" striped>
|
||
<Table.Header bg={headBg}>
|
||
<Table.Row>
|
||
<Table.ColumnHeader>Market</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">N</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
Model %
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
Gerçek %
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
Fark
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="center">
|
||
Kalibrasyon
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
Bahis
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
İsabet %
|
||
</Table.ColumnHeader>
|
||
<Table.ColumnHeader textAlign="end">
|
||
ROI %
|
||
</Table.ColumnHeader>
|
||
</Table.Row>
|
||
</Table.Header>
|
||
<Table.Body>
|
||
{markets.map((m) => {
|
||
const cal = calibrationLabel(m.calibration);
|
||
return (
|
||
<Table.Row key={m.market}>
|
||
<Table.Cell>
|
||
<VStack align="start" gap={0}>
|
||
<Text fontWeight="semibold">{m.market}</Text>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{MARKET_LABELS[m.market] ?? m.market}
|
||
</Text>
|
||
</VStack>
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end">{m.samples}</Table.Cell>
|
||
<Table.Cell textAlign="end">
|
||
{m.shown_pct.toFixed(1)}%
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end" fontWeight="semibold">
|
||
{m.actual_pct.toFixed(1)}%
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end">
|
||
<Text
|
||
color={`${cal.color}.500`}
|
||
fontWeight="medium"
|
||
>
|
||
{m.gap > 0 ? "+" : ""}
|
||
{m.gap.toFixed(1)}
|
||
</Text>
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="center">
|
||
<Badge colorPalette={cal.color} variant="subtle">
|
||
{cal.text}
|
||
</Badge>
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end">
|
||
{m.bet_count}
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end">
|
||
{m.bet_count > 0
|
||
? `${m.bet_hit_pct.toFixed(0)}%`
|
||
: "—"}
|
||
</Table.Cell>
|
||
<Table.Cell textAlign="end">
|
||
{m.bet_count > 0 ? (
|
||
<Text
|
||
color={`${roiColor(m.bet_roi_pct)}.500`}
|
||
fontWeight="semibold"
|
||
>
|
||
{m.bet_roi_pct > 0 ? "+" : ""}
|
||
{m.bet_roi_pct.toFixed(1)}%
|
||
</Text>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
);
|
||
})}
|
||
</Table.Body>
|
||
</Table.Root>
|
||
</Box>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{/* Decision rationale (why picks come out) */}
|
||
<Card.Root bg={cardBg} borderColor={borderColor}>
|
||
<Card.Body>
|
||
<Heading size="sm" mb={1}>
|
||
Öneri gerekçesi (karar dağılımı)
|
||
</Heading>
|
||
<Text fontSize="sm" color="fg.muted" mb={4}>
|
||
Her market için betting brain'in ne karar verdiği:
|
||
BET (oyna), WATCH (izle), REJECT (ele).
|
||
</Text>
|
||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={3}>
|
||
{markets.map((m) => (
|
||
<Box
|
||
key={m.market}
|
||
p={3}
|
||
borderWidth="1px"
|
||
borderColor={borderColor}
|
||
borderRadius="lg"
|
||
>
|
||
<HStack justify="space-between" mb={2}>
|
||
<Text fontWeight="semibold">{m.market}</Text>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{MARKET_LABELS[m.market] ?? ""}
|
||
</Text>
|
||
</HStack>
|
||
<HStack gap={1} flexWrap="wrap">
|
||
{Object.entries(m.actions)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([action, count]) => (
|
||
<Badge
|
||
key={action}
|
||
variant="subtle"
|
||
colorPalette={
|
||
action === "BET"
|
||
? "green"
|
||
: action === "WATCH"
|
||
? "blue"
|
||
: action === "REJECT"
|
||
? "gray"
|
||
: "purple"
|
||
}
|
||
>
|
||
{action}: {count}
|
||
</Badge>
|
||
))}
|
||
</HStack>
|
||
</Box>
|
||
))}
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
<Text fontSize="xs" color="fg.muted">
|
||
Son güncelleme:{" "}
|
||
{perf.generated_at
|
||
? new Date(perf.generated_at).toLocaleString("tr-TR")
|
||
: "—"}{" "}
|
||
· Pencere: {perf.window_days} gün · ECE düşük = daha dürüst
|
||
</Text>
|
||
</>
|
||
)}
|
||
</VStack>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
icon,
|
||
label,
|
||
value,
|
||
bg,
|
||
border,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
value: number;
|
||
bg: string;
|
||
border: string;
|
||
}) {
|
||
return (
|
||
<Card.Root bg={bg} borderColor={border}>
|
||
<Card.Body>
|
||
<HStack gap={3}>
|
||
<Box color="primary.400" fontSize="xl">
|
||
{icon}
|
||
</Box>
|
||
<VStack align="start" gap={0}>
|
||
<Text fontSize="2xl" fontWeight="bold">
|
||
<AnimatedCounter value={value} />
|
||
</Text>
|
||
<Text fontSize="xs" color="fg.muted">
|
||
{label}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
);
|
||
}
|