EDGE Python SDK
Official Python SDK for the EDGE by Predigy 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
pip install predigy-edge-sdk
Note: The PyPI package name is
predigy-edge-sdk, but the import name is stilledge_sdk(from edge_sdk import EdgeClient). Do not runpip install edge-sdk— that is an unrelated third-party package.
Current version: 5.5.0
Requirements: Python 3.10+ (dependencies: httpx>=0.25.0, pydantic>=2.0.0)
Quick Start
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:
# 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 client.get_quote("mkt_abc123", side="YES", amount=50.0)
print(f"Cost: ${quote.total_cost:.2f} for {quote.contracts:.1f} contracts")
# Execute the trade
trade = await client.execute_trade("mkt_abc123", side="YES", amount=50.0)
print(f"Trade {trade.trade_id} executed! New balance: ${trade.new_balance:.2f}")
asyncio.run(main())
Authentication
Every request requires an API key passed in the X-API-Key header. The SDK handles this automatically:
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, with higher per-route limits on hot paths (300/min trade+sell, 600/min quote, 120/min reads). Exceeding a limit returns 429 with a Retry-After header (see Error Handling).
Player identity (client.for_user)
Without this, every quote, trade, sell and portfolio read on your API key acts as ONE shared account — one wallet, one set of cooldowns, one holding for the sell-quote "exceeds the player's holding" check, and no restricted-player match. 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).
player = client.for_user("their-player-id") # 1-64 chars of A-Z a-z 0-9 @ . _ : + -
quote = await player.get_quote("mkt_abc123", side="YES", amount=100.0)
trade = await player.execute_trade("mkt_abc123", side="YES", amount=100.0)
portfolio = await player.get_portfolio() # THIS player's wallet and 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 silent otherwise ("" is treated by the server as no header — the shared account again). EDGE hashes the value into its own user_external_id and stores only the hash, so responses and webhooks carry user_external_id, never the value you sent.
API Reference
Markets
# 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_abc123")
# 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_abc123", num_levels=5, step_cents=2)
history = await client.get_history("mkt_abc123", limit=20)
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,
# Any FUTURE UTC timestamp — the server refuses one in the past.
trading_closes_at=(
datetime.now(timezone.utc) + timedelta(hours=4)
).isoformat(),
)
⚠️
trading_closes_atis required in practice.accept_in_play_tradesdefaults toTrueserver-side, and an in-play market must declare when trading closes — so supplytrading_closes_at, or passaccept_in_play_trades=Falsefor a pre-event-only market. (Before 3.0.0 the SDK exposed neither parameter andcreate_market()could not succeed at all.)
⚠️
max_exposureis a MODELLED maximum under EDGE's trading controls, not a contractual guarantee. It is mutually exclusive withb_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 a422naming the nearest achievable figure, never silently adjusted.
Quotes and Trades
# Get a price quote (does not execute a trade)
quote = await client.get_quote("mkt_abc123", 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 client.execute_trade("mkt_abc123", side="YES", amount=100.0)
# Execute with slippage protection
trade = await client.execute_trade(
"mkt_abc123", 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 client.get_sell_quote("mkt_abc123", side="YES", contracts=50.0)
print(f"Would net: ${quote.net_payout:.2f}")
# Sell (cash out) contracts from an existing position
sell = await client.sell_position("mkt_abc123", 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 client.sell_position("mkt_abc123", 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 client.execute_trade("mkt_abc123", side="YES", amount=100.0, idempotency_key=key)
sell = await client.sell_position("mkt_abc123", side="YES", contracts=50.0, idempotency_key=str(uuid.uuid4()))
Idempotency:
POST .../tradeandPOST .../sellaccept an optionalIdempotency-Keyheader — retrying with the same key and the same request replays the original response (idempotent_replay=True) instead of executing twice; the same key with a different request is refused with 409. Pass it per call asidempotency_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):
# 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 (400) 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
portfolio = await client.get_portfolio()
print(f"Balance: ${portfolio.balance:.2f}")
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}")
# The 5 headline figures without the positions list
summary = await client.get_portfolio_summary()
# The player's locked Balance Bonus credits (limit 1-500) and whether each is still "pending"
bonus = await client.get_balance_bonus_history(limit=50)
print(f"Locked: ${bonus.total_locked:.2f}, earned: ${bonus.total_earned:.2f}, rows: {bonus.count}")
Admin Operations
# Get platform 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_abc123", outcome="YES")
# Halt, reopen, reschedule (omitted kwarg = unchanged) or void a market
await client.suspend_market("mkt_abc123")
await client.unsuspend_market("mkt_abc123")
await client.reschedule_market("mkt_abc123", event_start_time="2026-09-06T19:00:00Z")
voided = await client.void_market("mkt_abc123") # 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_abc123", limit=100)
for pos in page.positions:
if not pos.is_retail: # retail players are paid at the cage — never credit their wallet
...
# 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 reports + 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)
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)
result = await client.retail.execute_retail_trade("ticket_ref", RetailTradeRequest(...))
# Cash out a ticket's position on one market+side
await client.retail.cashout_ticket("ticket_ref", RetailCashoutRequest(...))
# 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)
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_abc123", 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, and get_sport_statistics takes year/month:
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_lpwere 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.
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.
# Read the rebate ledger (locked credits the operator owes)
ledger = await client.compliance.list_rebate_ledger(
market_id="mkt_abc123",
vested=True,
limit=100,
)
# Per-market rebate period summary
period = await client.compliance.get_rebate_period("mkt_abc123")
# Read the Balance Bonus config (tiers + resolved values + source + clamps)
config = await client.get_balance_bonus_config(market_id="mkt_abc123")
# Tune one market's config (None resets a knob to the inherited value)
await client.update_balance_bonus_config(
scope="market",
market_id="mkt_abc123",
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.
# 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
# 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 both
fields.
Webhooks
# 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)
Health Check
health = await client.health_check()
print(health) # {"status": "healthy"}
Error Handling
The SDK raises typed exceptions for different error scenarios:
from edge_sdk.exceptions import (
EdgeAPIError, # Base class for all API errors
EdgeAuthError, # 401 — Invalid or missing API key
EdgeRateLimitError, # 429 — Too many requests
EdgeValidationError,# 422 — Invalid request data
)
try:
trade = await client.execute_trade("mkt_abc123", 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.
Webhook Verification
When receiving webhook deliveries, verify the HMAC-SHA256 signature to ensure the payload is authentic:
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 (35 registered — see 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. Common ones:
trade.executed— A trade was placedmarket.created— A new market was createdmarket.settled— A market was settled with an outcomemarket.sell_only— The cutoff worker reachedevent_start_timeon a market withaccept_in_play_trades=false: buys refused, existing positions still sellable untilMarket.sell_closes_at. Before 2026-09-04 this transition firedmarket.suspended— if you keyed on that for "buys closed at kickoff", move to this eventmarket.suspended— An operator halted a market (/suspend,/auto_pauseor the sportsbook mirror); the cutoff worker no longer fires itparlay.created/parlay.leg_resolved— Parlay lifecyclerebate.participant_credited/rebate.period_closed— Balance Bonusmarket.voided/position.refund_applied— A voided market and its per-position refunds (5.1.0)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:
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
- Integration Guide: docs/EDGE_INTEGRATION_GUIDE.md
- Live API (Swagger): edge-production-7b77.up.railway.app/docs
- Frontend Demo: edge-by-predigy.netlify.app
License
Proprietary — see LICENSE. Copyright (c) 2026 Predigy Inc. All rights reserved. Use is permitted only under a separate written license agreement with Predigy Inc.
Release files for predigy-edge-sdk 5.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| predigy_edge_sdk-5.5.0.tar.gz | 89.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| predigy_edge_sdk-5.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 137.6 kB
Release files / predigy_edge_sdk-5.5.0.tar.gz
| Download URL | predigy_edge_sdk-5.5.0.tar.gz |
|---|---|
| Size | 89.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aafff5de309d21762f784c780e5580d30bc81fc915a66e392cd980e77b71faa1
|
|
BLAKE2b-256 checksum How to use checksums |
1de0751b6453d0843f4f301d5e5a38759bb24f30d6aafeabdec631d946576935
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.16
|
Release files / predigy_edge_sdk-5.5.0-py3-none-any.whl
| Download URL | predigy_edge_sdk-5.5.0-py3-none-any.whl |
|---|---|
| Size | 48.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
85a93ac32817b66589e9f0680ccbbd8f586449d08dfee86511cdbcab7c009875
|
|
BLAKE2b-256 checksum How to use checksums |
26830b9e3cdc3761e000d0429ce814c9fc72f956bac8532038def63e65bd1a4d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.11.16
|