Metadata-Version: 2.4
Name: paycrest-router
Version: 0.1.0
Summary: Smart provider routing engine for the Paycrest /markets API
Author-email: Chibuzor Adigwe <chibuzoradigwe7@gmail.com>
License: MIT
Keywords: crypto,offramp,onramp,paycrest,routing
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# paycrest-router

Smart provider routing engine for the [Paycrest](https://paycrest.io) `/markets` API.

Given a market order book, selects the best provider for a given corridor (token / fiat / network / side / amount) by scoring candidates on rate × reliability.

## Install

```bash
pip install paycrest-router
# or from source:
pip install -e ".[dev]"
```

## Library usage

```python
from decimal import Decimal
from paycrest_router import fetch_book, select

# 1. Fetch the order book — no API key needed, /markets is public
book = fetch_book()

# 2. Pick the best provider for your corridor
result = select(
    book,
    side="sell",         # "sell" = offramp (crypto → fiat), "buy" = onramp (fiat → crypto)
    token="USDT",        # "USDT" or "USDC"
    fiat="NGN",          # "NGN", "KES", "GHS", "XOF"
    network="base",      # "base", "arbitrum-one", "bnb-smart-chain", "polygon", "ethereum", "celo", "lisk"
    token_amount=Decimal("100"),
)

# 3. Use the result in your Paycrest order payload
if result:
    provider_id = result.candidate.provider_id  # e.g. "gFaEppVt"
    rate        = result.candidate.rate          # e.g. Decimal("1370.01")
    success_pct = result.candidate.success_pct  # e.g. Decimal("98.12")

    paycrest_payload = {
        "amount": "100",
        "token": "USDT",
        "network": "base",
        "destination": {
            "currency": "NGN",
            "providerId": provider_id,   # ← this is what you got from select()
            "recipient": { ... },
        },
    }
else:
    # No provider passed the eligibility gates.
    # Omit providerId entirely — Paycrest will auto-route.
    provider_id = None
```

### Error handling

`fetch_book()` raises `PaycrestFetchError` if the API is unreachable. `select()` never raises — it returns `None` on any failure. The safe pattern:

```python
from paycrest_router import fetch_book, select, PaycrestFetchError

try:
    book = fetch_book()
except PaycrestFetchError as e:
    # Network down or Paycrest API error — fall back to auto-routing
    book = None

provider_id = None
if book:
    result = select(book, side="sell", token="USDT", fiat="NGN",
                    network="base", token_amount=Decimal("100"))
    if result:
        provider_id = result.candidate.provider_id
# If provider_id is None, just omit it from your Paycrest payload
```

### Async usage

```python
from decimal import Decimal
from paycrest_router import fetch_book_async, select

book = await fetch_book_async()
result = select(book, side="sell", token="USDC", fiat="KES",
                network="arbitrum-one", token_amount=Decimal("500"))
```

### Custom config

```python
from paycrest_router import RoutingConfig, select

config = RoutingConfig(
    min_success_pct=95.0,            # only trust providers with 95%+ fill rate
    liquidity_buffer=1.2,            # provider must hold 20% more than the payout
    success_exponent=2.0,            # weight reliability more heavily vs. rate
    provider_denylist=["kVMyxKfB"],  # never pin these providers
)
result = select(book, side="sell", token="USDT", fiat="NGN", network="base",
                token_amount=Decimal("100"), config=config)
```

### Low-level access

```python
from paycrest_router import parse_book, filter_eligible, rank

candidates = parse_book(book)           # list[RouteCandidate]
eligible   = filter_eligible(candidates, side="sell", token="USDT",
                              fiat="NGN", network="base", token_amount=Decimal("100"))
ranked     = rank(eligible, side="sell")
best       = ranked[0] if ranked else None
```

## CLI

```bash
# Find the best provider
paycrest-router select --side sell --token USDT --fiat NGN --network base --amount 100

# See all eligible ranked candidates for a corridor
paycrest-router inspect --side sell --token USDC --fiat NGN --network base --amount 500

# Dump the raw order book (useful for building test fixtures)
paycrest-router book
```

### `select` output

```json
{
  "selected": true,
  "provider_id": "gFaEppVt",
  "side": "sell",
  "token": "USDT",
  "fiat": "NGN",
  "network": "base",
  "rate": "1370.01",
  "rate_type": "floating",
  "success_pct": "98.12",
  "min_amount": "0.5",
  "max_amount": "5000",
  "balance": "3667499",
  "balance_ccy": "NGN",
  "balance_usd": "2648.01",
  "settled": 2769
}
```

When no provider qualifies:

```json
{"selected": false, "provider_id": null, "reason": "no_eligible"}
```

When `selected` is `false`, omit `providerId` from your Paycrest order.

### Exit codes

| Code | Meaning |
| ---- | ------- |
| `0`  | Success — including when `selected` is `false` |
| `1`  | Bad arguments |
| `2`  | Network / API fetch error |

### All `select` flags

```text
--side sell|buy
--token USDT|USDC
--network base|arbitrum-one|bnb-smart-chain|polygon|ethereum|celo|lisk
--fiat NGN|KES|GHS|XOF
--amount 100           token amount (sell side)
--fiat-amount 140000   fiat amount (buy side alternative)
--min-success-pct      default 90.0
--liquidity-buffer     default 1.1
--success-exponent     default 1.0
--denylist "a,b"       comma-separated provider IDs to exclude
--base-url             default https://api.paycrest.io/v2
--timeout              default 10.0 seconds
--retries              default 2
--verbose              enable debug logging
```

## How the algorithm works

Selection runs in two phases: hard gates that disqualify providers, then a scoring formula that ranks the survivors.

### Phase 1 — Hard gates (all must pass)

Given your trade (side, token, fiat, network, amount Q):

| Gate | Condition |
| ---- | --------- |
| Corridor match | `side = side ∧ token = token ∧ network = network ∧ fiat = fiat` |
| Amount range | `min ≤ Q ≤ max` |
| Liquidity (sell) | `balance ≥ (Q × rate) × β` |
| Liquidity (buy) | `balance ≥ Q × β` |
| Denomination sanity | sell: `balance_ccy = fiat` / buy: `balance_ccy = token` |
| Success rate floor | `success_pct ≥ φ` (null = excluded) |
| Denylist | `provider_id ∉ denylist` |

β = `liquidity_buffer` (default 1.1 — 10% headroom above the payout amount).
φ = `min_success_pct` (default 90.0%).

For buy side where only fiat amount F is known, token amount is derived per provider: `Q = F / rate`.

### Phase 2 — Scoring (expected value)

Survivors are ranked by expected value — rate weighted by the probability the provider actually fills the order:

**Sell (offramp) — maximise fiat received:**

```
score = rate × (success_pct / 100)^k
```

**Buy (onramp) — minimise fiat spent per token:**

```
score = (success_pct / 100)^k / rate
```

k = `success_exponent` (default 1.0) controls the rate vs reliability trade-off:

| k    | Effect |
| ---- | ------ |
| 0    | Ignore reliability entirely — pure rate |
| 1    | Linear penalty on failure probability (default, balanced) |
| 2    | Quadratic penalty — strongly prefer reliable providers |
| → ∞  | Only 100% providers score non-zero |

**Concrete example at k=1:**

| Provider | Rate | success_pct | Score (sell)                    |
| -------- | ---- | ----------- | ------------------------------- |
| ProvA    | 1359 | 99.38%      | 1359 × 0.9938 = **1350.6** ← wins |
| ProvB    | 1379 | 97.58%      | 1379 × 0.9758 = **1345.5**     |

ProvA wins despite the lower rate — the reliability gap costs ProvB more than the rate advantage gains it.

**At k=0.1** the reliability term is almost completely flattened (90% → 0.9895, 99% → 0.9990 — a 0.1% difference), so score ≈ rate. This causes the highest-rate provider to always win regardless of reliability or settlement speed.

### Tie-breaking

Primary sort key is score. Ties broken by `balance_usd` descending, then `settled` descending — deeper liquidity and more lifetime orders win.
