This commit is contained in:
@@ -41,8 +41,9 @@ import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EditUserModal } from "./edit-user-modal";
|
||||
import LeagueTiersContent from "./league-tiers-content";
|
||||
import ModelPerformanceContent from "./model-performance-content";
|
||||
|
||||
type AdminTab = "overview" | "users" | "league-tiers";
|
||||
type AdminTab = "overview" | "users" | "league-tiers" | "model-performance";
|
||||
|
||||
// ========================
|
||||
// Admin Stat Card
|
||||
@@ -142,6 +143,7 @@ export default function AdminContent() {
|
||||
{ key: "overview", label: t("overview") },
|
||||
{ key: "users", label: t("user-management") },
|
||||
{ key: "league-tiers", label: "Lig Tier" },
|
||||
{ key: "model-performance", label: "Model Performansı" },
|
||||
];
|
||||
|
||||
const getUserDisplayName = (user: AdminUserDto) => {
|
||||
@@ -553,6 +555,9 @@ export default function AdminContent() {
|
||||
{/* League Tiers Tab */}
|
||||
{activeTab === "league-tiers" && <LeagueTiersContent />}
|
||||
|
||||
{/* Model Performance Tab */}
|
||||
{activeTab === "model-performance" && <ModelPerformanceContent />}
|
||||
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
isOpen={!!editingUser}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1239,8 +1239,53 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
marketProbability: uiText("market-probability-short", "Piyasa"),
|
||||
};
|
||||
|
||||
const leagueConfidence = prediction.match_info?.league_confidence;
|
||||
const leagueConfStyles: Record<string, { color: string; label: string }> = {
|
||||
high: {
|
||||
color: "green",
|
||||
label: uiText("league-conf-high", "Bu ligde model güçlü"),
|
||||
},
|
||||
medium: {
|
||||
color: "yellow",
|
||||
label: uiText("league-conf-medium", "Bu ligde model orta"),
|
||||
},
|
||||
low: {
|
||||
color: "red",
|
||||
label: uiText("league-conf-low", "Bu ligde model zayıf"),
|
||||
},
|
||||
};
|
||||
const leagueConfMeta = leagueConfidence
|
||||
? leagueConfStyles[leagueConfidence.label]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<VStack align="stretch" gap={5}>
|
||||
{leagueConfidence && leagueConfMeta ? (
|
||||
<HStack
|
||||
justify="space-between"
|
||||
p={2.5}
|
||||
px={3}
|
||||
borderWidth="1px"
|
||||
borderColor={`${leagueConfMeta.color}.300`}
|
||||
bg={`${leagueConfMeta.color}.50`}
|
||||
borderRadius="lg"
|
||||
_dark={{ bg: `${leagueConfMeta.color}.950` }}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Badge colorPalette={leagueConfMeta.color} variant="solid">
|
||||
{leagueConfMeta.label}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{uiText("league-conf-basis", "geçmiş performans")}: ROI{" "}
|
||||
{leagueConfidence.bet_roi > 0 ? "+" : ""}
|
||||
{leagueConfidence.bet_roi}% · {leagueConfidence.bet_n}{" "}
|
||||
{uiText("bets-short", "bahis")}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
) : null}
|
||||
{isLive ? (
|
||||
<Box
|
||||
p={3}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AnalyticsOverviewDto,
|
||||
LeagueTierDto,
|
||||
LeagueTierStatsDto,
|
||||
ModelPerformanceDto,
|
||||
RetrainStatusDto,
|
||||
SettingDto,
|
||||
UpdateLeagueTierDto,
|
||||
@@ -30,6 +31,16 @@ const getAnalyticsOverview = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Model Performance (forward-test)
|
||||
const getModelPerformance = (days?: number) => {
|
||||
return apiRequest<ApiResponse<ModelPerformanceDto>>({
|
||||
url: "/admin/model-performance",
|
||||
client: "admin",
|
||||
method: "get",
|
||||
params: days ? { days: String(days) } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Settings
|
||||
const getAllSettings = () => {
|
||||
return apiRequest<ApiResponse<Record<string, string>>>({
|
||||
@@ -204,6 +215,7 @@ const getRetrainStatus = () => {
|
||||
|
||||
export const adminService = {
|
||||
getAnalyticsOverview,
|
||||
getModelPerformance,
|
||||
getAllSettings,
|
||||
updateSetting,
|
||||
getAllUsageLimits,
|
||||
|
||||
@@ -134,3 +134,30 @@ export interface AnalyticsOverviewDto {
|
||||
aiHealth?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Model Performance (Forward-Test)
|
||||
// ========================
|
||||
|
||||
export interface ModelPerformanceMarketDto {
|
||||
market: string;
|
||||
samples: number;
|
||||
shown_pct: number;
|
||||
actual_pct: number;
|
||||
gap: number;
|
||||
ece: number;
|
||||
calibration: "good" | "overconfident" | "underconfident";
|
||||
bet_count: number;
|
||||
bet_hit_pct: number;
|
||||
bet_roi_pct: number;
|
||||
actions: Record<string, number>;
|
||||
tiers: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelPerformanceDto {
|
||||
window_days: number;
|
||||
settled_runs: number;
|
||||
settled_markets: number;
|
||||
generated_at: string;
|
||||
markets: ModelPerformanceMarketDto[];
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
export const AdminQueryKeys = {
|
||||
all: ["admin"] as const,
|
||||
analytics: () => [...AdminQueryKeys.all, "analytics"] as const,
|
||||
modelPerformance: (days?: number) =>
|
||||
[...AdminQueryKeys.all, "modelPerformance", days] as const,
|
||||
settings: () => [...AdminQueryKeys.all, "settings"] as const,
|
||||
usageLimits: (params?: AdminPaginationParams) =>
|
||||
[...AdminQueryKeys.all, "usageLimits", params] as const,
|
||||
@@ -32,6 +34,15 @@ export const useAdminAnalytics = (enabled = true) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Model Performance (forward-test)
|
||||
export const useModelPerformance = (days = 90, enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: AdminQueryKeys.modelPerformance(days),
|
||||
queryFn: () => adminService.getModelPerformance(days),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
// Settings
|
||||
export const useAdminSettings = () => {
|
||||
return useQuery({
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface MatchInfoDto {
|
||||
match_date_ms: number;
|
||||
league_id?: string | null;
|
||||
is_top_league?: boolean;
|
||||
// Backtest-derived per-league confidence (ROI + sample size). null = too little data.
|
||||
league_confidence?: {
|
||||
label: "high" | "medium" | "low";
|
||||
bet_roi: number;
|
||||
bet_n: number;
|
||||
hit: number;
|
||||
} | null;
|
||||
sport?: SportType;
|
||||
// Live snapshot — set when the match is in play (used to detect stale predictions)
|
||||
status?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user