This commit is contained in:
@@ -1249,73 +1249,85 @@ export class DataFetcherTask {
|
|||||||
// (Preserved from original — no logic changes)
|
// (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
|
* Persist live PRE-MATCH odds movement into a dedicated, FK-FREE table
|
||||||
* tables (the live odds elsewhere are only a JSON blob on live_matches). This
|
* (live_odds_history). Why not the structured odds_history table: that one is
|
||||||
* is what makes opening→closing ranges and steam queryable in SQL. Mirrors the
|
* tied to odd_categories.match_id, a FOREIGN KEY to `matches`. Upcoming matches
|
||||||
* historical feeder's change-detection (feeder-persistence.service.ts) but for
|
* live ONLY in live_matches (not yet archived to `matches`), so any write to
|
||||||
* the live HTML odds format. Change-only writes; ALL markets in the payload
|
* odd_categories/odds_history for them fails the FK — silently losing all
|
||||||
* are tracked (so anomalies/steam on any bet type are captured).
|
* 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(
|
private async persistOddsHistory(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
odds: Record<string, Record<string, number>>,
|
odds: Record<string, Record<string, number>>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cats = await this.prisma.oddCategory.findMany({
|
if (!DataFetcherTask.liveOddsTableReady) {
|
||||||
where: { matchId },
|
await this.prisma.$executeRawUnsafe(
|
||||||
include: { selections: true },
|
`CREATE TABLE IF NOT EXISTS live_odds_history (
|
||||||
});
|
id BIGSERIAL PRIMARY KEY,
|
||||||
for (const [catName, sels] of Object.entries(odds)) {
|
match_id TEXT NOT NULL,
|
||||||
let category = cats.find((c) => c.name === catName);
|
market TEXT NOT NULL,
|
||||||
if (!category) {
|
selection TEXT NOT NULL,
|
||||||
category = await this.prisma.oddCategory.create({
|
previous_value DOUBLE PRECISION,
|
||||||
data: { matchId, name: catName },
|
new_value DOUBLE PRECISION NOT NULL,
|
||||||
include: { selections: true },
|
change_time TIMESTAMPTZ NOT NULL DEFAULT now())`,
|
||||||
});
|
);
|
||||||
cats.push(category);
|
await this.prisma.$executeRawUnsafe(
|
||||||
}
|
`CREATE INDEX IF NOT EXISTS idx_loh_match_time ON live_odds_history(match_id, change_time)`,
|
||||||
for (const [selName, value] of Object.entries(sels)) {
|
);
|
||||||
const sVal = String(value);
|
DataFetcherTask.liveOddsTableReady = true;
|
||||||
if (!selName || !sVal || !(value > 0)) continue;
|
}
|
||||||
const existing = category.selections.find((s) => s.name === selName);
|
|
||||||
if (existing) {
|
// Last-known value per (market, selection) for this match.
|
||||||
if (existing.oddValue !== sVal) {
|
const last = await this.prisma.$queryRawUnsafe<
|
||||||
const oldVal = parseFloat(existing.oddValue || "0");
|
Array<{ market: string; selection: string; new_value: number }>
|
||||||
const newVal = parseFloat(sVal);
|
>(
|
||||||
if (!isNaN(oldVal) && !isNaN(newVal) && oldVal > 0) {
|
`SELECT DISTINCT ON (market, selection) market, selection, new_value
|
||||||
await this.prisma.oddsHistory.create({
|
FROM live_odds_history WHERE match_id = $1
|
||||||
data: {
|
ORDER BY market, selection, change_time DESC`,
|
||||||
selectionId: existing.dbId,
|
matchId,
|
||||||
matchId,
|
);
|
||||||
previousValue: oldVal,
|
const lastMap = new Map<string, number>();
|
||||||
newValue: newVal,
|
for (const r of last) {
|
||||||
},
|
lastMap.set(`${r.market}|${r.selection}`, Number(r.new_value));
|
||||||
});
|
}
|
||||||
}
|
|
||||||
await this.prisma.oddSelection.update({
|
for (const [market, sels] of Object.entries(odds)) {
|
||||||
where: { dbId: existing.dbId },
|
for (const [selection, value] of Object.entries(sels)) {
|
||||||
data: { oddValue: sVal },
|
if (!(value > 0)) continue;
|
||||||
});
|
const prev = lastMap.get(`${market}|${selection}`);
|
||||||
existing.oddValue = sVal;
|
if (prev === undefined) {
|
||||||
}
|
// first capture for this selection = opening
|
||||||
} else {
|
await this.prisma.$executeRawUnsafe(
|
||||||
const created = await this.prisma.oddSelection.create({
|
`INSERT INTO live_odds_history (match_id, market, selection, previous_value, new_value)
|
||||||
data: {
|
VALUES ($1, $2, $3, NULL, $4)`,
|
||||||
categoryId: category.dbId,
|
matchId, market, selection, value,
|
||||||
name: selName,
|
);
|
||||||
oddValue: sVal,
|
lastMap.set(`${market}|${selection}`, value);
|
||||||
openingValue: sVal,
|
} else if (Math.abs(prev - value) > 1e-9) {
|
||||||
},
|
// odds moved → log the movement
|
||||||
});
|
await this.prisma.$executeRawUnsafe(
|
||||||
category.selections.push(created);
|
`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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.debug(
|
// WARN (not debug): silent debug is exactly what hid the earlier FK failure.
|
||||||
`odds_history persist skipped for ${matchId}: ${message}`,
|
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,
|
// Log this pre-match odds refresh into live_odds_history (FK-free table) so
|
||||||
// so opening→closing ranges & steam are captured in the DB (not just the
|
// opening→closing movement & steam are queryable in the DB for UPCOMING
|
||||||
// live_matches JSON blob). Runs only for pre-match matches reached here.
|
// matches too (the structured odds_history can't — FK to `matches`).
|
||||||
if (Object.keys(odds).length > 0) {
|
if (Object.keys(odds).length > 0) {
|
||||||
await this.persistOddsHistory(match.id, odds);
|
await this.persistOddsHistory(match.id, odds);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user