142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
|
|
import { eq, and } from "drizzle-orm";
|
|||
|
|
import { database } from "~/database/context";
|
|||
|
|
import * as schema from "~/database/schema";
|
|||
|
|
|
|||
|
|
export type PlayoffMatchOdds = typeof schema.playoffMatchOdds.$inferSelect;
|
|||
|
|
export type NewPlayoffMatchOdds = typeof schema.playoffMatchOdds.$inferInsert;
|
|||
|
|
|
|||
|
|
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Convert American moneyline odds to raw implied probability (includes vig).
|
|||
|
|
*
|
|||
|
|
* Positive odds (e.g. +150): probability = 100 / (odds + 100)
|
|||
|
|
* Negative odds (e.g. -110): probability = |odds| / (|odds| + 100)
|
|||
|
|
*
|
|||
|
|
* Returns a value in [0, 1].
|
|||
|
|
*/
|
|||
|
|
export function americanToImpliedProbability(moneyline: number): number {
|
|||
|
|
if (moneyline === 0) return 0;
|
|||
|
|
if (moneyline > 0) {
|
|||
|
|
return 100 / (moneyline + 100);
|
|||
|
|
}
|
|||
|
|
const abs = Math.abs(moneyline);
|
|||
|
|
return abs / (abs + 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Convert an implied probability (0–1) to American moneyline odds.
|
|||
|
|
*
|
|||
|
|
* prob >= 0.5 → negative (favourite): -(prob / (1 - prob)) * 100
|
|||
|
|
* prob < 0.5 → positive (underdog): ((1 - prob) / prob) * 100
|
|||
|
|
*
|
|||
|
|
* Returns rounded integer.
|
|||
|
|
*/
|
|||
|
|
export function impliedProbabilityToAmerican(probability: number): number {
|
|||
|
|
if (probability <= 0 || probability >= 1) {
|
|||
|
|
throw new RangeError("Probability must be strictly between 0 and 1");
|
|||
|
|
}
|
|||
|
|
if (probability >= 0.5) {
|
|||
|
|
return Math.round(-(probability / (1 - probability)) * 100);
|
|||
|
|
}
|
|||
|
|
return Math.round(((1 - probability) / probability) * 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Remove the vig from a two-way market to get fair probabilities summing to 1.
|
|||
|
|
*
|
|||
|
|
* Returns [fairP1, fairP2].
|
|||
|
|
*/
|
|||
|
|
export function normalizeOdds(
|
|||
|
|
moneyline1: number,
|
|||
|
|
moneyline2: number
|
|||
|
|
): [number, number] {
|
|||
|
|
const raw1 = americanToImpliedProbability(moneyline1);
|
|||
|
|
const raw2 = americanToImpliedProbability(moneyline2);
|
|||
|
|
const total = raw1 + raw2;
|
|||
|
|
return [raw1 / total, raw2 / total];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── DB operations ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export async function findOddsByMatchId(
|
|||
|
|
playoffMatchId: string
|
|||
|
|
): Promise<PlayoffMatchOdds[]> {
|
|||
|
|
const db = database();
|
|||
|
|
return await db.query.playoffMatchOdds.findMany({
|
|||
|
|
where: eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
|
|||
|
|
with: { participant: true },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function findOddsForParticipant(
|
|||
|
|
playoffMatchId: string,
|
|||
|
|
participantId: string
|
|||
|
|
): Promise<PlayoffMatchOdds | undefined> {
|
|||
|
|
const db = database();
|
|||
|
|
return await db.query.playoffMatchOdds.findFirst({
|
|||
|
|
where: and(
|
|||
|
|
eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
|
|||
|
|
eq(schema.playoffMatchOdds.participantId, participantId)
|
|||
|
|
),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Insert or update odds for a participant in a matchup.
|
|||
|
|
* One row per (playoffMatchId, participantId) — overwritten on each call.
|
|||
|
|
* Implied probability is computed automatically from moneylineOdds.
|
|||
|
|
*/
|
|||
|
|
export async function upsertMatchOdds(
|
|||
|
|
playoffMatchId: string,
|
|||
|
|
participantId: string,
|
|||
|
|
data: {
|
|||
|
|
moneylineOdds: number;
|
|||
|
|
oddsSource?: string;
|
|||
|
|
}
|
|||
|
|
): Promise<PlayoffMatchOdds> {
|
|||
|
|
const db = database();
|
|||
|
|
|
|||
|
|
const impliedProbability = americanToImpliedProbability(data.moneylineOdds).toFixed(4);
|
|||
|
|
const payload = { ...data, impliedProbability };
|
|||
|
|
|
|||
|
|
const existing = await findOddsForParticipant(playoffMatchId, participantId);
|
|||
|
|
|
|||
|
|
if (existing) {
|
|||
|
|
const [updated] = await db
|
|||
|
|
.update(schema.playoffMatchOdds)
|
|||
|
|
.set({ ...payload, recordedAt: new Date(), updatedAt: new Date() })
|
|||
|
|
.where(eq(schema.playoffMatchOdds.id, existing.id))
|
|||
|
|
.returning();
|
|||
|
|
return updated;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [created] = await db
|
|||
|
|
.insert(schema.playoffMatchOdds)
|
|||
|
|
.values({ playoffMatchId, participantId, ...payload })
|
|||
|
|
.returning();
|
|||
|
|
return created;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function deleteOddsByMatchId(playoffMatchId: string): Promise<void> {
|
|||
|
|
const db = database();
|
|||
|
|
await db
|
|||
|
|
.delete(schema.playoffMatchOdds)
|
|||
|
|
.where(eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function deleteOddsForParticipant(
|
|||
|
|
playoffMatchId: string,
|
|||
|
|
participantId: string
|
|||
|
|
): Promise<void> {
|
|||
|
|
const db = database();
|
|||
|
|
await db
|
|||
|
|
.delete(schema.playoffMatchOdds)
|
|||
|
|
.where(
|
|||
|
|
and(
|
|||
|
|
eq(schema.playoffMatchOdds.playoffMatchId, playoffMatchId),
|
|||
|
|
eq(schema.playoffMatchOdds.participantId, participantId)
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
}
|