Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bitpin-python

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_token is reused for ~15 minutes.
  • 429-aware retries — public reads and authentication back off and retry on throttling (honoring Retry-After and 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 is IRT. This SDK does not convert units for you — the trading bots that use it apply their own BITPIN_PRICE_DIVISOR / BITPIN_TMN_BALANCE_DIVISOR at the call site if they need a different unit. Keep this in mind when reading prices and balances.


Table of contents


Installation

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 bpn

The only runtime dependency is requests:

pip install -r requirements.txt
# or
pip install requests

Credentials setup

Private 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, or
  • ACCOUNT_DIR — a directory that contains a key.json.
export KEY_FILE=/opt/bots/account_a/key.json
# or
export ACCOUNT_DIR=/opt/bots/account_a   # loads /opt/bots/account_a/key.json

This 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.


Environment variables

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.

Price streamer (WebSocket)

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 uvicorn

1. ws_probe.py — connect only

A 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)

2. price_streamer.py — local service (integrates with get_orderbook)

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 9102

Once 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/health

Prices from the streamer are in the same unit as the REST API (Toman).


Quick start

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)

API reference

get_orderbook(symbol, count=2)

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 (default 2).
  • Returns — a dict on success, or the integer -1 after 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.0

Always check for -1 before indexing the result — that is how errors are signalled.


get_quantity_precision(market)

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.0

new_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.


get_market_info()

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

get_valid_token()

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.


get_bitpin_balance(symbol=None, free=False) / get_balance(...)

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. If None, returns a dict of all assets.
  • free (bool)True → available (unfrozen) balance; False → total (available + frozen).
  • Returns — a float for one asset, a dict[str, float] when symbol is None, or None/0 when unavailable.

The exchange asset RIAL is normalized to IRT, 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.


new_order(side, quantity, market, price)

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)

get_open_orders(order_id=None, symbol=None, side=None, limit=50)

Query orders via GET /api/v1/odr/orders/.

  • If order_id is given → returns the single order as a dict ({} on error).
  • Otherwise → returns a list of active orders ([] on error), filtered by optional symbol / side, capped at limit (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)

check_order(order_id)

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_order(order_id)

Cancel an open order via DELETE /api/v1/odr/orders/<order_id>/.

  • ReturnsTrue on success (HTTP 204), False otherwise. 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_balance_cache(enabled=False, ttl_s=10, max_stale_s=30, debug=False)

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,
)

invalidate_balance_cache(reason="manual")

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")

log(msg)

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 started

Real-world usage patterns

These 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, avol

Tick 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.0

Place 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)

Notes & gotchas

  • Error sentinels, not exceptions, for public reads. get_orderbook returns -1 on failure; get_open_orders returns {} / []; get_bitpin_balance can return None. Check before use.
  • new_order raises on non-2xx responses (after printing the payload and server response). Wrap it in try/except in 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_DIVISOR only if your strategy needs a different unit (e.g. Rial).
  • Precision rounding is best-effort. new_order floors 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_DIR for clean multi-account isolation.

License

See LICENSE if present, otherwise treat as provided as-is for use within your own project.

About

Python client for Bitpin Exchange APIs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages