Originally published on our blog.

Search "build a crypto trading bot python" and the results split into two piles: toy scripts that call one exchange endpoint and stop, and courses that promise passive income by Friday. This post is neither. We are going to build the honest version: a Python skeleton that screens the whole market, qualifies candidates with real numbers, marks a level from candles, and then goes to sleep until the alert engine calls it. About sixty lines, the requests library, and the Osiris Developer API doing the market watching. It will not print money, and the last section says exactly why. What it gives you is the right architecture to build on, the same one we arrived at after running a bot in production taught us what actually breaks.

What you need

Python 3, the requests package, and an API key. Keys are created in Settings → Developer on the Developer plan; they look like osk_ followed by a long random string, they are shown once, and they belong in an environment variable, never in source control. Full reference docs live at developers/introduction if you want the whole surface; this tutorial uses four endpoints. Start the file every snippet below grows from:

import os import time import requests BASE = "https://osiristrader.com/api" session = requests.Session() session.headers["Authorization"] = "Bearer " + os.environ["OSIRIS_KEY"] def osiris(path, **params): while True: res = session.get(BASE + path, params=params, timeout=10) if res.status_code == 429: time.sleep(int(res.headers.get("Retry-After", "5"))) continue res.raise_for_status() return res.json() 

The retry branch is not decoration. When you exceed the burst limit (120 requests a minute on the Developer plan, 50,000 a day) the API answers 429 with an exact Retry-After in seconds. Honoring it costs one line; a tight retry loop instead burns the very budget you are waiting for. This whole tutorial spends under ten requests per cycle, so quota will not be your problem for a long time.

Step 1: let the screener rank the market

The amateur bot watches one pair it saw on a subreddit. The market does not care which pair you watch; opportunity shows up where it shows up, and most hours it shows up nowhere near your favorite coin. So the first call ranks everything. The Osiris engine recomputes thirty-plus metrics for every USDT perpetual on four venues every 30 seconds, and the rankings endpoint serves the sorted result in one request. We ask the volsurge category for its SCORE: three surge dimensions (volume pace, range expansion, and the move itself) each normalized against its own "clear surge" level, so a score of 1.0 or more means at least one of them is genuinely abnormal right now, whichever one it is.

top = osiris("/v1/rankings/volsurge", metric="SCORE", interval="5m", limit=10) for row in top["data"]: print(f"{row['symbol']:12} {row['value']:.2f}") 

That prints something like WIFUSDT 2.41, SOLUSDT 1.87, ORDIUSDT 1.55: the ten pairs doing the most unusual thing on the 5-minute timeframe, across the whole board, for one request. Pairs too young to have enough history simply do not appear; the list is always honestly rankable.

Step 2: qualify candidates with the metric sets

A surge alone is a headline, not a setup. Half the names on that list are moving because bitcoin moved, and a bot that buys every green candle is a donation with extra steps. So we pull the full metric set for all ten in one bulk call and keep only pairs that are moving on their own story with real buying behind them: BTC return correlation under 0.4 and positive taker flow.

symbols = ",".join(row["symbol"] for row in top["data"]) metrics = osiris("/v1/metrics", symbols=symbols, interval="5m") candidates = [] for symbol, m in metrics["data"].items(): corr = m.get("correlation", {}).get("retCorr") flow = m.get("trades", {}).get("FLOW") if corr is None or flow is None: continue # young listing, window too short yet  if corr < 0.4 and flow > 0: candidates.append(symbol) if not candidates: raise SystemExit("nothing qualifies right now, which is an answer too") pick = candidates[0] 

Two details here save hours of debugging later. First, fields the engine cannot compute yet are absent, not zero, which is why the code checks for None instead of trusting every key to exist; a zero that means "no data" poisons every filter downstream, so the API refuses to produce one. Second, "nothing qualifies" is a normal and frequent outcome. A bot that must always find a trade will always find one, and the market charges tuition for that personality trait.

Step 3: read structure from candles

Now we know what is moving and that the move has its own legs. Next question: where is it moving from? We pull hourly candles, the same bars the Osiris charts draw, and mark the most naive support there is: the lowest low of the last 24 closed hourly bars. One subtlety matters and it is the classic bug in every homemade bot: the newest element in the array is the live bar, still growing on every trade. An indicator computed over it repaints until the bucket closes. Decide deliberately which side of that line each calculation sits on; here the level uses closed bars only, and the live bar contributes just the current price.

bars = osiris(f"/v1/candles/{pick}", interval="1h", limit=100)["data"] closed = bars[:-1] # the last element is the live, still-growing bar price = bars[-1]["close"] # which makes the current price free support = min(bar["low"] for bar in closed[-24:]) print(f"{pick}: price {price}, 24h support {support}") 

A real bot would do more with structure than one minimum, and the API will hand it to you: the levels endpoint serves the same multi-timeframe support and resistance zones you see drawn on our charts, scored and clustered. We keep the naive version here because the point of this step is the shape of the loop, not the quality of the level.

Step 4: stop polling, hand the watch to the engine

Here is where most tutorial bots go wrong: they end with while True: check_price(); sleep(10), thousands of requests a day to observe that nothing happened. We invert it. One request registers the condition with the alert engine, the same one behind the site's alerts, which evaluates it against every live tick around the clock. The moment it triggers, Osiris POSTs a JSON payload to a URL you control, and your code wakes up. Your side spends zero requests while waiting.

res = session.post( BASE + "/v1/alerts", json={ "symbol": pick, "type": "price", "direction": "below", "price": support, "note": "surge candidate pulled back to 24h support", "webhook_url": "https://your.app/hooks/osiris?secret=CHANGE_ME", }, timeout=10, ) res.raise_for_status() alert = res.json()["data"] print("armed:", alert["id"], alert["status"]) 

Creation is validated: a price alert's level must sit on the far side of the current price, so if the market slid below your support while you were computing it, you get a 422 instead of an alert that fires instantly. The webhook URL must be public; localhost is rejected at creation, so for development run a tunnel (cloudflared or ngrok both work) and treat the full URL as a credential, secret included, because anyone who knows it can POST fake events at you. The receiver needs nothing beyond the standard library:

import json from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = "CHANGE_ME" seen = set() class Hook(BaseHTTPRequestHandler): def do_POST(self): if "secret=" + SECRET not in self.path: self.send_response(403) self.end_headers() return event = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) self.send_response(200) self.end_headers() # acknowledge first, think after  key = (event["alert"]["id"], event["trigger"]["time"]) if key in seen: return # delivery is at-least-once, retries happen  seen.add(key) print(event["alert"]["symbol"], "touched", event["trigger"]["price"]) # a real bot would re-check metrics here and decide  HTTPServer(("0.0.0.0", 8000), Hook).serve_forever() 

Three habits in that handler are worth copying from day one. Answer 200 immediately and think afterwards: delivery retries up to 3 attempts on a 10-second timeout, and a handler that works synchronously inside the request risks a retry arriving mid-action. Deduplicate on the pair of alert id and trigger time, which is unique per firing. And design for the miss: if your box was down for all three attempts, that firing is gone, so on recovery ask GET /v1/alerts?status=all, where lastTriggeredAt and triggerCount say exactly what you slept through.

What this skeleton is not

Now the part the yacht courses skip. This bot does not place orders, size positions, or manage a stop, and that is deliberate, because the strategy it sketches, buy a surging pair on a retest of naive support, is a demonstration, not an edge. The thresholds were picked for readability, not measured from data, and an unmeasured strategy wired to an exchange account is just a losing process with excellent uptime. We have receipts for this: our own production bot lost money through its first live iterations while its backtests looked respectable, and the fixes were never in the signal, they were in the plumbing around it: reconciliation on startup, a slippage ledger, kill criteria written before going live. Add to that everything the risk playbook says about sizing and streaks, none of which sixty lines can contain.

What the skeleton does give you is the loop that scales: rank the whole market instead of staring at one pair, qualify with numbers instead of vibes, respect the live bar, and let the engine watch so your code only runs at moments that matter. From here the natural next steps are the WebSocket stream to replace candle polling, the levels endpoint to replace the naive minimum, and intersecting two rankings for multi-factor screens; the API docs cover all three, and the whole surface ships as an OpenAPI spec you can import into any client generator. Every snippet from this post, plus a JavaScript quickstart and the Postman import, lives in our examples repo on GitHub, ready to clone. Build on it in that order, measure everything, and treat every line you add as a claim about the market that has to earn its place. The bots that survive are not the cleverest ones. They are the ones whose authors knew exactly what they had not yet proven.