This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user