Architecture¶
This page is the "how does it all fit together" entry point — the binaries that ship, how they talk to each other, and the shape of the data that flows through them.
What ships¶
graph TB
subgraph "User-launched binaries"
DASH["QE Dashboard.app<br/>(GLFW + ImGui + ImPlot)"]
RUN["qe_run<br/>(backtest CLI)"]
FCTR["qe_factor<br/>(factor research CLI)"]
CREDS["qe_creds<br/>(keyring CLI)"]
end
subgraph "Long-running"
DAEMON["qe_daemon<br/>(live event loop)"]
end
subgraph "Core library (linked statically)"
LIBQE["libqe.a<br/>BarSeries · Engine · DSL · Indicators · Analytics · IO"]
end
subgraph "External services"
IBKR["IBKR Gateway / TWS<br/>(127.0.0.1:7497 / 4002)"]
ALPACA["Alpaca REST + WS<br/>(api.alpaca.markets)"]
YAHOO["Yahoo Finance<br/>(quotes + news, free)"]
KEYCH["OS Keyring<br/>(Keychain / libsecret)"]
end
subgraph "On-disk artifacts"
QECFG[(".qe configs<br/>~/Documents/quant-strategy/")]
RESULTS[("results.json")]
FREPORT[("factor_report.json")]
JOURNAL[("events.jsonl<br/>~/Library/Application Support/<br/>qe_daemon/state/")]
LOGS[("daemon logs<br/>~/Library/Application Support/<br/>qe_daemon/logs/")]
end
DASH --> LIBQE
DASH -. quotes and news polls .-> YAHOO
DASH -. control socket .-> DAEMON
DASH -. spawns .-> RUN
DASH -. spawns .-> FCTR
DASH -. spawns via F6 Deploy .-> DAEMON
DASH -. reads .-> KEYCH
RUN --> LIBQE
RUN --> QECFG
RUN --> RESULTS
FCTR --> LIBQE
FCTR --> QECFG
FCTR --> FREPORT
DAEMON --> LIBQE
DAEMON --> QECFG
DAEMON --> JOURNAL
DAEMON --> LOGS
DAEMON --> IBKR
DAEMON --> ALPACA
DAEMON -. reads .-> KEYCH
CREDS --> KEYCH
Five binaries, one core library. Every binary links libqe.a
statically — the same backtest engine that powers qe_run also
powers qe_daemon's live event loop. The dashboard's
backtest-results renderer and the factor-research viewer read
the same files the CLIs produce. No client/server inside the
process tree — the dashboard and the daemon communicate over
a UNIX domain control socket; everything else is a one-shot
subprocess fork (Cmd+S on F3 → qe_run).
Backtest data flow (qe_run and the F3 → Cmd+S path)¶
sequenceDiagram
participant User
participant QE_Run as qe_run
participant Loader as load_qe()
participant Engine as MultiEngine
participant Strategy as Strategy (CRTP)
participant Reporter as HTML / JSON writers
User->>QE_Run: qe_run config.qe
QE_Run->>Loader: parse + bind + eval
Loader-->>QE_Run: BacktestValue<br/>(BoundAst + ExecutionSpec + DataSpec)
Note over QE_Run: Resolve data spec to BarSeries (yahoo / file / csv_url)
QE_Run->>Engine: run(MultiBarSeries, ExpressionStrategy, ExecutionConfig)
loop per bar i = 0 .. N-1
Engine->>Engine: apply pending fills at bar i (causality)
Engine->>Strategy: on_bar(i, MultiBarView, MultiPortfolio)
Strategy->>Strategy: cross_sectional_prepass (if XS sites)
Strategy->>Strategy: evaluate entry / exit / size
Strategy-->>Engine: submit_buy / submit_sell
end
Engine-->>QE_Run: BacktestResults<br/>(equity, trades, forecasts, attribution)
QE_Run->>Reporter: write_results_json() + render_html()
Reporter-->>User: results.json + report.html
Key invariants (the engine enforces these structurally; no strategy can bypass them):
- Causality. Signals computed at bar
imay use data from bars0..i. Fills execute at bari+1's open, neveri's close. SeeMultiEngine's hot loop —apply_fillruns BEFOREstrategy.on_bareach tick. - Single signal evaluator pool per strategy. Stateful
indicators (
sma,ema,rsi,atr,rolling_*, ...) keep one in-memory pool per Evaluator. The same Evaluator powers backtest AND live — see "Live trading data flow" below. - Zero allocations in the hot loop. Every
std::vectorholding trades / equity points isreserve()-d to a known upper bound. Verified bytests/test_alloc_counter.cpp.
Live trading data flow (qe_daemon + dashboard attach)¶
graph LR
subgraph "qe_daemon process"
MAIN[main loop]
QUOTES[IbkrQuoteSource<br/>tick queue]
AGG[BarAggregator<br/>per symbol]
LIVE["LiveEngine<br/>multi-leg + XS barrier"]
EVAL["Evaluator pool<br/>per leg"]
RISK["PreTradeRisk<br/>caps + pause + kill"]
ROUTER["OrderRouter<br/>broker submit"]
WDOG["DisconnectWatchdog<br/>3-state"]
JOURNAL["StateJournal<br/>append-only JSONL"]
SOCK["ControlSocketServer<br/>UNIX domain"]
HANDLER["DaemonControlHandler<br/>status / orders / log_tail"]
end
subgraph "Broker"
IBKR["IBKR Gateway<br/>(TWS protocol)"]
end
subgraph "Dashboard"
ATTACH[DaemonClient]
DCACHE[DaemonOrderCache<br/>3s poll]
F6[F6 TRADE panels]
end
IBKR -- ticks --> QUOTES
QUOTES -- pop --> MAIN
MAIN --> AGG
AGG -- closed bars --> LIVE
LIVE -- evaluate --> EVAL
EVAL -- intent --> RISK
RISK -- accepted --> ROUTER
ROUTER -- orders --> IBKR
IBKR -- fills --> ROUTER
ROUTER -- events --> JOURNAL
JOURNAL -- broadcast --> SOCK
WDOG -. poll is_connected .-> IBKR
WDOG -- soft-pause --> RISK
WDOG -- trip --> RISK
SOCK <-- control verbs --> ATTACH
HANDLER --- SOCK
ATTACH --> DCACHE
DCACHE --> F6
The dashboard is a polite observer. It does not own the
broker session — qe_daemon does — so closing the dashboard
window leaves trading running unchanged. The dashboard attaches
over the control socket (~/Library/Application Support/qe_daemon/daemon.sock),
polls daemon-side state every 3 s, and renders it through the
F6 panels (Working Orders / Executions / Account / Order Log).
Control verbs (stop, kill, pause, resume) flow back the
same way.
Why the daemon is its own process — broker connections authenticate per client; one client per account. Putting the broker session inside the dashboard means closing the dashboard disconnects the broker and stops the strategy mid-session. Note what it does not do: the orders you already have working at IBKR stay working. Nothing in this codebase cancels them, and the venue does not cancel them for you when a client goes away — so closing the dashboard would leave live orders running with nothing watching them. Putting the session in a long-running daemon is the only safe design for headless / overnight strategies.
Cross-sectional fork-join barrier (EPIC-69)¶
The live engine's tricky bit. Cross-sectional strategies
(signalize_universe, anything with is_top / is_bottom /
quantile) need ALL universe symbols' bars to be fresh before
computing ranks. Without a barrier, the first symbol to close
at session-end would trigger the prepass against 1 fresh +
(N-1) stale slots — the strategy's first rebalance would be
effectively random.
sequenceDiagram
participant A as Symbol A (1st to close)
participant B as Symbol B (2nd)
participant C as Symbol C (3rd, completes quorum)
participant Eng as LiveEngine
participant Risk as PreTradeRisk
participant Router as OrderRouter
A->>Eng: on_bar at ts_T
Eng->>Eng: record A in pending set
Note over Eng: pending 1 of 3 - defer
B->>Eng: on_bar at ts_T
Eng->>Eng: record B in pending set
Note over Eng: pending 2 of 3 - defer
C->>Eng: on_bar at ts_T
Eng->>Eng: record C - quorum reached
Note over Eng: flush_xs_batch with reason quorum
Eng->>Eng: refresh per_symbol_ctxs (all fresh)
loop for each XS leg
Eng->>Eng: cross_sectional_prepass
Eng->>Eng: evaluate entry / exit
end
Eng->>Risk: check intent per fired leg
Risk-->>Eng: accepted
Eng->>Router: submit intent
Note over Eng: pending cleared, ready for next bar
Three flush reasons appear in the daemon log as
[xs_batch:reason] suffixes:
reason |
When | Operator action |
|---|---|---|
quorum |
Every universe symbol ticked at ts. The healthy case. | None |
new_ts |
A bar at a fresh ts arrived while a prior batch was still pending. Prior batch force-flushed with stale slots. | Investigate why a symbol skipped a bar. |
timeout |
5 s wall-clock since batch start without quorum. WARN log. | Same as new_ts. |
Non-cross-sectional portfolios bypass the barrier entirely (per-bar fast path).
Disconnect watchdog state machine (EPIC-70)¶
stateDiagram-v2
[*] --> Healthy
Healthy --> Healthy: is_connected true
Healthy --> Paused: 3 misses (soft_pause)
Paused --> Healthy: reconnect (resume)
Paused --> Tripped: 30 misses (kill_switch trip)
Tripped --> [*]: restart required
Transition callbacks (omitted from the diagram for parser simplicity):
| Transition | Callback fired |
|---|---|
Healthy → Paused |
on_soft_pause("ibkr_disconnect_soft") → PreTradeRisk::set_watchdog_pause |
Paused → Healthy |
on_resume() → PreTradeRisk::clear_watchdog_pause |
Paused → Tripped |
KillSwitch::trip("ibkr_disconnect") + OrderScheduler::cancel_all (local pending slices) + a journaled count of what is still working at the venue — see below |
- Healthy → normal operation.
- Paused → new orders reject (
PreTradeRiskgate). Open orders untouched. Auto-resumes on reconnect within the trip window. Reversible. - Tripped → KillSwitch tripped, pending (never-submitted) staged slices dropped, daemon needs restart. Sticky. The daemon does not exit; it keeps consuming data and booking fills.
-
Orders already working at the venue survive a trip. A trip blocks new submissions; it does not reach the venue. This is a deliberate design decision (EPIC-88 D1), not a gap: cancelling is an explicit operator act, never a side effect of a trip.
Until EPIC-88 the callback did try
list_open_orders()→cancel_order()here. That loop was deleted rather than repaired (T88.2), because the only trip reason that reaches this callback isibkr_disconnect— at which point the connection is down, the list call returns "not connected", and it returned early. It never cancelled an order and could not. What replaced it is an honest report: the callback asks the broker how many orders are working, logs the answer, and journals avenue_orders_left_workingrow carrying either a count or the literalunknown (broker link down). A post-mortem has to be able to tell "there were none" from "we could not find out", and the old code made those identical.To actually cancel, use
qe_daemon cancel-allor the F6 CANCEL ALL control (EPIC-88 T88.1) while the link is up, or cancel by hand in TWS / IB Gateway / Client Portal. Note that after anibkr_disconnecttrip the link is by definition down, so the manual route is the only one available until it recovers. See Live-trading safety, Layer 3.
The dashboard's F6 broker: badge reflects the state directly:
connected (green) / paused · Ns (amber, with miss count) /
TRIPPED (reason) (red).
On-disk layout¶
graph TB
subgraph "~/Documents/quant-strategy/"
WS_BT["backtests/<br/>*.qe (hand-authored)"]
WS_SW["sweeps/<br/>*.qe (parameter sweeps)"]
WS_POS["positions/<br/>example_positions.json"]
WS_OUT["out/<br/>results.json (per-config)"]
end
subgraph "~/Library/Application Support/qe_dashboard/"
DASH_CFG["config.json<br/>(symbols, refresh_seconds,<br/>active_*_path, news_filter_focused, ...)"]
end
subgraph "~/Library/Application Support/qe_daemon/"
DAEMON_SOCK["daemon.sock<br/>(UNIX control socket)"]
DAEMON_STATE["state/<br/>events.jsonl + events-UTC.jsonl<br/>(flat; override per deploy<br/>with live(journal_dir = ...))"]
DAEMON_LOGS["logs/<br/>STEM-PID.log"]
end
subgraph "~/Library/Keychains/"
LOGIN_KC["login.keychain-db<br/>alpaca-paper, alpaca-live<br/>(IBKR has no API keys)"]
end
One folder per concern. The workspace lives under
~/Documents/quant-strategy/ (overridable via Cmd+, →
Settings → Workspace). The dashboard's own state goes under
Application Support/qe_dashboard/. The daemon's runtime state
(socket, journal, logs) lives under
Application Support/qe_daemon/ — distinct from the dashboard
so multiple daemons (one per .qe) can share the same machine.
Where to go next¶
- Build + setup —
install.md: wizard, scripted install, broker credentials. - Author a strategy —
first-factor-tutorial.md: 10-minute walkthrough from clone to a saved.qe. - Read the
.qereference —qe-language.md: grammar + every builtin. - Drive the dashboard —
dashboard-walkthrough.md: per-screen tour. - Operate the daemon —
live-trading-runbook.md+qe-daemon-smoke.md. - Forecasting layer —
forecasting.md: ridge / lassopredict_returnAPI. - Factor research —
factor-research.md:qe_factor-driven IC / long-short flow.