+EV Edge Alerts over WebSocket

A complete, opinionated pattern for building a Pinnacle-anchored edge-alert system on top of the WebSocket stream. Covers no-vig math, soft-book comparison, deduping, stale-line guards, and Discord webhook delivery. The same pattern powers internal alerting for several Business and Scale tier customers.

Reference Quickstart Examples Player props Edge alerts Troubleshooting

What "+EV" means in this guide

"+EV" is shorthand for positive expected value — a bet where the true win probability is higher than the price implies. The standard pattern: pick a sharp book as the source of truth, remove the vig to get the implied fair probability, then compare every soft-book price to that fair line. When a soft book offers a number better than fair, that's the edge.

StepWhat we do
1Stream all books for the sport over WebSocket
2Cache each (event, market, outcome) per book
3On each sharp-book update, no-vig the pair (Over + Under) → fair probability
4Compare every soft-book price to the fair probability
5If edge ≥ threshold and the line is fresh, fire alert
6Dedupe so you only fire once per (event, market, line, side, book)

Sharp-book fallback chain

Pinnacle is the canonical sharp on game lines. For player props (where Pinnacle's coverage is thinner), Novig and Circa fill the gap. Walk in this order, take the first book with both Over and Under quoted:

const SHARP_ORDER = ["pinnacle", "novig", "circa", "fanduel", "draftkings"];

This is the same order our server-side /v1/clv endpoint uses. If neither Pinnacle nor Novig has both sides, we don't fabricate a fair line — see our no-synthetic-data policy. Drop the candidate.

No-vig math

The bookmaker's vig means the two-side implied probabilities sum to > 100%. To get the true implied probability, divide each side's implied by the sum:

function americanToDecimal(american) {
  return american > 0 ? american / 100 + 1 : 100 / -american + 1;
}

function impliedProb(decimal) {
  return 1 / decimal;
}

function noVigPair(decimalOver, decimalUnder) {
  const pOver  = impliedProb(decimalOver);
  const pUnder = impliedProb(decimalUnder);
  const total  = pOver + pUnder;
  return {
    fair_p_over:  pOver  / total,
    fair_p_under: pUnder / total,
    vig:          total - 1,  // e.g. 0.045 = 4.5% vig
  };
}

Full producer

Node + the ws library. Handles connect, sharp/soft cache, edge detection, Discord webhook delivery, and dedupe.

// edge_alerter.mjs   ·  npm install ws node-fetch
import WebSocket from "ws";

const KEY            = process.env.PARLAY_API_KEY;
const DISCORD        = process.env.DISCORD_WEBHOOK;
const SPORTS         = ["basketball_nba", "baseball_mlb", "icehockey_nhl"];
const EV_THRESHOLD   = 0.025;        // 2.5% edge
const MAX_LINE_AGE_S = 90;            // sharp line must be ≤ 90s old
const ALERT_TTL_S    = 600;           // re-alert if 10 min has passed
const SHARP_ORDER    = ["pinnacle", "novig", "circa", "fanduel", "draftkings"];
const SHARP_SET      = new Set(SHARP_ORDER);

// Caches keyed by (event_id|market_key|line|side)
const sharpCache  = new Map();   // → { book, dec, ts, opp_dec }
const softCache   = new Map();   // → { book → { dec, ts } }
const alertedAt   = new Map();   // → unix_s of last alert (for dedupe)

function decFromAmerican(a) { return a > 0 ? a/100 + 1 : 100/-a + 1; }
function key(r) {
  const side = (r.outcome || "").endsWith("Over") ? "O"
            : (r.outcome || "").endsWith("Under") ? "U"
            : r.outcome;
  return `${r.event_id}|${r.market_key}|${r.line ?? "-"}|${side}`;
}
function oppKey(k) {
  return k.endsWith("|O") ? k.slice(0,-1) + "U"
       : k.endsWith("|U") ? k.slice(0,-1) + "O" : null;
}

function onRow(row) {
  if (row.price_american == null) return;
  const dec = row.price_decimal || decFromAmerican(row.price_american);
  const k   = key(row);
  const now = Date.now() / 1000;
  const ts  = Date.parse(row.last_update) / 1000 || now;

  if (SHARP_SET.has(row.bookmaker)) {
    // Only the first SHARP_ORDER hit wins for this (event,market,line,side)
    const prev = sharpCache.get(k);
    const prevRank = prev ? SHARP_ORDER.indexOf(prev.book) : Infinity;
    const newRank  = SHARP_ORDER.indexOf(row.bookmaker);
    if (newRank <= prevRank) {
      sharpCache.set(k, { book: row.bookmaker, dec, ts });
    }
  } else {
    const bucket = softCache.get(k) || {};
    bucket[row.bookmaker] = { dec, ts };
    softCache.set(k, bucket);
  }

  // Try to evaluate edge on whichever side just landed
  checkEdge(k, now);
}

function checkEdge(k, now) {
  const sharp = sharpCache.get(k);
  if (!sharp || now - sharp.ts > MAX_LINE_AGE_S) return;

  const opp = sharpCache.get(oppKey(k));
  if (!opp || now - opp.ts > MAX_LINE_AGE_S) return;  // need both sides for no-vig

  const pSide  = 1 / sharp.dec;
  const pOpp   = 1 / opp.dec;
  const total  = pSide + pOpp;
  const pFair  = pSide / total;          // no-vig fair probability for THIS side

  const softs = softCache.get(k) || {};
  for (const [book, info] of Object.entries(softs)) {
    if (now - info.ts > MAX_LINE_AGE_S) continue;
    const pSoft = 1 / info.dec;
    const edge  = pFair - pSoft;          // soft > fair → +EV
    if (edge < EV_THRESHOLD) continue;

    const dedupeKey = `${k}|${book}`;
    if ((alertedAt.get(dedupeKey) || 0) > now - ALERT_TTL_S) continue;
    alertedAt.set(dedupeKey, now);

    fireAlert({ k, book, info, sharp, edge, pFair });
  }
}

async function fireAlert({ k, book, info, sharp, edge, pFair }) {
  const american = info.dec >= 2.0
      ? `+${Math.round((info.dec - 1) * 100)}`
      : `-${Math.round(100 / (info.dec - 1))}`;
  const line = (
    `**+EV ${(edge*100).toFixed(2)}%**   ${k}\n` +
    `  ${book.padEnd(12)} @ ${info.dec.toFixed(3)} (${american})\n` +
    `  sharp ${sharp.book} @ ${sharp.dec.toFixed(3)} → fair ${(pFair*100).toFixed(1)}%`
  );
  console.log(line);
  if (DISCORD) {
    await fetch(DISCORD, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: "```\n" + line + "\n```" }),
    });
  }
}

function connect(sport) {
  const url = `wss://parlay-api.com/v1/ws/odds/${sport}`;
  const ws  = new WebSocket(url, { headers: { "X-API-Key": KEY } });
  ws.on("message", (buf) => {
    const f = JSON.parse(buf.toString());
    if (f.type === "initial_state" || f.type === "odds_update") {
      for (const row of f.data) onRow(row);
    }
  });
  ws.on("close", (code) => {
    if ([1008, 4001, 4003].includes(code)) {
      console.error(`fatal close ${code} on ${sport}`); return;
    }
    setTimeout(() => connect(sport), 2000);
  });
  ws.on("error", (e) => console.error("ws err:", sport, e.message));
}

for (const s of SPORTS) connect(s);

Sample alert output

+EV 3.21%   2026-05-13_BOS_NYK|player_points|28.5|O
  fanatics      @ 2.080 (+108)
  sharp pinnacle @ 1.952 → fair 49.4%

+EV 2.87%   2026-05-13_LAD_SD|h2h|-|H
  betmgm        @ 2.150 (+115)
  sharp pinnacle @ 2.020 → fair 48.5%

Stale-line guard (why it matters)

The single most common false-positive in +EV alerting is a stale soft-book line. The soft book hasn't moved with the market, so it looks +EV, but by the time you place the bet, the book has updated or limited the line. Two guards we use:

Tuning the threshold

EV thresholdHit rateWhat you'll see
≥ 1.0%~300–600 alerts/dayNoisy. Many alerts get washed by the time you bet. Good for a side-by-side dashboard, bad for push notifications.
≥ 2.5%~80–150 alerts/daySweet spot for most volume operators. High enough to survive the time it takes to place. Discord-friendly cadence.
≥ 5.0%~15–30 alerts/dayRare, usually requires either a lineup-news edge or a book that's badly mispricing. Bigger edge, bigger limit risk (sharp books will limit you).

Best practices we've learned

Use /v1/clv to validate after

If you place a bet from an alert, log the price and call POST /v1/clv after the line closes. It returns the closing-line value as a percentage, which is the industry-standard metric for +EV validation. Over a 500-bet sample, CLV should average > 0; if it doesn't, your alerting logic is leaking edge somewhere.

Alternative — let us do the math

If you'd rather not roll your own, POST /v1/clv takes a list of bets and returns no-vig fair price + edge + CLV per row. You can also call GET /v1/sports/{sport_key}/ev for a snapshot of every current +EV opportunity computed with the same sharp-fallback chain and no-vig math used above. Tradeoff: REST snapshots are 1–2 s old; WebSocket-driven alerts are 300–800 ms. Either works; choose based on how aggressive your edge needs to be.