This commit is contained in:
@@ -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<string, Record<string, number>>,
|
||||
): Promise<void> {
|
||||
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<string, number>();
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user