Metadata-Version: 2.4
Name: predigy-edge-sdk
Version: 9.2.0
Summary: Official Python SDK for the EDGE by Predigy prediction market API (retail, parlay, compliance/MICS, LP)
Author: Predigy Inc.
License: Proprietary
Project-URL: Homepage, https://edge-by-predigy.netlify.app
Project-URL: Repository, https://github.com/Predigy-LLC/EDGE
Project-URL: Documentation, https://edge-production-7b77.up.railway.app/docs
Project-URL: Bug Tracker, https://github.com/Predigy-LLC/EDGE/issues
Keywords: edge,predigy,prediction-market,lmsr,sdk,retail,parlay,compliance,mics,liquidity-provider
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.25.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: mypy==2.3.0; extra == "dev"
Dynamic: license-file

# EDGE Python SDK

Official Python SDK for the [EDGE by Predigy](https://edge-by-predigy.netlify.app) prediction market API.

EDGE is a prediction market pricing engine that integrates with sportsbook platforms. This SDK provides a fully-typed async client for all API operations.

---

## Installation

```bash
pip install predigy-edge-sdk
```

> **Note:** The PyPI package name is `predigy-edge-sdk`, but the import name is still `edge_sdk` (`from edge_sdk import EdgeClient`). Do **not** run `pip install edge-sdk` — that is an unrelated third-party package.

**Current version:** 9.2.0
**Requirements:** Python 3.10+ (dependencies: `httpx>=0.25.0`, `pydantic>=2.0.0`)

---

## Quick Start

```python
import asyncio
from edge_sdk import EdgeClient

async def main():
    async with EdgeClient(
        base_url="https://edge-production-7b77.up.railway.app",
        api_key="your-api-key",
    ) as client:
        # Quote, trade, sell and portfolio act as ONE player (see "Player identity" below)
        player = client.for_user("their-player-id")

        # List open markets
        result = await client.list_markets(status="OPEN")
        for market in result.markets:
            print(f"{market.title}: YES={market.prices.yes:.1%}, NO={market.prices.no:.1%}")

        # Get a quote before trading
        quote = await player.get_quote("mkt_3f9c1d2e4b5a", side="YES", amount=50.0)
        print(f"Cost: ${quote.total_cost:.2f} for {quote.contracts:.1f} contracts")

        # Execute the trade
        trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=50.0)
        # Show the player's balance from your own wallet; EDGE's balance field is informational (starts at $2,000,000)
        print(f"Trade {trade.trade_id}: {trade.contracts:.1f} contracts for ${trade.cost:.2f}")

asyncio.run(main())
```

---

## Authentication

Every request requires an API key passed in the `X-API-Key` header. The SDK handles this automatically:

```python
client = EdgeClient(
    base_url="https://edge-production-7b77.up.railway.app",
    api_key="your-api-key",
)
```

You receive your API key when your operator account is created by the Predigy team.

**Key scopes:** keys carry a scope — `READ_ONLY` < `TRADE` < `ADMIN`. A `READ_ONLY` key can list markets, quote, and pull compliance reports but cannot trade; `TRADE` adds trade/sell and retail flows; `ADMIN` is required for market creation, settlement, feeds, LP, and config writes. Requests below your key's scope return **403**.

**Rate limits:** the default limit is 60 requests/min per operator key per route, with higher per-route limits on hot paths (1,500/min trade+sell, 600/min quote, 120/min reads). Exceeding a limit returns **429** with a `Retry-After` header (see [Error Handling](#error-handling)).

### Player identity (`client.for_user`)

**Without this, every quote, trade, sell and portfolio read on your API key is refused with 400** (`EdgeAPIError`, `Missing X-Edge-User-Id header…`) — since 2026-09-06 the EDGE API requires the header on those seven routes for every real or sandbox operator. Only Predigy's own DEMO operator still accepts a header-less call — there it acts as ONE shared account (one informational balance, one set of cooldowns, one holding for the sell-quote check, no restricted-player match), which is exactly why everyone else must send it. The EDGE API tells players apart by the `X-Edge-User-Id` header; `client.for_user(user_id)` returns a client that sends it on every request (flat methods and every sub-client alike), with your own reference for the player as the value. The client you built is unchanged and keeps sending no header. A scoped client is a shallow copy over the **same** `httpx` client (one connection pool, however many players) — make one per request, call `for_user` again to switch players (it replaces, never stacks), and close the client you built, not the scoped one (closing a scoped client is a no-op).

```python
player = client.for_user("their-player-id")   # 1-64 chars of A-Z a-z 0-9 @ . _ : + -
quote = await player.get_quote("mkt_3f9c1d2e4b5a", side="YES", amount=100.0)
trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=100.0)
portfolio = await player.get_portfolio()      # THIS player's positions
```

It is a client-level method rather than a kwarg on each call because *who the caller is acting as* is a property of a session, not of one request — and one seam covers every player-scoped route, today's seven and any added later. (`idempotency_key` stays per call because idempotency genuinely is per call.) The server's rule for the value — 1–64 characters of `A-Z a-z 0-9 @ . _ : + -` — is enforced by the SDK before any request, with `EdgeValidationError`: the cheap failures are otherwise silent or surface only at the server (`""` is treated by the server as no header — a 400 for any operator that is not DEMO, the shared account again on DEMO). EDGE keys the player on its own `user_external_id`, derived from the value by a hash, and also stores the value itself: the trade, sell and portfolio replies and every player-scoped webhook carry it back unchanged as `player_id`, alongside `user_external_id` (in `rebate.period_closed`, on each `entries` item rather than on `data`), so you can credit the right wallet without keeping a mapping. (Quotes and `get_balance_bonus_history` carry neither.) `player_id` is `None` for a player EDGE has not seen with the header: a header-less DEMO call, a retail ticket, or a player not seen with it since EDGE began storing it.

---

## API Reference

### Markets

```python
# List markets with optional filters
markets = await client.list_markets(status="OPEN", category="NBA", limit=10)

# Get a single market by external ID
market = await client.get_market("mkt_3f9c1d2e4b5a")

# Simulated order-book depth (num_levels 0-50, step_cents 1-10) and trade history, newest first (limit 1-200)
depth = await client.get_depth("mkt_3f9c1d2e4b5a", num_levels=5, step_cents=2)
history = await client.get_history("mkt_3f9c1d2e4b5a", limit=20)
```

### Horse racing

`list_markets` caps `limit` at 100, and a busy race day holds more open horse-racing
markets than that — so build a meeting tab bar from `get_race_day()`, then list one
meeting at a time:

```python
day = await client.get_race_day()        # every meeting you have open markets on
for meeting in day.meetings:
    page = await client.list_markets(horse_meeting_id=meeting.meeting_id)
    for market in page.markets:
        if market.horse:                 # None on non-horse and cross-track markets
            print(market.horse.race_number, [r.silk_seed for r in market.horse.runners])
```

`horse_scope="race" | "card" | "race_day"` filters by market scope (any other value is a
400). `get_race_day` is mounted only at `/horse-racing/race-day`, never under `/v1`.
`is_demo` is a display flag, never a settlement signal.

```python
from datetime import datetime, timedelta, timezone

# Create a market. State the risk in DOLLARS — `max_exposure` is the most this
# market is modelled to lose, and EDGE derives the engine's liquidity
# parameter from it and the price the market opens at.
market = await client.create_market(
    title="Lakers vs Celtics — Lakers Win",
    category="NBA",
    description="Will the Lakers win tonight's game?",
    max_exposure=10_000.00,
    initial_price_yes=0.55,
    # Required on every market (since 2026-09-06): when buying stops
    # (accept_in_play_trades=False) or in-play trading begins (True). It is
    # also the field the automatic cutoff starts from, with `trading_closes_at`
    # then driving `IN_PLAY` → `CLOSED`.
    event_start_time=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
    # Any FUTURE UTC timestamp — the server refuses one in the past.
    trading_closes_at=(
        datetime.now(timezone.utc) + timedelta(hours=4)
    ).isoformat(),
)
```

> **⚠️ `trading_closes_at` is required in practice.** `accept_in_play_trades`
> defaults to `True` server-side, and an in-play market must declare when
> trading closes — so supply `trading_closes_at`, or pass
> `accept_in_play_trades=False` for a pre-event-only market. `event_start_time`
> is required either way (since 2026-09-06). (Before 3.0.0 the
> SDK exposed neither parameter and `create_market()` could not succeed at all.)

> **⚠️ `max_exposure` is a MODELLED maximum** under EDGE's trading controls, not
> a contractual guarantee. It is mutually exclusive with `b_base`. Not every
> amount is expressible, and which ones are depends on the opening price and
> the time to the event ($69.32–$6.93M at 50¢ and $391.21–$39.1M at 2¢ for a
> market created a day or more out, `k = 1.0`; 1.5x those inside 15 minutes); an
> out-of-range amount is
> refused with a `422` naming the nearest achievable figure, never silently
> adjusted.

### Quotes and Trades

```python
player = client.for_user("your-player-id")
# Get a price quote (does not execute a trade)
quote = await player.get_quote("mkt_3f9c1d2e4b5a", side="YES", amount=100.0)
print(f"Contracts: {quote.contracts}")
print(f"Avg price: ${quote.avg_fill_price:.4f}")
print(f"Fee: ${quote.fee:.2f} ({quote.fee_rate:.2%})")
print(f"Total cost: ${quote.total_cost:.2f}")

# Execute a trade
trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=100.0)

# Execute with slippage protection
trade = await player.execute_trade(
    "mkt_3f9c1d2e4b5a", side="YES", amount=100.0,
    max_avg_price=0.60,  # Reject if avg price exceeds $0.60
)

# Preview a sell's payout without executing (quote.contracts is what would actually close)
quote = await player.get_sell_quote("mkt_3f9c1d2e4b5a", side="YES", contracts=50.0)
print(f"Would net: ${quote.net_payout:.2f}")

# Sell (cash out) contracts from an existing position
sell = await player.sell_position("mkt_3f9c1d2e4b5a", side="YES", contracts=50.0)
print(f"Net payout: ${sell.net_payout:.2f}")

# Sell with a floor: the WHOLE sell is refused (EdgeAPIError, 400) if the average fill would be below it
sell = await player.sell_position("mkt_3f9c1d2e4b5a", side="YES", contracts=50.0, min_avg_price=0.45)

# Replay-safe retries: generate a key ONCE per attempt, persist it with the order, and
# retry a timed-out call with the SAME key — the replay carries idempotent_replay=True
import uuid

key = str(uuid.uuid4())
trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=100.0, idempotency_key=key)
sell = await player.sell_position("mkt_3f9c1d2e4b5a", side="YES", contracts=50.0, idempotency_key=str(uuid.uuid4()))
```

> **Idempotency:** `POST .../trade` and `POST .../sell` accept an optional `Idempotency-Key` header — retrying with the same key, the same request and the same `X-Edge-User-Id` replays the original response (`idempotent_replay=True`) instead of executing twice; the same key with a different request is refused with 409, as is the same request sent for a different player. **On the SELL route a 409 always means the earlier request under that key was ACCEPTED and may already have executed; on the BUY route read the error's `code` first (`err.code` — from 8.1.0; 8.0.0 does not surface it; `err.code` is set only on 409s — the structured 400/403/422 refusals carry theirs in `err.body["error"]`), because the arbitrage guard also returns 409 and nothing is charged there: `idempotency_key_reused` is a real reuse, `arbitrage_blocked` / `arbitrage_check_unavailable` is the guard. Never parse `detail`. For a real reuse — never answer it by generating a new key; resend the original body for the same player, or reconcile that trade. The one safe reuse with a changed body is after a REFUSAL, which stores no key at all** (`docs/API.md`, the `Idempotency-Key` note on the trade route). Pass it per call as `idempotency_key`: the SDK forwards it exactly as given and never generates a key or retries on its own — you own the retry loop. Omit the kwarg and no header is sent; the request is the one the SDK always made. The server bounds the key to 1–255 characters and refuses anything else with 422 (`EdgeValidationError`) — `""` included; the SDK does not check it.

### Data feeds (`client.feeds`)

EDGE exposes seven feed endpoints under `/admin/feeds` — `status`, `sync`, `events`,
`configure`, `manual-event`, `pending` and `import` — and `client.feeds` wraps all seven
(from 5.2.0; on 5.1.0 call them over raw HTTP):

```python
# ADMIN key. `configure` is a partial update: send only what you are changing.
await client.feeds.configure({"feed_adapter": "sportsdataio", "feed_sports": ["NBA"], "sync_mode": "approval"})
sync = await client.feeds.trigger_sync()   # raises EdgeAPIError (409, code "feed_adapter_not_configured") if no adapter is configured
if sync.stopped:
    print("sync ended early:", sync.stopped)   # a 200 is not "complete"

# Approval mode: staged events wait in `list_pending` until imported (or their event_time passes).
pending = await client.feeds.list_pending(limit=50)
refs = [e["event_ref"] for e in pending["events"]]
if refs:
    await client.feeds.import_pending(refs)
# Also: get_status() (READ_ONLY+), list_events(status=, sport=, limit=, offset=) (any scope),
# create_manual_event(sport=, home_team=, away_team=, event_time=, initial_probability_home=) — NOT idempotent.
```

`get_status`, `trigger_sync` and `import_pending` return models; the other four return the JSON
object as a `dict`. Nothing is validated in the SDK — a bad value is the server's 400/422. **The
feed reference is `docs/API.md`, section *Data feeds — `/admin/feeds/*`*** — parameters, response
shapes, scopes and error codes for all seven; `docs/DATA_FEED_ADAPTER_GUIDE.md` describes the
adapter model behind them.

### Portfolio

```python
player = client.for_user("your-player-id")
portfolio = await player.get_portfolio()
# Show the player's balance from your own wallet; EDGE's balance field is informational (starts at $2,000,000)
print(f"Unrealized P&L: ${portfolio.total_unrealized_pnl:.2f}")

for pos in portfolio.positions:
    print(f"  {pos.market_title} ({pos.side}): {pos.contracts} contracts, P&L: ${pos.unrealized_pnl:.2f}")
# `positions` is ONE page (limit 1-500, default 100); totals and total_positions cover all
page2 = await player.get_portfolio(limit=100, offset=100)

# The 5 headline figures without the positions list
summary = await player.get_portfolio_summary()

# The player's locked Balance Bonus credits (limit 1-500) and whether each is still "pending"
bonus = await player.get_balance_bonus_history(limit=50)
print(f"Locked: ${bonus.total_locked:.2f}, earned: ${bonus.total_earned:.2f}, rows: {bonus.count}")
```

### Admin Operations

```python
# Get your operator's statistics
stats = await client.get_stats()
print(f"Total markets: {stats.total_markets}")
print(f"Total volume: ${stats.total_volume:,.2f}")

# Settle a market
result = await client.settle_market("mkt_3f9c1d2e4b5a", outcome="YES")

# Halt, reopen, reschedule (omitted kwarg = unchanged) or void a market
await client.suspend_market("mkt_3f9c1d2e4b5a")
await client.unsuspend_market("mkt_3f9c1d2e4b5a")
await client.reschedule_market("mkt_3f9c1d2e4b5a", event_start_time="2026-09-06T19:00:00Z")
voided = await client.void_market("mkt_3f9c1d2e4b5a")  # voided["total_refunded"] is a decimal STRING

# Revenue breakdown (floats, rounded to cents) and per-player positions on a market
revenue = await client.get_revenue()
page = await client.get_market_positions("mkt_3f9c1d2e4b5a", limit=100)
for pos in page.positions:
    if not pos.is_retail:  # retail players are paid at the cage — never credit their wallet
        ...

# The audit trail — the reconciliation feed behind the MONEY webhooks (7.0.0).
# Not generic webhook recovery: the `feed.` skip below means a dropped feed
# webhook is never handed back here. See the docstring.
# after_id is a forward cursor: rows ABOVE that id, oldest first. 0 starts at the
# beginning and is a REAL cursor, not "unset". An empty page means caught up —
# keep the same cursor. Mounted un-prefixed only: a base_url ending in /v1 404s.
cursor = 0
audit_page = await client.list_audit_events(after_id=cursor, limit=200)
for row in audit_page.items:
    if row.resource_type == "market" and not row.action.startswith("feed."):
        await client.get_market_positions(row.resource_id)   # reconcile
if audit_page.items:
    cursor = audit_page.items[-1].id

# ⚠️ THIS LOOP IS NOT COMPLETE ON ITS OWN, and the caveat has to travel with
# the code you just copied. Baseline first: settle, void and regrade rows all
# land inside the money transaction, so normally the row arrives with the change.
# A row can be missing two ways: a SETTLE OR VOID whose audit write fails inside
# its savepoint writes NO row at all (regrade is not affected), and the ~30s
# visibility lag is a mitigation rather than a guarantee, so a transaction open
# longer than it can still be stepped past. The required backstop is a periodic
# full re-read of your open and recently-settled markets against
# get_market_positions - daily is ample. That sweep, not this cursor, is what
# makes the reconciliation complete.

# Reset sandbox data (demo/sandbox environments only — returns 403 in production)
await client.reset_sandbox()
```

### Sub-Clients (SDK 2.x)

SDK 2.x adds grouped sub-clients alongside the flat 1.x methods (nothing was removed — 2.x is non-breaking):

| Sub-client | Surface |
|------------|---------|
| `client.retail` | Cashier / retail ticket flows |
| `client.parlay` | Parlay market creation and leg resolution |
| `client.compliance` | 18 compliance report methods + Balance Bonus rebate reads |
| `client.lp` | Liquidity Provider management |
| `client.feeds` | Data feeds — `get_status`, `trigger_sync`, `list_events`, `configure`, `create_manual_event`, `list_pending`, `import_pending` (from 5.2.0) |

#### Retail (`client.retail`)

```python
from edge_sdk.types import MintTicketRequest, RetailTradeRequest, RetailCashoutRequest

# Mint a retail ticket at a cashier terminal
ticket = await client.retail.mint_ticket(MintTicketRequest(...))

# Trade against the ticket (debits ticket balance).
# idempotency_key is REQUIRED on both wager methods -- the server refuses a
# keyless retail wager or cash-out with 422. Mint ONE per cashier attempt and
# reuse it on every retry of that attempt: the retry then replays the original
# response instead of wagering -- or paying -- a second time.
result = await client.retail.execute_retail_trade(
    "ticket_ref", RetailTradeRequest(...), idempotency_key="till-7-slip-0042",
)

# Cash out a ticket's position on one market+side
await client.retail.cashout_ticket(
    "ticket_ref", RetailCashoutRequest(...), idempotency_key="till-7-cashout-0042",
)

# Redeem a winning ticket / check status
await client.retail.redeem_ticket("ticket_ref")
status = await client.retail.get_ticket_status("ticket_ref")
```

#### Parlay (`client.parlay`)

```python
from edge_sdk.types import ParlayCreateRequest, ParlayLegResolveRequest

# Create a 2-3 leg parlay market (admin)
market = await client.parlay.create_parlay(ParlayCreateRequest(...))

# Resolve one leg
await client.parlay.resolve_parlay_leg("mkt_3f9c1d2e4b5a", ParlayLegResolveRequest(...))
```

#### Compliance (`client.compliance`)

18 compliance report methods plus the Balance Bonus rebate reads (shown in the next section). All return the raw report dict. Most daily reports take a `date` string (plus report-specific filters), but the range-based reports — `get_past_post_report`, `get_large_wagers`, `get_structuring_alerts`, `get_operator_config_history` — take optional `date_from`/`date_to` strings (the first three also take `limit` 1–500, server default 100, and `offset`: they return ONE page, and the body's `total_count` is the full count — except that `get_past_post_report` pages two lists, `flags` counted by `total_count` and `past_post_trades` counted by `past_post_trade_count`), and `get_sport_statistics` takes `year`/`month`:

```python
report = await client.compliance.get_exception_report(date="2026-07-06")
transactions = await client.compliance.get_daily_transactions(date="2026-07-06")
large = await client.compliance.get_large_wagers(date_from="2026-07-01", date_to="2026-07-06")
```

Full method list: `get_exception_report`, `get_daily_transactions`, `get_daily_results`, `get_daily_wagering_detail`, `get_daily_wagering_summary`, `get_past_post_report`, `get_large_wagers`, `get_structuring_alerts`, `get_futures_reconciliation`, `get_accrual_recap`, `get_sport_statistics`, `get_customer_detail`, `get_customer_summary`, `get_cutoff_enforcement_log`, `get_operator_config_history`, `get_shift_close_report`, `get_outstanding_liability`, `get_outstanding_liability_totals`.

#### Liquidity Providers (`client.lp`)

> ⚠️ `designate_lp` / `revoke_lp` were **removed** — designating a Liquidity
> Provider requires Predigy's own credential, so those methods could only
> return 403 for an operator. Request an LP designation through Predigy.
> See the CHANGELOG for the full reasoning.

```python
from edge_sdk.types import LPConfigUpdateRequest

lps = await client.lp.list_lps()
await client.lp.update_lp_config("user_ext_id", LPConfigUpdateRequest(...))
dashboard = await client.lp.get_dashboard("user_ext_id")
activity = await client.lp.get_activity("user_ext_id", limit=50)
analytics = await client.lp.get_analytics()
```

### Balance Bonus (Admin / Compliance)

The counter-flow surge rebate ("Balance Bonus") read + config surface
(SDK 2.2.0). Reads require a `READ_ONLY` key; config writes require an
`ADMIN` key. The backend owns all validation, clamping, and audit — these
methods are thin HTTP wrappers.

```python
# Read the rebate ledger (locked credits the operator owes). Each row is untyped;
# pay the rows whose "status" is "earned" -- not every row with vested=True, which
# can persist on a market voided after a regrade (its "status" reads "not_earned").
ledger = await client.compliance.list_rebate_ledger(
    market_id="mkt_3f9c1d2e4b5a",
    limit=100,
)

# Per-market rebate period summary
period = await client.compliance.get_rebate_period("mkt_3f9c1d2e4b5a")

# Read the Balance Bonus config (tiers + resolved values + source + clamps)
config = await client.get_balance_bonus_config(market_id="mkt_3f9c1d2e4b5a")

# Tune one market's config (None resets a knob to the inherited value)
await client.update_balance_bonus_config(
    scope="market",
    market_id="mkt_3f9c1d2e4b5a",
    config={"enabled": True, "headline_cap": 0.12},
)
```

### Risk controls & fees (Admin)

Your own risk dials and your own trading fees (SDK 2.3.0). Reads need `READ_ONLY`; writes
need `ADMIN`.

```python
# Read your fee terms. bounds + platform defaults are SERVED, so render your
# inputs from this payload rather than hardcoding limits.
fees = await client.get_fees()
# {"base_fee_rate": 0.02, "max_fee_rate": None, "bounds": {...}, ...}

# Omitting a key leaves that fee alone; None CLEARS it.
await client.update_fees({"base_fee_rate": "0.02"})  # max_fee_rate untouched
await client.update_fees({"base_fee_rate": None})    # back to the 1.75% default

# 8.4.0: a separate sell fee and your surge setting (same absent / None rules).
await client.update_fees({"sell_fee_rate": "0.01"})  # base fee on SELLS only; 0 = none
await client.update_fees({"surge_scale": "0.5"})     # players pay half the everyday surge
await client.update_fees({"sell_fee_rate": None, "surge_scale": None})  # sell = buy fee; surge = 100%

# Decimal is accepted on both doors, converted to the type each one wants:
# fees -> a decimal STRING (exact; the server parses it as a Decimal)
from decimal import Decimal
await client.update_fees({"base_fee_rate": Decimal("0.0175")})

# Risk dials. A None value deletes a key and reverts to the engine default.
# risk controls -> a FLOAT (the server's validator 422s a numeric string)
await client.update_risk_controls_config({"circuit_breaker": {"caution": Decimal("0.3")}})
```

⚠️ **The two doors take different types on the wire, and that is deliberate.**
`update_fees` sends a `Decimal` as a string because the server parses it back to
a `Decimal` — exact, where `float` would round-trip through binary.
`update_risk_controls_config` sends a `float` because the server's risk-control
validator accepts only `int`/`float` and rejects a numeric string with a 422.
You pass a `Decimal` to either; the SDK picks the right wire type.

⚠️ **A fee change takes effect immediately, including on markets already open.**
There is no per-market fee snapshot, so the next trade on every open market is
priced at the new rate.

⚠️ **Raising or clearing `max_fee_rate` CAN raise what your traders pay.** It is
a ceiling, not a second fee — but if the current ceiling is holding fees down,
lifting it releases those charges.

Your fee is your revenue; there is no platform minimum, so `0` is legal on every
field.

### Webhooks

```python
# Register a webhook endpoint
webhook = await client.create_webhook(
    url="https://your-app.com/webhook",
    events=["trade.executed", "market.settled"],
    description="Production trade notifications",
)
print(f"Webhook ID: {webhook.webhook.external_id}")
print(f"Secret: {webhook.secret}")  # Store this — shown only once!

webhook_id = webhook.webhook.external_id

# List / read back (neither returns the secret)
webhooks = await client.list_webhooks()
one = await client.get_webhook(webhook_id)

# Change only the fields you pass; an omitted kwarg is left unchanged
await client.update_webhook(webhook_id, events=["trade.executed"], description="Trades only")

# Prove the endpoint is reachable: a signed test.ping, and the log it lands in
ping = await client.test_webhook(webhook_id)
print(ping.status, ping.response_status)        # "delivered" 200  /  "failed" 503
for attempt in await client.get_webhook_deliveries(webhook_id, limit=20):  # newest first
    print(attempt.event_type, attempt.attempt_number, attempt.delivered_at, attempt.error_message)

# Rotate the signing secret — the new one is shown ONCE, and the old one stops
# verifying immediately. (403 for the demo operator in production.)
rotated = await client.rotate_webhook_secret(webhook_id)
print(f"New secret: {rotated.secret}")

# Deactivate (soft delete) — the row stays listed with is_active=False;
# update_webhook(webhook_id, is_active=True) reverses it
await client.delete_webhook(webhook_id)
```

### API Keys, Sportsbook Mirror, Regrade Requests (8.3.0)

```python
# API keys (ADMIN scope). The plaintext is returned ONCE: store it, never log it.
# Mint first, revoke last. 409 at 20 live keys (revoke one first) and for your
# last live ADMIN key -- every case: docs/API.md, "API keys — several per operator".
minted = await client.create_api_key(name="till-rotation", scope="TRADE")
store_secret(minted.api_key)
# ...switch the till to minted.api_key and confirm it works, THEN revoke the old key:
keys = await client.list_api_keys()                     # revoked included; never a secret
old_key = next(k for k in keys if k.name == "till" and k.revoked_at is None)  # the key being replaced
await client.revoke_api_key(old_key.external_id)

# Mirror your fixed-odds book onto the linked EDGE market (TRADE scope)
r = await client.suspend_sportsbook_market("your-event-ref", reason="fixed-odds suspended")
r.idempotent  # True on a repeat: nothing changed, no webhook
await client.resume_sportsbook_market("your-event-ref")

# Regrade a RESOLVED market (submit needs ADMIN scope)
req = await client.submit_regrade_request(
    market_id="mkt_3f9c1d2e4b5a", claimed_corrected_outcome="NO",
    source_citation_url="https://league.example.com/ruling/9",
    source_citation_summary="League reversed the ruling", reason="official_ruling_reversal",
    requestor_name="Ana Ops", requestor_role="Trading lead", requestor_email="ana@example.com",
)
status = await client.get_regrade_request(req.regrade_request_id)
```

### Odds Anchor: Drift and Correction (9.1.0)

```python
drift = await client.get_anchor_drift("mkt_3f9c1d2e4b5a")  # live odds; .alert_level, .anchor_correction
# A house-account trade (ADMIN). Reads the consensus CACHE (filled by the odds-anchor sweep or a Predigy refresh);
# while the cache has no consensus for the market, every correction is 409 no_consensus.
fix = await client.correct_to_anchor("mkt_3f9c1d2e4b5a", idempotency_key="fix-mkt_3f9c1d2e4b5a-1")  # one key per correction
```

### Health Check

```python
health = await client.health_check()
print(health)  # {"status": "healthy"}
```

---

## Error Handling

The SDK raises typed exceptions for different error scenarios:

```python
from edge_sdk.exceptions import (
    EdgeAPIError,       # Base class for all API errors
    EdgeAuthError,      # 401 — Invalid or missing API key
    EdgeRateLimitError, # 429 — Too many requests
    EdgeServiceBusyError, # 503 + Retry-After — busy, retry (from 8.1.0)
    EdgeValidationError,# 422 — Invalid request data
)

player = client.for_user("your-player-id")
try:
    trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=100.0)
except EdgeAuthError as e:
    print(f"Authentication failed: {e.detail}")
except EdgeRateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after} seconds")
except EdgeValidationError as e:
    print(f"Invalid request: {e.detail}")
except EdgeAPIError as e:
    print(f"API error {e.status_code}: {e.detail}")
    print(f"Request ID: {e.request_id}")  # Useful for support
```

All exceptions include a `request_id` field that you can reference when contacting support.

### Busy (`503`) — from 8.1.0

When a trade or sell cannot get its market's lock or a place within the service's
concurrent-trade capacity in time, EDGE answers `503` with a `Retry-After` header, and the SDK
raises `EdgeServiceBusyError` (a subclass of `EdgeAPIError`) carrying the delay in `retry_after`
(whole seconds when the header is valid; `None` otherwise — the error is still raised — so do
not assume it is present). That refusal charged nothing. **But** while the
database's connections are saturated, ANY route can answer `503` with `Retry-After` — do not
assume from it that nothing moved (`docs/API.md`, the `503` row of *Error Responses*).

- A wager route (trade, sell, retail ticket trade / cash-out): wait `retry_after` seconds, then
  resend the SAME request with the SAME `idempotency_key`. **Never mint a new key for the retry.**
- **Online only:** a `execute_trade` / `sell_position` sent WITHOUT `idempotency_key` (optional there) must not be
  retried automatically — it may already have gone through. Reconcile it against the player's
  transaction history first. The retail ticket routes require a key, so this never applies there.

A `503` without `Retry-After` (a switched-off feature, an unreachable database) stays a plain
`EdgeAPIError`.

```python
import asyncio, uuid
from edge_sdk import EdgeServiceBusyError

key = str(uuid.uuid4())  # one key per attempt, reused on every retry
player = client.for_user("your-player-id")
while True:
    try:
        trade = await player.execute_trade("mkt_3f9c1d2e4b5a", side="YES", amount=100.0,
                                           idempotency_key=key)
        break
    except EdgeServiceBusyError as e:
        await asyncio.sleep(e.retry_after if e.retry_after is not None else 1)
```

---

## Webhook Verification

When receiving webhook deliveries, verify the HMAC-SHA256 signature to ensure the payload is authentic:

```python
from edge_sdk import verify_signature

# In your webhook handler (e.g., FastAPI)
@app.post("/webhook")
async def handle_webhook(request: Request):
    body = await request.body()
    signature = request.headers.get("X-Edge-Signature", "")

    if not verify_signature(body, signature, WEBHOOK_SECRET):
        raise HTTPException(401, "Invalid signature")

    event = json.loads(body)
    print(f"Received event: {event['event_type']}")
    # Process event...
```

The `X-Edge-Signature` header format is `sha256=<hex_digest>`.

**Webhook event types** (39 registered — see [docs/API.md](https://github.com/Predigy-LLC/EDGE/blob/main/docs/API.md) for the full list). Every one has a real dispatch site; six names that never did were withdrawn in 5.0.0 (`odds.drift_detected` has since come back with a sender). Common ones:
- `trade.executed` — A trade was placed
- `market.created` — A new market was created via `POST /admin/markets`. A market created by a sports-feed `sync` (auto mode) or `import` fires `feed.event_created` instead; a market created by `manual-event` or by the horse-racing feed fires no creation event
- `market.settled` — A market was settled with an outcome
- `market.sell_only` — The cutoff worker reached `event_start_time` on a market with `accept_in_play_trades=false`: buys refused, existing positions still sellable until `Market.sell_closes_at`. Before 2026-09-04 this transition fired `market.suspended` — if you keyed on that for "buys closed at kickoff", move to this event
- `market.suspended` — An operator halted a market (`/suspend`, `/auto_pause` or the sportsbook mirror), or EDGE did because its feed event can never resolve (`reason` beginning `unresolvable_`); the cutoff worker no longer fires it
- `parlay.created` / `parlay.leg_resolved` — Parlay lifecycle
- `rebate.participant_credited` / `rebate.period_closed` — Balance Bonus
- `market.voided` / `position.refund_applied` — A voided market and its per-position refunds (5.1.0)
- `trade.voided` — The operator voided ONE wager (`POST /admin/markets/{id}/trades/{trade_id}/void`): `refund_amount` credited, `void_adjustment` borne by the operator
- `market.rescheduled` — The operator moved a market's schedule after creation (5.2.0)

---

## Advanced Usage

### Custom HTTP Client

You can provide your own `httpx.AsyncClient` for custom timeouts, proxies, or connection pooling. Since 9.2.0 the SDK always sends `api_key` as `X-API-Key` unless your injected client sets `X-API-Key` itself, and uses `base_url` unless the client sets its own (before, an injected client without them 401'd on every call). The SDK never modifies your client, so one pool can be shared by clients with different keys:

```python
import httpx

custom_client = httpx.AsyncClient(
    base_url="https://edge-production-7b77.up.railway.app",
    timeout=60.0,
    headers={"X-API-Key": "your-api-key", "Content-Type": "application/json"},
    limits=httpx.Limits(max_connections=20),
)

client = EdgeClient(
    base_url="https://edge-production-7b77.up.railway.app",
    api_key="your-api-key",
    http_client=custom_client,
)
```

### Type Safety

The SDK is fully typed with Pydantic models. All responses are validated and provide IDE autocompletion. The `py.typed` marker (PEP 561) enables type checking in tools like mypy and pyright.

---

## Links

- **API Documentation:** [docs/API.md](https://github.com/Predigy-LLC/EDGE/blob/main/docs/API.md)
- **Integration Guide:** [docs/EDGE_INTEGRATION_GUIDE.md](https://github.com/Predigy-LLC/EDGE/blob/main/docs/EDGE_INTEGRATION_GUIDE.md)
- **Live API (Swagger):** [edge-production-7b77.up.railway.app/docs](https://edge-production-7b77.up.railway.app/docs)
- **Frontend Demo:** [edge-by-predigy.netlify.app](https://edge-by-predigy.netlify.app)

---

## License

Proprietary — see [LICENSE](./LICENSE). Copyright (c) 2026 Predigy Inc. All rights reserved. Use is permitted only under a separate written license agreement with Predigy Inc.
