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.
"+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.
| Step | What we do |
|---|---|
| 1 | Stream all books for the sport over WebSocket |
| 2 | Cache each (event, market, outcome) per book |
| 3 | On each sharp-book update, no-vig the pair (Over + Under) → fair probability |
| 4 | Compare every soft-book price to the fair probability |
| 5 | If edge ≥ threshold and the line is fresh, fire alert |
| 6 | Dedupe so you only fire once per (event, market, line, side, book) |
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.
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
};
}
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);
+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%
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:
last_update per row. The stream pushes this on every row. If now - last_update > 90s on a soft book, skip — the line is stale, the book isn't quoting actively right now.| EV threshold | Hit rate | What you'll see |
|---|---|---|
| ≥ 1.0% | ~300–600 alerts/day | Noisy. 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/day | Sweet spot for most volume operators. High enough to survive the time it takes to place. Discord-friendly cadence. |
| ≥ 5.0% | ~15–30 alerts/day | Rare, usually requires either a lineup-news edge or a book that's badly mispricing. Bigger edge, bigger limit risk (sharp books will limit you). |
line match exactly. A Pinnacle Over 28.5 isn't comparable to a DraftKings Over 27.5. The lines differ; the no-vig math is invalid across them.push_mode in the connected envelope. If your tier is coalesced (Business 1 s, Enterprise 0.5 s), realize your timing window is the coalesce interval. Scale tier raw is the only path to ~ 300 ms reaction.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.
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.