StatsHub Docs
Plans

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> (match src/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> (no test script exists; there is no vitest config — default resolution works, as in src/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 guard NaN/<= 1.
  • Reuse existing helpers from src/lib/value-bets-utils.ts: MARKET_TYPE_LABELS, formatOdds, calcEdge, and types ValueBetGroup / ValueBetsResponse. Reuse types Market / ProcessedOutcome / OddsComparisonResponse from src/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 into BetBuilderGroup[] (+ 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, or null if 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, or null.
  • 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: devigProportional from src/lib/devig.ts; ValueBetGroup from src/lib/value-bets-utils.ts; Market, ProcessedOutcome from src/pages/api/event/[id]/odds-comparison.ts; MARKET_TYPE_LABELS, calcEdge from src/lib/value-bets-utils.ts.

  • Produces:

    • Types BetBuilderSelection and BetBuilderGroup:
      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 by buildMarketGroups).
  • 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 against src/lib/value-bets-utils.ts:218 during 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, eventIds fetch, fixture picker usage), src/components/odds-comparison.tsx:811 (odds-comparison call signature).

Interfaces:

  • Consumes: buildPlayerPropGroups, buildMarketGroups, BetBuilderGroup from src/lib/bet-builder.ts; ValueBetsResponse from src/lib/value-bets-utils.ts; OddsComparisonResponse from src/pages/api/event/[id]/odds-comparison.ts; ToolkitLayout, PremiumGate, useUser.

  • Produces: default-exported BetBuilderPage with .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 400

Expected: 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 &amp; 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 — open src/components/premium-gate.tsx and pass what it expects (it may take isSubscribed, feature, or children only; match v2's usage at value-bets-v2.tsx:1549). (b) The upcoming-fixtures response field names — match v2's FixturesResponse usage 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, BetBuilderSelection from src/lib/bet-builder.ts; formatOdds, formatEdge from src/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 (the TOOLS/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-builder with 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 (showEdge state) + 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).

On this page