This commit is contained in:
Executable
+471
@@ -0,0 +1,471 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export type SignalTier =
|
||||
| 'CORE'
|
||||
| 'VALUE'
|
||||
| 'LEAN'
|
||||
| 'LONGSHOT'
|
||||
| 'PASS';
|
||||
|
||||
export class MatchInfoDto {
|
||||
@ApiProperty()
|
||||
match_id: string;
|
||||
|
||||
@ApiProperty()
|
||||
match_name: string;
|
||||
|
||||
@ApiProperty()
|
||||
home_team: string;
|
||||
|
||||
@ApiProperty()
|
||||
away_team: string;
|
||||
|
||||
@ApiProperty()
|
||||
league: string;
|
||||
|
||||
@ApiProperty()
|
||||
match_date_ms: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
league_id?: string | null;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
is_top_league?: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: ['football', 'basketball'],
|
||||
})
|
||||
sport?: 'football' | 'basketball';
|
||||
}
|
||||
|
||||
export class DataQualityDto {
|
||||
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
||||
label: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
@ApiProperty()
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
home_lineup_count: number;
|
||||
|
||||
@ApiProperty()
|
||||
away_lineup_count: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 'none' })
|
||||
lineup_source?: string;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
flags: string[];
|
||||
}
|
||||
|
||||
export class ConfidenceIntervalDto {
|
||||
@ApiProperty()
|
||||
lower: number;
|
||||
|
||||
@ApiProperty()
|
||||
upper: number;
|
||||
|
||||
@ApiProperty()
|
||||
width: number;
|
||||
|
||||
@ApiProperty({ enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
||||
band: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
@ApiProperty()
|
||||
threshold_met: boolean;
|
||||
}
|
||||
|
||||
export class RiskDto {
|
||||
@ApiProperty({ enum: ['LOW', 'MEDIUM', 'HIGH', 'EXTREME'] })
|
||||
level: 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
||||
|
||||
@ApiProperty()
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
is_surprise_risk: boolean;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
surprise_type: string | null;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
surprise_score?: number;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
surprise_comment?: string | null;
|
||||
|
||||
@ApiProperty({ type: [String], required: false })
|
||||
surprise_reasons?: string[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class EngineBreakdownDto {
|
||||
@ApiProperty()
|
||||
team: number;
|
||||
|
||||
@ApiProperty()
|
||||
player: number;
|
||||
|
||||
@ApiProperty()
|
||||
odds: number;
|
||||
|
||||
@ApiProperty()
|
||||
referee: number;
|
||||
}
|
||||
|
||||
export class MatchPickDto {
|
||||
@ApiProperty()
|
||||
market: string;
|
||||
|
||||
@ApiProperty()
|
||||
pick: string;
|
||||
|
||||
@ApiProperty()
|
||||
probability: number;
|
||||
|
||||
@ApiProperty()
|
||||
confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
odds: number;
|
||||
|
||||
@ApiProperty()
|
||||
raw_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
calibrated_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
min_required_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
edge: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
ev_edge?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
implied_prob?: number;
|
||||
|
||||
@ApiProperty()
|
||||
play_score: number;
|
||||
|
||||
@ApiProperty()
|
||||
playable: boolean;
|
||||
|
||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
||||
|
||||
@ApiProperty()
|
||||
stake_units: number;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
decision_reasons: string[];
|
||||
|
||||
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
|
||||
confidence_interval?: ConfidenceIntervalDto;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
||||
})
|
||||
signal_tier?: SignalTier;
|
||||
}
|
||||
|
||||
export class MatchBetAdviceDto {
|
||||
@ApiProperty()
|
||||
playable: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
suggested_stake_units: number;
|
||||
|
||||
@ApiProperty()
|
||||
reason: string;
|
||||
|
||||
@ApiProperty({ required: false, enum: ['HIGH', 'MEDIUM', 'LOW'] })
|
||||
confidence_band?: 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
min_confidence_for_play?: number;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
||||
})
|
||||
signal_tier?: SignalTier;
|
||||
}
|
||||
|
||||
export class MatchBetSummaryItemDto {
|
||||
@ApiProperty()
|
||||
market: string;
|
||||
|
||||
@ApiProperty()
|
||||
pick: string;
|
||||
|
||||
@ApiProperty()
|
||||
raw_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
calibrated_confidence: number;
|
||||
|
||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
||||
|
||||
@ApiProperty()
|
||||
playable: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
stake_units: number;
|
||||
|
||||
@ApiProperty()
|
||||
play_score: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
ev_edge?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
implied_prob?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
odds?: number;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
reasons: string[];
|
||||
|
||||
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
|
||||
confidence_interval?: ConfidenceIntervalDto;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
enum: ['CORE', 'VALUE', 'LEAN', 'LONGSHOT', 'PASS'],
|
||||
})
|
||||
signal_tier?: SignalTier;
|
||||
}
|
||||
|
||||
export class HtFtPredictionDto {
|
||||
@ApiProperty()
|
||||
'1/1': number;
|
||||
@ApiProperty()
|
||||
'1/X': number;
|
||||
@ApiProperty()
|
||||
'1/2': number;
|
||||
@ApiProperty()
|
||||
'X/1': number;
|
||||
@ApiProperty()
|
||||
'X/X': number;
|
||||
@ApiProperty()
|
||||
'X/2': number;
|
||||
@ApiProperty()
|
||||
'2/1': number;
|
||||
@ApiProperty()
|
||||
'2/X': number;
|
||||
@ApiProperty()
|
||||
'2/2': number;
|
||||
@ApiProperty()
|
||||
pick: string;
|
||||
@ApiProperty()
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export class AggressivePickDto {
|
||||
@ApiProperty()
|
||||
market: string;
|
||||
|
||||
@ApiProperty()
|
||||
pick: string;
|
||||
|
||||
@ApiProperty()
|
||||
probability: number;
|
||||
|
||||
@ApiProperty()
|
||||
confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
odds: number;
|
||||
|
||||
@ApiProperty()
|
||||
raw_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
calibrated_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
min_required_confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
edge: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
ev_edge?: number;
|
||||
|
||||
@ApiProperty({ required: false, default: 0 })
|
||||
implied_prob?: number;
|
||||
|
||||
@ApiProperty()
|
||||
play_score: number;
|
||||
|
||||
@ApiProperty()
|
||||
playable: boolean;
|
||||
|
||||
@ApiProperty({ enum: ['A', 'B', 'C', 'PASS'] })
|
||||
bet_grade: 'A' | 'B' | 'C' | 'PASS';
|
||||
|
||||
@ApiProperty()
|
||||
stake_units: number;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
decision_reasons: string[];
|
||||
|
||||
@ApiProperty({ type: ConfidenceIntervalDto, required: false })
|
||||
confidence_interval?: ConfidenceIntervalDto;
|
||||
}
|
||||
|
||||
export class ScenarioTop5ItemDto {
|
||||
@ApiProperty()
|
||||
scenario: string;
|
||||
|
||||
@ApiProperty()
|
||||
score: number;
|
||||
|
||||
@ApiProperty()
|
||||
probability: number;
|
||||
}
|
||||
|
||||
export class ScorePredictionDto {
|
||||
@ApiProperty()
|
||||
ft: string;
|
||||
|
||||
@ApiProperty()
|
||||
ht: string;
|
||||
|
||||
@ApiProperty()
|
||||
xg_home: number;
|
||||
|
||||
@ApiProperty()
|
||||
xg_away: number;
|
||||
|
||||
@ApiProperty()
|
||||
xg_total: number;
|
||||
}
|
||||
|
||||
export class MatchPredictionDto {
|
||||
@ApiProperty()
|
||||
model_version: string;
|
||||
|
||||
@ApiProperty({ type: MatchInfoDto })
|
||||
match_info: MatchInfoDto;
|
||||
|
||||
@ApiProperty({ type: DataQualityDto })
|
||||
data_quality: DataQualityDto;
|
||||
|
||||
@ApiProperty({ type: RiskDto })
|
||||
risk: RiskDto;
|
||||
|
||||
@ApiProperty({ type: EngineBreakdownDto })
|
||||
engine_breakdown: EngineBreakdownDto;
|
||||
|
||||
@ApiProperty({ type: MatchPickDto, nullable: true })
|
||||
main_pick: MatchPickDto | null;
|
||||
|
||||
@ApiProperty({ type: MatchPickDto, nullable: true })
|
||||
value_pick: MatchPickDto | null;
|
||||
|
||||
@ApiProperty({ type: MatchBetAdviceDto })
|
||||
bet_advice: MatchBetAdviceDto;
|
||||
|
||||
@ApiProperty({ type: [MatchBetSummaryItemDto] })
|
||||
bet_summary: MatchBetSummaryItemDto[];
|
||||
|
||||
@ApiProperty({ type: [MatchPickDto] })
|
||||
supporting_picks: MatchPickDto[];
|
||||
|
||||
@ApiProperty({ type: AggressivePickDto, nullable: true })
|
||||
aggressive_pick: AggressivePickDto | null;
|
||||
|
||||
@ApiProperty({ type: HtFtPredictionDto, required: false })
|
||||
htft?: HtFtPredictionDto;
|
||||
|
||||
@ApiProperty({ type: [ScenarioTop5ItemDto] })
|
||||
scenario_top5: ScenarioTop5ItemDto[];
|
||||
|
||||
@ApiProperty({ type: ScorePredictionDto })
|
||||
score_prediction: ScorePredictionDto;
|
||||
|
||||
@ApiProperty({ type: Object })
|
||||
market_board: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
reasoning_factors: string[];
|
||||
}
|
||||
|
||||
export class ValueBetDto {
|
||||
@ApiProperty()
|
||||
matchId: string;
|
||||
|
||||
@ApiProperty()
|
||||
matchName: string;
|
||||
|
||||
@ApiProperty()
|
||||
betType: string;
|
||||
|
||||
@ApiProperty()
|
||||
prediction: string;
|
||||
|
||||
@ApiProperty()
|
||||
confidence: number;
|
||||
|
||||
@ApiProperty()
|
||||
odd: number;
|
||||
|
||||
@ApiProperty()
|
||||
expectedValue: number;
|
||||
}
|
||||
|
||||
export class PredictionHistoryStatsDto {
|
||||
@ApiProperty()
|
||||
totalPredictions: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalResolved: number;
|
||||
|
||||
@ApiProperty()
|
||||
correctPredictions: number;
|
||||
|
||||
@ApiProperty()
|
||||
accuracyRate: number;
|
||||
}
|
||||
|
||||
export class PredictionHistoryResponseDto {
|
||||
@ApiProperty({ type: PredictionHistoryStatsDto })
|
||||
stats: PredictionHistoryStatsDto;
|
||||
|
||||
@ApiProperty({ type: [Object] })
|
||||
history: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export class UpcomingPredictionsDto {
|
||||
@ApiProperty()
|
||||
count: number;
|
||||
|
||||
@ApiProperty({ type: [MatchPredictionDto] })
|
||||
matches: MatchPredictionDto[];
|
||||
|
||||
@ApiProperty()
|
||||
modelVersion: string;
|
||||
}
|
||||
|
||||
export class AIHealthDto {
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
modelLoaded: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
predictionServiceReady: boolean;
|
||||
}
|
||||
|
||||
export * from './smart-coupon.dto';
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
ArrayMaxSize,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class GeneratePredictionDto {
|
||||
@ApiProperty({ description: 'Match ID to generate prediction for' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
matchId: string;
|
||||
}
|
||||
|
||||
export enum CouponStrategy {
|
||||
SAFE = 'SAFE',
|
||||
BALANCED = 'BALANCED',
|
||||
AGGRESSIVE = 'AGGRESSIVE',
|
||||
VALUE = 'VALUE',
|
||||
MIRACLE = 'MIRACLE',
|
||||
}
|
||||
|
||||
export class SmartCouponRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'List of match IDs for coupon',
|
||||
example: ['match-1', 'match-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
matchIds: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: CouponStrategy,
|
||||
default: CouponStrategy.BALANCED,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(CouponStrategy)
|
||||
strategy?: CouponStrategy;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum matches in coupon', example: 5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
maxMatches?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Minimum confidence threshold (0-100)',
|
||||
example: 60,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
minConfidence?: number;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Smart Coupon DTOs aligned with AI Engine V20+ contract.
|
||||
*/
|
||||
|
||||
export type CouponStrategy =
|
||||
| 'SAFE'
|
||||
| 'BALANCED'
|
||||
| 'AGGRESSIVE'
|
||||
| 'VALUE'
|
||||
| 'MIRACLE';
|
||||
|
||||
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'EXTREME';
|
||||
export type DataQualityLabel = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
export interface SmartCouponRequestDto {
|
||||
match_ids: string[];
|
||||
strategy?: CouponStrategy;
|
||||
max_matches?: number;
|
||||
min_confidence?: number;
|
||||
}
|
||||
|
||||
export interface CouponBetDto {
|
||||
match_id: string;
|
||||
match_name: string;
|
||||
market: string;
|
||||
pick: string;
|
||||
probability: number;
|
||||
confidence: number;
|
||||
odds: number;
|
||||
risk_level: RiskLevel;
|
||||
data_quality: DataQualityLabel;
|
||||
}
|
||||
|
||||
export interface RejectedMatchDto {
|
||||
match_id: string;
|
||||
reason: string;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface SmartCouponResponseDto {
|
||||
strategy: CouponStrategy;
|
||||
generated_at: string;
|
||||
match_count: number;
|
||||
bets: CouponBetDto[];
|
||||
total_odds: number;
|
||||
expected_win_rate: number;
|
||||
rejected_matches: RejectedMatchDto[];
|
||||
}
|
||||
|
||||
export interface SmartCouponApiError {
|
||||
error: string;
|
||||
detail?: string;
|
||||
match_ids_failed?: string[];
|
||||
}
|
||||
|
||||
export interface StrategyInfo {
|
||||
name: CouponStrategy;
|
||||
description: string;
|
||||
typical_odds: string;
|
||||
}
|
||||
|
||||
export interface StrategiesResponseDto {
|
||||
strategies: StrategyInfo[];
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { PredictionsService } from './predictions.service';
|
||||
import {
|
||||
MatchPredictionDto,
|
||||
PredictionHistoryResponseDto,
|
||||
UpcomingPredictionsDto,
|
||||
ValueBetDto,
|
||||
AIHealthDto,
|
||||
} from './dto';
|
||||
import {
|
||||
GeneratePredictionDto,
|
||||
SmartCouponRequestDto,
|
||||
} from './dto/predictions-request.dto';
|
||||
import { Public } from 'src/common/decorators';
|
||||
|
||||
@ApiTags('Predictions')
|
||||
@Controller('predictions')
|
||||
export class PredictionsController {
|
||||
constructor(private readonly predictionsService: PredictionsService) {}
|
||||
|
||||
/**
|
||||
* GET /predictions/health
|
||||
* Check AI Engine health status
|
||||
*/
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: 'Check AI Engine health status' })
|
||||
@ApiResponse({ status: 200, type: AIHealthDto })
|
||||
async checkHealth(): Promise<AIHealthDto> {
|
||||
return this.predictionsService.checkHealth();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /predictions/upcoming
|
||||
* Get predictions for upcoming matches
|
||||
*/
|
||||
@Get('upcoming')
|
||||
@ApiOperation({ summary: 'Get predictions for upcoming matches' })
|
||||
@ApiResponse({ status: 200, type: UpcomingPredictionsDto })
|
||||
async getUpcoming(): Promise<UpcomingPredictionsDto> {
|
||||
return this.predictionsService.getUpcomingPredictions();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /predictions/test/:id
|
||||
* Refetch match data and get prediction
|
||||
*/
|
||||
@Get('test/:id')
|
||||
@ApiOperation({ summary: 'Refetch match data and get prediction' })
|
||||
@ApiParam({ name: 'id', description: 'Match ID' })
|
||||
async getTestPrediction(@Param('id') id: string) {
|
||||
return this.predictionsService.testPrediction(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /predictions/value-bets
|
||||
* Get EV+ betting opportunities
|
||||
*/
|
||||
@Get('value-bets')
|
||||
@ApiOperation({ summary: 'Get value betting opportunities (EV+)' })
|
||||
@ApiResponse({ status: 200, type: [ValueBetDto] })
|
||||
async getValueBets(): Promise<ValueBetDto[]> {
|
||||
return this.predictionsService.getValueBets();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /predictions/history
|
||||
* Get prediction history and accuracy stats
|
||||
*/
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Get prediction history and accuracy statistics' })
|
||||
@ApiResponse({ status: 200, type: PredictionHistoryResponseDto })
|
||||
async getHistory(): Promise<PredictionHistoryResponseDto> {
|
||||
return this.predictionsService.getPredictionHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /predictions/:matchId
|
||||
* Get prediction for a specific match
|
||||
*/
|
||||
@Get(':matchId')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Get prediction for a specific match' })
|
||||
@ApiParam({ name: 'matchId', description: 'Match ID' })
|
||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||
@ApiResponse({ status: 404, description: 'Match not found' })
|
||||
async getPrediction(
|
||||
@Param('matchId') matchId: string,
|
||||
): Promise<MatchPredictionDto> {
|
||||
// Check cache first
|
||||
const cached = await this.predictionsService.getCachedPrediction(matchId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get from AI Engine
|
||||
const prediction = await this.predictionsService.getPredictionById(matchId);
|
||||
|
||||
if (!prediction) {
|
||||
throw new NotFoundException(`Match not found: ${matchId}`);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.predictionsService.cachePrediction(matchId, prediction);
|
||||
|
||||
return prediction;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /predictions/generate
|
||||
* Generate prediction with provided match data
|
||||
*/
|
||||
@Post('generate')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Generate prediction with provided match data' })
|
||||
@ApiResponse({ status: 200, type: MatchPredictionDto })
|
||||
async generatePrediction(
|
||||
@Body() dto: GeneratePredictionDto,
|
||||
): Promise<MatchPredictionDto> {
|
||||
const prediction = await this.predictionsService.getPredictionWithData({
|
||||
matchId: dto.matchId,
|
||||
});
|
||||
|
||||
if (!prediction) {
|
||||
throw new NotFoundException('Failed to generate prediction');
|
||||
}
|
||||
|
||||
return prediction;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /predictions/smart-coupon
|
||||
* Generate Smart Coupon using AI Engine V20
|
||||
*/
|
||||
@Post('smart-coupon')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Generate Smart Coupon with V20 AI recommendations',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Smart coupon generated successfully',
|
||||
})
|
||||
async generateSmartCoupon(@Body() dto: SmartCouponRequestDto): Promise<any> {
|
||||
const coupon = await this.predictionsService.getSmartCoupon(
|
||||
dto.matchIds,
|
||||
dto.strategy || 'BALANCED',
|
||||
{
|
||||
maxMatches: dto.maxMatches,
|
||||
minConfidence: dto.minConfidence,
|
||||
},
|
||||
);
|
||||
|
||||
if (!coupon) {
|
||||
throw new NotFoundException('Failed to generate Smart Coupon');
|
||||
}
|
||||
|
||||
return coupon;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PredictionsController } from './predictions.controller';
|
||||
import { PredictionsService } from './predictions.service';
|
||||
import { AiFeatureStoreService } from './services/ai-feature-store.service';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
import { MatchesModule } from '../matches/matches.module';
|
||||
import { PredictionsQueue } from './queues/predictions.queue';
|
||||
import { PredictionsProcessor } from './queues/predictions.processor';
|
||||
import { PREDICTIONS_QUEUE } from './queues/predictions.types';
|
||||
import { FeederModule } from '../feeder/feeder.module';
|
||||
|
||||
const redisEnabled = process.env.REDIS_ENABLED === 'true';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
HttpModule.register({
|
||||
timeout: 30000, // 30 seconds
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
...(redisEnabled
|
||||
? [BullModule.registerQueue({ name: PREDICTIONS_QUEUE })]
|
||||
: []),
|
||||
MatchesModule,
|
||||
FeederModule,
|
||||
],
|
||||
controllers: [PredictionsController],
|
||||
providers: [
|
||||
PredictionsService,
|
||||
AiFeatureStoreService,
|
||||
...(redisEnabled ? [PredictionsQueue, PredictionsProcessor] : []),
|
||||
],
|
||||
exports: [PredictionsService, AiFeatureStoreService],
|
||||
})
|
||||
export class PredictionsModule {}
|
||||
+1166
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import axios from 'axios';
|
||||
import { PredictionJobType } from './predictions.types';
|
||||
import { PredictionsProcessor } from './predictions.processor';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('PredictionsProcessor', () => {
|
||||
let processor: PredictionsProcessor;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.AI_ENGINE_URL = 'http://unit-ai:8000';
|
||||
processor = new PredictionsProcessor();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AI_ENGINE_URL;
|
||||
});
|
||||
|
||||
it('posts to analyze endpoint for predict-match jobs', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: { ok: true } } as any);
|
||||
|
||||
const job = {
|
||||
id: 'j1',
|
||||
name: PredictionJobType.PREDICT_MATCH,
|
||||
data: { matchId: 'match-123' },
|
||||
} as any;
|
||||
|
||||
const result = await processor.process(job);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'http://unit-ai:8000/v20plus/analyze/match-123',
|
||||
{},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('posts mapped payload to coupon endpoint for smart-coupon jobs', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: { bets: [] } } as any);
|
||||
|
||||
const job = {
|
||||
id: 'j2',
|
||||
name: PredictionJobType.SMART_COUPON,
|
||||
data: {
|
||||
matchIds: ['m1', 'm2'],
|
||||
strategy: 'BALANCED',
|
||||
options: { maxMatches: 4, minConfidence: 65 },
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await processor.process(job);
|
||||
|
||||
expect(result).toEqual({ bets: [] });
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'http://unit-ai:8000/v20plus/coupon',
|
||||
{
|
||||
match_ids: ['m1', 'm2'],
|
||||
strategy: 'BALANCED',
|
||||
max_matches: 4,
|
||||
min_confidence: 65,
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for unknown job type', async () => {
|
||||
const job = {
|
||||
id: 'j3',
|
||||
name: 'unknown-job',
|
||||
data: {},
|
||||
} as any;
|
||||
|
||||
await expect(processor.process(job)).rejects.toThrow(
|
||||
'Unknown job type: unknown-job',
|
||||
);
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import {
|
||||
PREDICTIONS_QUEUE,
|
||||
PredictionJobType,
|
||||
PredictMatchJobData,
|
||||
SmartCouponJobData,
|
||||
} from './predictions.types';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Predictions Processor
|
||||
* Handles heavy AI computations in background via HTTP calls to AI Engine
|
||||
*/
|
||||
@Processor(PREDICTIONS_QUEUE)
|
||||
export class PredictionsProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(PredictionsProcessor.name);
|
||||
private readonly aiEngineUrl: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Default to container service URL
|
||||
this.aiEngineUrl = process.env.AI_ENGINE_URL || 'http://ai-engine:8000';
|
||||
}
|
||||
|
||||
async process(job: Job<any, any, string>): Promise<any> {
|
||||
this.logger.debug(`Processing job ${job.id}: ${job.name}`);
|
||||
|
||||
switch (job.name) {
|
||||
case PredictionJobType.PREDICT_MATCH:
|
||||
return this.handlePredictMatch(job.data as PredictMatchJobData);
|
||||
|
||||
case PredictionJobType.SMART_COUPON:
|
||||
return this.handleSmartCoupon(job.data as SmartCouponJobData);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Single Match Prediction
|
||||
* HTTP POST /v20plus/analyze/:id
|
||||
*/
|
||||
private async handlePredictMatch(data: PredictMatchJobData): Promise<any> {
|
||||
const { matchId } = data;
|
||||
this.logger.log(`🤖 AI Engine: Predicting match ${matchId}...`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.aiEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
{},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw this.mapAxiosError(error, matchId, 'predict');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Smart Coupon Generation
|
||||
* HTTP POST /v20plus/coupon
|
||||
*/
|
||||
private async handleSmartCoupon(data: SmartCouponJobData): Promise<any> {
|
||||
const { matchIds, strategy } = data;
|
||||
this.logger.log(`🎫 AI Engine: Generating ${strategy} Coupon...`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.aiEngineUrl}/v20plus/coupon`,
|
||||
{
|
||||
match_ids: matchIds,
|
||||
strategy,
|
||||
max_matches: data.options?.maxMatches,
|
||||
min_confidence: data.options?.minConfidence,
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw this.mapAxiosError(error, matchIds.join(','), 'smart-coupon');
|
||||
}
|
||||
}
|
||||
|
||||
private mapAxiosError(
|
||||
error: unknown,
|
||||
identifier: string,
|
||||
flow: 'predict' | 'smart-coupon',
|
||||
): Error {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return error instanceof Error
|
||||
? error
|
||||
: new Error(`AI_ENGINE_UNKNOWN|${flow}|Unknown error`);
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
const detail = error.response?.data?.detail || error.message;
|
||||
const code = error.code || '';
|
||||
|
||||
if (status === 502) {
|
||||
this.logger.error(`AI Engine 502 (${flow}:${identifier}): ${detail}`);
|
||||
return new Error(`AI_ENGINE_502|${flow}|${detail}`);
|
||||
}
|
||||
|
||||
if (status === 504) {
|
||||
this.logger.error(`AI Engine 504 (${flow}:${identifier}): ${detail}`);
|
||||
return new Error(`AI_ENGINE_504|${flow}|${detail}`);
|
||||
}
|
||||
|
||||
if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') {
|
||||
this.logger.error(`AI Engine timeout (${flow}:${identifier}): ${detail}`);
|
||||
return new Error(`AI_ENGINE_TIMEOUT|${flow}|${detail}`);
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`AI Engine error (${flow}:${identifier}) [${status ?? 'N/A'}]: ${detail}`,
|
||||
);
|
||||
return new Error(`AI_ENGINE_ERROR|${flow}|${detail}`);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import {
|
||||
PREDICTIONS_QUEUE,
|
||||
PredictionJobType,
|
||||
PredictMatchJobData,
|
||||
SmartCouponJobData,
|
||||
} from './predictions.types';
|
||||
|
||||
@Injectable()
|
||||
export class PredictionsQueue {
|
||||
private readonly logger = new Logger(PredictionsQueue.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue(PREDICTIONS_QUEUE)
|
||||
public readonly queue: Queue,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Add a single match prediction job
|
||||
*/
|
||||
async addPredictMatchJob(data: PredictMatchJobData) {
|
||||
this.logger.debug(`Adding prediction job for match: ${data.matchId}`);
|
||||
return this.queue.add(PredictionJobType.PREDICT_MATCH, data, {
|
||||
priority: 1, // High priority
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a smart coupon generation job
|
||||
*/
|
||||
async addSmartCouponJob(data: SmartCouponJobData) {
|
||||
this.logger.debug(
|
||||
`Adding smart coupon job: ${data.strategy} (${data.matchIds.length} matches)`,
|
||||
);
|
||||
return this.queue.add(PredictionJobType.SMART_COUPON, data, {
|
||||
priority: 5, // Lower priority than single predictions
|
||||
});
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Prediction Queue Types
|
||||
* Senior Level Strict Typing
|
||||
*/
|
||||
|
||||
export const PREDICTIONS_QUEUE = 'predictions-queue';
|
||||
|
||||
export enum PredictionJobType {
|
||||
PREDICT_MATCH = 'predict-match',
|
||||
SMART_COUPON = 'smart-coupon',
|
||||
}
|
||||
|
||||
export interface PredictMatchJobData {
|
||||
matchId: string;
|
||||
forceUpdate?: boolean;
|
||||
}
|
||||
|
||||
export interface SmartCouponJobData {
|
||||
matchIds: string[];
|
||||
strategy: string;
|
||||
options?: {
|
||||
maxMatches?: number;
|
||||
minConfidence?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type PredictionJob =
|
||||
| { type: PredictionJobType.PREDICT_MATCH; data: PredictMatchJobData }
|
||||
| { type: PredictionJobType.SMART_COUPON; data: SmartCouponJobData };
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiFeatureStoreService {
|
||||
private readonly logger = new Logger(AiFeatureStoreService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Bir maç için AI özelliklerini hesaplar ve 'match_ai_features' tablosuna yazar.
|
||||
* Bu metod Feeder yeni veri çektiğinde tetiklenmelidir.
|
||||
*/
|
||||
async calculateAndSaveFeatures(matchId: string): Promise<void> {
|
||||
const match = await this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
include: {
|
||||
homeTeam: {
|
||||
include: { homeMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
|
||||
},
|
||||
awayTeam: {
|
||||
include: { awayMatches: { take: 5, orderBy: { mstUtc: 'desc' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!match || !match.homeTeam || !match.awayTeam) return;
|
||||
|
||||
// 1. Form Score Calculation (0-100)
|
||||
// Son 5 maçtaki galibiyet, beraberlik ve atılan gollerin ağırlıklı ortalaması
|
||||
const homeForm = this.calculateFormScore(match.homeTeam.homeMatches);
|
||||
const awayForm = this.calculateFormScore(match.awayTeam.awayMatches);
|
||||
|
||||
// 2. ELO — Read from team_elo_ratings table (populated by AI Engine compute_elo.py)
|
||||
const homeElo = match.homeTeamId
|
||||
? await this.getTeamElo(match.homeTeamId)
|
||||
: 1500.0;
|
||||
const awayElo = match.awayTeamId
|
||||
? await this.getTeamElo(match.awayTeamId)
|
||||
: 1500.0;
|
||||
|
||||
// 3. Missing Player Impact (Sakat/Cezalı etkisi)
|
||||
// Feeder'dan gelen lineups verisindeki eksik as oyuncuları analiz etmeliyiz.
|
||||
// Şimdilik 0.0 (Etkisiz) olarak set ediyoruz, ilerde Lineup analizi buraya eklenecek.
|
||||
const missingImpact = 0.0;
|
||||
|
||||
// 4. Save to Feature Store
|
||||
await this.prisma.footballAiFeature.upsert({
|
||||
where: { matchId },
|
||||
update: {
|
||||
homeElo,
|
||||
awayElo,
|
||||
homeFormScore: homeForm,
|
||||
awayFormScore: awayForm,
|
||||
missingPlayersImpact: missingImpact,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
matchId,
|
||||
homeElo,
|
||||
awayElo,
|
||||
homeFormScore: homeForm,
|
||||
awayFormScore: awayForm,
|
||||
missingPlayersImpact: missingImpact,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Features calculated for match ${matchId} (Home Form: ${homeForm}, Away Form: ${awayForm})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form Puanı Hesaplama Algoritması (V17 Simplified)
|
||||
* W=30, D=10, L=0 puan. + Gol başına 5 puan (max 15).
|
||||
* Toplam skor 0-100 arasına normalize edilir.
|
||||
*/
|
||||
private calculateFormScore(matches: any[]): number {
|
||||
if (!matches || matches.length === 0) return 50; // Nötr form
|
||||
|
||||
let totalPoints = 0;
|
||||
const maxPoints = matches.length * 45; // Max olası puan (30win + 15goal)
|
||||
|
||||
for (const m of matches) {
|
||||
// Skor kontrolü (bazı maçlar oynanmamış olabilir)
|
||||
if (m.scoreHome === null || m.scoreAway === null) continue;
|
||||
|
||||
const isWin = m.scoreHome > m.scoreAway; // Home team context
|
||||
const isDraw = m.scoreHome === m.scoreAway;
|
||||
|
||||
if (isWin) totalPoints += 30;
|
||||
else if (isDraw) totalPoints += 10;
|
||||
|
||||
const goals = Math.min(m.scoreHome, 3); // Max 3 gol katkısı
|
||||
totalPoints += goals * 5;
|
||||
}
|
||||
|
||||
// Normalize to 0-100
|
||||
// Eğer hiç maç oynanmadıysa yine 50 dön.
|
||||
return matches.length > 0 ? (totalPoints / maxPoints) * 100 : 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* team_elo_ratings tablosundan takımın güncel ELO puanını okur.
|
||||
* Kayıt yoksa varsayılan 1500.0 döner.
|
||||
*/
|
||||
private async getTeamElo(teamId: string): Promise<number> {
|
||||
const row = await this.prisma.teamEloRating.findUnique({
|
||||
where: { teamId },
|
||||
select: { overallElo: true },
|
||||
});
|
||||
return row?.overallElo ?? 1500.0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user