OKX · Risk Control
OKX API Auto-Trading Risk Control: Positions, Orders, and Error Handling
You might assume quant risk comes from the market: misread the direction and you lose. But when a script is running, what zeros out an account in an instant isn't necessarily the market—it can just as well be your own code: a flipped direction, a loop that won't stop, a duplicate order after a dropped connection. Trading by hand, you get one last glance before hitting confirm; a script doesn't get that glance—it faithfully and continuously executes the error.
In this piece we (the MeowQuant desk) focus on quant risk control, and we come at it from "how to guard against it at the code level," not vaguely shouting "control your position." We break down the few classes of risk specific to API trading, give you copy-ready stop-loss, position-splitting, and retry code, then lay out the account-security bottom line and the order of operations for a "small-size cold start." By the end you should be able to fit your script with several brakes: a cap on the single order (for the total we only give you a skeleton — the checks it's missing are listed out in the text, and filling them in is on you), a stop-loss that doesn't depend on you watching, a state check before any resend, and credentials cut down to the minimum permission. Having all the brakes fitted doesn't mean you won't lose money — it means that when something goes wrong, you know where the boundary of the loss is.
Risks specific to API trading
Manual trading and API auto-trading have different risk profiles. Manual trading's main enemies are emotion and judgment; API trading adds, on top of that, a whole class of "machine-specific" risks. Recognize them and you'll know where to fit the brakes.
Runaway program
This is the classic one. A piece of logic is wrong—buy/sell direction flipped, a stop-loss condition written backwards, a loop missing its exit condition—and the program won't stop to wonder "is something off here?" the way a person would; it executes the wrong instruction over and over. A human might catch one wrong order and notice; a script can place dozens of wrong ones in seconds. The lethality of a runaway is exactly in its faithfulness.
Duplicate orders
These crop up most when the network stutters or a request times out. You send an order request, get no response, the script thinks it failed and resends—but the first order actually filled, so you end up with two. On futures, that means a doubled position and doubled risk. Duplicate orders usually aren't a logic bug but a failure to handle "the uncertain in-between state."
Dropped connection / going offline
Your script runs on your computer or server, and the moment the network drops it loses contact with the exchange. The bad case: your position sits exposed in the market while the stop-loss logic that should protect it has gone offline along with the script. By the time you reconnect, things may be unrecognizable. The essence of connection risk is: don't stake all your protection on the assumption that "the script stays online."
Position management and splitting
The first wall of risk control is making sure no single error can reach all your funds. This is done with position rules—and those rules need to be hard-coded, not left to your in-the-moment restraint.
A few principles worth sticking to:
- Set a per-order cap. No single order exceeds a fixed fraction of total funds (say 1%–2%). That way even if this order is entirely wrong on direction, the loss has a ceiling.
- Set a total-position cap. All open positions together don't exceed a total, so the program never gets a chance to bet all your funds at once.
- Split, don't go all-in. Break funds into a few tranches and enter in batches, reducing the impact of a judgment error at any single entry point.
- Leave the script some cash. Always keep a portion of funds out of trading in the account—both a buffer and room for you to act manually when something goes wrong.
Write these as hard constraints in code, check them before placing an order, and refuse the order if they're not met. What follows is a skeleton — you cannot drop it straight into live trading: it implements only the per-order cap, and we list what's missing item by item right after the code:
MAX_PER_ORDER_RATIO = 0.02 # single order no more than 2% of the capital base
MAX_TOTAL_RATIO = 0.30 # total position no more than 30% of the base (not implemented in this skeleton)
def check_position_limit(okx, symbol, order_value_usdt):
if not (order_value_usdt > 0):
raise ValueError('Order value must be positive')
bal = okx.fetch_balance()
usdt_total = bal['USDT']['total'] # note: this is the balance of USDT alone, not account equity
# Per-order cap: using the USDT balance as the base only holds approximately
# in the simple case of a spot account that is almost entirely USDT
if order_value_usdt > usdt_total * MAX_PER_ORDER_RATIO:
raise ValueError('Exceeds per-order position cap, order refused')
# TODO: the total-position cap (MAX_TOTAL_RATIO) is not done yet — as written,
# this function gives you no total-exposure constraint at all
return True
The reason this can only be read as a skeleton is that it is still missing at least four things, and each omission can leave the "cap" meaningless:
- How the capital base is computed.
bal['USDT']['total']is the balance of one currency, USDT. If you also hold BTC or ETH, or you're running a futures account, the real base should be account equity converted under a single valuation standard, not that one number. - Exposure from existing positions and open orders. For a total-position cap to hold, you have to count the positions you already carry plus the orders still resting on the book that could fill at any moment. Look only at "how big is this one order" and ten orders in a row will blow through the cap anyway.
- Concurrent order placement. If several strategies call this function at the same time, they can each read the same balance, each decide "not over the cap," and then all place orders together. Either serialise the order path with a lock, or keep a local ledger of "capacity already committed."
- The validity of the input itself. The amount has to be positive — that's what the check on the first line is for. A negative number or a zero makes the comparison meaningless.
Skeleton or not, the direction is right: it turns "I should control my position" from an intention into a gate the script must pass before it can place an order. Intentions waver; code doesn't. What you have to do is fill in those four items before you let it touch real money.
Stop-loss must be programmatic (in code)
If you remember only one line from this article, remember this: the stop-loss must be programmatic. A script runs automatically, and you can't watch the screen 24/7; when the market reverses fast, by the time you see it and close out by hand you're often several price levels too late.
Programmatic stop-losses come in two approaches, best used together:
Approach one: exchange-side conditional / stop orders. Place a stop order on OKX at the same time as the main order, so the exchange closes your position automatically at the trigger price. Its biggest upside: even if your script drops offline, this stop-loss still takes effect on the exchange side. It's the most reliable move against "an exposed position when the connection drops."
Approach two: local monitoring + trigger close in the script. The script continuously reads the floating loss on the position in a loop, and once it exceeds your threshold it actively fires a close order. It's more flexible (can do trailing stops, time-based stops, etc.), but requires the script to be online.
Below is a minimal skeleton of a local-monitoring stop-loss to demonstrate the logic (always verify on the demo account before going live):
import ccxt, time
# 🔴 Once this triggers it sells at market — a real action that fills immediately.
# Run it enough on your demo (Demo Trading) Key first, confirm the trigger
# condition and the size are right, and only then consider live credentials.
# The demo Key is created separately inside the demo trading environment;
# it is not the same set of credentials as your live one.
okx = ccxt.okx({
'apiKey': 'YOUR_DEMO_apiKey',
'secret': 'YOUR_DEMO_secret',
'password': 'YOUR_DEMO_Passphrase',
'enableRateLimit': True,
})
# Without this line, the stop-loss below closes a real position at market on live
okx.set_sandbox_mode(True)
# Self-check: this must print 1. If it prints None, stop before going further
print(okx.headers.get('x-simulated-trading'))
symbol = 'BTC/USDT'
entry_price = 60000 # your average entry price (example)
amount = 0.001 # position size
stop_ratio = 0.05 # stop out on a 5% drop
while True:
ticker = okx.fetch_ticker(symbol)
last = ticker['last']
drawdown = (entry_price - last) / entry_price
if drawdown >= stop_ratio:
okx.create_order(symbol, 'market', 'sell', amount)
print(f'Stop-loss triggered, closed at market @ {last}')
break
time.sleep(10) # leave a gap, don't hammer the endpoint
Two notes: one, the loop must have a sleep or it'll hit the rate limit; two, a local stop-loss is only a supplement—the exchange-side stop order is the last line of defense when the connection drops, so do both to be solid.
Guarding against duplicate orders and dropped connections
The root of duplicate orders is "I'm not sure whether the last order actually went through." The core of guarding against it: don't blindly resend—confirm state first.
Two methods used together:
Use a client-side custom order ID (OKX's docs call it clOrdId). Attach an ID you generate yourself when placing an order. The exchange does check it for duplicates, but the scope of that check is only the orders in your account that haven't reached a terminal state—reuse an ID that collides with an order still alive (live or partially filled) and the request is rejected with 51016, "Client order ID already exists."
And here is the boundary, which you need to see clearly: per OKX's documentation, once an order reaches a terminal state (fully filled, cancelled), the same clOrdId can be used again. In other words, it stops "two requests fired in the same instant while the first is still resting on the book"; it does not stop "the previous order actually filled, you never got the response, and you sent it again with the same ID"—that second one the exchange accepts as a brand-new order. So don't treat clOrdId as an idempotency guarantee. It's a handle, nothing more.
There's also a hard format requirement: alphanumeric characters only, 32 at most. Which means str(uuid.uuid4())—36 characters with hyphens—doesn't qualify; you need .hex.
What actually prevents duplicates is the flow below; clOrdId is just the handle the flow holds on to:
- Before placing the order, record "this business intent → clOrdId → state" locally and persist it to disk—not just in memory, because the moment the process crashes, memory is gone.
- When a request times out or simply never answers, do not resend. Take that clOrdId and query the exchange first.
- "Not in the open orders" is not the same as "didn't fill." It may already have filled and moved into history, so you need a lookup that covers terminal states: take the clOrdId to the order-details endpoint (the open-order list endpoint doesn't accept that filter at all), and go through the fill records too where needed. Concluding from open orders alone is an easy mistake at this step.
- Only once you've confirmed the clOrdId genuinely doesn't exist may you resend.
- After the process restarts, the first thing to do is pull the intents still left hanging out of that local record, reconcile each one's state, and only then let the strategy run on.
import ccxt, uuid
# clOrdId must be alphanumeric only, 32 characters at most.
# uuid4() as a string carries hyphens and runs 36 chars — not valid; .hex gives 32 alphanumerics.
def new_cid():
return 'meow' + uuid.uuid4().hex[:16] # 4 + 16 = 20 chars, all alphanumeric
def save_intent(cid, **fields):
"""Persist 'intent → clOrdId → state' to disk. SQLite, a flat file, whatever —
what matters is that you can read it back after a restart. Sketch only;
implement it yourself."""
...
def find_order_by_cid(okx, symbol, cid):
"""Look an order up by clOrdId. This goes through the order-details
endpoint GET /api/v5/trade/order, which reaches both live orders
and ones that have already reached a terminal state.
Note: the request parameters of the pending-order and history lists
(orders-pending / orders-history) have no clOrdId field at all, so
passing it in params is silently ignored — the endpoint still returns
a whole page of orders, you filter locally, and when paging fails to
turn it up you conclude it was never placed. So don't use those two
list endpoints to find an order by cid."""
try:
# Pass None for ordId and ccxt queries by the clOrdId in params instead
return okx.fetch_order(None, symbol, params={'clOrdId': cid})
except ccxt.OrderNotFound:
return None
symbol = 'BTC/USDT'
cid = new_cid()
# 1) Persist first, send afterwards
save_intent(cid, symbol=symbol, side='buy', amount=0.001, price=50000, state='sending')
try:
order = okx.create_order(
symbol, 'limit', 'buy', 0.001, 50000,
params={'clientOrderId': cid}, # ccxt maps this onto OKX's clOrdId
)
save_intent(cid, state='placed')
except Exception as e:
# 2) Timeout or no response: whether this order went in is unknown — don't resend blindly
print('Order outcome unknown, checking first:', e)
found = find_order_by_cid(okx, symbol, cid)
if found is None:
# 3) Open orders checked, history checked, confirmed absent — only now may you resend
save_intent(cid, state='not_found_retry')
else:
# It went in; record the real state and don't send a second one
save_intent(cid, state=found['status'])
After a timeout / dropped connection, check before topping up. That except branch above is this rule in code form: a request timing out doesn't mean the order failed, only that you don't know the outcome. Same after reconnecting—run fetch_open_orders, fetch_positions, and where needed a pass over order history and fills, see the account's real current state clearly, then let the strategy continue. Making "check state first, then act" the fixed first step after reconnecting blocks most duplicate operations caused by losing contact.
Rate limits, timeouts, and retries
Risk control isn't only about avoiding losses—it also includes keeping the script from "killing itself." Handle rate limits and timeouts poorly and your strategy can fail at the critical moment.
Rate limits: turn on 'enableRateLimit': True at init, and ccxt automatically inserts gaps between requests. Add exponential backoff in high-frequency loops (wait 1 second on the first hit, then 2, then 4). We cover this in more detail in our common API errors piece; here we just stress: any loop hammering an endpoint, rate-limiting is standard issue.
Timeouts: set an explicit timeout on requests, so the script doesn't seize up entirely because one request hangs. ccxt can be configured with 'timeout' (in milliseconds):
okx = ccxt.okx({
'apiKey': 'YOUR_apiKey',
'secret': 'YOUR_secret',
'password': 'YOUR_Passphrase',
'enableRateLimit': True,
'timeout': 10000, # 10-second timeout per request, avoids hanging
})
Retries have a cap. You can retry on a network stutter, but set a maximum count; once you hit it, alert or stop, rather than retrying forever. Infinite retry is itself a kind of "runaway"—it keeps consuming requests, and possibly repeating operations, while you're not watching.
Permissions and IP whitelist: the security bottom line
The bottom layer of risk control is account security. However steady your script, leak the credentials and it all goes to zero. There are two near-exceptionless bottom lines here:
Tick only "Read" and "Trade"—never "Withdraw." Running quant never needs the withdrawal action at all. As long as the Key has no withdrawal permission, even if your apiKey, secret, and Passphrase trio leaks someday, nobody can move your coins out to an on-chain address; tick withdrawal and a leak is like handing over the keys to your safe. But "no withdrawal" is not insurance, and what it covers is narrower than you might assume: by OKX's own definition, the trade permission itself includes funding transfers (funding account ↔ trading account, main account ↔ sub-accounts) and part of the account settings, so whoever holds the key can still shuffle money between your accounts. As for losses, a trade-only key can still wipe you out—the holder can push orders through on a thin, illiquid pair and take the other side themselves, turning your principal into trading losses that look like ordinary fills on the statement. The position and stop-loss constraints above limit the damage, but the real second line is the IP allowlist — it has to be done alongside "no withdrawal", not instead of it.
Set an IP whitelist. If your script runs on a fixed-IP machine (cloud server, home broadband with a static public IP), add that IP to the Key's whitelist so only requests from it are accepted—security up another notch. OKX allows up to 20 IPs per Key, IPv4, IPv6 and CIDR ranges included, so "my egress changes now and then" doesn't automatically mean giving up on the whitelist. When the egress IP really is unstable, the direction is to pin the egress down (move the strategy onto a fixed-IP machine) rather than skip the binding altogether—and there's one rule that's easy to miss: a Key with no IP bound that carries trade or withdraw permission expires after 14 days of inactivity. Bound or not, never hard-code credentials into code that gets uploaded; use environment variables or a config file that isn't synced.
These two, plus the positions and stop-losses above, make up the protection where "even if the worst happens, the loss has a boundary." The exact steps for creating a Key are in our API quant intro, and you can also use the API key checklist to run through it before opening a Key.
Small-size cold start: the pre-real-money checklist
With all the code-level risk control fitted, going live still has one last step: don't pile in heavy from the start. When a new strategy goes to real money, start with a "small-size cold start," in this order:
- Run on the demo continuously first. Cover a few up-and-down cycles, and confirm ordering, cancelling, stop-loss, and callbacks all match expectations. This step costs nothing; skipping it is using real money as your test environment.
- Then cold-start with a tiny bit of real money. An amount small enough that losing it all wouldn't sting; treat it as tuition. The slippage and depth the demo can't reproduce only surface now.
- Watch it for the first few days. Don't actually walk away; confirm live behavior matches the demo, the stop-loss really triggers, and there are no duplicate orders, then scale up gradually.
- Consider futures and leverage last. Before spot runs steadily, don't touch leverage. Leverage multiplies every class of risk above, so at the beginner stage we strongly suggest spot only.
Combine this checklist with the code constraints above and you've fitted your script not just one brake but several: positions are capped, stop-loss triggers automatically, state gets checked before any resend, a leaked credential can't move coins out on-chain (though it can still transfer between your accounts, and can still bleed the balance through deliberate self-dealing, which is why the IP allowlist sits alongside it), and you test with small money before going in. Any one alone isn't enough—together they put enough of a buffer between "error" and "blow-up."
A common gotcha
print spam the screen, hit Ctrl+C to stop it, fix the direction, add the exit condition and a sleep(10), and tack on that per-order position-cap check. But had this happened on live with leverage on, that string of bad orders would have been real money pouring out continuously. It's exactly this kind of scenario that sets a hard rule: stop-loss logic must be verified on the demo first, the loop must have a gap and an exit condition, and every order must pass the position-cap check before placement—none of the three optional.
FAQ
What's the risk in API auto-trading most likely to blow up an account?
Not the market itself, but the program's faithfulness—a script never hesitates like a human. A flipped buy/sell direction, a loop missing its exit condition, a duplicate order fired off after a dropped connection: things you might catch before confirming by hand, a script will execute dozens of times in a row. Add futures leverage and that kind of error can zero out your capital in a very short time. The core of guarding against it is writing risk control into the code, not relying on yourself to watch.
Does the stop-loss have to be in code? Can't I just watch it manually?
We strongly recommend making it programmatic. A script runs automatically, and you can't watch 24/7; once the market reverses fast, by the time you see it and stop out by hand it's often too late. Write the stop-loss logic into the strategy, or use OKX's conditional/stop orders so the program closes the position at the trigger line automatically—that keeps pace with auto-trading. Watching by hand can only be a supplement, never the sole line of defense.
How do I stop a script from placing duplicate orders?
Attaching a client-side custom order ID (clOrdId in OKX's docs) helps, but don't treat it as an idempotency guarantee: per the official documentation its dedup scope is only the orders in your account that haven't reached a terminal state — collide with one that is still live or partially filled and the request is rejected (51016), while once an order is fully filled or cancelled the same ID can be used again, so it can't stop “the previous order already filled and you sent it a second time”. What actually prevents duplicates is the flow: persist “business intent → clOrdId → state” to disk before placing the order; when a request times out or never answers, take that ID and query orders and fills first, keeping in mind that not being in the open orders doesn't mean it didn't fill — you also have to go through order history and fill records; only once you've confirmed it genuinely doesn't exist may you resend; after a restart, recover the pending intents from the local record first. On format, clOrdId must be alphanumeric only and at most 32 characters.
How do I set API Key permissions to be safe?
For a quant Key, tick only Read and Trade, never Withdraw. That way even if the three credentials leak, nobody can move your coins out to an on-chain address. Do not treat that as insurance, though, and note that what it covers is narrower than you might assume: per OKX's documentation, the trade permission itself includes funding transfers and part of the account settings, so the holder can still shuffle money between your funding account, trading account and sub-accounts; on top of that, a trade-only key can still wipe you out, because whoever holds it can push orders through on a thin, illiquid pair and take the other side themselves, draining your principal as trading losses. Then bind an IP whitelist to the Key (if you run on a fixed-IP machine) so only requests from the specified IPs are accepted, taking security up another notch. Automated trading never needs to withdraw anything.
How much should I put in the first time a new strategy goes live?
Use an amount you wouldn't mind losing entirely, treat it as tuition rather than capital. Smooth the flow on the demo first, then do a cold start with a tiny bit of real money, watch it for the first few days, confirm live behavior matches the demo and that slippage and fills are within expectations, then scale up gradually. Going in heavy from the start is betting all your funds on unverified code.
What happens to a running script if the connection drops?
It depends on how you wrote it. The bad version: on a dropped connection the request times out and the script crashes, or it blindly retries and causes duplicate orders, while your position sits exposed in the market with no stop-loss. The sound approach: set timeouts and retry caps on requests, and after reconnecting, first check current positions and open-order state before deciding what to do, and rely on an exchange-side stop order (not just the script's local judgment) so that even if the script drops offline, the close-out protection still takes effect on the exchange side.
Risk control isn't done-and-dusted once you finish the code—it has to grow alongside your strategy. We suggest saving this article's checklist and running through it every time you put up a new strategy. Next you can read the common API errors to round out your script's stability, and always verify new code on the demo account first. Remember the old line: survive first, talk returns later.