diff --git a/messages/en.json b/messages/en.json index 158ecaf..39d8ed8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -342,7 +342,36 @@ "risk-label": "Risk", "data-quality-label": "Data Quality", "rejected-matches-title": "Rejected Matches", - "no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest." + "no-suggestion-yet": "No coupon has been generated yet. Choose a strategy and click AI Suggest.", + "freq-engine-title": "Frequency Engine", + "freq-engine-subtitle": "Analyzes teams' historical performance by odds band. Uses statistical database scans instead of AI models.", + "freq-suggest": "Generate Frequency Coupon", + "freq-suggest-loading": "Running frequency analysis...", + "freq-min-signal": "Minimum Signal", + "freq-min-signal-help": "Combined signal threshold (0.50-0.99). Lower = more matches, higher = more precise results.", + "freq-markets": "Markets", + "freq-markets-help": "Select markets to analyze. Leave empty to scan all markets.", + "freq-ev-label": "Expected Value (EV)", + "freq-ev-help": "Hit Rate × Total Odds. Above 1.0 means +EV (profitable).", + "freq-hit-rate": "Est. Hit Rate", + "freq-hit-rate-help": "Combined historical hit rate of all bets in the coupon.", + "freq-ev-positive": "+EV Positive", + "freq-ev-negative": "EV Negative", + "freq-home-signal": "Home Signal", + "freq-away-signal": "Away Signal", + "freq-combined-signal": "Combined Signal", + "freq-odds-band": "Odds Band", + "freq-league-profile": "League Profile", + "freq-league-golcu": "High-Scoring", + "freq-league-defansif": "Defensive", + "freq-league-normal": "Normal", + "freq-match-count": "Past Matches", + "freq-reasoning-title": "Analysis Details", + "freq-no-result": "No matches found meeting frequency analysis criteria. Try lowering the signal threshold.", + "freq-mode-active": "Frequency Engine active", + "ai-mode-active": "AI Engine active", + "engine-mode-label": "Engine Mode", + "engine-mode-help": "AI: Gemini-based AI prediction. Frequency: Database-driven statistical analysis." }, "profile": { diff --git a/messages/tr.json b/messages/tr.json index 1600d1c..a927c31 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -331,7 +331,36 @@ "risk-label": "Risk", "data-quality-label": "Veri Kalitesi", "rejected-matches-title": "Elenen Maçlar", - "no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın." + "no-suggestion-yet": "Henüz kupon üretilmedi. Strateji seçip AI Öner butonuna basın.", + "freq-engine-title": "Frekans Motoru", + "freq-engine-subtitle": "Takımların oran bandına göre tarihsel performansını analiz eder. AI modeli yerine istatistiksel veritabanı taraması kullanır.", + "freq-suggest": "Frekans Kuponu Oluştur", + "freq-suggest-loading": "Frekans analizi çalışıyor...", + "freq-min-signal": "Minimum Sinyal", + "freq-min-signal-help": "Kombinasyon sinyal eşiği (0.50-0.99). Düşürürseniz daha fazla maç bulunur, yükseltirseniz daha kesin sonuçlar gelir.", + "freq-markets": "Marketler", + "freq-markets-help": "Analiz edilecek marketleri seçin. Boş bırakırsanız tüm marketler taranır.", + "freq-ev-label": "Beklenen Değer (EV)", + "freq-ev-help": "Hit Rate × Toplam Oran. 1.0'ın üzeri +EV (karlı) anlamına gelir.", + "freq-hit-rate": "Tahmini İsabet", + "freq-hit-rate-help": "Tüm bahislerin birleşik tarihsel isabet oranı.", + "freq-ev-positive": "+EV Pozitif", + "freq-ev-negative": "EV Negatif", + "freq-home-signal": "Ev Sinyali", + "freq-away-signal": "Dep Sinyali", + "freq-combined-signal": "Kombine Sinyal", + "freq-odds-band": "Oran Bandı", + "freq-league-profile": "Lig Profili", + "freq-league-golcu": "Golcü", + "freq-league-defansif": "Defansif", + "freq-league-normal": "Normal", + "freq-match-count": "Geçmiş Maç", + "freq-reasoning-title": "Analiz Detayları", + "freq-no-result": "Frekans analizine uygun yeterli maç bulunamadı. Sinyal eşiğini düşürmeyi deneyin.", + "freq-mode-active": "Frekans Motoru aktif", + "ai-mode-active": "AI Motoru aktif", + "engine-mode-label": "Motor Seçimi", + "engine-mode-help": "AI: Gemini tabanlı yapay zeka tahmini. Frekans: Veritabanı tabanlı istatistiksel analiz." }, "profile": { "title": "Profil", diff --git a/src/components/coupons/coupon-builder-content.tsx b/src/components/coupons/coupon-builder-content.tsx index 194bf32..3d6abfa 100644 --- a/src/components/coupons/coupon-builder-content.tsx +++ b/src/components/coupons/coupon-builder-content.tsx @@ -23,6 +23,7 @@ import { LuBadgeAlert, LuCheck, LuCircleHelp, + LuDatabase, LuEye, LuEyeOff, LuLayers3, @@ -38,6 +39,7 @@ import { import { SlideUp } from "@/components/motion"; import { useColorModeValue } from "@/components/ui/color-mode"; import { Tooltip } from "@/components/ui/overlays/tooltip"; +import FrequencyPanel from "@/components/coupons/frequency-panel"; import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks"; import type { CouponItemDto, @@ -352,6 +354,7 @@ export default function CouponBuilderContent() { SmartCouponResultDto | undefined >(undefined); const [matchCount, setMatchCount] = React.useState(5); // Default: 5 matches + const [engineMode, setEngineMode] = React.useState<"ai" | "frequency">("ai"); React.useEffect(() => { if (!upcomingQuery.data && !upcomingQuery.isPending) { @@ -763,6 +766,42 @@ export default function CouponBuilderContent() { + {/* Engine Mode Toggle */} + + + + {t("engine-mode-label")} + + + + setEngineMode("ai")} + > + AI + + setEngineMode("frequency")} + > + Frekans + + + + {engineMode === "ai" ? t("ai-mode-active") : t("freq-mode-active")} + + + + + + {engineMode === "frequency" ? ( + + ) : ( + <> + + )} diff --git a/src/components/coupons/frequency-panel.tsx b/src/components/coupons/frequency-panel.tsx new file mode 100644 index 0000000..a12c8fa --- /dev/null +++ b/src/components/coupons/frequency-panel.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { + Badge, + Box, + Button, + Card, + Flex, + Grid, + Heading, + HStack, + Icon, + IconButton, + Separator, + Text, + VStack, +} from "@chakra-ui/react"; +import { useTranslations } from "next-intl"; +import React from "react"; +import { + LuBadgeAlert, + LuBarChart3, + LuCircleHelp, + LuDatabase, + LuTrendingUp, + LuZap, +} from "react-icons/lu"; +import { useColorModeValue } from "@/components/ui/color-mode"; +import { Tooltip } from "@/components/ui/overlays/tooltip"; +import { useGenerateFrequencyCoupon } from "@/lib/api/coupons/use-hooks"; +import type { + FrequencyCouponResultDto, + FrequencyCouponBetDto, +} from "@/lib/api/coupons/types"; +import { ApiError } from "@/lib/api/create-api-client"; +import { useCouponStore } from "@/lib/stores/coupon-store"; + +const AVAILABLE_MARKETS = ["OU1.5", "OU2.5", "OU3.5", "BTTS", "MS"]; + +function InfoIcon({ content, label }: { content: string; label: string }) { + return ( + + + + + + ); +} + +const profileColor = (p: string) => + ({ GOLCU: "red", DEFANSIF: "blue", NORMAL: "gray" })[p] || "gray"; + +const profileLabel = (p: string, t: ReturnType) => + ({ + GOLCU: t("freq-league-golcu"), + DEFANSIF: t("freq-league-defansif"), + NORMAL: t("freq-league-normal"), + })[p] || p; + +export default function FrequencyPanel() { + const t = useTranslations("coupons"); + const { addItem, clearCoupon } = useCouponStore(); + + const cardBg = useColorModeValue("white", "gray.800"); + const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50"); + const borderColor = useColorModeValue("gray.200", "gray.700"); + + const freqMutation = useGenerateFrequencyCoupon(); + + const [minSignal, setMinSignal] = React.useState(0.65); + const [maxMatches, setMaxMatches] = React.useState(3); + const [selectedMarkets, setSelectedMarkets] = React.useState([]); + const [result, setResult] = React.useState< + FrequencyCouponResultDto | undefined + >(undefined); + + const toggleMarket = (m: string) => + setSelectedMarkets((prev) => + prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m], + ); + + const handleGenerate = () => { + freqMutation.mutate( + { + maxMatches, + minSignal, + markets: selectedMarkets.length > 0 ? selectedMarkets : undefined, + }, + { + onSuccess: (response) => { + const data = (response as any)?.data ?? response; + setResult(data as FrequencyCouponResultDto); + // Sync to coupon store + if (data && Array.isArray((data as any).bets)) { + clearCoupon(); + (data as FrequencyCouponResultDto).bets.forEach( + (bet: FrequencyCouponBetDto) => + addItem({ + matchId: bet.match_id, + matchName: bet.match_name, + market: bet.market, + pick: bet.pick, + odd: bet.odds, + }), + ); + } + }, + onError: () => setResult(undefined), + }, + ); + }; + + const errorMessage = + freqMutation.error instanceof ApiError + ? freqMutation.error.message + : freqMutation.error instanceof Error + ? freqMutation.error.message + : undefined; + + return ( + + {/* Controls Card */} + + + + + + {t("freq-engine-title")} + + + + + {t("freq-engine-subtitle")} + + + + {/* Min Signal Slider */} + + + + + + {t("freq-min-signal")} + + + + + {(minSignal * 100).toFixed(0)}% + + + setMinSignal(Number(e.target.value) / 100)} + style={{ width: "100%", accentColor: "#0891b2", cursor: "pointer" }} + /> + + 50% + 95% + + + + {/* Max Matches */} + + + + + + {t("match-count-label")} + + + + {maxMatches} + + + setMaxMatches(Number(e.target.value))} + style={{ width: "100%", accentColor: "#9333ea", cursor: "pointer" }} + /> + + 2 + 5 + + + + + + {/* Market Filter */} + + + + {t("freq-markets")} + + + + + {AVAILABLE_MARKETS.map((m) => { + const active = selectedMarkets.includes(m); + return ( + toggleMarket(m)} + _hover={{ opacity: 0.8 }} + > + {m} + + ); + })} + + {selectedMarkets.length === 0 && ( + + Tüm marketler taranacak + + )} + + + + + + + {/* Results Card */} + {result && result.bets.length > 0 && ( + + + + + + + {t("freq-engine-title")} + + + + {result.ev_positive + ? t("freq-ev-positive") + : t("freq-ev-negative")} + + + + + + {/* EV Stats */} + + + + {t("freq-ev-label")} + + + {result.expected_value.toFixed(3)} + + + + + {t("freq-hit-rate")} + + + {(result.expected_hit_rate * 100).toFixed(1)}% + + + + + {t("total-odds")} + + + {result.total_odds.toFixed(2)} + + + + + {/* Bets */} + {result.bets.map((bet: FrequencyCouponBetDto) => ( + + + + {bet.match_name} + + {bet.league} • {bet.market}: {bet.pick} + + + + {bet.odds.toFixed(2)} + + + + + + {t("freq-home-signal")} + + + {(bet.home_signal * 100).toFixed(0)}% + + + {bet.home_odds_band} + + + + + {t("freq-away-signal")} + + + {(bet.away_signal * 100).toFixed(0)}% + + + {bet.away_odds_band} + + + + + {t("freq-combined-signal")} + + + {(bet.combined_signal * 100).toFixed(0)}% + + + + + + {t("freq-league-profile")}:{" "} + {profileLabel(bet.league_profile, t)} + + + {t("freq-match-count")}: {bet.home_match_count}/ + {bet.away_match_count} + + + + ))} + + {/* Reasoning */} + {result.reasoning.length > 0 && ( + + + {t("freq-reasoning-title")} + + + {result.reasoning.map((r, i) => ( + + • {r} + + ))} + + + )} + + {/* Rejected */} + {result.rejected_matches.length > 0 && ( + + + + {t("rejected-matches-title")} + + + {result.rejected_matches.map((entry, i) => ( + + {entry.match_name}: {entry.reason} + + ))} + + + )} + + + + )} + + {/* No result message */} + {result && result.bets.length === 0 && ( + + + + {t("freq-no-result")} + + + + )} + + {/* Error */} + {errorMessage && ( + + + {errorMessage} + + + )} + + ); +} diff --git a/src/lib/api/coupons/service.ts b/src/lib/api/coupons/service.ts index 912de65..39e9169 100644 --- a/src/lib/api/coupons/service.ts +++ b/src/lib/api/coupons/service.ts @@ -10,6 +10,8 @@ import { MatchAnalysisResultDto, DailyBankoResponseDto, SmartCouponResultDto, + FrequencyCouponRequestDto, + FrequencyCouponResultDto, } from "./types"; /** @@ -70,6 +72,15 @@ const suggestCoupon = (dto: SuggestCouponDto) => { }); }; +const generateFrequencyCoupon = (dto: FrequencyCouponRequestDto) => { + return apiRequest>({ + url: "/coupon/frequency-coupon", + client: "core", + method: "post", + data: dto, + }); +}; + export const couponsService = { analyzeMatch, createCoupon, @@ -77,4 +88,6 @@ export const couponsService = { getHistory, getUserStats, suggestCoupon, + generateFrequencyCoupon, }; + diff --git a/src/lib/api/coupons/types.ts b/src/lib/api/coupons/types.ts index 04b7907..b30cd8b 100644 --- a/src/lib/api/coupons/types.ts +++ b/src/lib/api/coupons/types.ts @@ -106,3 +106,50 @@ export interface SmartCouponResultDto { expected_win_rate: number; rejected_matches: SuggestedCouponRejectedMatchDto[]; } + +// ======================== +// Frequency Engine DTOs +// ======================== + +export interface FrequencyCouponRequestDto { + matchIds?: string[]; + maxMatches?: number; + minSignal?: number; + markets?: string[]; +} + +export interface FrequencyCouponBetDto { + match_id: string; + match_name: string; + league: string; + market: string; + pick: string; + home_signal: number; + away_signal: number; + combined_signal: number; + league_profile: string; + historical_hit_rate: number; + odds: number; + home_odds_band: string; + away_odds_band: string; + home_match_count: number; + away_match_count: number; +} + +export interface FrequencyCouponRejectedDto { + match_id: string; + match_name: string; + reason: string; +} + +export interface FrequencyCouponResultDto { + strategy: "FREQUENCY"; + generated_at: string; + bets: FrequencyCouponBetDto[]; + total_odds: number; + expected_hit_rate: number; + expected_value: number; + ev_positive: boolean; + reasoning: string[]; + rejected_matches: FrequencyCouponRejectedDto[]; +} diff --git a/src/lib/api/coupons/use-hooks.ts b/src/lib/api/coupons/use-hooks.ts index 99bd24c..f6af311 100644 --- a/src/lib/api/coupons/use-hooks.ts +++ b/src/lib/api/coupons/use-hooks.ts @@ -4,6 +4,7 @@ import type { CreateCouponDto, SuggestCouponDto, AnalyzeMatchDto, + FrequencyCouponRequestDto, } from "./types"; export const CouponsQueryKeys = { @@ -55,3 +56,11 @@ export const useSuggestCoupon = () => { mutationFn: (dto: SuggestCouponDto) => couponsService.suggestCoupon(dto), }); }; + +export const useGenerateFrequencyCoupon = () => { + return useMutation({ + mutationFn: (dto: FrequencyCouponRequestDto) => + couponsService.generateFrequencyCoupon(dto), + }); +}; +