Python SDK
Official Python client for the PropDAO trading API. One file, zero dependencies, Python 3.9+.
Installation REQUIRED
The SDK is a single file with zero dependencies (stdlib urllib only). Python 3.9+.
Option A — copy-paste. Save the source below as propdao.py next to your bot.
Option B — pip (PyPI package publishing soon):
pip install propdao
Environment
export PROPDAO_API_KEY=pd_live_... export PROPDAO_ACCOUNT=prop-abc123 # optional; examples default to your first account
Get the key from the terminal: avatar → Settings → Developers → Generate key.
SDK source COPY & PASTE
Save as propdao.py. Wraps every endpoint, raises PropDAOError on failures, generates an intentId per order so retries never double-fill, and waits out 409 races for you.
"""PropDAO agent SDK. Zero dependencies.
from propdao import PropDAO
pd = PropDAO() # reads PROPDAO_API_KEY
acct = pd.get_accounts()[0]["account_id"]
print(pd.get_risk(acct)["roomUsd"]) # dollars you can lose before breach
pd.market_buy(acct, "BTCUSDC", 0.01, leverage=2, sl=60000)
"""
import json
import os
import secrets
import time
import urllib.error
import urllib.request
__all__ = ["PropDAO", "PropDAOError"]
__version__ = "0.1.0"
class PropDAOError(Exception):
def __init__(self, status, message):
super().__init__(f"{status}: {message}")
self.status = status
self.message = message
class PropDAO:
def __init__(self, api_key=None, base_url="https://app.propdao.finance/api/v1", timeout=15):
self.api_key = api_key or os.environ.get("PROPDAO_API_KEY")
if not self.api_key:
raise ValueError("api_key required (or set PROPDAO_API_KEY)")
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def _req(self, method, path, body=None, _retried=False):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
self.base_url + path, data=data, method=method,
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json",
"User-Agent": f"propdao-python/{__version__}"},
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as r:
return json.load(r)
except urllib.error.HTTPError as e:
# 429 on a read: wait out Retry-After (capped) once, then surface it.
if e.code == 429 and method == "GET" and not _retried:
time.sleep(min(5, float(e.headers.get("Retry-After") or 1)))
return self._req(method, path, body, _retried=True)
try:
msg = json.load(e).get("error", e.reason)
except Exception:
msg = e.reason
raise PropDAOError(e.code, msg) from None
except (urllib.error.URLError, TimeoutError, OSError) as e:
# status 0 = never got a response; the intentId makes a retry safe.
raise PropDAOError(0, f"network error: {getattr(e, 'reason', e)}") from None
# -- read --------------------------------------------------------------
def health(self):
return self._req("GET", "/health")
def markets(self):
"""Every tradable symbol with maxLeverage, lotStep, and the fee schedule. No key needed."""
return self._req("GET", "/markets")
def challenges(self):
"""Account sizes, prices and rules. No key needed."""
return self._req("GET", "/challenges")["data"]
def me(self):
return self._req("GET", "/me")
def get_accounts(self):
return self._req("GET", "/accounts").get("accounts", [])
def get_account(self, account_id):
return self._req("GET", f"/accounts/{account_id}")
def get_open_positions(self, account_id):
return self._req("GET", f"/accounts/{account_id}/positions?limit=500")["data"]
def get_open_orders(self, account_id):
return self._req("GET", f"/accounts/{account_id}/orders?limit=500")["data"]
def get_trades(self, account_id, limit=100, offset=0):
return self._req("GET", f"/accounts/{account_id}/trades?limit={limit}&offset={offset}")["data"]
def get_risk(self, account_id):
"""equity, floor, roomUsd, roomPct, breached — size positions off roomUsd."""
return self._req("GET", f"/accounts/{account_id}/risk")
# -- trade -------------------------------------------------------------
def place_order(self, account_id, symbol, side, qty, order_type="market", leverage=None,
limit_price=None, trigger_price=None, sl=None, tp=None, intent_id=None, **extra):
"""order_type: market | limit | stop_market | stop_limit | take_market | take_limit | scale | twap.
intent_id: idempotency key (auto-generated if omitted, so a network retry never double-fills).
extra: twapMs, twapMin, twapMax, twapTrigger, scaleStart, scaleEnd, scaleCount, ..."""
body = {"symbol": symbol, "side": side.upper(), "qty": qty, "orderType": order_type, "leverage": leverage,
"limitPrice": limit_price, "triggerPrice": trigger_price, "sl": sl, "tp": tp,
"intentId": intent_id or secrets.token_urlsafe(16), **extra}
body = {k: v for k, v in body.items() if v is not None}
for attempt in range(5):
try:
return self._req("POST", f"/accounts/{account_id}/orders", body)
except PropDAOError as e:
# 409 = same intentId still executing (a retried request raced itself); wait for its result.
if e.status != 409 or attempt == 4:
raise
time.sleep(0.5 * (attempt + 1))
def market_buy(self, account_id, symbol, qty, **kw):
return self.place_order(account_id, symbol, "BUY", qty, **kw)
def market_sell(self, account_id, symbol, qty, **kw):
return self.place_order(account_id, symbol, "SELL", qty, **kw)
def limit_buy(self, account_id, symbol, qty, price, **kw):
return self.place_order(account_id, symbol, "BUY", qty, order_type="limit", limit_price=price, **kw)
def limit_sell(self, account_id, symbol, qty, price, **kw):
return self.place_order(account_id, symbol, "SELL", qty, order_type="limit", limit_price=price, **kw)
def twap(self, account_id, symbol, side, qty, minutes, leverage=None, min_price=None, max_price=None, **kw):
"""Work `qty` into the market over `minutes`. Returns the account state; the TWAP appears in twapOrders."""
return self.place_order(account_id, symbol, side, qty, order_type="twap", leverage=leverage,
twapMs=int(minutes * 60_000), twapMin=min_price, twapMax=max_price, **kw)
def set_risk(self, account_id, position_id, sl=None, tp=None):
"""Set or clear stop-loss / take-profit on an open position (None leaves it unchanged, pass 0 to clear)."""
body = {}
if sl is not None: body["sl"] = None if sl == 0 else sl
if tp is not None: body["tp"] = None if tp == 0 else tp
return self._req("PATCH", f"/accounts/{account_id}/positions/{position_id}", body)
def cancel_order(self, account_id, order_id):
return self._req("DELETE", f"/accounts/{account_id}/orders/{order_id}")
def cancel_all_orders(self, account_id):
"""Cancel every resting order and TWAP. Cancelling one rung of a scale ladder
cancels its siblings, so a 404 on a later rung is expected and skipped."""
out = []
for o in self.get_open_orders(account_id):
try:
if o.get("orderType") == "twap":
out.append(self.cancel_twap(account_id, o["id"]))
else:
out.append(self.cancel_order(account_id, o["id"]))
except PropDAOError as e:
if e.status != 404:
raise
return out
def cancel_twap(self, account_id, twap_id):
return self._req("DELETE", f"/accounts/{account_id}/twaps/{twap_id}")
def close_position(self, account_id, position_id, percent=1.0):
return self._req("POST", f"/accounts/{account_id}/positions/{position_id}/close", {"percent": percent})
def close_all(self, account_id):
return [self.close_position(account_id, p["id"]) for p in self.get_open_positions(account_id)]Quick start 5 MIN
Trading in five lines:
from propdao import PropDAO
pd = PropDAO() # reads PROPDAO_API_KEY
acct = pd.get_accounts()[0]["account_id"] # first account you own
risk = pd.get_risk(acct) # ALWAYS before sizing
print(f"room to breach: ${risk['roomUsd']:.2f} ({risk['floorKind']} floor)")
r = pd.market_buy(acct, "BTCUSDC", 0.01, leverage=2, sl=60000)
print(r["status"]) # MARKET BUY executed @ 64210.50 (BTCUSDC, 2x). Fee -$0.10.API reference REFERENCE
Constructor
pd = PropDAO(
api_key="pd_live_...", # or PROPDAO_API_KEY env var
base_url="https://app.propdao.finance/api/v1", # default
timeout=15, # seconds
)Read
| Method | Auth | Returns |
|---|---|---|
pd.health() | No | {status, services, time} |
pd.markets() | No | {fees, data:[{symbol, maxLeverage, lotStep…}], total} |
pd.challenges() | No | List of account sizes, prices and rules |
pd.me() | Yes | {userId, loginKind, apiKeyId} |
pd.get_accounts() | Yes | List of accounts you own |
pd.get_account(acct) | Yes | Full live state incl. the whole trade history — heavy. Prefer get_risk / get_open_positions in loops |
pd.get_risk(acct) | Yes | {equity, floor, floorKind, roomUsd, roomPct, breached…} |
pd.get_open_positions(acct) | Yes | Open positions at live marks (up to 500) |
pd.get_open_orders(acct) | Yes | Resting orders and live TWAPs (up to 500) |
pd.get_trades(acct, limit=100, offset=0) | Yes | Closed trades, newest first |
Trade
| Method | Description |
|---|---|
pd.place_order(acct, symbol, side, qty, order_type="market", leverage=None, limit_price=None, trigger_price=None, sl=None, tp=None, intent_id=None, **extra) | Any order type. extra passes twap*/scale* fields |
pd.market_buy(acct, symbol, qty, **kw) | Market long |
pd.market_sell(acct, symbol, qty, **kw) | Market short. Against an open long this opens a hedge leg, not a close — pass reduceOnly=True or use close_position |
pd.limit_buy(acct, symbol, qty, price, **kw) | Resting limit buy |
pd.limit_sell(acct, symbol, qty, price, **kw) | Resting limit sell |
pd.twap(acct, symbol, side, qty, minutes, leverage=None, min_price=None, max_price=None) | Work size in over time. Min $100 notional |
pd.set_risk(acct, position_id, sl=None, tp=None) | Set SL/TP by price. 0 clears |
pd.close_position(acct, position_id, percent=1.0) | Close all or part at mark |
pd.close_all(acct) | Flatten every position |
pd.cancel_order(acct, order_id) | Cancel a resting order |
pd.cancel_twap(acct, twap_id) | Stop a running TWAP |
pd.cancel_all_orders(acct) | Cancel every resting order and TWAP. Ladder siblings already gone (404) are skipped |
Order keyword arguments
| Argument | Notes |
|---|---|
leverage | Default 1. Clamped to the symbol cap (2x / 1.5x / 1x) |
sl, tp | Stop-loss / take-profit price. Accepted on market and limit only; other types: set on the position afterwards with set_risk |
tif | "gtc" (default) · "ioc" (market only) · "alo" post-only, rejected if it would cross |
reduceOnly | Market: routed to a partial close of the opposing position. Resting orders: stored, not enforced yet |
intent_id | Idempotency key; auto-generated if omitted |
twapMs, twapMin, twapMax, twapTrigger, twapRandomize, twapMaxSlippagePct | TWAP controls |
scaleStart, scaleEnd, scaleCount, scaleDist | Scale-ladder controls |
Recipes EXAMPLES
Risk-sized entry
Size a position so the stop-loss risks a quarter of your room:
from propdao import PropDAO pd = PropDAO(); acct = pd.get_accounts()[0]["account_id"] SYMBOL, STOP_PCT, RISK_FRACTION = "ETHUSDC", 0.02, 0.25 risk = pd.get_risk(acct) mkt = next(m for m in pd.markets()["data"] if m["symbol"] == SYMBOL) px = next((p["mark"] for p in pd.get_open_positions(acct) if p["symbol"] == SYMBOL), None) or 2600.0 budget = risk["roomUsd"] * RISK_FRACTION # dollars we are willing to lose on this trade qty = budget / (px * STOP_PCT) # units such that a 2% adverse move loses `budget` qty = round(qty / mkt["lotStep"]) * mkt["lotStep"] r = pd.market_buy(acct, SYMBOL, qty, leverage=min(2, mkt["maxLeverage"]), sl=px * (1 - STOP_PCT)) print(r["status"])
Grid bot
Five limit buys below and five limit sells above a reference price. Remember the engine never nets: a filled sell above opens a short leg beside your long rather than reducing it, so a grid here is a hedged grid. Use close_position when you want to actually take size off.
from propdao import PropDAO
pd = PropDAO(); acct = pd.get_accounts()[0]["account_id"]
SYMBOL, MID, STEP, QTY = "BTCUSDC", 81000.0, 500.0, 0.001
for i in range(1, 6):
pd.limit_buy(acct, SYMBOL, QTY, MID - STEP * i, leverage=2)
pd.limit_sell(acct, SYMBOL, QTY, MID + STEP * i, leverage=2)
print(len(pd.get_open_orders(acct)), "orders resting")DCA bot
Buy a fixed dollar amount on a schedule, but only while there is room. From examples/dca_bot.py:
"""DCA bot: buy a fixed dollar amount of BTC every N minutes, but only while
the account has room. Stops itself when room gets thin.
PROPDAO_API_KEY=pd_live_... python dca_bot.py
"""
import os
import time
from propdao import PropDAO
ACCOUNT = os.environ.get("PROPDAO_ACCOUNT") # optional; defaults to first account
SYMBOL = "BTCUSDC"
USD_PER_BUY = 200
EVERY_MIN = 60
MIN_ROOM_USD = 500 # stop buying below this
pd = PropDAO()
acct = ACCOUNT or pd.get_accounts()[0]["account_id"]
lot = next(m["lotStep"] for m in pd.markets()["data"] if m["symbol"] == SYMBOL)
while True:
risk = pd.get_risk(acct)
if risk["breached"]:
print("account breached, stopping"); break
if risk["roomUsd"] < MIN_ROOM_USD:
print(f"room ${risk['roomUsd']:.0f} < ${MIN_ROOM_USD}, skipping this round")
else:
# price from the position mark if we have one, else from the latest trade
state = pd.get_account(acct)
px = next((p["mark"] for p in state.get("openPositions", []) if p["symbol"] == SYMBOL and p.get("mark")), None)
qty = round((USD_PER_BUY / px) / lot) * lot if px else lot
r = pd.market_buy(acct, SYMBOL, qty, leverage=1)
print(r.get("status"))
time.sleep(EVERY_MIN * 60)Risk guard
Run beside any strategy: flatten before the platform does it for you. From examples/risk_guard.py:
"""Risk guard: run alongside any strategy. Every few seconds, if room to
breach drops below a floor you choose, flatten everything and cancel all
working orders — before the platform does it for you at the floor.
PROPDAO_API_KEY=pd_live_... python risk_guard.py
"""
import os
import time
from propdao import PropDAO
ACCOUNT = os.environ.get("PROPDAO_ACCOUNT")
MIN_ROOM_PCT = 1.0 # flatten when < 1% of equity remains above the floor
POLL_S = 3
pd = PropDAO()
acct = ACCOUNT or pd.get_accounts()[0]["account_id"]
while True:
r = pd.get_risk(acct)
print(f"equity {r['equity']:.2f} floor {r['floor']:.2f} room {r['roomUsd']:.2f} ({r['roomPct']:.2f}%)")
if r["openPositions"] and r["roomPct"] < MIN_ROOM_PCT:
print("room too thin — flattening")
pd.cancel_all_orders(acct)
pd.close_all(acct)
time.sleep(POLL_S)Stop-loss + take-profit
Attach both at entry, or set them later by price:
r = pd.market_buy(acct, "ETHUSDC", 0.1, leverage=2, sl=2500, tp=2900) # ...or adjust on the open position pos = pd.get_open_positions(acct)[0] pd.set_risk(acct, pos["id"], sl=2550) # move the stop up pd.set_risk(acct, pos["id"], tp=0) # 0 clears the take-profit
TWAP
# work 0.5 BTC in over 30 minutes, skipping slices above 82,000 state = pd.twap(acct, "BTCUSDC", "BUY", 0.5, minutes=30, leverage=2, max_price=82000) twap_id = state["twapOrders"][-1]["id"] # ...later pd.cancel_twap(acct, twap_id)
Portfolio monitor
pd = PropDAO(); acct = pd.get_accounts()[0]["account_id"]
r = pd.get_risk(acct)
print(f"equity ${r['equity']:,.2f} floor ${r['floor']:,.2f} room ${r['roomUsd']:,.2f} ({r['roomPct']:.2f}%)")
print(f"{'Symbol':<10}{'Side':<6}{'Qty':>10}{'Entry':>12}{'Mark':>12}{'uPnL':>10}")
for p in pd.get_open_positions(acct):
print(f"{p['symbol']:<10}{p['side']:<6}{p['qty']:>10}{p['entry']:>12,.2f}{p['mark']:>12,.2f}{p['unrealizedPnl']:>10,.2f}")LLM agent
Claude gets the SDK as tools and decides. From examples/llm_agent.py:
"""An LLM trading agent in ~60 lines: Claude gets the PropDAO SDK as tools,
checks its room to breach, and decides. Run it on a schedule (cron, every
15 minutes) rather than in a tight loop — the model, not a timer, sets the pace.
pip install propdao anthropic
PROPDAO_API_KEY=pd_live_... ANTHROPIC_API_KEY=sk-ant-... python llm_agent.py
"""
import json
import os
import anthropic
from anthropic import beta_tool
from propdao import PropDAO
pd = PropDAO()
ACCT = os.environ.get("PROPDAO_ACCOUNT") or pd.get_accounts()[0]["account_id"]
@beta_tool
def get_risk() -> str:
"""Equity, drawdown floor and roomUsd (dollars that can be lost before the account is closed). Check before every trade."""
return json.dumps(pd.get_risk(ACCT))
@beta_tool
def get_positions() -> str:
"""Open positions with entry, qty, side, leverage, sl, tp."""
return json.dumps(pd.get_open_positions(ACCT))
@beta_tool
def place_order(symbol: str, side: str, qty: float, leverage: float = 1, sl: float | None = None, tp: float | None = None) -> str:
"""Market order. symbol like BTCUSDC; side BUY or SELL; qty in units of the asset.
Args:
symbol: e.g. BTCUSDC, ETHUSDC
side: BUY or SELL
qty: units of the asset, not dollars
leverage: 1-5 depending on symbol
sl: stop-loss price
tp: take-profit price
"""
r = pd.place_order(ACCT, symbol, side, qty, leverage=leverage, sl=sl, tp=tp)
return r.get("status", "ok")
@beta_tool
def close_position(position_id: str) -> str:
"""Close an open position at market."""
return json.dumps(pd.close_position(ACCT, position_id).get("status", "ok"))
client = anthropic.Anthropic()
runner = client.beta.messages.tool_runner(
model="claude-opus-5",
max_tokens=16000,
system=(
"You manage one PropDAO prop-firm account. The account is closed permanently the moment "
"equity touches the drawdown floor. Rules: always call get_risk first; never risk more than "
"a quarter of roomUsd on any single position's stop; prefer doing nothing over a marginal trade. "
"Finish with one line: what you did and why."
),
tools=[get_risk, get_positions, place_order, close_position],
messages=[{"role": "user", "content": "Review the account and act if there is a clear, well-sized opportunity in BTC or ETH."}],
)
final = runner.until_done()
for block in final.content:
if block.type == "text":
print(block.text)Error handling REFERENCE
Every failure raises PropDAOError with .status and .message. Status 0 means no response was received — the SDK already attached an intentId, so retrying is safe. Reads that hit 429 are retried once after Retry-After before surfacing; writes are not.
from propdao import PropDAO, PropDAOError
try:
pd.market_buy(acct, "BTCUSDC", 0.01, leverage=2)
except PropDAOError as e:
if e.status == 429:
time.sleep(2) # rate limited
elif e.status == 403 and "breached" in e.message:
raise SystemExit("account is over")
elif e.status == 0:
pass # network blip - retry with the same intent_id
elif e.status == 400 and e.message.startswith("Hold positions"):
time.sleep(1) # 1 s min hold / 0.5 s between executions
else:
print("API error", e.status, e.message)| Status | Meaning | Action |
|---|---|---|
| 400 | Bad input, or Hold positions 1s · max 1 execution every 0.5s | Read message; for the timing error wait and resend the same intentId |
| 401 | Invalid or revoked key | Check PROPDAO_API_KEY |
| 403 | Not your account / breached | Stop trading that account |
| 404 | Order or position not found | Refresh state |
| 409 | Same intentId still executing | SDK waits and retries for you |
| 429 | Rate limited | Back off; 300 reads / 60 orders per min |
| 0 | Network error, no response | Retry — the intentId makes it safe |
| 5xx | Engine error | Retry reads; check state before retrying an order |