algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
session_source.hpp
1#pragma once
2
3#include "../runner/loop.hpp"
4#include "../runner/order_updates.hpp"
5#include "../runner/reconcile_orders.hpp"
6
7#include <algorithm>
8#include <algotradelib/broker>
9
10#include <chrono>
11#include <cstddef>
12#include <iterator>
13#include <map>
14#include <optional>
15#include <stdexcept>
16#include <string>
17#include <string_view>
18#include <type_traits>
19#include <utility>
20#include <vector>
21
22namespace algotradelib {
23namespace live {
24
39
51inline BrokerError incomplete_history(std::string code, std::string message) {
52 BrokerError error;
54 error.reached_broker_ = true;
55 error.broker_code_ = std::move(code);
56 error.message_ = std::move(message);
57 return error;
58}
59
66 return incomplete_history(
67 "history_not_continued",
68 "the broker returned another history page, and this adapter declares no cursor to read it "
69 "(has_history_cursor is false): the history is incomplete"
70 );
71}
72
79 return incomplete_history(
80 "history_page_limit",
81 "the order history ran past " + std::to_string(pages) + " pages and the last still said there is more: "
82 "the history is incomplete"
83 );
84}
85
86namespace session_source_detail {
87
88// Whether `Runner` delivers bars in slices (`Runner::takes_bar_slices`, a strategy declaring `on_bars`).
89// A runner of one's own without the constant takes bars one at a time, as before.
90template <typename Runner, typename = void>
91struct takes_slices : std::false_type {};
92
93template <typename Runner>
94struct takes_slices<Runner, std::enable_if_t<Runner::takes_bar_slices>> : std::true_type {};
95
96// Whether `Runner` can be fed `Event` as history.
97template <typename Runner, typename Event, typename = void>
98struct feeds_warmup_of : std::false_type {};
99
100template <typename Runner, typename Event>
101struct feeds_warmup_of<
102 Runner, Event, std::void_t<decltype(std::declval<Runner&>().feed_warmup(std::declval<const Event&>()))>
103> : std::true_type {};
104
105// Whether `Runner` can be fed history on the route it takes, which every `runner::Runner` can: a slice
106// for a strategy declaring `on_bars`, a bar otherwise. Required by `poll_bars`: a runner without it would
107// send what was decided on a cold start's history.
108template <typename Runner>
109struct feeds_warmup : feeds_warmup_of<
110 Runner, std::conditional_t<takes_slices<Runner>::value, strategy::BarSlice, strategy::BarEvent>
111> {};
112
113} // namespace session_source_detail
114
155template <
156 typename Session,
157 typename Journal = runner::MemoryJournal,
158 typename MarketData = Session
159>
161public:
165 std::optional<Money> amount_;
168 };
169
179
181 using Caps = typename Session::Caps;
183 using BrokerTag = typename Session::BrokerTag;
184
185 // The trading session is read for holdings, the order listing and history, which a market-data source
186 // does not have; it belongs in `MarketData`, which this does not check.
187 static_assert(
188 !Caps::data_only,
189 "SessionSource's first session is the trading session (holdings, orders), and this one's client is a "
190 "market-data source (DataOnlyTraits): pass a broker's session first and the data source as the "
191 "market-data session, SessionSource<Trading, Journal, MarketData>"
192 );
193
200 static constexpr bool provides_working_orders = Caps::has_working_orders;
201
207 SessionSource(Session& session, std::vector<InstrumentId> universe)
208 : session_(&session), market_data_(&session), universe_(std::move(universe)) {}
209
217 SessionSource(Session& session, MarketData& market_data, std::vector<InstrumentId> universe)
218 : session_(&session), market_data_(&market_data), universe_(std::move(universe)) {}
219
229 void set_journal(Journal& journal) { journal_ = &journal; }
230
236 void set_match_window(runner::MatchWindow window) { window_ = window; }
237
242 const runner::ReconcileReport& last_reconcile() const noexcept { return reconcile_; }
243
251 void set_buying_power_currencies(std::vector<Currency> currencies) {
252 currencies_ = std::move(currencies);
253 }
254
260 const std::vector<Currency>& buying_power_currencies() const noexcept { return currencies_; }
261
269 std::size_t bars_not_yet_closed() const noexcept { return bars_not_yet_closed_; }
270
276 std::size_t bars_fed_as_warmup() const noexcept { return bars_fed_as_warmup_; }
277
289 std::size_t bars_not_delivered() const noexcept { return bars_not_delivered_; }
290
296 void set_bar_query(BarInterval interval, int count) {
297 interval_ = interval;
298 count_ = count;
299 }
300
309 void set_calendar_country(Country country) { calendar_country_ = country; }
310
315 Country calendar_country() const noexcept { return calendar_country_; }
316
326 const std::optional<BrokerError>& last_error() const noexcept { return last_error_; }
327
346 template <typename Runner>
347 runner::PollOutcome poll(runner::PollKind kind, Runner& runner_instance) {
348 switch (kind) {
350 return poll_calendar(runner_instance);
352 return poll_bars(runner_instance);
354 return poll_quotes(runner_instance);
356 return poll_holdings(runner_instance);
358 return poll_working_orders(runner_instance);
360 return poll_buying_power(runner_instance);
362 return poll_price_limits(runner_instance);
363 }
365 }
366
379 template <typename Runner>
380 runner::PollOutcome read_order_list(Runner& runner_instance) {
381 if constexpr (Caps::has_working_orders) {
382 // **No order updates from this read, and the reason is a hazard rather than a preference.**
383 // This runs inside `PollingLoop::gate_for`, which the loop calls *before* it applies the
384 // startup hold, and it is handed a mutable runner -- so a `Runner::feed` here would dispatch
385 // to a strategy that can still submit, against the picture the gate exists to withhold. The
386 // listing is adopted as the baseline instead, which is also the honest reading of it: a
387 // predecessor's resting order is initial state, not a change, and it is installed on
388 // `Portfolio::working_` by the same call, which is where a strategy reads what it inherited.
389 //
390 // The cost is stated rather than hidden: an order present at startup that fills or is
391 // cancelled before the first scheduled poll produces no update at all.
392 return poll_working_orders(runner_instance, /*publish=*/false);
393 } else {
394 OrderHistoryQuery query;
395 if constexpr (Caps::has_history_limit) {
396 // Gated as `read_history_in` gates it: a broker that cannot honour a limit may refuse the query.
397 query.limit_ = history_limit_;
398 }
399 // Rows are kept as listed, page after page, with no de-duplication: this listing is undated, and
400 // two orders on different days can share a number. Nothing reaches `reconcile` unless the whole
401 // history was read.
402 std::vector<OrderSnapshot> rows;
403 bool complete = false;
404 for (int page = 0; page < maximum_history_pages_ && !complete; ++page) {
405 auto result = session_->order_history(query);
406 if (!result) {
407 last_error_ = result.error();
408 return poll_outcome_of(*last_error_);
409 }
410 auto& listed = result.value().orders_;
411 rows.insert(rows.end(), std::make_move_iterator(listed.begin()), std::make_move_iterator(listed.end()));
412 const bool more = result.value().next_cursor_ && !result.value().next_cursor_->empty();
413 if (!more) {
414 complete = true;
415 } else if constexpr (Caps::has_history_cursor) {
416 query.cursor_ = result.value().next_cursor_;
417 } else {
418 last_error_ = history_not_continued();
419 return poll_outcome_of(*last_error_);
420 }
421 }
422 if (!complete) {
423 last_error_ = history_page_limit(maximum_history_pages_);
424 return poll_outcome_of(*last_error_);
425 }
426 last_error_.reset();
427 // What this call establishes, with or without a journal, is the thing the gate actually needs:
428 // that this process has looked at the broker's own record before sending.
429 reconcile(runner_instance, rows);
431 }
432 }
433
443 const runner::ChargedCosts& charged_costs() const noexcept { return charged_; }
444
445private:
446 // Fetches bars for each instrument and feeds only those not seen before, in period-end order
447 // across the universe; only each instrument's newest may send (older ones are fed as history, see
448 // `Runner::feed_warmup`).
449 template <typename Runner>
450 runner::PollOutcome poll_bars(Runner& runner_instance) {
451 if constexpr (session_source_detail::takes_slices<Runner>::value) {
452 if (interrupted_) {
453 throw std::logic_error(
454 "a bar poll for on_bars was interrupted by an exception (" + *interrupted_ + "); its period may be "
455 "partly recorded and partly delivered, so this source does not continue: restart the program with a "
456 "fresh runner and source; older periods warm up the strategy, and the newest eligible period may trade"
457 );
458 }
459 }
460 // Nothing configured is nothing read: `last_error()` is left as it was, as the other polls leave it.
461 if (universe_.empty()) {
463 }
464 // **Every instrument is read before anything is fed**, and then the new bars of all of them are
465 // fed in period-end order, ties in universe order -- the order a replay feeds them. Fed one
466 // instrument at a time, a cold start handed a cross-sectional strategy all of one name's history
467 // and then the next name's from its beginning, so the decision instant ran backwards between
468 // names and the strategy's state diverged from a replay's. A query that fails feeds nothing this
469 // poll; the next poll reads everything again.
470 struct Collected {
471 std::size_t instrument_{0};
472 strategy::BarEvent event_;
473 bool newest_{false};
474 };
475 std::vector<Collected> collected;
476 for (std::size_t at = 0; at < universe_.size(); ++at) {
477 const auto& instrument = universe_[at];
478 BarQuery query;
479 query.instrument_ = instrument;
480 query.interval_ = interval_;
481 query.count_ = count_;
482
483 auto result = market_data_->bars(query);
484 if (!result) {
485 last_error_ = result.error();
486 return poll_outcome_of(*last_error_);
487 }
488 const auto& series = result.value();
489 if (series.bars_.empty()) {
490 continue;
491 }
492
493 // Only bars newer than the last one fed, and in order. A poll returning the same closed bar
494 // again is the normal case between closes, and feeding it twice would double every signal
495 // derived from it.
496 //
497 // **And only bars whose period has ended.** Every shipped adapter's daily query runs up to
498 // today, and the broker answers today's row while the session is still trading -- KIS
499 // documents `mod_yn` for "the current business day" on `inquire-daily-itemchartprice`, and
500 // Kiwoom's daily close field is `cur_prc`, the current price. Fed, that bar reached the
501 // strategy with the price so far as its close, `last_bar_` moved past it, and the session's
502 // real close was never fed. So a bar ending after the instant the loop reported is left for
503 // a later poll, which reads it again. Its period end is `bar_period_end`'s, the instant the
504 // runner would have stamped the decision with, so the decision is never dated ahead of the
505 // clock either.
506 //
507 // The instant is the later of the loop's (`Runner::observed_at`) and when this machine
508 // received the series (`BarSeries::received_at_`, which every shipped adapter stamps), so a
509 // source driven by hand still has one, and one a finished `run` left behind cannot hold back a
510 // bar received closed since; with neither, every bar is fed, as before. **Only where the
511 // period end is known**: a close-stamped bar states it, and a day or an intraday bar stamped at its
512 // open ends one interval later. A week, a month or a year stamped at its open is fed as
513 // before, because no adapter's convention for those stamps has been observed and a wrong
514 // guess would hold every closed week back by a week.
515 const auto instant = std::max(runner_instance.observed_at(), series.received_at_);
516 const bool knowable = series.stamp_ == BarStamp::CLOSE || series.interval_ == BarInterval::ONE_DAY ||
517 bar_interval_length(series.interval_).has_value();
518 const bool timed = knowable && instant != std::chrono::system_clock::time_point{};
519 const auto& seen = last_bar_[instrument.symbol_];
520 const auto first = collected.size();
521 for (const auto& bar : series.bars_) {
522 if (seen && bar.timestamp_ <= *seen) {
523 continue;
524 }
525 if (timed && instant < strategy::bar_period_end(bar, series.interval_, series.stamp_)) {
526 // Counted once per bar, not once per poll: a daily bar is met by every poll of its session.
527 auto& held = held_bar_[instrument.symbol_];
528 if (!held || !(*held == bar.timestamp_)) {
529 held = bar.timestamp_;
530 ++bars_not_yet_closed_;
531 }
532 break;
533 }
534 collected.push_back(Collected{
535 at, strategy::make_bar_event(instrument, series.interval_, series.stamp_, bar), false});
536 }
537 // **Only the newest bar of an instrument's read may send.** The ones before it -- a cold
538 // start's history, a backlog after an outage -- are history: the strategy is shown them, so its
539 // state evolves as a replay's does, and what it decides on them is discarded
540 // (`Runner::feed_warmup`). Otherwise a restart acts on every signal in its history at today's
541 // price, which for an event-triggered strategy is a trade nobody decided today.
542 if (collected.size() > first) {
543 collected.back().newest_ = true;
544 }
545 }
546
547 std::stable_sort(collected.begin(), collected.end(), [](const Collected& a, const Collected& b) {
548 return a.event_.period_end_ < b.event_.period_end_;
549 });
550 // **And not at or before another instrument's history.** An instrument whose read ends early --
551 // a name suspended today, or for days -- has a newest bar no later than the other names' older
552 // ones; sent there, a cross-sectional strategy would act on a stale cross-section at today's
553 // price. So a newest bar sends only when its period ended strictly after every history bar of the
554 // read; one that did not is history too, and that instrument's decision waits for its next bar.
555 // Strictly: a tie with a history bar is the same stale session whichever name the universe lists
556 // first.
557 std::optional<std::chrono::system_clock::time_point> latest_history;
558 for (const auto& item : collected) {
559 if (!item.newest_ && (!latest_history || *latest_history < item.event_.period_end_)) {
560 latest_history = item.event_.period_end_;
561 }
562 }
563 static_assert(
564 session_source_detail::feeds_warmup<Runner>::value,
565 "SessionSource feeds a read's older bars through Runner::feed_warmup; a runner without it "
566 "would send decisions made on history"
567 );
568 auto outcome = runner::PollOutcome::UPDATED;
569 if (collected.empty()) {
571 }
572 if constexpr (session_source_detail::takes_slices<Runner>::value) {
573 // **One delivery per period end and interval.** The bars are in period-end order, so a period is
574 // complete when a later one starts. Within one read a period is all history or all live (a newest
575 // bar tying a history bar is history), so the whole bucket goes one way.
576 //
577 // **An exception stops the source** (a bar the cache refuses, a handler that throws). The period
578 // being collected may then be partly recorded and partly delivered, and resuming it in a later poll
579 // would have to reconcile it with that poll's history and order; every later poll throws instead,
580 // so nothing is lost or delivered twice in silence. A restart builds a fresh runner and source.
581 strategy::slice_detail::Bucket bucket;
582 bool bucket_is_history = false;
583 try {
584 for (const auto& item : collected) {
585 const auto& instrument = universe_[item.instrument_];
586 const auto& event = item.event_;
587 // **A period already delivered is not reopened.** The bar keeps its own series complete, but an
588 // older bar must not overwrite a newer price or turn the clock back.
589 if (delivered_through_ && !(*delivered_through_ < event.period_end_)) {
590 runner_instance.record_history_only(event);
591 last_bar_[instrument.symbol_] = event.bar_.timestamp_;
592 ++bars_not_delivered_;
593 continue;
594 }
595 if (!bucket.empty() && bucket.period_end() < event.period_end_) {
596 deliver(runner_instance, bucket, bucket_is_history);
597 }
598 if (bucket.holds(event)) {
599 last_bar_[instrument.symbol_] = event.bar_.timestamp_;
600 ++bars_not_delivered_;
601 continue;
602 }
603 const bool history =
604 !item.newest_ || (latest_history && !(*latest_history < event.period_end_));
605 runner_instance.record(event);
606 bucket.add(event);
607 bucket_is_history = history;
608 last_bar_[instrument.symbol_] = event.bar_.timestamp_;
609 if (history) {
610 ++bars_fed_as_warmup_;
611 }
612 }
613 deliver(runner_instance, bucket, bucket_is_history);
614 } catch (const std::exception& error) {
615 interrupted_ = error.what();
616 throw;
617 } catch (...) {
618 interrupted_ = "an unknown exception";
619 throw;
620 }
621 } else {
622 for (const auto& item : collected) {
623 const auto& instrument = universe_[item.instrument_];
624 if (!item.newest_ || (latest_history && !(*latest_history < item.event_.period_end_))) {
625 runner_instance.feed_warmup(item.event_);
626 ++bars_fed_as_warmup_;
627 last_bar_[instrument.symbol_] = item.event_.bar_.timestamp_;
628 continue;
629 }
630 runner_instance.context().set_last_price(instrument, item.event_.bar_.close_);
631 runner_instance.feed(item.event_);
632 last_bar_[instrument.symbol_] = item.event_.bar_.timestamp_;
633 }
634 }
635 last_error_.reset();
636 return outcome;
637 }
638
639 // Delivers one period's slices, one interval at a time, as history or live.
640 template <typename Runner>
641 void deliver(Runner& runner_instance, strategy::slice_detail::Bucket& bucket, bool history) {
642 while (!bucket.empty()) {
643 auto slice = bucket.take_first();
644 delivered_through_ = slice.period_end_;
645 if (history) {
646 runner_instance.feed_warmup(slice);
647 } else {
648 runner_instance.feed(slice);
649 }
650 }
651 }
652
653 // Fetches a quote per instrument and feeds each one.
654 template <typename Runner>
655 runner::PollOutcome poll_quotes(Runner& runner_instance) {
656 if (universe_.empty()) {
658 }
659 auto outcome = runner::PollOutcome::UNCHANGED;
660 for (const auto& instrument : universe_) {
661 auto result = market_data_->quote(instrument);
662 if (!result) {
663 last_error_ = result.error();
664 return poll_outcome_of(*last_error_);
665 }
666 strategy::QuoteEvent event;
667 event.quote_ = result.value();
668 runner_instance.feed(event);
670 }
671 last_error_.reset();
672 return outcome;
673 }
674
675 // Refreshes positions, keeping whatever working orders the context already holds.
676 //
677 // The two inquiries are separate calls on their own cadences, so each must merge rather than
678 // replace: rebuilding the portfolio from a holdings snapshot alone would silently drop every
679 // working order, and reconciliation would then place orders it had already placed.
680 template <typename Runner>
681 runner::PollOutcome poll_holdings(Runner& runner_instance) {
682 auto result = session_->holdings();
683 if (!result) {
684 last_error_ = result.error();
685 return poll_outcome_of(*last_error_);
686 }
687 last_error_.reset();
688
689 auto portfolio = runner_instance.context().portfolio();
690 portfolio.positions_ = result.value().positions_;
691 // **The basis travels with the rows it describes, and both directions of leaving it behind are
692 // defects.** Keeping the previous basis would let a snapshot that established nothing be measured
693 // against an older read's declaration -- a third-party source's undeclared figure authorizing a
694 // sell. Dropping it would leave the portfolio unestablished after a broker that *did* declare, so
695 // every checked sell would refuse at both shipped adapters. It is assigned, not merged, for the
696 // same reason `positions_` is: this is one read, and its figures mean what this read says.
697 portfolio.sellable_basis_ = result.value().sellable_basis_;
698 // When the snapshot arrived, which is the best the adapters can do: none gets a time from the
699 // broker for a holdings answer, so `received_at_` is this machine's clock at parse time. The loop's staleness
700 // gate therefore measures time since arrival, and understates the age of the figures by the
701 // request's own latency.
702 portfolio.as_of_ = result.value().received_at_;
703 runner_instance.context().set_portfolio(std::move(portfolio));
705 }
706
707 // Refreshes what may be spent, per configured currency, keeping everything else.
708 //
709 // One request per currency, on the holdings cadence. A currency whose answer fails leaves the
710 // previous reading in place and reports the failure, rather than dropping a figure the guard is
711 // about to size against -- the loop's own staleness gate is what decides when an old reading
712 // stops being good enough, and it can only do that if the reading is still there.
713 template <typename Runner>
714 runner::PollOutcome poll_buying_power(Runner& runner_instance) {
715 if constexpr (!Caps::has_buying_power) {
716 static_cast<void>(runner_instance);
718 } else {
719 if (currencies_.empty()) {
721 }
722 auto portfolio = runner_instance.context().portfolio();
723 bool updated = false;
724 // Kept apart so the winning outcome and the reason reported for it stay the same currency's.
725 std::optional<BrokerError> outright;
726 std::optional<BrokerError> throttled;
727 for (const auto& currency : currencies_) {
728 auto result = session_->buying_power(currency);
729 if (!result) {
730 // **A real failure outranks a rate-limit refusal**, wherever in the sweep each landed.
731 // They are not the same news: the limiter refusing is the limiter working, and the loop
732 // deliberately does not count it against a poll kind, while an outage must count. Taking
733 // whichever came last would let a currency that fails every sweep be reported as merely
734 // throttled the moment another currency was throttled behind it, and the failing budget
735 // would age on unnoticed.
736 //
737 // The reason is kept beside its outcome rather than overwritten, because `last_error()`
738 // promises why the last failing poll failed: reporting `FAILED` next to a rate-limit
739 // reason would send a reader after the wrong currency.
740 if (poll_outcome_of(result.error()) == runner::PollOutcome::FAILED) {
741 if (!outright) {
742 outright = result.error();
743 }
744 } else if (!throttled) {
745 throttled = result.error();
746 }
747 continue;
748 }
749 bool replaced = false;
750 for (auto& reading : portfolio.buying_power_) {
751 if (reading.cash_.currency_ == currency) {
752 reading = result.value();
753 replaced = true;
754 break;
755 }
756 }
757 if (!replaced) {
758 portfolio.buying_power_.push_back(result.value());
759 }
760 updated = true;
761 }
762 if (updated) {
763 runner_instance.context().set_portfolio(std::move(portfolio));
764 }
765 const auto& reason = outright ? outright : throttled;
766 if (reason) {
767 last_error_ = *reason;
768 } else {
769 last_error_.reset();
770 }
771 // **A partial answer is a failed poll**, even though the currencies that answered were still
772 // applied. Reporting `UPDATED` because *something* arrived would reset the loop's consecutive
773 // failure count, and a currency failing every time would then never abandon the run: the loop
774 // would report healthy polls indefinitely while orders in that currency were sized against a
775 // figure from hours ago. The readings are kept because the alternative -- dropping a budget
776 // the guard is about to size against -- is worse, and the staleness gate is what decides when
777 // an old reading stops being good enough. `last_error_` survives for the same reason.
778 if (outright) {
780 }
781 if (throttled) {
782 return poll_outcome_of(*throttled);
783 }
785 }
786 }
787
788 // Refreshes today's permitted price band for each instrument, and records it on the runner.
789 //
790 // The second poll whose result is not an event, and the second that **installs and feeds nothing**
791 // -- `runner::PollKind::PRICE_LIMITS` states that obligation and why it is stated for a poll that
792 // cannot break it. The band lands in `Runner::set_price_limits`, which dates it, and
793 // reconciliation reads it when a plan is built.
794 //
795 // Swept per instrument, with the partial-failure rules `poll_buying_power` argues in full and for
796 // the same reasons: **a real failure outranks a rate-limit refusal** wherever in the sweep each
797 // landed, the reason is kept beside its own outcome so `last_error()` describes the failure it
798 // reports, and **a partial answer is a failed poll** even though the instruments that answered are
799 // still installed.
800 //
801 // **A failure leaves the previous band in place**, as the calendar and the budget both do. Erasing
802 // it would turn a timed-out request into an unclamped order, which is the opposite of the
803 // direction a failure should push; the runner's age policy is what decides when the band that is
804 // still there has stopped being usable, and it can only decide that if it is still there.
805 template <typename Runner>
806 runner::PollOutcome poll_price_limits(Runner& runner_instance) {
807 if constexpr (!MarketData::Caps::has_price_limits) {
808 // A broker that publishes no band compiles no request, and a loop with this cadence set
809 // against one simply never reads a band: the capability is absent, and absent is not faked.
810 static_cast<void>(runner_instance);
812 } else {
813 if (universe_.empty()) {
815 }
816 bool updated = false;
817 std::optional<BrokerError> outright;
818 std::optional<BrokerError> throttled;
819 for (const auto& instrument : universe_) {
820 auto result = market_data_->price_limits(instrument);
821 if (!result) {
822 if (poll_outcome_of(result.error()) == runner::PollOutcome::FAILED) {
823 if (!outright) {
824 outright = result.error();
825 }
826 } else if (!throttled) {
827 throttled = result.error();
828 }
829 continue;
830 }
831 runner_instance.set_price_limits(instrument, std::move(result.value()));
832 updated = true;
833 }
834 const auto& reason = outright ? outright : throttled;
835 if (reason) {
836 last_error_ = *reason;
837 } else {
838 last_error_.reset();
839 }
840 if (outright) {
842 }
843 if (throttled) {
844 return poll_outcome_of(*throttled);
845 }
847 }
848 }
849
850 // Refreshes which dates the market trades, and puts the answer where the loop and a strategy read it.
851 //
852 // The only poll whose result is not an event. It lands in `Context::set_calendar`, from which
853 // `runner::PollingLoop` derives the market phase and a strategy answers "does tomorrow trade" --
854 // one copy, one owner, and no way for the loop and the strategy to be looking at different
855 // calendars.
856 //
857 // `from` is left absent so each broker supplies its own "today", which is today *there*: KIS dates
858 // every request in Korean local time and its traits layer fills that in. Asking with a date derived
859 // from this process's clock would consult the wrong day for the hours either side of local midnight.
860 //
861 // **A failure leaves the previous calendar in place**, exactly as `poll_buying_power` leaves the
862 // previous budget. Erasing it would take a run that knew today was a trading day and make it
863 // `UNKNOWN` because a reference-data request timed out, which refuses every order; the loop's own
864 // reasoning about an uncovered date is what decides when an old answer has stopped being usable,
865 // and it can only do that if the answer is still there.
866 template <typename Runner>
867 runner::PollOutcome poll_calendar(Runner& runner_instance) {
868 if constexpr (!MarketData::Caps::has_calendar) {
869 // A broker that publishes no calendar compiles no request. The loop then never establishes a
870 // phase, which it reports rather than guesses at: the capability is absent, and absent is not
871 // faked.
872 static_cast<void>(runner_instance);
874 } else {
875 auto result = market_data_->calendar(calendar_country_);
876 if (!result) {
877 last_error_ = result.error();
878 return poll_outcome_of(*last_error_);
879 }
880 last_error_.reset();
881 runner_instance.context().set_calendar(std::move(result.value()));
883 }
884 }
885
886 // Accumulates what the broker stated it charged, from one working-order listing.
887 //
888 // **One invariant holds the arithmetic together: what `charged_seen_` holds for a figure is
889 // exactly what has been contributed to the totals for it, and at which filled quantity.** Every
890 // rule below exists to keep that true, and each was a place it was once broken.
891 //
892 // - **A figure is cumulative per order**, so a total moves by the *delta* against what is held
893 // rather than by the whole figure again.
894 // - **A reading that states nothing withdraws nothing**, and does not move the quantity the held
895 // figure belongs to. Silence that advanced the quantity made the next real fee look like a
896 // disagreement about the previous one, and it was dropped.
897 // - **A figure for a quantity this figure has not been accepted at is new information**, whether
898 // the order was silent in between or this is its first figure at all.
899 // - **Two present figures at one quantity are a conflict**, and the held one stands: nothing in a
900 // snapshot says which the broker sent later.
901 // - **An order does not change the currency of its own charge**, so a reading that does is a
902 // conflict rather than a replacement that would strand what was accumulated in the first.
903 // - **Absence is not zero.** An order stating nothing contributes to no currency's total.
904 void observe_charged(const std::vector<OrderSnapshot>& rows) {
905 std::vector<std::string> present;
906 present.reserve(rows.size());
907 for (const auto& row : rows) {
908 present.push_back(row.order_id_);
909 const auto& execution = row.execution_;
910 const auto states_cost = execution.commission_ || execution.tax_;
911
912 auto held = charged_seen_.find(row.order_id_);
913 if (held == charged_seen_.end()) {
914 ObservedCharge fresh;
915 fresh.counted_cost_ = states_cost;
916 charged_seen_.emplace(row.order_id_, std::move(fresh));
917 held = charged_seen_.find(row.order_id_);
918 if (states_cost) {
919 ++charged_.orders_stating_cost_;
920 } else {
921 // Counted once, at first sight, and never taken back when a figure arrives later: both
922 // counters are monotone so that differencing them across runs cannot underflow, and an
923 // order that began without a figure is in both counts by design.
924 ++charged_.orders_stating_none_;
925 }
926 } else if (states_cost && !held->second.counted_cost_) {
927 held->second.counted_cost_ = true;
928 ++charged_.orders_stating_cost_;
929 }
930
931 take_figure(
932 charged_.commission_, held->second.commission_,
933 execution.commission_, execution.filled_quantity_
934 );
935 take_figure(
936 charged_.tax_, held->second.tax_, execution.tax_, execution.filled_quantity_
937 );
938 }
939
940 for (const auto& previous : charged_listing_) {
941 bool still_there = false;
942 for (const auto& id : present) {
943 if (id == previous) {
944 still_there = true;
945 break;
946 }
947 }
948 if (!still_there) {
949 // A row that was there and is not. It says nothing about *why*, and nothing about whether a
950 // charge was missed -- see the field's own documentation.
951 ++charged_.working_list_disappearances_;
952 }
953 }
954 charged_listing_ = std::move(present);
955 }
956
957 // Moves one running total by a new reading, keeping `held` in step with what the total was given.
958 //
959 // `quantity` is how much of the order had filled when the broker stated `now`. A figure is taken
960 // when that exceeds the quantity this figure was last accepted at; at or below it, two present
961 // amounts that differ are a conflict and the held one stands.
962 void take_figure(
963 std::vector<runner::ChargedCosts::StatedTotal>& totals,
964 ObservedFigure& held,
965 const std::optional<Money>& now,
966 const Decimal64& quantity
967 ) {
968 if (!now) {
969 // Silence withdraws nothing, and leaves this figure where it was -- including the quantity it
970 // belongs to, so a fee arriving later for the filled quantity is still new information.
971 return;
972 }
973 if (!held.amount_) {
974 if (charged_.add(totals, *now)) {
975 held.amount_ = now;
976 held.at_ = quantity;
977 }
978 return;
979 }
980 if (!(held.amount_->currency_ == now->currency_)) {
981 // An order does not change the currency of its own charge. Replacing it would strand what was
982 // accumulated in the first currency and base every later delta on the second.
983 ++charged_.conflicting_restatements_;
984 return;
985 }
986 if (!decimal_less(held.at_, quantity)) {
987 if (!(held.amount_->amount_ == now->amount_)) {
988 // Two present figures for one filled quantity, and nothing says which is later.
989 ++charged_.conflicting_restatements_;
990 }
991 return;
992 }
993 const auto moved = decimal_subtract(now->amount_, held.amount_->amount_);
994 if (!moved) {
995 // The delta between two figures the broker stated does not fit -- a held five quintillion
996 // restated at a tenth of a won reaches it. Counted for the same reason a failed sum is: the
997 // figure is not contributed, and a reader has to be able to see that rather than read the
998 // total as complete.
999 ++charged_.unrepresentable_sums_;
1000 return;
1001 }
1002 if (charged_.add(totals, Money{*moved, now->currency_})) {
1003 held.amount_ = now;
1004 held.at_ = quantity;
1005 }
1006 }
1007
1008 // Refreshes working orders, keeping positions. Compiles to nothing without the capability.
1009 template <typename Runner>
1010 runner::PollOutcome poll_working_orders(Runner& runner_instance, bool publish = true) {
1011 if constexpr (Caps::has_working_orders) {
1012 auto result = session_->working_orders();
1013 if (!result) {
1014 last_error_ = result.error();
1015 return poll_outcome_of(*last_error_);
1016 }
1017 last_error_.reset();
1018
1019 auto portfolio = runner_instance.context().portfolio();
1020 portfolio.working_ = result.value().orders_;
1021 // Stamped with the listing, so a plan sized before the next holdings read can tell the account
1022 // is older than the orders it is reconciled with; see `Portfolio::listing_newer_than_holdings`.
1023 portfolio.working_as_of_ = result.value().received_at_;
1024 runner_instance.context().set_portfolio(std::move(portfolio));
1025
1026 // Swept from the rows just fetched, so reconciliation costs no request of its own. A
1027 // `WorkingOrder` carries the normalized snapshot the matcher reads; nothing broker-specific
1028 // reaches it.
1029 std::vector<OrderSnapshot> rows;
1030 rows.reserve(result.value().orders_.size());
1031 for (const auto& working : result.value().orders_) {
1032 rows.push_back(working.order_);
1033 }
1034 // **Observed here because this is where the rows exist.** `Source::poll` hands the loop a
1035 // `PollOutcome` and nothing else, and these rows are swept into reconciliation and dropped --
1036 // so a cost accumulated in the loop could only ever be accumulated from nothing.
1037 observe_charged(rows);
1038 reconcile(runner_instance, rows);
1039
1040 // **Last, and after reconciliation, deliberately.** `Runner::feed` settles, so an order can
1041 // leave the process from inside this call -- and a strategy answering an update must do so
1042 // against the portfolio this poll installed, with reconciliation already done and any local
1043 // failure it raised already standing as a hold. Feeding earlier would let a strategy act on a
1044 // poll whose own conclusions were still being drawn.
1045 //
1046 // `publish` is false only for the startup read; see `read_order_list` for why that is a
1047 // hazard rather than a preference.
1048 //
1049 // **No halt check between updates, which the replay does have, and the asymmetry is the
1050 // point.** A hold here refuses what a strategy *sends* and does not make what the broker
1051 // *said* untrue -- a runner that has stopped trading still wants its handler told that a
1052 // resting order filled. A halted replay is the opposite case: the engine can no longer model
1053 // what it is being asked to, so no callback may run at all.
1054 if (publish) {
1055 for (const auto& update : updates_.changed(result.value().orders_)) {
1056 runner_instance.feed(update);
1057 }
1058 } else {
1059 updates_.adopt(result.value().orders_);
1060 }
1061 // **After** the updates, so a strategy's own `on_order_update` has spoken first. This listing is
1062 // the only thing that sees a stop leave, and a disappearance feeds no callback -- so where a
1063 // deferred target's stops are all gone, the broker's own record of them is read here, on demand,
1064 // and decides it. A failed read leaves the target waiting and reports like any poll failure. See
1065 // `Runner::deferred_targets`.
1066 //
1067 // The same read serves an order this runner sent that only the history can settle now (one a
1068 // listing carried and no longer does, one it withdrew, one restored that no listing carries, one
1069 // past the confirmation timeout, one a sweep named; see `Runner::unconfirmed_awaiting_evidence`). Settled first, so a released target is planned against
1070 // what the history said became of it.
1071 const auto deferred_since = runner_instance.deferred_awaiting_evidence();
1072 const auto unconfirmed_since = runner_instance.unconfirmed_awaiting_evidence();
1073 if (deferred_since || unconfirmed_since) {
1074 const auto since = !deferred_since ? *unconfirmed_since :
1075 !unconfirmed_since ? *deferred_since :
1076 std::min(*deferred_since, *unconfirmed_since);
1077 std::vector<OrderSnapshot> evidence;
1078 // Read through the later of the decision instant and the loop's clock, and never before
1079 // `since`: a sent order's `since` is the loop's clock, and on daily bars the decision instant
1080 // is the last session's end -- after a weekend that ended the range before the day the order
1081 // was sent, and the order could never be found.
1082 const auto until = std::max({runner_instance.context().now(), runner_instance.observed_at(), since});
1083 if (!read_evidence(since, until, evidence)) {
1084 return poll_outcome_of(*last_error_);
1085 }
1086 if (unconfirmed_since) {
1087 runner_instance.confirm_from_history(evidence);
1088 }
1089 if (deferred_since) {
1090 runner_instance.retry_deferred(evidence);
1091 }
1092 }
1094 } else {
1095 (void)publish;
1096 // Unreachable through the loop, which never schedules a kind the caller left unset, and a
1097 // caller who sets a cadence for an inquiry this broker lacks gets a poll that does nothing
1098 // rather than a compile error -- the cadence is data, not a capability claim.
1099 (void)runner_instance;
1101 }
1102 }
1103
1104 // Resolves what `rows` settle, leaving withheld whatever they do not.
1105 //
1106 // Does nothing at all without a journal, which is what keeps the pre-existing behaviour exactly
1107 // as it was: a source with no journal reads the list, lifts the gate, and matches nothing.
1108 //
1109 // `now` comes from the runner's own context rather than from the page's `received_at_`. The two
1110 // are different clocks -- the context's is local, like the journal's stamps, and the page's is the
1111 // broker's -- and the matcher already widens for that difference in one place. Measuring the
1112 // window against the broker's clock as well would apply the allowance twice.
1113 template <typename Runner>
1114 void reconcile(Runner& runner_instance, const std::vector<OrderSnapshot>& rows) {
1115 if (journal_ == nullptr) {
1116 return;
1117 }
1118 const auto replay = journal_->replay();
1119 const auto open = runner::unresolved_instructions(replay);
1120 if (open.empty()) {
1121 reconcile_ = runner::ReconcileReport{};
1122 } else if (!sweep(runner_instance, replay, open, rows)) {
1123 return;
1124 }
1125 search_again(runner_instance, replay, open, rows);
1126 }
1127
1128 // The instant a sweep measures the matching window against.
1129 //
1130 // The later of the decision instant and the loop's last reading of the wall clock. The decision
1131 // instant alone moves only with bars, quotes and phase changes: on daily bars, or with a stop
1132 // pending, an order that is plainly absent from every listing stayed "not enough time has passed"
1133 // until the next bar, and its instrument stayed withheld. Both have to be wall time on the journal's
1134 // clock: the decision instant comes from a bar's or quote's own time or from the caller, the other
1135 // from whoever drives the runner. One that runs ahead of the journal, or a wall clock set forward,
1136 // shortens the window; nothing here makes the sweep safe against a clock that is wrong.
1137 //
1138 // Before anything was fed and without a loop both are the epoch, as at the startup read after a
1139 // restart. A resolution stamped there reads back as damage and halts the next start, so the wall
1140 // clock stands in.
1141 template <typename Runner>
1142 static std::chrono::system_clock::time_point sweep_instant(const Runner& runner_instance) {
1143 const auto now = std::max(runner_instance.context().now(), runner_instance.observed_at());
1144 return now == std::chrono::system_clock::time_point{} ? std::chrono::system_clock::now() : now;
1145 }
1146
1147 // The journal's own sweep: decides what `rows` settle of `open`. Returns `false` where nothing
1148 // further may be concluded from this read (the history did not arrive, or a resolution could not be
1149 // written).
1150 template <typename Runner>
1151 bool sweep(Runner& runner_instance, const runner::JournalReplay& replay,
1152 const std::vector<runner::OpenInstruction>& open, const std::vector<OrderSnapshot>& rows) {
1153 auto candidates = rows;
1154 if (runner::any_placement(open)) {
1155 // **An open-orders listing cannot answer the placement question.** Each broker's working-order
1156 // inquiry returns only what is still resting -- Toss maps it to its `OPEN` scope, KIS to the
1157 // modifiable set, Kiwoom to its unfilled-order list -- and a market or aggressive limit order can fill and leave that listing
1158 // before the first sweep. Matching against it alone would then find nothing, and after the
1159 // window closed would report `NOT_PLACED` and clear the ledger for an order that existed and
1160 // executed. So a sweep with any placement owed reads the history as well.
1161 //
1162 // Paid for only when something is actually owed, which is rare: the normal sweep still costs
1163 // no request of its own.
1164 std::vector<OrderSnapshot> history;
1165 if (!read_history(open, history)) {
1166 // Seeing part of the picture is worse than seeing none. `NOT_PLACED` is a conclusion drawn
1167 // from absence, and absence from a listing that failed to arrive is not evidence.
1168 reconcile_ = runner::decide_nothing(
1169 open, "the order history could not be read, so nothing could be concluded from absence"
1170 );
1171 return false;
1172 }
1173 if constexpr (Caps::history_requires_range) {
1174 runner::merge_rows(candidates, history);
1175 } else {
1176 // A history that takes no range (Kiwoom reads one date at a time, Toss a cursor); see
1177 // `runner::merge_dated_history`.
1178 runner::merge_dated_history(candidates, history);
1179 }
1180 }
1181
1182 reconcile_ = runner::reconcile_against(
1183 open,
1184 candidates,
1185 replay,
1186 sweep_instant(runner_instance),
1187 runner_instance.ledger(),
1188 *journal_,
1189 window_,
1190 // The line says what was found; this says what was read, and what the runner then does about it.
1191 runner_instance.serialized() ?
1192 std::string_view(
1193 " (the order list and the order history of its send); that does not establish that no order was "
1194 "placed, and a serialized runner keeps the order owed until dated evidence accounts for it or an "
1195 "operator releases it (Runner::release_obligations)"
1196 ) :
1197 std::string_view(
1198 " (the order list and the order history of its send); that does not establish that no order was "
1199 "placed"
1200 )
1201 );
1202 if (reconcile_.unrecorded_) {
1203 // The sweep decided, and the decision could not be written down. Trading on a resolution that
1204 // exists only in memory is how a restart re-opens a question that was already answered -- and
1205 // a journal that cannot be written will not manage the next order either.
1206 runner_instance.halt_locally(
1207 "the order journal could not record a reconciliation, so the hold stands"
1208 );
1209 return false;
1210 }
1211 // **A placement whose reply was lost, now answered**: the runner has counted it since the
1212 // send, and keeps counting a matched one until holdings show it -- the ledger, just cleared, no
1213 // longer withholds the instrument, and a filled market order is in no listing.
1214 for (const auto& match : reconcile_.matches_) {
1215 if (match.verdict_ != runner::MatchVerdict::MATCHED && match.verdict_ != runner::MatchVerdict::NOT_PLACED) {
1216 continue;
1217 }
1218 for (const auto& instruction : open) {
1219 if (instruction.entry_.id_ != match.entry_id_ || instruction.entry_.action_ != runner::JournalAction::PLACE) {
1220 continue;
1221 }
1222 runner_instance.resolve_indeterminate_placement(
1223 instruction.entry_.instrument_, instruction.entry_.side_,
1224 match.verdict_ == runner::MatchVerdict::MATCHED ? std::optional<std::string>(match.order_id_) :
1225 std::nullopt,
1226 instruction.entry_.id_
1227 );
1228 break;
1229 }
1230 }
1231 return true;
1232 }
1233
1234 // Looks again for the placements a serialized runner still owes without a name after a sweep closed
1235 // the journal's question about them (`Runner::serialized`): a row can reach the broker's lists after
1236 // that sweep, and nothing else would ever name the order.
1237 //
1238 // Positive evidence only. A row must be dated inside the send's window to count, since this runs for
1239 // as long as the order is owed and an undated row of the same shape may be another day's order; and a
1240 // row that could be another placement's as well (an order of the same shape sent just before or after,
1241 // whatever number the journal has for it) is nobody's here. Anything but one row left changes nothing
1242 // and writes nothing: no order is forgotten here.
1243 template <typename Runner>
1244 void search_again(Runner& runner_instance, const runner::JournalReplay& replay,
1245 const std::vector<runner::OpenInstruction>& open, const std::vector<OrderSnapshot>& rows) {
1246 if (!runner_instance.serialized()) {
1247 return;
1248 }
1249 std::vector<std::string> ids;
1250 for (const auto& owed : runner_instance.obligations()) {
1251 const bool still_open = std::any_of(open.begin(), open.end(), [&](const runner::OpenInstruction& instruction) {
1252 return instruction.entry_.id_ == owed.journal_id_;
1253 });
1254 if (owed.state_ == runner::ObligationState::UNNAMED && !owed.journal_id_.empty() && !still_open) {
1255 ids.push_back(owed.journal_id_);
1256 }
1257 }
1258 const auto late = runner::placements_of(replay, ids);
1259 if (late.empty()) {
1260 return;
1261 }
1262 std::vector<OrderSnapshot> history;
1263 if (!read_history(late, history)) {
1264 return;
1265 }
1266 // Undated rows go before the two lists are merged: a listing row with no date (KIS real dates none)
1267 // would otherwise stand in for the dated history row of the same order, and then be set aside.
1268 const auto dated = [](std::vector<OrderSnapshot> all) {
1269 all.erase(
1270 std::remove_if(all.begin(), all.end(), [](const OrderSnapshot& row) { return !row.ordered_at_; }), all.end()
1271 );
1272 return all;
1273 };
1274 auto candidates = dated(rows);
1275 runner::merge_dated_history(candidates, dated(history));
1276 // A listing row whose day the history did not establish is undated here too.
1277 candidates.erase(
1278 std::remove_if(
1279 candidates.begin(), candidates.end(), [](const OrderSnapshot& row) { return row.ordered_date_assumed_; }
1280 ),
1281 candidates.end()
1282 );
1283 runner::drop_rows_fitting_another_placement(candidates, replay, late, window_);
1284 const auto now = sweep_instant(runner_instance);
1285 for (const auto& instruction : late) {
1286 const auto match = runner::match_against(instruction, candidates, now, window_);
1287 if (match.verdict_ != runner::MatchVerdict::MATCHED) {
1288 continue;
1289 }
1290 // Written before the runner hears of it, as every resolution is: a restart restores the name.
1291 if (!journal_->append(runner::resolution_entry(instruction, match, now))) {
1292 runner_instance.halt_locally("the order journal could not record a reconciliation, so the hold stands");
1293 return;
1294 }
1295 runner_instance.resolve_indeterminate_placement(
1296 instruction.entry_.instrument_, instruction.entry_.side_, std::optional<std::string>(match.order_id_),
1297 instruction.entry_.id_
1298 );
1299 // The ledger: what may have marked the instrument meanwhile is this order's own timeout, which a
1300 // named order does not carry. Lifted unless this sweep left something undecided on the instrument,
1301 // or the runner still owes there an order no sweep named or one held for an operator.
1302 const auto& withheld = reconcile_.withheld_;
1303 if (std::find(withheld.begin(), withheld.end(), match.instrument_) == withheld.end() &&
1304 runner_instance.obligation_state(match.instrument_) == runner::ObligationState::NAMED) {
1305 runner_instance.ledger().resolve_after_reading_orders(match.instrument_);
1306 }
1307 }
1308 }
1309
1310 // Fills `into` with the order history covering everything `open` could have reached the broker in.
1311 //
1312 // Returns whether the whole range was read. A partial read is a failure, not a smaller success,
1313 // because every row this misses is one the matcher will count as absent.
1314 //
1315 // Both broker differences are handled here rather than pushed onto the caller: a calendar range
1316 // where the broker requires one, and cursor paging where the broker offers one. The page cap is a
1317 // guard against a cursor that never terminates, and exhausting it is reported as a failure for the
1318 // same reason -- a truncated listing is a listing with holes in it.
1319 bool read_history(const std::vector<runner::OpenInstruction>& all,
1320 std::vector<OrderSnapshot>& into) {
1321 // **An operator-only instruction sets no bound.** A sweep cannot resolve it, and one salvaged from a
1322 // torn line carries no time, which reads as the epoch: counted, it would ask for every day since 1970.
1323 std::vector<runner::OpenInstruction> open;
1324 for (const auto& instruction : all) {
1325 if (!instruction.entry_.requires_operator_) {
1326 open.push_back(instruction);
1327 }
1328 }
1329 if (open.empty()) {
1330 return true;
1331 }
1332 if constexpr (Caps::history_requires_range) {
1333 return read_history_in(runner::history_range_for(open, window_), into);
1334 } else {
1335 // **One stated date at a time** for a broker whose history takes a single date -- Kiwoom's
1336 // `kt00007` requires it and refuses a range, so an undated sweep failed every time and a journalled
1337 // Kiwoom runner could resolve nothing. The days are the ones `history_range_for` spans: a day
1338 // either side of the instructions' own window, in UTC, which contains every market-local day.
1339 auto earliest = open.front().opened_at_;
1340 auto latest = runner::instruction_sent_by(open.front(), window_);
1341 for (const auto& instruction : open) {
1342 if (instruction.opened_at_ < earliest) {
1343 earliest = instruction.opened_at_;
1344 }
1345 const auto sent_by = runner::instruction_sent_by(instruction, window_);
1346 if (sent_by > latest) {
1347 latest = sent_by;
1348 }
1349 }
1350 return read_history_days(earliest, latest, into);
1351 }
1352 }
1353
1354 // Reads the history one stated date at a time, for every UTC day from the day before `from` to the
1355 // day after `to`: a broker's order date is its own market's, and that span contains it.
1356 bool read_history_days(
1357 std::chrono::system_clock::time_point from,
1358 std::chrono::system_clock::time_point to,
1359 std::vector<OrderSnapshot>& into
1360 ) {
1361 std::vector<Date> days;
1362 const auto add = [&days](const Date& day) {
1363 for (const auto& seen : days) {
1364 if (seen.year_ == day.year_ && seen.month_ == day.month_ && seen.day_ == day.day_) {
1365 return;
1366 }
1367 }
1368 days.push_back(day);
1369 };
1370 const auto until = to + std::chrono::hours(24);
1371 for (auto at = from - std::chrono::hours(24); at < until; at += std::chrono::hours(24)) {
1372 add(runner::date_of(at));
1373 }
1374 add(runner::date_of(until));
1375 // Pages are concatenated, dropping only an **exact** copy -- the same id and instrument with the
1376 // same order time stated on both. Two days' orders can share a number, so the id alone is not
1377 // identity, and a row with no time is never assumed to be another's copy. See
1378 // `runner::merge_dated_history`.
1379 for (const auto& day : days) {
1380 std::vector<OrderSnapshot> page;
1381 if (!read_history_in({day, day}, page, true)) {
1382 return false;
1383 }
1384 for (const auto& row : page) {
1385 bool copy = false;
1386 for (const auto& held : into) {
1387 if (same_history_row(held, row)) {
1388 copy = true;
1389 break;
1390 }
1391 }
1392 if (!copy) {
1393 into.push_back(row);
1394 }
1395 }
1396 }
1397 return true;
1398 }
1399
1400 // Fills `into` with the history a deferred target's stops can appear in: from the day before it was
1401 // deferred to today.
1402 //
1403 // **Two query shapes, because the brokers that hold stops disagree.** KIS takes a range
1404 // (`history_requires_range`), so one query covers it. Kiwoom's `kt00007` takes **one** date, which it
1405 // requires, and refuses a range -- so each day is asked for on its own, from the day before the
1406 // deferral (or the send) to the day after now, both in UTC so the market-local day is always inside:
1407 // three or four requests within a week, six across a weekend on daily bars, repeated on each listing
1408 // poll while an entry waits. A broker taking neither shape is asked each day as well, which states a
1409 // date it may ignore rather than omitting one it may need.
1410 bool read_evidence(
1411 std::chrono::system_clock::time_point since,
1412 std::chrono::system_clock::time_point now,
1413 std::vector<OrderSnapshot>& into
1414 ) {
1415 if constexpr (Caps::history_requires_range) {
1416 return read_history_in(
1417 {runner::date_of(since - std::chrono::hours(24)), runner::date_of(now + std::chrono::hours(24))},
1418 into
1419 );
1420 } else {
1421 // **Through the day after `now` in UTC**, the same upper bound as the ranged query. `date_of` is a
1422 // UTC date and a broker's order date is its own market's: at 08:45 KST it is still the previous
1423 // day in UTC, and stopping at `date_of(now)` never asked for the Korean day the stop was on.
1424 return read_history_days(since, now, into);
1425 }
1426 }
1427
1428 // Fills `into` with the order history over `range` where the broker takes one, or wherever `dated`
1429 // asks for the dates to be stated regardless; see `read_history`.
1430 bool read_history_in(
1431 const std::pair<Date, Date>& range,
1432 std::vector<OrderSnapshot>& into,
1433 bool dated = Caps::history_requires_range
1434 ) {
1435 OrderHistoryQuery query;
1436 if constexpr (Caps::has_history_limit) {
1437 // Gated, because KIS does not ignore a limit it cannot honour -- it refuses the whole inquiry
1438 // with `limit_unsupported`. An ungated assignment here made every KIS placement sweep take the
1439 // history-failure branch, so a KIS runner recovered nothing and withheld forever.
1440 query.limit_ = history_limit_;
1441 }
1442 if (dated) {
1443 query.from_ = range.first;
1444 query.to_ = range.second;
1445 }
1446
1447 for (int page = 0; page < maximum_history_pages_; ++page) {
1448 auto result = session_->order_history(query);
1449 if (!result) {
1450 last_error_ = result.error();
1451 return false;
1452 }
1453 // A row already read is dropped only as an exact copy (`same_history_row`): a range spans days,
1454 // and two days' orders can share a number.
1455 for (const auto& row : result.value().orders_) {
1456 bool copy = false;
1457 for (const auto& held : into) {
1458 if (same_history_row(held, row)) {
1459 copy = true;
1460 break;
1461 }
1462 }
1463 if (!copy) {
1464 into.push_back(row);
1465 }
1466 }
1467 const bool more = result.value().next_cursor_ && !result.value().next_cursor_->empty();
1468 if (!more) {
1469 return true;
1470 }
1471 if constexpr (Caps::has_history_cursor) {
1472 query.cursor_ = result.value().next_cursor_;
1473 } else {
1474 // The page says there is more and this adapter cannot ask for it. Reading what arrived as the
1475 // whole history would conclude an order absent that may be on the next page.
1476 last_error_ = history_not_continued();
1477 return false;
1478 }
1479 }
1480 // Every `false` carries an error about this read: a caller reports `*last_error_`.
1481 last_error_ = history_page_limit(maximum_history_pages_);
1482 return false;
1483 }
1484
1485 // Whether two history rows are the same report of one order: `runner::exact_copy`, and the same status
1486 // and filled quantity. Two reads that disagree about an order are both kept, so whoever reads them
1487 // sees the disagreement rather than whichever came first.
1488 static bool same_history_row(const OrderSnapshot& a, const OrderSnapshot& b) {
1489 return runner::exact_copy(a, b) && a.status_ == b.status_ &&
1490 !decimal_less(a.execution_.filled_quantity_, b.execution_.filled_quantity_) &&
1491 !decimal_less(b.execution_.filled_quantity_, a.execution_.filled_quantity_);
1492 }
1493
1494 Session* session_;
1495 MarketData* market_data_;
1496 std::vector<InstrumentId> universe_;
1497 std::vector<Currency> currencies_;
1498 // What the broker has stated it charged, across every run made against this source.
1499 runner::ChargedCosts charged_;
1500 // The last accepted reading per order id, so a cumulative figure is not added twice.
1501 std::map<std::string, ObservedCharge> charged_seen_;
1502 // The order ids in the previous working-order listing, for the disappearance diagnostic.
1503 std::vector<std::string> charged_listing_;
1504 Country calendar_country_{Country::KR};
1505 Journal* journal_{nullptr};
1506 int maximum_history_pages_{20};
1507 runner::MatchWindow window_;
1508 runner::ReconcileReport reconcile_;
1509 // Turns successive working-order listings into `on_order_update` events.
1510 runner::OrderUpdateWatch<BrokerTag> updates_;
1511 BarInterval interval_{BarInterval::ONE_DAY};
1512 int count_{100};
1513 std::size_t bars_not_yet_closed_{0};
1514 std::size_t bars_fed_as_warmup_{0};
1515 // For a strategy declaring `on_bars`: bars not delivered (a period already delivered, or a second bar
1516 // of one instrument in a period), and the last period end delivered, over the source's life.
1517 std::size_t bars_not_delivered_{0};
1518 std::optional<std::chrono::system_clock::time_point> delivered_through_;
1519 // Set by an exception during a bar poll for `on_bars`; every later bar poll then throws.
1520 std::optional<std::string> interrupted_;
1521 std::map<std::string, std::optional<std::chrono::system_clock::time_point>> held_bar_;
1522 int history_limit_{100};
1523 std::map<std::string, std::optional<std::chrono::system_clock::time_point>> last_bar_;
1524 std::optional<BrokerError> last_error_;
1525};
1526
1527} // namespace live
1528} // namespace algotradelib
An exact signed decimal: an int64 coefficient times 10^-scale.
Definition types.hpp:297
std::size_t bars_not_delivered() const noexcept
Returns how many bars a strategy declaring on_bars was not shown.
SessionSource(Session &session, std::vector< InstrumentId > universe)
Constructs a source that reads everything from one session.
SessionSource(Session &session, MarketData &market_data, std::vector< InstrumentId > universe)
Constructs a source that reads market data from a second session.
typename Session::Caps Caps
The broker's capabilities, from the account session.
const runner::ChargedCosts & charged_costs() const noexcept
Returns the commission and tax the broker stated on the orders this source has seen.
std::size_t bars_fed_as_warmup() const noexcept
Returns how many bars were fed as history (Runner::feed_warmup), so their decisions were discarded.
runner::PollOutcome read_order_list(Runner &runner_instance)
Reads the broker's order list once, which lifts PollingLoop's startup hold.
const runner::ReconcileReport & last_reconcile() const noexcept
Returns what the last check against the journal resolved and left unresolved.
runner::PollOutcome poll(runner::PollKind kind, Runner &runner_instance)
Polls one kind of data and feeds or installs what arrived in the runner.
typename Session::BrokerTag BrokerTag
The broker tag, from the account session.
void set_buying_power_currencies(std::vector< Currency > currencies)
Sets the currencies a buying-power poll asks about (one request per currency).
void set_match_window(runner::MatchWindow window)
Sets the tolerance used to match listed orders to journal entries without a broker reference.
void set_calendar_country(Country country)
Sets the market whose trading calendar the calendar poll asks for (Country::KR by default).
static constexpr bool provides_working_orders
true if an order-list read installs the broker's working orders in the portfolio.
void set_journal(Journal &journal)
Sets the journal each order listing is checked against.
const std::vector< Currency > & buying_power_currencies() const noexcept
Returns the buying-power currencies; with RiskLimits::enforce_buying_power_ on, the loop holds placem...
const std::optional< BrokerError > & last_error() const noexcept
Returns the error recorded by the most recent failing read.
std::size_t bars_not_yet_closed() const noexcept
Returns how many bars were left for a later poll because their period had not ended.
void set_bar_query(BarInterval interval, int count)
Sets the bars each poll requests.
Country calendar_country() const noexcept
Returns the market the calendar poll asks about.
Connects a runner to a broker session for live trading.
Definition bind.hpp:12
BrokerError history_not_continued()
Makes the incomplete-history error for a broker whose adapter cannot read a further page (has_history...
BrokerError incomplete_history(std::string code, std::string message)
Makes the error an order-history read reports when the history is incomplete (a page said there is mo...
BrokerError history_page_limit(int pages)
Makes the incomplete-history error for a history longer than the page limit.
runner::PollOutcome poll_outcome_of(const BrokerError &error) noexcept
Classifies a failed inquiry for a poll.
@ NOT_PLACED
No row corresponds and the window has closed: no order was found.
@ MATCHED
Exactly one row corresponds; the order exists.
void merge_rows(std::vector< OrderSnapshot > &into, const std::vector< OrderSnapshot > &rows)
Adds rows to a listing, skipping any whose order id is already there.
Date date_of(std::chrono::system_clock::time_point at)
Returns the UTC calendar date of an instant.
bool any_placement(const std::vector< OpenInstruction > &open)
Tests whether any instruction is a placement a listing could decide (which needs history,...
void merge_dated_history(std::vector< OrderSnapshot > &into, const std::vector< OrderSnapshot > &rows)
Adds history rows to a working-order listing, dropping established copies (exact_copy) and listing ro...
ReconcileReport decide_nothing(const std::vector< OpenInstruction > &open, std::string detail)
Makes a report that decides nothing, for a sweep that could not see a complete listing.
std::pair< Date, Date > history_range_for(const std::vector< OpenInstruction > &open, const MatchWindow &window)
Returns the date range an order-history inquiry must cover for some instructions.
std::chrono::system_clock::time_point instruction_sent_by(const OpenInstruction &instruction, const MatchWindow &window)
Returns the last instant an instruction could have reached the broker.
PollKind
What a PollingLoop polls for; each has a cadence in PollSchedule.
Definition loop.hpp:30
@ WORKING_ORDERS
Working orders, which reconciliation and cancellation need.
Definition loop.hpp:38
@ BARS
Completed bars.
Definition loop.hpp:32
@ PRICE_LIMITS
Today's price bands, which an aggressive limit price is clamped to; polled second,...
Definition loop.hpp:58
@ HOLDINGS
Holdings, which orders are sized against.
Definition loop.hpp:36
@ QUOTES
Current quotes, for strategies that act between bars.
Definition loop.hpp:34
@ CALENDAR
The trading calendar, from which the loop derives the market phase; polled first, and on by default.
Definition loop.hpp:49
@ BUYING_POWER
Buying power, polled beside holdings (not before each order).
Definition loop.hpp:40
PollOutcome
What became of one poll.
Definition loop.hpp:117
@ RATE_LIMITED
The rate limiter refused the poll; not counted as a failure.
Definition loop.hpp:125
@ UNCHANGED
The poll succeeded and there was nothing new.
Definition loop.hpp:121
@ FAILED
The poll failed; counted toward the failure budget.
Definition loop.hpp:123
@ UPDATED
New data arrived and was fed to the runner, or installed in it (a calendar, price bands).
Definition loop.hpp:119
void drop_rows_fitting_another_placement(std::vector< OrderSnapshot > &rows, const JournalReplay &replay, const std::vector< OpenInstruction > &matching, const MatchWindow &window={})
Removes the rows that could be another placement's order as well.
OrderMatch match_against(const OpenInstruction &instruction, const std::vector< OrderSnapshot > &rows, const std::vector< OpenInstruction > &recorded, std::chrono::system_clock::time_point now, const MatchWindow &window={})
Decides what a broker listing says about one instruction of unknown outcome.
ReconcileReport reconcile_against(const std::vector< OpenInstruction > &open, const std::vector< OrderSnapshot > &rows, const JournalReplay &replay, std::chrono::system_clock::time_point now, IndeterminacyLedger &ledger, Journal &journal, const MatchWindow &window={}, std::string_view unplaced_note={})
Resolves what a broker listing settles, records it in the journal, and clears the ledger for each ins...
JournalEntry resolution_entry(const OpenInstruction &instruction, const OrderMatch &match, std::chrono::system_clock::time_point now)
Makes the RESOLVED journal line that records a sweep's verdict.
std::vector< OpenInstruction > placements_of(const JournalReplay &replay, const std::vector< std::string > &ids)
Returns the placements with the given journal ids, each with the span in which it may have reached th...
Definition journal.hpp:592
std::vector< OpenInstruction > unresolved_instructions(const JournalReplay &replay)
Lists the instructions whose outcome is unknown, in the order they were opened.
Definition journal.hpp:537
bool exact_copy(const OrderSnapshot &a, const OrderSnapshot &b)
Tests whether two rows are established copies of one order: the same order id and instrument,...
@ NAMED
An order the broker named, waiting for its history and holdings to account for it.
@ UNNAMED
An order whose existence is unknown (its reply was lost and no sweep named it).
void bar(S &strategy, Ctx &context, const BarEvent &event)
Calls strategy.on_bar(context, event) if the strategy declares on_bar; otherwise does nothing.
Definition concept.hpp:191
BarEvent make_bar_event(InstrumentId instrument, BarInterval interval, BarStamp stamp, Bar bar)
Makes a bar event, computing its period_end_ with bar_period_end.
Definition event.hpp:273
std::chrono::system_clock::time_point bar_period_end(const Bar &bar, BarInterval interval, BarStamp stamp)
Computes the instant a bar's period ended.
Definition event.hpp:232
The library: broker-neutral types (core), and the modules below.
@ CLOSE
The timestamp is the instant the period ended.
constexpr std::optional< std::chrono::seconds > bar_interval_length(BarInterval interval) noexcept
Returns an interval's fixed length, for an intraday interval.
@ PROTOCOL
The reply could not be read, or the broker declared a failure this client does not classify further: ...
Definition types.hpp:704
constexpr bool error_category_is_rate_limit(ErrorCategory category) noexcept
Tests whether an error of this category is a broker asking the caller to slow down,...
Definition types.hpp:722
Country
A country (market) the library supports.
Definition types.hpp:30
std::optional< Decimal64 > decimal_subtract(const Decimal64 &lhs, const Decimal64 &rhs)
Subtracts two decimals exactly.
Definition types.hpp:530
bool decimal_less(const Decimal64 &lhs, const Decimal64 &rhs)
Compares two decimals numerically, whatever their scales.
Definition types.hpp:665
BarInterval
The period one bar covers.
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
The filter for an order-history inquiry.
Definition types.hpp:2325
std::optional< int > limit_
The most rows to return, where the broker supports a limit.
Definition types.hpp:2335
std::optional< std::string > cursor_
A previous page's next_cursor_, passed back unchanged.
Definition types.hpp:2333
One order's stated charges, as last recorded.
bool counted_cost_
Whether this order was already counted as one that states a cost.
One charge figure of one order, and the filled quantity it was recorded at.
std::optional< Money > amount_
The amount the broker stated, empty until it states one.
Decimal64 at_
The filled quantity amount_ was recorded at.
Commission and tax the broker stated on the orders a loop happened to poll (Toss states them; KIS doe...
Definition loop.hpp:303
The time allowances used when matching journalled instructions against a broker listing.
What one sweep of the broker's order list settled, and what is still withheld.