From 42b6c7ce43c5744cd7f3426559516517fbf88006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fahri=20Can=20Se=C3=A7er?= Date: Sun, 7 Jun 2026 21:28:52 +0300 Subject: [PATCH] Update data-fetcher.task.ts --- src/tasks/data-fetcher.task.ts | 128 ++++++++++++++++++--------------- 1 file changed, 70 insertions(+), 58 deletions(-) diff --git a/src/tasks/data-fetcher.task.ts b/src/tasks/data-fetcher.task.ts index 27c8a80..9cd0df9 100755 --- a/src/tasks/data-fetcher.task.ts +++ b/src/tasks/data-fetcher.task.ts @@ -1249,73 +1249,85 @@ export class DataFetcherTask { // (Preserved from original — no logic changes) // ──────────────────────────────────────────────────────────── + // One-time guard: run CREATE TABLE IF NOT EXISTS only once per process. + private static liveOddsTableReady = false; + /** - * Persist live odds movement into the structured odd_selections / odds_history - * tables (the live odds elsewhere are only a JSON blob on live_matches). This - * is what makes opening→closing ranges and steam queryable in SQL. Mirrors the - * historical feeder's change-detection (feeder-persistence.service.ts) but for - * the live HTML odds format. Change-only writes; ALL markets in the payload - * are tracked (so anomalies/steam on any bet type are captured). + * Persist live PRE-MATCH odds movement into a dedicated, FK-FREE table + * (live_odds_history). Why not the structured odds_history table: that one is + * tied to odd_categories.match_id, a FOREIGN KEY to `matches`. Upcoming matches + * live ONLY in live_matches (not yet archived to `matches`), so any write to + * odd_categories/odds_history for them fails the FK — silently losing all + * pre-match movement. This table is keyed by raw match_id (no FK), so it + * captures opening→closing movement for upcoming matches across ALL markets. + * Change-only inserts; first capture per selection = opening (previous NULL). + * (Finished-match closing odds are still captured in odd_selections/odds_history + * by the archival / 08:00 job, which DO have the match in `matches`.) */ private async persistOddsHistory( matchId: string, odds: Record>, ): Promise { try { - const cats = await this.prisma.oddCategory.findMany({ - where: { matchId }, - include: { selections: true }, - }); - for (const [catName, sels] of Object.entries(odds)) { - let category = cats.find((c) => c.name === catName); - if (!category) { - category = await this.prisma.oddCategory.create({ - data: { matchId, name: catName }, - include: { selections: true }, - }); - cats.push(category); - } - for (const [selName, value] of Object.entries(sels)) { - const sVal = String(value); - if (!selName || !sVal || !(value > 0)) continue; - const existing = category.selections.find((s) => s.name === selName); - if (existing) { - if (existing.oddValue !== sVal) { - const oldVal = parseFloat(existing.oddValue || "0"); - const newVal = parseFloat(sVal); - if (!isNaN(oldVal) && !isNaN(newVal) && oldVal > 0) { - await this.prisma.oddsHistory.create({ - data: { - selectionId: existing.dbId, - matchId, - previousValue: oldVal, - newValue: newVal, - }, - }); - } - await this.prisma.oddSelection.update({ - where: { dbId: existing.dbId }, - data: { oddValue: sVal }, - }); - existing.oddValue = sVal; - } - } else { - const created = await this.prisma.oddSelection.create({ - data: { - categoryId: category.dbId, - name: selName, - oddValue: sVal, - openingValue: sVal, - }, - }); - category.selections.push(created); + if (!DataFetcherTask.liveOddsTableReady) { + await this.prisma.$executeRawUnsafe( + `CREATE TABLE IF NOT EXISTS live_odds_history ( + id BIGSERIAL PRIMARY KEY, + match_id TEXT NOT NULL, + market TEXT NOT NULL, + selection TEXT NOT NULL, + previous_value DOUBLE PRECISION, + new_value DOUBLE PRECISION NOT NULL, + change_time TIMESTAMPTZ NOT NULL DEFAULT now())`, + ); + await this.prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS idx_loh_match_time ON live_odds_history(match_id, change_time)`, + ); + DataFetcherTask.liveOddsTableReady = true; + } + + // Last-known value per (market, selection) for this match. + const last = await this.prisma.$queryRawUnsafe< + Array<{ market: string; selection: string; new_value: number }> + >( + `SELECT DISTINCT ON (market, selection) market, selection, new_value + FROM live_odds_history WHERE match_id = $1 + ORDER BY market, selection, change_time DESC`, + matchId, + ); + const lastMap = new Map(); + for (const r of last) { + lastMap.set(`${r.market}|${r.selection}`, Number(r.new_value)); + } + + for (const [market, sels] of Object.entries(odds)) { + for (const [selection, value] of Object.entries(sels)) { + if (!(value > 0)) continue; + const prev = lastMap.get(`${market}|${selection}`); + if (prev === undefined) { + // first capture for this selection = opening + await this.prisma.$executeRawUnsafe( + `INSERT INTO live_odds_history (match_id, market, selection, previous_value, new_value) + VALUES ($1, $2, $3, NULL, $4)`, + matchId, market, selection, value, + ); + lastMap.set(`${market}|${selection}`, value); + } else if (Math.abs(prev - value) > 1e-9) { + // odds moved → log the movement + await this.prisma.$executeRawUnsafe( + `INSERT INTO live_odds_history (match_id, market, selection, previous_value, new_value) + VALUES ($1, $2, $3, $4, $5)`, + matchId, market, selection, prev, value, + ); + lastMap.set(`${market}|${selection}`, value); } } } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - this.logger.debug( - `odds_history persist skipped for ${matchId}: ${message}`, + // WARN (not debug): silent debug is exactly what hid the earlier FK failure. + this.logger.warn( + `live_odds_history persist failed for ${matchId}: ${message}`, ); } } @@ -1488,9 +1500,9 @@ export class DataFetcherTask { }, }); - // Fill the structured odds_history table from this pre-match odds refresh, - // so opening→closing ranges & steam are captured in the DB (not just the - // live_matches JSON blob). Runs only for pre-match matches reached here. + // Log this pre-match odds refresh into live_odds_history (FK-free table) so + // opening→closing movement & steam are queryable in the DB for UPCOMING + // matches too (the structured odds_history can't — FK to `matches`). if (Object.keys(odds).length > 0) { await this.persistOddsHistory(match.id, odds); }