Metadata-Version: 2.4
Name: puntersedge
Version: 0.3.1
Summary: Official Python client for the PuntersEdge Australian Sports Odds API — live bookmaker odds, racing, best-odds comparison and arbitrage.
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: Get an API key, https://puntersedge.online/api?utm_source=python_sdk&utm_medium=pypi#signup
Keywords: australian sports odds api,betting api,odds api,odds comparison,arbitrage betting,horse racing odds,sportsbet,betfair,sports betting data,bookmaker odds
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25
Dynamic: license-file

# PuntersEdge — Australian Sports Odds API (Python client)

Official Python client for the [**PuntersEdge Australian Sports Odds API**](https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme) — live bookmaker odds across 14 Australian books, racing next-to-go, best-odds comparison, and pre-computed **arbitrage / value** signals, all as clean JSON.

> Get a **free API key** (1,500 credits/month, no credit card) → **[puntersedge.online/api](https://puntersedge.online/api?utm_source=python_sdk&utm_medium=readme#signup)**

[![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)

---

## Why this API

Most "sports odds API" products have thin Australian coverage. PuntersEdge is **Australian-first**: Sportsbet, TAB, Neds, Ladbrokes, Unibet, PointsBet, Betr, BetRight, NextBet, Palmerbet and TABtouch — eleven Australian books on racing, six of them on sports — across AFL, NRL, NBA, WNBA, tennis and cricket, plus **horse / greyhound / harness racing**.

- 🟢 **Live bookmaker odds** — one REST endpoint, 14 AU books (measured live at puntersedge.online/coverage-report)
- 🏇 **Racing next-to-go** — runners + prices for horse, greyhound, harness
- ⚖️ **Best-odds comparison** — best price per selection across every book
- 🎯 **Arbitrage, pre-computed** — surebets and spreads/totals line arbs, with suggested stake splits already calculated for you to place yourself. Racing back/lay against the exchange is withheld pending a Betfair data licence; use `racing_best_odds()` for cross-book racing value
- 📊 **Value & promo boards** — daily plays ranked by EV per $1
- 🔌 **Predictable JSON** — simple `X-API-Key` auth

Full docs: **[puntersedge.online/developers](https://puntersedge.online/developers)** · Pricing: **[puntersedge.online/api/pricing](https://puntersedge.online/api/pricing)**

## Install

```bash
pip install puntersedge
```

## 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 | 5,040 | 7,200 |
| 3 hours | **960 — fits** | 1,680 | 2,400 |

Against the free tier's 1,500 credits/month, **exactly one cell fits**: a single sport polled
three-hourly. Everything else is over — a 15-minute two-sport loop by 13×.

*(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

- 🔑 **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)

## 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
