Metadata-Version: 2.4
Name: puntersedge
Version: 0.3.2
Summary: Australian odds API client — horse, greyhound and harness racing plus AFL, NRL and other sports odds from 14 Australian bookmakers, with best-price comparison, results and form.
Author-email: PuntersEdge <support@puntersedge.online>
License: MIT
Project-URL: Homepage, https://puntersedge.online/api?utm_source=python_sdk&utm_medium=pypi
Project-URL: Documentation, https://puntersedge.online/developers?utm_source=python_sdk&utm_medium=pypi
Project-URL: API Pricing, https://puntersedge.online/api/pricing?utm_source=python_sdk&utm_medium=pypi
Project-URL: Repository, https://github.com/Propertyscout001/puntersedge-python
Project-URL: Changelog, https://github.com/Propertyscout001/puntersedge-python/blob/main/CHANGELOG.md
Project-URL: Issue Tracker, https://github.com/Propertyscout001/puntersedge-python/issues
Project-URL: Runnable examples, https://github.com/Propertyscout001/puntersedge-examples
Project-URL: Live coverage report, https://puntersedge.online/api/coverage?utm_source=python_sdk&utm_medium=pypi
Project-URL: TypeScript client, https://www.npmjs.com/package/puntersedge
Project-URL: MCP server, https://pypi.org/project/puntersedge-mcp/
Project-URL: Get an API key, https://puntersedge.online/api?utm_source=python_sdk&utm_medium=pypi#signup
Keywords: australian odds api,horse racing api,greyhound racing api,harness racing api,racing data api,afl odds api,nrl odds api,odds comparison api,bookmaker odds comparison,australian bookmaker odds,sportsbet odds,tab odds,ladbrokes odds,neds odds,new zealand racing odds,closing line data,sports odds api,betting data api
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25
Dynamic: license-file

# PuntersEdge — Australian odds API (Python client)

Official Python client for the [PuntersEdge odds API](https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme) — Australian and New Zealand **racing** odds (AU thoroughbred, greyhound and harness; NZ thoroughbred and harness) and Australian **sports** odds, priced per bookmaker, with best-price comparison, settled results, form and a permanent market-movement archive.

```bash
pip install puntersedge
```

Free key: 3,000 credits/month, no credit card — [puntersedge.online/api](https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme#signup). Or call the `/v1/demo/*` endpoints with no key at all.

[![PyPI](https://img.shields.io/pypi/v/puntersedge.svg)](https://pypi.org/project/puntersedge/)
[![Python](https://img.shields.io/pypi/pyversions/puntersedge.svg)](https://pypi.org/project/puntersedge/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

---

## What is actually covered

Numbers here are not marketing copy — they are recomputed every 30 minutes and published at
**[puntersedge.online/coverage-report.json](https://puntersedge.online/coverage-report.json)**. Read that
rather than trusting this paragraph; the snapshot below was measured **14 Sep 2026 23:06 UTC**.

- **14 Australian bookmakers on racing**: `tab`, `tabtouch`, `betdeluxe`, `betr_au`, `pointsbetau`,
  `betright`, `playup`, `palmerbet`, `unibet`, `neds`, `ladbrokes_au`, `sportsbet`, `betgold`, `boostbet`.
  Median books quoting an AU race: **14** (mean 13.18) across 1,539 races in the 7-day window.
- **Racing**: AU thoroughbred, harness and greyhound; NZ thoroughbred and harness. There are **no NZ
  greyhounds**. Races in other countries appear only because an AU book lists the meeting — the median
  there is one book, which is not coverage, and you should not treat it as such.
- **Sports**: `afl`, `aflw`, `nrl`, `nrlw`, `nba`, `wnba`, `nfl`, `ncaaf`, `mlb`, `nhl`, `mma`,
  `tennis_atp`, `tennis_wta`, `cricket_test`, `cricket_other`, `rugby_union`, `super_league`,
  `soccer_epl`, `soccer_other`, `basketball_other`. **Sports depth is materially thinner than racing** —
  fewer books quote a fixture than quote a race. Check before you build on it.

### What is not here

- **Betfair Exchange and Pinnacle are excluded.** Betfair is ingested for internal reference only and
  withheld from every customer response pending a Betfair data licence; Pinnacle is a non-Australian
  reference book and is not part of the AU comparison set. `arb_racing()` returns 410 for the same
  reason. Any page claiming this client returns exchange prices is wrong.
- **There is no `horse-racing` sport key.** Racing and sports are separate endpoint families because a
  race has runners, barriers and a jump time and a fixture has two teams and a market.
- **`/v1/racing/next-to-go` is not AU-only.** `racing_best_odds(country="AU")` filters;
  `racing_next_to_go()` does not take `country` yet, so it returns everything an AU book happens to
  list, including overseas meetings quoted by a single book.
- **This client wraps 21 of the API's 60 paths.** Settled results, form, market movers, the
  closing-line archive, price history, webhooks and the keyless `/v1/demo/*` endpoints are on the API
  but not yet on this client — call them directly, or use the TypeScript client, which covers them.
  `racing/closing-lines` is plan-gated and returns 403 on the free tier either way.

Bookmaker names above are the identifiers this API returns for publicly posted prices. PuntersEdge is
not affiliated with, endorsed by, or an agent of any bookmaker.

Full docs: **[puntersedge.online/developers](https://puntersedge.online/developers?utm_source=python_sdk&utm_medium=readme)** · Pricing: **[puntersedge.online/api/pricing](https://puntersedge.online/api/pricing?utm_source=python_sdk&utm_medium=readme)**

## Also available

- **TypeScript / JavaScript** — `npm install puntersedge` ([npmjs.com/package/puntersedge](https://www.npmjs.com/package/puntersedge)), same endpoints, same design.
- **MCP server** — `pip install puntersedge-mcp` ([PyPI](https://pypi.org/project/puntersedge-mcp/)), for Claude, Cursor and other agent hosts.


## Quickstart

```python
from puntersedge import PuntersEdge

pe = PuntersEdge()  # https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme#signup

# List active sports
for sport in pe.sports():
    print(sport["key"], "-", sport["title"])

# Head-to-head odds for the NRL
nrl = pe.odds("nrl", markets="h2h")

# Best available price per selection across all books
for event in pe.best_odds("nrl"):
    for sel in event["selections"]:
        print(sel["name"], sel["best_price"], "@", sel["best_bookmaker"])

# Next races to jump (horse + greyhound)
races = pe.racing_next_to_go(categories="horse,greyhound")
```

## Arbitrage scanner in 5 lines

```python
from puntersedge import PuntersEdge

pe = PuntersEdge()
for arb in pe.arb_sports(min_profit_pct=0):
    if arb.get("is_arb"):
        print(f"{arb['home_team']} v {arb['away_team']}: {arb['arb_pct']}%")
        for leg in arb["optimal_stakes"]:
            print(f"   stake ${leg['stake']} on {leg['name']} @ {leg['bookmaker']}")
```

Racing, book-vs-book across the whole field (no exchange needed):

```python
races = pe.racing_best_odds(categories="horse", num_races=5)
for r in races:
    if float(r["market_percentage"]) < 100:      # the field is beatable across books
        print(r["venue"], "R" + r["race_number"], r["market_percentage"])
```

`market_percentage` is the server's own overround over exactly the live runners, with
scratchings already excluded. Under 100 means the best prices across books beat the field.

The scanner reads the same endpoint, so racing is one flag:

```bash
puntersedge-arb scan --racing --no-sports --categories horse,greyhound
```

⛔ There is no racing back/lay support and there will not be while the exchange side is
withheld: `/v1/arb/racing` and `/v1/racing/exchange` return HTTP 410 to every customer key.
A scanner built on those could only ever return nothing.

⚠️ Book-vs-book racing arbs are **rarer than sports**. Measured over 25 consecutive live
races, the tightest market was 118.31% — a scan returning nothing is the normal result, not
a fault.

## Endpoints covered

| Method | What it returns |
| --- | --- |
| `sports()` | Active sports + keys |
| `odds(sport_key, markets=...)` | Bookmaker odds for a sport |
| `best_odds(sport_key)` | Best price per selection + arb flags |
| `racing_next_to_go(categories=...)` | Next races, runners + prices |
| `racing_events(hours_ahead=...)` | Upcoming race list |
| `arb_sports(min_profit_pct=...)` | Surebets + best-odds overlays |
| `racing_best_odds(categories=...)` | Best win/place/tote per runner across the AU books |
| `arb_racing(...)` | **Withheld** — 410 pending a Betfair data licence |
| `arb_lines(sport_key=...)` | Spreads/totals line arbs |
| `arb_best_prices(sport_key=...)` | Best + worst + average per book |
| `value_promos(book=...)` | EV-ranked promo board |
| `usage()` | Credits used / remaining |
| `data_quality_summary()` | Connector freshness + audit status |
| `health()` / `uptime()` | Service health |

## Error handling

```python
from puntersedge import PuntersEdge, RateLimitError, AuthenticationError

pe = PuntersEdge()
try:
    pe.best_odds("nrl")
except AuthenticationError:
    print("Bad or missing API key")
except RateLimitError:
    print("Monthly credit cap or rate limit hit")
```

5xx responses are retried automatically (configurable via `retries=`).

## Configuration

You never have to put your key in your code. `PuntersEdge()` finds it for you, checking
each source **per setting** — so a `base_url` in your file still applies when your key comes
from the environment:

| | source |
|---|---|
| 1 | the `api_key=` argument |
| 2 | `$PUNTERSEDGE_API_KEY` |
| 3 | `$PUNTERSEDGE_CONFIG_FILE`, if set — then that file only |
| 4 | `~/.config/puntersedge/config` (`%APPDATA%\puntersedge\config` on Windows) |

Create the file once:

```bash
mkdir -p ~/.config/puntersedge
printf '[puntersedge]\napi_key = 3f8b1c04-5e7a-4d21-9b6e-0a2c8d5f1e93\n' > ~/.config/puntersedge/config
chmod 600 ~/.config/puntersedge/config
```

```ini
[puntersedge]
api_key  = 3f8b1c04-5e7a-4d21-9b6e-0a2c8d5f1e93
base_url = https://api.puntersedge.online/v1
timeout  = 15
retries  = 2

[arb]
bettable_books  = sportsbet, tab, neds
min_edge_pct    = 0.5
max_quote_age_s = 120
```

```python
pe = PuntersEdge()                      # reads [puntersedge]
cfg = GateConfig.load()                 # reads [arb] — never sees your key
print(pe.key_source)                    # "$PUNTERSEDGE_API_KEY" — the source, never the key
```

If no key is found, the error names every source it tried and what each one reported, so
"wrong file", "empty variable" and "wrong section" do not all look like a 401.

Explicit arguments still work — `PuntersEdge(api_key="...", timeout=30)` — and override
everything else.

**A note on the config file.** It is checked before it is read: refused outright if another
user owns it or can write to it, and warned about if others can read it (`chmod 600` fixes
that). It holds your PuntersEdge API key and, if you use alerts, one webhook URL — which is
also a credential, since anyone holding it can post to your channel. It has exactly three
sections, and any other section is a startup error.

## Scanning and stake sizing

```python
from puntersedge import PuntersEdge
from puntersedge.arb import GateConfig, Scanner, size

scanner = Scanner(
    PuntersEdge(),
    GateConfig(bettable_books={"sportsbet", "tab", "neds"}),
    sports=["afl", "nrl"],
    credit_budget=500,
)

result = scanner.poll()
print(result.summary())
# 41 candidates | 1 passed | 5 credits | refused: no_cross=28, server_not_arb=9, stale_quote=3

for opp in result.arbs:
    plan = size(opp, total=200, minimums={"sportsbet": 5.0})
    if plan:
        for leg in plan.legs:
            print(f"  {leg.book:12} {leg.selection:24} ${leg.stake:7.2f} @ {leg.odds}")
        print(f"  guaranteed ${plan.profit:.2f} ({plan.profit_pct:.2f}%)")
    else:
        print(" ", plan.reason)
```

**`plan.profit` is the worst case after rounding** — what you are actually guaranteed, not
the textbook figure. That distinction is the whole point of the module. On thin arbs, rounding
an equal-profit split to whole dollars can push the worst outcome to zero or below, and at that
point the position is no longer locked: it is a directional bet you did not choose.

Be precise about what that does and does not mean, because an earlier version of this README
was not. It is **not** a guaranteed loss, and cannot be — for any genuine arb the branch profits
weighted by `1/oᵢ` sum to `T(1−S) > 0`, so at least one branch always pays. Rounding destroys
the guarantee, not the money.

How often it bites depends on your **stake cap**, because the whole effect is the size of the
rounding step relative to the edge. On 3,000 generated 2- and 3-leg arbs with `inv_sum` between
0.94 and 0.999, at whole-dollar stakes, naive per-leg rounding leaves a non-positive worst case
in:

| cap | $10 | $20 | $40 | $60 | $100 | $200 |
|---|---|---|---|---|---|---|
| rate | 84.8% | 55.4% | 31.3% | 18.6% | 11.1% | 4.5% |

*(This README quoted a flat **6.9%** until 2026-08-21. That figure is real — it corresponds to a
cap of about $150 — but quoting it without the cap made a property of your stake size look like
a property of arbitrage. The companion "worse than optimal in 56%" does not reproduce at any cap;
the same sweep gives 68–91%. And the word it used was "guaranteed loss", which is the part that
was actually wrong.)*

`size()` searches for the plan that maximises your worst case and refuses outright when nothing
clears zero. `tests/test_sizing.py` checks it against exhaustive brute force over the whole
lattice for both two- and three-leg markets. For two legs it is also exact by construction: at a
fixed total one branch rises in the anchor stake while the other falls, so the max-min is
unimodal and the optimum is at floor or ceil of the crossing. `total` is a **cap**: it will
stake less than you offered if that pays better, and never more.

**When a poll finds nothing, ask it why.** `result.diagnosis()` distinguishes an efficient
market from a broken scanner — a filter matching no sports, enrichment failing, or every
candidate priced at books you don't hold. A count on its own cannot.

### Credits — read this before you set an interval

A poll costs **3 credits per sport in your list**, plus 1 more per sport that needs a freshness
check — price ages are not in the arb response, so they have to be fetched separately. A
two-sport scan is `3×2 + 1 = 7` credits. **The bill scales with the length of `sports=`, not
just with your interval**, which is the part that surprises people.

| interval | 1 sport (4/poll) | 2 sports (7/poll) | 3 sports (10/poll) |
|---|---|---|---|
| 60s | 172,800 | 302,400 | 432,000 |
| 5 min | 34,560 | 60,480 | 86,400 |
| 15 min | 11,520 | 20,160 | 28,800 |
| 1 hour | **2,880 — fits** | 5,040 | 7,200 |
| 3 hours | **960 — fits** | **1,680 — fits** | **2,400 — fits** |

Against the free tier's 3,000 credits/month, **four cells fit**: a single sport polled hourly, or
one, two or three sports polled three-hourly. Everything else is over — a 15-minute two-sport loop by nearly 7×.

*(Until 2026-08-21 this table assumed a flat 3 credits per poll and understated every figure,
by roughly 2× at three sports. `estimate_poll_cost()` was right the whole time; the table was
not.)*

**The free tier cannot run a live scanner.** That is arithmetic, not a limitation I chose.
A useful sports scanner wants Standard or above. `scanner.budget_advice(interval)` prints the
number for your configuration, and `credit_budget=` makes the scanner refuse to exceed a cap
rather than silently draining your month.

Two things keep the bill down automatically: gates that need only the arb payload run
*first*, so only sports with surviving candidates get paid for; and there is no point
polling faster than 15 minutes, because that is how often the upstream sports feed refreshes
— a faster loop buys nothing but spend.

## Command line

```bash
pip install puntersedge

puntersedge-arb config                          # where your key comes from (never the key)
puntersedge-arb scan --sports afl,nrl --stake 200
puntersedge-arb scan --racing --no-sports --categories horse   # racing only, 3 credits
puntersedge-arb scan --watch 900 --budget 5000 --record
puntersedge-arb ledger pnl
```

`scan` prints its credit cost before spending anything, and refuses a `--watch` interval
faster than the 900s upstream refresh unless you pass `--yes` — a faster loop cannot surface
anything new, it only spends. Exit codes are distinct (`2` config, `3` credit budget), and
finding no arbs exits `0`, because an efficient market is not an error.

## The ledger

`--record` writes each sized arb to an append-only JSONL log. It keeps three things apart
that are easy to conflate and expensive to confuse:

```bash
puntersedge-arb ledger place  <bet_id> sportsbet "Lions" 50 2.10   # a leg you got on
puntersedge-arb ledger settle <bet_id> sportsbet 105               # what it returned
puntersedge-arb ledger pnl
```

```
arbitrage (all legs placed) : +12.40 over 8 positions, 1600.00 staked (0.78%)
UNHEDGED (a leg missed)     : +55.00 over 1 position, 50.00 staked — directional bets, NOT arbitrage
open                        : 2 positions, 400.00 at risk
planned, never placed       : 31 — no money moved, excluded from P&L
```

Three rules it enforces, each of which cost the author of this library real money to learn:

- **A plan is not a bet.** Recording an intention as a placement overstated one production
  system's conversion by 3.6×. Plans contribute nothing to P&L until you record what
  actually went on, per leg, at the stake and price you actually got.
- **A partly-placed arb is a punt.** If one leg is refused you are holding a directional bet
  you never intended. That is reported on its own line and never added to the arb figure —
  a naked leg that wins is not evidence the strategy works.
- **Duplicates collapse on read.** A double-written settlement once moved a published track
  record. Deduplicating on read rather than on write makes the total immune to any writer
  bug, including ones not yet written.

Stakes and returns are yours to record — nothing here places a bet or reads a bookmaker
account. The ledger lives in your XDG state directory at mode `600`, never the working
directory.

## Alerts

Opt-in, and off unless you configure it:

```ini
[alerts]
webhook_url  = https://discord.com/api/webhooks/...
min_edge_pct = 1.0
cooldown_s   = 3600
max_per_hour = 20
```

```bash
puntersedge-arb scan --watch 900 --alert
puntersedge-arb scan --alert-console     # print them instead of sending
puntersedge-arb scan --alert-dry-run     # exercise the throttle, deliver nothing
```

Your webhook URL is a credential — anyone holding it can post to your channel — so it lives
in the config file beside your API key, is wrapped so it cannot be printed by accident, and
never appears in a message, an error, or a log line. Errors quote the host and the status
code, never the URL.

Two things this gets right that are easy to get wrong:

- **Deduplication never compares the message text.** An alert's identity is the event and its
  legs — no odds, no edge, no age, no timestamp. Put any moving number in the compared text
  and "has this changed?" is true on every poll, so the throttle never engages. That failure
  sent one system in this estate ~24 identical emails a day for three days, with a correct,
  tested, 12-hourly throttle in place the whole time.
- **Quota counts what was sent, not what was attempted.** A suppressed alert, a failed
  webhook and a dry run all consume nothing and start no cooldown. Recording before sending
  makes a rejected call extend its own lockout, so the window can never be waited out.

Every poll prints the alert line even when it is all zeros — an alerter that has quietly
suppressed everything for an hour looks exactly like a quiet market otherwise.

## Boundaries

This package **never holds bookmaker credentials, never places bets, and never operates a
betting account.** It reads odds and computes sizing; you place every bet yourself in your
own session. There is nowhere in the config file to put a bookmaker login, and attempting
to add one is a startup error rather than a documented discouragement.

Racing back/lay arbitrage — backing at a bookmaker and laying on the Betfair exchange — is
**not available to API customers**. `/v1/arb/racing` and `/v1/racing/exchange` return HTTP
410 on every customer key, because the exchange side is withheld pending a Betfair data
licence. Use `racing_best_odds()` for cross-book racing value, which needs no exchange.

No profit is claimed or implied. Book-vs-book arbitrage on Australian markets is thin,
intermittent, and self-limiting — bookmakers restrict accounts that do it. The `arb` tools
here are instrumentation: they tell you which of the feed's candidates survive scrutiny,
and why the rest do not.

## Links

- **TypeScript / JavaScript client** — `npm install puntersedge` — https://www.npmjs.com/package/puntersedge
- **MCP server for AI agents** — `pip install puntersedge-mcp` — https://pypi.org/project/puntersedge-mcp/
- **Free API key** — https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme#signup
- **Documentation** — https://puntersedge.online/developers?utm_source=python_sdk&utm_medium=readme
- **Pricing** — https://puntersedge.online/api/pricing?utm_source=python_sdk&utm_medium=readme
- **Live sandbox (no key)** — https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme#trylive
- **Postman collection** — https://api.puntersedge.online/postman.json (Postman → Import → Link)
- **Runnable examples** — https://github.com/Propertyscout001/puntersedge-examples (next-to-go, best odds, movers, results, price history)
- **Next-to-go dashboard** — https://github.com/Propertyscout001/au-racing-odds-dashboard (one stdlib-only file, works without a key)

## Disclaimer

Data is provided for informational and analytical use only. Odds move — verify each leg before staking. Not financial advice. 18+. Gamble responsibly — [Gambling Help 1800 858 858](https://www.gamblinghelponline.org.au).

## License

MIT © PuntersEdge
