algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
engine.hpp
Go to the documentation of this file.
1#pragma once
2
3#include "./clock.hpp"
4#include "./fill.hpp"
5#include "./ledger.hpp"
6
7#include "../runner/order_updates.hpp"
8#include "../runner/runner.hpp"
9
10#include <algotradelib/core>
11
12#include <chrono>
13#include <cstddef>
14#include <cstdint>
15#include <optional>
16#include <stdexcept>
17#include <string>
18#include <utility>
19#include <vector>
20
21namespace algotradelib {
22namespace backtest {
23
32
38template <typename BrokerTag>
45 std::uint64_t accepted_on_{0};
56 std::optional<std::chrono::system_clock::time_point> decided_at_;
70 bool released_{false};
75 std::optional<Date> session_;
76};
77
86template <typename BrokerTag>
88public:
89
91 static constexpr bool reaches_a_broker = false;
104 if (!can_model(order)) {
106 }
108 entry.order_ = order;
109 entry.ref_.primary_ = std::to_string(++issued_);
110 entry.accepted_on_ = bar_;
111 entry.decided_at_ = deciding_at_;
112 entry.decision_is_exact_ = decision_is_exact_;
113 last_ref_ = entry.ref_;
114 queued_.push_back(std::move(entry));
116 }
117
129 static bool can_model(const strategy::PlannedOrder& order) noexcept {
130 if (order.session_ != OrderSession::REGULAR) {
131 return false;
132 }
133 switch (order.type_) {
135 // A stop the model can release: both prices, and a day order (the only stop KIS and Kiwoom offer).
136 return order.trigger_price_.has_value() && order.price_.has_value() &&
137 order.time_in_force_ == TimeInForce::DAY;
138 case OrderType::LIMIT:
140 // A trigger on a limit or market order would be ignored; refuse it.
141 return models_time_in_force(order.time_in_force_) && !order.trigger_price_;
142 case OrderType::UNKNOWN:
148 return false;
149 }
150 return false;
151 }
152
163 static constexpr bool models_time_in_force(TimeInForce condition) noexcept {
164 switch (condition) {
165 case TimeInForce::UNKNOWN:
166 case TimeInForce::DAY:
169 return true;
170 }
171 return false;
172 }
173
181 for (auto at = queued_.begin(); at != queued_.end(); ++at) {
182 if (at->ref_ == working.ref_) {
183 queued_.erase(at);
185 }
186 }
188 }
189
194 std::optional<OrderRef<BrokerTag>> last_ref() const { return last_ref_; }
195
200 const std::vector<QueuedOrder<BrokerTag>>& queued() const noexcept { return queued_; }
201
208 for (const auto& entry : queued_) {
209 if (entry.ref_ == ref) {
210 return &entry;
211 }
212 }
213 return nullptr;
214 }
215
221 void assign_session(const OrderRef<BrokerTag>& ref, Date session) {
222 for (auto& entry : queued_) {
223 if (entry.ref_ == ref) {
224 entry.session_ = session;
225 return;
226 }
227 }
228 }
229
235 bool withdraw(const OrderRef<BrokerTag>& ref) {
236 for (auto at = queued_.begin(); at != queued_.end(); ++at) {
237 if (at->ref_ == ref) {
238 queued_.erase(at);
239 return true;
240 }
241 }
242 return false;
243 }
244
250 bool release(const OrderRef<BrokerTag>& ref) {
251 for (auto& entry : queued_) {
252 if (entry.ref_ == ref) {
253 entry.released_ = true;
254 return true;
255 }
256 }
257 return false;
258 }
259
263 void open_bar() noexcept { ++bar_; }
264
269 std::uint64_t bar() const noexcept { return bar_; }
270
276 void deciding_at(std::chrono::system_clock::time_point at, bool exact = true) {
277 deciding_at_ = at;
278 decision_is_exact_ = exact;
279 }
280
281private:
282 std::vector<QueuedOrder<BrokerTag>> queued_;
283 std::optional<OrderRef<BrokerTag>> last_ref_;
284 std::uint64_t issued_{0};
285 std::uint64_t bar_{0};
286 std::optional<std::chrono::system_clock::time_point> deciding_at_;
287 bool decision_is_exact_{true};
288};
289
293 std::chrono::system_clock::time_point at_{};
305};
306
310 std::vector<EquityPoint> equity_;
312 std::vector<Execution> executions_;
317 std::vector<strategy::Rejection> rejected_;
319 std::vector<strategy::GuardRejection> refused_;
321 std::size_t gaps_{0};
323 std::size_t bars_{0};
325 std::size_t unfilled_{0};
336 std::optional<BrokerError> halted_;
338 std::size_t aborted_{0};
340 std::size_t quotes_{0};
358 std::size_t unvalued_bars_{0};
360 std::optional<std::chrono::system_clock::time_point> first_unvalued_;
362 std::size_t stop_releases_{0};
364 std::size_t stop_fills_intrabar_{0};
366 std::size_t limit_fills_bounded_{0};
377 std::size_t overdrawn_bars_{0};
379 std::optional<std::chrono::system_clock::time_point> first_overdrawn_;
381 std::optional<Money> lowest_cash_;
383 std::optional<std::chrono::system_clock::time_point> lowest_cash_at_;
385 std::size_t expired_day_orders_{0};
386};
387
417
436template <typename Strategy, typename CapsType = strategy::NoCaps>
438public:
445
453 Strategy strategy = {},
454 ReplayConfig config = {},
455 NextBarFillModel fills = NextBarFillModel{}
456 )
457 : config_(std::move(config)),
458 fills_(std::move(fills)),
459 ledger_(config_.cash_, config_.currency_),
460 clock_(config_.session_),
461 runner_(std::move(strategy), config_.limits_, config_.series_capacity_) {
462 for (const auto& reference : config_.instruments_) {
463 runner_.context().set_instrument(reference);
464 }
465 runner_.context().set_calendar(config_.calendar_);
466 // So a report can compare an execution's currency with the curve's.
467 result_.currency_ = config_.currency_;
468 }
469
474 const Strategy& strategy() const noexcept { return runner_.strategy(); }
475
480 const ReplayLedger& ledger() const noexcept { return ledger_; }
481
486 const NextBarFillModel& fills() const noexcept { return fills_; }
487
492 const ReplayResult& result() const noexcept { return result_; }
493
517 void feed(const strategy::BarEvent& event, std::optional<bool> trading_day = true) {
518 // **A halted replay consumes nothing further.** `feed` keeps returning `void` so no caller has to
519 // change, and a caller that ignores `ReplayResult` sees a run that ends early rather than one
520 // that carries on costing nothing: no bar counted, no fill, no mark, no equity point, and no
521 // callback into the strategy. That is API compatibility, which is not the same as invisibility,
522 // and it is why the halt is stated on the result and first in the report.
523 if (result_.halted_) {
524 pending_.clear();
525 return;
526 }
527 if constexpr (Runner::takes_bar_slices) {
528 // **Checked before anything is counted, settled, marked or cached**, so a refused bar leaves the
529 // replay as it was. The pending period is delivered here, before this bar's settlement, exactly
530 // where `on_bar` would have run for that period's last bar.
531 check_slice_order(event);
532 if (!pending_.empty() && pending_.period_end() < event.period_end_) {
533 deliver_pending();
534 if (result_.halted_) {
535 return;
536 }
537 }
538 }
539 ++result_.bars_;
540 if (previous_ && !ReplayClock::contiguous(*previous_, event)) {
541 // A gap is counted rather than interpolated across: nothing happened in it that a strategy
542 // should be told about, and inventing a bar would be inventing data.
543 ++result_.gaps_;
544 }
545
546 // Opened before anything fills against this bar, so that an order placed during it carries a
547 // later stamp than the ones already resting and cannot fill against the bar it was decided on.
548 runner_.executor().open_bar();
549 // And dated, for the half of that rule a counter cannot express -- see `QueuedOrder::decided_at_`.
550 // A bar's decisions are all made at its period end, whichever handler makes them: `on_fill`
551 // during settlement, `on_phase`, `on_bar`, and a handler answering an order update afterwards.
552 //
553 // **Whether that instant is exact depends on the stamp**, and getting this wrong deferred real
554 // fills by a month. A close-stamped bar's period end is its own timestamp, stated by the broker.
555 // An open-stamped one is `bar_period_end`'s arithmetic, which is exact for an intraday bar (late
556 // only for a session's last bar cut short by the close) and deliberately **nominal** for a day, a
557 // week, a month and a year -- and every shipped adapter stamps every daily and longer bar `OPEN`.
558 // February's nominal end is five days into March, so an order decided on February's bar looked as
559 // though it had been decided inside March's period and was deferred to April.
560 runner_.executor().deciding_at(event.period_end_, period_end_is_exact(event));
561
562 settle_queued_against(event);
563 // **The halt stops this bar too, not merely the next one.** Returning only from settlement left
564 // `feed` to mark the book, record an equity point, advance the clock and call `on_phase` and
565 // `on_bar` on the very bar whose fill could not be costed -- so the strategy decided once more
566 // on a portfolio missing that trade, and could queue orders that then survived in the executor
567 // because every later `feed` is a no-op and `finish` returns early. Nothing runs after the
568 // refusal.
569 if (result_.halted_) {
570 return;
571 }
572 // **A mark that will not fit halts the replay**, like a fill it cannot cost or record. The lot
573 // stays at its previous price, while `Runner::feed` is about to hand the strategy this bar's
574 // close as the last price -- so `on_bar` would decide, and `Runner::settle` would size and check
575 // an order, against a position valued one bar ago beside a price from this one. An order placed
576 // from that view fills on a later bar and changes every figure after it. This used to skip only
577 // the equity point and carry on, which reported nothing and let that order through.
578 if (!ledger_.mark(event.instrument_, event.bar_.close_)) {
579 BrokerError error;
581 error.reached_broker_ = false;
582 error.broker_code_ = "mark_not_representable";
583 error.message_ = "a position in " + event.instrument_.symbol_ + " could not be valued at the "
584 "bar's close without leaving an exact decimal, so the replay stopped rather than let a "
585 "strategy decide on a position one bar behind its price";
586 result_.halted_ = std::move(error);
587 abort_queue();
588 return;
589 }
590 record_equity(event);
591
592 // **The price moves with the portfolio, not after it.** `Runner::settle` values a market order
593 // at `context_.last_price`, so a view whose positions are marked to this bar while the price is
594 // still the previous bar's is not a fresher picture -- it is an incoherent one, and it is worse
595 // than a uniformly stale picture because it looks current. After a close moves from 10 to 100, a
596 // market buy from `on_phase` would be checked against buying power and exposure at 10 while the
597 // positions beside it say 100, which can admit an order those limits exist to refuse.
598 runner_.context().set_last_price(event.instrument_, event.bar_.close_);
599
600 if (clock_.advance(event, trading_day)) {
601 runner_.context().set_phase(clock_.phase());
602 // **The clock moves with the price and the portfolio, for the same reason.**
603 // `Runner::feed(MarketPhase)` does not advance the context's time -- only `feed(BarEvent)`
604 // does, and that runs later -- so `on_phase` would decide with this bar's marks and close
605 // beside the *previous* bar's instant. `RiskGuard` records a send at `GuardInputs::now_` and
606 // counts sends within `rate_window_` of it, so across a gap longer than that window a phase
607 // order is filed under the old instant and the bar order that follows does not see it: two
608 // orders through a one-order window at what the replay calls the same moment.
609 runner_.context().set_now(event.period_end_);
610 // **Installed before the phase change is acted on, not after.** A strategy placing an order
611 // from `on_phase` used to decide on a portfolio predating this bar's fills and this bar's
612 // mark, while the engine already held a fresher answer and had simply not installed it yet.
613 install_portfolio();
614 drain_reports(runner_.feed(clock_.phase()));
615 } else {
616 runner_.context().set_phase(clock_.phase());
617 }
618
619 if constexpr (Runner::takes_bar_slices) {
620 // Cached, priced and clocked now; the strategy sees it with the rest of its period.
621 runner_.record(event);
622 pending_.add(event);
623 } else {
624 install_portfolio();
625 drain_reports(runner_.feed(event));
626 }
627 // **After the dispatch, not before it**, so an order this bar's `on_bar` or `on_phase` placed is
628 // reported. Before it would report only what settlement had already changed.
629 publish_order_updates();
630 previous_ = event;
631 }
632
649 void feed(const strategy::QuoteEvent& event, std::optional<bool> trading_day = true) {
650 // The same rule `feed(const BarEvent&)` opens with: a halted replay consumes nothing further,
651 // because a strategy told about anything after the halt decides on a run that has stopped being
652 // able to cost what it asks for.
653 if (result_.halted_) {
654 pending_.clear();
655 return;
656 }
657 if constexpr (Runner::takes_bar_slices) {
658 // A quote at or after the pending period comes after it: the period is delivered while its own
659 // price, clock and phase are still installed, before this quote changes any of them.
660 const auto at = event.quote_.received_at_;
661 if (!pending_.empty() && at < pending_.period_end()) {
662 throw std::invalid_argument(
663 "a quote earlier than bars already fed: with on_bars, feed quotes and bars in time order"
664 );
665 }
666 if (pending_.empty() && delivered_through_ && at < *delivered_through_) {
667 throw std::invalid_argument(
668 "a quote earlier than a period already delivered: with on_bars, feed quotes and bars in time order"
669 );
670 }
671 if (!pending_.empty()) {
672 deliver_pending();
673 if (result_.halted_) {
674 return;
675 }
676 }
677 }
678 ++result_.quotes_;
679 // Dated before anything can place an order, so a decision made here is not credited a price from
680 // earlier in the bar it will fill against. See `QueuedOrder::decided_at_`.
681 runner_.executor().deciding_at(event.quote_.received_at_);
682
683 // **The phase is re-derived at the quote's own instant, before the handler runs.** Without this
684 // a quote at 10:30 was shown the phase as it stood at the previous close, and the default
685 // `RiskLimits::permitted_phases_` refuses an opening order in every phase but `OPEN` -- so a
686 // strategy that lives correctly refused in a replay. `PollingLoop` installs the phase before
687 // each due poll, this is the same rule, and a change is announced first for the reason
688 // `feed(const BarEvent&)` announces it first: a handler placing an order from `on_phase` must
689 // decide against the phase this instant established, not the one before it.
690 const bool phase_changed = clock_.advance_to(event.quote_.received_at_, trading_day);
691 runner_.context().set_phase(clock_.phase());
692 if (phase_changed) {
693 // **The price and the clock move first, then the change is announced.** `Runner::feed` sets
694 // both from the quote, but it runs *after* this, so announcing first left `on_phase` deciding
695 // on the previous bar's close and stamping its send at the previous instant -- a market order
696 // sized and guarded at the wrong value. This is the order
697 // `feed(const BarEvent&)` already uses: mark, price, clock, announce, dispatch.
698 runner_.context().set_last_price(event.quote_.instrument_, event.quote_.last_price_);
699 runner_.context().set_now(event.quote_.received_at_);
700 install_portfolio();
701 drain_reports(runner_.feed(clock_.phase()));
702 }
703
704 // Installed before the dispatch for the reason every other dispatch site here installs: a
705 // handler reads `ctx.portfolio()`, and the engine holds a fresher answer than the last one it
706 // pushed -- an order placed by the *previous* quote has had no settlement pass behind it.
707 install_portfolio();
708 drain_reports(runner_.feed(event));
709 // **After the dispatch, exactly as the bar path does.** An earlier version left
710 // it out, arguing that live a quote poll publishes no order update -- which is true of that one
711 // call and false of the loop it sits in: an order placed from `on_quote` can be listed by a later
712 // working-order poll and announced. Publishing here is the closer parity, and it also
713 // announces an order that would otherwise fill and be gone before any later observation point.
714 publish_order_updates();
715 }
716
725 void finish() {
726 if (result_.halted_) {
727 // **The strategy is not stopped normally, and that is deliberate.** `Runner::stop` calls
728 // `on_stop`, which may place or flatten -- and an order placed into a replay that has already
729 // stopped being able to cost one would either fill uncosted or be silently dropped. Neither is
730 // a shutdown a reader could interpret, so nothing runs: the queue was cleared at the halt and
731 // `aborted_` says how much was in it. The report states that the strategy never saw a stop.
732 pending_.clear();
733 return;
734 }
735 if constexpr (Runner::takes_bar_slices) {
736 // The last period has no later bar to deliver it: it goes before `on_stop` and before `unfilled_`
737 // is counted, so both see what it placed.
738 if (!pending_.empty()) {
739 deliver_pending();
740 if (result_.halted_) {
741 return;
742 }
743 }
744 }
745 install_portfolio();
746 drain_reports(runner_.stop());
747 // The only boundary that can observe what `on_stop` placed or withdrew. Before `unfilled_` is
748 // counted, so the count describes the queue the run actually ended with.
749 publish_order_updates();
750 result_.unfilled_ = runner_.executor().queued().size();
751 }
752
753private:
754 // Refuses a bar a strategy declaring `on_bars` cannot be shown consistently: one for a period already
755 // delivered (it would start a second slice for that period, and could meet an order decided on the
756 // first), one earlier than the pending period, or a second bar of one instrument and interval in a
757 // period. Throws before anything changes.
758 void check_slice_order(const strategy::BarEvent& event) const {
759 if (delivered_through_ && !(*delivered_through_ < event.period_end_)) {
760 throw std::invalid_argument(
761 "a bar for a period already delivered to on_bars: feed every bar of a period before anything "
762 "later (a bar of the next period, a quote at or after it, or finish)"
763 );
764 }
765 if (pending_.empty()) {
766 return;
767 }
768 if (event.period_end_ < pending_.period_end()) {
769 throw std::invalid_argument(
770 "a bar earlier than bars already fed: with on_bars, feed bars in period-end order across "
771 "instruments"
772 );
773 }
774 if (event.period_end_ == pending_.period_end() && pending_.holds(event)) {
775 throw std::invalid_argument(
776 "a second bar of one instrument and interval for the same period end: on_bars receives one bar "
777 "per instrument"
778 );
779 }
780 }
781
782 // Delivers the pending period to `on_bars`, one slice per interval in the order each was first fed.
783 //
784 // Each slice is decided at the period end, exact if any of its bars states its end and nominal only
785 // if all of them derive it (see `period_end_is_exact`): a nominal bar fed last must not weaken an
786 // exact one. The portfolio is installed before each slice, because an `on_order_update` published
787 // after the last bar, or an earlier slice, may have changed the queue.
788 void deliver_pending() {
789 const auto end = pending_.period_end();
790 auto slices = pending_.take();
791 delivered_through_ = end;
792 for (const auto& slice : slices) {
793 if (result_.halted_) {
794 return;
795 }
796 bool exact = false;
797 for (const auto& bar : slice.bars_) {
798 exact = exact || period_end_is_exact(bar);
799 }
800 runner_.executor().deciding_at(end, exact);
801 install_portfolio();
802 drain_reports(runner_.feed(slice));
803 publish_order_updates();
804 }
805 }
806
807 // Clears every order still queued after a halt, counting them.
808 //
809 // They are withdrawn rather than left outstanding so that nothing downstream reads a terminal
810 // replay as one with live working orders, and counted in `aborted_` rather than `unfilled_`
811 // because "the data ran out with this resting" and "the replay stopped while this was resting"
812 // are different facts about a run.
813 void abort_queue() {
814 // A period still being collected is never delivered after a halt.
815 pending_.clear();
816 std::vector<OrderRef<Tag>> remaining;
817 remaining.reserve(runner_.executor().queued().size());
818 for (const auto& entry : runner_.executor().queued()) {
819 remaining.push_back(entry.ref_);
820 }
821 for (const auto& ref : remaining) {
822 runner_.executor().withdraw(ref);
823 }
824 result_.aborted_ = remaining.size();
825 }
826
827 // Returns whether `event`'s period end is enforced as the decision instant (`true`), or treated as a
828 // nominal upper bound whose fills are allowed and counted as unchecked (`false`).
829 //
830 // A close-stamped bar carries its period end as its timestamp, so the broker stated it. An
831 // open-stamped bar's end is derived by adding the interval's nominal length, which is right for an
832 // intraday interval save a session's last bar when the session is not a whole number of them: KRX's
833 // 15:00 hourly bar is given 16:00 for its 15:30 close. That end is late, not exact, and it is still
834 // enforced strictly, because a late bound only defers a fill (to the next session's bars) and never
835 // credits a price from before the decision; treating it as nominal would let such fills through
836 // unchecked. For every calendar interval the end is an approximation -- 24 hours is not a trading day, 31 days
837 // is not a month, and 366 is not a year. The approximation always runs **long**, which is what
838 // makes it usable: an upper bound on the decision instant still proves causality whenever a bar's
839 // start is at or after it.
840 static bool period_end_is_exact(const strategy::BarEvent& event) noexcept {
841 return event.stamp_ == BarStamp::CLOSE || bar_interval_length(event.interval_).has_value();
842 }
843
844 // Returns when `event`'s period began, where that is answerable **from the event itself**.
845 //
846 // Three cases, in the order they are decided; the order matters:
847 //
848 // 1. **An open-stamped bar states it.** `BarStamp::OPEN` means `bar_.timestamp_` *is* the first
849 // instant of the period, for every interval. Nothing has to be derived.
850 // 2. **An intraday interval has a fixed length**, so a close-stamped one began that long before it
851 // ended. A session's last bar cut short by the close began later than that (KRX's hourly bar ending
852 // 15:30 began at 15:00, not 14:30): the derived start is early, which refuses fills the check could
853 // have allowed and never allows one it should refuse.
854 // 3. **A close-stamped daily bar** began at the session open on its own local date, which is the
855 // date its period end falls on and where its open price comes from. **That assumes the stamp
856 // falls on the session's own date**, which is true of a bar stamped at the close and false of
857 // one stamped at the following local midnight; such a series gets a start a day late.
858 //
859 // **Everything else is unanswerable and says so.** A close-stamped weekly bar ends on Friday and
860 // began on Monday; taking the session open on the *period end's* date would have called Friday
861 // 09:00 its start, which is four days too late -- so a Tuesday quote would have passed the check
862 // and filled at Monday's open, a price from two days before the decision. An earlier version of
863 // this function did exactly that and called it conservative; it was the opposite. The same was
864 // true of an open-stamped daily bar, whose period end is 24 hours after its start.
865 //
866 // What an empty answer means to the caller is in `may_fill`: not "no constraint", and not a bound
867 // to be inferred from somewhere else either, but "this check cannot run -- allow the fill and
868 // record that it was not checked".
869 std::optional<std::chrono::system_clock::time_point> period_start_of(
870 const strategy::BarEvent& event
871 ) const {
872 if (event.stamp_ == BarStamp::OPEN) {
873 return event.bar_.timestamp_;
874 }
875 if (const auto duration = interval_duration(event.interval_)) {
876 return event.period_end_ - *duration;
877 }
878 if (event.interval_ == BarInterval::ONE_DAY) {
879 return clock_.session().opens_at(event.period_end_);
880 }
881 return std::nullopt;
882 }
883
884 // Returns whether `entry` may fill against `event`, and records where the question *could this be
885 // credited a price from before it was decided* could not be answered at all.
886 //
887 // Three answers:
888 //
889 // 1. **An undated order is eligible.** That is every order placed by a caller driving
890 // `QueueingExecutor` directly, and this rule exists to defer fills rather than to invent them.
891 // **No test can reach this branch through `Backtester`**, because the engine stamps the instant
892 // before every dispatch that can place -- so a mutation removing it is not caught, and that is
893 // a property of the branch rather than a weakness in the tests. It is kept because
894 // `QueuedOrder::decided_at_` is an optional on a public type, and an undated one is a state the
895 // type admits; nothing outside this class calls this function, so no caller exercises it today.
896 // 2. **Where `period_start_of` answers, the comparison is made**: the bar's period must have begun
897 // **at or after** the decision. A period that began before it contains the decision, and its
898 // open price -- which is what `NextBarFillModel` credits a market order with -- is from before
899 // the strategy decided anything. This is every bar shape this library produces: intraday,
900 // open-stamped, and close-stamped daily. A derived start can be early (a close-stamped intraday
901 // bar cut short by the close), which defers a fill and never credits an earlier price.
902 // 3. **Where either side of that comparison is a calendar boundary this engine cannot establish,
903 // it makes no inference at all.** Two ways in, and the second was found last: the bar's start is
904 // underivable (a close-stamped weekly, monthly or yearly bar), or the decision instant is only
905 // an upper bound (an open-stamped bar of any calendar interval, which is what every shipped
906 // adapter produces) and the bar's start falls before it. Either way the fill is allowed, on the
907 // bar-counter rule that governed every fill before this check existed, and **counted** in
908 // `ReplayResult::unchecked_fill_causality_` so the report can say the check did not happen.
909 //
910 // **Three successive versions of this function tried to infer that period instead, and all three
911 // were wrong.** The previous bar of the instrument: wrong, because two intervals of one
912 // instrument overlap. The previous bar of the same instrument *and* interval: wrong, because
913 // nothing in `feed`'s contract requires one stamp convention or anchor for a series, so two
914 // same-interval weekly bars can overlap too. Each inference rested on an invariant that is usually
915 // true and is not guaranteed -- and the repository's own rule covers exactly this: *a conservative
916 // refusal beats a narrow test you cannot justify.* There is no refusal available here either,
917 // because deferring an unprovable fill would stop a close-stamped weekly replay filling **anything**
918 // -- every order in one is decided at a boundary this engine equally cannot place. That is a
919 // statement about the shape rather than about every interval in it: a sound lower bound of *period
920 // end minus seven days* does exist for a week and would let a full-week series fill on time, but it
921 // defers a week after every holiday-shortened one, and the 31-day and 366-day bounds defer nearly
922 // every monthly and yearly order. So the honest third answer is to check nothing, claim nothing,
923 // and report the gap.
924 bool may_fill(
925 const QueuedOrder<Tag>& entry,
926 const strategy::BarEvent& event
927 ) {
928 if (!entry.decided_at_) {
929 return true;
930 }
931 if (const auto start = period_start_of(event)) {
932 if (!(*start < *entry.decided_at_)) {
933 // Proven: the period began at or after the decision. A *nominal* decision instant is an
934 // upper bound on the true one, so this proves it for those too.
935 return true;
936 }
937 if (entry.decision_is_exact_) {
938 return false;
939 }
940 // The start is known and the decision is only bounded, so this is the unprovable case again
941 // from the other side: the order may have been decided before this period after all. Allowed
942 // and counted, on the same terms as an underivable start.
943 ++result_.unchecked_fill_causality_;
944 return true;
945 }
946 ++result_.unchecked_fill_causality_;
947 return true;
948 }
949
950 // Fills orders resting from an earlier bar against `event`, and tells the strategy.
951 //
952 // **Walked by reference rather than by iterator or index.** Applying a fill calls `on_fill`, which
953 // may place or withdraw orders, so any position into the queue is invalid the moment the strategy
954 // is told anything. The eligible references are collected first and each is looked up again when
955 // its turn comes; one the callback withdrew is simply no longer there.
956 //
957 // The queue is never emptied, so an order this loop has not reached yet is outstanding, visible in
958 // every portfolio installed along the way, and addressable by a cancellation from any callback.
959 void settle_queued_against(const strategy::BarEvent& event) {
960 const auto bar_date = clock_.session().local_date(event.bar_.timestamp_);
961 // A session the replay had no closing bar for -- a missing last minute -- still ended: an order
962 // of an earlier date is withdrawn before this bar can fill it.
963 expire_day_orders(event, bar_date, false);
964 std::vector<OrderRef<Tag>> eligible;
965 std::vector<OrderRef<Tag>> first_session;
966 for (const auto& entry : runner_.executor().queued()) {
967 // A different instrument's order waits for that instrument's next bar, which is what makes an
968 // interleaved multi-instrument replay behave. An order accepted during this bar waits for the
969 // next one, which is the no-lookahead rule.
970 if (!(entry.order_.instrument_ == event.instrument_)) {
971 continue;
972 }
973 if (entry.accepted_on_ >= runner_.executor().bar()) {
974 continue;
975 }
976 // **And, where the event says enough to check it, it cannot fill against a bar whose period
977 // had already begun when it was decided.** The counter above is enough while only bars place
978 // orders and no two periods overlap; a quote arrives inside a period without opening one, so
979 // without this an order decided at 10:30 fills at that day's 09:00 open -- a price from before
980 // the decision. Where the event does *not* say enough, `may_fill` allows the fill and records
981 // that nothing was checked. See it for all three answers.
982 if (!may_fill(entry, event)) {
983 continue;
984 }
985 eligible.push_back(entry.ref_);
986 // **Which session a DAY order lives in**, fixed at the first bar it is eligible against: that
987 // bar's session is the one the order trades in. A decision the bar cannot fill without looking
988 // ahead -- a quote at 10:00 against the day's bar, or the day's close -- trades in the next
989 // session, as a market order decided there fills at the next open: one session, shifted, never
990 // the several a resting order had before. Recorded here, beside the one `may_fill` call, so
991 // `unchecked_fill_causality_` still counts each order once per bar.
992 if (!entry.session_ && time_in_force_expires_at_close(entry.order_.time_in_force_)) {
993 first_session.push_back(entry.ref_);
994 }
995 }
996 for (const auto& ref : first_session) {
997 runner_.executor().assign_session(ref, bar_date);
998 }
999
1000 for (const auto& ref : eligible) {
1001 const auto* entry = runner_.executor().find(ref);
1002 if (entry == nullptr) {
1003 // Withdrawn by a callback earlier in this loop. Nothing to fill.
1004 continue;
1005 }
1006 const auto order = entry->order_;
1007 const auto rule = rule_for(order.instrument_);
1008 // An `OPEN`-stamped bar states its start, so a fill at its open has a known day; the period
1009 // end's day is nominal there -- a day late for a daily bar, days into the next month for a
1010 // monthly one. See `NextBarFillModel::decide`'s `open_date`.
1011 const auto open_date = event.stamp_ == BarStamp::OPEN
1012 ? std::optional<Date>(clock_.session().local_date(event.bar_.timestamp_))
1013 : std::nullopt;
1014 auto decision = fills_.decide(
1015 order, event, rule, clock_.session().local_date(event.period_end_), open_date,
1016 entry->released_
1017 );
1018 if (decision.released_) {
1019 // Recorded before anything below can withdraw the order, and whether or not it filled: a stop
1020 // that released and still rests is a limit from the next bar on.
1021 runner_.executor().release(ref);
1022 ++result_.stop_releases_;
1023 }
1024 if (decision.costing_error_) {
1025 // **Checked before the generic no-execution branch**, because that branch would leave a
1026 // resting day limit queued to be retried on the next bar, and a refusal is not a bar that
1027 // failed to reach a price. The order goes whatever its type, exactly once.
1028 runner_.executor().withdraw(ref);
1029 result_.halted_ = std::move(*decision.costing_error_);
1030 // **The halt governs the engine, not just this order.** Returning here stops the other
1031 // orders eligible on this same bar from filling behind a cost the model could not compute,
1032 // and `feed` stops consuming bars from its next call onward.
1033 abort_queue();
1034 return;
1035 }
1036 if (!decision.execution_) {
1037 if (!decision.still_working_) {
1038 // A market order the bar could not price, or a limit whose arithmetic did not fit. It does
1039 // not rest and it did not trade, so it stops being outstanding.
1040 runner_.executor().withdraw(ref);
1041 }
1042 continue;
1043 }
1044 // Removed before the ledger moves and before anything is shown a portfolio, so no view ever
1045 // carries a filled order as still working.
1046 runner_.executor().withdraw(ref);
1047 // Stamped from the same reference data the ledger is given, so the two can never disagree
1048 // about what currency one fill was in.
1049 decision.execution_->currency_ = currency_for(order.instrument_);
1050 if (!ledger_.apply(*decision.execution_, currency_for(order.instrument_))) {
1051 // **A modelled fill the ledger cannot record halts the replay**, for the same reason a cost
1052 // it cannot compute does. `apply` is atomic, so nothing is half-written -- but the fill
1053 // happened in the model and did not happen in the books, and continuing would carry on from
1054 // a portfolio missing a trade the strategy was never told about. This used to `continue`,
1055 // which recreated at the ledger boundary exactly the silently-missing-trade path the
1056 // costing refusal above exists to remove.
1057 BrokerError error;
1058 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1059 error.reached_broker_ = false;
1060 error.broker_code_ = "execution_not_representable";
1061 error.message_ = "an execution's cash arithmetic does not fit an exact decimal, so the "
1062 "ledger could not record a fill the model produced";
1063 result_.halted_ = std::move(error);
1064 abort_queue();
1065 return;
1066 }
1067 note_overdraft(*decision.execution_);
1068 if (decision.intrabar_release_fill_) {
1069 ++result_.stop_fills_intrabar_;
1070 }
1071 if (decision.bounded_) {
1072 ++result_.limit_fills_bounded_;
1073 }
1074 result_.executions_.push_back(*decision.execution_);
1075 install_portfolio();
1076 drain_reports(runner_.feed(decision.execution_->fill_));
1077 }
1078 expire_day_orders(event, bar_date, true);
1079 // Closes the settlement batch: every withdrawal this bar made is now done, and so is anything
1080 // an `on_fill` handler placed or cancelled in response to one.
1081 publish_order_updates();
1082 }
1083
1084 // Keeps the lowest cash an execution left in the replay's currency, when it is below zero.
1085 void note_overdraft(const Execution& execution) {
1086 if (!(execution.currency_ == result_.currency_)) {
1087 return;
1088 }
1089 const auto cash = ledger_.cash_in(execution.currency_);
1090 if (!cash || !decimal_less(*cash, Decimal64::parse("0"))) {
1091 return;
1092 }
1093 if (!result_.lowest_cash_ || decimal_less(*cash, result_.lowest_cash_->amount_)) {
1094 result_.lowest_cash_ = Money{*cash, execution.currency_};
1095 result_.lowest_cash_at_ = execution.fill_.at_;
1096 }
1097 }
1098
1099 // Withdraws every DAY order of `event`'s instrument whose session has ended: before the bar's
1100 // fills (`after_fills` false), those of an earlier date; after them, also those of the bar's own
1101 // date when the bar ends the session.
1102 //
1103 // A KRX DAY order expires at the close. Kept queued, a replay's DAY limit or stop was
1104 // good-till-cancelled on daily bars -- a limit below the market waited for weeks, and a stop placed
1105 // at an entry still rested months later -- where live it was gone that evening. A bar ends a
1106 // session when its interval is a day or longer, or when it is an intraday bar ending at or after the
1107 // session's close; a bar of a later date ends it in any case. Withdrawn inside the settlement batch,
1108 // before order updates are published and before the strategy is shown the bar, so nothing reads an
1109 // expired order as working. Counted in `ReplayResult::expired_day_orders_`.
1110 void expire_day_orders(const strategy::BarEvent& event, const Date& bar_date, bool after_fills) {
1111 // No fixed duration is what a day or longer has (`interval_duration`); an intraday interval has one.
1112 const bool daily_or_longer = !interval_duration(event.interval_);
1113 const auto closes = clock_.session().opens_at(event.bar_.timestamp_) - clock_.session().open_ +
1114 clock_.session().close_;
1115 const bool ends_session = after_fills && (daily_or_longer || !(event.period_end_ < closes));
1116 std::vector<OrderRef<Tag>> expired;
1117 for (const auto& entry : runner_.executor().queued()) {
1118 if (!(entry.order_.instrument_ == event.instrument_) || !entry.session_ ||
1119 !time_in_force_expires_at_close(entry.order_.time_in_force_)) {
1120 continue;
1121 }
1122 if (*entry.session_ < bar_date || (*entry.session_ == bar_date && ends_session)) {
1123 expired.push_back(entry.ref_);
1124 }
1125 }
1126 for (const auto& ref : expired) {
1127 runner_.executor().withdraw(ref);
1128 ++result_.expired_day_orders_;
1129 }
1130 }
1131
1132 // Records equity at this bar, and the return since the previous point.
1133 void record_equity(const strategy::BarEvent& event) {
1134 if (const auto cash = ledger_.cash_in(config_.currency_);
1135 cash && decimal_less(*cash, Decimal64::parse("0"))) {
1136 ++result_.overdrawn_bars_;
1137 if (!result_.first_overdrawn_) {
1138 result_.first_overdrawn_ = event.period_end_;
1139 }
1140 }
1141 const auto equity = ledger_.equity_in(config_.currency_);
1142 if (!equity) {
1143 // Every lot was marked, and the total of cash and lots still does not fit an exact decimal.
1144 // No point is recorded -- a point at an approximate total would be a figure nobody computed
1145 // -- and the omission is counted, because a gap in the curve has other causes too.
1146 ++result_.unvalued_bars_;
1147 if (!result_.first_unvalued_) {
1148 result_.first_unvalued_ = event.period_end_;
1149 }
1150 equity_gap_ = true;
1151 return;
1152 }
1153 EquityPoint point;
1154 point.at_ = event.period_end_;
1155 point.equity_ = *equity;
1156 point.period_return_ = math::missing();
1157 // **No return across an omitted point.** The one below would measure two or more bars and be
1158 // handed to the Sharpe ratio and the annualization as one bar's return, so the first point
1159 // after a gap carries none -- as the first point of the run does.
1160 const bool after_gap = equity_gap_;
1161 equity_gap_ = false;
1162 if (!result_.equity_.empty() && !after_gap) {
1163 const auto& previous = result_.equity_.back().equity_;
1164 // Only from positive equity. From a negative total the ratio's sign is reversed: a further loss
1165 // reads as a gain, and a run that lost more than its capital reported a positive Sharpe ratio.
1166 if (previous.coefficient() > 0) {
1167 const auto moved = decimal_subtract(*equity, previous);
1168 if (moved) {
1169 const auto ratio = decimal_divide(*moved, previous, 8);
1170 if (ratio) {
1171 point.period_return_ = analytics_to_scalar(*ratio);
1172 }
1173 }
1174 }
1175 }
1176 result_.equity_.push_back(point);
1177 }
1178
1179 // Converts an exact ratio to the scalar the metrics compute in.
1180 //
1181 // The one crossing of the decimal boundary in this engine, and it goes outward: a return is an input
1182 // to `sharpe` and the rest, which are defined on `Scalar`. Nothing converts back, because no figure
1183 // a report produces is ever sent to a broker. The same conversion as `analytics::to_scalar` (one
1184 // division by an exact power of ten, so the nearest double within its bounds), written out because
1185 // this header does not include the analytics layer; keep the two alike.
1186 static math::Scalar analytics_to_scalar(const Decimal64& value) {
1187 math::Scalar divisor{1};
1188 for (std::uint8_t at = 0; at < value.scale(); ++at) {
1189 divisor *= math::Scalar{10};
1190 }
1191 return static_cast<math::Scalar>(value.coefficient()) / divisor;
1192 }
1193
1194 // Returns the grid for `instrument`, unknown when no reference data was supplied.
1195 // Returns the currency `instrument` trades in, from the reference data the replay was given.
1196 //
1197 // **Every fill used to be booked in `config_.currency_`**, which is documented as the currency the
1198 // *starting cash* is in. A replay of a dollar-quoted instrument therefore banked dollars as won at
1199 // a rate of one -- an implicit currency conversion, which this library never makes silently.
1200 //
1201 // The fallback is defensive rather than a policy: `reconcile` refuses an instrument with no
1202 // reference data before a `PlannedOrder` exists (`strategy/reconcile.hpp`), so nothing can reach
1203 // the fill model unregistered. It is written the conservative way round anyway -- an unregistered
1204 // instrument books in the currency the ledger was opened in, which is the behaviour every existing
1205 // replay already had.
1206 // Installs the portfolio a strategy and its guards decide on.
1207 //
1208 // The single place the replay answers "what does this account look like", so that every callback
1209 // boundary is answered the same way.
1210 //
1211 // **It has to be a complete answer, because it overwrites one.**
1212 // `Runner::persist_reservation` writes an accepted order into the context portfolio and relies on
1213 // a wholesale replacement carrying what the reservation was standing in for
1214 // (`Runner::persist_reservation`'s comment). Live that replacement is a broker's listing. Here it is this
1215 // function, and a partial answer is therefore not a smaller improvement -- it is a *loss*, because
1216 // the reservation it erased was carrying the part it left out.
1217 //
1218 // The reservation carried two things, so this carries both: the outstanding orders themselves,
1219 // and the money an outstanding buy has committed.
1220 // Reports every queued order whose state has changed since the last observation.
1221 //
1222 // ## Where this is called from, and why not simply beside `install_portfolio`
1223 //
1224 // At the three points where a *batch* of changes to the queue has finished: the end of
1225 // settlement, the end of `feed`, and the end of `finish`. The queue changes in exactly three
1226 // ways -- `place` and `cancel` from a strategy dispatch, and `withdraw` from settlement -- and
1227 // each of those three boundaries closes one batch of them.
1228 //
1229 // The obvious alternative, publishing from `install_portfolio`, is wrong and quietly so: the
1230 // last install of a bar runs *before* `feed(BarEvent)` dispatches `on_bar`, so an order placed
1231 // there enters the queue after it, and the next bar's settlement withdraws the order before that
1232 // bar's first install. An order placed on one bar and filled on the next -- which is every market
1233 // order -- would be present at no install at all and would be reported nowhere.
1234 //
1235 // ## The portfolio is installed before each dispatch, not once for the batch
1236 //
1237 // A handler told about an order has to be able to find it. With every optional limit disabled
1238 // `Runner::persist_reservation` writes no working row, so nothing else would put the order in
1239 // the view, and a strategy would be told about an order it could neither see nor withdraw.
1240 // Installing once for the batch is not enough either, because a handler answering the first
1241 // update may place or cancel, which invalidates the picture the second one reads.
1242 //
1243 // ## What a handler's own order does
1244 //
1245 // It enters the queue behind this observation and is reported at the *next* boundary, never
1246 // within this one, so a strategy cannot drive itself round a loop here. An order placed by
1247 // `on_order_update` during `finish` has no later boundary and is not reported at all.
1248 void publish_order_updates() {
1249 if (result_.halted_) {
1250 // Nothing runs after a refusal, for the reason `feed` documents: a handler called here would
1251 // decide on a replay that has stopped being able to cost what it asks for.
1252 return;
1253 }
1254 std::vector<WorkingOrder<Tag>> rows;
1255 rows.reserve(runner_.executor().queued().size());
1256 for (const auto& entry : runner_.executor().queued()) {
1257 rows.push_back(working_row(entry));
1258 }
1259 for (const auto& update : updates_.changed(rows)) {
1260 // **Re-checked against the queue immediately before it is dispatched, not only when the batch
1261 // was observed.** A handler answering the first update of a batch can cancel a *different*
1262 // order in the same batch, and the event captured for that order is then a statement about an
1263 // order that no longer exists -- the portfolio installed below would correctly omit it while
1264 // the event announced it, which is exactly the contradiction the install exists to prevent.
1265 //
1266 // Checked on the state as well as the reference, because a handler can also replace an order
1267 // with another carrying the same one.
1268 const auto* still_queued = find_queued(update);
1269 if (still_queued == nullptr) {
1270 continue;
1271 }
1272 install_portfolio();
1273 drain_reports(runner_.feed(update));
1274 if (result_.halted_) {
1275 return;
1276 }
1277 }
1278 // **Once, after the whole batch.** Every pass that can withdraw a stop -- the bar's, the phase's,
1279 // the fills' and these updates' -- ends here, so the strategy has answered every update before a
1280 // fallback is released; releasing after each pass let a later update's instruction arrive after
1281 // the fallback had already been queued. The live path waits for a whole listing the same way.
1282 settle_deferred();
1283 }
1284
1285 // Returns the queued order `update` describes, if it is still outstanding in that same state.
1286 //
1287 // Matched on the reference and then on the observable state, so an order withdrawn or amended
1288 // since the batch was observed is not announced as it stood.
1289 const QueuedOrder<Tag>* find_queued(const OrderSnapshot& update) const {
1290 for (const auto& entry : runner_.executor().queued()) {
1291 if (entry.ref_.primary_ != update.order_id_) {
1292 continue;
1293 }
1294 const auto row = working_row(entry);
1295 if (runner::order_update_detail::same_observable_state(row.order_, update)) {
1296 return &entry;
1297 }
1298 }
1299 return nullptr;
1300 }
1301
1302 void install_portfolio() {
1303 // The value a weight is sized against: the ledger's equity in the replay's currency at the
1304 // latest marks, which is what the equity curve records. Cleared when that total does not fit,
1305 // so a weight is refused on such a bar rather than sized against the previous one.
1306 if (const auto equity = ledger_.equity_in(config_.currency_)) {
1307 runner_.set_portfolio_value(Money{*equity, config_.currency_});
1308 } else {
1309 runner_.clear_portfolio_value();
1310 }
1311 auto portfolio = ledger_.snapshot<Tag>(clock_.now());
1312 for (const auto& entry : runner_.executor().queued()) {
1313 portfolio.working_.push_back(working_row(entry));
1314 commit_open_buy(portfolio, entry);
1315 }
1316 runner_.context().set_portfolio(std::move(portfolio));
1317 }
1318
1319 // Returns the working-order row for an outstanding replay order.
1320 //
1321 // `reserved_` stays false, unlike a runner's own reservation. The flag distinguishes this
1322 // library's arithmetic from what a broker reported, and in a replay the engine *is* the broker:
1323 // this row is the listing. The one consequence worth stating is that
1324 // `strategy::SellableConventionProbe`, which reads unreserved rows as evidence about a broker's
1325 // convention, will read these -- and a replay's sellable figure is a model that never moves, so
1326 // the probe reports what the simulator does rather than what a broker does.
1327 WorkingOrder<Tag> working_row(const QueuedOrder<Tag>& entry) const {
1328 WorkingOrder<Tag> row;
1329 row.ref_ = entry.ref_;
1330 // **The replay is this order's broker, so it is the thing that issued the number.** A row left
1331 // with an empty `order_id_` is an order a reader cannot name, which a live row never is; the
1332 // reference's primary part is the only identifier a replay has, and it is already monotone and
1333 // stable for as long as the order is outstanding. Nothing in the library matched a replay
1334 // working row on this field while it was empty, so filling it changes no existing decision.
1335 row.order_.order_id_ = entry.ref_.primary_;
1336 row.order_.instrument_ = entry.order_.instrument_;
1337 row.order_.side_ = entry.order_.side_;
1338 row.order_.type_ = entry.order_.type_;
1339 row.order_.time_in_force_ = entry.order_.time_in_force_;
1340 row.order_.session_ = entry.order_.session_;
1341 row.order_.venue_ = entry.order_.venue_;
1342 row.order_.price_ = entry.order_.price_;
1343 // A stop's trigger, as a live listing reports it -- released or not, since neither broker's
1344 // listing says which.
1345 row.order_.trigger_price_ = entry.order_.trigger_price_;
1346 row.order_.quantity_ = entry.order_.quantity_;
1347 row.order_.currency_ = currency_for(entry.order_.instrument_);
1348 // Open, and nothing filled: a replay fills an order whole or not at all, so a row that exists
1349 // has traded nothing.
1350 row.order_.status_ = OrderStatus::PENDING;
1351 row.order_.execution_.filled_quantity_ = Decimal64::parse("0");
1352 row.amendable_quantity_ = entry.order_.quantity_;
1353 return row;
1354 }
1355
1356 // Takes what an outstanding buy has committed out of the buying power the replay models.
1357 //
1358 // **Rows alone do not fix `enforce_buying_power_`**, which reads the scalar and not the orders.
1359 // `ReplayLedger::snapshot` builds buying power from ledger cash, and that cash has not paid for
1360 // anything still resting -- so with a hundred of cash, a resting buy costing a hundred, and a
1361 // second buy costing a hundred in another instrument, the second passes and both can still fill.
1362 //
1363 // Valued at the order's own price, else the last close, which is the same estimate the guard
1364 // checked the order against — and it is an **estimate**, not a bound. A limit buy cannot fill
1365 // above its limit, so for those the commitment is at least the eventual spend before costs. A
1366 // market order has no stated price at all: it fills where the next bar opens, and slippage and
1367 // commission are both outside this figure, so the real spend can exceed what was committed. An
1368 // order that can be valued by neither route is left uncommitted rather than guessed at.
1369 //
1370 // **Scoped to the limit, exactly as `Runner::reserve` is.**
1371 // `buying_power_in()` is a public figure a strategy is free to size its next order from, so
1372 // lowering it for a caller who never enabled the check would change how their strategy sizes --
1373 // a behaviour change well past enforcing a ceiling, and the reason the runner passes `spends`
1374 // rather than always debiting. It also keeps `ReportCaveats::buying_power_is_ledger_cash` true
1375 // for every replay that does not ask for the check.
1376 void commit_open_buy(strategy::Portfolio<Tag>& portfolio, const QueuedOrder<Tag>& entry) const {
1377 if (!config_.limits_.enforce_buying_power_ || entry.order_.side_ != OrderSide::BUY) {
1378 return;
1379 }
1380 const auto price = entry.order_.price_ ?
1381 entry.order_.price_ :
1382 runner_.context().last_price(entry.order_.instrument_);
1383 if (!price) {
1384 return;
1385 }
1386 const auto spent = decimal_multiply(entry.order_.quantity_, *price);
1387 if (!spent) {
1388 return;
1389 }
1390 const auto currency = currency_for(entry.order_.instrument_);
1391 for (auto& reading : portfolio.buying_power_) {
1392 if (!(reading.cash_.currency_ == currency)) {
1393 continue;
1394 }
1395 if (const auto left = decimal_subtract(reading.cash_.amount_, *spent)) {
1396 reading.cash_.amount_ = *left;
1397 }
1398 return;
1399 }
1400 }
1401
1402 Currency currency_for(const InstrumentId& instrument) const {
1403 const auto reference = runner_.context().instrument(instrument);
1404 if (!reference) {
1405 return config_.currency_;
1406 }
1407 return reference->currency_;
1408 }
1409
1410 PriceRule rule_for(const InstrumentId& instrument) const {
1411 const auto reference = runner_.context().instrument(instrument);
1412 if (!reference) {
1413 return PriceRule::unknown("no reference data supplied to the replay");
1414 }
1415 return reference->price_rule_;
1416 }
1417
1418 // Collects what a runner pass reported, so a replay ends with the whole picture.
1419 template <typename Report>
1420 void drain_reports(const Report& report) {
1421 collect(report);
1422 }
1423
1424 // Adds a pass's refusals to the result.
1425 template <typename Report>
1426 void collect(const Report& report) {
1427 for (const auto& rejection : report.rejected_) {
1428 result_.rejected_.push_back(rejection);
1429 }
1430 for (const auto& refusal : report.refused_) {
1431 result_.refused_.push_back(refusal);
1432 }
1433 }
1434
1435 // Settles a target the runner deferred behind a stop, once this replay has withdrawn the stop and
1436 // the strategy has answered every update of the batch. Called only from `publish_order_updates`.
1437 //
1438 // **A replay's withdrawal is synchronous**: `QueueingExecutor::cancel` removes the stop from the
1439 // queue before the pass that asked for it returns. Waiting for the next bar's portfolio to show that
1440 // would place the flatten a bar late and fill it two bars late -- a delay the live path, whose
1441 // cancellation can be confirmed between bars, does not have. So after every pass the replay shows
1442 // the runner its book as it now stands and lets it release from that one snapshot: this ledger is the
1443 // broker, so the stop's absence and the holding after it are known together. See
1444 // `Runner::retry_deferred_on_one_snapshot`. An order placed here waits for the next bar like any
1445 // other, which is the lookahead rule.
1446 void settle_deferred() {
1447 if (runner_.deferred_targets().empty()) {
1448 return;
1449 }
1450 install_portfolio();
1451 collect(runner_.retry_deferred_on_one_snapshot());
1452 }
1453
1454 ReplayConfig config_;
1455 NextBarFillModel fills_;
1456 ReplayLedger ledger_;
1457 ReplayClock clock_;
1458 Runner runner_;
1459 // Turns this replay's own working listing into `on_order_update` events.
1460 runner::OrderUpdateWatch<Tag> updates_;
1461 ReplayResult result_;
1462 // Whether a bar since the last equity point went unvalued, so the next point carries no return.
1463 bool equity_gap_{false};
1464 std::optional<strategy::BarEvent> previous_;
1465 // For a strategy declaring `on_bars`: the bars of the period not yet delivered, and the period end of
1466 // the last one delivered. A later bar may not join a delivered period, so nothing is decided twice.
1467 strategy::slice_detail::Bucket pending_;
1468 std::optional<std::chrono::system_clock::time_point> delivered_through_;
1469};
1470
1471} // namespace backtest
1472} // namespace algotradelib
Bar-interval arithmetic and the replay clock.
A three-letter currency code, such as KRW or USD (ISO 4217 style).
Definition types.hpp:234
An exact signed decimal: an int64 coefficient times 10^-scale.
Definition types.hpp:297
static Decimal64 parse(std::string_view input)
Parses a decimal string exactly (no binary floating point).
Definition types.hpp:323
const ReplayLedger & ledger() const noexcept
Returns the ledger: positions, cash and realized profit.
Definition engine.hpp:480
Backtester(Strategy strategy={}, ReplayConfig config={}, NextBarFillModel fills=NextBarFillModel{})
Constructs a replay.
Definition engine.hpp:452
const ReplayResult & result() const noexcept
Returns what the replay has produced so far.
Definition engine.hpp:492
void feed(const strategy::QuoteEvent &event, std::optional< bool > trading_day=true)
Replays one quote: moves the price and the clock to it, re-derives the phase (calling on_phase on a c...
Definition engine.hpp:649
const NextBarFillModel & fills() const noexcept
Returns the fill model, for summarize.
Definition engine.hpp:486
void feed(const strategy::BarEvent &event, std::optional< bool > trading_day=true)
Replays one bar.
Definition engine.hpp:517
QueueingExecutor< Tag > Executor
The executor type.
Definition engine.hpp:442
runner::Runner< Strategy, CapsType, Tag, Executor > Runner
The runner::Runner the replay drives, the same type a live session uses.
Definition engine.hpp:444
void finish()
Ends the replay: calls on_stop (with the current portfolio, including orders from the last bar),...
Definition engine.hpp:725
const Strategy & strategy() const noexcept
Returns the strategy, to read the state it kept.
Definition engine.hpp:474
BacktestTag Tag
The order-reference tag.
Definition engine.hpp:440
The fill model: market orders at the next bar's open, limit orders when the next bar's range reaches ...
Definition fill.hpp:68
The replay's executor: accepts orders and holds them until they fill or are withdrawn.
Definition engine.hpp:87
void deciding_at(std::chrono::system_clock::time_point at, bool exact=true)
Sets the decision instant stamped on orders accepted from now on.
Definition engine.hpp:276
const std::vector< QueuedOrder< BrokerTag > > & queued() const noexcept
Returns every outstanding order.
Definition engine.hpp:200
static constexpr bool models_time_in_force(TimeInForce condition) noexcept
Definition engine.hpp:163
std::optional< OrderRef< BrokerTag > > last_ref() const
Returns the reference issued to the most recently accepted order.
Definition engine.hpp:194
std::uint64_t bar() const noexcept
Returns the bar being replayed.
Definition engine.hpp:269
runner::SendOutcome cancel(const WorkingOrder< BrokerTag > &working)
Withdraws the order a working row names, matched by reference.
Definition engine.hpp:180
bool release(const OrderRef< BrokerTag > &ref)
Marks a queued stop-limit order as released.
Definition engine.hpp:250
void assign_session(const OrderRef< BrokerTag > &ref, Date session)
Records the session date of a queued order; see QueuedOrder::session_.
Definition engine.hpp:221
static bool can_model(const strategy::PlannedOrder &order) noexcept
Definition engine.hpp:129
const QueuedOrder< BrokerTag > * find(const OrderRef< BrokerTag > &ref) const
Finds an outstanding order.
Definition engine.hpp:207
runner::SendOutcome place(const strategy::PlannedOrder &order)
Accepts and queues an order, issuing it a reference.
Definition engine.hpp:103
bool withdraw(const OrderRef< BrokerTag > &ref)
Removes an order from the queue.
Definition engine.hpp:235
void open_bar() noexcept
Starts the next bar; orders accepted from now on cannot fill against it.
Definition engine.hpp:263
static bool contiguous(const strategy::BarEvent &previous, const strategy::BarEvent &event)
Tests whether a bar follows the previous one with no gap.
Definition clock.hpp:148
Positions, cash and realized profit, updated by each execution of a replay.
Definition ledger.hpp:61
void set_calendar(TradingCalendar calendar)
Replaces the trading calendar (for runners and sources, not strategies).
Definition context.hpp:290
Runs one strategy: feeds it events, reconciles what it asks for, checks the risk limits,...
Definition runner.hpp:297
const Context & context() const noexcept
Returns the context.
Definition runner.hpp:543
The backtest fill model: whether a queued order trades against the next bar, and at what price and co...
The replay ledger: positions, cash and realized profit a backtest keeps, in exact decimals.
Replays a strategy over stored bars: fill and cost models, the replay ledger, and the report.
Definition clock.hpp:14
std::optional< std::chrono::seconds > interval_duration(BarInterval interval)
Returns the fixed duration of an interval (bar_interval_length).
Definition clock.hpp:30
double Scalar
The floating-point type the analytics and math layers compute in (double).
Definition scalar.hpp:18
Scalar missing()
Returns the value that stands for "no observation" (a quiet NaN).
Definition scalar.hpp:28
SendOutcome
What became of one instruction (a placement or a cancellation) an executor was asked to send.
Definition execution.hpp:30
@ REJECTED
The broker refused the instruction (for a placement, the order does not exist); nothing needs reconci...
Definition execution.hpp:34
@ ACCEPTED
The broker accepted the instruction (for a placement, the order exists).
Definition execution.hpp:32
@ REFUSED_LOCALLY
The binding refused the instruction before sending it (such as an idempotency key at a broker without...
Definition execution.hpp:63
What a strategy sees and emits: events, intents, the portfolio, reconciliation and risk limits.
Definition concept.hpp:16
The library: broker-neutral types (core), and the modules below.
@ CLOSE
The timestamp is the instant the period ended.
@ OPEN
The timestamp is the first instant of the period.
constexpr std::optional< std::chrono::seconds > bar_interval_length(BarInterval interval) noexcept
Returns an interval's fixed length, for an intraday interval.
@ INVALID_ARGUMENT
The request was invalid, refused by the client before sending or by the broker.
Definition types.hpp:713
@ LIMIT
지정가: fills at the stated price or better.
Definition types.hpp:1710
@ BEST_LIMIT
최유리지정가: priced at the best quote on the opposite side.
Definition types.hpp:1716
@ PRIMARY_LIMIT
최우선지정가: priced at the best quote on the order's own side.
Definition types.hpp:1718
@ CONDITIONAL_LIMIT
조건부지정가: a limit that becomes a market order at the close.
Definition types.hpp:1714
@ MID_PRICE
중간가: priced at the midpoint of the spread.
Definition types.hpp:1720
@ STOP_LIMIT
스톱지정가: a limit released when a trigger price is reached.
Definition types.hpp:1722
@ CLOSING_PRICE
시간외종가: priced at a session's closing price.
Definition types.hpp:1724
@ MARKET
시장가: fills at whatever the book offers.
Definition types.hpp:1712
@ REGULAR
The continuous regular session.
Definition types.hpp:1842
std::optional< Decimal64 > decimal_subtract(const Decimal64 &lhs, const Decimal64 &rhs)
Subtracts two decimals exactly.
Definition types.hpp:530
std::optional< Decimal64 > decimal_divide(const Decimal64 &lhs, const Decimal64 &rhs, std::uint8_t scale)
Divides two decimals, truncating toward zero at a given scale.
Definition types.hpp:602
constexpr bool time_in_force_expires_at_close(TimeInForce condition) noexcept
Tests whether an order with this condition expires at the session's close, so its filled quantity is ...
Definition types.hpp:1822
bool decimal_less(const Decimal64 &lhs, const Decimal64 &rhs)
Compares two decimals numerically, whatever their scales.
Definition types.hpp:665
std::optional< Decimal64 > decimal_multiply(const Decimal64 &lhs, const Decimal64 &rhs)
Multiplies two decimals exactly; the scales add.
Definition types.hpp:571
TimeInForce
How long an order stays valid and what happens to an unfilled remainder.
Definition types.hpp:1797
@ FILL_OR_KILL
Fills entirely and immediately, or not at all.
Definition types.hpp:1804
@ DAY
Rests until the session ends; an unfilled remainder is cancelled then.
Definition types.hpp:1800
@ IMMEDIATE_OR_CANCEL
Fills what it can immediately and cancels the remainder.
Definition types.hpp:1802
Decimal64 close_
The last trade price.
std::chrono::system_clock::time_point timestamp_
The instant marking the period; BarSeries::stamp_ says which edge.
The error a broker or transport operation returns.
Definition types.hpp:742
std::string message_
A human-readable description, for logs; do not parse it.
Definition types.hpp:772
ErrorCategory category_
The kind of failure; the field to branch on.
Definition types.hpp:744
std::string broker_code_
The broker's own error code, verbatim (for example KIS's EGW00201).
Definition types.hpp:761
bool reached_broker_
Whether a request was sent before this error.
Definition types.hpp:753
A calendar date with no time of day or time zone.
Definition types.hpp:1142
A broker's reference to one order, used to amend or cancel it.
Definition types.hpp:2111
std::string primary_
The order number or id.
Definition types.hpp:2113
static PriceRule unknown(std::string reason)
Returns a rule that knows no grid and refuses to round.
Decimal64 last_price_
The last traded price, as the broker reported it.
Definition types.hpp:874
std::chrono::system_clock::time_point received_at_
When this process received the quote, on this machine's clock.
Definition types.hpp:880
InstrumentId instrument_
The instrument.
Definition types.hpp:872
The trading days a broker reported for one market, ascending by date.
Definition calendar.hpp:44
An order still open to modification or cancellation.
Definition types.hpp:2290
OrderRef< Broker > ref_
The reference to amend or cancel the order with.
Definition types.hpp:2294
The broker tag of a replay's order references (they reach no broker).
Definition ledger.hpp:25
One point on a replay's equity curve, recorded per bar.
Definition engine.hpp:291
Decimal64 equity_
Cash plus positions marked at the bar's close, in the replay's currency.
Definition engine.hpp:295
math::Scalar period_return_
The return since the previous point; missing() on the first point, after an unvalued bar,...
Definition engine.hpp:304
std::chrono::system_clock::time_point at_
The bar's period end.
Definition engine.hpp:293
One order a replay accepted and has not yet filled or withdrawn.
Definition engine.hpp:39
std::optional< std::chrono::system_clock::time_point > decided_at_
When the strategy decided the order, if known.
Definition engine.hpp:56
std::optional< Date > session_
The market-local date of the session a DAY order lives in, once known (the date of the first bar it c...
Definition engine.hpp:75
bool released_
Whether a stop-limit order has been released, so it now rests as its limit.
Definition engine.hpp:70
bool decision_is_exact_
Whether decided_at_ is enforced strictly, or only a nominal upper bound (the period end of an open-st...
Definition engine.hpp:64
strategy::PlannedOrder order_
The order as reconciliation planned it.
Definition engine.hpp:41
std::uint64_t accepted_on_
The bar it was accepted during; it cannot fill against that bar.
Definition engine.hpp:45
OrderRef< BrokerTag > ref_
The reference the replay issued for it.
Definition engine.hpp:43
A replay's settings, besides the strategy and the bars.
Definition engine.hpp:389
strategy::RiskLimits limits_
The risk limits, applied as they are live.
Definition engine.hpp:397
Decimal64 cash_
The starting cash, in currency_.
Definition engine.hpp:391
TradingCalendar calendar_
The calendar ctx.calendar() returns, for a strategy asking about dates it has no bar for (such as whe...
Definition engine.hpp:415
Currency currency_
The accounting currency; the equity curve is in this currency only.
Definition engine.hpp:393
std::vector< Instrument > instruments_
Reference data for every instrument replayed; orders on an instrument without it are refused.
Definition engine.hpp:395
std::size_t series_capacity_
How many trailing bars the context caches per series, for indicators; must be positive.
Definition engine.hpp:407
strategy::SessionWindow session_
The session window phases are derived from and DAY orders expire at; one for the whole replay.
Definition engine.hpp:405
Everything a replay produced; summarize turns it into a Report.
Definition engine.hpp:308
std::size_t unvalued_bars_
Bars whose equity total did not fit an exact decimal, so they have no curve point.
Definition engine.hpp:358
std::size_t stop_releases_
Stop-limit orders a bar released; see ReportCaveats::stop_releases_.
Definition engine.hpp:362
std::size_t bars_
The bars fed.
Definition engine.hpp:323
std::size_t limit_fills_bounded_
Limit-type fills moved to stay in range; see ReportCaveats::limit_fills_bounded_.
Definition engine.hpp:366
std::size_t unchecked_fill_causality_
How many fill decisions could not be checked for causality; see ReportCaveats::unchecked_fill_causali...
Definition engine.hpp:350
std::optional< std::chrono::system_clock::time_point > first_unvalued_
The period end of the first unvalued bar, if any.
Definition engine.hpp:360
std::vector< strategy::GuardRejection > refused_
Instructions a risk guard refused.
Definition engine.hpp:319
std::vector< strategy::Rejection > rejected_
Instructions not placed: reconciliation could not build them, or the replay cannot model them (BINDIN...
Definition engine.hpp:317
std::vector< Execution > executions_
Every execution, in order.
Definition engine.hpp:312
std::size_t gaps_
Bars that did not follow their predecessor; gaps are not interpolated.
Definition engine.hpp:321
std::size_t expired_day_orders_
DAY orders withdrawn unfilled when their session ended, as a broker does at the close.
Definition engine.hpp:385
std::size_t unfilled_
Orders still queued when finish ran.
Definition engine.hpp:325
std::optional< BrokerError > halted_
Why the replay stopped early, if it did.
Definition engine.hpp:336
std::size_t stop_fills_intrabar_
Fills of stops released inside a bar; see ReportCaveats::stop_fills_intrabar_.
Definition engine.hpp:364
std::size_t quotes_
The quotes fed; see ReportCaveats::quotes_ for what quote-driven orders mean in a replay.
Definition engine.hpp:340
Currency currency_
The currency of the equity curve (the replay's accounting currency).
Definition engine.hpp:327
std::optional< std::chrono::system_clock::time_point > first_overdrawn_
The first bar end at which cash was below zero, if any.
Definition engine.hpp:379
std::size_t aborted_
Orders still working when the replay halted, cleared from the queue (unlike unfilled_).
Definition engine.hpp:338
std::optional< Money > lowest_cash_
The lowest cash any execution left in currency_, if below zero (checked at every fill).
Definition engine.hpp:381
std::optional< std::chrono::system_clock::time_point > lowest_cash_at_
When lowest_cash_ was reached (that execution's Fill::at_).
Definition engine.hpp:383
std::vector< EquityPoint > equity_
One point per bar that could be valued, in order.
Definition engine.hpp:310
std::size_t overdrawn_bars_
Bars that closed with cash below zero in currency_ (counted per bar fed, so two instruments count bot...
Definition engine.hpp:377
One bar, delivered to on_bar.
Definition event.hpp:202
BarStamp stamp_
Whether bar_.timestamp_ marks the period's open or close.
Definition event.hpp:208
std::chrono::system_clock::time_point period_end_
When the bar's period ended: the earliest moment the bar could be known.
Definition event.hpp:218
InstrumentId instrument_
The instrument the bar is for.
Definition event.hpp:204
BarInterval interval_
The period length the bar covers.
Definition event.hpp:206
One order the runner is to place.
std::vector< BuyingPower > buying_power_
What may be spent, per currency.
Definition portfolio.hpp:71
A quote the runner has just read, delivered to on_quote.
Definition event.hpp:390
QuoteSnapshot quote_
The quote, as the broker reported it.
Definition event.hpp:392
The limits the operator sets for RiskGuard.
Definition risk.hpp:268
The intraday session of a market, as the operator states it.
Definition event.hpp:106
static SessionWindow krx()
Returns the KRX regular session, 09:00-15:30 KST.
Definition event.hpp:120