A lightweight, single-file Python SDK for the Bitpin cryptocurrency exchange REST API (https://api.bitpin.ir, /api/v1/).
It wraps the public market endpoints (orderbook, markets, currencies) and the private trading endpoints (authentication, wallets, order placement / cancellation / inspection) behind a small set of module-level functions. It is designed to be dropped straight into a trading bot: import it as a module and call the functions.
The module was extracted from a production USDT↔TMN / coin↔IRT market-making stack, so it also includes production-grade niceties:
- Automatic token refresh — one cached
access_tokenis reused for ~15 minutes. - 429-aware retries — public reads and authentication back off and retry on throttling (honoring
Retry-Afterand Bitpin's"available in N seconds"detail messages). - In-RAM balance cache — a single wallet snapshot serves all assets, with a controlled fallback to the last good snapshot on error, and automatic invalidation after every order placement / cancellation.
- Market precision cache — price / amount decimal precision is fetched once per hour and reused for rounding.
- Optional local price service — the orderbook reader can prefer a local WebSocket depth cache and fall back to REST only when that data is missing or stale.
⚠️ Units. Bitpin's quote markets (e.g.USDT_IRT,BTC_IRT) are priced in Toman, and asset balances are likewise returned in Toman, even though the ticker/symbol isIRT. This SDK does not convert units for you — the trading bots that use it apply their ownBITPIN_PRICE_DIVISOR/BITPIN_TMN_BALANCE_DIVISORat the call site if they need a different unit. Keep this in mind when reading prices and balances.
- Installation
- Credentials setup
- Environment variables
- Price streamer (WebSocket)
- Quick start
- API reference
- Real-world usage patterns
- Notes & gotchas
The SDK is a single file, bitpin.py. Copy it into your project (or into a shared modules/ directory that is on sys.path) and import it:
import bitpin as bpnThe only runtime dependency is requests:
pip install -r requirements.txt
# or
pip install requestsPrivate endpoints (wallets, orders) need a Bitpin API key and secret key. The SDK loads them lazily from a JSON key file the first time a private call is made.
Create a key.json file:
{
"bitpin": {
"api_key": "YOUR_API_KEY",
"secret_key": "YOUR_SECRET_KEY"
}
}A flat file (without the "bitpin" wrapper) is also accepted, and both lowercase (api_key) and uppercase (API_KEY) keys work:
{ "api_key": "YOUR_API_KEY", "secret_key": "YOUR_SECRET_KEY" }Then point the SDK at it with one of these environment variables:
KEY_FILE— absolute/relative path directly to the key file, orACCOUNT_DIR— a directory that contains akey.json.
export KEY_FILE=/opt/bots/account_a/key.json
# or
export ACCOUNT_DIR=/opt/bots/account_a # loads /opt/bots/account_a/key.jsonThis design makes the SDK multi-account friendly: run one process per account (e.g. under PM2/Supervisor), each with its own
KEY_FILE/ACCOUNT_DIR, and each process reads exactly the account it was assigned.
Public endpoints (get_orderbook, get_quantity_precision, get_market_info) do not require credentials.
| Variable | Used by | Default | Meaning |
|---|---|---|---|
KEY_FILE |
credentials | – | Path to the key JSON file. |
ACCOUNT_DIR |
credentials | – | Directory containing key.json (used if KEY_FILE is unset). |
ACCOUNT_NAME |
error messages | – | Optional label shown in the "key file not found" error. |
BITPIN_PRICE_SERVICE_URL |
get_orderbook |
http://127.0.0.1:9102 |
Base URL of an optional local depth/price service. |
BITPIN_PRICE_MAX_AGE_S |
get_orderbook |
5 |
Max age (seconds) for local depth data before falling back to REST. |
BITPIN_PRICE_HOST |
price_streamer.py |
127.0.0.1 |
Bind host for the local price service. |
BITPIN_PRICE_PORT |
price_streamer.py |
9102 |
Bind port for the local price service. |
BITPIN_STREAM_MARKETS |
streamers | – | Extra markets to stream, comma-separated (e.g. BTC_IRT,USDT_IRT). |
BITPIN_ACCOUNTS_DIR |
price_streamer.py |
../accounts |
Directory scanned for */config.json PAIR_BITPIN markets. |
BITPIN_ORDERBOOK_CHANNEL_PREFIX |
streamers | orderbook: |
Centrifugo channel prefix for the order-book feed. |
get_orderbook can serve top-of-book straight from a live WebSocket feed
instead of hitting REST on every call. Two ready-to-run scripts ship with the
SDK; both connect to Bitpin's Centrifugo order-book feed
(wss://centrifugo.bitpin.ir/connection/websocket) with no credentials.
They need three extra packages (already in requirements.txt):
pip install centrifuge-python fastapi uvicornA minimal, HTTP-free reader: it connects, subscribes, and prints each live order-book update. Use it to verify connectivity or watch prices in a terminal.
python ws_probe.py # default markets
python ws_probe.py BTC_IRT USDT_IRT # markets as CLI args[12:03:44] USDT_IRT bid=59000 ask=59010 mid=59005 (bvol=1234.5 avol=678.9)
The same live feed, but kept in a RAM cache and exposed over a tiny HTTP API on
127.0.0.1:9102. Run it as one long-lived process alongside your bots:
python price_streamer.py
# or: uvicorn price_streamer:app --host 127.0.0.1 --port 9102Once it is running, get_orderbook automatically prefers it: on each call it
first does a fast GET /depth/<MARKET> (600 ms timeout) and only falls back to
REST when the local data is missing or older than BITPIN_PRICE_MAX_AGE_S. No
code change is needed in your strategy — just point BITPIN_PRICE_SERVICE_URL
at it (the default already matches).
Endpoints:
| Endpoint | Purpose |
|---|---|
GET /depth/<MARKET> |
Latest cached depth + age_s / fresh flags (404 if no data yet). |
GET /health |
Connection state, reconnect count, and per-market data ages. |
curl http://127.0.0.1:9102/depth/USDT_IRT
curl http://127.0.0.1:9102/healthPrices from the streamer are in the same unit as the REST API (Toman).
import bitpin as bpn
# ---- Public data (no credentials needed) ----
book = bpn.get_orderbook("USDT_IRT", count=1)
best_bid = book["bid"][0]["price"]
best_ask = book["ask"][0]["price"]
print("best bid/ask:", best_bid, best_ask)
prec = bpn.get_quantity_precision("USDT_IRT")
print("price precision:", prec["p"], "amount precision:", prec["q"])
# ---- Private data (needs KEY_FILE / ACCOUNT_DIR) ----
usdt_free = bpn.get_bitpin_balance("USDT", free=True)
irt_free = bpn.get_bitpin_balance("IRT", free=True)
print("free USDT:", usdt_free, "free IRT:", irt_free)
# ---- Place, inspect, and cancel a limit order ----
order = bpn.new_order(side="buy", quantity=10, market="USDT_IRT", price=59000)
oid = order["id"]
status = bpn.check_order(oid)
print("filled base:", status["executedQty"], "avg price:", status["executedPrice"])
bpn.cancel_order(oid)Fetch the top of the orderbook for a market.
symbol(str) — market symbol, e.g."USDT_IRT","BTC_IRT". Case-insensitive (upper-cased internally).count(int) — number of levels to return per side (default2).- Returns — a dict on success, or the integer
-1after repeated errors:
{
"bid": [{"price": 59000.0, "vol": 1234.5}, ...], # sorted highest price first
"ask": [{"price": 59010.0, "vol": 678.9}, ...], # sorted lowest price first
}Prices are in Bitpin's native unit. Internally it first tries the optional local price service (BITPIN_PRICE_SERVICE_URL); if unavailable or stale it hits GET /api/v1/mth/orderbook/<symbol>/ with 429-aware retries.
d = bpn.get_orderbook("USDT_IRT", count=1)
if d == -1 or not d:
raise RuntimeError("Bitpin depth error")
bid = float(d["bid"][0]["price"])
ask = float(d["ask"][0]["price"])
bvol = float(d["bid"][0]["vol"])
avol = float(d["ask"][0]["vol"])
mid = (bid + ask) / 2.0Always check for
-1before indexing the result — that is how errors are signalled.
Return the decimal precision for price and base amount of a market. Backed by an hourly cache of GET /api/v1/mkt/markets/.
market(str) — e.g."USDT_IRT".- Returns —
{"q": base_amount_precision, "p": price_precision}. On any error it returns a safe fallback of{"q": 2, "p": 0}.
prec = bpn.get_quantity_precision("USDT_IRT") # -> {"q": 2, "p": 0}
# Smallest price step ("one tick") for a market:
p = int(prec["p"])
one_tick = 1.0 / (10 ** p) if p > 0 else 1.0
# Smallest amount step:
q = int(prec["q"])
min_qty = 1.0 / (10 ** q) if q > 0 else 1.0new_order uses this internally to round the price and amount, so you usually don't have to round manually — but the value is handy for computing tick sizes and minimum quantities in your strategy.
Return the raw requests.Response from GET /api/v1/mkt/currencies/ (list of currencies/metadata). You call .json() on it yourself.
resp = bpn.get_market_info()
if resp.status_code == 200:
currencies = resp.json()Return a valid bearer access_token, authenticating (and caching) on first use and transparently refreshing ~30s before the 15-minute expiry. You rarely call this directly — every private function calls it for you — but it is exposed for custom requests.
token = bpn.get_valid_token()
headers = {"Authorization": f"Bearer {token}"}Authentication uses POST /api/v1/usr/authenticate/ with the loaded api_key/secret_key and retries on 429.
Return wallet balances from GET /api/v1/wlt/wallets/. get_balance is a convenience alias with the same signature.
symbol(str | None) — asset symbol (e.g."USDT","IRT","BTC"). Case-insensitive. IfNone, returns a dict of all assets.free(bool) —True→ available (unfrozen) balance;False→ total (available + frozen).- Returns — a
floatfor one asset, adict[str, float]whensymbol is None, orNone/0when unavailable.
The exchange asset
RIALis normalized toIRT, and its balance is reported in Toman.
# One asset, free balance:
usdt_free = float(bpn.get_bitpin_balance("USDT", free=True) or 0.0)
# Toman quote balance (note: apply your own divisor if your account reports rial):
tmn_free = float(bpn.get_bitpin_balance("IRT", free=True) or 0.0) / BITPIN_TMN_BALANCE_DIVISOR
# All assets at once (total balances):
everything = bpn.get_bitpin_balance() # {"USDT": 12.3, "IRT": 4560000.0, ...}
# Alias form:
btc_total = bpn.get_balance("BTC")Results are served from a short-lived in-RAM snapshot (see configure_balance_cache). On a REST failure, the last successful snapshot is reused so the strategy keeps running.
Place a limit order via POST /api/v1/odr/orders/. Accepts HTTP 200 or 201. Price and amount are automatically rounded to the market's precision. On success the balance cache is invalidated.
side(str) —"buy"or"sell"(case-insensitive).quantity(Decimal | float | str) — base amount (e.g. the amount of USDT).market(str) — e.g."USDT_IRT".price(Decimal | float | str) — price in the market's native unit.- Returns — the parsed JSON dict of the created order (contains
id,state, ...). Raises on non-2xx responses.
r = bpn.new_order(side="sell", quantity=10, market="USDT_IRT", price=59500)
order_id = r.get("id")
state = r.get("state")If your strategy tracks price in Toman but the market is native-rial, multiply at the call site (this is exactly what the reference bots do):
r = bpn.new_order(side=side, quantity=qty, market=symbol,
price=price_tmn * BITPIN_PRICE_DIVISOR)Query orders via GET /api/v1/odr/orders/.
- If
order_idis given → returns the single order as a dict ({}on error). - Otherwise → returns a list of active orders (
[]on error), filtered by optionalsymbol/side, capped atlimit(max 100).
# A single order:
order = bpn.get_open_orders(order_id=123456)
# All active sell orders on a market:
active_sells = bpn.get_open_orders(symbol="USDT_IRT", side="sell", limit=50)Fetch one order and return it in a normalized fill-status shape (derived from Bitpin's dealed_base_amount / dealed_quote_amount). Convenient for post-cancel fill checks.
- Returns:
{
"executedQty": float, # filled base amount (e.g. USDT)
"executedSum": float, # filled quote value (e.g. toman)
"executedPrice": float, # approximate average fill price (quote/base)
"state": str|None,# order state
"raw": dict, # the raw order dict
}st = bpn.check_order(order_id)
ex_q = float(st.get("executedQty", 0.0))
ex_v = float(st.get("executedSum", 0.0))
if ex_q > 0:
avg_px = float(st.get("executedPrice", 0.0))
print(f"filled {ex_q} @ ~{avg_px}, value {ex_v}")Cancel an open order via DELETE /api/v1/odr/orders/<order_id>/.
- Returns —
Trueon success (HTTP204),Falseotherwise. Invalidates the balance cache on success.
if bpn.cancel_order(order_id):
print("cancelled")A common pattern is cancel then check what actually filled:
bpn.cancel_order(order_id)
st = bpn.check_order(order_id)
if st["executedQty"] > 0:
record_fill(st["executedPrice"], st["executedQty"])Configure the in-RAM wallet cache once, after your strategy config is loaded. Idempotent; changing the settings marks the cache dirty so the next read refreshes.
enabled(bool) — turn caching behavior on/off.ttl_s(float) — how long a snapshot is served without hitting REST.max_stale_s(float) — max acceptable staleness (clamped to be ≥ttl_s).debug(bool) — print cache hit/refresh diagnostics.
bpn.configure_balance_cache(
enabled=True,
ttl_s=10,
max_stale_s=30,
debug=False,
)Force the next balance read to go to REST (the snapshot is kept only as an error fallback). Called automatically after every successful new_order / cancel_order; call it yourself after any out-of-band balance change.
bpn.invalidate_balance_cache(reason="external transfer")Small helper that prints a [HH:MM:SS] message line. Used throughout the module for consistent timestamped logging; reusable in your own code.
bpn.log("strategy started") # -> [12:03:44] strategy startedThese snippets mirror how the SDK is driven inside the reference market-making bots.
Top-of-book helper (native price → your quote unit via a divisor):
def bitpin_top(symbol):
d = bpn.get_orderbook(symbol, count=1)
if d == -1 or not d:
raise RuntimeError("Bitpin depth error")
bid = float(d["bid"][0]["price"]) / BITPIN_PRICE_DIVISOR
ask = float(d["ask"][0]["price"]) / BITPIN_PRICE_DIVISOR
bvol = float(d["bid"][0]["vol"])
avol = float(d["ask"][0]["vol"])
mid = (bid + ask) / 2.0
return bid, ask, mid, bvol, avolTick size & minimum quantity from precision:
def one_tick(symbol):
p = int(bpn.get_quantity_precision(symbol)["p"])
return 1.0 / (10 ** p) if p > 0 else 1.0
def min_qty(symbol):
q = int(bpn.get_quantity_precision(symbol)["q"])
return 1.0 / (10 ** q) if q > 0 else 1.0Place a maker order, wait, then cancel and record the fill:
r = bpn.new_order(side="buy", quantity=qty, market="USDT_IRT",
price=px_tmn * BITPIN_PRICE_DIVISOR)
oid = r.get("id")
if oid:
# ... wait until no longer at top of book ...
bpn.cancel_order(oid)
st = bpn.check_order(oid)
ex_q = float(st.get("executedQty", 0.0))
ex_v = float(st.get("executedSum", 0.0)) / BITPIN_PRICE_DIVISOR
if ex_q > 0 and ex_v > MIN_NOTIONAL_TMN:
px = float(st.get("executedPrice", 0.0)) / BITPIN_PRICE_DIVISOR
save_trade("buy", px, ex_q)Taker order (cross the spread, then cancel any remainder):
price = best_ask if side == "buy" else best_bid
r = bpn.new_order(side=side, quantity=qty, market="USDT_IRT",
price=price * BITPIN_PRICE_DIVISOR)
oid = r.get("id")
if oid:
time.sleep(0.3)
bpn.cancel_order(oid)- Error sentinels, not exceptions, for public reads.
get_orderbookreturns-1on failure;get_open_ordersreturns{}/[];get_bitpin_balancecan returnNone. Check before use. new_orderraises on non-2xx responses (after printing the payload and server response). Wrap it intry/exceptin a trading loop.- Units are your responsibility. The SDK returns prices/balances in Toman as-is; apply your own
BITPIN_PRICE_DIVISOR/BITPIN_TMN_BALANCE_DIVISORonly if your strategy needs a different unit (e.g. Rial). - Precision rounding is best-effort.
new_orderfloors the amount and rounds the price to market precision; if precision lookup fails it logs a warning and sends the values as-is. - One account per process. Point each process at its own
KEY_FILE/ACCOUNT_DIRfor clean multi-account isolation.
See LICENSE if present, otherwise treat as provided as-is for use within your own project.