Statistics and Reporting¶
Learning objectives
After reading this page you will understand:
- How EduMatcher defines a trading date, and why it is not the UTC date
- How to record market and exchange index statistics continuously using
pm-stats - How to query statistics data without writing SQL using
pm-stats-cli - Common analyst workflows: end-of-day summaries, intraday price analysis, trade analysis, index level history, and order lifecycle investigation
- How to export statistics for external analysis (spreadsheets, BI tools)
- How the statistics system integrates with other tools like
pm-ticker - How to troubleshoot and validate statistics data
Overview — Statistics Architecture¶
EduMatcher has a two-part statistics system:
| Component | Role | Type | Purpose |
|---|---|---|---|
| pm-stats | Subscriber | Long-running process | Listens to trades, book updates, index level updates, and private order lifecycle events; writes OHLCV, snapshots, trade log, index history, and order_events to data/stats.db |
| pm-stats-cli | Query tool | One-shot CLI | Reads from data/stats.db and prints human-friendly or machine-readable output without SQL |
This split keeps the recorder separate from the query interface, so you can:
- Start and stop
pm-stats-cliat any time without affecting the live recorder - Reload historical data after the engine restarts
- Build reports, dashboards, and automated analysis without needing live connections
- Keep the database read-only for auditing and compliance
Dates and Timestamps — read this first¶
Every date question in stats.db has two possible answers, and mixing them up
is the easiest way to produce a report that is quietly wrong. The database
uses two distinct concepts:
| Concept | Where it appears | What it is |
|---|---|---|
| Instant | every ts column |
An ISO-8601 UTC timestamp with an explicit +00:00 offset, e.g. 2026-06-14T09:00:01.000+00:00 |
| Trading date | the date column of daily_stats and index_daily_stats |
The calendar date in the exchange's session timezone that an instant belongs to, e.g. 2026-06-14 |
The trading date is deliberately not the UTC date. An exchange's daily
rollup has to align to the calendar day its participants actually traded. A
session that runs into the evening — or any session that straddles 00:00 UTC —
would otherwise be split across two date values, and the daily summary would
no longer describe a single trading session.
Setting the session timezone¶
Set it once, on the recorder:
pm-stats writes that value into the database's stats_meta table, and every
reader — pm-stats-cli, pm-ticker, the API Gateway — picks it up from there.
Readers need no timezone configuration at all, and cannot disagree with the
recorder about which trading day a --date refers to:
Two guardrails back this up:
- Restarting
pm-statsagainst an existing database with a different--timezoneis refused, because thedatecolumn would then mean two different things within one file. Use a different--dbinstead. - Passing
--timezoneto a reader overrides the recorded value and prints a warning when the two disagree:
$ pm-stats-cli --timezone UTC daily --date 2026-06-14
[WARN] --timezone UTC differs from the session timezone this database was
recorded with (Europe/Stockholm); --date will resolve to a different
trading day than pm-stats used
pm-clearing still needs its own --timezone
pm-clearing keeps a separate database and takes the same flag with the
same meaning for trade_events.trade_date. Give it the same value you
gave pm-stats, or daily_stats.volume will not reconcile against
gateway_daily_summary.traded_qty — that pairing spans two files and is
not checked automatically.
If your exchange runs in UTC, leave everything at the default and none of this applies.
Inspecting what a database was recorded with¶
stats_meta is a plain key/value table:
$ sqlite3 data/stats.db "SELECT key, value FROM stats_meta"
recorder|pm-stats
session_timezone|Europe/Stockholm
snapshot_interval_sec|900.0
The schema version is in PRAGMA user_version:
The version is bumped both when the table definitions change and when the
meaning of stored values changes — version 2 introduced the distinct combo,
OCO and quote event_type values, which the DDL alone cannot express, and
version 3 added feed_gaps and widened trade_log's primary key, and
version 4 moved prices from REAL display money to INTEGER ticks, and
version 5 added the instruments reference table.
pm-stats refuses to open a database whose user_version does not match the
build, rather than writing new-format rows into an old-format file. If you hit
that, move or delete the file and let pm-stats create a fresh one.
What --date means¶
--date 2026-06-14 always means "the 2026-06-14 trading day". For
daily_stats and index_daily_stats that is a direct match on the date
column. For the timestamped tables (trade_log, price_snapshots,
order_events, index_level_snapshots) it is resolved to the UTC instant
range that trading day covers:
--timezone Europe/Stockholm --date 2026-06-14
→ ts >= 2026-06-13T22:00:00+00:00
and ts < 2026-06-14T22:00:00+00:00
Daylight-saving transitions are handled: a trading day may be 23 or 25 hours long, and the range follows local midnight either side.
What --from / --to accept¶
--from and --to are instants, and all of these are accepted:
| Form | Example | Interpreted as |
|---|---|---|
UTC with Z |
2026-06-14T09:00:00Z |
09:00 UTC |
| Explicit offset | 2026-06-14T11:00:00+02:00 |
09:00 UTC |
| No offset | 2026-06-14T11:00:00 |
11:00 session-local, so 09:00 UTC in Stockholm |
Bounds are compared as instants, not as text, so the three rows above select
exactly the same trades. Both bounds are inclusive, and they are precise: a
--to 2026-06-14T16:30:00 bound excludes a trade at 16:30:00.500, because
that trade genuinely happened after the bound. Give the bound sub-second
precision if you want to include it.
Data Folder Location¶
The location where pm-stats writes data/stats.db depends on how EduMatcher is installed:
| Running mode | Default location | Environment override |
|---|---|---|
Source checkout (poetry run pm-stats) |
<repo>/src/data/stats.db |
EDUMATCHER_DATA_DIR |
Installed (pm-stats on PATH) |
~/.local/share/edumatcher/stats.db |
EDUMATCHER_DATA_DIR |
Set the data directory in your shell profile (~/.zshrc or ~/.bashrc) to override either default:
Then every pm-* command — including pm-stats and pm-stats-cli — will use that location automatically:
Common use cases:
| Scenario | Environment variable | Purpose |
|---|---|---|
| Installed user (default for pipx) | (unset) → ~/.local/share/edumatcher |
Persistent user data folder |
Source checkout (default for poetry run) |
(unset) → <repo>/src/data/ |
Development environment |
| Isolated sessions | ~/sessions/morning |
Per-session isolation for demos or testing |
| Shared network | /mnt/shared/trading/ |
Shared data across machines |
Example: Per-session isolation
# Session 1: Morning trading (uses custom data directory)
export EDUMATCHER_DATA_DIR="$HOME/sessions/morning"
poetry run pm-engine
poetry run pm-stats
poetry run pm-stats-cli daily
# Session 2: Afternoon trading (different database)
export EDUMATCHER_DATA_DIR="$HOME/sessions/afternoon"
poetry run pm-engine
poetry run pm-stats
Each session maintains its own stats.db, so historical data doesn't mix.
Finding your data
To see where pm-stats is writing data:
See Processes — Environment variables for full details on EDUMATCHER_DATA_DIR.
Prices Are Stored As Integer Ticks¶
The single most important thing to know about this database: the price
columns do not contain money. They contain an integer count of ticks. If you
open stats.db in a SQL browser and see 15025, that is 150.25.
$ sqlite3 data/stats.db "SELECT symbol, price, quantity, tick_decimals FROM trade_log LIMIT 1"
AAPL|15025|100|2
$ pm-stats-cli trades --symbol AAPL --limit 1
ts | trade_id | symbol | price | quantity | ...
------------------------------+----------+--------+--------+----------+----
2026-06-14T09:00:01.000+00:00 | 1 | AAPL | 150.25 | 100 | ...
Same row. pm-stats-cli and the API convert for you; raw SQL does not.
The conversion rule¶
Every table holding prices carries a tick_decimals column — the number of
decimal places that symbol prices to. Divide by ten to that power:
Always use the row's own tick_decimals. Symbols do not share a tick size:
an FX pair at four decimals and an equity at two sit in the same table, and
assuming 2 for both silently mangles the FX prices.
-- Right
SELECT symbol, price / power(10, tick_decimals) AS display_price FROM trade_log;
-- Wrong: breaks on any symbol that is not 2-decimal
SELECT symbol, price / 100.0 FROM trade_log;
Why integers¶
The engine matches in integer ticks and converts to a float only to publish.
pm-stats converts straight back at ingest, so the number stored is the exact
number the engine matched on. Nothing rounds, and a day's turnover is an
integer sum that cannot drift however many fills it spans — where a float sum
would differ in its last digits depending on the order the trades were added.
This also lets daily_stats reconcile exactly against pm-clearing, which
stores its own archive the same way (see
P&L and Clearing).
The three exceptions¶
| Column | Type | Why |
|---|---|---|
daily_stats.vwap |
REAL | turnover / volume is a ratio and rarely lands on a tick. Both inputs are stored exactly beside it, so compute it yourself when you need full precision. |
price_snapshots.mid_price |
REAL | The midpoint of two adjacent ticks is a half tick, which no integer can hold. best_bid and best_ask beside it are exact. |
index level, aggregate_cap, divisor |
REAL | An index level is a computed, dimensionless number, not a price on a tick grid — there is no tick size to express it in. |
Both REAL exceptions are still in ticks, not in money, so they convert with the same rule. Index levels are neither — they are already the number you want.
turnover — stored in ticks, converted like a price¶
turnover is sum(price_ticks × quantity). Dividing by the tick scale gives
sum(price_money × quantity) — the money notional, exactly:
So turnover converts on output like every other tick column, and a row is
self-consistent in whichever unit you are reading:
turnover |
volume |
vwap |
turnover / volume |
|
|---|---|---|---|---|
| Raw SQL (ticks) | 460000 |
40 |
11500.0 |
11500.0 = vwap ✔ |
pm-stats-cli (money) |
4600.0 |
40 |
115.0 |
115.0 = vwap ✔ |
What this means for exported data¶
A CSV export carries the converted display prices, since the conversion happens
before output. But --wide also includes tick_decimals, so the exact integers
remain recoverable:
Keep that column if you intend to reconcile against stats.db or clearing.db
later.
Residual float exposure¶
After all of the above, the only values where floating point can still bite are
the three REAL exceptions. aggregate_cap is the most exposed: index market
caps run to 10^12 or beyond, where a double's absolute resolution is around
10^-4. Treat its last few digits as noise, and compare with a tolerance rather
than for equality.
The Statistics Database Schema¶
All statistics are stored in data/stats.db, a SQLite 3 database with seven data
tables, an instrument reference table, and a metadata table.
Row identity and pagination cursors¶
Keyset pagination (--after, and next_cursor on the API) needs a total
ordering, so each cursor carries the last row's sort key plus a tiebreaker.
Which tiebreaker depends on the table:
| Table | Cursor tiebreaker | Stable across VACUUM? |
|---|---|---|
order_events |
seq — a real INTEGER PRIMARY KEY AUTOINCREMENT column |
Yes |
daily_stats |
symbol (with date) — real columns |
Yes |
index_daily_stats |
index_id (with date) — real columns |
Yes |
trade_log |
SQLite's implicit rowid |
Not guaranteed |
price_snapshots |
SQLite's implicit rowid |
Not guaranteed |
index_level_snapshots |
SQLite's implicit rowid |
Not guaranteed |
The last three have composite primary keys rather than an explicit INTEGER
PRIMARY KEY, so SQLite is free to renumber their rowids during a VACUUM.
In practice it usually does not — SQLite 3.37 leaves them untouched in the
straightforward case — but the documentation only says it may, so nothing
should depend on it.
Do not hold a cursor across a VACUUM
A VACUUM between fetching one page and requesting the next can, on those
three tables, make the cursor's rowid refer to a different row. The
damage is bounded — ts is the primary sort and rowid only breaks ties —
so at worst you skip or repeat rows sharing a single timestamp. It will
not error.
This matters for the retention procedure below, which ends in a VACUUM.
Finish paginating first, or re-run the query from the start afterwards.
instruments¶
Reference data for every instrument seen, so the database describes itself rather than requiring outside knowledge to interpret.
Primary key: symbol.
| Column | Type | Description |
|---|---|---|
symbol |
TEXT | Instrument ticker |
tick_decimals |
INTEGER | Decimal places a price trades in; 1 tick = 10^-tick_decimals |
tick_size |
REAL | The tick as a number, e.g. 0.01. Derivable from tick_decimals, stored so a BI tool can join without arithmetic |
currency |
TEXT | Reserved for future use — always NULL. See below |
source |
TEXT | config (authoritative, from engine symbol_meta) or observed (inferred from a message) |
updated_ts |
TEXT | When the row was last written |
Two sources populate it, and config wins:
config— the engine'ssymbol_meta.tick_size, received in the startupsystem.symbolsresponse. Authoritative, and covers symbols that never trade and would otherwise never appear.observed— thetick_decimalson a trade or book message, for a symbol that has no config entry. Best effort: a message only reveals the scale that message was produced at, so it never overwrites aconfigrow.
$ pm-stats-cli instruments
symbol | tick_decimals | tick_size | currency | source | updated_ts
-------+---------------+-----------+----------+----------+------------------------------
AAPL | 2 | 0.01 | | config | 2026-06-14T08:59:12.004+00:00
FXPAIR | 4 | 0.0001 | | config | 2026-06-14T08:59:12.004+00:00
NEWCO | 3 | 0.001 | | observed | 2026-06-14T09:11:40.882+00:00
currency is reserved, not merely empty
EduMatcher has no currency model at all. Prices carry no currency, no
conversion happens anywhere, and index_daily_stats.aggregate_cap sums
across whatever the constituents happen to be priced in. The column is
present and always NULL so that a consumer can see the field is
absent rather than assume a currency — and so introducing a currency
model later does not require migrating the reference table.
Do not write to it, and do not infer a currency from its emptiness.
stats_meta¶
Key/value provenance for the file, written by pm-stats on every start.
| Key | Description |
|---|---|
session_timezone |
The IANA timezone the date columns are expressed in. Readers resolve this automatically |
snapshot_interval_sec |
The --snapshot-interval in force, so a consumer can tell a sparse series from a coarse one |
recorder |
The process that wrote the file (pm-stats) |
The schema version lives in PRAGMA user_version rather than in this table, so
it can be checked before any query is attempted.
daily_stats¶
Aggregated OHLCV (open, high, low, close, volume) and related metrics for each symbol per trading day.
Primary key: (date, symbol) — one row per symbol per trading day, upserted as trades arrive.
| Column | Type | Null? | Description |
|---|---|---|---|
date |
TEXT | no | Trading date YYYY-MM-DD in the session timezone |
symbol |
TEXT | no | Instrument ticker |
open_price |
INTEGER | yes | First trade price of the day, in ticks; null if no trades |
high_price |
INTEGER | yes | Highest trade price, in ticks |
low_price |
INTEGER | yes | Lowest trade price, in ticks |
close_price |
INTEGER | yes | Last trade price, in ticks |
volume |
INTEGER | no | Total traded quantity; 0 if the day had no trades |
trade_count |
INTEGER | no | Number of trades; 0 if the day had no trades |
turnover |
INTEGER | no | sum(price_ticks × quantity) — exact. Divide by 10^tick_decimals for money notional |
vwap |
REAL | yes | Volume-weighted average price in ticks. Derived (turnover / volume), so REAL |
open_bid |
INTEGER | yes | Best bid at first book update of the day, in ticks |
open_ask |
INTEGER | yes | Best ask at first book update of the day, in ticks |
close_bid |
INTEGER | yes | Best bid at engine shutdown, in ticks |
close_ask |
INTEGER | yes | Best ask at engine shutdown, in ticks |
largest_trade_qty |
INTEGER | yes | Quantity of the single largest trade. Note this is 0, not null, on a day with no trades |
largest_trade_price |
INTEGER | yes | Price of the single largest trade, in ticks |
tick_decimals |
INTEGER | no | Decimal scale for this row's tick columns |
Use case: End-of-day summaries, daily trend analysis, multi-day performance tracking.
Prices are unadjusted
daily_stats carries no corporate-action awareness. Prices are exactly
what printed on the day, with no split or dividend adjustment and no
marker indicating that an adjustment event occurred. A multi-day series
that spans a corporate action is therefore discontinuous, and returns
computed across it will be wrong. pm-index maintains the corporate-action
audit record — see Market Index.
price_snapshots¶
Intraday mid-price, bid/ask, and percentage-change history, recorded at most once per interval per symbol (default: 15 minutes).
Primary key: (ts, symbol).
Index: (symbol, ts).
| Column | Type | Null? | Description |
|---|---|---|---|
ts |
TEXT | no | ISO-8601 UTC instant, second precision, e.g. 2026-06-14T09:00:00+00:00 |
symbol |
TEXT | no | Instrument ticker |
mid_price |
REAL | yes | Midpoint in ticks; REAL because a midpoint can be a half tick. See the fallback chain below — not always a true mid |
best_bid |
INTEGER | yes | Best bid at snapshot time in ticks; null if the bid side was empty |
best_ask |
INTEGER | yes | Best ask at snapshot time in ticks; null if the ask side was empty |
pct_change |
REAL | yes | Percentage change of mid_price from the previous persisted row (1.25 means +1.25 %); a percentage, never converted |
tick_decimals |
INTEGER | no | Decimal scale for this row's tick columns |
mid_price is resolved in this order, and only the first case is a genuine mid-price:
| Book state | mid_price |
|---|---|
| Both sides present | (best_bid + best_ask) / 2, in ticks — may be a half tick |
| Bid only | best_bid |
| Ask only | best_ask |
| Neither, but a last trade exists | last_price |
| Nothing available | null |
mid_price on a one-sided book
On a one-sided book mid_price is simply whichever side exists, so a
series can silently mix true mid-prices with single-sided quotes. Because
pct_change is computed from consecutive mid_price values, a book
flipping between two-sided and one-sided produces a percentage move that
reflects the change in definition, not a change in the market. Check
best_bid/best_ask for null before treating pct_change as a return.
Related: pct_change compares against the previous snapshot that had a
usable mid_price. If an intervening snapshot had none, the percentage
silently spans more than one interval.
trade_log¶
Append-only record of every matched trade — no aggregation, one row per trade.
Primary key: (trade_id, ts). Inserts are OR IGNORE, so a repeated delivery of the same trade — same id and same timestamp — is deduplicated.
Why the key is composite
trade_id is a counter that restarts at 1 on every engine run, not a
globally unique identifier. Keyed on trade_id alone, the first trade of
a restarted engine would collide with the first trade of the previous run
and be silently discarded, understating volume for the rest of the day.
Including ts keeps a post-restart id reuse as a distinct row while still
deduplicating a genuine duplicate delivery. pm-clearing records the same
defect against its own archive as finding CL-C1.
| Column | Type | Null? | Description |
|---|---|---|---|
ts |
TEXT | no | ISO-8601 UTC instant, millisecond precision. This is the engine's trade timestamp |
trade_id |
TEXT | no | Engine trade counter, unique within one engine run only |
symbol |
TEXT | no | Instrument ticker |
price |
INTEGER | no | Execution price, in ticks — divide by 10^tick_decimals |
quantity |
INTEGER | no | Matched quantity |
tick_decimals |
INTEGER | no | Decimal scale for price |
buy_gateway_id |
TEXT | yes | Gateway that submitted the buy order |
sell_gateway_id |
TEXT | yes | Gateway that submitted the sell order |
aggressor_side |
TEXT | yes | BUY or SELL for a continuous match; AUCTION for an uncross print |
Index: (symbol, ts).
aggressor_side is mirrored from the engine payload verbatim:
| Value | Meaning |
|---|---|
BUY |
An incoming buy order swept resting sell liquidity |
SELL |
An incoming sell order swept resting buy liquidity |
AUCTION |
An opening or closing uncross print — both sides were resting, so there is no true aggressor |
This is what makes trade classification and order-flow imbalance possible, and it is also the only way to separate auction prints from continuous ones:
# Continuous-session buy-side pressure for one trading day
pm-stats-cli --format json trades --symbol AAPL --date 2026-06-14 --limit 100000 \
| python3 -c "
import json, sys, collections
rows = json.load(sys.stdin)
by_side = collections.Counter()
for r in rows:
by_side[r['aggressor_side']] += r['quantity']
print(dict(by_side))
"
Use case: Trade-by-trade analysis, order-flow imbalance, separating auction from continuous volume, audit trails.
feed_gaps¶
Trades the recorder can prove it never received.
Primary key: seq (AUTOINCREMENT).
Index: (ts).
| Column | Type | Description |
|---|---|---|
seq |
INTEGER | Monotonic local sequence |
ts |
TEXT | UTC instant of the trade that revealed the gap |
stream |
TEXT | Which feed the gap was detected on (currently trade.executed) |
expected_id |
INTEGER | The trade id expected next |
received_id |
INTEGER | The trade id that actually arrived |
missing_count |
INTEGER | How many trades are unaccounted for between them |
ZeroMQ PUB/SUB drops messages silently once a subscriber falls behind its high-water mark, so without this table a session that lost trades is indistinguishable from a quiet one. Because the engine numbers trades with a monotonic counter, a jump in that counter is direct evidence of loss, and each jump is written here inside the same transaction as the trade that revealed it.
Use case: answering "is this session's data complete?" before trusting a volume, VWAP or turnover figure.
Detection works two ways, and both write here:
| Source | Mechanism | Covers |
|---|---|---|
| Publisher sequence | Every published message carries a per-topic monotonic counter in a third ZeroMQ frame; a jump means messages were dropped | Every subscribed stream — trade.executed, book.*, order.*, combo.*, oco.*, quote.*, index.update |
| Engine trade id | Engine trade ids are monotonic within a run, so a jump is independent evidence | trade.executed only |
The two overlap on trades deliberately: the trade-id check still catches loss that happened upstream of the publisher, which a publisher-side counter cannot see by construction.
What an empty feed_gaps does and does not prove
A sequence counter restarts at 1 when its publisher restarts, so the recorder treats a decrease as a restart rather than a gap — messages genuinely lost across a publisher restart are not detectable.
A topic published by a process that does not stamp sequences is reported
once at WARNING ("carries no sequence frame; loss on it cannot be
detected") and then ignored, so a silent stream cannot masquerade as a
clean one.
order_events¶
Append-only order lifecycle history captured from private engine topics. This table is used by API Gateway history endpoints to reconstruct per-gateway order, fill, cancel, amend, combo, OCO, and quote events.
Primary key: seq (AUTOINCREMENT).
Indexes: (order_id), (gateway_id, ts), (symbol, ts), (event_type, ts).
order_events.ts is a different clock from trade_log.ts
order_events.ts is the wall-clock instant at which pm-stats recorded
the event. trade_log.ts is the instant the engine stamped on the
trade. The two therefore cannot be merged into one ordered timeline: a
FILL row can carry a timestamp earlier or later than the trade_log
row for the same execution, depending on delivery latency. Within
order_events alone, order by seq, which is monotonic and reliable.
| Column | Type | Description |
|---|---|---|
seq |
INTEGER | Monotonic local sequence assigned by SQLite for stable event ordering |
ts |
TEXT | ISO-8601 timestamp (UTC, millisecond precision) when pm-stats recorded the event |
event_type |
TEXT | Normalized event category — see the full value table below |
order_id |
TEXT | Order-like identifier; for combo/OCO/quote events this may be combo_id, oco_id, or quote_id |
gateway_id |
TEXT | Gateway identity that owns the private event |
symbol |
TEXT | Instrument ticker when present in the event payload |
side |
TEXT | BUY or SELL when applicable |
order_type |
TEXT | Order type from the original order or lifecycle event |
tif |
TEXT | Time-in-force value when present |
price |
REAL | Limit/order price when present |
quantity |
INTEGER | Original or submitted quantity when present |
remaining_qty |
INTEGER | Quantity remaining after the event when provided by the engine |
status |
TEXT | Engine status value when present |
fill_price |
REAL | Execution price for fill events |
fill_qty |
INTEGER | Executed quantity for fill events |
trade_id |
TEXT | Trade identifier linked to a fill event |
reason |
TEXT | Rejection, cancel, expire, or status reason when provided |
client_order_id |
TEXT | Client-supplied order identifier when present |
combo_parent_id |
TEXT | Parent combo identifier for combo child events |
oco_group_id |
TEXT | OCO group identifier for linked order events |
priority_reset |
INTEGER | 1 when an amend reset queue priority, 0 when it did not, null when not applicable |
Use case: API Gateway order history, support investigations, per-gateway audit trails, fill-only history, and lifecycle reconstruction for a single order ID.
event_type values¶
Every value the recorder can write, and the engine topic it comes from:
event_type |
Source topic | Meaning |
|---|---|---|
ACK |
order.ack.* with accepted: true |
Order accepted |
REJECT |
order.ack.* with accepted: false |
Order rejected |
FILL |
order.fill.* |
Execution against the order |
AMEND |
order.amended.* |
Order amended |
CANCEL |
order.cancelled.* |
Order cancelled |
EXPIRE |
order.expired.* |
Order expired |
COMBO_ACK |
combo.ack.* with accepted: true |
Combo accepted |
COMBO_REJECT |
combo.ack.* with accepted: false |
Combo rejected |
COMBO_STATUS |
combo.status.* |
Combo status update |
OCO_ACK |
oco.ack.* with accepted: true |
OCO pair accepted |
OCO_REJECT |
oco.ack.* with accepted: false |
OCO pair rejected |
OCO_CANCEL |
oco.cancelled.* |
One leg of an OCO pair cancelled |
QUOTE_ACK |
quote.ack.* with accepted: true |
Quote accepted |
QUOTE_REJECT |
quote.ack.* with accepted: false |
Quote rejected |
QUOTE_STATUS |
quote.status.* |
Quote status update |
UNKNOWN |
any ack topic missing its accepted flag |
See the note below |
EVENT |
any other subscribed private topic | Unclassified |
Combo, OCO and quote events each carry their own accept / reject / cancel /
status value rather than a single family name, so a rejected combo is
distinguishable from an accepted one and oco.cancelled is findable as a
cancellation. Filters are validated against this list — pm-stats-cli rejects
an unknown --event-type at parse time and the API returns 422, rather than
silently returning an empty page.
UNKNOWN means the engine did not say
Every ack-style payload carries an accepted flag, so its absence
indicates a bug or a corrupted message. When that happens the recorder
writes UNKNOWN and logs at ERROR, rather than defaulting to REJECT —
recording a rejection the engine never asserted would put a fabricated
fact into the audit trail. If you see UNKNOWN rows, check the pm-stats
log for the topic that produced them.
index_daily_stats¶
Aggregated daily OHLC (open, high, low, close) for each configured exchange index, one row per (date, index_id), upserted on every index.update event pm-stats receives from pm-index.
Primary key: (date, index_id).
Index: (index_id, date).
| Column | Type | Description |
|---|---|---|
date |
TEXT | Trading date YYYY-MM-DD in the session timezone |
index_id |
TEXT | Index identifier (e.g. EDU100) |
open_level |
REAL | Index level at the first update of the day |
high_level |
REAL | Highest index level seen during the day |
low_level |
REAL | Lowest index level seen during the day |
close_level |
REAL | Index level at the most recently received update — see the finality note below |
close_session_state |
TEXT | Session state as of that most recent update (e.g. CONTINUOUS, CLOSED) — the key to knowing whether close_level is final |
open_aggregate_cap |
REAL | Aggregate constituent market cap at the first update, in display money |
close_aggregate_cap |
REAL | Aggregate constituent market cap at the most recent update, in display money |
update_count |
INTEGER | Number of index.update events folded into this day's row |
Use case: Daily index trend analysis, comparing index performance across trading dates, spotting days with unusually few updates (a thin update_count may indicate a quiet index or a connectivity gap), and — the most common ask — looking up an index's official end-of-day (EOD) closing level for a chosen date.
Note: an index has no independent trades or volume of its own — its level is computed from constituent prices — so this table has no volume/trade_count/vwap columns the way daily_stats does.
close_level is only final once close_session_state is CLOSED
close_level (and close_session_state) are updated on every index.update tick — they always reflect whatever was most recently received for that date, not necessarily the actual end-of-day print. For any past date this is a non-issue: no more updates can arrive for a date that has rolled over, so close_level is guaranteed final. But if you query today's date while the session is still open, close_level is a live "last level so far" that will keep changing intraday, and close_session_state will show whatever state the market is currently in (e.g. CONTINUOUS), not CLOSED.
To reliably get the true EOD close for a given date:
- Simplest: query a date that has already ended —
close_levelfor a prior date is always final. - To confirm today's row is final: check that
close_session_state == "CLOSED".pm-indexsets this via a forced publish when the session transitions toCLOSED, so once you see it,close_levelfor that date will not change again.
See Getting the EOD index level for a date below for a worked example.
index_level_snapshots¶
Time series of every index level update received from pm-index, one row per index.update event (no additional throttling in pm-stats — pm-index already rate-limits its own publications via publish_interval_sec before it ever sends one).
Primary key: (ts, index_id). Inserts are OR IGNORE, so two updates for one index landing in the same millisecond retain only the first.
Index: (index_id, ts).
| Column | Type | Description |
|---|---|---|
ts |
TEXT | ISO-8601 UTC instant, millisecond precision |
index_id |
TEXT | Index identifier |
level |
REAL | Current index level at this update. Dimensionless — an index level is a computed number, not a price, so it is neither in ticks nor in money |
aggregate_cap |
REAL | Aggregate constituent market cap at this update, in display money — see the currency note below |
divisor |
REAL | Index divisor in effect at this update. Dimensionless: level = aggregate_cap / divisor |
session_state |
TEXT | Index session state at this update — see the full set below |
day_open |
REAL | Day's opening level, when known at this update |
day_high |
REAL | Day's high level so far, when known at this update |
day_low |
REAL | Day's low level so far, when known at this update |
aggregate_cap is money, but no currency is recorded
Market cap is the one monetary value in stats.db that is not in
ticks — an index spans several instruments, so there is no single tick
grid to express it on, and it is stored as display money in whatever
currency the constituents are priced in. That currency is not recorded
anywhere in the database. If your exchange lists in more than one
currency, aggregate_cap sums across them and is not meaningful without
knowing the mix.
It is also the value most exposed to floating-point error: caps run to 10^12 or beyond, where a double resolves to roughly 10^-4. Treat its last few digits as noise and compare with a tolerance.
session_state (in both this table and index_daily_stats.close_session_state)
takes exactly one of the five values from the engine's session model:
| Value | Meaning |
|---|---|
PRE_OPEN |
Orders accepted, no matching |
OPENING_AUCTION |
Auction collection before the opening uncross |
CONTINUOUS |
Normal continuous matching |
CLOSING_AUCTION |
Auction collection before the closing uncross |
CLOSED |
Market closed — the only value that makes close_level final |
See Session Scheduling and Auctions for the full phase model.
Use case: Intraday index charting, index-level history queries for pm-terminal-style viewers, reconstructing an index's level trajectory over any time window.
Why this table exists: pm-index also keeps its own append-only JSONL history file (data/indexes/<id>_history.jsonl) for corporate-action, delisting, and constituent-change audit records — that file remains the source of truth for those event types and is unaffected by this table. But that file is not indexed and every query against it is a full linear scan, which does not scale as a session runs longer. index_level_snapshots exists specifically to give the level time series (the data an index chart needs) a queryable, indexed home, the same way price_snapshots already does for instrument prices — it does not replace or duplicate the JSONL file's audit role.
Running the Statistics Recorder¶
Start pm-stats as a background process after the engine starts:
# Terminal 1: Start the engine
pm-engine --verbose
# Terminal 2: Start statistics recorder (after engine is ready)
pm-stats
pm-stats will:
- Connect as a subscriber to the engine's PUB socket (:5556)
- Connect as a second, independent subscriber to
pm-index's own PUB socket (:5558 by default) forindex.updateevents —pm-indexbinds a separate endpoint from the engine, so this is a distinct ZMQ connection, not an additional topic filter on the engine socket - Wait briefly for ZMQ subscriptions to propagate, then request the symbol list from the engine via PUSH (:5555); on receipt, request a current book snapshot per symbol so opening bid/ask and initial price rows are captured even before new trading activity
- Begin recording trades to
daily_statsas they execute, detecting and logging any trades that never arrived (seefeed_gaps) - Write an intraday snapshot on the first book update for a symbol, then at most one per
--snapshot-interval(default 15 minutes) — a quiet symbol produces none - Write trade-by-trade records to
trade_logimmediately - Write private order lifecycle events to
order_events - Write every received index update to
index_level_snapshotsand upsert the day's rollup intoindex_daily_stats— no exchange indexes configured means noindex.updatetraffic and these two tables simply stay empty, which is expected and not an error - At engine shutdown, record the final close bid/ask to
daily_stats
Startup options:
| Flag | Default | Description |
|---|---|---|
--db |
data/stats.db |
Custom statistics database path |
--timezone |
UTC |
Exchange session timezone defining the trading date written to the date columns (IANA name). Recorded into the database, so readers pick it up automatically. Restarting against an existing database with a different value is refused |
--snapshot-interval |
900 (15 min) |
Seconds between price_snapshots rows per symbol; minimum 1. Lower values give finer intraday resolution at the cost of more database writes. Anything under a second is rejected at startup: price_snapshots is keyed on (ts, symbol) at second precision, so it cannot hold more than one row per second per symbol and the extras would be silently discarded |
--sql-trace |
off | Log executed SQLite statements from the stats writer connection — useful for debugging what pm-stats is actually writing |
--log-level |
WARNING |
Explicit level: CRITICAL, ERROR, WARNING, INFO, DEBUG |
-v, --verbose |
off | Increase verbosity (-v → INFO, -vv → DEBUG) |
-q, --quiet |
off | Reduce output to warnings/errors |
--log-target |
server |
Where this process's own operational log records go: server (auto-detected pm-log-srv), stdout, or file |
--log-file |
— | Operational log file path — required when --log-target file |
--log-failover-timeout |
30 |
Grace window in seconds before falling back to a local log file once pm-log-srv becomes unreachable |
Use --db if you want to record into a different location:
Use --snapshot-interval to change how often intraday price snapshots are recorded:
pm-stats --snapshot-interval 60 # one-minute snapshots
pm-stats --snapshot-interval 300 # five-minute snapshots
pm-stats --snapshot-interval 3600 # hourly snapshots
Start order: ZeroMQ connect() is asynchronous and retries indefinitely, so
starting pm-stats before the engine does not raise an error — but the startup
symbol request is sent once, shortly after launch, and is lost if nothing is
listening. The practical consequence of starting too early is that opening
bid/ask and the initial snapshot row are missing for the day, not that the
process fails. Start pm-stats after the engine is up.
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Stopped cleanly via Ctrl-C / SIGTERM |
1 |
Startup failed, or the receive loop terminated unexpectedly and the process stopped recording |
A non-zero exit means data was not being recorded. Supervise pm-stats on its
exit code — a running process is not by itself evidence that recording is
happening, and the log will carry a pm-stats stopped recording: … line.
Restarting mid-session¶
pm-stats can be restarted at any point during a trading day without losing
the day's figures. On the first event it sees for a given symbol it rebuilds
that symbol's running totals from trade_log, and the opening bid/ask from the
existing daily_stats row, before applying the new event. Open, high, low,
volume, trade count, VWAP and largest-trade all continue from where they were.
Two limits worth knowing:
- Anything the engine published while
pm-statswas down was never received and cannot be recovered — ZeroMQ PUB/SUB has no replay. The rollup is consistent with what was recorded, not necessarily with what traded. - For an index,
update_countis rebuilt as the number of retainedindex_level_snapshotsrows, which can be marginally lower than the number of updates originally received if any shared a millisecond.
One recorder per database¶
Exactly one pm-stats process may write to a given stats.db, and this is
enforced. Two recorders against one file would each keep their own
in-memory rollup and overwrite each other's daily_stats rows, producing
figures that describe neither process — so a second one refuses to start:
$ pm-stats --db data/stats.db
[ERROR] fatal startup error: another pm-stats process is already recording to
data/stats.db (lock held on data/stats.db.lock). Two recorders on one
database overwrite each other's daily rollups — use a different --db.
$ echo $?
1
The lock is an exclusive transaction on a sidecar stats.db.lock file, held
for the life of the process and released on shutdown. A few consequences worth
knowing:
- The
.lockfile appears alongsidestats.db. It holds no data and can be deleted when no recorder is running. - If
pm-statsis killed withSIGKILL, the operating system releases the lock along with the process — there is no stale lock to clean up by hand. - Readers are unaffected:
pm-stats-cli,pm-tickerand the API Gateway never take this lock. - Running two recorders deliberately is still fine, as long as each has its
own
--db.
Querying with pm-stats-cli¶
Once pm-stats has recorded data, use pm-stats-cli to query without SQL.
Basic Syntax¶
Global options:
| Flag | Default | Description |
|---|---|---|
--db |
data/stats.db |
Path to statistics database |
--format |
table |
Output format: table (human), json (structured), or csv (export) |
--no-header |
off | Omit the header row from table and csv output |
--timezone |
from the DB | Override the session timezone that --date and offset-less --from/--to resolve in. Defaults to the value the database was recorded with; overriding warns on mismatch |
Row limits and truncation¶
Every list command has a --limit. When more rows match than the limit allows,
the CLI prints a warning to stderr and gives you a cursor to continue from:
$ pm-stats-cli trades --date 2026-06-14 --limit 200
... 200 rows ...
[WARN] Output truncated at --limit 200. More rows exist; re-run with
--after eyJ0cyI6IjIwMjYtMDYtMTRUMTA6MDA6MDAuMDAwKzAwOjAwIiwicm93aWQiOjIwMH0= for the next page.
Because the warning goes to stderr it never corrupts a redirected CSV or JSON
payload — but it also means you will not see it if you redirect both
streams. When a count has to be exact, either raise --limit beyond the
expected row count or page through with --after until no warning appears.
--after takes the cursor verbatim and is available on daily, snapshots,
trades, order-events, index-daily and index-snapshots.
Available Commands¶
daily — Daily OHLCV Summary¶
Show daily summary rows from daily_stats.
pm-stats-cli daily
pm-stats-cli daily --date 2026-06-14
pm-stats-cli daily --date 2026-06-14 --symbol AAPL
pm-stats-cli daily --wide # include bid/ask and largest-trade columns
pm-stats-cli daily --limit 10
# Multi-day history for one symbol, oldest first
pm-stats-cli daily --symbol AAPL --from-date 2026-06-01 --to-date 2026-06-30
Options:
| Option | Default | Description |
|---|---|---|
--date |
latest available | One trading date to query |
--from-date |
— | Start of an inclusive multi-day range |
--to-date |
— | End of an inclusive multi-day range |
--symbol |
all | Limit to one symbol |
--limit |
100 | Maximum rows to return |
--after |
— | Continue from a previous run's truncation cursor |
--wide |
off | Include open/close bid/ask and largest-trade fields |
daily returns a single date unless you ask for a range
With no date filter at all, daily returns rows for the latest
available date only — --limit bounds how many symbols come back, not
how many days. To get history across dates you must pass --from-date
and/or --to-date; either bound may be omitted for an open-ended range.
Range results are ordered oldest first, which is the order a chart plots
them in. An explicit --date overrides a range if both are given.
Example output (default table format):
date | symbol | open_price | high_price | low_price | close_price | volume | trade_count | vwap
-----------|--------|------------|------------|-----------|-------------|--------|-------------|-------
2026-06-14 | AAPL | 150 | 153.25 | 149.5 | 152.75 | 5000 | 12 | 151.82
2026-06-14 | MSFT | 414 | 418.5 | 413 | 417 | 3200 | 8 | 415.63
Null values render as an empty cell in table and csv output. Only
--format json writes an explicit null.
snapshots — Intraday Price History¶
Show periodic price snapshots from price_snapshots for one symbol over a time range. The recording interval is set by pm-stats --snapshot-interval (default: 15 minutes).
pm-stats-cli snapshots --symbol AAPL
pm-stats-cli snapshots --symbol AAPL --date 2026-06-14
pm-stats-cli snapshots --symbol MSFT --from 2026-06-14T09:00:00+00:00 --to 2026-06-14T16:30:00+00:00
pm-stats-cli snapshots --symbol AAPL --limit 50
Options:
| Option | Required | Default | Description |
|---|---|---|---|
--symbol |
Yes | — | Symbol to query |
--date |
No | all dates | Restrict to one trading date |
--from |
No | — | Start timestamp (inclusive, ISO format) |
--to |
No | — | End timestamp (inclusive, ISO format) |
--limit |
No | 500 | Maximum rows to return |
--after |
No | — | Continue from a previous run's truncation cursor |
Example output:
ts | symbol | mid_price | best_bid | best_ask | pct_change
--------------------------|--------|-----------|----------|----------|-----------
2026-06-14T09:00:00+00:00 | AAPL | 150.5 | 150 | 151 |
2026-06-14T09:15:00+00:00 | AAPL | 151 | 150.5 | 151.5 | 0.33
2026-06-14T09:30:00+00:00 | AAPL | 151.25 | 151 | 151.5 | 0.17
The first row's pct_change is empty because there is no previous snapshot to
compare against. Timestamps always carry the +00:00 offset — copy them
verbatim into --from/--to.
trades — Trade-by-Trade History¶
Show individual trades from trade_log with optional filtering.
pm-stats-cli trades
pm-stats-cli trades --symbol AAPL
pm-stats-cli trades --symbol AAPL --date 2026-06-14
pm-stats-cli trades --symbol MSFT --from 2026-06-14T09:00:00+00:00 --to 2026-06-14T10:00:00+00:00
pm-stats-cli trades --limit 50
Options:
| Option | Default | Description |
|---|---|---|
--symbol |
all | Limit to one symbol |
--date |
all dates | Restrict to one trading date |
--from |
— | Start timestamp (inclusive) |
--to |
— | End timestamp (inclusive) |
--limit |
200 | Maximum rows to return |
--after |
— | Continue from a previous run's truncation cursor |
Example output:
ts | trade_id | symbol | price | quantity | aggressor_side | buy_gateway_id | sell_gateway_id
------------------------------+----------+--------+-------+----------+----------------+----------------+----------------
2026-06-14T09:00:01.000+00:00 | 1 | AAPL | 150 | 100 | BUY | TRADER01 | MM01
2026-06-14T09:00:05.123+00:00 | 2 | AAPL | 150.5 | 50 | AUCTION | MM01 | TRADER02
2026-06-14T09:00:10.456+00:00 | 3 | AAPL | 150.2 | 200 | SELL | TRADER02 | TRADER01
order-events — Private Order Lifecycle Events¶
Show order lifecycle events from order_events for one gateway. The gateway is
required because lifecycle history is private per participant.
pm-stats-cli order-events --gateway TRADER01
pm-stats-cli order-events --gateway TRADER01 --symbol AAPL
pm-stats-cli order-events --gateway TRADER01 --event-type FILL
pm-stats-cli order-events --gateway TRADER01 --date 2026-06-14 --limit 50
pm-stats-cli --format json order-events --gateway TRADER01 --from 2026-06-14T09:00:00+00:00
Options:
| Option | Required | Default | Description |
|---|---|---|---|
--gateway |
Yes | - | Gateway ID that owns the private events |
--symbol |
No | all symbols | Restrict to one symbol |
--event-type |
No | all event types | Restrict to one normalized type — see event_type values. An unknown value is rejected at parse time |
--date |
No | all dates | Restrict to one trading date |
--from |
No | - | Start timestamp (inclusive) |
--to |
No | - | End timestamp (inclusive) |
--limit |
No | 500 | Maximum rows to return |
--after |
No | - | Continue from a previous run's truncation cursor |
Example output:
seq | ts | event_type | order_id | gateway_id | symbol | side | order_type | tif | price | quantity | remaining_qty | status
----|-------------------------------|------------|----------|------------|--------|------|------------|-----|-------|----------|---------------|---------
1 | 2026-06-14T09:00:00.100+00:00 | ACK | O-AAPL-1 | TRADER01 | AAPL | BUY | LIMIT | DAY | 150 | 100 | 100 | ACCEPTED
2 | 2026-06-14T09:00:01.000+00:00 | FILL | O-AAPL-1 | TRADER01 | AAPL | BUY | | | | | 0 | FILLED
order-lifecycle — One Order's Event Trail¶
Show every lifecycle event for one order-like ID owned by a gateway. For combo,
OCO, and quote events, the ID may be a combo_id, oco_id, or quote_id stored
in the order_id column.
pm-stats-cli order-lifecycle --gateway TRADER01 --order-id O-AAPL-1
pm-stats-cli --format csv order-lifecycle --gateway TRADER01 --order-id O-AAPL-1
Options:
| Option | Required | Default | Description |
|---|---|---|---|
--gateway |
Yes | - | Gateway ID that owns the private event trail |
--order-id |
Yes | - | Order, combo, OCO, or quote identifier to reconstruct |
symbols — Symbol Discovery¶
List all symbols with data in the statistics DB.
dates — Trading Date Discovery¶
List all available trading dates recorded in daily_stats.
Example output:
index-daily — Daily Index OHLC Summary¶
Show daily index summary rows from index_daily_stats.
pm-stats-cli index-daily
pm-stats-cli index-daily --date 2026-06-14
pm-stats-cli index-daily --date 2026-06-14 --index-id EDU100
pm-stats-cli index-daily --wide # include open/close aggregate market cap
pm-stats-cli index-daily --limit 10
Options:
| Option | Default | Description |
|---|---|---|
--date |
latest available | One trading date to query |
--from-date |
— | Start of an inclusive multi-day range |
--to-date |
— | End of an inclusive multi-day range |
--index-id |
all indexes | Limit to one index |
--limit |
100 | Maximum rows to return |
--after |
— | Continue from a previous run's truncation cursor |
--wide |
off | Include open/close aggregate market cap columns |
As with daily, omitting every date filter returns the latest date only;
pass --from-date/--to-date for a series across days.
Example output (default table format):
date | index_id | open_level | high_level | low_level | close_level | close_session_state | update_count
-----------|----------|------------|------------|-----------|-------------|----------------------|-------------
2026-06-14 | EDU100 | 1042.1 | 1056.3 | 1040.05 | 1048.73 | CLOSED | 512
close_session_state is CLOSED above, so close_level (1048.73) is confirmed as the final EOD print for that date — see Getting the EOD index level for a date below.
index-snapshots — Intraday Index Level History¶
Show every recorded index level update from index_level_snapshots for one index over a time range. Unlike snapshots for instruments, there is no configurable recording interval to tune — every index.update event pm-stats receives is recorded (pm-index has already rate-limited its own publications before pm-stats ever sees them).
pm-stats-cli index-snapshots --index-id EDU100
pm-stats-cli index-snapshots --index-id EDU100 --date 2026-06-14
pm-stats-cli index-snapshots --index-id EDU100 --from 2026-06-14T09:00:00+00:00 --to 2026-06-14T16:30:00+00:00
pm-stats-cli index-snapshots --index-id EDU100 --limit 50
Options:
| Option | Required | Default | Description |
|---|---|---|---|
--index-id |
Yes | — | Index to query |
--date |
No | all dates | Restrict to one trading date |
--from |
No | — | Start timestamp (inclusive, ISO format) |
--to |
No | — | End timestamp (inclusive, ISO format) |
--limit |
No | 500 | Maximum rows to return |
--after |
No | — | Continue from a previous run's truncation cursor |
Example output:
ts | index_id | level | aggregate_cap | divisor | session_state
------------------------------|----------|---------|---------------|---------|----------------
2026-06-14T09:00:00.000+00:00 | EDU100 | 1042.10 | 7350000000000 | 1.25 | OPENING_AUCTION
2026-06-14T09:00:05.500+00:00 | EDU100 | 1043.85 | 7362000000000 | 1.25 | CONTINUOUS
instruments — Instrument Reference Data¶
Show the tick scale for each symbol, from the instruments
table.
Options:
| Option | Default | Description |
|---|---|---|
--symbol |
all | Limit to one symbol |
Use this to interpret raw SQL output: divide any tick column by
10^tick_decimals to get display money.
gaps — Detected Feed Gaps¶
Show trades the recorder never received, from feed_gaps.
pm-stats-cli gaps
pm-stats-cli gaps --date 2026-06-14
pm-stats-cli gaps --from 2026-06-14T09:00:00Z --to 2026-06-14T12:00:00Z
Options:
| Option | Default | Description |
|---|---|---|
--date |
all dates | Restrict to one trading date |
--from |
— | Start timestamp (inclusive) |
--to |
— | End timestamp (inclusive) |
--limit |
500 | Maximum rows to return |
Example output:
seq | ts | stream | expected_id | received_id | missing_count
----+-------------------------------+----------------+-------------+-------------+--------------
1 | 2026-06-14T09:14:02.115+00:00 | trade.executed | 3 | 6 | 3
2 | 2026-06-14T11:41:55.008+00:00 | trade.executed | 8 | 20 | 12
No rows found. is the healthy result. See the
feed_gaps warning for what detection does and does not cover.
index-ids — Index Discovery¶
List all index IDs with data in the statistics DB.
pm-stats-cli index-ids
pm-stats-cli index-ids --date 2026-06-14 # indexes with data on a specific date
If no exchange indexes are configured, this returns no rows — that is expected, not an error.
Order Lifecycle History Queries¶
order_events can be queried directly with pm-stats-cli or through the API
Gateway history endpoints. Use pm-stats-cli for local support, audit, and
offline analysis. Use API Gateway history when a client should see only the
private history for its authenticated trading credential.
Direct CLI examples:
pm-stats-cli order-events --gateway TRADER01 --symbol AAPL --event-type FILL --limit 50
pm-stats-cli order-lifecycle --gateway TRADER01 --order-id ORDER_ID
pm-stats-cli --format json order-events --gateway TRADER01 --date 2026-06-14
For API Gateway history queries, start the recorder, engine, stats database, and API gateway with matching config:
Then query order lifecycle history through HTTP with a trading API key:
curl -H 'Authorization: Bearer key-trader-demo' \
'http://127.0.0.1:8080/api/v1/history/orders?symbol=AAPL&event_type=FILL&limit=50'
API filters for /api/v1/history/orders:
| Query parameter | Required | Description |
|---|---|---|
symbol |
No | Restrict to one symbol |
event_type |
No | Restrict to one normalized type — see event_type values. An unknown value returns 422 |
date |
No | Restrict to one YYYY-MM-DD date based on order_events.ts |
from |
No | Inclusive ISO timestamp lower bound |
to |
No | Inclusive ISO timestamp upper bound |
limit |
No | Maximum rows to return, default 500, maximum 5000 |
after |
No | Opaque keyset-pagination cursor from a previous response's next_cursor; fetches the next page |
/history/orders (and /history/fills) responses include next_cursor when
more rows are available. See
API Gateway — Pagination for the full
count/has_more/next_cursor contract, including which endpoints are
exceptions to it.
To reconstruct one order's lifecycle, use the order ID path:
curl -H 'Authorization: Bearer key-trader-demo' \
'http://127.0.0.1:8080/api/v1/history/orders/ORDER_ID'
For fill-only history, use the shortcut endpoint:
curl -H 'Authorization: Bearer key-trader-demo' \
'http://127.0.0.1:8080/api/v1/history/fills?symbol=AAPL&date=2026-06-14'
Responses include an events array, count, and for list-style queries a has_more flag. Each event row mirrors the order_events table columns, so JSON output can be loaded directly into audit notebooks or support tooling.
Read-only API keys with gateway_id: null cannot query private order lifecycle history. Use a trading credential whose gateway_id owns the orders being investigated.
Output Formats¶
Table Format (default)¶
Human-readable aligned columns, designed for terminal viewing.
Good for: interactive exploration, demos, quick spot-checks.
JSON Format¶
Machine-readable structured output for automation and downstream tools.
Output:
Good for: scripts, APIs, BI tools, data pipelines.
CSV Format¶
Comma-separated values suitable for spreadsheets and data analysis tools.
Output:
ts,trade_id,symbol,price,quantity,aggressor_side,buy_gateway_id,sell_gateway_id
2026-06-14T09:00:01.000+00:00,1,AAPL,150.0,100,BUY,TRADER01,MM01
2026-06-14T09:00:05.123+00:00,2,AAPL,150.5,50,AUCTION,MM01,TRADER02
Good for: Excel, Google Sheets, R/Python data frames, general-purpose analysis.
Use --no-header to suppress the header row:
Common Analyst Workflows¶
End-of-Day Summary Report¶
Generate a quick summary of all symbols for a given trading date:
This shows open/close prices, bid/ask spreads, volume, trade count, and VWAP for every symbol.
Follow-up questions: - Which symbol had the highest volume? - What was the spread between open bid and close bid? - Did any symbol experience a large single trade?
Intraday Price Volatility Analysis¶
Check mid-price movement for one symbol throughout the day:
Look at the pct_change column to spot:
- Periods of high volatility (large jumps)
- Periods of stagnation (flat pricing)
- Potential technical support/resistance levels
- Times when the book was empty (null bids/asks)
Trade Flow Analysis¶
Examine all trades for a symbol to identify patterns:
Then analyze in a spreadsheet or Python:
import pandas as pd
trades = pd.read_csv('aapl_trades.csv', parse_dates=['ts'])
trades['hour'] = trades['ts'].dt.hour
# Trades per hour
print(trades.groupby('hour').size())
# Average trade size
print(trades.groupby('hour')['quantity'].mean())
# Who are the active participants?
print(trades['buy_gateway_id'].value_counts() + trades['sell_gateway_id'].value_counts())
Participant Performance Analysis¶
Export trade logs and group by participant to see:
pm-stats-cli --format json trades --date 2026-06-14 | jq '.[] | {buyer: .buy_gateway_id, seller: .sell_gateway_id, price: .price, qty: .quantity}' > participant_flows.json
Then aggregate in your tool of choice: - How many trades did each participant execute? - What was their average trade size? - Did they tend to be buyers or sellers?
Multi-Day Price Trends¶
Compare the same symbol across multiple trading dates. A date range is
required — without one, daily returns only the latest date:
pm-stats-cli --format csv daily --symbol AAPL \
--from-date 2026-01-01 --to-date 2026-06-30 --limit 1000 > aapl_history.csv
Either bound may be omitted for an open-ended range, so this exports everything recorded for the symbol:
pm-stats-cli --format csv daily --symbol AAPL --from-date 1970-01-01 --limit 1000 \
> aapl_history.csv
Rows come back oldest first. Check stderr for a truncation warning — if one
appears, raise --limit or page with --after.
This gives you historical OHLCV to track trends, seasonal patterns, or support/resistance zones over time. Remember that prices are unadjusted: a series spanning a corporate action is discontinuous and returns computed across it will be wrong.
Index Level History¶
Chart or export an exchange index's intraday level trajectory:
Look for the same signals snapshots gives for instruments — periods of rapid
level movement, gaps that may indicate a connectivity issue between
pm-index and pm-stats, and the session_state column shifting from
OPENING_AUCTION/CONTINUOUS/CLOSED.
Compare the index's daily performance across dates the same way you would for a symbol — again, a date range is required for more than one day:
pm-stats-cli --format csv index-daily --index-id EDU100 \
--from-date 2026-01-01 --to-date 2026-06-30 --limit 1000 > edu100_history.csv
Getting the EOD index level for a date¶
To look up an index's official end-of-day closing level for a specific date,
query index-daily for that date and index:
date | index_id | open_level | high_level | low_level | close_level | close_session_state | update_count
-----------|----------|------------|------------|-----------|-------------|----------------------|-------------
2026-06-14 | EDU100 | 1042.1 | 1056.3 | 1040.05 | 1048.73 | CLOSED | 512
close_level is the answer. For any date in the past this is always safe to
read directly — no more index.update events can arrive for a date once it
has rolled over, so close_level cannot change after the fact.
If you are querying today's date, confirm the row is actually final
before trusting it, since close_level is updated on every tick and is a
live "last level so far" until the session closes:
pm-stats-cli index-daily --index-id EDU100 --format json \
| python3 -c "import json,sys; r=json.load(sys.stdin)[0]; print(r['close_level'], r['close_session_state'])"
If close_session_state prints CLOSED, close_level is the final EOD
print. Any other value (e.g. CONTINUOUS, OPENING_AUCTION) means the
session is still running and close_level will keep moving — re-query
after the close, or wait for close_session_state to flip to CLOSED.
For scripting, JSON output makes this a one-line check:
pm-stats-cli index-daily --date 2026-06-14 --index-id EDU100 --format json \
| python3 -c "
import json, sys
row = json.load(sys.stdin)[0]
if row['close_session_state'] != 'CLOSED':
sys.exit('not final yet: ' + row['close_session_state'])
print(f\"EOD close for {row['date']} {row['index_id']}: {row['close_level']}\")
"
Validation — Did the Trade Complete Correctly?¶
After a trading session ends, verify key metrics:
- Check the recording is complete — do this first:
No rows found.means no loss was detected. Any row means the figures below are computed from an incomplete trade record, andvolume,turnoverandvwapfor the affected symbols will be understated. There is no way to recover the missing trades after the fact — ZeroMQ PUB/SUB has no replay — so treat the day's numbers as approximate and investigate whypm-statsfell behind.
pm-stats also logs a session total at INFO when it shuts down:
-
Check daily summary recorded:
Verify: all symbols present, volume > 0, open/close prices are reasonable. -
Check trade count:
Verify: matches expected number from the trading floor. If a# --no-header so every line is a trade; --limit must exceed the expected # count, or the result is silently truncated. pm-stats-cli --format csv --no-header trades --date 2026-06-14 --limit 100000 | wc -l[WARN] Output truncatedline appears on stderr, the count is not complete — raise--limitand re-run.
The authoritative count without any limit concerns:
pm-stats-cli --format json daily --date 2026-06-14 \
| python3 -c "import json,sys; print(sum(r['trade_count'] for r in json.load(sys.stdin)))"
-
Check for any empty books:
Empty books during active trading hours may indicate a problem. Use JSON here:pm-stats-cli --format json snapshots --symbol AAPL --date 2026-06-14 \ | python3 -c " import json, sys for row in json.load(sys.stdin): if row['best_bid'] is None or row['best_ask'] is None: print(row['ts'], row['best_bid'], row['best_ask']) "tableandcsvrender a null as an empty cell, so a text search fornullfinds nothing. -
Check largest trade vs. typical trade size:
Look atlargest_trade_qtyvs. average (volume / trade_count). Outliers warrant investigation.
Order Lifecycle Investigation¶
Use order_events when the question is about what happened to a submitted order rather than what trades printed to the market.
Examples:
# All recent events for a gateway
pm-stats-cli order-events --gateway TRADER01 --limit 100
# One order from ACK through fills, cancels, expiry, or rejection
pm-stats-cli order-lifecycle --gateway TRADER01 --order-id ORDER_ID
# Fill-only view for one symbol and date
pm-stats-cli order-events --gateway TRADER01 --symbol AAPL --event-type FILL --date 2026-06-14
Use this workflow to answer:
- Was the order accepted or rejected? (
ACK/REJECT, and equallyCOMBO_ACK/COMBO_REJECT,OCO_ACK/OCO_REJECT,QUOTE_ACK/QUOTE_REJECT) - Did an amend reset priority?
- Which fills belong to this order ID?
- Was the order cancelled or expired? (
CANCEL,EXPIRE, andOCO_CANCELfor an OCO leg) - Was it linked to a combo or OCO group? (
combo_parent_id,oco_group_id) - Does API Gateway history match the live private WebSocket events seen by the client?
# Every rejection across all event families for one gateway
for t in REJECT COMBO_REJECT OCO_REJECT QUOTE_REJECT; do
pm-stats-cli order-events --gateway TRADER01 --event-type "$t"
done
Integration with Other Tools¶
Combining with pm-ticker¶
pm-ticker uses data/stats.db to display OHLCV and volume context in its live display.
To verify pm-stats is recording correctly while pm-ticker runs:
# Terminal 1: Start engine
pm-engine --verbose
# Terminal 2: Start stats
pm-stats
# Terminal 3: Start ticker (reads from stats DB)
pm-ticker
# Terminal 4: Live-check stats as trades occur
watch -n 5 'pm-stats-cli daily | tail -5'
Exporting to BI Tools¶
Example: Export daily summaries to a cloud data warehouse:
# Export as CSV — pass a date range, or you get only the latest date
pm-stats-cli --format csv daily --from-date 2026-01-01 --limit 10000 > daily_stats.csv
# Upload to BigQuery, Redshift, Snowflake, etc.
bq load my_dataset.daily_stats daily_stats.csv
# Or load into local database
sqlite3 analysis.db <<EOF
.mode csv
.import --skip 1 daily_stats.csv daily_stats
EOF
Carry the session timezone with the export
stats.db records the session timezone in stats_meta, but a CSV export
does not — and date cannot be interpreted without it. Read it before
exporting and keep it with the file:
Tick size and price precision are now recorded, in the
instruments table — export it alongside your data and
the prices become interpretable on their own. Currency still is not:
EduMatcher has no currency model, and instruments.currency is a reserved
NULL rather than a value to read.
Python / Pandas Integration¶
Query and analyze directly in Python:
import subprocess
import json
import pandas as pd
# Get daily stats as JSON
result = subprocess.run(
['pm-stats-cli', '--format', 'json', 'daily', '--date', '2026-06-14'],
capture_output=True,
text=True
)
daily = pd.DataFrame(json.loads(result.stdout))
# Pivot to wide format for correlation analysis
daily_pivot = daily.set_index('symbol')
print(daily_pivot[['open_price', 'close_price', 'volume']])
# Calculate returns
daily['return_pct'] = (daily['close_price'] - daily['open_price']) / daily['open_price'] * 100
print(daily[['symbol', 'return_pct']])
Troubleshooting¶
No data recorded — where did the trades go?¶
-
Verify
If not running, start it.pm-statsis running: -
Check that
Look for log messages showing thatpm-statsconnected to the engine:pm-statssent abook.snapshot_request. -
Verify the database file exists and has the right tables:
You should see:daily_stats,price_snapshots,trade_log,order_events,index_daily_stats,index_level_snapshots,feed_gaps,instruments, andstats_meta. -
Check for recent trades:
If empty, no trades have executed yet. Execute a test trade first. -
Check for order lifecycle history:
If empty, no private order lifecycle topics have reachedsqlite3 data/stats.db "SELECT ts,event_type,order_id,gateway_id,symbol FROM order_events ORDER BY seq DESC LIMIT 5;"pm-statsyet. Submit, amend, cancel, or fill an order whilepm-statsis running.
No index data recorded — where did the index updates go?¶
-
Confirm the exchange actually has an index configured. If
engine_config.yamlhas noindexes:block,pm-indexpublishes nothing andindex_daily_stats/index_level_snapshotsstaying empty is correct behavior, not a bug. -
Verify
pm-indexis running:pm-statsconnects topm-index's own PUB socket (default port 5558), separate from the engine's PUB socket — ifpm-indexisn't running, there is nothing forpm-statsto receive. -
Check for recorded index updates:
Ifindex-idsreturns nothing,pm-statshas not received anyindex.updateevent yet — confirmpm-indexis up and has finished its own startup index calculation. -
Check
pm-statslogs at-v/INFOor higher forrecorded index update index_id=...lines, or run with--sql-traceto see the underlyingINSERT/UPDATEstatements againstindex_level_snapshots/index_daily_stats.
Queries return "No rows found" but I know data should exist¶
-
Check the date format:
Use the exact date returned, e.g.,--date 2026-06-14. -
Verify the symbol exists:
pm-stats-cliupper-cases--symbol,--gatewayand--index-idbefore querying, so--symbol aapland--symbol AAPLbehave identically. The values stored in the database are whatever the engine published — if those are not upper-case, the CLI cannot match them and you must query withsqlite3directly. -
Check the session timezone.
--dateselects a trading day in the--timezoneyou pass. Ifpm-stats-cliuses a different--timezonethanpm-statsrecorded with,--dateresolves to the wrong window and returns too few rows, too many, or none — with no error: -
Check the time window for snapshots/trades:
If using--from/--to, both bounds are inclusive instants. A bound without an offset is read as session-local time; appendZor an explicit offset to be unambiguous.
Database is locked or "unable to open"¶
-
stats.dbruns in WAL mode, so one writer (pm-stats) and any number of readers (pm-stats-cli,pm-ticker, the API Gateway) proceed concurrently without blocking each other. Both the writer and readers also set a 5-secondbusy_timeoutto absorb the brief exclusive lock taken during a WAL checkpoint. -
A second writer is still excluded. Attempting to write directly with
sqlite3whilepm-statsis running can still produce a lock error, and running twopm-statsprocesses against one file corrupts the daily rollups (see "One recorder per database" above). Usepm-stats-clifor queries and keep a single recorder per file. -
If you need to copy the DB for backup, do it online — do not
cpthe file. In WAL mode a plaincpcan capture a torn database without the companion-walfile:
Growth and retention
trade_log, order_events and feed_gaps are append-only and unbounded
— pm-stats never prunes them. Never prune feed_gaps: it is the only
record that a session was incomplete, and deleting it silently converts a
known-lossy dataset back into an apparently clean one. On a long-running deployment, plan for periodic
archival (copy out with VACUUM INTO, then DELETE old rows and VACUUM
— see the cursor caveat under Row identity and pagination
cursors)
and monitor the file size. There is no built-in retention setting.
Snapshot times seem wrong or are missing¶
- The first
book.*message for a symbol always writes a snapshot. After that, a snapshot is written when abook.*message arrives and the configured interval has elapsed since the last one for that symbol. - The default interval is 15 minutes (
--snapshot-interval 900). If you need finer resolution, startpm-statswith a smaller value, e.g.--snapshot-interval 60for one-minute snapshots. - If trading is light and no book updates occur during the interval, no snapshot is recorded for that period. This is by design — snapshots only record when the market moves.
To verify:
You should see roughly one entry every 15 minutes. Large gaps indicate periods with no trading.
VWAP calculation looks wrong¶
VWAP is recalculated on every trade and stored at that moment. The final VWAP for the day is stored in daily_stats after the close.
To verify VWAP manually:
# --limit must exceed the day's trade count, or you will be comparing a
# partial recomputation against the full-day figure and they will not match.
pm-stats-cli --format csv trades --symbol AAPL --date 2026-06-14 --limit 100000 | \
awk -F, 'NR>1 {qty_sum += $5; price_qty += $4*$5} END {print price_qty/qty_sum}'
This calculates \(\sum(price \times qty) / \sum(qty)\) from the trade log. Compare it to the value in daily_stats:
The awk figure is in display money because pm-stats-cli converted the
prices on the way out, while daily_stats.vwap is in ticks. Divide the
stored VWAP by 10^tick_decimals before comparing, or the two will differ by
a factor of a hundred and look catastrophically wrong when nothing is:
pm-stats-cli --format json daily --symbol AAPL --date 2026-06-14 --wide \
| python3 -c "
import json, sys
row = json.load(sys.stdin)[0]
scale = 10 ** row['tick_decimals']
print('vwap (money):', row['vwap']) # already converted on output
print('vwap (ticks):', row['vwap'] * scale)
print('turnover/vol :', row['turnover'] / row['volume'] / scale)
"
Because prices are stored as exact integers, the recomputation should now
agree to full precision rather than merely to within rounding — turnover is
an integer sum that does not depend on the order the trades were added.
If the figures disagree, check in this order:
- Are you comparing ticks against money? By far the most common cause.
- Was the output truncated? A
[WARN]line on stderr means the awk sum covered only part of the day. - Do
pm-statsandpm-stats-cliuse the same--timezone? If not,--dateselects a different set of trades than the one folded into thedaily_statsrow, and the two will never match.
See Also¶
- Processes — pm-stats and pm-stats-cli — full process documentation
- Processes — pm-ticker — live ticker that uses statistics data
- Audit Trail —
pm-audit-clifor querying the full event log - Persistence — where all data files are stored