Bet Builder (Fair Odds) Implementation Plan
The implementation plan for fair-odds pricing in the multi-selection bet builder. Written 2026-07-09.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers
(recommended) or superpowers to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: A new /bet-builder page where a user picks one fixture and sees a bet365-style stack of every market, each selection annotated with our fair odds (model-derived for player props, margin-removed market price for efficient markets).
Architecture: No new server route and no edits to live server code. The page reuses two existing client endpoints — /api/value-bets-v2?eventIds=<matchId> (player-prop model fair odds) and /api/event/<matchId>/odds-comparison (normalized market prices across bookmakers). Two new pure, unit-tested libraries do the work: src/lib/devig.ts (margin removal) and src/lib/bet-builder.ts (shape the two sources into display groups). The page and its presentational components render the result. Player-prop groups come entirely from the v2 response; all other market groups come from odds-comparison + devig — so the two sources never need cross-matching.
Tech Stack: Next.js (pages router), React, TypeScript, SWR, Tailwind, Vitest, Drizzle (only indirectly, via existing endpoints).
Global Constraints
- Framework: Next.js pages router; new page file default-exports a component and sets
Component.getLayout = (page) => <ToolkitLayout>{page}</ToolkitLayout>(matchsrc/pages/value-bets-v2.tsx). - Access/gating: premium-gated, matching value-bets-v2 — use
useUser()(isSubscribed), redirect unauthenticated to/login, and wrap gated content in<PremiumGate>exactly as v2 does. Nav badge:"pro". - Tests run with Vitest:
npx vitest run <path>(notestscript exists; there is no vitest config — default resolution works, as insrc/ai/tools/__tests__/vocab.test.ts). Test imports:import { describe, it, expect } from "vitest";. - Odds are decimal throughout the fair-odds math. Bookmaker odds arrive from odds-comparison as strings — parse with
Number(...)and guardNaN/<= 1. - Reuse existing helpers from
src/lib/value-bets-utils.ts:MARKET_TYPE_LABELS,formatOdds,calcEdge, and typesValueBetGroup/ValueBetsResponse. Reuse typesMarket/ProcessedOutcome/OddsComparisonResponsefromsrc/pages/api/event/[id]/odds-comparison.ts. - Devig method for the MVP is proportional (multiplicative), implemented behind a single function so it can be swapped later.
- No git commits performed by the implementer unless the user explicitly asks (user drives git). Steps below omit commit steps for that reason; stage nothing automatically.
File Structure
- Create
src/lib/devig.ts— pure margin-removal helper (+src/lib/__tests__/devig.test.ts). - Create
src/lib/bet-builder.ts— types + two pure builder functions that turn the two data sources intoBetBuilderGroup[](+src/lib/__tests__/bet-builder.test.ts). - Create
src/pages/bet-builder.tsx— the page: gating, match picker, data fetching, display toggle, renders groups. - Create
src/components/bet-builder/market-group.tsx— one collapsible market group (presentational). - Create
src/components/bet-builder/selection-row.tsx— one selection row: fair odds, and (when toggled) best price + edge (presentational). - Modify
src/components/toolkit-sidebar.tsx— add a "Bet Builder" nav entry.
Task 1: Devig helper (src/lib/devig.ts)
Files:
- Create:
src/lib/devig.ts - Test:
src/lib/__tests__/devig.test.ts
Interfaces:
-
Produces:
devigProportional(decimalOdds: number[]): number[] | null— given the complete set of decimal odds for one market, returns fair decimal odds in the same order, ornullif it can't devig (fewer than 2 valid odds, any odd<= 1, or implied-probability sum<= 1, i.e. no margin to remove).fairOddsForOutcome(decimalOdds: number[], index: number): number | null— convenience: fair odds for a single outcome within its market, ornull.
-
Step 1: Write the failing tests
Create src/lib/__tests__/devig.test.ts:
import { describe, it, expect } from "vitest";
import { devigProportional, fairOddsForOutcome } from "../devig";
describe("devigProportional", () => {
it("removes margin from a 2-way market and fair probs sum to 1", () => {
// 1.90 / 1.90 => implied 0.5263 each, sum 1.0526 (5.26% over-round)
const fair = devigProportional([1.9, 1.9]);
expect(fair).not.toBeNull();
const probs = fair!.map((o) => 1 / o);
expect(probs[0] + probs[1]).toBeCloseTo(1, 6);
// symmetric => fair odds 2.0 each
expect(fair![0]).toBeCloseTo(2.0, 4);
expect(fair![1]).toBeCloseTo(2.0, 4);
});
it("removes margin from a 3-way (1X2) market preserving order", () => {
const fair = devigProportional([2.1, 3.4, 3.6]);
expect(fair).not.toBeNull();
const probs = fair!.map((o) => 1 / o);
expect(probs.reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
// favourite keeps shortest fair odds
expect(fair![0]).toBeLessThan(fair![1]);
expect(fair![0]).toBeLessThan(fair![2]);
// devigging lengthens every price (margin removed)
expect(fair![0]).toBeGreaterThan(2.1);
});
it("returns null when there is no margin (sum of implied probs <= 1)", () => {
expect(devigProportional([2.1, 2.1])).toBeNull(); // implied 0.952, sum 0.952
});
it("returns null for fewer than 2 valid odds", () => {
expect(devigProportional([1.9])).toBeNull();
expect(devigProportional([])).toBeNull();
});
it("returns null when any odd is <= 1 or NaN", () => {
expect(devigProportional([1.0, 5.0])).toBeNull();
expect(devigProportional([NaN, 1.9])).toBeNull();
});
});
describe("fairOddsForOutcome", () => {
it("returns the fair odds at the given index", () => {
const single = fairOddsForOutcome([1.9, 1.9], 0);
expect(single).toBeCloseTo(2.0, 4);
});
it("returns null when the market cannot be devigged", () => {
expect(fairOddsForOutcome([2.1, 2.1], 0)).toBeNull();
expect(fairOddsForOutcome([1.9, 1.9], 5)).toBeNull(); // out of range
});
});- Step 2: Run the tests to verify they fail
Run: npx vitest run src/lib/__tests__/devig.test.ts
Expected: FAIL (module ../devig not found).
- Step 3: Implement
src/lib/devig.ts
/**
* Margin-removal ("devig") helpers.
*
* A bookmaker's decimal odds for a complete market imply probabilities that sum
* to MORE than 1 (the "over-round" / margin). Removing that margin yields fair
* probabilities that sum to 1, which we convert back to fair decimal odds.
*
* MVP method: proportional (multiplicative) — divide each implied probability by
* the over-round. Kept behind one function so a sharper method (Shin,
* margin-weighted) can replace it later without touching callers.
*/
const isValidOdd = (o: number): boolean =>
typeof o === "number" && Number.isFinite(o) && o > 1;
/**
* Devig a complete market. Returns fair decimal odds in the same order as the
* input, or null when it cannot be devigged:
* - fewer than 2 valid odds
* - any odd is NaN / <= 1
* - implied probabilities sum to <= 1 (no margin to remove)
*/
export function devigProportional(decimalOdds: number[]): number[] | null {
if (!Array.isArray(decimalOdds) || decimalOdds.length < 2) return null;
if (!decimalOdds.every(isValidOdd)) return null;
const impliedProbs = decimalOdds.map((o) => 1 / o);
const overround = impliedProbs.reduce((a, b) => a + b, 0);
if (overround <= 1) return null;
return impliedProbs.map((p) => {
const fairProb = p / overround;
return 1 / fairProb;
});
}
/** Fair odds for one outcome within its market, or null. */
export function fairOddsForOutcome(
decimalOdds: number[],
index: number
): number | null {
const fair = devigProportional(decimalOdds);
if (!fair || index < 0 || index >= fair.length) return null;
return fair[index];
}- Step 4: Run the tests to verify they pass
Run: npx vitest run src/lib/__tests__/devig.test.ts
Expected: PASS (all cases).
Task 2: Bet-builder shaping library (src/lib/bet-builder.ts)
Turns the two data sources into a single ordered list of display groups. Player-prop groups come from the v2 ValueBetGroup[]; all other groups come from odds-comparison Market[] + devig. The two sources own disjoint market types, so there is no cross-source matching.
Files:
- Create:
src/lib/bet-builder.ts - Test:
src/lib/__tests__/bet-builder.test.ts
Interfaces:
-
Consumes:
devigProportionalfromsrc/lib/devig.ts;ValueBetGroupfromsrc/lib/value-bets-utils.ts;Market,ProcessedOutcomefromsrc/pages/api/event/[id]/odds-comparison.ts;MARKET_TYPE_LABELS,calcEdgefromsrc/lib/value-bets-utils.ts. -
Produces:
- Types
BetBuilderSelectionandBetBuilderGroup:export interface BetBuilderSelection { label: string; line: number | null; fairOdds: number | null; bestPrice: number | null; bestBookmaker: string | null; edge: number | null; source: "model" | "market"; } export interface BetBuilderGroup { title: string; source: "model" | "market"; selections: BetBuilderSelection[]; } buildPlayerPropGroups(groups: ValueBetGroup[]): BetBuilderGroup[]buildMarketGroups(markets: Market[]): BetBuilderGroup[]PLAYER_PROP_EXCLUDED_CATEGORY = "Player Performance"(the odds-comparison category owned by the model source, skipped bybuildMarketGroups).
- Types
-
Step 1: Write the failing tests
Create src/lib/__tests__/bet-builder.test.ts:
import { describe, it, expect } from "vitest";
import {
buildPlayerPropGroups,
buildMarketGroups,
BetBuilderGroup,
} from "../bet-builder";
import type { ValueBetGroup } from "../value-bets-utils";
import type { Market } from "@/pages/api/event/[id]/odds-comparison";
const baseGroup = (over: Partial<ValueBetGroup>): ValueBetGroup =>
({
groupKey: "k",
playerId: 1,
playerName: "Cole Palmer",
teamId: 1,
teamName: "Chelsea",
matchId: 100,
matchInternalId: null,
matchDate: 0,
marketType: "shots",
line: 2.5,
matchSlug: null,
homeTeamId: null,
homeTeamName: null,
awayTeamId: null,
awayTeamName: null,
uniqueTournamentId: null,
models: [],
bookmakers: [],
avgOverEdge: null,
bestOverOdds: 2.2,
avgUnderEdge: null,
bestUnderOdds: null,
matchOdds: null,
last30Stats: [],
sampleSize: 0,
hitRates: { l10: null, l20: null, l30: null },
recentGames: [],
position: null,
windowHorizon: null,
scannerFairOdds: 2.0,
scannerEdge: 10,
scannerModelName: "theo",
...over,
}) as ValueBetGroup;
describe("buildPlayerPropGroups", () => {
it("groups player props by market label and carries model fair odds", () => {
const groups = buildPlayerPropGroups([
baseGroup({ marketType: "shots", line: 2.5, scannerFairOdds: 2.0 }),
baseGroup({ marketType: "shots", line: 1.5, scannerFairOdds: 1.5, playerName: "Enzo" }),
baseGroup({ marketType: "yellowCard", line: 0.5, scannerFairOdds: 3.0 }),
]);
const shots = groups.find((g) => g.title === "Shots")!;
expect(shots).toBeTruthy();
expect(shots.source).toBe("model");
expect(shots.selections.length).toBe(2);
expect(shots.selections[0].fairOdds).toBe(2.0);
expect(shots.selections[0].label).toContain("Cole Palmer");
expect(shots.selections[0].label).toContain("2.5");
const cards = groups.find((g) => g.title === "Yellow Cards")!;
expect(cards.selections[0].fairOdds).toBe(3.0);
});
it("skips groups with no model fair odds", () => {
const groups = buildPlayerPropGroups([
baseGroup({ scannerFairOdds: null }),
]);
const shots = groups.find((g) => g.title === "Shots");
expect(shots).toBeFalsy();
});
});
describe("buildMarketGroups", () => {
const mlMarket: Market = {
name: "ML",
category: "Match Result",
outcomes: [
{
label: "Home",
bookmakerOdds: [{ bookmaker: "bet365", odds: "2.10" }],
bestOdds: { bookmaker: "bet365", odds: "2.10" },
},
{
label: "Draw",
bookmakerOdds: [{ bookmaker: "bet365", odds: "3.40" }],
bestOdds: { bookmaker: "bet365", odds: "3.40" },
},
{
label: "Away",
bookmakerOdds: [{ bookmaker: "bet365", odds: "3.60" }],
bestOdds: { bookmaker: "bet365", odds: "3.60" },
},
],
};
it("devigs a 1X2 market using a single bookmaker and lengthens all prices", () => {
const groups = buildMarketGroups([mlMarket]);
expect(groups.length).toBe(1);
const g = groups[0];
expect(g.source).toBe("market");
expect(g.selections.length).toBe(3);
// every fair price is longer than the raw price (margin removed)
expect(g.selections[0].fairOdds!).toBeGreaterThan(2.1);
// best price surfaced for the toggle
expect(g.selections[0].bestPrice).toBeCloseTo(2.1, 4);
// fair probs sum to ~1
const sum = g.selections.reduce((a, s) => a + 1 / s.fairOdds!, 0);
expect(sum).toBeCloseTo(1, 6);
});
it("excludes the Player Performance category (owned by the model source)", () => {
const playerMarket: Market = {
name: "Player Shots",
category: "Player Performance",
outcomes: [
{
label: "Cole Palmer Over 2.5",
bookmakerOdds: [{ bookmaker: "bet365", odds: "2.00" }],
bestOdds: { bookmaker: "bet365", odds: "2.00" },
},
],
};
const groups = buildMarketGroups([mlMarket, playerMarket]);
expect(groups.some((g) => g.title === "Player Shots")).toBe(false);
expect(groups.length).toBe(1);
});
it("leaves fairOdds null when a market cannot be devigged", () => {
const noMargin: Market = {
name: "Some Market",
category: "Special Markets",
outcomes: [
{ label: "A", bookmakerOdds: [{ bookmaker: "bet365", odds: "2.10" }], bestOdds: { bookmaker: "bet365", odds: "2.10" } },
{ label: "B", bookmakerOdds: [{ bookmaker: "bet365", odds: "2.10" }], bestOdds: { bookmaker: "bet365", odds: "2.10" } },
],
};
const groups = buildMarketGroups([noMargin]);
expect(groups[0].selections.every((s) => s.fairOdds === null)).toBe(true);
// best price still surfaced
expect(groups[0].selections[0].bestPrice).toBeCloseTo(2.1, 4);
});
});- Step 2: Run the tests to verify they fail
Run: npx vitest run src/lib/__tests__/bet-builder.test.ts
Expected: FAIL (module ../bet-builder not found).
- Step 3: Implement
src/lib/bet-builder.ts
import { devigProportional } from "./devig";
import {
MARKET_TYPE_LABELS,
calcEdge,
type ValueBetGroup,
} from "./value-bets-utils";
import type {
Market,
ProcessedOutcome,
} from "@/pages/api/event/[id]/odds-comparison";
export interface BetBuilderSelection {
label: string;
line: number | null;
fairOdds: number | null;
bestPrice: number | null;
bestBookmaker: string | null;
edge: number | null;
source: "model" | "market";
}
export interface BetBuilderGroup {
title: string;
source: "model" | "market";
selections: BetBuilderSelection[];
}
/** odds-comparison category that the model source owns; skipped by market builder. */
export const PLAYER_PROP_EXCLUDED_CATEGORY = "Player Performance";
/** Preferred single bookmaker to devig against, in priority order (case-insensitive). */
const PREFERRED_BOOKMAKERS = ["bet365", "kambi", "paddy power"];
/**
* Player-prop groups from the value-bets-v2 response. Each ValueBetGroup already
* carries the chosen model's fair odds (`scannerFairOdds`) plus best price/edge.
* Groups with no model fair odds are dropped.
*/
export function buildPlayerPropGroups(
groups: ValueBetGroup[]
): BetBuilderGroup[] {
const byMarket = new Map<string, BetBuilderSelection[]>();
for (const g of groups) {
const fair = g.scannerFairOdds ?? null;
if (fair == null) continue;
const title = MARKET_TYPE_LABELS[g.marketType] ?? g.marketType;
const best = g.bestOverOdds ?? null;
const edge =
g.scannerEdge != null
? g.scannerEdge
: best != null
? calcEdge(best, fair)
: null;
const selection: BetBuilderSelection = {
label: `${g.playerName} Over ${g.line}`,
line: g.line,
fairOdds: fair,
bestPrice: best,
bestBookmaker: null,
edge,
source: "model",
};
const list = byMarket.get(title) ?? [];
list.push(selection);
byMarket.set(title, list);
}
return Array.from(byMarket.entries()).map(([title, selections]) => ({
title,
source: "model" as const,
selections: selections.sort(
(a, b) => (b.edge ?? -Infinity) - (a.edge ?? -Infinity)
),
}));
}
/** Pick a single bookmaker present on the most outcomes of a market (preferred books first). */
function pickBookmaker(market: Market): string | null {
const counts = new Map<string, number>();
for (const o of market.outcomes) {
for (const bo of o.bookmakerOdds) {
counts.set(bo.bookmaker, (counts.get(bo.bookmaker) ?? 0) + 1);
}
}
if (counts.size === 0) return null;
// Preferred books first (case-insensitive), if present on >= 2 outcomes.
for (const pref of PREFERRED_BOOKMAKERS) {
for (const [name, count] of counts.entries()) {
if (name.toLowerCase().includes(pref) && count >= 2) return name;
}
}
// Otherwise the book covering the most outcomes.
let best: string | null = null;
let bestCount = 0;
for (const [name, count] of counts.entries()) {
if (count > bestCount) {
best = name;
bestCount = count;
}
}
return best;
}
const oddFromOutcome = (
o: ProcessedOutcome,
bookmaker: string
): number | null => {
const bo = o.bookmakerOdds.find((b) => b.bookmaker === bookmaker);
const n = bo ? Number(bo.odds) : NaN;
return Number.isFinite(n) && n > 1 ? n : null;
};
const bestOf = (
o: ProcessedOutcome
): { price: number | null; bookmaker: string | null } => {
if (o.bestOdds) {
const n = Number(o.bestOdds.odds);
return {
price: Number.isFinite(n) && n > 1 ? n : null,
bookmaker: o.bestOdds.bookmaker,
};
}
return { price: null, bookmaker: null };
};
/**
* Market groups from odds-comparison. Devig each market against a single
* bookmaker's complete set of outcomes. Player-performance markets are skipped
* (the model source owns those). fairOdds is null when the market can't be
* devigged; bestPrice/edge are still surfaced where available.
*/
export function buildMarketGroups(markets: Market[]): BetBuilderGroup[] {
const out: BetBuilderGroup[] = [];
for (const market of markets) {
if (market.category === PLAYER_PROP_EXCLUDED_CATEGORY) continue;
if (!market.outcomes || market.outcomes.length === 0) continue;
const book = pickBookmaker(market);
const bookOdds = book
? market.outcomes.map((o) => oddFromOutcome(o, book))
: market.outcomes.map(() => null);
// Devig only across outcomes where the chosen book has a valid price.
const validIdx = bookOdds
.map((v, i) => (v != null ? i : -1))
.filter((i) => i >= 0);
let fairByIndex: (number | null)[] = market.outcomes.map(() => null);
if (validIdx.length >= 2) {
const fair = devigProportional(validIdx.map((i) => bookOdds[i] as number));
if (fair) {
validIdx.forEach((i, k) => {
fairByIndex[i] = fair[k];
});
}
}
const selections: BetBuilderSelection[] = market.outcomes.map((o, i) => {
const { price, bookmaker } = bestOf(o);
const fairOdds = fairByIndex[i];
const edge =
fairOdds != null && price != null ? calcEdge(price, fairOdds) : null;
const line =
typeof o.hdp === "number"
? o.hdp
: o.bestOdds && typeof o.bestOdds.line === "number"
? o.bestOdds.line
: null;
return {
label: o.label,
line,
fairOdds,
bestPrice: price,
bestBookmaker: bookmaker,
edge,
source: "market" as const,
};
});
out.push({ title: market.name, source: "market", selections });
}
return out;
}Note:
calcEdge(bestPrice, fairOdds)— confirm argument order againstsrc/lib/value-bets-utils.ts:218during implementation and match it (edge % of a taken price vs the fair price). Adjust the two call sites above if the signature differs.
- Step 4: Run the tests to verify they pass
Run: npx vitest run src/lib/__tests__/bet-builder.test.ts
Expected: PASS. If the calcEdge argument order differs, fix the two call sites and the expected edge sign in the test, then re-run.
Task 3: Bet Builder page — shell, gating, match picker, data (src/pages/bet-builder.tsx)
Files:
- Create:
src/pages/bet-builder.tsx - Reference (read, do not modify):
src/pages/value-bets-v2.tsx(layout/gating pattern,eventIdsfetch, fixture picker usage),src/components/odds-comparison.tsx:811(odds-comparison call signature).
Interfaces:
-
Consumes:
buildPlayerPropGroups,buildMarketGroups,BetBuilderGroupfromsrc/lib/bet-builder.ts;ValueBetsResponsefromsrc/lib/value-bets-utils.ts;OddsComparisonResponsefromsrc/pages/api/event/[id]/odds-comparison.ts;ToolkitLayout,PremiumGate,useUser. -
Produces: default-exported
BetBuilderPagewith.getLayout. -
Step 1: Verify the two upstream endpoints return data for a real match
Confirm a dev server is running (http://localhost:3000). Pick a real upcoming fixture id (from the v2 page network tab or /api/tournament?upcomingFixtures=true). Then:
Run:
curl -s "http://localhost:3000/api/event/<matchId>/odds-comparison?homeTeam=&awayTeam=" | head -c 400Expected: JSON with a markets array. If it returns 401/403, the endpoint is auth-gated — note it and, if so, the page must call it with the user's session cookie (the browser fetch does this automatically; a server-side call would not). This step only confirms shape.
- Step 2: Implement the page
Create src/pages/bet-builder.tsx. Mirror the gating/layout of value-bets-v2.tsx. Use a fixture picker: reuse the same upcoming-fixtures fetch the v2 page uses (/api/tournament?upcomingFixtures=true&days=4 for the list; POST /api/tournament/upcoming-fixtures-multi if v2's picker needs it — copy v2's usage). Keep the picker minimal for the MVP: a searchable <select>/combobox of upcoming fixtures is acceptable; visual polish comes in the frontend-design pass.
import React, { useMemo, useState } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import useSWR from "swr";
import ToolkitLayout from "@/components/toolkit-layout";
import { PremiumGate } from "@/components/premium-gate";
import { useUser } from "@/contexts/user-context";
import {
buildPlayerPropGroups,
buildMarketGroups,
type BetBuilderGroup,
} from "@/lib/bet-builder";
import type { ValueBetsResponse } from "@/lib/value-bets-utils";
import type { OddsComparisonResponse } from "@/pages/api/event/[id]/odds-comparison";
import { MarketGroup } from "@/components/bet-builder/market-group";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
type FixtureLite = {
id: number;
homeTeamName: string;
awayTeamName: string;
startTimestamp: number;
};
function BetBuilderPage() {
const router = useRouter();
const { user, loading: userLoading, isSubscribed, subscriptionLoading } =
useUser();
// Gate: match value-bets-v2 behaviour.
React.useEffect(() => {
if (!userLoading && !user) router.push("/login");
}, [userLoading, user, router]);
const matchId = router.query.matchId
? Number(router.query.matchId)
: null;
const [showEdge, setShowEdge] = useState(false);
// --- Upcoming fixtures for the picker (reuse v2's source) ---
const { data: fixturesData } = useSWR<{ fixtures?: FixtureLite[] } | any>(
"/api/tournament?upcomingFixtures=true&days=4",
fetcher
);
// NOTE: shape the picker list from whatever the endpoint returns; during
// implementation, match the exact field names v2 uses (see value-bets-v2.tsx
// FixturesResponse usage). Fall back to an empty list if absent.
const fixtures: FixtureLite[] = useMemo(() => {
const raw = (fixturesData && (fixturesData.fixtures || fixturesData.data)) || [];
return Array.isArray(raw) ? raw : [];
}, [fixturesData]);
const selectedFixture = fixtures.find((f) => f.id === matchId) || null;
// --- Data for the selected match ---
const { data: v2Data } = useSWR<ValueBetsResponse>(
matchId ? `/api/value-bets-v2?eventIds=${matchId}&showAll=true` : null,
fetcher
);
const { data: oddsData } = useSWR<OddsComparisonResponse>(
matchId && selectedFixture
? `/api/event/${matchId}/odds-comparison?homeTeam=${encodeURIComponent(
selectedFixture.homeTeamName
)}&awayTeam=${encodeURIComponent(selectedFixture.awayTeamName)}`
: matchId
? `/api/event/${matchId}/odds-comparison?homeTeam=&awayTeam=`
: null,
fetcher
);
const groups: BetBuilderGroup[] = useMemo(() => {
const playerGroups = v2Data?.data
? buildPlayerPropGroups(v2Data.data)
: [];
const marketGroups = oddsData?.markets
? buildMarketGroups(oddsData.markets)
: [];
// Market groups (Result, Goals, BTTS…) first, then player-prop groups.
return [...marketGroups, ...playerGroups];
}, [v2Data, oddsData]);
const onPick = (id: number) => {
router.push(
{ pathname: "/bet-builder", query: { matchId: id } },
undefined,
{ shallow: true }
);
};
if (userLoading || subscriptionLoading) {
return (
<div className="flex items-center justify-center pt-32 text-sm text-muted-foreground">
Loading…
</div>
);
}
return (
<>
<Head>
<title>Bet Builder — Fair Odds | StatsHub</title>
<meta
name="description"
content="See the fair odds for any selection in a match — model-derived for player props, margin-removed market price for efficient markets."
/>
</Head>
<PremiumGate isSubscribed={isSubscribed}>
<div className="max-w-3xl mx-auto px-4 pt-6 pb-16">
<div className="mb-6">
<h1 className="font-bebas text-3xl sm:text-4xl tracking-wide">
Bet Builder
</h1>
<p className="text-sm text-muted-foreground mt-1">
Fair odds for any selection — margin removed.
</p>
</div>
{/* Match picker */}
<div className="mb-5 flex flex-wrap items-center gap-3">
<select
className="bg-background border border-border rounded-md px-3 py-2 text-sm"
value={matchId ?? ""}
onChange={(e) => e.target.value && onPick(Number(e.target.value))}
>
<option value="">Pick a match…</option>
{fixtures.map((f) => (
<option key={f.id} value={f.id}>
{f.homeTeamName} v {f.awayTeamName}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm ml-auto select-none">
<input
type="checkbox"
checked={showEdge}
onChange={(e) => setShowEdge(e.target.checked)}
/>
Show market price & edge
</label>
</div>
{/* Market stack */}
{!matchId ? (
<div className="text-sm text-muted-foreground py-16 text-center">
Pick a match to see fair odds.
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground py-16 text-center">
No odds available for this match yet.
</div>
) : (
<div className="space-y-3">
{groups.map((g) => (
<MarketGroup key={`${g.source}:${g.title}`} group={g} showEdge={showEdge} />
))}
</div>
)}
</div>
</PremiumGate>
</>
);
}
export default BetBuilderPage;
BetBuilderPage.getLayout = function getLayout(page: React.ReactElement) {
return <ToolkitLayout>{page}</ToolkitLayout>;
};During implementation, confirm the real shapes: (a)
PremiumGate's props — opensrc/components/premium-gate.tsxand pass what it expects (it may takeisSubscribed,feature, or children only; match v2's usage atvalue-bets-v2.tsx:1549). (b) The upcoming-fixtures response field names — match v2'sFixturesResponseusage so the picker lists real fixtures. (c)useUser()returns{ user, loading, isSubscribed, subscriptionLoading }(confirmed in v2 at line 255).
- Step 3: Type-check the new files
Run: npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "bet-builder|devig" | head
Expected: no errors referencing the new files. Fix any type mismatches (most likely PremiumGate props or fixtures shape) before moving on.
Task 4: Presentational components (market-group.tsx, selection-row.tsx)
Files:
- Create:
src/components/bet-builder/selection-row.tsx - Create:
src/components/bet-builder/market-group.tsx
Interfaces:
-
Consumes:
BetBuilderGroup,BetBuilderSelectionfromsrc/lib/bet-builder.ts;formatOdds,formatEdgefromsrc/lib/value-bets-utils.ts. -
Produces: named exports
SelectionRow,MarketGroup. -
Step 1: Implement
selection-row.tsx
import React from "react";
import type { BetBuilderSelection } from "@/lib/bet-builder";
import { formatOdds, formatEdge } from "@/lib/value-bets-utils";
export function SelectionRow({
selection,
showEdge,
}: {
selection: BetBuilderSelection;
showEdge: boolean;
}) {
const { label, fairOdds, bestPrice, edge } = selection;
const positive = (edge ?? 0) > 0;
return (
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border/60 text-sm">
<span className="truncate pr-3">{label}</span>
<div className="flex items-center gap-4 shrink-0">
<span
className="font-semibold tabular-nums"
title="Fair odds"
>
{fairOdds != null ? formatOdds(fairOdds) : "—"}
</span>
{showEdge && (
<>
<span className="text-muted-foreground tabular-nums w-14 text-right" title="Best market price">
{bestPrice != null ? formatOdds(bestPrice) : "—"}
</span>
<span
className={`tabular-nums w-14 text-right ${
edge == null
? "text-muted-foreground"
: positive
? "text-emerald-500"
: "text-red-500"
}`}
title="Edge vs best price"
>
{edge != null ? formatEdge(edge) : "—"}
</span>
</>
)}
</div>
</div>
);
}- Step 2: Implement
market-group.tsx
import React, { useState } from "react";
import { ChevronDown } from "lucide-react";
import type { BetBuilderGroup } from "@/lib/bet-builder";
import { SelectionRow } from "./selection-row";
export function MarketGroup({
group,
showEdge,
}: {
group: BetBuilderGroup;
showEdge: boolean;
}) {
const [open, setOpen] = useState(true);
return (
<div className="rounded-lg bg-card border border-border overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left"
>
<span className="font-semibold">{group.title}</span>
<div className="flex items-center gap-3">
{showEdge && (
<div className="hidden sm:flex items-center gap-4 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>Fair</span>
<span className="w-14 text-right">Best</span>
<span className="w-14 text-right">Edge</span>
</div>
)}
<ChevronDown
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
</div>
</button>
{open && (
<div>
{group.selections.map((s, i) => (
<SelectionRow key={`${s.label}:${i}`} selection={s} showEdge={showEdge} />
))}
</div>
)}
</div>
);
}- Step 3: Type-check
Run: npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "bet-builder" | head
Expected: no errors referencing the new files. Confirm formatOdds/formatEdge exist in value-bets-utils.ts (they do: lines 208 and 213) and accept a number.
Task 5: Navigation entry (src/components/toolkit-sidebar.tsx)
Files:
-
Modify:
src/components/toolkit-sidebar.tsx(theTOOLS/nav array around lines 35-47) -
Step 1: Add the nav item
Open src/components/toolkit-sidebar.tsx. In the tools array where { label: "Value Bets", href: "/value-bets-v2", icon: <Diamond size={16} />, badge: "pro" } is defined (~line 35), add a Bet Builder entry directly after it, using an icon already imported in that file (e.g. Calculator or Layers from lucide-react — add the import to the existing lucide-react import line if not present):
{ label: "Bet Builder", href: "/bet-builder", icon: <Calculator size={16} />, badge: "pro" },If Calculator is not already imported, add it to the existing import { ... } from "lucide-react"; line at the top of the file.
- Step 2: Type-check
Run: npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "toolkit-sidebar" | head
Expected: no errors.
Task 6: End-to-end verification in the browser
Files: none (verification only).
- Step 1: Run the full unit suite for the new libs
Run: npx vitest run src/lib/__tests__/devig.test.ts src/lib/__tests__/bet-builder.test.ts
Expected: all PASS.
-
Step 2: Drive the page (dev server on
http://localhost:3000, signed-in/subscribed session) -
Navigate to
/bet-builder. -
Confirm the "Bet Builder" item appears in the sidebar and is active.
-
Pick a match from the dropdown → URL becomes
/bet-builder?matchId=<id>. -
Confirm market groups render (Match Result / Total Goals / BTTS, etc.) with a fair-odds column, and player-prop groups (Shots, SOT, Cards…) appear with model fair odds.
-
Toggle "Show market price & edge" → best price + edge columns appear and hide.
-
Screenshot both states and look at them (blank frame = failure). Spot-check one 1X2 market: the three fair odds should imply probabilities summing to ~100% and each fair price should be a little longer than the raw bookmaker price.
-
Step 3: Edge-case checks
-
Load
/bet-builderwith no match → "Pick a match" empty state. -
Pick a match with no odds yet (if available) → "No odds available" empty state, no crash.
Self-Review Notes (spec coverage)
- Placement / route / nav / picker / URL param → Tasks 3, 5.
- Fair-odds engine, two sources → Tasks 1 (devig), 2 (shaping). Player props from v2 response; markets devigged.
- Display toggle (default off) → Task 3 (
showEdgestate) + Task 4 (row rendering). - Devig as swappable helper → Task 1.
- Error/edge cases (no match, no odds, no model output, incomplete market) → Tasks 2 (null fairOdds), 3 (empty states), 6 (checks).
- Testing (devig unit, shaping unit, browser drive) → Tasks 1, 2, 6.
- Gating → Global Constraints + Task 3.
- Out of scope (multi-selection, correlations, fixture-page entry) → not implemented, per spec.
Resolved open items from the spec: (1) No new /api/bet-builder route — the two existing endpoints plus pure client-side shaping libraries cover it with less risk and zero edits to live server code. (2) Gating = premium, matching v2. (3) Fixture picker = reuse v2's upcoming-fixtures source with a minimal dropdown for the MVP (visual polish deferred to the frontend-design pass).