An open source multi-client order management system.
git clone git@github.com:maxkuttner/openoms.git && cd openoms
docker compose up -d # a Postgres to run against; skip if you already have one
cargo run -- init # prompts for your Postgres, generates oms.toml, creates the database
cargo run # start the OMS on localhost:3001The bundled docker-compose.yml runs Postgres 16 on 127.0.0.1:5432 with the
superuser postgres / postgres, which is exactly what init assumes — so with it
running you can press Enter through every prompt and type postgres for the
password. It deliberately does not pre-create the ods database: init creates
that itself and refuses if it already exists. Data lives in a named volume, so
docker compose down keeps it and docker compose down -v throws it away.
Then, in a second terminal:
cd cockpit && npm install && npm run dev # admin console on localhost:5173init asks for your Postgres host, port, database name, superuser name and
password — pressing Enter through every prompt targets a default local Postgres
(localhost:5432, superuser postgres, database ods). A passed --host,
--port, etc. seeds what Enter takes instead of being ignored, and a passed
--password skips that prompt entirely. init then generates the oms role
password, an encryption master key and a cockpit login password, writes them to
oms.toml (mode 0600, and appends the filename to .gitignore if one already
exists in the current directory — it does not create a .gitignore), and creates
the role, database, schemas, migrations, grants and reference data. The cockpit
password is written to oms.toml either way; interactively it is also printed
once at the end, as the only mode where a human is there to read it.
Run it once. If oms.toml already exists, init refuses and tells you to use
database migrate to upgrade, or to delete the file to start over — but deleting it
throws away the master key, and with it anything it decrypts. If provisioning
itself fails partway (after oms.toml was written), fix the cause and run oms database init --resume — it creates only whichever of the role/database is
still missing (the common case is the role existing but CREATE DATABASE
having failed) and finishes migrations, grants and seeding, all of which are
safe to re-run.
The instrument catalog starts empty. Put broker credentials in .env and it fills
itself on the next boot; see Loading instruments. Create
portfolios, accounts and trading identities in the cockpit.
| Step | What happens |
|---|---|
init |
Prompts for the Postgres connection, generates the oms role password, the master key and a cockpit login password, writes oms.toml (mode 0600), then creates the role, database, schemas, migrations, grants and reference data. --non-interactive takes the connection from flags/env instead of prompting. The cockpit password is written to oms.toml in both modes; --non-interactive just doesn't also print it. |
cargo run |
Starts the server. It never creates or migrates anything — if the database is missing or stale, it says so and names the command to run. |
- Rust (stable) —
curl https://sh.rustup.rs -sSf \| sh - cmake and a C++ compiler — the embedded FIX engine (
quickfix) is C++ - OpenSSL 3 on macOS —
brew install openssl@3(Linux uses the system one) - PostgreSQL — either Docker, for the bundled
docker-compose.yml, or an existing server with a superuser you know the password of - Node — only if you want the cockpit web UI
# macOS, using the bundled Postgres
brew install cmake openssl@3 node
# macOS, bringing your own Postgres instead
brew install cmake openssl@3 node postgresql@16Everything lives in oms.toml, generated by oms init. Flags and environment
variables override it, in that order — that is how Docker and CI inject settings
without touching the file:
cargo run -- database init --host db.internal --username admin
POSTGRES_HOST=db.internal cargo run -- database init| Setting | oms.toml |
Flag | Environment |
|---|---|---|---|
| Host | database.host |
--host |
POSTGRES_HOST |
| Port | database.port |
--port |
POSTGRES_PORT |
| Superuser name | database.username |
--username |
POSTGRES_USERNAME |
| Superuser password | (never stored) | --password |
POSTGRES_PASSWORD |
| Database | database.database |
--database |
POSTGRES_DATABASE |
oms role password |
oms.password |
--oms-password |
OMS_PASSWORD |
| Master key | oms.master_key |
— | — |
| Bind address | server.bind_addr |
— | OMS_BIND_ADDR |
| Cockpit password | server.admin_password |
— | OMS_ADMIN_PASSWORD |
Anything left unset at every tier falls back to a built-in default — localhost,
5432, postgres/postgres, ods, and openoms-dev for both the oms role and
cockpit passwords (loopback only; a still-default password is refused against
anything else).
There is one application role, oms. It owns the database, both schemas and every
table in them, and it is what the server connects as.
Back up oms.toml. The master key in it is the only thing that can decrypt
stored credentials — losing the file loses them. The superuser password is
deliberately not in it: oms init prompts for it once, to provision the database,
and never writes it down. database init, database migrate and database drop
each still take it the same way as before, via --password/POSTGRES_PASSWORD.
database status no longer needs it — it authenticates as the oms role instead,
falling back to the superuser whenever the oms role connection fails: the server
has never been initialized, or an install from before this fallback existed has an
oms role password this command has no way to reconstruct.
.env is an override file, not a prerequisite — copy .env.example when you need
broker credentials, a real admin password, or a non-local database.
One role, oms, created by database init. It owns the database, both schemas and
everything in them, and it is what the server connects as — so there is exactly one
password to set. The superuser only creates and destroys it.
role oms
schema public instrument, instrument_derivative, broker_instrument,
venue, currency, calendar, calendar_holiday
schema oms orders, portfolios, principals, accounts, api_keys, …
The split is for consumers, not permissions: public holds master data another
service can point at, oms holds this application's operational tables.
oms init runs database init for you on a fresh machine. These are the
subcommands underneath it, and what you use directly afterwards — for CI,
non-interactive provisioning, or to inspect and maintain a database you already have:
cargo run -- database init # create everything; fails if it already exists
cargo run -- database init --resume # finish an init that failed partway through
cargo run -- database migrate # apply pending migrations (idempotent)
cargo run -- database status # what exists, what is pending
cargo run -- database drop # destroy the database (roles are kept)init is deliberately strict. If the roles or database already exist it stops and
tells you to run migrate instead, rather than silently skipping steps or resetting
credentials on a database that already holds data. The one exception is --resume:
if a previous init created the role and/or database but failed before finishing
migrations, grants or seeding, oms database init --resume creates only whichever
of the role/database is still missing — never touching the credentials of one that
already exists — and re-runs the rest, every step of which is safe to re-run. Because
it skips the wrong-server refusal, double-check --host/--database before passing
it: pointed at the wrong database, resume's migrations (some of which are DROP …)
would run there instead.
init, migrate and drop connect as the superuser (--password/
POSTGRES_PASSWORD, defaulting to postgres on loopback) because they create,
alter or destroy the role and the database itself. status is read-only and
connects as the oms role instead, so it needs no superuser credential in the
normal case; it falls back to the superuser whenever that connection fails — most
commonly because the server has never been initialized, but also for an install
whose oms role password predates this fallback and isn't recorded anywhere
status can read it.
Upgrading an existing install is git pull && cargo run -- database migrate.
The instrument catalog comes from brokers, not from a bundled list. There are two ways in, and they run the same code.
Automatic. With broker credentials in .env, an empty catalog is populated in
the background on boot. This is the normal path after database init — start the
server, and instruments appear. The sync can take minutes for full option chains.
# OMS_SYNC_ON_BOOT=never # opt out entirely
# OMS_SYNC_UNDERLYINGS=SPY,QQQ # only these option chains, instead of allExplicit, when you want to re-sync or see what would change:
cargo run -- setup sync-broker --broker alpaca
cargo run -- setup sync-broker --broker alpaca --underlyings SPY,QQQ
cargo run -- setup sync-broker --broker alpaca --dry-runTwo ways in.
Cockpit (localhost:5173) — configuration, monitoring, minting tokens.
Python — for actually sending orders:
pip install -e clients/pythonfrom oms_client import OMS
oms = OMS("http://localhost:3001", token=os.environ["OMS_TRADING_TOKEN"])
pf = oms.portfolios()[0]
oid = oms.submit(portfolio=pf.portfolio_id,
symbol="SPY260918C00770000@OPRA",
side="buy", quantity=1)
print(oms.wait_for(oid).status)
for row in oms.orders(status="routed"):
print(row.order_id, row.instrument_symbol, row.cum_qty)There is a CLI too — oms orders list, oms positions, oms submit. See
clients/python/README.md.
- Cockpit login — one password,
OMS_ADMIN_PASSWORD(enable withOMS_ADMIN_AUTH_ENABLED=true). Sent as a bearer to/admin. Use a strong random value for anything real. - Trading tokens — minted on the cockpit's Trading tokens page or via
POST /admin/trading-tokens, shown once. A token belongs to a principal (a trader, strategy or service); what it may trade comes from that principal's portfolio grants (can_trade/can_view/can_allocate). Mint several tokens under one principal to rotate credentials without re-permissioning. Revoke anytime. - Trading tokens can reach only the trading routes. They can never touch
/admin.
| Symptom | Cause |
|---|---|
refusing to start: OMS_ADMIN_PASSWORD is not set |
OMS_BIND_ADDR is not loopback. Set a real admin password in .env. |
password authentication failed for user "oms" |
OMS_PASSWORD doesn't match the role. Fix the value, or ALTER ROLE oms PASSWORD '…'. |
role "oms" already exists on init |
Something is already provisioned. Use migrate, or drop first. |
| Server exits naming a migration | Run cargo run -- database migrate. |
Link error mentioning -lssl on macOS |
brew install openssl@3, or set OPENSSL_DIR. |

