FAQ & Common Mistakes¶
Common questions and gotchas when learning EduMatcher. For the first checks to make when something does not work at all, start with When Something Does Not Work.
Architecture & Setup¶
The engine started but gateways can't connect. What's wrong?¶
Check that:
1. The engine is running before the gateways try to connect.
2. ZMQ ports 5555 and 5556 are not blocked by a firewall or in use by another
process: lsof -i :5555 -i :5556
3. The participant ID exists in the deployed configuration (pm-config-show
lists them).
4. On the container route, the console runs inside the exchange
(./edumatcher.sh shell). The message bus is not published to your own
machine unless you set EM_ZMQ=1.
If the engine is down or unreachable, pm-alf-console exits after about three
seconds with Gateway authentication timed out.
The book already has liquidity before I submit any orders. Why?¶
If your engine_config.yaml has a market_maker_quotes block for the symbol,
the engine injects a two-sided seed quote on behalf of the configured market-maker
gateway at startup. This is intentional — it gives the book immediate liquidity
so participants always have something to trade against.
A side effect is that your first aggressive order may fill immediately against the seed quote, before a second participant has typed anything.
To run with a deliberately empty book (e.g. for a demo of price discovery from
scratch), either:
- deploy one of the -nomm example configurations, such as s3-basic-nomm
(pm-setup --config s3-basic-nomm --force, or
./edumatcher.sh config s3-basic-nomm on the container route), or
- generate your own with pm-config-gen --no-mm-seed-quotes.
On the container route, also note that the default mm-demo process profile
runs a market-maker bot that quotes every symbol; set EM_PROFILE=default in
.env to run without it.
Why did the seed MM quote disappear after the second day?¶
By default, market_maker_quotes entries have seed_once: true. This means the
quote is only injected on the first startup for a symbol (detected by the
absence of a book_stats.json entry). From the second day onward the real market
maker is expected to submit its own quotes.
To inject the seed on every startup (e.g. for a repeatable classroom demo):
market_maker_quotes:
- gateway_id: MM01
bid_price: 149.00
ask_price: 151.00
bid_qty: 500
ask_qty: 500
tif: DAY
seed_once: false # inject on every startup
To force a day-1 re-injection without changing the config:
Why is the engine not matching orders? It starts in CLOSED state¶
With sessions_enabled: true (the default), the engine starts in CLOSED state
and waits for pm-scheduler to drive it through the session phases before it
accepts orders.
Three ways to get to a matching state quickly:
- Start
pm-scheduleralongside the engine — it drives the transition from CLOSED → PRE_OPEN → OPENING_AUCTION → CONTINUOUS automatically at the configured wall-clock times, or immediately withpm-scheduler --now. - Move the phase by hand from the operator console:
pm-admin --id OPS01, thenSESSION|STATE=PRE_OPENandSESSION|STATE=CONTINUOUS. See the Quick Start's Run a Trading Session. - Disable sessions for testing or demos:
With
sessions_enabled: falsethe engine enters CONTINUOUS state immediately on startup andpm-schedulerhas no effect.
Why is my viewer empty even though it started correctly?¶
An empty viewer usually means the symbol has no resting orders yet. pm-viewer
subscribes to book.<SYMBOL> and then requests an initial snapshot, but if the
book is empty the snapshot is also empty.
Fix: Submit a resting order, enable market-maker quote seeds for that symbol, or verify that you are watching a configured symbol.
Why did pm-scheduler exit immediately?¶
Because no configuration is deployed. The scheduler exits with a fatal error rather than falling back to its built-in default times — running a timetable the engine has never seen is worse than not starting at all.
Do I really need a terminal window for every process?¶
No. Each process is an independent program communicating over ZeroMQ, and each
blocks on its own event loop, so started by hand each needs its own terminal.
But you rarely need to start them by hand: pm-opctl-cli start starts a whole
named profile of processes in the background, in the right order, and
pm-opctl-cli list shows their health. The container does exactly this for
you. Open terminals only for what you interact with: trader consoles, the
operator console and viewers. tmux or screen help when you do want many
side by side.
The source checkout also has tools/launch_all.sh, which opens every process
in its own macOS Terminal window. It uses osascript, so it only works on
macOS.
How do I see what messages are flowing between processes?¶
Run pm-audit --terminal in a separate window. It subscribes to all
ZeroMQ messages and prints them to stdout, giving a full real-time trace of
every event in the system.
Why does the engine use integer ticks instead of float prices?¶
Float arithmetic can introduce tiny representation errors in comparisons and aggregation. Using integer ticks makes price-time matching and level math exact.
How do I reset all persisted state to start fresh?¶
Stop the exchange, then let pm-opctl-cli clear the data directory:
pm-opctl-cli stop
pm-opctl-cli clear --state # order books, GTC orders, stats, index and clearing state
pm-opctl-cli clear --all # ...and the logs and audit files as well
The deployed configuration under ref_data/ is never touched. On the
container route, run the same commands with ./edumatcher.sh shell while the
exchange is running, then ./edumatcher.sh restart.
After a reset the engine re-seeds market-maker quotes as if it were day 1
(because book_stats.json no longer exists).
Orders & Execution¶
Why was my IOC order rejected?¶
IOC (Immediate-Or-Cancel) orders cannot rest on the book, so they are
rejected during any non-matching phase (PRE_OPEN, OPENING_AUCTION,
CLOSING_AUCTION, CLOSED). IOC is only valid during CONTINUOUS trading.
[09:20:11.403] REJECTED 6f5471b08c2d3eb970dc8c864bf270cb code=SESSION_NOT_PERMITTED IOC orders not accepted during PRE_OPEN
The same applies to MARKET and FOK orders.
Fix: Type SESSION in the trader console to see the current phase, then
wait for CONTINUOUS, or use a LIMIT order with TIF=DAY if you are
willing to have the order rest.
Why didn't my STOP order trigger?¶
STOP orders only trigger from last trade price events. If no trades have occurred since you submitted the STOP, the trigger is never evaluated — regardless of the current bid/ask quotes.
# This SELL STOP at 148 won't fire if no trades have happened yet
NEW|SYM=AAPL|SIDE=SELL|TYPE=STOP|QTY=100|STOP=148.00
Fix: Check whether any trades have printed in the engine output (--verbose
mode) or in the audit log. A stop only becomes relevant once the market is
actively trading through its trigger level.
Also check the trigger direction. A SELL STOP triggers when last_trade_price <= stop_price.
If the market is above your stop, it won't fire until price falls to or through it.
See Stop Trigger Logic for full details.
Why did my STOP_LIMIT order trigger but not fill?¶
This is expected behavior. When a STOP_LIMIT triggers, it converts to a
LIMIT order at your specified PRICE=. If the market has moved past that
limit price before the converted order can match, it sits on the book unfilled.
Example: SELL STOP_LIMIT with STOP=148.00 and PRICE=147.50.
If the market gaps down to $146.00 before the converted limit can fill,
your order rests at $147.50 — but nobody is bidding that high. You are
protected from filling at $146.00, but you may not fill at all.
Tradeoff: STOP gives fill certainty. STOP_LIMIT gives price certainty. Neither gives both.
My FOK order was rejected even though there was liquidity. Why?¶
FOK checks whether the entire quantity can be filled at the limit price before executing. It does not execute at all unless the full size is available.
# Book has only 80 shares at 150.00, but you want 100
NEW|SYM=AAPL|SIDE=BUY|TYPE=FOK|QTY=100|PRICE=150.00
# → REJECTED because 80 < 100
Fix: Reduce QTY to match available liquidity, or use a LIMIT order
and accept a partial fill.
Why is my LIMIT order filling at a different price than I specified?¶
Your limit price is the worst price you'll accept — not necessarily the price you'll get. If better prices are available, you fill at those better prices.
Example: You submit BUY LIMIT QTY=100 PRICE=151.00 but the book has asks
at 149.90 and 150.20. You fill at 149.90 and 150.20 (both better than your
limit of 151.00), not at 151.00.
This is called price improvement — you get a better deal than the worst price you were willing to accept.
I submitted a MARKET order but it only partially filled. Where did the rest go?¶
A MARKET order sweeps the book until it runs out of liquidity. If the book does not have enough resting orders to fill the entire quantity, the unfilled remainder is discarded (not rested). You will receive one or more partial fill events, then a cancellation for the remaining quantity.
Fix: If you need guaranteed full execution at whatever price, use FOK and ensure liquidity is available. If you're comfortable with partial fills, MARKET is correct — just understand the remainder is discarded, not queued.
P&L & Position¶
Why is my unrealized P&L different from my realized P&L?¶
They measure different things:
- Unrealized P&L: paper gain/loss on shares you still hold, calculated as
(current_price - avg_cost) × position. This changes every time a trade prints and updates the last trade price. - Realized P&L: profit/loss locked in when you closed part of your position (sold shares you were long, or bought back a short). This never changes after the trade.
Until you close your position, your gains are "on paper" — the market can take them back. Once realized, they are yours regardless of subsequent price moves.
Why does my average cost change when I buy more shares?¶
When you add to an existing position, your VWAP average cost updates to reflect the new shares at the new price. This is standard practice for tracking the true cost basis of a position built over multiple trades.
Example:
Buy 100 shares @ 150.00 → avg_cost = 150.00
Buy 100 shares @ 152.00 → avg_cost = (150 × 100 + 152 × 100) / 200 = 151.00
See P&L & Clearing — VWAP Average Cost.
Why do I have a negative position?¶
A negative position means you are short — you have sold shares you did not own. This can happen if you submit a SELL order without first having bought any shares. In a real brokerage this requires a margin account and stock borrowing; EduMatcher does not enforce these constraints, so short positions are allowed freely.
Sessions & Orders¶
What happens to my open orders when the market closes?¶
It depends on their time-in-force:
| TIF | At market close |
|---|---|
DAY |
Expired and removed. You receive order.expired events. |
GTC |
Saved to data/gtc_orders.json and reloaded on next engine start. |
ATO |
Expired when OPENING_AUCTION phase ends. |
ATC |
Expired when CLOSING_AUCTION phase ends. |
My GTC orders didn't reload after restart. Why?¶
GTC orders are written to data/gtc_orders.json on engine shutdown (when
you press Ctrl-C or the scheduler sends a CLOSED transition). If the engine was
killed forcefully (kill -9 or a crash), the file was not written.
Fix: Check whether data/gtc_orders.json exists and has recent content.
If the engine was killed mid-session, the file may be stale or missing.
Why is my order rejected with "Gateway not authenticated"?¶
Your gateway ID is not in the engine's allowlist. Gateway IDs must be
pre-configured in engine_config.yaml under participants:
Deploy the configuration (pm-config-deploy) and restart the engine after
editing it.
Self-Match Prevention¶
What is SMP and why did my order get cancelled?¶
Self Match Prevention (SMP) stops you from accidentally trading against your own resting orders. If you submit a buy that would match a sell you already have on the same book, SMP kicks in.
# Submit a buy that would cross with your own resting sell
NEW|SYM=AAPL|SIDE=BUY|TYPE=LIMIT|QTY=100|PRICE=151.00|SMP=CANCEL_AGGRESSOR
With SMP=CANCEL_AGGRESSOR, the incoming buy is cancelled instead of
filling against your own sell. Options:
| Value | Behaviour |
|---|---|
NONE |
No SMP — you can trade against yourself |
CANCEL_AGGRESSOR |
The incoming order is cancelled |
CANCEL_RESTING |
The resting order is cancelled |
CANCEL_BOTH |
Both orders are cancelled |
Installation & Setup¶
Do I need Poetry or pipx?¶
It depends on your role:
| Role | Tool | Install command |
|---|---|---|
| Student / instructor (just want to run the exchange) | pipx | pipx install edumatcher |
| Developer (modifying the engine, running tests) | Poetry | poetry install --with dev |
End users should not need a source checkout at all. After pipx install edumatcher
all pm-* commands are on your PATH and ready to use.
I installed with pipx but pm-engine is not found. What do I do?¶
pipx installs scripts to ~/.local/bin, which may not be on your PATH yet.
pipx ensurepath # adds ~/.local/bin to your shell profile
exec $SHELL # reload your current shell
which pm-engine # should now print a path
If you just installed pipx itself for the first time, open a new terminal
window after running pipx ensurepath — the PATH change only takes effect
in new shells.
What does pm-setup do and do I have to run it?¶
pm-setup is a one-time bootstrap command for installed (pipx) users:
- Creates the data directory (
~/.local/share/edumatcherby default). - Compiles a bundled example configuration —
s10-basicunless you choose another with--config— and deploys it to<data directory>/ref_data/engine_config.json, keeping the YAML source beside it. - Prints the
export EDUMATCHER_DATA_DIR=...line to add to~/.zshrcor~/.bashrc.
Without a deployed configuration no process will start, so you need either
pm-setup or your own pm-config-deploy. An already-deployed configuration
is kept unless you pass --force. The container installation runs the
equivalent for you.
The engine says it cannot find engine_config.yaml. Where should the file be?¶
In exactly one place, always:
| Mode | Data directory |
|---|---|
| pipx installed | ~/.local/share/edumatcher |
| Source checkout | <repo>/src/data/ |
| Either | EDUMATCHER_DATA_DIR, if set |
The message means nothing has been deployed there yet. Install a configuration and the error goes away:
pm-config-deploy ~/my-session/engine_config.yaml
pm-config-deploy --show # print the path it deployed to
There is no --config flag and no EDUMATCHER_CONFIG variable. Both used to
exist, and the reason they no longer do is that they let one process be
started against a configuration the rest of the exchange had never seen — the
resulting symptom (a gateway reporting no instruments while the engine happily
traded ten) gave no hint of the cause.
Where are my data files stored?¶
Data files (gtc_orders.json, stats.db, audit.log, etc.) go to:
| Mode | Data directory |
|---|---|
| pipx installed | ~/.local/share/edumatcher |
| Source checkout | <repo>/src/data/ |
| Either | EDUMATCHER_DATA_DIR environment variable (highest priority) |
To isolate two sessions from each other, run each in its own directory and set
EDUMATCHER_DATA_DIR to a different path:
How do I upgrade EduMatcher to a newer version?¶
pipx upgrade edumatcher # Python package, from PyPI
./edumatcher.sh update # container installation, latest release
Your configuration and data files are not touched by the upgrade. If the new
version changes the configuration format, redeploy your configuration with
pm-config-deploy (or, for a bundled example, pm-setup --config <name>
--force). Do not run pm-setup --force otherwise: it replaces the deployed
configuration with a bundled example.