This commit is contained in:
+248
@@ -0,0 +1,248 @@
|
||||
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
|
||||
export type PredictionRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
||||
export type PredictionDataQuality = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
export type BetGrade = 'A' | 'B' | 'C' | 'PASS';
|
||||
|
||||
export interface PredictionPickRow {
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number;
|
||||
raw_confidence: number;
|
||||
calibrated_confidence: number;
|
||||
min_required_confidence: number;
|
||||
edge: number;
|
||||
play_score: number;
|
||||
playable: boolean;
|
||||
bet_grade: BetGrade;
|
||||
stake_units: number;
|
||||
decision_reasons: string[];
|
||||
}
|
||||
|
||||
export interface PredictionBetSummaryRow {
|
||||
market: string;
|
||||
pick: string;
|
||||
raw_confidence: number;
|
||||
calibrated_confidence: number;
|
||||
bet_grade: BetGrade;
|
||||
playable: boolean;
|
||||
stake_units: number;
|
||||
play_score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface SingleMatchPredictionPackage {
|
||||
model_version: string;
|
||||
match_info: {
|
||||
match_id: string;
|
||||
match_name: string;
|
||||
home_team: string;
|
||||
away_team: string;
|
||||
league: string;
|
||||
match_date_ms: number;
|
||||
};
|
||||
data_quality: {
|
||||
label: PredictionDataQuality;
|
||||
score: number;
|
||||
flags: string[];
|
||||
home_lineup_count: number;
|
||||
away_lineup_count: number;
|
||||
};
|
||||
risk: {
|
||||
level: PredictionRiskLevel;
|
||||
score: number;
|
||||
is_surprise_risk: boolean;
|
||||
surprise_type: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
engine_breakdown: {
|
||||
team: number;
|
||||
player: number;
|
||||
odds: number;
|
||||
referee: number;
|
||||
};
|
||||
main_pick: PredictionPickRow | null;
|
||||
value_pick: PredictionPickRow | null;
|
||||
bet_advice: {
|
||||
playable: boolean;
|
||||
suggested_stake_units: number;
|
||||
reason: string;
|
||||
};
|
||||
bet_summary: PredictionBetSummaryRow[];
|
||||
supporting_picks: PredictionPickRow[];
|
||||
aggressive_pick: {
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number | null;
|
||||
} | null;
|
||||
scenario_top5: Array<{
|
||||
score: string;
|
||||
prob: number;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
score_prediction: {
|
||||
ft: string;
|
||||
ht: string;
|
||||
xg_home: number;
|
||||
xg_away: number;
|
||||
xg_total: number;
|
||||
};
|
||||
market_board: Record<string, unknown>;
|
||||
reasoning_factors: string[];
|
||||
ai_commentary?: string | null;
|
||||
}
|
||||
|
||||
export interface SmartCouponResult {
|
||||
strategy: string;
|
||||
generated_at: string;
|
||||
match_count: number;
|
||||
bets: Array<{
|
||||
match_id: string;
|
||||
match_name: string;
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number;
|
||||
risk_level: PredictionRiskLevel;
|
||||
data_quality: PredictionDataQuality;
|
||||
}>;
|
||||
total_odds: number;
|
||||
expected_win_rate: number;
|
||||
rejected_matches: Array<{
|
||||
match_id: string;
|
||||
reason: string;
|
||||
threshold?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmartCouponService {
|
||||
private readonly logger = new Logger(SmartCouponService.name);
|
||||
private readonly aiEngineUrl: string;
|
||||
|
||||
constructor(private readonly geminiService: GeminiService) {
|
||||
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
|
||||
}
|
||||
|
||||
async analyzeMatch(matchId: string): Promise<SingleMatchPredictionPackage> {
|
||||
let prediction: SingleMatchPredictionPackage;
|
||||
try {
|
||||
const response = await axios.post<SingleMatchPredictionPackage>(
|
||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
);
|
||||
prediction = response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
throw new HttpException(
|
||||
`AI analyze failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
'AI analyze failed',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate AI commentary (non-blocking — fail-safe)
|
||||
prediction.ai_commentary = await this.generateMatchCommentary(prediction);
|
||||
return prediction;
|
||||
}
|
||||
|
||||
private async generateMatchCommentary(
|
||||
prediction: SingleMatchPredictionPackage,
|
||||
): Promise<string | null> {
|
||||
if (!this.geminiService.isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.geminiService.generateText(
|
||||
JSON.stringify(prediction, null, 2),
|
||||
{
|
||||
model: 'gemini-2.0-flash',
|
||||
temperature: 0.7,
|
||||
maxTokens: 600,
|
||||
systemPrompt: MATCH_COMMENTARY_SYSTEM_PROMPT,
|
||||
},
|
||||
);
|
||||
return result.text || null;
|
||||
} catch (error) {
|
||||
this.logger.warn('AI commentary generation failed, skipping', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async generateDailyBankoCoupon(
|
||||
matchIds: string[],
|
||||
): Promise<SmartCouponResult | null> {
|
||||
if (matchIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getSmartCoupon(matchIds, 'SAFE', {
|
||||
maxMatches: 2,
|
||||
minConfidence: 78,
|
||||
});
|
||||
}
|
||||
|
||||
async getSmartCoupon(
|
||||
matchIds: string[],
|
||||
strategy:
|
||||
| 'SAFE'
|
||||
| 'BALANCED'
|
||||
| 'AGGRESSIVE'
|
||||
| 'VALUE'
|
||||
| 'MIRACLE' = 'BALANCED',
|
||||
options: { maxMatches?: number; minConfidence?: number } = {},
|
||||
): Promise<SmartCouponResult> {
|
||||
try {
|
||||
const response = await axios.post<SmartCouponResult>(
|
||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
||||
{
|
||||
match_ids: matchIds,
|
||||
strategy,
|
||||
max_matches: options.maxMatches,
|
||||
min_confidence: options.minConfidence,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate smart coupon', error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
throw new HttpException(
|
||||
`Coupon generation failed: ${detail}`,
|
||||
error.response?.status || HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
throw new HttpException(
|
||||
'Coupon generation failed',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MATCH_COMMENTARY_SYSTEM_PROMPT = `Sen uzman bir futbol bahis analistisin. Sana verilen model çıktısını analiz edip kısa, net ve aksiyon odaklı Türkçe bir yorum yaz.
|
||||
|
||||
Kurallar:
|
||||
- Max 3-4 kısa paragraf, gereksiz uzatma
|
||||
- Playable olan marketleri ve nedenlerini açıkla
|
||||
- Edge pozitif olan marketleri vurgula (bahisçiden daha iyi biliyoruz)
|
||||
- Tüm edge'ler negatifse "trap maç" olarak uyar
|
||||
- xG ve skor senaryolarına göre strateji öner
|
||||
- Bahis grade'lerini açıkla: A = güvenli, B = iyi, PASS = oynama
|
||||
- Data quality ve risk seviyesini yorumla (kadro onaylı mı, probable XI mi)
|
||||
- "Ben olsam..." formatında kişisel tavsiye ver
|
||||
- Emoji kullan: ⚽ ✅ ⚠️ 🎯 ❌ 💰
|
||||
- Markdown formatı KULLANMA, düz metin yaz
|
||||
- Bahis terminolojisi kullan: edge, value, implied odds, xG`;
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { User, UserCoupon, Match } from '@prisma/client';
|
||||
|
||||
export class CreateCouponDto {
|
||||
strategy: string; // 'SAFE', 'VALUE', 'CUSTOM'
|
||||
items: {
|
||||
matchId: string;
|
||||
selection: string; // 'MS 1', '2.5 UST'
|
||||
odd: number;
|
||||
}[];
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface UserStatsDto {
|
||||
totalCoupons: number;
|
||||
wonCoupons: number;
|
||||
winRate: number; // Percentage
|
||||
totalInvested: number; // Unit based (1 unit per coupon)
|
||||
totalReturn: number;
|
||||
roi: number; // Return on Investment %
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserCouponService {
|
||||
private readonly logger = new Logger(UserCouponService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Kullanıcı için yeni bir kupon oluşturur ve kaydeder.
|
||||
*/
|
||||
async createCoupon(user: User, dto: CreateCouponDto): Promise<UserCoupon> {
|
||||
const totalOdds = dto.items.reduce((acc, item) => acc * item.odd, 1);
|
||||
|
||||
const coupon = await this.prisma.userCoupon.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
strategy: dto.strategy,
|
||||
totalOdds: parseFloat(totalOdds.toFixed(2)),
|
||||
isPublic: dto.isPublic || false,
|
||||
status: 'PENDING',
|
||||
couponItems: {
|
||||
create: dto.items.map((item) => ({
|
||||
matchId: item.matchId,
|
||||
selection: item.selection,
|
||||
oddAtTime: item.odd,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
couponItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Coupon created for user ${user.email} with odds ${totalOdds}`,
|
||||
);
|
||||
return coupon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bekleyen kuponların sonuçlarını kontrol eder ve günceller.
|
||||
* Bu metod bir Cron Job tarafından periyodik olarak çağrılmalıdır.
|
||||
*/
|
||||
async updatePendingCoupons(): Promise<void> {
|
||||
// Sadece bitmiş (FT) maçları içeren PENDING kuponları çek
|
||||
const pendingCoupons = await this.prisma.userCoupon.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
include: {
|
||||
couponItems: {
|
||||
include: { match: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const coupon of pendingCoupons) {
|
||||
let isCouponWon = true;
|
||||
let isCouponLost = false;
|
||||
let allMatchesFinished = true;
|
||||
|
||||
for (const item of coupon.couponItems) {
|
||||
if (item.match.status !== 'FT') {
|
||||
allMatchesFinished = false;
|
||||
break; // Henüz bitmemiş maç var, kuponu güncelleme
|
||||
}
|
||||
|
||||
const isItemWon = this.checkSelection(item.selection, item.match);
|
||||
|
||||
// Sonucu item bazında güncelle
|
||||
if (item.isCorrect !== isItemWon) {
|
||||
await this.prisma.userCouponItem.update({
|
||||
where: { id: item.id },
|
||||
data: { isCorrect: isItemWon },
|
||||
});
|
||||
}
|
||||
|
||||
if (!isItemWon) {
|
||||
isCouponLost = true;
|
||||
isCouponWon = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCouponLost) {
|
||||
await this.prisma.userCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'LOST' },
|
||||
});
|
||||
} else if (allMatchesFinished && isCouponWon) {
|
||||
await this.prisma.userCoupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { status: 'WON' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basit bir kural seti ile bahsin tutup tutmadığını kontrol eder.
|
||||
* Gerçek dünyada bu daha karmaşık bir 'BetSettlementService' olmalıdır.
|
||||
*/
|
||||
private checkSelection(selection: string, match: Match): boolean {
|
||||
const home = match.scoreHome ?? 0;
|
||||
const away = match.scoreAway ?? 0;
|
||||
const total = home + away;
|
||||
|
||||
switch (selection) {
|
||||
case 'MS 1':
|
||||
return home > away;
|
||||
case 'MS X':
|
||||
return home === away;
|
||||
case 'MS 2':
|
||||
return away > home;
|
||||
case '1.5 UST':
|
||||
return total > 1.5;
|
||||
case '2.5 UST':
|
||||
return total > 2.5;
|
||||
case '3.5 UST':
|
||||
return total > 3.5;
|
||||
case '2.5 ALT':
|
||||
return total < 2.5;
|
||||
case 'KG VAR':
|
||||
return home > 0 && away > 0;
|
||||
case 'KG YOK':
|
||||
return home === 0 || away === 0;
|
||||
default:
|
||||
return false; // Bilinmeyen market
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının bahis performans istatistiklerini getirir.
|
||||
*/
|
||||
async getUserStatistics(userId: string): Promise<UserStatsDto> {
|
||||
const coupons = await this.prisma.userCoupon.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: { in: ['WON', 'LOST'] },
|
||||
},
|
||||
});
|
||||
|
||||
const totalCoupons = coupons.length;
|
||||
if (totalCoupons === 0) {
|
||||
return {
|
||||
totalCoupons: 0,
|
||||
wonCoupons: 0,
|
||||
winRate: 0,
|
||||
totalInvested: 0,
|
||||
totalReturn: 0,
|
||||
roi: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const wonCoupons = coupons.filter((c) => c.status === 'WON');
|
||||
const totalInvested = totalCoupons; // Her kupona 1 birim yatırıldığını varsayıyoruz
|
||||
const totalReturn = wonCoupons.reduce((acc, c) => acc + c.totalOdds, 0);
|
||||
const winRate = (wonCoupons.length / totalCoupons) * 100;
|
||||
const roi = ((totalReturn - totalInvested) / totalInvested) * 100;
|
||||
|
||||
return {
|
||||
totalCoupons,
|
||||
wonCoupons: wonCoupons.length,
|
||||
winRate: parseFloat(winRate.toFixed(2)),
|
||||
totalInvested,
|
||||
totalReturn: parseFloat(totalReturn.toFixed(2)),
|
||||
roi: parseFloat(roi.toFixed(2)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user