![]() |
algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
|
One sentence, and the rest of this page is the argument for it: a strategy is pure, a broker is a type parameter, and the runner is the only thing that touches both.
Everything else follows. A strategy that holds no session can be replayed against history without faking a transport, so a backtest exercises the same source a live account does. A broker that is a type parameter rather than a virtual base costs nothing at run time and turns "you handed a KIS reference to a Toss session" into a compile error. And a runner that is the only component holding both is the only place the order-safety rules have to be written.
graph TD
subgraph W["what you write"]
S["your strategy<br/><i>a struct with template handlers</i>"]
end
subgraph L["the library"]
direction TB
V["<b><vanilla></b><br/>reference strategies"]
ST["<b><strategy></b><br/>events, intents, portfolio,<br/>risk limits, reconciliation"]
RU["<b><runner></b><br/>Runner, context, dispatch,<br/>PollingLoop, journal, stops"]
BT["<b><backtest></b><br/>ReplayClock, fill and cost<br/>models, ledger, report"]
LV["<b><live></b><br/>SessionExecutor,<br/>SessionSource, bind"]
AN["<b><analytics></b><br/>frames, indicators, metrics"]
MA["<b><math></b><br/>Scalar, matrices"]
BR["<b><broker></b><br/>BrokerTraits, BrokerCaps,<br/>BrokerSession"]
CO["<b><core></b><br/>Decimal64, Money, InstrumentId,<br/>bars, calendar, Result, BrokerError"]
NW["<b><network></b><br/>HTTPS transport, OAuth,<br/>WebSocket, rate-limit pacer"]
KT["<b><kis></b> <b><tossinvest></b> <b><kiwoom></b><br/>adapters: one BrokerTraits<br/>specialization each"]
end
X(("KIS / Toss / Kiwoom<br/>HTTPS"))
S --> ST
V --> ST
ST --> CO
RU --> ST
BT --> RU
BT -. "with a backend" .-> AN
LV --> RU
LV --> BR
AN --> MA
AN --> CO
BR --> CO
BR --> NW
KT --> BR
KT --> NW
LV -.binds.-> KT
NW --> X
classDef pure fill:#e8f4ea,stroke:#4a7a55,color:#123
classDef io fill:#fdeee6,stroke:#a4663c,color:#123
classDef you fill:#e7eefc,stroke:#3f5f9e,color:#123
class S you
class V,ST,RU,BT,AN,MA,CO pure
class BR,NW,KT,LV io
Green is I/O-free. Orange is the broker side; its HTTP and TLS code is in <network> and the adapters, which <algotradelib/broker> and <algotradelib/live> reach only as far as the rate-limit header. The green boundary is not a convention — it is asserted at compile time by two translation units that include exactly one umbrella header each and #error if a transport or adapter macro is visible: tests/test-strategy-isolation.cpp for <algotradelib/strategy>, and tests/test-runner-isolation.cpp for <algotradelib/runner>. A third, tests/test-broker-isolation.cpp, holds <algotradelib/broker> to a narrower line: Asio and the network module's rate-limit types, but no Beast and no OpenSSL.
What that assertion does and does not claim is worth being exact about. It establishes that a dry-run or replay translation unit instantiates no adapter and no transport code, so there is no instantiated code path from a backtest to a market. It does not claim libssl is off the link line: the package exports one interface target for every consumer, so an offline binary carries the dependency it never calls; the library does not have a transport-free target yet.
Two other edges are worth reading off the picture:
An adapter must not expose vendor types to the strategy layer. That is the rule most libraries state, and on its own it is not enough here. The one underneath it is about values:
Core is broker-neutral in its values, not only in its types. A closed enum class in core/ whose members name one country's venues or sessions satisfies "no vendor types" and still breaks the layering, because the leak is in the enumerators.
The test is not "does this cover the brokers we have" but "could someone add a broker for another country, in their own project, without editing this library?" BrokerTraits is already specializable out of tree; the vocabulary has to be extensible the same way. Where it is not yet — Venue enumerates one country's market structure — that is a known gap rather than a settled design.
Code<N> is the fixed-capacity, allocation-free preservation type that exists for this: a code the library does not recognize is carried verbatim beside the normalized enums rather than discarded — as far as it fits. Code<N> holds at most N characters from A-Z, 0-9 and _, and a code outside that alphabet or over that length is not stored, which is why every field built on it documents an empty value as meaning either "nothing was sent" or "what was sent would not fit".
Runner::feed is the whole engine. What changes between a dry run, a backtest and a live session is the executor it sends through and the clock it reads — not the strategy, and not the reconciliation, the risk guard or the order-safety rules.
graph LR
ST["your strategy<br/>(unchanged in all three)"]
subgraph R["Runner::feed"]
direction TB
D["dispatch to a handler"] --> I["intents"] --> RC["reconcile against<br/>portfolio + working orders"] --> RG["risk guard<br/>+ kill switch"] --> PL["planned orders"]
end
ST --> D
PL --> E1["RecordingExecutor<br/><b>DryRunner</b>"]
PL --> E2["QueueingExecutor + ReplayLedger<br/><b>Backtester</b>"]
PL --> E3["SessionExecutor<br/><b>live::bind</b>"]
E1 --> O1["a list of what it would have sent"]
E2 --> O2["fills, an equity curve,<br/>a Report with its caveats"]
E3 --> O3["a BlockingSession,<br/>then a broker"]
classDef pure fill:#e8f4ea,stroke:#4a7a55,color:#123
classDef io fill:#fdeee6,stroke:#a4663c,color:#123
class ST,D,I,RC,RG,PL,E1,E2,O1,O2 pure
class E3,O3 io
The consequence that pays for the whole design: NextBarFillModel::decide takes the next bar and nothing else, so a backtest cannot see the signal's own bar. The lookahead discipline is structural rather than a rule to remember — and because the strategy source is byte-identical across the three columns, what the replay scored is what the live loop runs.
PollingLoop wraps the live column with a cadence, a stop control and two gates. It is not a fourth configuration; it calls the same feed.
sequenceDiagram autonumber participant Src as SessionSource participant PLoop as PollingLoop participant Run as Runner participant Str as your strategy participant Jrn as FileJournal participant Exe as SessionExecutor participant Brk as broker PLoop->>Src: it is time for bars Src->>Brk: bars, holdings, working orders Brk-->>Src: snapshots Src-->>PLoop: BarEvent + a fresh portfolio PLoop->>Run: feed(event) Run->>Str: on_bar(ctx, event) Str-->>Run: TargetPosition Note over Run: diff the target against the portfolio<br/>and the working orders Note over Run: round to tick and lot, clamp into<br/>today's band, apply every risk limit Run->>Jrn: append + fsync, before the send Run->>Exe: place / replace / cancel Exe->>Brk: POST Brk-->>Exe: ack, rejection, or nothing Exe-->>Run: ACCEPTED / REJECTED / INDETERMINATE Run->>Jrn: append the outcome
The executor's answer — ACCEPTED, REJECTED or INDETERMINATE — is the step the rest of the library is arranged around. A POST that fails in transit is not a failure — it is an unknown, because the order may or may not have reached the broker. The runner never retries it. It marks the instrument in the indeterminacy ledger and withholds everything further for that instrument until the broker's own order list has been read, because a retry is exactly what doubles a position.
The append + fsync before the send is why the journal exists. The record is appended, flushed and fsynced before the send is attempted, so it exists during the exact window in which the process may vanish. An in-memory ledger cannot survive a SIGKILL, and an empty ledger after a restart means nothing is remembered, not that nothing is owed.
An adapter is a BrokerTraits<Client> specialization: associated types, static constexpr bool capability flags, one-line call thunks, and pure normalizers. It is non-intrusive — the client class does not inherit anything — and it lives in the adapter's own directory.
graph TD SC["strategy-side code<br/><i>names no broker</i>"] BS["BrokerSession<Client><br/><i>one verb set, no vendor type</i>"] BT["BrokerTraits<Client><br/><i>specialized per broker,<br/>out of tree if you like</i>"] BC["BrokerCaps<Client><br/><i>static constexpr bool</i>"] K["KisClient"] T["TossClient"] M["your broker"] SC --> BS BS --> BT BS --> BC BT -.specialized for.-> K BT -.specialized for.-> T BT -.specialized for.-> M SC -. "escape hatch:<br/>session.client()" .-> K classDef pure fill:#e8f4ea,stroke:#4a7a55,color:#123 classDef io fill:#fdeee6,stroke:#a4663c,color:#123 classDef ext fill:#f4eefa,stroke:#6b4a8a,color:#123 class SC,BC pure class BS,BT,K,T io class M ext
Core headers include no broker header, and adding a broker edits no shared file: tests/mock_broker.hpp adds a broker of its own entirely within itself, which is the only evidence worth having for that claim. ATL_ASSERT_BROKER(Client), asserted once in the adapter's own tests, compiles every verb the capabilities claim and reads every capability constant (see the broker port). These checks are for safety at build time, not for speed: they generate no code, and what is fast at run time is the static dispatch they check.
Three consequences that come up constantly:
Brokers do not publish the same information, and that is not a problem to be solved by normalization.
What this rules out is the tempting middle: a normalized model for something only one broker reports, so the shared type carries a field that is always empty for the other; or a synthesized value the broker never sent — resampling bars to fake an interval, deriving a session window from a holiday list. Both produce a surface that looks portable and is not. An interval a broker does not publish is refused, never aggregated from a shorter one.
The same instinct is why nothing is ever silently downgraded. A condition the caller stated is either honored or rejected with INVALID_ARGUMENT before any authentication and before any socket write — never quietly approximated, because a downgrade changes when and at what price an order executes.
graph LR W["the wire and the ledger<br/><b>Decimal64</b><br/><i>exact, no binary float</i>"] A["analytics<br/><b>math::Scalar</b><br/><i>double by default</i>"] P["an order price<br/><b>strategy::Price</b>"] W -- "to_scalar<br/>(total)" --> A A -- "round_scalar_to_tick, then Price::checked<br/><i>optional — refuses a<br/>non-finite value, and refuses<br/>an unknown tick ladder</i>" --> P P --> W classDef pure fill:#e8f4ea,stroke:#4a7a55,color:#123 class W,A,P pure
Prices, quantities and money stay in Decimal64 anywhere they cross the wire or are reconciled, because binary floating point cannot hold them exactly and a reconciliation compares for equality. Analytics needs a float, so it gets one — and the two meet at three named, tested functions in analytics/conversion.hpp: to_scalar one way, and to_decimal (for a computed quantity or weight) and round_scalar_to_tick (for a price, whose result still goes through Price::checked) back.
The return path is the narrow one. strategy::Price has no public constructor. The only routes in validate against the instrument's PriceRule, so an indicator value cannot reach the wire as an off-tick limit price. An instrument whose ladder the library does not know gets no answer rather than a guess, because attaching the equity grid to an ETF would round onto a ladder the exchange does not use and the order would be rejected — with the library having confidently produced the price.
That is also why the tick rule lives in market data rather than in the strategy layer, and why PriceRule records its own source_: no adapter builds the grid from a broker's reply (a broker states at most the tick at one price), so the table is this library's own and a stale one has to be visible rather than assumed.