Python¶
There are two ways Python touches this project, and they are not the same thing. One needs nothing installed; the other compiles the engine.
| What it is | Needs | |
|---|---|---|
| Daemon client | Read and reconfigure a running qe_daemon over its control socket |
A Python interpreter. Nothing else. |
| quant-engine-py | nanobind bindings — run backtests and analytics in-process | A C++ toolchain, vcpkg, and a compile of the engine |
If you want to watch or steer a live daemon, you want the first. If you want to drive the backtest engine from a notebook, you want the second.
The boundary, for both¶
Python may start a run and read its results. Python may not be called from inside the engine's per-bar loop.
This is a correctness property, not a style preference. The hot loop is allocation-free with no virtual dispatch — held by a counting allocator in the engine's own test suite — and the performance numbers in Architecture assume it. A per-bar Python callback would take the GIL once per bar and allocate on every one, voiding those numbers while the allocation test still passed, because the allocations would happen on the Python side where it is not looking.
So there is no way to write a strategy in Python here. Strategies come
from a .qe file. That is enforced by a test, not by convention.
1. The daemon client¶
Ships in the engine repo at python/qe_daemon/. Standard library
only — it links nothing and builds nothing, so it runs from a machine
with no engine on it. That is the same property that lets the
dashboard attach to a daemon on another host.
import sys; sys.path.insert(0, "python")
from qe_daemon import DaemonClient, DaemonRefused
c = DaemonClient("/run/user/1002/qe_daemon.sock")
print(c.status()["slices_pending"])
print(c.reload_check(config="live_config.qe")["changes"])
Read verbs hang off the client; mutating verbs hang off c.control
— c.control.pause(), c.control.reload_apply(...). That mirrors the
daemon's own book / book_rebuild split, so a mutation is visible
at the call site rather than hidden in a keyword argument.
The split is naming, not enforcement. The daemon allow-lists the
read verbs by name and refuses everything else, per connection.
c.control.kill() against a read-only socket raises because the
daemon said no — not because the client declined to send it. That is
the property worth having, and it holds only because the policy is not
duplicated in the client.
Errors are typed and nothing returns a default: DaemonRefused
(the daemon answered no, carrying its own message), DaemonUnreachable
(socket), DaemonProtocolError (the reply did not match the
envelope). A verb your daemon predates raises DaemonRefused naming
it, which is how you feature-detect.
That last point is not fussiness. A previous probe read a key at the wrong nesting level, got a default, and reported an empty account for six hours against a daemon holding ten positions. The client subscripts everywhere so a shape change raises instead of reporting a plausible nothing.
See the daemon runbook for the operational context and for re-pinning the wire format.
2. quant-engine-py — the bindings¶
A separate repository, deliberately: the engine stays pure C++, its dependency manifest is untouched, and the headless deployment host never grows a Python build chain.
It is private, like the engine itself, and not on PyPI. Publishing a wheel that links a private engine needs a deliberate answer rather than a default; that answer is "later, once the surface has settled". For now it is a local editable install.
What is bound¶
import qe
# bars — struct-of-arrays in, read-only numpy views out
s = qe.BarSeries.from_arrays(timestamps=..., open=..., high=...,
low=..., close=..., volume=...)
mb = qe.MultiBarSeries.from_series(symbols=["AAA", "BBB"], series=[s, s2])
mb.align_intersection()
mb.bars[mb.symbol_index("AAA")].close # float64 view, no copy
# a .qe, evaluated and run
cfg = qe.load_qe("strategy.qe")
res = qe.run_backtest(cfg, qe.load_bars(cfg))
# analytics
qe.ic_analysis(...) qe.rolling_ic_analysis(...)
qe.long_short_backtest(...)
qe.sharpe(...) qe.sortino(...) qe.max_drawdown(...)
qe.cagr(...) qe.total_return(...) qe.kahan_sum(...)
Not bound yet: sweeps, walk-forward, research configs, and individual trades as objects.
Three things that will surprise you if nobody says them¶
Bar columns are views over the engine's own buffers, not copies,
and they are read-only. BarSeries is parallel std::vector<double>
by architectural rule, which is already numpy's layout — that is the
one place this design is pleasant to bind. Read-only because the
engine's buffers are not Python's to write through, and a per-bar
mutation surface is the boundary above in a different costume.
run_backtest returns numbers and no provenance. A run handed
caller-supplied bars must not publish an artifact naming a data file
it never read; nothing downstream could detect that forgery. No
library entry point produces provenance at all — the only thing that
does is the qe_run CLI, whose path loads from the config it records.
So if you need a reproducible artifact, run the .qe through
qe_run. A test asserts both halves of this.
Drawdown has two sign conventions, and this is the engine's, not
the binding's: qe.max_drawdown returns a positive fraction
(0.25 = gave back a quarter), while LongShortResult.max_dd is the
most negative excursion (-0.202). Compare like with like.
Version alignment, which is the real cost¶
Two repos, no CI, and a C++ API with no stability guarantee. The failure mode is a binding compiled against one engine and read as though it spoke for another: it imports, it runs, and it returns subtly different numbers.
Three things hold the line:
- The engine is a submodule pinned at a tag, never a branch. The
consequence is deliberate: the bindings lag the engine by one
release. A commit landing on the engine's
maintoday is not reachable from Python until the next tag. - The extension carries the version and commit it was compiled from — the version read from the engine's own generated constant, not a literal the bindings maintain. At import, if the submodule source is present, its current commit is compared and a mismatch raises. Not warns: the whole point is that the mismatch is invisible in the results.
- A parity test runs one
.qethrough bothqe_runand the bindings and requires the equity curve to match point for point, withqe_runbuilt from the same pinned tag so a version difference cannot be mistaken for a binding bug.