@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* ===================================================
|
||||
* HISTORICAL PREDICTION BACKFILL
|
||||
* ===================================================
|
||||
* Replays v28-pro-max (or any engine) against historical
|
||||
* finished matches to populate prediction_runs with
|
||||
* payload_summary + odds_snapshot + eventual_outcome +
|
||||
* unit_profit, so we can compute true ROI / calibration.
|
||||
*
|
||||
* Requires ENABLE_BACKFILL=true to prevent accidental run.
|
||||
*
|
||||
* Usage:
|
||||
* ENABLE_BACKFILL=true npx ts-node --transpile-only \
|
||||
* -r tsconfig-paths/register src/scripts/backfill-prediction-runs.ts \
|
||||
* --from 2023-05-01 --to 2025-12-31 --limit 50000 --sport football
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import axios from "axios";
|
||||
import {
|
||||
resolveOutcomeForPick,
|
||||
computeUnitProfit,
|
||||
type MatchResult,
|
||||
} from "../tasks/prediction-settlement.market-resolver";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const AI_ENGINE_URL = process.env.AI_ENGINE_URL || "http://127.0.0.1:3005";
|
||||
const REQUEST_DELAY_MS = Number(process.env.BACKFILL_DELAY_MS ?? 300);
|
||||
const CONCURRENCY = Number(process.env.BACKFILL_CONCURRENCY ?? 2);
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
opts[args[i].replace(/^--/, "")] = args[i + 1];
|
||||
}
|
||||
return {
|
||||
from: opts.from ?? "2023-05-01",
|
||||
to: opts.to ?? new Date().toISOString().slice(0, 10),
|
||||
limit: Number(opts.limit ?? 50000),
|
||||
sport: (opts.sport ?? "football") as "football" | "basketball",
|
||||
engine: opts.engine ?? "v28-pro-max",
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function runOne(matchId: string, engine: string): Promise<any | null> {
|
||||
try {
|
||||
const resp = await axios.post(
|
||||
`${AI_ENGINE_URL}/v20plus/analyze/${matchId}`,
|
||||
{ engine_version: engine },
|
||||
{ timeout: 25000 },
|
||||
);
|
||||
return resp.data;
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` ✗ ${matchId} ${err?.response?.status ?? ""} ${err?.message ?? ""}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayloadSummary(payload: any): Record<string, any> {
|
||||
const main = payload?.main_pick ?? null;
|
||||
const value = payload?.value_pick ?? null;
|
||||
const advice = payload?.bet_advice ?? {};
|
||||
return {
|
||||
model_version: payload?.model_version,
|
||||
decision_trace_id: payload?.decision_trace_id ?? null,
|
||||
main_pick: main
|
||||
? {
|
||||
market: main.market,
|
||||
pick: main.pick,
|
||||
playable: main.playable,
|
||||
bet_grade: main.bet_grade,
|
||||
calibrated_confidence: main.calibrated_confidence,
|
||||
ev_edge: main.ev_edge ?? 0,
|
||||
stake_units: main.stake_units,
|
||||
odds: main.odds ?? null,
|
||||
}
|
||||
: null,
|
||||
value_pick: value
|
||||
? {
|
||||
market: value.market,
|
||||
pick: value.pick,
|
||||
playable: value.playable,
|
||||
calibrated_confidence: value.calibrated_confidence,
|
||||
ev_edge: value.ev_edge ?? 0,
|
||||
odds: value.odds ?? null,
|
||||
}
|
||||
: null,
|
||||
bet_advice: {
|
||||
playable: advice.playable ?? false,
|
||||
suggested_stake_units: advice.suggested_stake_units ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function settleFromPayload(
|
||||
payload: any,
|
||||
result: MatchResult,
|
||||
): { outcome: string; unitProfit: number } {
|
||||
const advice = payload?.bet_advice ?? {};
|
||||
if (advice.playable !== true) return { outcome: "NO_BET", unitProfit: 0 };
|
||||
|
||||
const main = payload?.main_pick;
|
||||
if (!main || !main.playable) return { outcome: "NO_BET", unitProfit: 0 };
|
||||
|
||||
const pickRef = {
|
||||
market: String(main.market),
|
||||
pick: String(main.pick),
|
||||
stake_units: Number(main.stake_units ?? advice.suggested_stake_units ?? 1),
|
||||
odds: Number(main.odds ?? 0) || null,
|
||||
};
|
||||
const won = resolveOutcomeForPick(pickRef, result);
|
||||
if (won === null) {
|
||||
return { outcome: "NO_BET", unitProfit: 0 };
|
||||
}
|
||||
return {
|
||||
outcome: `${won ? "WON" : "LOST"}:${pickRef.market}:${pickRef.pick}`,
|
||||
unitProfit: computeUnitProfit(won, pickRef.stake_units, pickRef.odds),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.env.ENABLE_BACKFILL !== "true") {
|
||||
console.error(
|
||||
"✗ Backfill is gated. Re-run with ENABLE_BACKFILL=true to proceed.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opts = parseArgs();
|
||||
console.log("📦 Backfill prediction_runs");
|
||||
console.log(` Range: ${opts.from} → ${opts.to}`);
|
||||
console.log(` Sport: ${opts.sport}`);
|
||||
console.log(` Engine: ${opts.engine}`);
|
||||
console.log(` Limit: ${opts.limit}`);
|
||||
console.log(` AI engine: ${AI_ENGINE_URL}`);
|
||||
|
||||
const fromMs = BigInt(new Date(opts.from).getTime());
|
||||
const toMs = BigInt(new Date(opts.to).getTime());
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
sport: opts.sport as any,
|
||||
status: "FT",
|
||||
scoreHome: { not: null },
|
||||
scoreAway: { not: null },
|
||||
mstUtc: { gte: fromMs, lte: toMs },
|
||||
},
|
||||
orderBy: { mstUtc: "asc" },
|
||||
take: opts.limit,
|
||||
select: {
|
||||
id: true,
|
||||
mstUtc: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
htScoreHome: true,
|
||||
htScoreAway: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n ${matches.length} finished matches in range\n`);
|
||||
|
||||
let predicted = 0;
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (let i = 0; i < matches.length; i += CONCURRENCY) {
|
||||
const batch = matches.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map(async (m) => {
|
||||
const existing = await prisma.predictionRun.findFirst({
|
||||
where: { matchId: m.id, engineVersion: opts.engine },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await runOne(m.id, opts.engine);
|
||||
if (!payload) return;
|
||||
predicted += 1;
|
||||
|
||||
const summary = buildPayloadSummary(payload);
|
||||
const result: MatchResult = {
|
||||
scoreHome: m.scoreHome!,
|
||||
scoreAway: m.scoreAway!,
|
||||
htScoreHome: m.htScoreHome,
|
||||
htScoreAway: m.htScoreAway,
|
||||
};
|
||||
const settled = settleFromPayload(payload, result);
|
||||
|
||||
await prisma.predictionRun.create({
|
||||
data: {
|
||||
matchId: m.id,
|
||||
engineVersion: payload?.model_version ?? opts.engine,
|
||||
decisionTraceId: payload?.decision_trace_id ?? null,
|
||||
oddsSnapshot: payload?.odds_snapshot ?? null,
|
||||
payloadSummary: summary,
|
||||
eventualOutcome: settled.outcome,
|
||||
unitProfit: settled.unitProfit,
|
||||
},
|
||||
});
|
||||
written += 1;
|
||||
}),
|
||||
);
|
||||
|
||||
if ((i / CONCURRENCY) % 25 === 0) {
|
||||
console.log(
|
||||
` [${i + batch.length}/${matches.length}] predicted=${predicted} written=${written} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
await sleep(REQUEST_DELAY_MS);
|
||||
}
|
||||
|
||||
console.log("\n✅ Backfill complete");
|
||||
console.log(` Predicted: ${predicted}`);
|
||||
console.log(` Written: ${written}`);
|
||||
console.log(` Skipped: ${skipped}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* ===================================================
|
||||
* BACKTEST REPORT
|
||||
* ===================================================
|
||||
* Reads prediction_runs (with eventual_outcome + unit_profit
|
||||
* already filled by settlement / backfill) and prints
|
||||
* per-engine x per-market ROI tables. Also writes JSON.
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node --transpile-only -r tsconfig-paths/register \
|
||||
* src/scripts/print-backtest-report.ts [--engine v28-pro-max]
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface RawRow {
|
||||
engine_version: string;
|
||||
market: string | null;
|
||||
bet_grade: string | null;
|
||||
outcome_kind: "WON" | "LOST" | "NO_BET";
|
||||
unit_profit: number | null;
|
||||
count: bigint;
|
||||
}
|
||||
|
||||
interface AggregatedBucket {
|
||||
engine: string;
|
||||
market: string;
|
||||
betGrade: string;
|
||||
totalBets: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
noBets: number;
|
||||
totalProfit: number;
|
||||
roi: number;
|
||||
winRate: number;
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts: Record<string, string> = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
opts[args[i].replace(/^--/, "")] = args[i + 1];
|
||||
}
|
||||
return { engineFilter: opts.engine ?? null };
|
||||
}
|
||||
|
||||
async function loadRows(engineFilter: string | null): Promise<RawRow[]> {
|
||||
const engineClause = engineFilter
|
||||
? Prisma.sql`AND pr.engine_version = ${engineFilter}`
|
||||
: Prisma.sql``;
|
||||
|
||||
return prisma.$queryRaw<RawRow[]>(Prisma.sql`
|
||||
SELECT pr.engine_version,
|
||||
pr.payload_summary->'main_pick'->>'market' AS market,
|
||||
pr.payload_summary->'main_pick'->>'bet_grade' AS bet_grade,
|
||||
CASE
|
||||
WHEN pr.eventual_outcome LIKE 'WON:%' THEN 'WON'
|
||||
WHEN pr.eventual_outcome LIKE 'LOST:%' THEN 'LOST'
|
||||
ELSE 'NO_BET'
|
||||
END AS outcome_kind,
|
||||
pr.unit_profit,
|
||||
1::bigint AS count
|
||||
FROM prediction_runs pr
|
||||
WHERE pr.eventual_outcome IS NOT NULL
|
||||
${engineClause}
|
||||
`);
|
||||
}
|
||||
|
||||
function aggregate(rows: RawRow[]): AggregatedBucket[] {
|
||||
const map = new Map<string, AggregatedBucket>();
|
||||
for (const r of rows) {
|
||||
const market = r.market ?? "(none)";
|
||||
const betGrade = r.bet_grade ?? "(none)";
|
||||
const key = `${r.engine_version}|${market}|${betGrade}`;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
engine: r.engine_version,
|
||||
market,
|
||||
betGrade,
|
||||
totalBets: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
noBets: 0,
|
||||
totalProfit: 0,
|
||||
roi: 0,
|
||||
winRate: 0,
|
||||
});
|
||||
}
|
||||
const b = map.get(key)!;
|
||||
if (r.outcome_kind === "WON") b.wins += 1;
|
||||
else if (r.outcome_kind === "LOST") b.losses += 1;
|
||||
else b.noBets += 1;
|
||||
if (r.outcome_kind !== "NO_BET") b.totalBets += 1;
|
||||
b.totalProfit += Number(r.unit_profit ?? 0);
|
||||
}
|
||||
|
||||
const out = Array.from(map.values());
|
||||
for (const b of out) {
|
||||
b.roi = b.totalBets > 0 ? b.totalProfit / b.totalBets : 0;
|
||||
b.winRate = b.totalBets > 0 ? b.wins / b.totalBets : 0;
|
||||
}
|
||||
return out.sort((a, b) => b.totalBets - a.totalBets);
|
||||
}
|
||||
|
||||
function printTable(buckets: AggregatedBucket[]) {
|
||||
const header = [
|
||||
"engine".padEnd(20),
|
||||
"market".padEnd(10),
|
||||
"grade".padEnd(6),
|
||||
"bets".padStart(7),
|
||||
"wins".padStart(7),
|
||||
"losses".padStart(7),
|
||||
"noBet".padStart(7),
|
||||
"winRate".padStart(8),
|
||||
"profit".padStart(10),
|
||||
"ROI".padStart(8),
|
||||
].join(" │ ");
|
||||
console.log(header);
|
||||
console.log("─".repeat(header.length));
|
||||
for (const b of buckets) {
|
||||
console.log(
|
||||
[
|
||||
b.engine.padEnd(20),
|
||||
b.market.padEnd(10),
|
||||
b.betGrade.padEnd(6),
|
||||
String(b.totalBets).padStart(7),
|
||||
String(b.wins).padStart(7),
|
||||
String(b.losses).padStart(7),
|
||||
String(b.noBets).padStart(7),
|
||||
`${(b.winRate * 100).toFixed(1)}%`.padStart(8),
|
||||
b.totalProfit.toFixed(2).padStart(10),
|
||||
`${(b.roi * 100).toFixed(2)}%`.padStart(8),
|
||||
].join(" │ "),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function engineSummary(buckets: AggregatedBucket[]) {
|
||||
const byEngine = new Map<string, AggregatedBucket>();
|
||||
for (const b of buckets) {
|
||||
if (!byEngine.has(b.engine)) {
|
||||
byEngine.set(b.engine, {
|
||||
engine: b.engine,
|
||||
market: "ALL",
|
||||
betGrade: "ALL",
|
||||
totalBets: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
noBets: 0,
|
||||
totalProfit: 0,
|
||||
roi: 0,
|
||||
winRate: 0,
|
||||
});
|
||||
}
|
||||
const acc = byEngine.get(b.engine)!;
|
||||
acc.totalBets += b.totalBets;
|
||||
acc.wins += b.wins;
|
||||
acc.losses += b.losses;
|
||||
acc.noBets += b.noBets;
|
||||
acc.totalProfit += b.totalProfit;
|
||||
}
|
||||
const out = Array.from(byEngine.values());
|
||||
for (const e of out) {
|
||||
e.roi = e.totalBets > 0 ? e.totalProfit / e.totalBets : 0;
|
||||
e.winRate = e.totalBets > 0 ? e.wins / e.totalBets : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { engineFilter } = parseArgs();
|
||||
console.log("📊 Backtest report");
|
||||
if (engineFilter) console.log(` filter engine = ${engineFilter}`);
|
||||
|
||||
const rows = await loadRows(engineFilter);
|
||||
console.log(` ${rows.length} resolved prediction_runs loaded\n`);
|
||||
|
||||
const buckets = aggregate(rows);
|
||||
const enginesAll = engineSummary(buckets);
|
||||
|
||||
console.log("=== Per engine (all markets) ===");
|
||||
printTable(enginesAll);
|
||||
console.log("\n=== Per engine × market × bet_grade ===");
|
||||
printTable(buckets);
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const outDir = path.join(process.cwd(), "reports");
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir);
|
||||
const outFile = path.join(outDir, `backtest-${ts}.json`);
|
||||
fs.writeFileSync(
|
||||
outFile,
|
||||
JSON.stringify({ engines: enginesAll, buckets }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
console.log(`\n💾 Saved ${outFile}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
export interface MatchResult {
|
||||
scoreHome: number;
|
||||
scoreAway: number;
|
||||
htScoreHome: number | null;
|
||||
htScoreAway: number | null;
|
||||
}
|
||||
|
||||
export interface PickRef {
|
||||
market: string;
|
||||
pick: string;
|
||||
stake_units: number;
|
||||
odds: number | null;
|
||||
}
|
||||
|
||||
type Resolver = (pick: string, r: MatchResult) => boolean | null;
|
||||
|
||||
const ms1x2: Resolver = (pick, r) => {
|
||||
const outcome = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X";
|
||||
return pick === outcome;
|
||||
};
|
||||
|
||||
const ht1x2: Resolver = (pick, r) => {
|
||||
if (r.htScoreHome === null || r.htScoreAway === null) return null;
|
||||
const outcome =
|
||||
r.htScoreHome > r.htScoreAway ? "1" : r.htScoreHome < r.htScoreAway ? "2" : "X";
|
||||
return pick === outcome;
|
||||
};
|
||||
|
||||
const overUnder = (line: number): Resolver => (pick, r) => {
|
||||
const total = r.scoreHome + r.scoreAway;
|
||||
if (total === line) return null;
|
||||
const isOver = total > line;
|
||||
if (pick === "Üst" || pick === "Ust" || pick.toLowerCase() === "over") return isOver;
|
||||
if (pick === "Alt" || pick.toLowerCase() === "under") return !isOver;
|
||||
return null;
|
||||
};
|
||||
|
||||
const overUnderHt = (line: number): Resolver => (pick, r) => {
|
||||
if (r.htScoreHome === null || r.htScoreAway === null) return null;
|
||||
const total = r.htScoreHome + r.htScoreAway;
|
||||
if (total === line) return null;
|
||||
const isOver = total > line;
|
||||
if (pick === "Üst" || pick === "Ust" || pick.toLowerCase() === "over") return isOver;
|
||||
if (pick === "Alt" || pick.toLowerCase() === "under") return !isOver;
|
||||
return null;
|
||||
};
|
||||
|
||||
const btts: Resolver = (pick, r) => {
|
||||
const both = r.scoreHome > 0 && r.scoreAway > 0;
|
||||
if (pick === "Var" || pick === "KG Var" || pick.toLowerCase() === "yes") return both;
|
||||
if (pick === "Yok" || pick === "KG Yok" || pick.toLowerCase() === "no") return !both;
|
||||
return null;
|
||||
};
|
||||
|
||||
const htft: Resolver = (pick, r) => {
|
||||
if (r.htScoreHome === null || r.htScoreAway === null) return null;
|
||||
const ht =
|
||||
r.htScoreHome > r.htScoreAway ? "1" : r.htScoreHome < r.htScoreAway ? "2" : "X";
|
||||
const ft = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X";
|
||||
const normalized = pick.replace(/\s/g, "").toUpperCase();
|
||||
return normalized === `${ht}/${ft}`;
|
||||
};
|
||||
|
||||
const doubleChance: Resolver = (pick, r) => {
|
||||
const ft = r.scoreHome > r.scoreAway ? "1" : r.scoreHome < r.scoreAway ? "2" : "X";
|
||||
const normalized = pick.replace(/\s/g, "").toUpperCase().split(/[\/\-]/);
|
||||
if (normalized.length !== 2) return null;
|
||||
return normalized.includes(ft);
|
||||
};
|
||||
|
||||
const oddEven: Resolver = (pick, r) => {
|
||||
const total = r.scoreHome + r.scoreAway;
|
||||
const isOdd = total % 2 === 1;
|
||||
if (pick === "Tek" || pick.toLowerCase() === "odd") return isOdd;
|
||||
if (pick === "Çift" || pick === "Cift" || pick.toLowerCase() === "even") return !isOdd;
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolvers: Record<string, Resolver> = {
|
||||
MS: ms1x2,
|
||||
ML: ms1x2,
|
||||
"1X2": ms1x2,
|
||||
HT: ht1x2,
|
||||
IY: ht1x2,
|
||||
OU05: overUnder(0.5),
|
||||
OU15: overUnder(1.5),
|
||||
OU25: overUnder(2.5),
|
||||
OU35: overUnder(3.5),
|
||||
OU45: overUnder(4.5),
|
||||
TOTAL: overUnder(2.5),
|
||||
OU05_HT: overUnderHt(0.5),
|
||||
OU15_HT: overUnderHt(1.5),
|
||||
OU25_HT: overUnderHt(2.5),
|
||||
BTTS: btts,
|
||||
KG: btts,
|
||||
HTFT: htft,
|
||||
IYMS: htft,
|
||||
DC: doubleChance,
|
||||
CIFTE_SANS: doubleChance,
|
||||
OE: oddEven,
|
||||
TEKCIFT: oddEven,
|
||||
};
|
||||
|
||||
export function resolveOutcomeForPick(
|
||||
pick: PickRef,
|
||||
result: MatchResult,
|
||||
): boolean | null {
|
||||
const market = pick.market.toUpperCase().replace(/[\s\-]/g, "_");
|
||||
const resolver = resolvers[market] ?? resolvers[pick.market];
|
||||
if (!resolver) return null;
|
||||
try {
|
||||
return resolver(pick.pick, result);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeUnitProfit(
|
||||
won: boolean,
|
||||
stakeUnits: number,
|
||||
odds: number | null,
|
||||
): number {
|
||||
const stake = Number.isFinite(stakeUnits) && stakeUnits > 0 ? stakeUnits : 1;
|
||||
if (!won) return -stake;
|
||||
if (!odds || odds <= 1) return 0;
|
||||
return Number((stake * (odds - 1)).toFixed(4));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../database/prisma.service";
|
||||
import { TaskLockService } from "./task-lock.service";
|
||||
import {
|
||||
resolveOutcomeForPick,
|
||||
computeUnitProfit,
|
||||
type MatchResult,
|
||||
type PickRef,
|
||||
} from "./prediction-settlement.market-resolver";
|
||||
|
||||
interface UnresolvedRow {
|
||||
id: bigint;
|
||||
match_id: string;
|
||||
payload_summary: Prisma.JsonValue;
|
||||
odds_snapshot: Prisma.JsonValue;
|
||||
score_home: number | null;
|
||||
score_away: number | null;
|
||||
ht_score_home: number | null;
|
||||
ht_score_away: number | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
@Injectable()
|
||||
export class PredictionSettlementTask {
|
||||
private readonly logger = new Logger(PredictionSettlementTask.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly taskLock: TaskLockService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs after the previous-day match sync. Reconciles prediction_runs against actual results.
|
||||
*/
|
||||
@Cron("30 8 * * *", { timeZone: "Europe/Istanbul" })
|
||||
async settleCompletedPredictions() {
|
||||
if (process.env.FEEDER_MODE === "historical") {
|
||||
this.logger.debug("Skipping settlement in historical feeder mode");
|
||||
return;
|
||||
}
|
||||
await this.taskLock.runWithLease(
|
||||
"settleCompletedPredictions",
|
||||
2 * 60 * 60 * 1000,
|
||||
() => this.runSettlement(),
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
async runSettlement(): Promise<{ scanned: number; updated: number }> {
|
||||
let scanned = 0;
|
||||
let updated = 0;
|
||||
let cursor = 0n;
|
||||
|
||||
for (;;) {
|
||||
const rows = await this.prisma.$queryRaw<UnresolvedRow[]>(Prisma.sql`
|
||||
SELECT pr.id,
|
||||
pr.match_id,
|
||||
pr.payload_summary,
|
||||
pr.odds_snapshot,
|
||||
m.score_home,
|
||||
m.score_away,
|
||||
m.ht_score_home,
|
||||
m.ht_score_away,
|
||||
m.status
|
||||
FROM prediction_runs pr
|
||||
JOIN matches m ON m.id = pr.match_id
|
||||
WHERE pr.eventual_outcome IS NULL
|
||||
AND pr.id > ${cursor}
|
||||
AND m.status = 'FT'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
ORDER BY pr.id ASC
|
||||
LIMIT ${BATCH_SIZE}
|
||||
`);
|
||||
|
||||
if (rows.length === 0) break;
|
||||
|
||||
for (const row of rows) {
|
||||
scanned += 1;
|
||||
const settled = this.settleRow(row);
|
||||
if (settled === null) continue;
|
||||
|
||||
await this.prisma.$executeRaw(Prisma.sql`
|
||||
UPDATE prediction_runs
|
||||
SET eventual_outcome = ${settled.outcome},
|
||||
unit_profit = ${settled.unitProfit}
|
||||
WHERE id = ${row.id}
|
||||
`);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
cursor = rows[rows.length - 1].id;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Settlement finished: scanned=${scanned} updated=${updated}`,
|
||||
);
|
||||
return { scanned, updated };
|
||||
}
|
||||
|
||||
private settleRow(
|
||||
row: UnresolvedRow,
|
||||
): { outcome: string; unitProfit: number } | null {
|
||||
const summary = (row.payload_summary ?? {}) as Record<string, any>;
|
||||
const pick = this.pickToEvaluate(summary);
|
||||
if (!pick) {
|
||||
return { outcome: "NO_BET", unitProfit: 0 };
|
||||
}
|
||||
|
||||
const result: MatchResult = {
|
||||
scoreHome: row.score_home!,
|
||||
scoreAway: row.score_away!,
|
||||
htScoreHome: row.ht_score_home,
|
||||
htScoreAway: row.ht_score_away,
|
||||
};
|
||||
|
||||
const won = resolveOutcomeForPick(pick, result);
|
||||
if (won === null) {
|
||||
this.logger.debug(
|
||||
`Cannot resolve market=${pick.market} pick=${pick.pick} for run=${row.id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const odds = this.extractOdds(row.odds_snapshot, pick);
|
||||
const stake = pick.stake_units ?? 1;
|
||||
const profit = computeUnitProfit(won, stake, odds);
|
||||
|
||||
return {
|
||||
outcome: `${won ? "WON" : "LOST"}:${pick.market}:${pick.pick}`,
|
||||
unitProfit: profit,
|
||||
};
|
||||
}
|
||||
|
||||
private pickToEvaluate(summary: Record<string, any>): PickRef | null {
|
||||
const advice = summary.bet_advice ?? {};
|
||||
if (advice.playable !== true) return null;
|
||||
|
||||
const main = summary.main_pick;
|
||||
if (main && main.playable && main.market && main.pick) {
|
||||
return {
|
||||
market: String(main.market),
|
||||
pick: String(main.pick),
|
||||
stake_units: Number(main.stake_units ?? advice.suggested_stake_units ?? 1),
|
||||
odds: Number(main.odds ?? 0) || null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = summary.value_pick;
|
||||
if (fallback && fallback.playable && fallback.market && fallback.pick) {
|
||||
return {
|
||||
market: String(fallback.market),
|
||||
pick: String(fallback.pick),
|
||||
stake_units: Number(advice.suggested_stake_units ?? 1),
|
||||
odds: Number(fallback.odds ?? 0) || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractOdds(
|
||||
oddsSnapshot: Prisma.JsonValue,
|
||||
pick: PickRef,
|
||||
): number | null {
|
||||
if (pick.odds && pick.odds > 1) return pick.odds;
|
||||
if (!oddsSnapshot || typeof oddsSnapshot !== "object") return null;
|
||||
const snap = oddsSnapshot as Record<string, any>;
|
||||
const odds = snap.odds;
|
||||
if (!odds || typeof odds !== "object") return null;
|
||||
const category = odds[pick.market];
|
||||
if (!category || typeof category !== "object") return null;
|
||||
const value = Number(category[pick.pick]);
|
||||
return Number.isFinite(value) && value > 1 ? value : null;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { HttpModule } from "@nestjs/axios";
|
||||
import { DataFetcherTask } from "./data-fetcher.task";
|
||||
import { HistoricalResultsSyncTask } from "./historical-results-sync.task";
|
||||
import { LimitResetterTask } from "./limit-resetter.task";
|
||||
import { PredictionSettlementTask } from "./prediction-settlement.task";
|
||||
import { TaskLockService } from "./task-lock.service";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { FeederModule } from "../modules/feeder/feeder.module";
|
||||
@@ -24,7 +25,13 @@ import { FeederModule } from "../modules/feeder/feeder.module";
|
||||
DataFetcherTask,
|
||||
HistoricalResultsSyncTask,
|
||||
LimitResetterTask,
|
||||
PredictionSettlementTask,
|
||||
],
|
||||
exports: [
|
||||
DataFetcherTask,
|
||||
HistoricalResultsSyncTask,
|
||||
LimitResetterTask,
|
||||
PredictionSettlementTask,
|
||||
],
|
||||
exports: [DataFetcherTask, HistoricalResultsSyncTask, LimitResetterTask],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
Reference in New Issue
Block a user