Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# oms-client

Python client for the OMS trading API. Takes a trading token and nothing else — it
never touches the admin surface.

```bash
pip install -e clients/python # add [pandas] for .to_pandas()
```

## Getting a token

Trading tokens are minted by an admin, once, and shown once:

```bash
curl -X POST "$OMS_URL/admin/trading-tokens" \
-H "Authorization: Bearer $OMS_ADMIN_PASSWORD" \
-H 'Content-Type: application/json' \
-d '{"principal_id":"<uuid>","portfolio_id":"<uuid>","label":"my-laptop"}'
```

The `token` field of the response is what this client wants. It carries the
principal's grants (`can_trade` / `can_view` / `can_allocate`) on the portfolios it
was granted, and can do nothing else.

## Library

```python
from oms_client import OMS

oms = OMS("http://localhost:3001", token=os.environ["OMS_TRADING_TOKEN"])

pf = oms.portfolios()[0] # what am I allowed to trade?
oid = oms.submit(portfolio=pf.portfolio_id,
symbol="SPY260918C00770000@OPRA",
side="buy", quantity=1)
order = oms.wait_for(oid) # polls until terminal
print(order.status, order.avg_px)

for row in oms.orders(status="routed"): # the blotter
print(row.order_id, row.instrument_symbol, row.cum_qty)

oms.orders().to_pandas() # needs the [pandas] extra
```

Naming an instrument works three ways: `symbol="SPY260918C00770000@OPRA"`,
`symbol=... , venue=...`, or `instrument_id="2972271"`. A bare symbol resolves only
when it is unique across venues — many equity tickers are listed under several
exchange MICs, and the server answers 422 naming the candidates.

## CLI

```bash
export OMS_URL=http://localhost:3001
export OMS_TRADING_TOKEN=ak_....sk_....

oms portfolios
oms orders list --status routed --limit 20
oms orders get <order_id>
oms orders cancel <order_id>
oms submit --portfolio <id> --symbol SPY260918C00770000@OPRA --side buy --qty 1 --wait
oms positions <portfolio_id>
```

Add `--json` to any command to get raw JSON for piping into `jq`.

## Behaviour worth knowing

These follow from the server's contract, not from choices made here:

- **`submit` returns only an order id.** The API answers 204 with an empty body, so
the client generates the id. Call `order()` or `wait_for()` to see the outcome.
- **Retrying a submit is safe.** `order_id` is the idempotency key, so a repeat is a
409 — which this client treats as "already accepted" rather than an error. Never
resubmit under a *new* id after a failure; a 502 means the OMS kept the order and
only the broker leg failed.
- **`cancel` can be asynchronous.** It returns `"canceled"` when the OMS cancelled it
outright, or `"pending"` when the request went to the broker and will be confirmed
later. `"pending"` does not mean cancelled.
- **`wait_for` polls.** The OMS has no SSE or WebSocket channel for order events.
- **Errors are plain text**, mapped to typed exceptions (`Rejected`, `Forbidden`,
`BrokerRejected`, …), all subclasses of `OMSError`, each carrying `.status` and
`.message`.

## Tests

```bash
pip install -e "clients/python[dev]" && pytest clients/python
```

No server needed — the transport is stubbed.
185 changes: 185 additions & 0 deletions clients/python/examples/smoke_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""End-to-end walkthrough of the OMS Python client against a running OMS.

Exercises every method on the client, then sends one real order and cancels it.

The order is a **limit buy priced well below the market**, on purpose. A market
order fills immediately and cannot be cancelled, which would make the cancel half of
this script untestable; a resting bid sits in the book until we take it back. Crypto
is the default target because it trades 24/7 — the Alpaca options path only accepts
market orders during US market hours, so it cannot be exercised at 4am.

Orders route to Binance **testnet** when the broker connection's environment is
PAPER, so nothing here touches real money. Note the OMS deliberately marks crypto
against *production* prices even though it routes to testnet, so a mark and a fill
price can legitimately disagree.

Usage:
export OMS_URL=http://localhost:3001
export OMS_TRADING_TOKEN=ak_....sk_....

python examples/smoke_test.py # BTCUSDT@BINANCE, ~12 USDT bid
python examples/smoke_test.py --dry-run # everything except submit/cancel
python examples/smoke_test.py --symbol ETHUSDT@BINANCE --notional 20
python examples/smoke_test.py --portfolio <uuid> # skip auto-selection
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.request

from oms_client import OMS, OMSError, Rejected

# Where to ask what a coin is worth. Testnet, not production: the order rests in
# testnet's book, so testnet's price is the one that decides whether it rests.
TESTNET_TICKER = "https://testnet.binance.vision/api/v3/ticker/price?symbol={}"

# How far below the market to place the bid. Binance's PERCENT_PRICE_BY_SIDE filter
# rejects a bid below half the average price, so this stays comfortably inside that
# while being far enough out that nothing crosses it during the run.
DISCOUNT = 0.80


def step(n: int, title: str) -> None:
print(f"\n\033[1m[{n}] {title}\033[0m")


def reference_price(symbol_at_venue: str) -> float:
"""Current testnet price for the bare symbol part of SYMBOL@VENUE."""
symbol = symbol_at_venue.split("@")[0]
with urllib.request.urlopen(TESTNET_TICKER.format(symbol), timeout=10) as resp:
return float(json.load(resp)["price"])


def pick_portfolio(oms: OMS, explicit: str | None):
portfolios = oms.portfolios()
if not portfolios:
sys.exit("error: this token has no portfolio grants — ask an admin for one")
for p in portfolios:
print(f" {p.code:24} trade={p.can_trade} view={p.can_view} alloc={p.can_allocate}")
if explicit:
match = [p for p in portfolios if p.portfolio_id == explicit or p.code == explicit]
if not match:
sys.exit(f"error: {explicit} is not a portfolio this token can see")
return match[0]
tradeable = [p for p in portfolios if p.can_trade]
if not tradeable:
sys.exit("error: this token can view portfolios but not trade any (needs can_trade)")
# Prefer a crypto-looking portfolio, since the default symbol is crypto.
return next((p for p in tradeable if "crypto" in p.code.lower()), tradeable[0])


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--url", default=os.environ.get("OMS_URL", "http://localhost:3001"))
ap.add_argument("--symbol", default="BTCUSDT@BINANCE", help="SYMBOL@VENUE")
ap.add_argument("--portfolio", help="portfolio id or code (default: first tradeable)")
ap.add_argument("--notional", type=float, default=12.0, help="order size in quote ccy")
ap.add_argument("--price", type=float, help="explicit limit price (skips the lookup)")
ap.add_argument("--dry-run", action="store_true", help="read-only; no order is sent")
args = ap.parse_args()

token = os.environ.get("OMS_TRADING_TOKEN")
if not token:
sys.exit("error: set OMS_TRADING_TOKEN (mint one via POST /admin/trading-tokens)")

oms = OMS(args.url, token)

step(1, "Portfolios this token may act on")
portfolio = pick_portfolio(oms, args.portfolio)
print(f" -> using {portfolio.code} ({portfolio.portfolio_id})")

step(2, "Current positions")
positions = oms.positions(portfolio.portfolio_id)
if not positions:
print(" (none)")
for p in positions:
mark = "-" if p.mark is None else f"{p.mark:,.2f}"
print(f" instrument {p.instrument_id:>10} qty {p.net_qty:<12g} mark {mark}")

step(3, "Blotter — most recent orders in these portfolios")
recent = oms.orders(limit=5)
if not recent:
print(" (none)")
for r in recent:
print(f" {r.order_id[:8]} {str(r.instrument_symbol):22} {r.side:4} "
f"{r.status:16} {r.cum_qty:g}/{r.original_qty:g}")

step(4, "Error handling — an ambiguous symbol must be refused, legibly")
try:
oms.submit(portfolio=portfolio.portfolio_id, symbol="AAAU", side="buy", quantity=1)
print(" !! expected a rejection and did not get one")
except Rejected as err:
print(f" -> Rejected: {err.message}")
except OMSError as err:
# A token without an Alpaca-backed account cannot reach the ambiguity check;
# that is fine, the point is that it refused rather than guessed a venue.
print(f" -> refused ({err.status}): {err.message}")

# ── the live half ────────────────────────────────────────────────────────

price = args.price or round(reference_price(args.symbol) * DISCOUNT, 2)
qty = round(args.notional / price, 5)
step(5, f"Submit a resting limit buy: {qty:g} {args.symbol} @ {price:,.2f}")
print(f" (market is ~{price / DISCOUNT:,.2f}; this bid sits {(1 - DISCOUNT) * 100:.0f}% "
f"below it so it rests instead of filling)")
if args.dry_run:
print(" --dry-run: stopping before anything is sent")
return 0

try:
order_id = oms.submit(
portfolio=portfolio.portfolio_id,
symbol=args.symbol,
side="buy",
quantity=qty,
order_type="limit",
limit_price=price,
time_in_force="gtc",
client_order_id="sdk-smoke-test",
)
except OMSError as err:
sys.exit(f"error: submit refused ({err.status}): {err.message}")
print(f" -> order_id {order_id}")

step(6, "Read it back")
order = oms.order(order_id)
print(f" status={order.status} leaves={order.leaves_qty:g} "
f"cum={order.cum_qty:g} version={order.version}")

step(7, "Idempotency — resubmitting the same order_id is a no-op, not a duplicate")
again = oms.submit(
portfolio=portfolio.portfolio_id, symbol=args.symbol, side="buy", quantity=qty,
order_type="limit", limit_price=price, time_in_force="gtc", order_id=order_id,
)
print(f" -> returned the same id, no exception: {again == order_id}")

step(8, "Confirm it shows on the blotter")
mine = [r for r in oms.orders(limit=20) if r.order_id == order_id]
print(f" -> found: {bool(mine)}" + (f" status={mine[0].status}" if mine else ""))

step(9, "Cancel it")
outcome = oms.cancel(order_id, reason="smoke test")
print(f" -> {outcome}" + (" (forwarded to broker; confirmed asynchronously)"
if outcome == "pending" else " (cancelled locally)"))

step(10, "Wait for a terminal state")
try:
final = oms.wait_for(order_id, timeout=30, interval=1.0)
print(f" -> {final.status} (filled {final.cum_qty:g} of {final.original_qty:g})")
if final.status != "canceled":
print(f" !! expected 'canceled' — the resting bid may have been crossed")
except TimeoutError as err:
print(f" !! {err}")
print(" the cancel is still in flight; re-run `oms orders get` to follow it")

print("\n\033[1mdone\033[0m — every client method exercised against a live OMS")
return 0


if __name__ == "__main__":
sys.exit(main())
10 changes: 10 additions & 0 deletions clients/python/oms_client.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Metadata-Version: 2.4
Name: oms-client
Version: 0.1.0
Summary: Python client for the OMS trading API
Requires-Python: >=3.9
Requires-Dist: requests>=2.31.0
Provides-Extra: pandas
Requires-Dist: pandas>=2.0; extra == "pandas"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
14 changes: 14 additions & 0 deletions clients/python/oms_client.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
README.md
pyproject.toml
oms_client/__init__.py
oms_client/cli.py
oms_client/client.py
oms_client/errors.py
oms_client/models.py
oms_client.egg-info/PKG-INFO
oms_client.egg-info/SOURCES.txt
oms_client.egg-info/dependency_links.txt
oms_client.egg-info/entry_points.txt
oms_client.egg-info/requires.txt
oms_client.egg-info/top_level.txt
tests/test_client.py
1 change: 1 addition & 0 deletions clients/python/oms_client.egg-info/dependency_links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

2 changes: 2 additions & 0 deletions clients/python/oms_client.egg-info/entry_points.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[console_scripts]
oms = oms_client.cli:main
7 changes: 7 additions & 0 deletions clients/python/oms_client.egg-info/requires.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
requests>=2.31.0

[dev]
pytest>=8.0

[pandas]
pandas>=2.0
1 change: 1 addition & 0 deletions clients/python/oms_client.egg-info/top_level.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oms_client
46 changes: 46 additions & 0 deletions clients/python/oms_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Python client for the OMS trading API.

from oms_client import OMS

oms = OMS("http://localhost:3001", token=os.environ["OMS_TRADING_TOKEN"])
oid = oms.submit(portfolio=pf, symbol="SPY260918C00770000@OPRA",
side="buy", quantity=1)
print(oms.wait_for(oid).status)

Needs only a trading token (`key_id.secret`, minted by an admin via
`POST /admin/trading-tokens`). No admin credential is used anywhere in this package.
"""

from .client import OMS
from .errors import (
AlreadyExists,
AuthError,
BadRequest,
BrokerRejected,
Forbidden,
NotFound,
OMSError,
Rejected,
Unavailable,
)
from .models import Allocation, BlotterRow, Order, Portfolio, Position

__version__ = "0.1.0"

__all__ = [
"OMS",
"OMSError",
"AuthError",
"Forbidden",
"NotFound",
"AlreadyExists",
"Rejected",
"BrokerRejected",
"Unavailable",
"BadRequest",
"Order",
"BlotterRow",
"Portfolio",
"Position",
"Allocation",
]
Loading
Loading