Closing the Day: Settlement, Statements and Housekeeping¶
Learning objectives
After reading this page you will understand:
- What settlement is, in plain words, and why an exchange does it at the end of every trading day
- The money words used here — position, closing price, profit and loss, balance — each explained from scratch with numbers
- What
pm-eoddoes after the market closes, step by step, and what it deliberately does not do - How to set it up: the roster file, the scheduler hook, and running it without a scheduler
- How to read the statements and files it writes, and how to send them to your traders
- What the daily housekeeping (audit archive, log compression, backups) does, and how to restore a backup
- How to fix a day that went wrong, and what every warning and error message means
You do not need any finance background. Every financial word is explained the first time it appears, and again in the mini-glossary below. If you can follow a shopping receipt, you can follow this page.
Prerequisites: you can start the exchange and run a trading day
(Running the Exchange), and
you have read how sessions work
(Sessions and Scheduling). The
clearing process pm-clearing must be running during the day — it is part
of the default profile.
The big picture¶
At the end of a trading day two jobs are waiting for the operator.
- Money. During the day traders bought and sold shares. Some made a profit, some made a loss. Somebody has to add it all up, work out who should pay how much and who should receive how much, and tell everyone. That job is called settlement.
- Housekeeping. The exchange has produced a lot of files — an audit trail, logs, databases. Old files should be compressed to save disk space, and important databases should be copied somewhere safe in case a disk fails. That is routine maintenance.
pm-eod ("end of day") does both, automatically, right after the market
closes. It is a one-shot command: it starts, does its work in a few
seconds, prints what it did and exits. It is not a long-running process.
The money is fictional
Everything in this chapter uses EduMatcher's fictional currency. No
real money moves anywhere. pm-eod produces numbers and files:
"Bob owes the exchange 20.00". What those numbers mean in your class —
points, pretend dollars, a prize for the top of the leaderboard — is up to
you. If you ever do attach real value to them, that is entirely outside
EduMatcher; read What pm-eod does not do.
What happens, in order¶
flowchart TD
A["Trading day ends:<br/>the engine moves to CLOSED"] --> B["pm-clearing sees CLOSED,<br/>saves every position at the closing price<br/>(the 'close snapshot')"]
A --> C["pm-scheduler sees the engine confirm CLOSED<br/>and waits 30 seconds"]
C --> D["pm-scheduler starts<br/>pm-eod run --pending"]
B -. "ready by now" .-> D
D --> E["1. gate: is the day really closed<br/>and do the books agree?"]
E --> F["2. settle: work out what each<br/>trader owes or is owed"]
F --> G["3. report: write the statements"]
G --> H["4. audit: compress old audit files"]
H --> I["5. logs: compress old log files"]
I --> J["6. backup: copy the databases"]
| Step | Plain-words purpose | Can it fail the day? |
|---|---|---|
| gate | Check it is safe to settle: the day is closed, the closing prices were saved, and the books add up | Yes — nothing is written if it fails |
| settle | Calculate each trader's amount for the day and add it to the running balance | Yes — nothing is written if it fails |
| report | Write one statement per trader and one summary for the instructor | Yes — but the settlement is kept and the report is retried next time |
| audit | Safety net: compress any older audit-trail days that were missed | No — a warning only |
| logs | Compress and tidy log files that nobody is writing to any more | No — a warning only |
| backup | Make a verified, compressed copy of the important databases | No — a warning only |
The rule behind the last three rows: housekeeping trouble must never stop traders from getting their statements. If the backup disk is full, you get a warning, and the statements are still written.
What pm-eod does not do¶
- It does not send email. It writes files, including a ready-made table of names, email addresses and amounts, and you send them (see Sending the statements).
- It does not move money, real or pretend. It records numbers.
- It does not stop anyone trading. A trader whose balance is deeply negative can keep trading tomorrow. The balance is information, shown on the statement and the leaderboard.
- It does not charge fees. Settlement is purely the profit or loss from trading.
- It does not touch the engine's own end-of-day files. The engine keeps its
own archive in
<data>/eod/.pm-eodwrites to a different place,<data>/settlement/, on purpose. (See Persistence.)
The ideas, from scratch¶
This section builds the vocabulary one word at a time. Each idea uses the same tiny example, and the next section runs it through several days. Skip it only if you already know what mark-to-market means.
Shares, symbols and trades¶
A share is a small slice of ownership in a company. Each company on the
exchange has a short code, its symbol — in our examples AAPL.
A trade happens when one person agrees to buy and another agrees to sell, at the same price, for some quantity (how many shares).
Example. Alice buys 100
AAPLfrom Bob at a price of 10.00 each. Alice is the buyer, Bob is the seller. The trade is worth 100 × 10.00 = 1000.00 in total (the notional value).
In EduMatcher every trader connects through a gateway, and the gateway has
an identifier like GW_ALICE. The exchange never needs to know the trader's
real name — the system sees gateway identifiers. The
roster file is how you connect the two.
Positions: long and short¶
Your position in a symbol is how many shares you currently hold.
- If you own shares, you are long. After her purchase Alice is long 100.
- You can also sell shares you do not own (in the real world by borrowing them and promising to buy them back later; in EduMatcher the trader's position simply goes negative). Then you are short. After the sale Bob is short 100, shown as −100.
- If you hold nothing you are flat (position 0).
Someone who is long makes money when the price goes up. Someone who is short makes money when the price goes down (they sold high and hope to buy back low). That is the single most important fact for the rest of this page.
Average cost¶
If you buy in several steps at different prices, your average cost is the average price you paid, weighted by quantity. Buy 100 at 10.00 and then 50 at 10.20, and your average cost is (100 × 10.00 + 50 × 10.20) ÷ 150 = 10.0667. The accounting term is VWAP (volume-weighted average price). Average cost is what your profit is measured against. For the full mechanics, including what happens when you flip from long to short, see Positions and P&L.
The closing price and "marking to market"¶
What is your 100 shares worth right now? You cannot know until you sell them, but you can estimate with the latest price the market agreed on. At the end of the day that is the closing price: in EduMatcher, the price of the last trade of the day in that symbol.
Valuing what you hold at the latest market price, rather than at what you paid, is called marking to market, and the price used is the mark (or mark price). It is the same idea as checking your car's current resale value rather than what you paid for it.
Unrealized and realized profit and loss¶
P&L means profit and loss: the money made or lost.
- Unrealized P&L is the profit or loss on paper, on a position you still hold. Alice paid 10.00 and the closing price is 10.50, so her 100 shares have gained 100 × (10.50 − 10.00) = +50.00 on paper. She has not sold, so it is not locked in; tomorrow the price may fall and the gain shrinks or turns into a loss.
- Realized P&L is profit or loss that is locked in because you closed (sold, for a long position) part of the position. If Alice later sells her 100 at 10.30, she realizes 100 × (10.30 − 10.00) = +30.00, and that is final.
For a short position the sign flips. Bob is short 100 from 10.00 and the price rose to 10.50, so he has lost: −100 × (10.50 − 10.00) = −50.00 on paper.
Equity¶
Add the two together and you get one number for "how well am I doing in total
on this symbol": realized + unrealized P&L. This page calls it equity
(EduMatcher's tables call the parts realized_pnl and unrealized_pnl).
Equity does not care how you gained — banked profit and paper profit count
the same — which is exactly why it is the right number to settle.
Settlement¶
Settlement is the act of turning "who gained and who lost" into "who pays and who receives". Real exchanges do this every day for fast-moving products, because letting a large loss pile up unpaid for weeks is dangerous. EduMatcher does the same:
Each day, each trader settles the change in their equity since the previous close.
On day 1 the "previous close" is nothing (everyone starts at zero). On day 2 it is the end of day 1, and so on. Settling changes means a gain is paid exactly once. If Alice's equity was +50.00 yesterday and +20.00 today, today's settlement is −30.00: she gave back 30.00 of her paper gain.
Why settle paper profits at all?
It might feel strange to pay out money for a gain that has not been locked in. The reason is fairness and simplicity: every day each trader's account is brought up to date with the market, so nobody can hide a growing loss in an open position. Futures exchanges in the real world work this way (it is called daily mark-to-market settlement). And because each day settles only the change, it all adds up: once a trader closes everything, their total balance equals their locked-in profit, as the worked example below shows.
The central pool (who pays whom)¶
A natural question: "if Alice gains 50.00, who pays her?" The honest answer is that Alice did not trade with a single, fixed opponent all day — she may have traded with many people, and the exchange matches anonymously. So EduMatcher does not try to invent a "Bob pays Alice" payment. Instead everyone settles with the exchange itself, which acts as a central counterparty — the single middleman that every trader deals with. In this chapter we call that the central pool:
- A trader with a gain is owed that amount by the pool: "the exchange owes you 50.00".
- A trader with a loss owes the pool that amount: "you owe the exchange 50.00".
Adding netting to this: each trader gets one single net amount per day for all their trades together (not one payment per trade).
Because every trade has exactly one buyer and one seller, every unit of money one
trader gains is a unit another loses. The sum of everyone's amounts is therefore
exactly zero. Systems with this property are called zero-sum. Zero-sum
is the safety check at the heart of pm-eod: if the amounts do not add up to
zero, something is wrong with the data and pm-eod refuses to write anything.
Balance and opening balance¶
A trader's balance is the running total of all their daily amounts. It starts at the trader's opening balance — zero unless you give them a starting amount in the roster — and each settled day adds that day's amount (positive or negative). A negative balance means "this trader is net down overall". It is only information; it does not block trading.
Currency, minor units and rounding¶
Money amounts are shown with two decimals (12.34). Inside the program they
are stored as whole numbers of the smallest unit — the minor unit, the
cent or penny: 12.34 is stored as 1234. Whole numbers cannot drift the way
decimals can ("0.1 + 0.2 = 0.30000000000000004" is a classic computer trap).
Real P&L has more precision than two decimals, so each amount must be
rounded. Rounding each trader independently could leave the total off by a
cent. Example: three traders have exact amounts of +0.333, +0.333 and −0.666.
Rounded one by one: +0.33, +0.33, −0.67 → total −0.01, not zero. pm-eod
instead rounds so that the rounded amounts still add up (the largest
remainder method: round everyone down, then hand the leftover cents to the
amounts that were rounded down the most). Here the result is +0.33, +0.33,
−0.66 → total 0.00. If a tiny leftover truly cannot be placed (because of
floating-point noise of a fraction of a cent), it is booked to a special
account called EXCHANGE as a rounding line, so that the books always sum to
exactly zero.
The trading date¶
Each trading day belongs to a trading date — the calendar date in the
exchange's own timezone (locale.session_timezone in the
configuration; default
Europe/Stockholm). It is not the date on the server's clock and not the UTC
date. Why it matters: a market that runs into the late evening can straddle
midnight in UTC. All the exchange's daily figures — statistics, clearing,
settlement — use this one definition so they always agree.
Other words used on this page¶
| Word | Meaning |
|---|---|
| Ledger | A permanent, append-only record of money movements, like a bank statement. Here: the eod.db database. |
| Snapshot | A frozen copy of something at one moment. The close snapshot is every trader's position saved at the closing price. |
| Statement | A page sent to one trader summarising their day, like a bank statement. |
| Roster | The list that connects gateway identifiers to people (name, email). |
| Leaderboard | Traders ranked by balance, best first. |
| Gate | A checkpoint that must pass before work continues. |
| Idempotent | Safe to repeat: running it twice gives the same result as running it once. pm-eod is idempotent. |
| Audit trail | The exchange's complete, timestamped record of every message (Audit Trail). |
| Retention | How long old files are kept before they are deleted. |
| Compression | Storing a file in less space (here with the zstd format; typically 10× smaller for these files). |
| Backup | A second copy kept in case the original is lost or damaged. |
| Integrity check | The database's own self-test that none of its pages are corrupt. |
| Exit code | The number a command returns to the shell: 0 means "all well". |
A complete worked example¶
Three traders: Alice (GW_ALICE), Bob (GW_BOB) and Carol (GW_CAROL).
Alice starts with an opening balance of 100.00; the others start at 0.
The symbol is AAPL.
Day 1 — Monday 2026-11-02¶
Alice buys 100 AAPL from Bob at 10.00. It is the only trade of the day, so
it is also the last one: the closing price is 10.00.
| Trader | Position at close | Average cost | Unrealized P&L at 10.00 | Equity today | Equity yesterday | Settlement |
|---|---|---|---|---|---|---|
| Alice | +100 | 10.00 | 100 × (10.00 − 10.00) = 0.00 | 0.00 | 0 | 0.00 |
| Bob | −100 | 10.00 | −100 × (10.00 − 10.00) = 0.00 | 0.00 | 0 | 0.00 |
| Carol | 0 | – | – | 0 | 0 | 0.00 |
Zero-sum check: 0.00 + 0.00 + 0.00 = 0.00. ✔
Nobody gained or lost: a trade on its own is not a profit. Alice paid 10.00 for shares that are worth 10.00 at the close. What matters is what the price does next.
Running balances: Alice 100.00 (her opening balance), Bob 0.00, Carol 0.00.
Day 2 — Tuesday 2026-11-03¶
Carol buys 50 AAPL from Bob at 10.20. That is the last trade of the day, so
the closing price is 10.20: the price has risen by 0.20.
| Trader | Position at close | Average cost | Unrealized P&L at 10.20 | Equity today | Equity yesterday | Settlement |
|---|---|---|---|---|---|---|
| Alice | +100 | 10.00 | 100 × (10.20 − 10.00) = +20.00 | +20.00 | 0.00 | +20.00 (exchange owes her) |
| Bob | −150 | 10.0667 | −150 × (10.20 − 10.0667) = −20.00 | −20.00 | 0.00 | −20.00 (he owes the exchange) |
| Carol | +50 | 10.20 | 50 × (10.20 − 10.20) = 0.00 | 0.00 | 0 | 0.00 |
Zero-sum check: +20.00 − 20.00 + 0.00 = 0.00. ✔
Alice did not trade today, yet she is owed 20.00: she holds 100 shares and their price rose (that is marking to market). Bob is short, so the same rise costs him. His average sale price is (100 × 10.00 + 50 × 10.20) ÷ 150 = 10.0667, and with 150 shares short at a closing price of 10.20 he is 20.00 down on paper. Carol traded but her settlement is zero, because she bought at the closing price itself. Settlement follows the change in value, not trading activity.
Running balances: Alice 100.00 + 20.00 = 120.00, Bob −20.00, Carol
0.00. These are exactly the numbers pm-eod balances printed when this
example was run (see below).
Day 3 — Wednesday 2026-11-04 (Alice cashes in)¶
Alice sells all her 100 AAPL to Carol at 10.30, the last trade of the day,
so the closing price is 10.30.
| Trader | Position at close | Average cost | P&L at 10.30 | Equity today | Equity yesterday | Settlement |
|---|---|---|---|---|---|---|
| Alice | 0 | – | realized 100 × (10.30 − 10.00) = +30.00 | +30.00 | +20.00 | +10.00 |
| Bob | −150 | 10.0667 | −150 × (10.30 − 10.0667) = −35.00 | −35.00 | −20.00 | −15.00 |
| Carol | +150 | 10.2667 | 150 × (10.30 − 10.2667) = +5.00 | +5.00 | 0.00 | +5.00 |
Zero-sum check: +10.00 − 15.00 + 5.00 = 0.00. ✔ Running balances: Alice 130.00, Bob −35.00, Carol 5.00. (Carol's average cost is (50 × 10.20 + 100 × 10.30) ÷ 150 = 10.2667.)
Alice is flat now, so she has no unrealized P&L left; her profit is realized. Look at her amounts over the three days: 0.00, +20.00, +10.00 = +30.00, which is exactly her locked-in profit. This is the reassuring property of settling changes: once you are flat, the sum of everything settled equals the profit you actually made. (Her 130.00 balance is that 30.00 plus the 100.00 she started with.)
The same example, run for real¶
This is the actual output of pm-eod for days 1 and 2 above (with a roster
giving Alice an opening balance of 100.00), run after each day's close:
2026-11-02:
[ok] gate: closed, snapshot present, reconciled
[ok] settle: settled
[ok] report: wrote /srv/exchange/data/settlement/2026-11-02
[ok] audit: nothing pending
[ok] logs: no log directory
[ok] backup: backed up clearing.db, eod.db (132.0 KB -> 3.3 KB); not present: stats.db
2026-11-03:
[ok] gate: closed, snapshot present, reconciled
[ok] settle: settled
[ok] report: wrote /srv/exchange/data/settlement/2026-11-03
[ok] audit: nothing pending
[ok] logs: no log directory
[ok] backup: backed up clearing.db, eod.db (132.0 KB -> 4.1 KB); not present: stats.db
Running day 3 as well gives 130.00, −35.00 and 5.00, as in the table above.
The "not present: stats.db" part is normal in this demonstration, which ran
without pm-stats; on a real exchange stats.db is backed up too.
Setting it up¶
What must be running¶
| Requirement | Why |
|---|---|
pm-clearing running all day |
It writes the close snapshot pm-eod settles from. It is part of the default profile of pm-opctl-cli. |
Sessions enabled (sessions_enabled: true) and pm-scheduler --daily |
The scheduler drives the market to CLOSED, which is what triggers pm-eod. Without it see Running without the scheduler. |
pm-eod installed |
It is installed with the other pm-* commands. If you run from a source checkout and updated it, run poetry install once so the new command exists. |
Not in the mini profile
The mini profile has no pm-clearing, so there is nothing to settle and
its scheduler is deliberately started without the hook.
The roster file¶
The roster tells pm-eod who is behind each gateway. It is optional: with
no roster every statement is addressed to the bare gateway identifier. But a
roster makes the statements readable and gives you the email addresses for
sending them.
Create ref_data/roster.yaml inside your data directory (next to
engine_config.yaml):
traders:
- gateway: GW_ALICE
name: Alice Andersson
email: alice@example.org
opening_balance: 100.00
- gateway: GW_BOB
name: Bob Berg
email: bob@example.org
- gateway: GW_CAROL
name: Carol Carlsson
email: carol@example.org
| Field | Required | Meaning |
|---|---|---|
gateway |
Yes | The gateway identifier exactly as the trader logs in with it. Letters are upper-cased automatically (gw_alice becomes GW_ALICE). Must be unique. May not be EXCHANGE, which is reserved. |
name |
No | Printed on the statement. Defaults to the gateway identifier. |
email |
No | Printed on the statement and written to instructions.csv. Never used to send anything. |
opening_balance |
No (default 0) | The trader's starting balance in the fictional currency, a plain number with at most two decimals. Negative numbers are allowed. |
What happens in the corner cases:
- A trader in the roster who never traded still gets a statement every day (amount 0.00, balance = opening balance), so everybody enrolled receives something every day.
- A gateway that traded but is not in the roster still gets a statement,
addressed to the gateway identifier and marked "This gateway is not in the
roster." The run and the summary also list a warning
(
gateway GW_X is not in the roster), so you notice and add them. - A mistake in the file stops the report step with a message that names the
line, for example
roster.yaml: line 7: opening_balance has more than 2 decimals. The settlement itself is kept (the day's status isreport-failed); fix the file and runpm-eod run --date <date>again — without--only, so that the day is recorded asok(a run with--onlyis never recorded). The nextpm-eod run --pendingdoes the same by itself.
Opening balances are applied when displaying, not when settling
The ledger stores only each day's amounts. Opening balances come from the
roster every time a balance is shown. If you change an opening_balance
later, every balance shown from then on changes — but statements already
written keep the old numbers until you regenerate them with
pm-eod run --date <date> --only report.
Starting the hook¶
The simplest way is to let the scheduler start pm-eod for you after each
close:
# checkdocs: ignore (the quoted command has its own flags)
pm-scheduler --daily --on-close "pm-eod run --pending"
This is already in the default profile of pm-opctl-cli, so
pm-opctl-cli start does it for you. What happens at each close:
- The scheduler moves the engine to
CLOSEDand waits for the engine to confirm. - It waits 30 seconds, then starts the command. The pause gives
pm-clearingtime to save the close snapshot. Change it with--on-close-delay SECONDS. - The command runs in the background. The scheduler does not wait for it and goes on with its timetable.
- Its output appears in the scheduler's log, and the exit code is logged
(
--on-close command exited 1is a warning that something failed).
Also:
- The command runs once more when the scheduler starts. That is how a day that closed while the scheduler was down gets settled.
- Only one
pm-eodruns at a time. A trigger that finds one still running is skipped (with a warning); that is safe because--pendingalways picks up every date that is waiting. - In
--nowtest mode the hook is ignored. --on-closetakes any command, so you may add your own mailing script, for example--on-close "sh /srv/exchange/close.sh"where the script runspm-eod run --pendingand then sends the mail.
Running without the scheduler¶
If sessions_enabled is false (an always-open market) nothing ever "closes",
so no event can trigger pm-eod, and you decide when a "day" is over:
- Stop the engine gracefully at the end of your session (
Ctrl-CorSIGTERM, notkill -9). On a graceful shutdown the engine publishes its end-of-day message;pm-clearingthen marks every position at the last price and saves the close snapshot. - Settle that date yourself, telling
pm-eodto skip the "has the day closed?" test with--force:
--force skips only the closed-day test. The other checks (a close snapshot
exists; the books reconcile) still apply. The snapshot comes from pm-clearing
only on the end-of-day message or on CLOSED, so a market that is simply
left running has nothing to settle from.
A crontab line that settles yesterday every morning at 06:00, for an exchange you shut down every weekday evening, looks like this:
2-6 means Tuesday to Saturday, so it settles Monday to Friday. Run it on a
morning after a day without trading and it fails with no close snapshot for
<date> (exit code 1): harmless, but cron mails you about it.
If you skip a day (you forgot to run it, or its run failed and you moved on), nothing is lost: the next date you settle counts from the close of the last date that was settled, so it covers both days in one amount.
Stopping the engine more than once a day
Each graceful shutdown replaces that date's snapshot, so if you stop the engine at lunch and again in the evening, the evening one is settled and the afternoon's trading counts for that day. (On an exchange with a scheduler, the snapshot taken at the real market close is final: a later shutdown or restart that day never replaces it.)
Where everything lives¶
Everything is under the data directory (EDUMATCHER_DATA_DIR, see
Installation). Pass
--datapath DIR to pm-eod to use another one.
| Path (under the data directory) | What it is | Who writes it |
|---|---|---|
clearing.db |
Trades, positions and the close snapshots (the input) | pm-clearing |
stats.db |
Market statistics — only used to fill the "Market" table of the summary | pm-stats |
ref_data/roster.yaml |
Who is behind which gateway | You |
eod.db |
The settlement ledger: each day's amounts and the log of runs | pm-eod |
settlement/<date>/ |
The day's statements and summary | pm-eod |
backup/<date>/ |
Compressed copies of the databases | pm-eod |
audit/, audit.log |
The audit trail (day parts and .zst archives) |
pm-audit |
logs/ |
Fallback log files (written when the log server is unreachable) | every pm-* process |
eod/<date>/ |
The engine's own end-of-day files — not touched by pm-eod |
pm-engine |
.pm-eod.lock |
Stops two pm-eod runs overlapping |
pm-eod |
A normal day¶
Nothing for you to do. For example, with the market closing at 17:30:
| Time | What happens |
|---|---|
| 17:30:00 | The engine closes. pm-clearing saves the close snapshot. |
| 17:30:30 | The scheduler starts pm-eod run --pending. |
| 17:30:31 | pm-eod settles, writes the statements, does the housekeeping. |
| a few seconds later | It exits. settlement/<date>/ is ready. |
To check on it:
trade_date | status | started_at | finished_at | steps
-----------+--------+----------------------------------+----------------------------------+-------------------------------------------------------
2026-11-03 | ok | 2026-11-03T16:30:30.007000+00:00 | 2026-11-03T16:30:30.014000+00:00 | gate:ok settle:ok report:ok audit:ok logs:ok backup:ok
2026-11-02 | ok | 2026-11-02T16:30:30.007000+00:00 | 2026-11-02T16:30:30.014000+00:00 | gate:ok settle:ok report:ok audit:ok logs:ok backup:ok
The times are in UTC: 16:30:30 UTC is 17:30:30 in Stockholm, thirty seconds after the close.
The status column is one of:
status |
Meaning |
|---|---|
ok |
Settled and reported. (Housekeeping warnings do not change this; they are listed in steps.) |
report-failed |
Settled, but the statements could not be written. The money is safe in the ledger; the next pm-eod run --pending writes the report. |
failed |
The gate or the settlement failed. Nothing was recorded for the day. |
pm-eod status also prints pending: <dates> (on the error stream) when there
are closed days that have not been done yet.
For monitoring, pm-eod status -q prints nothing and returns exit code 0
when no closed day is waiting to be done, and 1 otherwise. A closed day whose
run failed still counts as waiting, so the check stays red until it is fixed. (A
failed run for a date that never closed, such as a mistyped date, does not.) Use
it from a health check or a cron alert.
The balances¶
Useful variations (note that --format is a global option and goes before the
word balances):
pm-eod --format json balances --gateway gw_alice # one trader, as JSON
pm-eod balances --date 2026-11-02 # as they stood after that day
pm-eod balances --check # verify the books
--check re-adds every settled day and reports any day that does not sum to
zero. Healthy output is OK - every settled date sums to zero.
One day in detail¶
pm-eod show reads one date straight from the ledger (eod.db):
pm-eod show --date 2026-11-02 # every account's amount and per-symbol P&L
pm-eod show --date 2026-11-02 --gateway gw_alice
pm-eod show --date 2026-11-02 --steps # the run's steps with their full messages
trade_date | account | amount | detail
-----------+----------+---------+-----------------------
2026-11-02 | GW_ALICE | 120.00 | ACME=100.00 BOLT=20.00
2026-11-02 | GW_BOB | -20.00 | ACME=-20.00
2026-11-02 | GW_CAROL | -100.00 | ACME=-80.00 BOLT=-20.00
amount is the day's settlement in display-currency units: positive means the
exchange owes the trader, negative means the trader owes the exchange. The
detail column is the same result split by symbol before rounding. The amounts of
one date always add up to zero; a rounding residual, if any, sits on the
EXCHANGE account. With --steps, a date with no recorded run shows no rows.
Exit codes of pm-eod run¶
| Code | Meaning | What to do |
|---|---|---|
| 0 | Clean — everything succeeded | Nothing |
| 1 | The day failed (gate, settle or report) | Read the [FAIL] line; see Troubleshooting |
| 2 | Settled and reported, but a housekeeping step warned | Read the [warn] line; the statements are fine |
The files pm-eod writes¶
For each settled date a folder settlement/<date>/ is created:
settlement/2026-11-03/
├── instructions.csv who pays / receives what (for mail-merge)
├── leaderboard.csv traders ranked by balance
├── summary.md the instructor's one-page summary
├── summary.json the same, machine-readable
└── statements/
├── GW_ALICE.md GW_ALICE.json GW_ALICE.csv
├── GW_BOB.md GW_BOB.json GW_BOB.csv
└── GW_CAROL.md GW_CAROL.json GW_CAROL.csv
The folder is built in a hidden work directory and renamed into place when complete. You never see a half-written day, and re-running a date replaces it cleanly.
The statement (statements/<gateway>.md)¶
This is what a trader reads. Here is Bob's statement for day 2 of the example (see also Your End-of-Day Statement, which explains it line by line to the trader):
# Statement for 2026-11-03
Bob Berg (GW_BOB) <bob@example.org>
**You owe the exchange 20.00.**
- Today's result: -20.00
- Balance after today: -20.00
## Result by symbol
| Symbol | Result |
|---|---:|
| AAPL | -20.00 |
## Open positions at the close
| Symbol | Quantity | Closing price |
|---|---:|---:|
| AAPL | -150 | 10.20 |
## Trades
| Time (UTC) | Symbol | Side | Quantity | Price |
|---|---|---|---:|---:|
| 10:30:01 | AAPL | SELL | 50 | 10.20 |
The headline sentence is always one of three:
| Sentence | When |
|---|---|
| The exchange owes you X. | Today's amount is positive |
| You owe the exchange X. | Today's amount is negative |
| Nothing to pay or receive today. | The amount is zero |
Three details worth knowing. Each line of "Result by symbol" is rounded on
its own, so with several symbols the lines can add up to 0.01 more or less
than "Today's result"; the total is the amount that counts (it is rounded so
that all traders together sum to exactly zero). Trade times are in UTC, not
the exchange's timezone. And the trade list comes from trade_events, which pm-clearing
prunes after 90 days by default. Settling a very old date later still gives the
correct amounts (those come from the close snapshots, which are never pruned)
but the trade list may be empty.
The same statement as JSON and CSV¶
statements/GW_BOB.json carries the same information for programs. Amounts
appear twice: as whole minor units (amount_minor: -2000) for exact
arithmetic, and as text (amount: "-20.00") for display.
| JSON field | Meaning |
|---|---|
trade_date, gateway, name, email |
Who and which day |
listed |
true if the gateway is in the roster |
amount_minor / amount |
Today's settlement (positive = exchange owes the trader) |
balance_minor / balance |
Running balance after today, including the opening balance |
symbol_pnl |
Today's amount broken down by symbol. A symbol the trader no longer holds, did not trade today and made nothing on today is left out (otherwise every symbol ever traded would show 0.00 forever) |
positions |
Open positions at the close: symbol, net_qty, mark (closing price) |
trades |
Today's trades: time_utc, symbol, side (BUY, SELL, or SELF), quantity, price |
statements/GW_BOB.csv has one row per symbol, handy for a spreadsheet:
instructions.csv — the payment instructions¶
One line per trader. This is the file to use for sending the statements or for a classroom "who pays whom" board:
gateway,name,email,amount,direction,balance
GW_ALICE,Alice Andersson,alice@example.org,20.00,receive,120.00
GW_BOB,Bob Berg,bob@example.org,-20.00,pay,-20.00
GW_CAROL,Carol Carlsson,carol@example.org,0.00,none,0.00
| Column | Meaning |
|---|---|
amount |
Today's settlement. Positive = the exchange owes the trader. |
direction |
receive (amount > 0), pay (amount < 0) or none (0) |
balance |
Running balance after today |
The direction column is from the trader's point of view: pay means this
trader pays the exchange.
leaderboard.csv¶
Traders ranked by running balance, highest first. Traders with an equal balance share a rank (and the next rank is skipped, as in sports tables):
rank,gateway,name,balance
1,GW_ALICE,Alice Andersson,120.00
2,GW_CAROL,Carol Carlsson,0.00
3,GW_BOB,Bob Berg,-20.00
The leaderboard includes opening balances
If you gave some traders a head start with opening_balance, the ranking
includes it. For a fair competition give everyone the same opening balance
(or none).
summary.md / summary.json — for the instructor¶
# Exchange summary for 2026-11-03
- Accounts settled: 3
- Sum of all accounts: 0.00
- Rounding booked to the exchange: 0.00
## Biggest winners
- GW_ALICE: 20.00
## Biggest losers
- GW_BOB: -20.00
## Market
Market statistics unavailable.
- Sum of all accounts is the zero-sum proof. It should always read
0.00. - Rounding booked to the exchange is normally
0.00; a non-zero value of a cent or two is the harmless rounding leftover described above. - Market lists volume, number of trades and the closing price per symbol,
taken from
stats.db. Ifpm-statsis not running (as in this demo) the summary says "Market statistics unavailable." - Warnings (for example unlisted gateways) are listed at the end.
Sending the statements¶
pm-eod stops at files, on purpose: sending mail needs credentials and a
decision about wording that belong to you. instructions.csv is the
ready-made input. A minimal script that prints one draft message per trader
(replace the print with smtplib or your mail tool when you are ready):
import csv
import sys
from pathlib import Path
day = sys.argv[1] # for example 2026-11-03
base = Path("settlement") / day
with (base / "instructions.csv").open() as fh:
for row in csv.DictReader(fh):
if not row["email"]:
continue
statement = (base / "statements" / f"{row['gateway']}.md").read_text()
print(f"To: {row['email']}")
print(f"Subject: Exchange statement {day}")
print()
print(statement)
print("-" * 40)
Or simply hand each trader their own statements/<gateway>.md file, or post
leaderboard.csv on the classroom screen.
The housekeeping steps in detail¶
After the money work, pm-eod tidies up. All three steps are safe to
repeat and report problems as warnings.
audit — the audit-trail safety net¶
pm-audit already compresses each audit-trail day into
audit/audit-<date>.log.zst shortly after midnight. The audit step is a
safety net: it compresses any earlier day that was missed (for instance
because pm-audit was stopped over midnight). It never touches today's data.
The message nothing pending is the normal outcome. Note that the day that has
just closed cannot be archived yet — a day is only archivable once it is over
— so it is pm-audit that archives it after midnight.
logs — compressing and tidying the fallback logs¶
When a process cannot reach the log server for 30 seconds it falls back to
writing its own file logs/<name>.log (see
Log Server). These files only
exist after an outage, but they can grow. The step:
- Looks at each
logs/*.logthat has not been written to for at least one hour and is not empty. - Writes its content, compressed, to
<name>.<YYYYMMDD>.log.zst(a counter is added if that name exists). - Checks the compressed copy by decompressing it again.
- Empties the original file in place — it does not delete it. A running
process keeps its log file open; deleting it would silently lose everything
it writes afterwards. If the file changed while being compressed, the step
leaves it alone, throws the copy away and warns
(
1 changed while compressing); the next run tries again. - Deletes compressed logs older than
--log-keep-days(default 14).
backup — verified copies of the databases¶
For clearing.db, stats.db and eod.db — the files you cannot recreate —
the step:
- Asks SQLite for a consistent online copy (SQLite's backup mechanism). This is safe while the exchange is running; you do not have to stop anything.
- Runs the database's integrity check on the copy.
- Compresses it to
backup/<date>/<name>.db.zstand decompresses it again to compare — only a copy proven identical replaces anything. - Deletes backup folders older than
--backup-keep-days(default 7).
A run that catches up several days takes one backup at the end, under the last day's date (see The scheduler was down).
To leave a database out — for example a very large stats.db that you can do
without — use --backup-skip, which may be repeated:
pm-eod run --pending --backup-skip stats.db. The step's message then ends with
skipped by request: stats.db, so a missing backup is never a surprise. Keep
clearing.db and eod.db: they hold the trades and the money record.
If the backup fails, the previous backup of that date is left untouched. A
database that does not exist is simply listed (not present: stats.db).
Compression typically makes these databases many times smaller (the small demonstration above went from 132 KB to 3.3 KB; a real one shrinks less, because real data is less repetitive).
Restoring a backup¶
You need the zstd command (or Python 3.14, which has compression.zstd
built in). Stop the processes that use the database first, then:
# 1. Decompress the backup to a new file
zstd -d backup/2026-11-03/clearing.db.zst -o /tmp/clearing-restored.db
# 2. Look at it before trusting it
sqlite3 /tmp/clearing-restored.db "PRAGMA integrity_check; SELECT COUNT(*) FROM trade_events;"
# 3. Move the broken files aside and put the restored one in place
mv clearing.db clearing.db.broken
rm -f clearing.db-wal clearing.db-shm
cp /tmp/clearing-restored.db clearing.db
Without the zstd tool, Python can do step 1:
from compression import zstd
from pathlib import Path
src = Path("backup/2026-11-03/clearing.db.zst")
Path("/tmp/clearing-restored.db").write_bytes(zstd.decompress(src.read_bytes()))
Delete the old -wal and -shm files (step 3). They belong to the broken
database and SQLite would otherwise try to apply them to the restored one.
Disk space¶
The housekeeping saves space overall, but keep an eye on three things:
settlement/ (small — a few kilobytes per trader per day), backup/ (daily
compressed copies, kept 7 days by default), and the audit archives
(pm-audit --keep-days, default: keep everything). Change the retention with
pm-eod run --log-keep-days N --backup-keep-days N and leave a large database out
of the backup with --backup-skip; in the scheduler hook
write it into the command: --on-close "pm-eod run --pending --backup-keep-days 14".
Correcting mistakes and special situations¶
Run a day again¶
pm-eod run --date 2026-11-03 is always safe. A day that is already settled
says already settled and is not counted twice; the report and housekeeping run
again (replacing the folder). To run only one step of a settled day:
pm-eod run --date 2026-11-03 --only report # rewrite the statements
pm-eod run --date 2026-11-03 --only backup # take the backup again
--only accepts report, audit, logs or backup. A step run with --only
is not recorded in pm-eod status, so it can never mark a good day as bad.
Look before you leap: --dry-run¶
runs the checks and prints the amounts that would be settled, writing nothing at all:
2026-11-03:
[ok] gate: closed, snapshot present, reconciled
[ok] settle: would settle: GW_ALICE +20.00, GW_BOB -20.00, GW_CAROL +0.00
The scheduler was down, or the machine was off¶
Nothing special: the next pm-eod run --pending (the scheduler runs it when it
starts) settles every closed day that is still waiting, oldest first. If one
of them fails, it stops there and says stopping: later dates wait for <date>,
because a later day must never be settled before an earlier one.
Catching up several days takes one backup, not one per day: every day would
copy the same current databases, only under an older date. Each day's backup
line says deferred: one backup after the run's last date, and the run ends with
the backup itself, filed under the last day it got through. The worked example,
with the scheduler down for all three days and --pending run once afterwards,
ends like this:
2026-11-04:
[ok] gate: closed, snapshot present, reconciled
[ok] settle: settled
[ok] report: wrote /srv/exchange/data/settlement/2026-11-04
[ok] audit: nothing pending
[ok] logs: no log directory
[ok] backup: deferred: one backup after the run's last date
2026-11-04:
[ok] backup: backed up clearing.db, eod.db (132.0 KB -> 4.5 KB); not present: stats.db
If a day fails and the run stops, the backup is still taken, under the last day before it.
The engine was restarted during the day¶
Restarting the engine announces CLOSED for a moment before the scheduler
moves it back to the right phase, so pm-clearing saves a provisional close
snapshot. pm-eod treats a day as closed only when its latest session phase
is CLOSED and the market was open earlier that day, so a day that is still
trading is never settled early. The real close
overwrites the provisional snapshot. If the day never closes properly (the
scheduler was stopped, say), it is never settled on its own: its gains and
losses are included in the next day that is settled, because each settlement
counts from the close of the last day that was settled.
Restarting the engine after the close (for maintenance, say) also announces
CLOSED, but changes nothing: once a day has really closed its snapshot is
final, and only the next trading day takes a new one.
The same goes for starting the exchange in the morning, before the open.
The engine announces CLOSED when it starts and the scheduler runs
pm-eod run --pending a moment later, but a day on which the market has not yet
opened has not closed: pm-eod settles only earlier days, and today waits for
the real close.
A trader joins or leaves mid-way¶
Add them to roster.yaml. If they trade, they are in the clearing data and
settle normally even before you add them (they just appear without a name until
you do). To fix up earlier statements run --only report for those dates.
Recomputing a day (--redo)¶
If something upstream was wrong and you want to recompute, use --redo:
--redo drops later days too
Balances are a running total, so correcting one day changes everything
after it. --redo therefore deletes the settlement of that date and every
later date from the ledger, recomputes the first one, and leaves the later
ones to the next pm-eod run --pending. Statement folders of the dropped
dates stay on disk until they are overwritten. If the recomputation fails,
nothing is deleted. Run --dry-run first and make sure you have a backup
(the backup step makes one every day).
After pm-opctl-cli clear¶
pm-opctl-cli clear --state wipes clearing.db — but it deliberately keeps
eod.db, settlement/ and backup/, because the balances should survive a
reset of the working data. Only clear --all removes them. One consequence: if
you reset and then trade again on the same calendar date, that date is
already settled in the ledger and pm-eod will refuse to settle it again.
Use pm-eod run --date <date> --redo to replace it.
Rehearsing with a fast clock¶
You can rehearse with pm-scheduler --daily --speed 600, which runs the
timetable ten simulated minutes per second. The trading date comes from the
real clock, though, so several fast simulated days all land on today's
date and share one close snapshot. You will get one settlement per real calendar
date, not one per simulated day. That is fine for checking the whole pipeline
works end to end; use --redo to run it again on the same date.
Weekends and holidays¶
On a day with no schedule the market never opens, so there is no CLOSED event,
no snapshot and nothing to settle. A Friday-to-Monday gap is simply one longer
interval: Monday settles the change in equity since Friday's close. (Precisely:
since the close of the last settled day, which is normally Friday.)
Troubleshooting¶
Every message below is printed on a [FAIL] or [warn] line.
Gate failures (exit code 1)¶
| Message | What it means | What to do |
|---|---|---|
<path>/clearing.db not found |
There is no clearing database in the data directory pm-eod is looking at |
Check --datapath / EDUMATCHER_DATA_DIR; check that pm-clearing ran |
no session phase recorded for <date> |
pm-clearing saw no session change on that date |
Was pm-clearing running and the scheduler enabled? For a no-scheduler setup add --force |
<date> has not closed yet |
The day has not opened and closed: trading is still going on, the close was never announced, or the market has not opened yet today (the CLOSED an engine announces when it starts is not a close) |
Wait for the close, or use --force if you have no scheduler |
no close snapshot for <date> |
The closing positions were never saved — pm-clearing was down at the close, or the date is from before this feature existed |
pm-clearing-cli snapshots --date <date> shows what exists. A missing snapshot cannot be made afterwards. Nothing is lost by leaving the day unsettled: the next day that is settled includes its gains and losses. Make sure pm-clearing runs at the next close |
clearing does not reconcile for <date> (N discrepancies; see pm-clearing-cli reconcile) |
The raw trade records and the daily totals disagree. Settling on inconsistent books would spread the error | Run pm-clearing-cli reconcile --from <date> --to <date> to see exactly which trader and symbol; fix the cause (a crash mid-write is the usual one) before settling |
Settlement failures (exit code 1)¶
| Message | What it means | What to do |
|---|---|---|
the day does not sum to zero: exact total X over N position(s) |
The amounts do not cancel out by more than rounding noise could explain — a real accounting inconsistency | Nothing was written; do not try to force it. Run pm-clearing-cli reconcile --from <date> --to <date> and look at pm-clearing-cli snapshots --date <date> for a trader whose numbers are far off. This points at a bug or a damaged clearing.db: keep the files (the daily backup has a copy) and report it |
a gateway may not be called EXCHANGE |
A trader used the reserved name | Rename the gateway in the configuration |
<date> has closed but is not settled; settle the dates in order (pm-eod run --pending) |
You asked for a date while an earlier closed day is still waiting. Days are settled oldest first, so the earlier one comes first | Run pm-eod run --pending, which takes them in order. If that fails on the earlier day, fix what its message says |
<date> is settled, which is later than <date> |
A later date was settled before this earlier one (for example you ran Friday and then Thursday by hand) | Run pm-eod run --date <the earlier date> --redo: it drops the later settlements and redoes them in order with the next --pending |
Report failures (exit code 1, settlement kept)¶
| Message | What it means | What to do |
|---|---|---|
<date> is not settled |
You ran --only report for a date that has no settlement |
Run the date without --only first |
roster.yaml: line N: … |
The roster file has an error on that line | Fix it; run the date again without --only (or wait for the next --pending) |
An OSError (disk full, permission denied) |
The folder could not be written | Free space or fix permissions; the next run retries |
Other¶
| Message | Meaning |
|---|---|
pm-eod: another pm-eod run is active |
The lock file is held. Wait, or check for a stuck process (ps aux \| grep pm-eod). |
--on-close command is still running; trigger skipped (scheduler log) |
A previous run has not finished; harmless — the next run catches up. |
could not start --on-close command (scheduler log) |
The command was not found. Is pm-eod on the scheduler's PATH? |
Housekeeping warnings (exit code 2)¶
| Message | Meaning |
|---|---|
another archive run holds the lock |
A pm-audit-cli archive is running. Harmless; the next run retries. |
archive failed: … |
The audit archive could not be made (often not enough free disk). The day's files are untouched. |
N changed while compressing |
A log was written while it was being compressed; it was left alone and will be retried. |
failed: clearing.db: … |
That database could not be backed up (full disk, damaged database). The previous backup is kept. A damaged database is serious: investigate now. |
Questions beginners ask¶
Is any of this real money?
No. The currency is fictional and pm-eod only writes numbers and files.
If I buy a share and the price goes up, do I get paid even though I haven't sold? In this system, yes — in the form of a positive settlement for that day. That is the mark-to-market idea. If the price falls back the next day, the next settlement is negative and takes it back. Over the whole life of a position the sum of the daily amounts equals the real profit or loss.
Why is my statement amount for today different from my P&L on the clearing tool? Clearing shows your total P&L (everything since the beginning). The statement shows today's change. Today's amount = total equity now minus total equity at the previous close.
Why does a trader who did nothing sometimes get a non-zero amount? Because they hold a position and the market moved. Settlement follows price moves on what you hold, not just your own trading activity.
Why do the amounts add to exactly zero? Every trade has a buyer and a seller. One's gain is the other's loss.
What if two traders disagree with the numbers?
Everything is reproducible. pm-clearing-cli snapshots --date <date> shows
the closing positions used, pm-clearing-cli trades shows the trades, and
pm-eod run --date <date> --dry-run recomputes without writing.
Can a trader go below zero? Yes. A negative balance is allowed and has no effect on trading.
Why didn't pm-eod run today?
Check pm-eod status (and pending:), the scheduler log for
--on-close, and that the market actually closed (holidays have no close).
Where do I see the current day, before the close?
pm-clearing-cli pnl and pm-clearing-cli gateways show live totals. Settlement
only happens after the close.
Checklist for your first real day¶
pm-clearingis running (check withpm-clearing-cli health).sessions_enabled: trueandpm-scheduler --dailyis running with--on-close.- You wrote
ref_data/roster.yamland every trader is in it. - You rehearsed once (see Rehearsing with a fast clock) and read the statement a trader would receive.
- You know where
settlement/<date>/instructions.csvappears and how you will send it. - You decided how long to keep backups and logs.
- The next morning:
pm-eod statusshowsok, andpm-eod balances --checkprintsOK.
Command summary¶
| Command | What it does |
|---|---|
pm-eod run --date D |
Settle, report and tidy up one trading date |
pm-eod run --pending |
Do every closed date that is waiting, oldest first |
pm-eod run --date D --dry-run |
Check and show the amounts, write nothing |
pm-eod run --date D --only STEP |
Re-run report, audit, logs or backup for a settled date |
pm-eod run --date D --force |
Skip the "has the day closed?" test (no scheduler) |
pm-eod run --date D --redo |
Recompute D and drop every later settlement |
pm-eod run … --backup-skip NAME |
Leave clearing.db, stats.db or eod.db out of the backup |
pm-eod status [-q] |
Recent runs; -q is a health check (0 = no closed day waiting) |
pm-eod balances [--date D] [--gateway G] [--check] |
Running balances, or verify the books |
The complete option reference is in the
Reference Manual
and pm-help pm-eod. The words used here are collected in the
Glossary.
See also¶
- Training Guide, chapter 20: Closing the Day — hands-on exercises: settle a day, read the statements, recover from a missed day, a broken roster and trading after the close
- Sessions and Scheduling — what drives the
market to
CLOSED, and the--on-closeoption - Statistics and Reporting — the other daily figures
- Positions and P&L — how positions, average cost and profit are calculated
- Persistence — every file the exchange writes
- Audit Trail — the day-by-day
archive the
auditstep safeguards - Health Monitor — watching the exchange's processes