# NHL Playoff Odds API — Monte Carlo Simulator & Value Bets (`commodus67/nhl-playoff-odds-monte-carlo`) Actor

Replays every remaining NHL game thousands of times for playoff, division, wild card and Presidents' Trophy probabilities for all 32 teams, then prices them against live Kalshi contracts with fee-adjusted EV and Kelly sizing.

- **URL**: https://apify.com/commodus67/nhl-playoff-odds-monte-carlo.md
- **Developed by:** [ELIO LIBERATORE](https://apify.com/commodus67) (community)
- **Categories:**
- **Stats:** 1 total users, 0 monthly users, 0.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.10 / 1,000 team projections

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## NHL Playoff Odds API — Monte Carlo Simulator & Value Bets

Every remaining game of the NHL season, replayed twenty thousand times, for all
32 clubs. You get a projected points total, the probability each team wins its
division, takes a wild card, qualifies for the playoffs at all, and finishes
first overall — and the same probabilities priced against the live contracts on
Kalshi, so you can see where the model and the market disagree.

No API key. No scraping. No subscription. Standings and schedules come from
ESPN's public feeds; prices come from Kalshi's public exchange API.

**Keywords:** NHL, hockey, playoff odds, Monte Carlo simulation, Stanley Cup,
sports analytics, prediction markets, Kalshi, betting odds, expected value,
Kelly criterion, sports data API.

***

### Why hockey needs its own model

This is not the football or baseball simulator with the team names swapped.
Three things about the NHL break a generic model:

**Standings run on points, not wins.** A win is two points. Losing in overtime
or a shootout is still worth one. Roughly 23% of games go past regulation, so a
season hands out about 3,100 points across 1,344 games rather than a tidy
two-per-game. A simulator that ranks teams by wins misprices every club that
lives in one-goal games. This one awards points the way the league does.

**The playoff field is not "the top eight by record".** Each conference sends
the top three of each of its two divisions — six teams — and then the two best
teams left in the conference regardless of division. A fourth-place team in a
strong division and a third-place team in a weak one are not interchangeable,
and the wild card is exactly where that asymmetry shows up. The simulator
applies the real rule on every one of its twenty thousand seasons.

**Overtime is close to a coin flip.** Three-on-three and the shootout are not
sixty minutes of hockey. A stronger team carries much less of its edge into the
extra period, so the strength gap is damped there rather than applied in full.

**The season is not 82 games any more.** The CBA signed in 2025 moved the league
to 84 games from 2026-27. This Actor never hardcodes a season length: it counts
the games actually on the schedule feed, so it was already right on the first
day of the new format and will stay right through the next change.

### What comes back

One row per team, with these columns among others:

| Column | What it is |
| --- | --- |
| `projectedPoints` | Mean points total across all simulated seasons |
| `fairPlayoffProbability` | Probability of qualifying, under the real 3+2 rule |
| `fairDivisionProbability` | Probability of finishing first in the division |
| `fairWildCardProbability` | Probability of qualifying *as a wild card* |
| `fairTopSeedProbability` | Probability of the best record in the conference |
| `fairPresidentsTrophyProbability` | Probability of the best record in the league |
| `trueTalentWinPct` | Strength estimate after regression, on a .500 scale |
| `marketPlayoffProbability` | The live Kalshi price, de-vigged |
| `playoffEdge` | Model minus market, in probability points |
| `netEV` | Expected value per contract **after Kalshi's fee** |
| `recommendation` | VALUE, WATCH, PASS, NO\_MARKET or NO\_MODEL |
| `suggestedStake` | Quarter-Kelly, clipped by your position and portfolio caps |

The playoff probabilities across all 32 rows always sum to exactly 16, because
exactly sixteen teams qualify. That is a property of the simulation, not a
normalisation applied afterwards, and it is the quickest sanity check you can
run on the output.

### The market side, done properly

**Qualification is not a mutually exclusive race.** Sixteen of thirty-two teams
make it, so the prices across the field sum to about 16, not to 1. De-vigging
that field — dividing every price by the total, the way you would for a division
winner market — would divide every probability by sixteen and manufacture
enormous fake edges across the whole league. This Actor treats qualification as
independent binaries and leaves the mid prices alone, and treats the four
division winner races as exclusive fields with the overround stripped out. The
`fieldSum` in the log tells you which one it applied and whether the field came
back complete.

**Fees are charged where they actually bite.** Kalshi's fee peaks at mid price:
1.75 cents per contract at $0.50 against 0.63 cents at $0.90. So a
one-and-a-half point edge on a coin-flip contract is a *losing* position after
fees, while the same edge on a heavy favourite is comfortably profitable.
`netEV` and `breakEvenProbability` are computed net of that fee, per contract,
with the rounding applied per order rather than per contract.

**Thin markets are not tradable markets.** A positive edge on a contract with no
open interest and a nine-cent spread is not an opportunity, it is a quote. Those
rows come back as WATCH with the reason attached, never as VALUE.

### The pre-season gate — read this in September

Before the first puck drops, this model knows exactly one thing: how each club
finished last season, shrunk 40% towards average. It does not know who was
traded, who got hurt, who signed in July or who changed coach. The market knows
all of it.

So in September the biggest apparent "edges" are not edges. They are the
offseason. Betting them is betting that the summer did not happen.

Until the league has played `minGamesPlayedForValue` games per team (10 by
default), every edge is still reported in full, but no row is allowed to claim
VALUE — they come back as WATCH so you can track how they move. Set it to 0 to
override that, deliberately. Once real games are in the books the current-season
record takes over from the prior, gradually, at the rate set by
`regressionGames`.

### Building your own history

An Apify run's dataset is deleted after a few days. Put a name in
`archiveToNamedDataset` — for example `nhl-playoff-odds-history` — and every run
also appends its rows to a named dataset in your account, which Apify keeps
indefinitely. Schedule the Actor daily and by March you own something you cannot
buy or reconstruct: the full path of how each team's probability moved across
the season, alongside what the market was charging for it on the same day.

### Inputs worth knowing about

| Input | Default | Why you would change it |
| --- | --- | --- |
| `iterations` | 20000 | Plenty for stable numbers. Raise for smoother tails. |
| `season` | current | A season is named by the year it *ends*: 2026-27 is `2027`. |
| `regressionGames` | 25 | Lower to trust the current record more. |
| `pythagoreanWeight` | 0.6 | How much comes from goal differential vs the raw record. |
| `overtimeProbability` | 0.23 | Share of games going past regulation. |
| `overtimeDamping` | 0.5 | 0 makes overtime a coin flip, 1 treats it like regulation. |
| `priorCarryover` | 0.6 | How much of last season survives into the pre-season prior. |
| `minGamesPlayedForValue` | 10 | Games needed before a VALUE call is allowed. |
| `includeDivisionMarkets` | true | Also price the four division winner races. |
| `marketProbabilities` | empty | Supply your own prices and skip Kalshi entirely. |

### Data sources

- **ESPN** — standings and full schedules, public endpoints, no key.
- **Kalshi** — `KXNHLPLAYOFF` for qualification and `KXNHLATLANTIC`,
  `KXNHLMETROPOLITAN`, `KXNHLCENTRAL`, `KXNHLPACIFIC` for the division races.
  Public exchange API, no key, CFTC-regulated.

If Kalshi is unreachable the run still completes: the model columns are filled
and the market columns come back null with an explanatory note, rather than the
run failing.

### What this is not

It is a statistical model and a market comparison, not advice. It does not place
orders, it does not know about injuries, goaltending changes, trades or coaching
changes, and it will be wrong about individual teams. Its value is in being
transparent about *why* it disagrees with a price, and honest about when it has
no business disagreeing at all.

# Actor input Schema

## `iterations` (type: `integer`):

How many full seasons to simulate. More iterations give smoother probabilities and cost a little more time. 20000 is plenty for stable numbers; the run takes a few seconds either way.

## `season` (type: `integer`):

An NHL season is named by the year it ends, so the 2026-27 season is 2027. Leave empty to use the current season. Set a year to project a past season from where it stood, which is useful for backtesting.

## `archiveToNamedDataset` (type: `string`):

Leave empty for a normal run. If you put a name here, every run also appends its results to a named dataset in your account, which Apify keeps indefinitely instead of deleting after a few days. Run it on a schedule and you build your own history of how the probabilities moved across the season, which is something you cannot reconstruct later. Use the same name every time, for example nhl-playoff-odds-history.

## `regressionGames` (type: `integer`):

How strongly a partial season is pulled towards the team's baseline. Think of it as adding this many imaginary average games. Hockey is a high-variance sport over a long season, so the default of 25 is deliberately larger than the NFL setting and smaller than baseball's. Lower it to trust the current record more, raise it to trust it less.

## `pythagoreanWeight` (type: `number`):

How much of the strength estimate comes from goals scored and allowed rather than the raw record. Goal differential predicts future results better than the record itself, which in hockey is badly distorted by shootouts and the loser point, so the default leans on it.

## `homeIceAdvantage` (type: `number`):

Added to the home team's strength in every simulated game. The default of 0.12 corresponds to roughly a 53 percent win rate for the home side between evenly matched teams, in line with recent NHL seasons. Home ice is worth noticeably less in hockey than in basketball or football. Set to 0 to ignore venue.

## `strengthUncertainty` (type: `number`):

How unsure the model is about each team's true talent. Every simulated season draws a slightly different version of each team. Setting it to zero makes the output far too confident.

## `overtimeProbability` (type: `number`):

Chance that any single simulated game is still tied after sixty minutes and goes to overtime or a shootout. Those games award three points instead of two: two to the winner and one to the loser. Around 23 percent of NHL games end this way, which is where the default comes from, and ignoring it would understate every team's points total by about nine.

## `overtimeDamping` (type: `number`):

Three-on-three overtime and the shootout are far closer to a coin flip than sixty minutes of hockey are. This shrinks the strength gap in those games: 0 makes overtime a pure coin flip, 1 treats it exactly like regulation. The default of 0.5 halves the edge a stronger team carries into the extra period.

## `priorCarryover` (type: `number`):

Before the season starts, every team's record is 0-0-0 and a model with no memory would return the same number for all 32 clubs. Last season's goal differential is shrunk towards average by this amount and used as the starting point. 0.6 keeps 60 percent of last season's separation. Set to 0 to start every team dead level.

## `minGamesPlayedForValue` (type: `integer`):

In September this model knows one thing: how each club finished last season. It does not know who was traded, who got hurt or who signed in July, and the market knows all of it. Until the league has played this many games per team, edges are still reported in full but no row is allowed to claim VALUE. Set to 0 to override that, deliberately.

## `includeDivisionMarkets` (type: `boolean`):

Adds Kalshi's Atlantic, Metropolitan, Central and Pacific division winner races alongside the qualification market. These are mutually exclusive races, so the overround is stripped out before the comparison. They are usually much thinner than the qualification market, so they are reported as a probability and an edge and never sized.

## `edgeThreshold` (type: `number`):

A team is flagged when the model probability beats the de-vigged market probability by at least this much. 0.05 means five percentage points.

## `bankroll` (type: `integer`):

Your total capital, in dollars. It only scales the suggestedStake column. The suggestedFractionOfBankroll column is independent of this number, so it stays meaningful whatever capital you run.

## `maxPerPositionPct` (type: `number`):

Hard ceiling on any single position, as a fraction of bankroll. 0.02 means no position ever exceeds 2% of your capital. Quarter-Kelly sizing is clipped to this ceiling, and the kellyFractionUncapped column shows what the model wanted before the clip.

## `maxTotalExposurePct` (type: `number`):

Ceiling on the sum of all open positions, as a fraction of bankroll. This divided by the per-position ceiling is how many positions actually fit: at the defaults, 6% over 2% means three. Qualifying rows beyond that keep their verdict but come back trimmed, with cappedByPortfolioLimit set to true.

## `marketProbabilities` (type: `array`):

Supply your own prices instead of reading Kalshi. Each entry needs a team name, a market of either playoff or division, and a probability between 0 and 1. Example: \[{ "team": "Colorado Avalanche", "market": "playoff", "impliedProbability": 0.9 }]. When this is filled in, no Kalshi call is made, no de-vig is applied and no stake is sized.

## Actor input object example

```json
{
  "iterations": 20000,
  "regressionGames": 25,
  "pythagoreanWeight": 0.6,
  "homeIceAdvantage": 0.12,
  "strengthUncertainty": 0.1,
  "overtimeProbability": 0.23,
  "overtimeDamping": 0.5,
  "priorCarryover": 0.6,
  "minGamesPlayedForValue": 10,
  "includeDivisionMarkets": true,
  "edgeThreshold": 0.05,
  "bankroll": 1000,
  "maxPerPositionPct": 0.02,
  "maxTotalExposurePct": 0.06,
  "marketProbabilities": []
}
```

# Actor output Schema

## `teamProjections` (type: `string`):

All 32 team rows: qualification, division, wild card and Presidents' Trophy probabilities, projected points and record, and the market comparison.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "iterations": 20000,
    "regressionGames": 25,
    "pythagoreanWeight": 0.6,
    "homeIceAdvantage": 0.12,
    "strengthUncertainty": 0.1,
    "overtimeProbability": 0.23,
    "overtimeDamping": 0.5,
    "priorCarryover": 0.6,
    "minGamesPlayedForValue": 10,
    "edgeThreshold": 0.05,
    "bankroll": 1000,
    "maxPerPositionPct": 0.02,
    "maxTotalExposurePct": 0.06,
    "marketProbabilities": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("commodus67/nhl-playoff-odds-monte-carlo").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "iterations": 20000,
    "regressionGames": 25,
    "pythagoreanWeight": 0.6,
    "homeIceAdvantage": 0.12,
    "strengthUncertainty": 0.1,
    "overtimeProbability": 0.23,
    "overtimeDamping": 0.5,
    "priorCarryover": 0.6,
    "minGamesPlayedForValue": 10,
    "edgeThreshold": 0.05,
    "bankroll": 1000,
    "maxPerPositionPct": 0.02,
    "maxTotalExposurePct": 0.06,
    "marketProbabilities": [],
}

# Run the Actor and wait for it to finish
run = client.actor("commodus67/nhl-playoff-odds-monte-carlo").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "iterations": 20000,
  "regressionGames": 25,
  "pythagoreanWeight": 0.6,
  "homeIceAdvantage": 0.12,
  "strengthUncertainty": 0.1,
  "overtimeProbability": 0.23,
  "overtimeDamping": 0.5,
  "priorCarryover": 0.6,
  "minGamesPlayedForValue": 10,
  "edgeThreshold": 0.05,
  "bankroll": 1000,
  "maxPerPositionPct": 0.02,
  "maxTotalExposurePct": 0.06,
  "marketProbabilities": []
}' |
apify call commodus67/nhl-playoff-odds-monte-carlo --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,commodus67/nhl-playoff-odds-monte-carlo"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/fYYelelE0UllNy0a5/builds/hJHWEl1ZgDWECJcxS/openapi.json
