Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
^CRAN\.md$
^LICENSE\.md$
^\.gitignore$
^tutorials$
5 changes: 5 additions & 0 deletions .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ jobs:
http-user-agent: ${{ matrix.config.http-user-agent }}
use-public-rspm: true

- name: Check offline quote-join tutorial
run: |
Rscript tutorials/quote-joins/odds_join.R
Rscript tutorials/quote-joins/test_odds_join.R

- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::rcmdcheck, any::roxygen2
Expand Down
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Authors@R:
person("Jacob", "Galperin", , "jgalperi@ramapo.edu", role = c("aut", "cre"))
Description: Query real-time and historical sports betting odds from the
'ParlayAPI' service <https://parlay-api.com>, which aggregates odds
from more than 30 sportsbooks and carries historical odds from 2005.
from more than 30 sportsbook sources. Coverage and historical
availability depend on the sport, source, market, date and account.
Provides functions for listing sports, fetching game odds and player
props, retrieving historical and closing lines, removing the
bookmaker margin from quoted prices ('devigging') by the
Expand Down
14 changes: 6 additions & 8 deletions R/endpoints.R
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ pa_sports <- function(all = FALSE, sandbox = NULL) {
#'
#' Fetches current odds for a sport. The response uses the same event
#' shape as The Odds API v4 (events containing `bookmakers`, each with
#' `markets`, each with `outcomes`), so parsing code written for that
#' API carries over unchanged.
#' `markets`, each with `outcomes`). Validate the returned R structures,
#' identities and supported filters when migrating an existing client.
#'
#' In sandbox mode the endpoint is keyless and serves synthetic events;
#' sandbox prices are always American regardless of `odds_format`.
Expand Down Expand Up @@ -136,7 +136,7 @@ pa_props <- function(sport,
#' Get a historical odds snapshot
#'
#' Fetches the odds board for a sport as it stood at a past timestamp.
#' Requires an API key; ParlayAPI's odds history reaches back to 2005.
#' Requires an API key. Available dates vary by dataset and account access.
#' There is no sandbox equivalent, so this function errors in sandbox
#' mode.
#'
Expand Down Expand Up @@ -181,11 +181,9 @@ pa_historical <- function(sport,

#' Get historical closing odds
#'
#' Fetches closing lines (the final pre-game price, the sharpest single
#' reference point for model calibration and CLV analysis). ParlayAPI
#' carries more than 30 million player prop closing lines since 2022 on
#' top of game closing lines. Requires an API key; errors in sandbox
#' mode.
#' Fetches historical closing lines for model calibration and CLV analysis.
#' Available books, markets and dates depend on the dataset and account.
#' Requires an API key; errors in sandbox mode.
#'
#' @inheritParams pa_odds
#' @param season Optional season filter, e.g. `"2023"`.
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@
<!-- badges: end -->

R client for [ParlayAPI](https://parlay-api.com), a real-time sports
odds API covering 30+ sportsbooks, with player props, historical odds
from 2005, and 30M+ prop closing lines since 2022. The package mirrors
the core of the official Python SDK (`pip install parlay-api`) and adds
the two local calculations every betting model needs: devigging and
Kelly sizing.
odds API with 30+ sportsbook sources, player props and historical
endpoints. Availability varies by sport, bookmaker, market, date and
account access. The package provides HTTP clients and local calculations
for devigging and Kelly sizing; those calculations depend on their inputs
and do not establish a profitable betting strategy.

Built for the R modeling community: everything returns plain data
frames, the math functions are pure R with no network dependency, and a
sandbox mode lets you run every example, test, and the vignette without
an API key.

## Start with an offline tutorial

[Prevent accidental many-to-many joins in R](tutorials/quote-joins/README.md)
uses synthetic quotes to show why event, period, outcome and handicap line
belong in a comparison key. Run it with base R, no packages or API key.

## Installation

Not on CRAN yet (see [CRAN.md](CRAN.md) for the submission runbook).
Expand Down Expand Up @@ -77,7 +83,7 @@ pa_auth("your-api-key") # or set PARLAY_API_KEY in .Renviron
events <- pa_odds("basketball_nba", markets = "h2h,spreads,totals")
props <- pa_props("baseball_mlb", markets = "player_strikeouts")

# Historical snapshot of the board (history back to 2005)
# Historical snapshot, subject to dataset and account availability
snap <- pa_historical("basketball_nba", date = "2024-10-19T12:00:00Z")

# Closing lines, the calibration target for backtests
Expand Down Expand Up @@ -106,14 +112,11 @@ vignette("line-shopping-and-devigging", package = "parlayapiR")

## The Odds API compatibility

`pa_odds()` returns the same event shape as The Odds API v4 (events
containing `bookmakers`, then `markets`, then `outcomes`) and takes the
same query parameters (`regions`, `markets`, `oddsFormat`,
`bookmakers`). TOA's v4 shape is the de facto standard for odds data
and their documentation is excellent, so parsing code written against
it, including code written for CRAN's `oddsapiR` package, carries over
with minimal changes. If you already have a TOA pipeline in R, the
migration is the base URL and the key.
`pa_odds()` uses nested event, bookmaker, market and outcome objects and
accepts familiar filters such as regions, markets and bookmakers. Check
the actual returned R structures, supported parameters, event identities
and odds formats when migrating an existing client. Similar JSON shapes
do not guarantee unchanged parsing or equivalent coverage.

## Notes for modelers

Expand All @@ -128,6 +131,13 @@ migration is the base URL and the key.
- WebSocket streaming exists on the API (Business tier and up) but is
not wrapped here; this package is deliberately plain HTTP.

## Private data use

Use your own account and API key for personal analysis or internal tools.
The package's code license does not grant rights to publicly display odds,
share a data feed or redistribute API data. Sandbox examples are synthetic;
inspect actual coverage and source timestamps when working with your key.

## License

MIT. R packages are often GPL-licensed by tradition, but MIT is fully
Expand Down
67 changes: 67 additions & 0 deletions tutorials/quote-joins/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Prevent accidental many-to-many joins in R with synthetic sports odds

**Every event, team, bookmaker, and price below is synthetic.** This tutorial runs offline with base R. It needs no API key, package installation, or downloaded dataset.

A join can succeed technically and still create comparisons that make no sense. Imagine two bookmakers quoting the Harbor Owls at both -3.5 and -4.5 points. Joining on the team name produces four combinations. Two pair different handicaps. A later calculation that selects the highest price can then present an attractive number for the wrong selection.

This is a data identity problem before it is a calculation problem. The same mistake appears when joining product variants, laboratory measurements, or repeated survey responses using an incomplete identifier.

## Reproduce the mistake

Save [odds_join.R](odds_join.R) and [test_odds_join.R](test_odds_join.R) together. Run:

```sh
Rscript odds_join.R
Rscript test_odds_join.R
```

Or explore the example interactively:

```r
source("odds_join.R")
q <- synthetic_quotes()

bad <- merge(q$left, q$right, by = "team_name")
bad[c("team_name", "line.x", "line.y")]
```

Both inputs contain two rows for the same display name, so this join produces four rows. Compare `line.x` with `line.y`: two combinations disagree. The row count grew because the key was duplicated on both sides. Nothing in `merge()` knows whether that multiplication was intended.

Do not repair this by taking the first row or calling `unique()` afterward. Neither operation establishes which two selections describe the same thing.

## Define the comparison before joining

For this deliberately narrow point-spread example, the key is:

```r
SPREAD_KEY <- c("event_id", "market", "period", "outcome_id", "line")
```

Each component earns its place. The event distinguishes repeat meetings. The market distinguishes a point spread from a set handicap. The period distinguishes a full match from its first period. The outcome identifies the selected competitor. The signed line distinguishes -3.5 from -4.5, and -3.5 from +3.5.

These identifiers must already refer to the same entities across the two inputs. Source-local event IDs cannot simply be assumed equivalent. Entity resolution is a separate prerequisite, not something a stricter join magically supplies.

Bookmaker identifies the input snapshot rather than the cross-book match key. The helper therefore accepts one book on each side and rejects mixing books within an input.

```r
good <- join_spread_quotes(q$left, q$right)
good[c("line", "decimal_price_left", "decimal_price_right")]
```

The two matching handicaps now produce two rows. The synthetic -3.5 selection pairs 1.91 with 1.95; the -4.5 selection pairs 2.08 with 2.12. Changing a display name does not break the match because display names are not keys.

## Make ambiguity an error

`validate_spread_quotes()` rejects missing identifiers, missing or nonfinite spread lines, invalid decimal prices, and repeated complete keys. Even two duplicate rows with identical prices fail: silently choosing one would conceal an upstream snapshot problem.

This matters because R can match missing join values. Two unknown handicaps are not evidence of the same selection. Reject them before joining. Other market families need their own rules; this helper intentionally rejects anything other than `point_spread`.

There is also a subtle numeric issue: multi-column joins can construct character keys internally. The script encodes finite handicap values as exact hexadecimal floating-point text for matching, then restores the original numeric line. It does not round near-equal lines into equality. Normalize externally only when a documented source contract justifies it.

## Keep the boundary visible

The accompanying tests exercise duplicate keys, opposite signs, nearly equal lines, missing values, different events, different outcomes, and mismatched periods. An inner join omits unmatched selections; an empty result means no exact matches, not that either book has no quotes. Monitor unmatched rows separately when adapting this pattern to a pipeline.

Correct identity establishes comparability only. It does not establish quote availability, recency, execution, or profitability. This example makes no timing claims and carries no real sportsbook data.

Written and reviewed with AI assistance by the ParlayAPI team. For an optional connection to your own account, see the [API documentation](https://parlay-api.com/docs). Private research must use your own credentials and applicable account permissions. This tutorial grants no permission to redistribute API data publicly.
100 changes: 100 additions & 0 deletions tutorials/quote-joins/odds_join.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# All example rows are synthetic. No API key, network, or packages are required.
# Scope: one snapshot per book containing point-spread outcome quotes.
# Exact numeric line equality is intentional: do not round distinct handicaps.
SPREAD_KEY <- c("event_id", "market", "period", "outcome_id", "line")

# Hexadecimal floating-point text preserves exact numeric identity in multi-key
# merge(), which otherwise constructs character keys internally. Normalize -0.
line_identity <- function(line) {
line[line == 0] <- 0
sprintf("%a", as.double(line))
}

validate_spread_quotes <- function(quotes, label = "quotes") {
if (!is.data.frame(quotes)) stop(label, ": expected a data frame", call. = FALSE)
needed <- c(SPREAD_KEY, "book", "decimal_price")
if (anyDuplicated(names(quotes)) || !all(needed %in% names(quotes))) {
stop(label, ": required columns must exist with unique names", call. = FALSE)
}
identities <- c("event_id", "market", "period", "outcome_id", "book")
for (column in identities) {
value <- quotes[[column]]
if (!is.character(value) || anyNA(value) || any(!nzchar(trimws(value))) || any(grepl("[[:cntrl:]]", value))) {
stop(label, ": ", column, " must contain nonempty character identities",
call. = FALSE)
}
}
if (any(quotes$market != "point_spread")) {
stop(label, ": this example only accepts point_spread quotes", call. = FALSE)
}
if (!is.numeric(quotes$line) || anyNA(quotes$line) ||
any(!is.finite(quotes$line))) {
stop(label, ": spread lines must be finite numbers, never missing", call. = FALSE)
}
if (!is.numeric(quotes$decimal_price) || anyNA(quotes$decimal_price) ||
any(!is.finite(quotes$decimal_price)) || any(quotes$decimal_price <= 1)) {
stop(label, ": decimal_price must be finite and greater than one", call. = FALSE)
}
if (length(unique(quotes$book)) > 1L) {
stop(label, ": supply one book per input snapshot", call. = FALSE)
}
identity <- quotes[SPREAD_KEY]
identity$line <- line_identity(identity$line)
if (anyDuplicated(identity)) {
stop(label, ": duplicate quote identity; resolve the snapshot before joining",
call. = FALSE)
}
invisible(quotes)
}

join_spread_quotes <- function(left, right) {
validate_spread_quotes(left, "left")
validate_spread_quotes(right, "right")
if (nrow(left) && nrow(right) && left$book[1L] == right$book[1L]) {
stop("Choose two different books", call. = FALSE)
}
# Select explicit columns so display names can never become implicit join keys.
columns <- c(SPREAD_KEY, "book", "decimal_price")
left_keys <- left[columns]
right_keys <- right[columns]
left_keys$line <- line_identity(left$line)
right_keys$line <- line_identity(right$line)
result <- merge(left_keys, right_keys, by = SPREAD_KEY, all = FALSE,
suffixes = c("_left", "_right"), sort = TRUE)
result$line <- left$line[match(result$line, left_keys$line)]
result
}

synthetic_quotes <- function() {
# Fictional competitors, books, event, and prices. This is not a live dataset.
left <- data.frame(
event_id = rep("synthetic_event_001", 2),
market = rep("point_spread", 2),
period = rep("full_match", 2),
outcome_id = rep("synthetic_team_harbor", 2),
team_name = rep("Harbor Owls", 2),
line = c(-3.5, -4.5),
book = rep("Synthetic Book A", 2),
decimal_price = c(1.91, 2.08),
stringsAsFactors = FALSE
)
right <- left
right$book <- "Synthetic Book B"
right$decimal_price <- c(1.95, 2.12)
list(left = left, right = right)
}

run_demo <- function() {
quotes <- synthetic_quotes()
naive <- merge(quotes$left, quotes$right, by = "team_name")
exact <- join_spread_quotes(quotes$left, quotes$right)
cat("ALL DATA SYNTHETIC\n")
cat("Team-name join:", nrow(naive), "rows;", sum(naive$line.x != naive$line.y),
"pair different handicaps.\n")
cat("Exact identity join:", nrow(exact), "rows.\n")
print(exact, row.names = FALSE)
invisible(exact)
}

# Sourcing defines helpers without printing; Rscript odds_join.R runs the demo.
if (sys.nframe() == 0L) run_demo()
Loading
Loading