Skip to content

Commit 962eab4

Browse files
authored
Refactoring to support live trading and research (#59)
1 parent 20e9cc8 commit 962eab4

211 files changed

Lines changed: 32970 additions & 958 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,7 @@ dump.rdb
4646
.infisical.json
4747
cmake-build-debug
4848
.idea/
49-
.clang-tidy
49+
.clang-tidy
50+
# AWS SDK for C++ install prefix (built per-node by scripts/build_dep.sh
51+
# from the external/aws submodule)
52+
external/aws-install/

.gitmodules

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
[submodule "external/boost-decimal"]
2+
path = external/boost-decimal
3+
url = https://github.com/boostorg/decimal.git
14
[submodule "external/libpqxx"]
25
path = external/libpqxx
36
url = https://github.com/jtv/libpqxx.git
4-
[submodule "external/boost-decimal"]
5-
path = external/boost-decimal
6-
url = https://github.com/boostorg/decimal
77
[submodule "external/Catch2"]
88
path = external/Catch2
99
url = https://github.com/catchorg/Catch2.git
10+
[submodule "external/aws"]
11+
path = external/aws
12+
url = https://github.com/aws/aws-sdk-cpp

ARCHITECTURE.md

Lines changed: 346 additions & 0 deletions
Large diffs are not rendered by default.

CMakeLists.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,21 @@ target_link_libraries(BacktestingEngineLib PUBLIC Boost::decimal)
232232

233233
target_link_libraries(BacktestingEngineLib PUBLIC pqxx OpenMP::OpenMP_CXX)
234234

235+
# AWS SDK for C++, DynamoDB client only — IG session credentials live in the
236+
# MarketDataLive table (shared/aws/dynamoAuth). Deliberately NOT an
237+
# add_subdirectory: the SDK is enormous and must not inherit this project's
238+
# global module/OpenMP flags, so scripts/build_dep.sh builds and installs it
239+
# once (static libs) into external/aws-install and it is consumed here as a
240+
# prebuilt package. AWSSDK_LINK_LIBRARIES resolves to the dynamodb target,
241+
# which chains core + CRT dependencies through its exported config.
242+
list(APPEND CMAKE_PREFIX_PATH "${CMAKE_SOURCE_DIR}/external/aws-install")
243+
# The static aws-cpp-sdk-core's exported link interface names ZLIB::ZLIB
244+
# (and curl/OpenSSL targets, already found above) but its config does not
245+
# find_dependency them — resolve ZLIB here first.
246+
find_package(ZLIB REQUIRED)
247+
find_package(AWSSDK REQUIRED CONFIG COMPONENTS dynamodb)
248+
target_link_libraries(BacktestingEngineLib PUBLIC ${AWSSDK_LINK_LIBRARIES})
249+
235250
# Main executable
236251
add_executable(BacktestingEngine source/main.cpp)
237252
target_link_libraries(BacktestingEngine BacktestingEngineLib)

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ I am actively experimenting with different approaches and want to avoid merge co
1212

1313
The project is [MIT-licensed](LICENSE.MD), so you're very welcome to fork it and take the code in your own direction.
1414

15+
To build and test: `scripts/build.sh`, then `ctest --test-dir build` (see the README and [QUICKSTART.md](QUICKSTART.md) for detail). Code style lives in [CONVENTIONS.md](CONVENTIONS.md).
16+
1517
## Use GitHub Issues for bugs, questions, and ideas
1618

1719
Bugs, questions, and ideas are all welcome on the [Issues tab](https://github.com/mccaffers/backtesting-engine-cpp/issues).

CONVENTIONS.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ Plain headers live next to the code they belong to under `source/` (e.g.
44
separate `include/` folder. The build exposes a single source root (`source/`),
55
so every project `#include` is **source-relative and path-qualified**, e.g.
66
`#include "shared/utilities/env.hpp"`. This makes each include / module global
7-
module fragment dependency self-describing. Both `source/*.cpp` and
8-
`source/*.cppm` are globbed automatically (`CONFIGURE_DEPENDS`), so a new file is
9-
picked up without editing `CMakeLists.txt`.
7+
module fragment dependency self-describing. (The build also exposes a second
8+
PUBLIC include root, `external/`, only for vendored third-party headers like
9+
`nlohmann/json.hpp`.) Both `source/*.cpp` and `source/*.cppm` are globbed
10+
automatically (`CONFIGURE_DEPENDS`), so a new file under `source/` is picked up
11+
without editing `CMakeLists.txt``source/` only; tests are not globbed, see
12+
below.
1013

1114
### Pragma once
1215
Headers should use `#pragma once` directive to guard to prevent multiple inclusions of the same header file.
1316

14-
### Lower Camel Case names
15-
camelCase applies to file names, types, and namespaces.
16-
application.cpp / class Application() / namespace tradingDefinitions
17+
### Naming
18+
File names are lowerCamelCase, types are PascalCase, namespaces are snake_case.
19+
databaseConnection.cppm / class DatabaseConnection / namespace symbol_scale
20+
(the one legacy camelCase namespace is tradingDefinitions)
21+
22+
### New files should come with ctests
23+
only exception would be if they are using libraries (eg. boost), we don't need to test libraries.
24+
Unlike `source/`, test translation units are NOT globbed: `tests/CMakeLists.txt`
25+
lists every file explicitly in `add_executable(unit_tests ...)`, so a new
26+
`tests/foo.cpp` silently never builds or runs until added there. Tests register
27+
with ctest via `catch_discover_tests` in the same file.

ENVIRONMENT.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Environment Variables
2+
3+
All engine configuration is read from the environment via `env::getOr(name, fallback)` (`source/shared/utilities/env.cpp`) — every variable is **optional with a default** unless noted. An unset *or empty* variable falls back to its default.
4+
5+
The `run` and `analysis` subcommands print a startup diagnostic dump of the whole environment to stderr, masking any variable whose name contains `PASSWORD`, `PASSWD`, `SECRET`, `TOKEN`, `CREDENTIAL`, `KEY`, or `AUTH`.
6+
7+
## Quick reference
8+
9+
| Variable | Default | `load` | `experiments` | `run` | `analysis` | `ingest` | `live` | `tracking` | `positions` | Purpose |
10+
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | --- |
11+
| `REDIS_HOST` | `127.0.0.1` ||||| |||| Redis host (port is always `6379`) |
12+
| `BACKTEST_BATCH` | *(current UTC ISO week)* ||| | | | | | | Weekly batch label (`YYYY-WW`, e.g. `2026-28`) naming the Elasticsearch outcome indices (`backtesting-results-2026-28`, ...; `experiments`: `backtesting-experiments-2026-28`). Frozen into every queued run descriptor and carried through all rolling-window rungs; `scripts/load.sh` exports it once so its per-strategy invocations share one label. Set explicitly to re-run into a past week's indices |
13+
| `ENTRY_SLIPPAGE_TENTH_PIPS` | `0` || | | | | | | | Slippage stress toggle: every backtest entry fills this many **tenths of a pip** against the trade (`3` = 0.3 pip; SL/TP anchors stay on the raw tick). Read once at `load` and frozen into every queued run descriptor, carried through all rolling-window rungs, and recorded in each run's results doc. **Validated** — a non-integer or negative value fails the load |
14+
| `QUESTDB_HOST` | `127.0.0.1` | | || ||| | | QuestDB host (`run`: the required `<questdb-host>` argument covers tick reads only — the OHLC/range-bar warm-up still connects to `$QUESTDB_HOST`, so `run <remote-host>` reads ticks from the argv host but seeds bar histories from here; `live`: OHLC pre-population reads) |
15+
| `QUESTDB_PORT` | `8812` | | ||| || | | QuestDB pgwire port for tick reads (`live`: OHLC pre-population reads). **Validated** — a non-numeric value is a hard failure |
16+
| `QUESTDB_ILP_PORT` | `9000` | | | | || | | | QuestDB ILP-over-HTTP port for tick writes |
17+
| `TICK_CACHE_TTL_MINUTES` | `60` | | || | | | | | Lifetime of a cached tick superset before it is reloaded from QuestDB (queue mode only — direct mode and `analysis` bypass the cache). **Validated** — a non-numeric or negative value throws on the cache's first use (once the first run is claimed), aborting the worker; `0` passes |
18+
| `TICK_CACHE_MAX_SUPERSETS` | `1` | | || | | | | | How many tick supersets (one per symbol set) a worker keeps resident before evicting the oldest. **Validated** — same rule as the TTL |
19+
| `ELASTIC_ENABLED` | `1` ||||| |||| Master toggle for Elasticsearch reporting; set `0` to disable (`load`/`experiments`: also skips the weekly index/alias admin; `live`: also silences the trade audit, the trace documents, and the `live-logs` log feed; `tracking`: also silences the per-deal `live-trades` documents; `positions`: also silences the per-cycle `live-function-logs` reports) |
20+
| `ELASTIC_HOST` | `http://localhost:9200` ||||| |||| Elasticsearch base URL (results reporting; weekly index/alias admin at `load`/`experiments`; `analysis` experiment documents; live winner source + trade audit; `tracking` deal documents; `positions` cycle reports) |
21+
| `ELASTIC_USER` | *(empty)* ||||| |||| Basic-auth username; empty means no auth header |
22+
| `ELASTIC_USER_PASSWORD` | *(empty)* ||||| |||| Basic-auth password |
23+
| `ELASTIC_TRADES_ENABLED` | `0` | | || | | | | | Set `1` to bulk-index every closed trade into `backtesting-trades` |
24+
| `ELASTIC_DEADLETTER_PATH` | `elastic_deadletter.ndjson` | | ||| |||| File that failed Elasticsearch documents are appended to after retries are exhausted |
25+
| `ELASTIC_FLUSH_SECONDS` | `30` | | ||| |||| Cadence of the background `_bulk` flusher that delivers queued reporting documents (run outcomes, experiment documents, engine exceptions, live trade audits, tracking deal documents); intervals with an empty buffer send nothing. A non-numeric or `< 1` value falls back to `30` with a logged warning |
26+
| `OHLC_PREPOPULATE` | `1` | | || | || | | Seed OHLC **and range** bar histories from QuestDB at each symbol's first tick (`run`: replay start; `live`: launch — big-bar strategies trade immediately instead of warming up for days). One switch covers both bar types; set `0` to disable all warm-up queries |
27+
| `INGEST_BIND_ADDR` | `127.0.0.1` | | | | || | | | UDP bind address for the tick receiver |
28+
| `INGEST_UDP_PORT` | `11111` | | | | || | | | UDP bind port (overridable by the command's port argument) |
29+
| `TRACKING_BIND_ADDR` | `127.0.0.1` | | | | | | || | UDP bind address for the deal/trade-update receiver |
30+
| `TRACKING_UDP_PORT` | `11112` | | | | | | || | UDP bind port (overridable by the command's port argument) |
31+
| `LIVE_BIND_ADDR` | `127.0.0.1` | | | | | || | | UDP bind address for the live tick receiver |
32+
| `LIVE_UDP_PORT` | `11110` | | | | | || | | UDP bind port (overridable by the command's port argument) |
33+
| `LIVE_MIN_SCORE` | `20` | | | | | || | | Winner floor on `results.performanceScore` when selecting strategies from Elasticsearch (top 3 per (symbol, strategy) pair is fixed in code, not env-configurable) |
34+
| `LIVE_MAX_DRAWDOWN_PERCENT` | `10` | | | | | || | | Hard ceiling on `results.maxDrawdownPercent` (peak-to-trough giveback) when selecting winners — the Calmar half of `performanceScore` only blends drawdown in, so this gate is what actually excludes spiky runs |
35+
| `LIVE_MIN_CALMAR_SCORE` | `30` | | | | | || | | Floor on `results.calmarScore` when selecting winners (Calmar ratio ~2 on the score scale where ratio 3 = 50): growth must be ~2x the worst giveback. Complements the drawdown ceiling — the floor rejects smooth-but-stagnant runs, the ceiling rejects fast growers with deep absolute givebacks |
36+
| `LIVE_TRADE_LOCK_SECONDS` | `30` | | | | | || | | TTL of the per-(strategy, direction) Redis trade lock |
37+
| `LIVE_TRACE_ENABLED` | `1` | | | | | || | | Emit live-mode trace documents (order/close lifecycle, book sync, IG guard refusals, startup/shutdown, minutely stats) to the Elasticsearch index `live-traces` through the async batch publisher; set `0` to disable. Every document carries an `env` field (`TRADING_ENVIRONMENT`). Delivery still requires `ELASTIC_ENABLED=1` |
38+
| `LIVE_LOG_SHIP_ENABLED` | `1` | | | | | || | | Ship every engine log line (`logLine`/`error`) as a document to the Elasticsearch index `live-logs`; set `0` to disable. An independent kill switch from `LIVE_TRACE_ENABLED` — the narrative log and the structured trace events are separate feeds. Delivery still requires `ELASTIC_ENABLED=1` |
39+
| `TRADING_ENVIRONMENT` | `demo` | | | | | |||| IG environment; lowercased into the DynamoDB credentials key `Auth#<env>` (`demo`/`live`); `tracking` stamps it into each `live-trades` document's `env` field |
40+
41+
## AWS credentials (`live` and `positions`)
42+
43+
The IG session credentials are pulled from the DynamoDB table `MarketDataLive` (`source/shared/aws/dynamoAuth`). The engine reads no AWS variables itself — the AWS SDK's default credential/region chain applies:
44+
45+
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION` — or a configured profile / instance role.
46+
47+
Effectively **required** for `live` order placement and the `positions` sync (without a session, `positions` logs a warning and every cycle skips safely); there is no in-code default.
48+
49+
## Build and script variables
50+
51+
| Variable | Default | Read by | Purpose |
52+
| --- | --- | --- | --- |
53+
| `CC` / `CXX` | `clang` / `clang++` | `scripts/build.sh` (non-Homebrew path) | Pin a specific compiler, e.g. `CC=clang-20 CXX=clang++-20` |
54+
| `ENABLE_COVERAGE` | `OFF` | `scripts/build.sh` | Instrument with Clang source-based coverage (CI turns it on for the SonarCloud report) |
55+
| `CLEAN` | `0` | `scripts/test.sh` | `CLEAN=1` forces a clean reconfigure before the test build |
56+
57+
`scripts/run.sh` additionally **requires** `ELASTIC_HOST`, `ELASTIC_USER`, `ELASTIC_USER_PASSWORD`, and `REDIS_HOST` to be set and non-empty — it aborts up front if any are missing (the engine itself would fall back to the defaults above). It also pins `ELASTIC_TRADES_ENABLED=0` for the run it launches (per-trade indexing off).
58+
59+
## Secrets management
60+
61+
I manage secrets with [Infisical](https://infisical.com/), which injects them into the process environment at runtime:
62+
63+
```
64+
infisical run -- bash ./scripts/run.sh
65+
```
66+
67+
If you're not using Infisical, export the variables yourself (shell profile or a sourced `.env`) before invoking the scripts.

0 commit comments

Comments
 (0)