algotradelib 0.0.0.9000
C++ header-only library for algorithmic trading
Loading...
Searching...
No Matches
client.hpp
1#pragma once
2
3#include "./config.hpp"
4#include "../common.hpp"
5
6#include <boost/asio/any_completion_handler.hpp>
7#include <boost/asio/async_result.hpp>
8#include <boost/asio/associated_executor.hpp>
9#include <boost/asio/dispatch.hpp>
10#include <boost/asio/post.hpp>
11#include <boost/asio/strand.hpp>
12
13#include <algorithm>
14#include <cstdint>
15#include <limits>
16#include <type_traits>
17#include <cstdio>
18#include <deque>
19#include <memory>
20#include <optional>
21#include <stdexcept>
22#include <string>
23#include <utility>
24#include <vector>
25
27
31 std::string account_number_;
33 std::int64_t sequence_{0};
35 std::string account_type_;
36};
37
41enum class TossOrderScope {
43 OPEN,
46};
47
57 std::int64_t account_sequence_{0};
61 std::optional<std::string> symbol_;
63 std::optional<Date> from_;
65 std::optional<Date> to_;
67 std::optional<std::string> cursor_;
69 std::optional<int> limit_;
70};
71
80 std::string order_id_;
84 std::optional<Decimal64> quantity_;
86 std::optional<Decimal64> price_;
89};
90
96inline std::optional<std::chrono::seconds> retry_after(const network::HttpResponse& response) {
97 const auto seconds = broker_detail::integer_header(response, "Retry-After");
98 if (!seconds) {
99 return std::nullopt;
100 }
101 return std::chrono::seconds(*seconds);
102}
103
109inline std::optional<int> reported_rate_limit(const network::HttpResponse& response) {
110 const auto limit = broker_detail::integer_header(response, "X-RateLimit-Limit");
111 if (!limit || *limit < 1 || *limit > std::numeric_limits<int>::max()) {
112 return std::nullopt;
113 }
114 return static_cast<int>(*limit);
115}
116
123inline std::optional<BrokerError> unsupported_bar_interval(BarInterval interval) {
124 if (interval == BarInterval::ONE_MINUTE || interval == BarInterval::ONE_DAY) {
125 return std::nullopt;
126 }
127 BrokerError error;
129 error.reached_broker_ = false;
130 error.broker_code_ = "unsupported_interval";
131 error.message_ =
132 "Toss publishes one-minute and one-day bars only; gate on "
133 "BrokerCaps<Client>::supports_interval before asking for another";
134 return error;
135}
136
146class TossClient : public std::enable_shared_from_this<TossClient> {
147public:
149 using QuoteHandler = net::any_completion_handler<void(Result<QuoteSnapshot>)>;
150
152 using BarsHandler = net::any_completion_handler<void(Result<BarSeries>)>;
153
155 using DepthHandler = net::any_completion_handler<void(Result<DepthSnapshot>)>;
156
158 using PriceLimitsHandler = net::any_completion_handler<void(Result<PriceLimits>)>;
159
161 using TradesHandler = net::any_completion_handler<void(Result<std::vector<TradePrint>>)>;
162
164 using PricesHandler = net::any_completion_handler<void(Result<std::vector<QuoteSnapshot>>)>;
165
167 using CalendarHandler = net::any_completion_handler<void(Result<TradingCalendar>)>;
168
170 using InstrumentHandler = net::any_completion_handler<void(Result<Instrument>)>;
172 using AccountsHandler = net::any_completion_handler<void(Result<std::vector<TossAccount>>)>;
173
175 using HoldingsHandler = net::any_completion_handler<void(Result<HoldingsSnapshot>)>;
177 using BuyingPowerHandler = net::any_completion_handler<void(Result<BuyingPower>)>;
179 using SellableQuantityHandler = net::any_completion_handler<void(Result<Decimal64>)>;
181 using CommissionScheduleHandler = net::any_completion_handler<void(Result<CommissionSchedule>)>;
182
184 using OrderAckHandler = net::any_completion_handler<void(Result<OrderAck>)>;
185
187 using OrderPageHandler = net::any_completion_handler<void(Result<OrderPage>)>;
188
190 using OrderHandler = net::any_completion_handler<void(Result<OrderSnapshot>)>;
191
193 using TokenHandler = net::any_completion_handler<void(std::optional<BrokerError>)>;
194
203 net::any_io_executor executor,
204 ssl::context& context,
205 TossConfig config,
206 network::HttpClientOptions options = {}
207 )
208 : strand_(net::make_strand(std::move(executor))), config_(std::move(config)),
209 http_(
210 std::make_shared<network::AsyncHttpClient>(strand_, context, "openapi.tossinvest.com", "443", options)
211 ) {}
212
224 template <typename CompletionToken>
225 auto async_bars(BarQuery query, CompletionToken&& token) {
226 return net::async_initiate<CompletionToken, void(Result<BarSeries>)>(
227 [self = shared_from_this(), query = std::move(query)](auto&& handler) mutable {
228 auto completion = BarsHandler(std::forward<decltype(handler)>(handler));
229 net::post(
230 self->strand_,
231 [self, query = std::move(query), completion = std::move(completion)]() mutable {
232 self->start_bars(std::move(query), std::move(completion));
233 }
234 );
235 },
236 token
237 );
238 }
239
250 template <typename CompletionToken>
251 auto async_calendar(Country country, std::optional<Date> from, CompletionToken&& token) {
252 return net::async_initiate<CompletionToken, void(Result<TradingCalendar>)>(
253 [self = shared_from_this(), country, from](auto&& handler) mutable {
254 auto completion = CalendarHandler(std::forward<decltype(handler)>(handler));
255 net::post(
256 self->strand_,
257 [self, country, from, completion = std::move(completion)]() mutable {
258 self->start_calendar(country, from, std::move(completion));
259 }
260 );
261 },
262 token
263 );
264 }
265
273 template <typename CompletionToken>
274 auto async_instrument(InstrumentId instrument, CompletionToken&& token) {
275 return net::async_initiate<CompletionToken, void(Result<Instrument>)>(
276 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
277 auto completion = InstrumentHandler(std::forward<decltype(handler)>(handler));
278 net::post(
279 self->strand_,
280 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
281 self->start_market_read(
282 std::move(instrument),
283 "/api/v1/stocks?symbols=",
284 std::move(completion),
285 parse_instrument
286 );
287 }
288 );
289 },
290 token
291 );
292 }
293
301 template <typename CompletionToken>
302 auto async_price_limits(InstrumentId instrument, CompletionToken&& token) {
303 return net::async_initiate<CompletionToken, void(Result<PriceLimits>)>(
304 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
305 auto completion = PriceLimitsHandler(std::forward<decltype(handler)>(handler));
306 net::post(
307 self->strand_,
308 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
309 self->start_market_read(
310 std::move(instrument),
311 "/api/v1/price-limits?symbol=",
312 std::move(completion),
313 parse_price_limits
314 );
315 }
316 );
317 },
318 token
319 );
320 }
321
330 template <typename CompletionToken>
331 auto async_trades(InstrumentId instrument, int count, CompletionToken&& token) {
332 return net::async_initiate<CompletionToken, void(Result<std::vector<TradePrint>>)>(
333 [self = shared_from_this(), instrument = std::move(instrument), count](
334 auto&& handler
335 ) mutable {
336 auto completion = TradesHandler(std::forward<decltype(handler)>(handler));
337 net::post(
338 self->strand_,
339 [self, instrument = std::move(instrument), count, completion = std::move(completion)]()
340 mutable {
341 // Toss caps this at fifty prints; asking for more would be answered with
342 // fifty anyway, so the cap is applied here rather than silently by the
343 // broker.
344 const int wanted = count < 1 ? 1 : (count > trade_limit_ ? trade_limit_ : count);
345 self->start_market_read(
346 std::move(instrument),
347 "/api/v1/trades?count=" + std::to_string(wanted) + "&symbol=",
348 std::move(completion),
349 parse_trades
350 );
351 }
352 );
353 },
354 token
355 );
356 }
357
365 template <typename CompletionToken>
366 auto async_prices(std::vector<InstrumentId> instruments, CompletionToken&& token) {
367 return net::async_initiate<CompletionToken, void(Result<std::vector<QuoteSnapshot>>)>(
368 [self = shared_from_this(), instruments = std::move(instruments)](auto&& handler) mutable {
369 auto completion = PricesHandler(std::forward<decltype(handler)>(handler));
370 net::post(
371 self->strand_,
372 [self, instruments = std::move(instruments), completion = std::move(completion)]()
373 mutable {
374 self->start_prices(std::move(instruments), std::move(completion));
375 }
376 );
377 },
378 token
379 );
380 }
381
389 template <typename CompletionToken>
390 auto async_depth(InstrumentId instrument, CompletionToken&& token) {
391 return net::async_initiate<CompletionToken, void(Result<DepthSnapshot>)>(
392 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
393 auto completion = DepthHandler(std::forward<decltype(handler)>(handler));
394 net::post(
395 self->strand_,
396 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
397 self->start_depth(std::move(instrument), std::move(completion));
398 }
399 );
400 },
401 token
402 );
403 }
404
413 template <typename CompletionToken>
414 auto async_quote(InstrumentId instrument, CompletionToken&& token) {
415 return net::async_initiate<CompletionToken, void(Result<QuoteSnapshot>)>(
416 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
417 auto completion = QuoteHandler(std::forward<decltype(handler)>(handler));
418 net::post(
419 self->strand_,
420 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
421 self->start_quote(std::move(instrument), std::move(completion));
422 }
423 );
424 },
425 token
426 );
427 }
428
435 template <typename CompletionToken>
436 auto async_accounts(CompletionToken&& token) {
437 return net::async_initiate<CompletionToken, void(Result<std::vector<TossAccount>>)>(
438 [self = shared_from_this()](auto&& handler) mutable {
439 auto completion = AccountsHandler(std::forward<decltype(handler)>(handler));
440 net::post(
441 self->strand_,
442 [self, completion = std::move(completion)]() mutable {
443 self->start_accounts(std::move(completion));
444 }
445 );
446 },
447 token
448 );
449 }
450
459 template <typename CompletionToken>
460 auto async_buying_power(Currency currency, std::int64_t account_sequence, CompletionToken&& token) {
461 return net::async_initiate<CompletionToken, void(Result<BuyingPower>)>(
462 [self = shared_from_this(), currency, account_sequence](auto&& handler) mutable {
463 auto completion = BuyingPowerHandler(std::forward<decltype(handler)>(handler));
464 net::post(
465 self->strand_,
466 [self, currency, account_sequence, completion = std::move(completion)]() mutable {
467 self->start_buying_power(currency, account_sequence, std::move(completion));
468 }
469 );
470 },
471 token
472 );
473 }
474
483 template <typename CompletionToken>
485 InstrumentId instrument,
486 std::int64_t account_sequence,
487 CompletionToken&& token
488 ) {
489 return net::async_initiate<CompletionToken, void(Result<Decimal64>)>(
490 [self = shared_from_this(), instrument = std::move(instrument), account_sequence](
491 auto&& handler
492 ) mutable {
493 auto completion = SellableQuantityHandler(std::forward<decltype(handler)>(handler));
494 net::post(
495 self->strand_,
496 [self, instrument = std::move(instrument), account_sequence,
497 completion = std::move(completion)]() mutable {
498 self->start_sellable_quantity(
499 std::move(instrument), account_sequence, std::move(completion)
500 );
501 }
502 );
503 },
504 token
505 );
506 }
507
515 template <typename CompletionToken>
516 auto async_commissions(std::int64_t account_sequence, CompletionToken&& token) {
517 return net::async_initiate<CompletionToken, void(Result<CommissionSchedule>)>(
518 [self = shared_from_this(), account_sequence](auto&& handler) mutable {
519 auto completion = CommissionScheduleHandler(std::forward<decltype(handler)>(handler));
520 net::post(
521 self->strand_,
522 [self, account_sequence, completion = std::move(completion)]() mutable {
523 self->start_commissions(account_sequence, std::move(completion));
524 }
525 );
526 },
527 token
528 );
529 }
530
538 template <typename CompletionToken>
539 auto async_holdings(std::int64_t account_sequence, CompletionToken&& token) {
540 return net::async_initiate<CompletionToken, void(Result<HoldingsSnapshot>)>(
541 [self = shared_from_this(), account_sequence](auto&& handler) mutable {
542 auto completion = HoldingsHandler(std::forward<decltype(handler)>(handler));
543 net::post(
544 self->strand_,
545 [self, account_sequence, completion = std::move(completion)]() mutable {
546 self->start_holdings(account_sequence, std::move(completion));
547 }
548 );
549 },
550 token
551 );
552 }
553
577 template <typename CompletionToken>
579 std::int64_t account_sequence,
580 OrderRequest order,
581 CompletionToken&& token
582 ) {
583 return net::async_initiate<CompletionToken, void(Result<OrderAck>)>(
584 [self = shared_from_this(), account_sequence, order = std::move(order)](
585 auto&& handler
586 ) mutable {
587 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
588 net::post(
589 self->strand_,
590 [
591 self,
592 account_sequence,
593 order = std::move(order),
594 completion = std::move(completion)
595 ]() mutable {
596 self->start_place_order(account_sequence, std::move(order), std::move(completion));
597 }
598 );
599 },
600 token
601 );
602 }
603
620 template <typename CompletionToken>
622 std::int64_t account_sequence,
623 TossAmendment amendment,
624 CompletionToken&& token
625 ) {
626 return net::async_initiate<CompletionToken, void(Result<OrderAck>)>(
627 [self = shared_from_this(), account_sequence, amendment = std::move(amendment)](
628 auto&& handler
629 ) mutable {
630 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
631 net::post(
632 self->strand_,
633 [
634 self,
635 account_sequence,
636 amendment = std::move(amendment),
637 completion = std::move(completion)
638 ]() mutable {
639 self->start_replace_order(
640 account_sequence, std::move(amendment), std::move(completion)
641 );
642 }
643 );
644 },
645 token
646 );
647 }
648
663 template <typename CompletionToken>
665 std::int64_t account_sequence,
666 std::string order_id,
667 CompletionToken&& token
668 ) {
669 return net::async_initiate<CompletionToken, void(Result<OrderAck>)>(
670 [self = shared_from_this(), account_sequence, order_id = std::move(order_id)](
671 auto&& handler
672 ) mutable {
673 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
674 net::post(
675 self->strand_,
676 [
677 self,
678 account_sequence,
679 order_id = std::move(order_id),
680 completion = std::move(completion)
681 ]() mutable {
682 self->start_cancel_order(account_sequence, std::move(order_id), std::move(completion));
683 }
684 );
685 },
686 token
687 );
688 }
689
705 template <typename CompletionToken>
706 auto async_orders(TossOrderQuery query, CompletionToken&& token) {
707 return net::async_initiate<CompletionToken, void(Result<OrderPage>)>(
708 [self = shared_from_this(), query = std::move(query)](auto&& handler) mutable {
709 auto completion = OrderPageHandler(std::forward<decltype(handler)>(handler));
710 net::post(
711 self->strand_,
712 [self, query = std::move(query), completion = std::move(completion)]() mutable {
713 self->start_orders(std::move(query), std::move(completion));
714 }
715 );
716 },
717 token
718 );
719 }
720
734 template <typename CompletionToken>
735 auto async_order(std::int64_t account_sequence, std::string order_id, CompletionToken&& token) {
736 return net::async_initiate<CompletionToken, void(Result<OrderSnapshot>)>(
737 [self = shared_from_this(), account_sequence, order_id = std::move(order_id)](
738 auto&& handler
739 ) mutable {
740 auto completion = OrderHandler(std::forward<decltype(handler)>(handler));
741 net::post(
742 self->strand_,
743 [
744 self,
745 account_sequence,
746 order_id = std::move(order_id),
747 completion = std::move(completion)
748 ]() mutable {
749 self->start_order(account_sequence, std::move(order_id), std::move(completion));
750 }
751 );
752 },
753 token
754 );
755 }
756
766 const auto* limiter = http_->rate_limits();
767 return limiter ? &limiter->stats() : nullptr;
768 }
769
771 void close() { http_->close(); }
772
773private:
774 friend struct TossClientTestAccess;
775
776 net::strand<net::any_io_executor> strand_;
777 TossConfig config_;
778 std::shared_ptr<network::AsyncHttpClient> http_;
779 TossRest token_;
780 bool refreshing_{false};
781 std::deque<TokenHandler> token_waiters_;
782
783 // One bar request spanning however many pages Toss needs to answer it.
784 //
785 // `descending_` accumulates pages exactly as Toss sends them — newest first,
786 // each page older than the last — and is reversed once when the operation
787 // finishes. Reversing per page, or inserting at the front, would do the same
788 // work repeatedly for no benefit.
789 struct BarOperation {
790 BarQuery query_;
791 Currency currency_;
792 std::vector<Bar> descending_;
793 std::optional<std::string> before_;
794 std::optional<std::string> last_cursor_;
795 int pages_{0};
796 BarsHandler completion_;
797
798 BarOperation(BarQuery query, BarsHandler completion)
799 : query_(std::move(query)), completion_(std::move(completion)) {
800 if (query_.before_) {
801 before_ = format_iso8601_utc(*query_.before_);
802 }
803 }
804 };
805
806 // Largest number of bars Toss returns in one page.
807 static constexpr int bar_page_limit_ = 200;
808
809 // Most pages one bar request may consume.
810 //
811 // A bound is needed because a broker that kept returning a cursor would
812 // otherwise page forever. Fifty pages is 10,000 bars, far beyond any window a
813 // caller asks for in one call, so reaching it means something is wrong rather
814 // than that the caller was unlucky.
815 static constexpr int bar_page_budget_ = 50;
816
817 // Validates a bar request and starts paging.
818 void start_bars(BarQuery query, BarsHandler completion) {
819 if (auto invalid = validate_bar_query(query)) {
820 deliver(std::move(completion), Result<BarSeries>(std::move(*invalid)));
821 return;
822 }
823
824 auto operation = std::make_shared<BarOperation>(std::move(query), std::move(completion));
825 ensure_token(
827 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
828 if (token_error) {
829 self->deliver(
830 std::move(operation->completion_), Result<BarSeries>(std::move(*token_error))
831 );
832 return;
833 }
834 self->request_bars(operation);
835 }
836 )
837 );
838 }
839
840 // Returns an error when Toss cannot answer the request as described.
841 static std::optional<BrokerError> validate_bar_query(const BarQuery& query) {
842 BrokerError error;
843 error.category_ = ErrorCategory::INVALID_ARGUMENT;
844 error.reached_broker_ = false;
845 if (!broker_detail::valid_symbol(query.instrument_.symbol_)) {
846 error.broker_code_ = "invalid_instrument";
847 error.message_ = "bar request requires a symbol";
848 return error;
849 }
850 if (query.count_ < 1) {
851 error.broker_code_ = "invalid_count";
852 error.message_ = "bar request must ask for at least one bar";
853 return error;
854 }
855 return unsupported_bar_interval(query.interval_);
856 }
857
858 // Returns Toss's code for a supported interval.
859 static const char* bar_interval_code(BarInterval interval) {
860 return interval == BarInterval::ONE_MINUTE ? "1m" : "1d";
861 }
862
863 // Sends the next page of a bar request.
864 void request_bars(const std::shared_ptr<BarOperation>& operation) {
865 const int outstanding = operation->query_.count_ -
866 static_cast<int>(operation->descending_.size());
867 const int wanted = outstanding > bar_page_limit_ ? bar_page_limit_ : outstanding;
868
869 std::string target = "/api/v1/candles?symbol=" +
870 broker_detail::query_encode(operation->query_.instrument_.symbol_) +
871 "&interval=" + bar_interval_code(operation->query_.interval_) +
872 "&count=" + std::to_string(wanted) +
873 "&adjusted=" + (operation->query_.adjusted_ ? "true" : "false");
874 if (operation->before_) {
875 target += "&before=" + broker_detail::query_encode(*operation->before_);
876 }
877
878 network::HttpRequest request;
879 // Bars are the traffic a trading reserve exists to hold back.
881 request.method_ = http::verb::get;
882 request.target_ = std::move(target);
883 request.headers_.emplace_back("Authorization", "Bearer " + token_.access_token_);
884
885 ++operation->pages_;
886 send(
887 std::move(request),
888 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
889 self->handle_bars_page(operation, std::move(result));
890 }
891 );
892 }
893
894 // Accumulates one page and either continues or finishes the request.
895 void handle_bars_page(
896 const std::shared_ptr<BarOperation>& operation,
897 Result<network::HttpResponse> result
898 ) {
899 std::optional<std::string> cursor;
900 auto appended = parse_bars_page(
901 std::move(result), operation->query_, operation->descending_, cursor, operation->currency_
902 );
903 if (appended) {
904 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(*appended)));
905 return;
906 }
907
908 const int collected = static_cast<int>(operation->descending_.size());
909 const bool satisfied = collected >= operation->query_.count_;
910 const bool exhausted = !cursor || cursor->empty();
911 const bool repeated = cursor && operation->last_cursor_ && *cursor == *operation->last_cursor_;
912 const bool spent = operation->pages_ >= bar_page_budget_;
913 if (satisfied || exhausted || repeated || spent) {
914 finish_bars(operation, cursor);
915 return;
916 }
917
918 operation->last_cursor_ = cursor;
919 operation->before_ = cursor;
920 request_bars(operation);
921 }
922
923 // Parses one page into the operation, or returns the error that ends it.
924 //
925 // A page that parses but contains no bar also ends the request: a broker that
926 // returns an empty page while still reporting a cursor would otherwise be paged
927 // forever.
928 //
929 // Takes the accumulation and cursor by reference rather than an operation, so the
930 // normalization is a pure function of a response and can be tested as one.
931 static std::optional<BrokerError> parse_bars_page(
932 Result<network::HttpResponse> result,
933 const BarQuery& query,
934 std::vector<Bar>& into,
935 std::optional<std::string>& cursor,
936 Currency& currency
937 ) {
938 if (!result) {
939 return std::move(result.error());
940 }
941 BrokerError parse_error;
942 auto document = broker_detail::parse_object(result.value(), parse_error);
943 if (!document) {
944 return parse_error;
945 }
946 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
947 return toss_error(result.value(), *document);
948 }
949
950 try {
951 const auto& payload = required_object(*document, "result");
952 const auto rows = payload.if_contains("candles");
953 if (!rows || !rows->is_array()) {
954 throw std::runtime_error("missing array field: candles");
955 }
956 const auto before = into.size();
957 for (const auto& value : rows->as_array()) {
958 if (!value.is_object()) {
959 throw std::runtime_error("candle entry is not an object");
960 }
961 const auto& row = value.as_object();
962 // The currency belongs to the series, so it is read once and then checked:
963 // a page that changed currency mid-series would not be one series.
964 const auto stated = Currency(broker_detail::json_string(row, "currency"));
965 if (into.empty() && before == 0) {
966 currency = stated;
967 } else if (!(stated == currency)) {
968 throw std::runtime_error("candle currency changed within one series");
969 }
970 into.push_back(parse_bar(row, query));
971 }
972 if (into.size() == before) {
973 cursor.reset();
974 return std::nullopt;
975 }
976 if (const auto next = payload.if_contains("nextBefore"); next && next->is_string()) {
977 cursor = std::string(next->as_string());
978 } else {
979 cursor.reset();
980 }
981 return std::nullopt;
982 } catch (const std::exception& exception) {
983 BrokerError error;
984 error.category_ = ErrorCategory::PROTOCOL;
985 error.broker_code_ = "invalid_candle_response";
986 error.message_ = exception.what();
987 return error;
988 }
989 }
990
991 // Normalizes one Toss candle.
992 //
993 // Toss stamps a one-minute bar at the instant it closed, aggregating
994 // `[t - 1m, t)`, and a one-day bar at local midnight, which is its open. The
995 // convention travels in the bar, because reading one as the other is a lookahead
996 // no timestamp can reveal.
997 static Bar parse_bar(const json::object& row, const BarQuery& query) {
998 static_cast<void>(query);
999 Bar bar;
1000 const auto timestamp = parse_iso8601_timestamp(broker_detail::json_string(row, "timestamp"));
1001 if (!timestamp) {
1002 throw std::runtime_error("invalid candle timestamp");
1003 }
1004 bar.timestamp_ = *timestamp;
1005 bar.open_ = Decimal64::parse(broker_detail::json_string(row, "openPrice"));
1006 bar.high_ = Decimal64::parse(broker_detail::json_string(row, "highPrice"));
1007 bar.low_ = Decimal64::parse(broker_detail::json_string(row, "lowPrice"));
1008 bar.close_ = Decimal64::parse(broker_detail::json_string(row, "closePrice"));
1009 bar.volume_ = Decimal64::parse(broker_detail::json_string(row, "volume"));
1010 return bar;
1011 }
1012
1013 // Reverses the accumulation and trims to the requested count.
1014 //
1015 // Paging runs backwards from the newest bar, so an overshoot is always at the
1016 // old end; the newest `count_` bars are what the caller asked for.
1017 void finish_bars(
1018 const std::shared_ptr<BarOperation>& operation,
1019 const std::optional<std::string>& cursor
1020 ) {
1021 BarSeries series;
1022 series.instrument_ = operation->query_.instrument_;
1023 series.interval_ = operation->query_.interval_;
1024 // Toss stamps a one-minute bar at the instant it closed, aggregating
1025 // `[t - 1m, t)`, and a one-day bar at local midnight, which is its open.
1026 series.stamp_ = operation->query_.interval_ == BarInterval::ONE_MINUTE ?
1029 series.currency_ = operation->currency_;
1030 series.adjusted_ = operation->query_.adjusted_;
1031 series.received_at_ = std::chrono::system_clock::now();
1032
1033 auto& bars = operation->descending_;
1034 if (static_cast<int>(bars.size()) > operation->query_.count_) {
1035 bars.resize(static_cast<std::size_t>(operation->query_.count_));
1036 }
1037 series.bars_.assign(bars.rbegin(), bars.rend());
1038 if (cursor && !cursor->empty()) {
1039 series.next_cursor_ = *cursor;
1040 }
1041 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(series)));
1042 }
1043
1044 // Returns the `result` member of a market-data response, or the error ending it.
1045 //
1046 // Toss wraps every market-data answer in `result`, sometimes as an object and
1047 // sometimes as a bare array, so the shape check belongs to each parser while the
1048 // status, envelope, and JSON handling belong here once.
1049 static Result<json::value> market_result(Result<network::HttpResponse> response) {
1050 if (!response) {
1051 return Result<json::value>(std::move(response.error()));
1052 }
1053 BrokerError parse_error;
1054 auto document = broker_detail::parse_object(response.value(), parse_error);
1055 if (!document) {
1056 return Result<json::value>(std::move(parse_error));
1057 }
1058 if (response.value().result_int() < 200 || response.value().result_int() >= 300) {
1059 return Result<json::value>(toss_error(response.value(), *document));
1060 }
1061 const auto payload = document->if_contains("result");
1062 if (!payload) {
1063 BrokerError error;
1064 error.category_ = ErrorCategory::PROTOCOL;
1065 error.broker_code_ = "invalid_market_response";
1066 error.message_ = "response carries no result";
1067 return Result<json::value>(std::move(error));
1068 }
1069 return Result<json::value>(std::move(*payload));
1070 }
1071
1072 // Normalizes Toss's reference data for one instrument.
1073 //
1074 // The identity comes from the instrument the caller asked about rather than from
1075 // the response's symbol, so the country and venue the caller stated survive.
1076 //
1077 // `price_rule_` is the library's own grid, not Toss's: Toss names a tick only when it refuses an
1078 // off-tick price, so the rule is chosen from the market rather than normalized from a field.
1079 static Result<Instrument> parse_instrument(
1080 InstrumentId requested,
1081 Result<network::HttpResponse> result
1082 ) {
1083 auto payload = market_result(std::move(result));
1084 if (!payload) {
1085 return Result<Instrument>(std::move(payload.error()));
1086 }
1087 try {
1088 if (!payload.value().is_array() || payload.value().as_array().empty()) {
1089 throw std::runtime_error("reference data result is not a non-empty array");
1090 }
1091 const auto& row = payload.value().as_array().front().as_object();
1092 if (broker_detail::json_string(row, "symbol") != requested.symbol_) {
1093 throw std::runtime_error("reference data response symbol does not match request");
1094 }
1095
1096 Instrument instrument;
1097 instrument.id_ = std::move(requested);
1098 instrument.name_ = broker_detail::json_string(row, "name");
1099 instrument.english_name_ = broker_detail::json_string(row, "englishName");
1100 instrument.isin_ = broker_detail::json_string(row, "isinCode");
1101 instrument.segment_ = market_segment(broker_detail::json_string(row, "market"));
1102 instrument.type_ = normalized_security_type(broker_detail::json_string(row, "securityType"));
1103 instrument.status_ =
1104 normalized_listing_status(broker_detail::json_string(row, "status"));
1105 instrument.currency_ = Currency(broker_detail::json_string(row, "currency"));
1106 if (const auto value = row.if_contains("isCommonShare"); value && value->is_bool()) {
1107 instrument.common_share_ = value->as_bool();
1108 }
1109 // **A listing date the broker stated and this library cannot read is recorded as unreadable,
1110 // and the row survives.** This used to fail the whole read, because `Instrument` then had
1111 // nowhere to record damage and an empty `delisted_on_` read as "still listed". `StatedDate`
1112 // is that place, and it has no spelling of the old reading, so the refusal would now only
1113 // cost the currency and the price grid of an instrument that is otherwise fully described.
1114 instrument.listed_on_ = stated_calendar_date(row, "listDate");
1115 instrument.delisted_on_ = stated_calendar_date(row, "delistDate");
1116 instrument.shares_outstanding_ = nullable_decimal(row, "sharesOutstanding");
1117 instrument.price_rule_ =
1118 price_rule_for(instrument.id_.country_, instrument.type_);
1119 return Result<Instrument>(std::move(instrument));
1120 } catch (const std::exception& exception) {
1121 return Result<Instrument>(market_error("invalid_reference_response", exception));
1122 }
1123 }
1124
1125 // Maps Toss's security type, tolerating a value this library has not normalized.
1126 static SecurityType normalized_security_type(std::string_view code) {
1127 if (code == "STOCK") {
1128 return SecurityType::STOCK;
1129 }
1130 if (code == "FOREIGN_STOCK") {
1131 return SecurityType::FOREIGN_STOCK;
1132 }
1133 if (code == "DEPOSITARY_RECEIPT") {
1134 return SecurityType::DEPOSITARY_RECEIPT;
1135 }
1136 if (code == "INFRASTRUCTURE_FUND") {
1137 return SecurityType::INFRASTRUCTURE_FUND;
1138 }
1139 if (code == "REIT") {
1140 return SecurityType::REIT;
1141 }
1142 if (code == "ETF") {
1143 return SecurityType::ETF;
1144 }
1145 if (code == "FOREIGN_ETF") {
1146 return SecurityType::FOREIGN_ETF;
1147 }
1148 if (code == "ETN") {
1149 return SecurityType::ETN;
1150 }
1151 if (code == "STOCK_WARRANTS") {
1152 return SecurityType::STOCK_WARRANT;
1153 }
1154 return SecurityType::UNKNOWN;
1155 }
1156
1157 // Maps Toss's listing status, tolerating an unfamiliar value.
1158 //
1159 // Named apart from the order-status mapper on purpose: a listing status and an
1160 // order status are unrelated vocabularies that happen to share a broker field
1161 // name, and overloading on return type alone would not compile anyway.
1162 static InstrumentStatus normalized_listing_status(std::string_view code) {
1163 if (code == "SCHEDULED") {
1164 return InstrumentStatus::SCHEDULED;
1165 }
1166 if (code == "ACTIVE") {
1167 return InstrumentStatus::ACTIVE;
1168 }
1169 if (code == "DELISTED") {
1170 return InstrumentStatus::DELISTED;
1171 }
1172 return InstrumentStatus::UNKNOWN;
1173 }
1174
1175 // Reads a listing or delisting date as one of three answers.
1176 //
1177 // `null` or absent is **not stated** -- Toss documents a `null` `listDate` as 정보 미제공,
1178 // information not provided, and a `null` `delistDate` as the value an active instrument carries --
1179 // a `YYYY-MM-DD` of digits forming a real date is **stated**, and anything else, including a value
1180 // that is not a string, is **unreadable**.
1181 static StatedDate stated_calendar_date(const json::object& row, std::string_view key) {
1182 const auto bound = calendar_bound(row, key);
1183 if (bound.unreadable_) {
1184 return StatedDate::unreadable();
1185 }
1186 return bound.date_ ? StatedDate::stated(*bound.date_) : StatedDate{};
1187 }
1188
1189 // Validates the request and asks for the calendar of one market.
1190 void start_calendar(Country country, std::optional<Date> from, CalendarHandler completion) {
1191 if (from && !is_valid_date(*from)) {
1192 BrokerError error;
1193 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1194 error.reached_broker_ = false;
1195 error.broker_code_ = "invalid_date";
1196 error.message_ = "calendar request requires a valid Gregorian date";
1197 deliver(std::move(completion), Result<TradingCalendar>(std::move(error)));
1198 return;
1199 }
1200
1201 ensure_token(
1203 [self = shared_from_this(), country, from, completion = std::move(completion)](
1204 std::optional<BrokerError> token_error
1205 ) mutable {
1206 if (token_error) {
1207 self->deliver(
1208 std::move(completion), Result<TradingCalendar>(std::move(*token_error))
1209 );
1210 return;
1211 }
1212 network::HttpRequest request;
1213 // The calendar is reference data, the lowest-priority traffic there is.
1214 request.group_ = network::RateLimitGroup::REFERENCE;
1215 request.method_ = http::verb::get;
1216 request.target_ = std::string("/api/v1/market-calendar/") + std::string(market_code(country));
1217 if (from) {
1218 request.target_ += "?date=" + iso_date(*from);
1219 }
1220 request.headers_.emplace_back(
1221 "Authorization", "Bearer " + self->token_.access_token_
1222 );
1223 self->send(
1224 std::move(request),
1225 [self, country, completion = std::move(completion)](
1226 Result<network::HttpResponse> result
1227 ) mutable {
1228 self->deliver(
1229 std::move(completion), parse_calendar(country, std::move(result))
1230 );
1231 }
1232 );
1233 }
1234 )
1235 );
1236 }
1237
1238 // Normalizes Toss's calendar into the three days it reports.
1239 //
1240 // A day is a trading day when Toss reports trading hours for it. The two markets name the hours
1241 // differently: KR has one `integrated` field, null "둘 다 휴장이면", when both KRX and NXT are
1242 // closed; US has four sessions, all null on a holiday, of which `regularMarket` is the exchange's
1243 // own. So the presence of those hours *is* the answer, and the hours themselves are not
1244 // normalized — only Toss publishes them. They are dropped here, not exposed (the library does not
1245 // offer session hours yet; see `BrokerCaps::has_calendar_sessions`).
1246 //
1247 // A `today` without that field at all is refused, not read as closed: reading the KR field on a
1248 // US reply reported every US trading day as closed.
1249 //
1250 // The two days either side are reported as business days, which is why they are
1251 // recorded as open without consulting their hours: Toss has already answered the
1252 // question by calling them that.
1253 static Result<TradingCalendar> parse_calendar(
1254 Country country,
1255 Result<network::HttpResponse> result
1256 ) {
1257 auto payload = market_result(std::move(result));
1258 if (!payload) {
1259 return Result<TradingCalendar>(std::move(payload.error()));
1260 }
1261 try {
1262 if (!payload.value().is_object()) {
1263 throw std::runtime_error("calendar result is not an object");
1264 }
1265 const auto& object = payload.value().as_object();
1266 TradingCalendar calendar;
1267 calendar.country_ = country;
1268 const std::string_view hours = calendar_hours_field(country);
1269 append_calendar_day(object, "previousBusinessDay", hours, true, calendar);
1270 append_calendar_day(object, "today", hours, false, calendar);
1271 append_calendar_day(object, "nextBusinessDay", hours, true, calendar);
1272 std::sort(
1273 calendar.days_.begin(),
1274 calendar.days_.end(),
1275 [](const TradingDay& lhs, const TradingDay& rhs) { return lhs.date_ < rhs.date_; }
1276 );
1277 calendar.received_at_ = std::chrono::system_clock::now();
1278 return Result<TradingCalendar>(std::move(calendar));
1279 } catch (const std::exception& exception) {
1280 return Result<TradingCalendar>(market_error("invalid_calendar_response", exception));
1281 }
1282 }
1283
1284 // Reads one reported day, deciding whether the market opens on it.
1285 static void append_calendar_day(
1286 const json::object& object,
1287 std::string_view key,
1288 std::string_view hours_key,
1289 bool business_day,
1290 TradingCalendar& calendar
1291 ) {
1292 const auto value = object.if_contains(key);
1293 if (!value || !value->is_object()) {
1294 return;
1295 }
1296 const auto& row = value->as_object();
1297 const auto date = parse_calendar_date(broker_detail::json_string(row, "date"));
1298 if (!date) {
1299 // A reported day whose date cannot be read produces no row and is counted, as KIS's are --
1300 // failing the response would lose `today` over a damaged neighbour. A day field that is absent
1301 // or not an object is not a reported day, and is skipped above without a count.
1302 ++calendar.unreadable_days_;
1303 return;
1304 }
1305 TradingDay day;
1306 day.date_ = *date;
1307 if (business_day) {
1308 day.open_ = true;
1309 } else {
1310 const auto hours = row.if_contains(hours_key);
1311 if (!hours) {
1312 throw std::runtime_error("calendar day has no " + std::string(hours_key) + " field");
1313 }
1314 day.open_ = !hours->is_null();
1315 }
1316 calendar.days_.push_back(day);
1317 }
1318
1319 // Parses a `YYYY-MM-DD` calendar date, or nothing when the text is not one.
1320 //
1321 // **Every character of every field must be a digit, and that is not pedantry.** `std::stoi` takes
1322 // a numeric prefix, so a shape check alone let `20x6-01-01` through as the year 20 -- a date that
1323 // is valid, plausible and not the one the broker sent, which is worse than no date at all because
1324 // nothing downstream can see that it is wrong. It also threw on `x026-01-01`, turning one bad
1325 // field into a failure of whatever response carried it. Reading the digits here makes both cases
1326 // the same answer: this text is not a date, said once, where a caller can act on it.
1327 static std::optional<Date> parse_calendar_date(std::string_view text) {
1328 if (text.size() != 10 || text[4] != '-' || text[7] != '-') {
1329 return std::nullopt;
1330 }
1331 const auto digits = [&](std::size_t at, std::size_t count) -> std::optional<int> {
1332 int value = 0;
1333 for (std::size_t index = at; index < at + count; ++index) {
1334 const auto character = text[index];
1335 if (character < '0' || character > '9') {
1336 return std::nullopt;
1337 }
1338 value = value * 10 + (character - '0');
1339 }
1340 return value;
1341 };
1342 const auto year = digits(0, 4);
1343 const auto month = digits(5, 2);
1344 const auto day = digits(8, 2);
1345 if (!year || !month || !day) {
1346 return std::nullopt;
1347 }
1348 Date date;
1349 date.year_ = *year;
1350 date.month_ = static_cast<unsigned>(*month);
1351 date.day_ = static_cast<unsigned>(*day);
1352 return is_valid_date(date) ? std::optional<Date>(date) : std::nullopt;
1353 }
1354
1355 // A date bound as the wire gave it: absent, readable, or stated and unreadable.
1356 struct CalendarBound {
1357 std::optional<Date> date_;
1358 bool unreadable_{false};
1359 };
1360
1361 // Reads an optional date bound, holding "the broker said nothing" apart from "the broker said
1362 // something unreadable".
1363 //
1364 // Absent and JSON `null` are the first: `null` is Toss's own spelling for a rate with no scheduled
1365 // change in that direction, and reading it as damage would refuse every open-ended rate it
1366 // publishes. Anything else that does not yield a date is the second -- including a value that is
1367 // not a string at all, which is a bound stated in a form nothing here can read rather than a bound
1368 // withheld.
1369 static CalendarBound calendar_bound(const json::object& row, std::string_view key) {
1370 CalendarBound bound;
1371 const auto value = row.if_contains(key);
1372 if (!value || value->is_null()) {
1373 return bound;
1374 }
1375 if (!value->is_string()) {
1376 bound.unreadable_ = true;
1377 return bound;
1378 }
1379 bound.date_ = parse_calendar_date(value->as_string().c_str());
1380 bound.unreadable_ = !bound.date_;
1381 return bound;
1382 }
1383
1384 // Most executions Toss returns in one request.
1385 static constexpr int trade_limit_ = 50;
1386
1387 // Most instruments Toss quotes in one request.
1388 static constexpr int price_batch_limit_ = 200;
1389
1390 // Issues one authenticated market-data GET for a single instrument.
1391 //
1392 // The three single-instrument reads differ only in their path and their parser,
1393 // so the token handling, the traffic class, and the validation live here once.
1394 template <typename Handler, typename Parse>
1395 void start_market_read(
1396 InstrumentId instrument,
1397 std::string prefix,
1398 Handler completion,
1399 Parse parse
1400 ) {
1401 using ResultType = decltype(parse(InstrumentId{}, Result<network::HttpResponse>(BrokerError{})));
1402 static_assert(!std::is_void<ResultType>::value, "a market-data parser must return a Result");
1403 if (!broker_detail::valid_symbol(instrument.symbol_)) {
1404 BrokerError error;
1405 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1406 error.reached_broker_ = false;
1407 error.broker_code_ = "invalid_instrument";
1408 error.message_ = "market data request requires a symbol";
1409 deliver(std::move(completion), ResultType(std::move(error)));
1410 return;
1411 }
1412
1413 ensure_token(
1415 [
1416 self = shared_from_this(),
1417 instrument = std::move(instrument),
1418 prefix = std::move(prefix),
1419 completion = std::move(completion),
1420 parse
1421 ](std::optional<BrokerError> token_error) mutable {
1422 if (token_error) {
1423 self->deliver(std::move(completion), ResultType(std::move(*token_error)));
1424 return;
1425 }
1426 network::HttpRequest request;
1427 // Market data is the traffic the trading reserve holds back.
1428 request.group_ = network::RateLimitGroup::MARKET_DATA;
1429 request.method_ = http::verb::get;
1430 request.target_ = prefix + broker_detail::query_encode(instrument.symbol_);
1431 request.headers_.emplace_back(
1432 "Authorization", "Bearer " + self->token_.access_token_
1433 );
1434 self->send(
1435 std::move(request),
1436 [self, instrument = std::move(instrument), completion = std::move(completion), parse](
1437 Result<network::HttpResponse> result
1438 ) mutable {
1439 self->deliver(
1440 std::move(completion), parse(std::move(instrument), std::move(result))
1441 );
1442 }
1443 );
1444 }
1445 )
1446 );
1447 }
1448
1449 // Normalizes Toss's price bounds.
1450 //
1451 // Both bounds are null in a market without a price limit, and the absence is
1452 // preserved: a bound of zero would read as "this instrument may not trade above
1453 // nothing", which is not what an unlimited market means.
1454 static Result<PriceLimits> parse_price_limits(
1455 InstrumentId instrument,
1456 Result<network::HttpResponse> result
1457 ) {
1458 static_cast<void>(instrument);
1459 auto payload = market_result(std::move(result));
1460 if (!payload) {
1461 return Result<PriceLimits>(std::move(payload.error()));
1462 }
1463 try {
1464 if (!payload.value().is_object()) {
1465 throw std::runtime_error("price limit result is not an object");
1466 }
1467 const auto& object = payload.value().as_object();
1468 PriceLimits limits;
1469 limits.currency_ = Currency(broker_detail::json_string(object, "currency"));
1470 limits.upper_ = nullable_decimal(object, "upperLimitPrice");
1471 limits.lower_ = nullable_decimal(object, "lowerLimitPrice");
1472 if (const auto stamp = object.if_contains("timestamp"); stamp && stamp->is_string()) {
1473 limits.exchange_time_ = parse_iso8601_timestamp(stamp->as_string().c_str());
1474 }
1475 return Result<PriceLimits>(std::move(limits));
1476 } catch (const std::exception& exception) {
1477 return Result<PriceLimits>(market_error("invalid_price_limit_response", exception));
1478 }
1479 }
1480
1481 // Normalizes Toss's recent executions.
1482 static Result<std::vector<TradePrint>> parse_trades(
1483 InstrumentId instrument,
1484 Result<network::HttpResponse> result
1485 ) {
1486 static_cast<void>(instrument);
1487 auto payload = market_result(std::move(result));
1488 if (!payload) {
1489 return Result<std::vector<TradePrint>>(std::move(payload.error()));
1490 }
1491 try {
1492 if (!payload.value().is_array()) {
1493 throw std::runtime_error("trade result is not an array");
1494 }
1495 const auto& rows = payload.value().as_array();
1496 std::vector<TradePrint> prints;
1497 prints.reserve(rows.size());
1498 for (const auto& value : rows) {
1499 if (!value.is_object()) {
1500 throw std::runtime_error("trade entry is not an object");
1501 }
1502 const auto& row = value.as_object();
1503 TradePrint print;
1504 print.price_ = Decimal64::parse(broker_detail::json_string(row, "price"));
1505 print.quantity_ = Decimal64::parse(broker_detail::json_string(row, "volume"));
1506 print.currency_ = Currency(broker_detail::json_string(row, "currency"));
1507 print.exchange_time_ =
1508 parse_iso8601_timestamp(broker_detail::json_string(row, "timestamp"));
1509 if (!print.exchange_time_) {
1510 throw std::runtime_error("invalid trade timestamp");
1511 }
1512 prints.push_back(std::move(print));
1513 }
1514 return Result<std::vector<TradePrint>>(std::move(prints));
1515 } catch (const std::exception& exception) {
1516 return Result<std::vector<TradePrint>>(market_error("invalid_trade_response", exception));
1517 }
1518 }
1519
1520 // Validates a batch and requests every instrument's price in one call.
1521 void start_prices(std::vector<InstrumentId> instruments, PricesHandler completion) {
1522 BrokerError error;
1523 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1524 error.reached_broker_ = false;
1525 if (instruments.empty()) {
1526 error.broker_code_ = "empty_batch";
1527 error.message_ = "a batch price request needs at least one instrument";
1528 deliver(std::move(completion), Result<std::vector<QuoteSnapshot>>(std::move(error)));
1529 return;
1530 }
1531 if (static_cast<int>(instruments.size()) > price_batch_limit_) {
1532 error.broker_code_ = "batch_too_large";
1533 error.message_ =
1534 "Toss quotes at most two hundred instruments per request; a longer list is "
1535 "refused rather than split, because splitting would spend several times the "
1536 "request budget a batch call exists to save";
1537 deliver(std::move(completion), Result<std::vector<QuoteSnapshot>>(std::move(error)));
1538 return;
1539 }
1540
1541 std::string symbols;
1542 for (const auto& instrument : instruments) {
1543 if (!broker_detail::valid_symbol(instrument.symbol_)) {
1544 error.broker_code_ = "invalid_instrument";
1545 error.message_ = "batch price request requires a symbol for every instrument";
1546 deliver(std::move(completion), Result<std::vector<QuoteSnapshot>>(std::move(error)));
1547 return;
1548 }
1549 if (!symbols.empty()) {
1550 symbols += ',';
1551 }
1552 symbols += instrument.symbol_;
1553 }
1554
1555 ensure_token(
1557 [
1558 self = shared_from_this(),
1559 instruments = std::move(instruments),
1560 symbols = std::move(symbols),
1561 completion = std::move(completion)
1562 ](std::optional<BrokerError> token_error) mutable {
1563 if (token_error) {
1564 self->deliver(
1565 std::move(completion),
1566 Result<std::vector<QuoteSnapshot>>(std::move(*token_error))
1567 );
1568 return;
1569 }
1570 network::HttpRequest request;
1571 request.group_ = network::RateLimitGroup::MARKET_DATA;
1572 request.method_ = http::verb::get;
1573 request.target_ = "/api/v1/prices?symbols=" + broker_detail::query_encode(symbols);
1574 request.headers_.emplace_back(
1575 "Authorization", "Bearer " + self->token_.access_token_
1576 );
1577 self->send(
1578 std::move(request),
1579 [self, instruments = std::move(instruments), completion = std::move(completion)](
1580 Result<network::HttpResponse> result
1581 ) mutable {
1582 self->deliver(
1583 std::move(completion), parse_prices(instruments, std::move(result))
1584 );
1585 }
1586 );
1587 }
1588 )
1589 );
1590 }
1591
1592 // Normalizes a batch of quotes, keeping the caller's instrument identities.
1593 //
1594 // Toss answers with a symbol per row, so each row is matched back to the
1595 // instrument the caller asked about rather than being rebuilt from the symbol
1596 // alone, which would lose the country and venue the caller stated. A row for a
1597 // symbol that was not asked for is a protocol error.
1598 static Result<std::vector<QuoteSnapshot>> parse_prices(
1599 const std::vector<InstrumentId>& requested,
1600 Result<network::HttpResponse> result
1601 ) {
1602 auto payload = market_result(std::move(result));
1603 if (!payload) {
1604 return Result<std::vector<QuoteSnapshot>>(std::move(payload.error()));
1605 }
1606 try {
1607 if (!payload.value().is_array()) {
1608 throw std::runtime_error("price result is not an array");
1609 }
1610 const auto& rows = payload.value().as_array();
1611 std::vector<QuoteSnapshot> quotes;
1612 quotes.reserve(rows.size());
1613 for (const auto& value : rows) {
1614 if (!value.is_object()) {
1615 throw std::runtime_error("price entry is not an object");
1616 }
1617 const auto& row = value.as_object();
1618 const auto symbol = broker_detail::json_string(row, "symbol");
1619 const auto match = std::find_if(
1620 requested.begin(),
1621 requested.end(),
1622 [&symbol](const InstrumentId& candidate) { return candidate.symbol_ == symbol; }
1623 );
1624 if (match == requested.end()) {
1625 throw std::runtime_error("price response carries an unrequested symbol");
1626 }
1627 QuoteSnapshot quote;
1628 quote.instrument_ = *match;
1629 quote.last_price_ = Decimal64::parse(broker_detail::json_string(row, "lastPrice"));
1630 quote.currency_ = Currency(broker_detail::json_string(row, "currency"));
1631 if (const auto stamp = row.if_contains("timestamp"); stamp && stamp->is_string()) {
1632 quote.exchange_time_ = parse_iso8601_timestamp(stamp->as_string().c_str());
1633 }
1634 quote.received_at_ = std::chrono::system_clock::now();
1635 quotes.push_back(std::move(quote));
1636 }
1637 return Result<std::vector<QuoteSnapshot>>(std::move(quotes));
1638 } catch (const std::exception& exception) {
1639 return Result<std::vector<QuoteSnapshot>>(market_error("invalid_price_response", exception));
1640 }
1641 }
1642
1643 // Returns a protocol error naming the response that could not be read.
1644 static BrokerError market_error(const char* code, const std::exception& cause) {
1645 BrokerError error;
1646 error.category_ = ErrorCategory::PROTOCOL;
1647 error.broker_code_ = code;
1648 error.message_ = cause.what();
1649 return error;
1650 }
1651
1652 // Validates the instrument and requests its book.
1653 void start_depth(InstrumentId instrument, DepthHandler completion) {
1654 if (!broker_detail::valid_symbol(instrument.symbol_)) {
1655 BrokerError error;
1656 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1657 error.reached_broker_ = false;
1658 error.broker_code_ = "invalid_instrument";
1659 error.message_ = "order book request requires a symbol";
1660 deliver(std::move(completion), Result<DepthSnapshot>(std::move(error)));
1661 return;
1662 }
1663
1664 ensure_token(
1666 [
1667 self = shared_from_this(),
1668 instrument = std::move(instrument),
1669 completion = std::move(completion)
1670 ](std::optional<BrokerError> token_error) mutable {
1671 if (token_error) {
1672 self->deliver(
1673 std::move(completion), Result<DepthSnapshot>(std::move(*token_error))
1674 );
1675 return;
1676 }
1677 network::HttpRequest request;
1678 // The book is polling traffic, held back by the trading reserve.
1679 request.group_ = network::RateLimitGroup::MARKET_DATA;
1680 request.method_ = http::verb::get;
1681 request.target_ = "/api/v1/orderbook?symbol=" +
1682 broker_detail::query_encode(instrument.symbol_);
1683 request.headers_.emplace_back(
1684 "Authorization", "Bearer " + self->token_.access_token_
1685 );
1686 self->send(
1687 std::move(request),
1688 [self, instrument = std::move(instrument), completion = std::move(completion)](
1689 Result<network::HttpResponse> result
1690 ) mutable {
1691 self->deliver(
1692 std::move(completion), parse_depth(std::move(instrument), std::move(result))
1693 );
1694 }
1695 );
1696 }
1697 )
1698 );
1699 }
1700
1701 // Normalizes one Toss order book.
1702 //
1703 // The levels are sorted rather than taken in the order they arrive. Toss's
1704 // specification documents `asks` as ascending and its own worked example lists
1705 // them descending, so trusting either would be a guess, and reading the best
1706 // offer off the wrong end of the book makes the spread negative.
1707 static Result<DepthSnapshot> parse_depth(
1708 InstrumentId instrument,
1709 Result<network::HttpResponse> result
1710 ) {
1711 if (!result) {
1712 return Result<DepthSnapshot>(std::move(result.error()));
1713 }
1714 BrokerError parse_error;
1715 auto document = broker_detail::parse_object(result.value(), parse_error);
1716 if (!document) {
1717 return Result<DepthSnapshot>(std::move(parse_error));
1718 }
1719 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
1720 return Result<DepthSnapshot>(toss_error(result.value(), *document));
1721 }
1722 try {
1723 const auto& payload = required_object(*document, "result");
1724 DepthSnapshot book;
1725 book.instrument_ = std::move(instrument);
1726 book.currency_ = Currency(broker_detail::json_string(payload, "currency"));
1727 append_levels(payload, "bids", book.bids_);
1728 append_levels(payload, "asks", book.asks_);
1729 order_depth(book);
1730 if (const auto stamp = payload.if_contains("timestamp"); stamp && stamp->is_string()) {
1731 book.exchange_time_ = parse_iso8601_timestamp(stamp->as_string().c_str());
1732 if (!book.exchange_time_) {
1733 throw std::runtime_error("invalid order book timestamp");
1734 }
1735 }
1736 book.received_at_ = std::chrono::system_clock::now();
1737 return Result<DepthSnapshot>(std::move(book));
1738 } catch (const std::exception& exception) {
1739 BrokerError error;
1740 error.category_ = ErrorCategory::PROTOCOL;
1741 error.broker_code_ = "invalid_orderbook_response";
1742 error.message_ = exception.what();
1743 return Result<DepthSnapshot>(std::move(error));
1744 }
1745 }
1746
1747 // Reads one side of the book into `levels`.
1748 static void append_levels(
1749 const json::object& payload,
1750 std::string_view key,
1751 std::vector<DepthLevel>& levels
1752 ) {
1753 const auto rows = payload.if_contains(key);
1754 if (!rows || !rows->is_array()) {
1755 throw std::runtime_error("missing array field: " + std::string(key));
1756 }
1757 levels.reserve(rows->as_array().size());
1758 for (const auto& value : rows->as_array()) {
1759 if (!value.is_object()) {
1760 throw std::runtime_error("order book entry is not an object");
1761 }
1762 const auto& row = value.as_object();
1763 levels.push_back(
1764 DepthLevel{
1765 Decimal64::parse(broker_detail::json_string(row, "price")),
1766 Decimal64::parse(broker_detail::json_string(row, "volume")),
1767 }
1768 );
1769 }
1770 }
1771
1772 void start_quote(InstrumentId instrument, QuoteHandler completion) {
1773 if (instrument.country_ != Country::KR || !broker_detail::valid_symbol(instrument.symbol_)) {
1774 BrokerError error;
1775 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1776 error.reached_broker_ = false;
1777 error.broker_code_ = "invalid_instrument";
1778 error.message_ = "Toss quote currently supports Korean equity symbols only";
1779 complete(std::move(completion), Result<QuoteSnapshot>(std::move(error)));
1780 return;
1781 }
1782 ensure_token(
1784 [self = shared_from_this(),
1785 instrument = std::move(instrument),
1786 completion = std::move(completion)](std::optional<BrokerError> token_error) mutable {
1787 if (token_error) {
1788 self->complete(
1789 std::move(completion), Result<QuoteSnapshot>(std::move(*token_error))
1790 );
1791 return;
1792 }
1793 self->request_quote(std::move(instrument), std::move(completion));
1794 }
1795 )
1796 );
1797 }
1798
1799 void start_accounts(AccountsHandler completion) {
1800 ensure_token(
1802 [self = shared_from_this(), completion = std::move(completion)](
1803 std::optional<BrokerError> token_error
1804 ) mutable {
1805 if (token_error) {
1806 self->complete_accounts(
1807 std::move(completion),
1808 Result<std::vector<TossAccount>>(std::move(*token_error))
1809 );
1810 return;
1811 }
1812 self->request_accounts(std::move(completion));
1813 }
1814 )
1815 );
1816 }
1817
1818 void start_buying_power(
1819 Currency currency,
1820 std::int64_t account_sequence,
1821 BuyingPowerHandler completion
1822 ) {
1823 auto invalid = validate_account_sequence(account_sequence);
1824 if (!invalid && !priced_currency(currency)) {
1825 // Refused here rather than sent: the endpoint answers for the currencies Toss prices, and a
1826 // request for any other is a condition the caller stated that cannot be honored.
1827 BrokerError error;
1828 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1829 error.reached_broker_ = false;
1830 error.broker_code_ = "unsupported_currency";
1831 error.message_ = "Toss prices buying power in KRW and USD only, not " + currency.str();
1832 invalid = std::move(error);
1833 }
1834 if (invalid) {
1835 complete_buying_power(std::move(completion), Result<BuyingPower>(std::move(*invalid)));
1836 return;
1837 }
1838 ensure_token(
1840 [self = shared_from_this(), currency, account_sequence, completion = std::move(completion)](
1841 std::optional<BrokerError> token_error
1842 ) mutable {
1843 if (token_error) {
1844 self->complete_buying_power(
1845 std::move(completion), Result<BuyingPower>(std::move(*token_error))
1846 );
1847 return;
1848 }
1849 self->request_buying_power(currency, account_sequence, std::move(completion));
1850 }
1851 )
1852 );
1853 }
1854
1855 void request_buying_power(
1856 Currency currency,
1857 std::int64_t account_sequence,
1858 BuyingPowerHandler completion
1859 ) {
1860 network::HttpRequest request;
1861 // The same class as holdings: an account read, above polling and below trading.
1862 request.group_ = network::RateLimitGroup::ACCOUNT;
1863 request.method_ = http::verb::get;
1864 request.target_ = "/api/v1/buying-power?currency=" + currency.str();
1865 request.headers_ = {
1866 {"Authorization", "Bearer " + token_.access_token_},
1867 {"X-Tossinvest-Account", std::to_string(account_sequence)},
1868 };
1869 send(
1870 std::move(request),
1871 [self = shared_from_this(), completion = std::move(completion)](
1872 Result<network::HttpResponse> result
1873 ) mutable {
1874 net::post(
1875 self->strand_,
1876 [self, completion = std::move(completion), result = std::move(result)]() mutable {
1877 self->complete_buying_power(
1878 std::move(completion), parse_buying_power(std::move(result))
1879 );
1880 }
1881 );
1882 }
1883 );
1884 }
1885
1886 void start_sellable_quantity(
1887 InstrumentId instrument,
1888 std::int64_t account_sequence,
1889 SellableQuantityHandler completion
1890 ) {
1891 if (auto invalid = validate_account_sequence(account_sequence)) {
1892 complete_sellable_quantity(std::move(completion), Result<Decimal64>(std::move(*invalid)));
1893 return;
1894 }
1895 if (!broker_detail::valid_symbol(instrument.symbol_)) {
1896 BrokerError error;
1897 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1898 error.reached_broker_ = false;
1899 error.broker_code_ = "invalid_instrument";
1900 error.message_ = "sellable-quantity request requires a symbol";
1901 complete_sellable_quantity(std::move(completion), Result<Decimal64>(std::move(error)));
1902 return;
1903 }
1904 ensure_token(
1906 [self = shared_from_this(), instrument = std::move(instrument), account_sequence,
1907 completion = std::move(completion)](std::optional<BrokerError> token_error) mutable {
1908 if (token_error) {
1909 self->complete_sellable_quantity(
1910 std::move(completion), Result<Decimal64>(std::move(*token_error))
1911 );
1912 return;
1913 }
1914 self->request_sellable_quantity(
1915 std::move(instrument), account_sequence, std::move(completion)
1916 );
1917 }
1918 )
1919 );
1920 }
1921
1922 void request_sellable_quantity(
1923 InstrumentId instrument,
1924 std::int64_t account_sequence,
1925 SellableQuantityHandler completion
1926 ) {
1927 network::HttpRequest request;
1928 request.group_ = network::RateLimitGroup::ACCOUNT;
1929 request.method_ = http::verb::get;
1930 request.target_ =
1931 "/api/v1/sellable-quantity?symbol=" + broker_detail::query_encode(instrument.symbol_);
1932 request.headers_ = {
1933 {"Authorization", "Bearer " + token_.access_token_},
1934 {"X-Tossinvest-Account", std::to_string(account_sequence)},
1935 };
1936 send(
1937 std::move(request),
1938 [self = shared_from_this(), completion = std::move(completion)](
1939 Result<network::HttpResponse> result
1940 ) mutable {
1941 net::post(
1942 self->strand_,
1943 [self, completion = std::move(completion), result = std::move(result)]() mutable {
1944 self->complete_sellable_quantity(
1945 std::move(completion), parse_sellable_quantity(std::move(result))
1946 );
1947 }
1948 );
1949 }
1950 );
1951 }
1952
1953 void start_commissions(std::int64_t account_sequence, CommissionScheduleHandler completion) {
1954 if (auto invalid = validate_account_sequence(account_sequence)) {
1955 complete_commissions(
1956 std::move(completion), Result<CommissionSchedule>(std::move(*invalid))
1957 );
1958 return;
1959 }
1960 ensure_token(
1962 [self = shared_from_this(), account_sequence, completion = std::move(completion)](
1963 std::optional<BrokerError> token_error
1964 ) mutable {
1965 if (token_error) {
1966 self->complete_commissions(
1967 std::move(completion), Result<CommissionSchedule>(std::move(*token_error))
1968 );
1969 return;
1970 }
1971 self->request_commissions(account_sequence, std::move(completion));
1972 }
1973 )
1974 );
1975 }
1976
1977 void request_commissions(std::int64_t account_sequence, CommissionScheduleHandler completion) {
1978 network::HttpRequest request;
1979 request.group_ = network::RateLimitGroup::ACCOUNT;
1980 request.method_ = http::verb::get;
1981 request.target_ = "/api/v1/commissions";
1982 request.headers_ = {
1983 {"Authorization", "Bearer " + token_.access_token_},
1984 {"X-Tossinvest-Account", std::to_string(account_sequence)},
1985 };
1986 send(
1987 std::move(request),
1988 [self = shared_from_this(), completion = std::move(completion)](
1989 Result<network::HttpResponse> result
1990 ) mutable {
1991 net::post(
1992 self->strand_,
1993 [self, completion = std::move(completion), result = std::move(result)]() mutable {
1994 self->complete_commissions(
1995 std::move(completion), parse_commissions(std::move(result))
1996 );
1997 }
1998 );
1999 }
2000 );
2001 }
2002
2003 void start_holdings(std::int64_t account_sequence, HoldingsHandler completion) {
2004 if (auto invalid = validate_account_sequence(account_sequence)) {
2005 complete_holdings(
2006 std::move(completion), Result<HoldingsSnapshot>(std::move(*invalid))
2007 );
2008 return;
2009 }
2010 ensure_token(
2012 [self = shared_from_this(), account_sequence, completion = std::move(completion)](
2013 std::optional<BrokerError> token_error
2014 ) mutable {
2015 if (token_error) {
2016 self->complete_holdings(
2017 std::move(completion), Result<HoldingsSnapshot>(std::move(*token_error))
2018 );
2019 return;
2020 }
2021 self->request_holdings(account_sequence, std::move(completion));
2022 }
2023 )
2024 );
2025 }
2026
2027 void ensure_token(TokenHandler handler) {
2028 if (token_.valid()) {
2029 std::move(handler)(std::nullopt);
2030 return;
2031 }
2032 token_waiters_.push_back(std::move(handler));
2033 if (refreshing_) {
2034 return;
2035 }
2036 refreshing_ = true;
2037 network::HttpRequest request;
2038 // Every other request waits behind a token refresh, including a cancellation.
2039 request.group_ = network::RateLimitGroup::AUTH;
2040 request.method_ = http::verb::post;
2041 request.target_ = "/oauth2/token";
2042 request.body_ = config_.to_form_urlencoded_body();
2043 request.headers_.emplace_back("content-type", "application/x-www-form-urlencoded");
2044 http_->async_request(
2045 std::move(request),
2046 [self = shared_from_this()](Result<network::HttpResponse> result) mutable {
2047 net::post(self->strand_, [self, result = std::move(result)]() mutable {
2048 self->finish_token(std::move(result));
2049 });
2050 }
2051 );
2052 }
2053
2054 void finish_token(Result<network::HttpResponse> result) {
2055 refreshing_ = false;
2056 std::optional<BrokerError> failure;
2057 if (!result) {
2058 failure = std::move(result.error());
2059 } else {
2060 BrokerError parse_error;
2061 auto document = broker_detail::parse_object(result.value(), parse_error);
2062 if (!document) {
2063 failure = std::move(parse_error);
2064 } else if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2065 failure = toss_error(result.value(), *document);
2066 failure->category_ = ErrorCategory::AUTHENTICATION;
2067 } else {
2068 try {
2069 token_.from_json(*document);
2070 } catch (const std::exception& exception) {
2071 BrokerError error;
2072 error.category_ = ErrorCategory::AUTHENTICATION;
2073 error.broker_code_ = "invalid_token_response";
2074 error.message_ = exception.what();
2075 failure = std::move(error);
2076 }
2077 }
2078 }
2079 if (failure) {
2080 // **The waiters asked for something else.** A token refresh that fails is handed to every
2081 // caller waiting behind it -- an order, a cancellation, an inquiry -- and none of those
2082 // requests was sent. Left unmarked, an authentication failure reads to a runner as the broker
2083 // refusing an order it never received: a request counted against the rate window, and a later
2084 // valid order refused once credentials recover. What failed was the token request, and that
2085 // is not the instruction this error is about to describe.
2086 failure->reached_broker_ = false;
2087 }
2088 auto waiters = std::move(token_waiters_);
2089 token_waiters_.clear();
2090 for (auto& waiter : waiters) {
2091 if (failure) {
2092 std::move(waiter)(*failure);
2093 } else {
2094 std::move(waiter)(std::nullopt);
2095 }
2096 }
2097 }
2098
2099 void request_quote(InstrumentId instrument, QuoteHandler completion) {
2100 network::HttpRequest request;
2101 // Quote polling is the traffic a reserve exists to hold back.
2102 request.group_ = network::RateLimitGroup::MARKET_DATA;
2103 request.method_ = http::verb::get;
2104 request.target_ = "/api/v1/prices?symbols=" + broker_detail::query_encode(instrument.symbol_);
2105 request.headers_.emplace_back("Authorization", "Bearer " + token_.access_token_);
2106 send(
2107 std::move(request),
2108 [
2109 self = shared_from_this(),
2110 instrument = std::move(instrument),
2111 completion = std::move(completion)
2112 ](Result<network::HttpResponse> result) mutable {
2113 net::post(
2114 self->strand_,
2115 [
2116 self,
2117 instrument = std::move(instrument),
2118 completion = std::move(completion),
2119 result = std::move(result)
2120 ]() mutable {
2121 self->complete(
2122 std::move(completion), parse_quote(std::move(instrument), std::move(result))
2123 );
2124 }
2125 );
2126 }
2127 );
2128 }
2129
2130 void request_accounts(AccountsHandler completion) {
2131 network::HttpRequest request;
2132 // Account discovery runs before a session is bound.
2133 request.group_ = network::RateLimitGroup::ACCOUNT;
2134 request.method_ = http::verb::get;
2135 request.target_ = "/api/v1/accounts";
2136 request.headers_.emplace_back("Authorization", "Bearer " + token_.access_token_);
2137 send(
2138 std::move(request),
2139 [self = shared_from_this(), completion = std::move(completion)](
2140 Result<network::HttpResponse> result
2141 ) mutable {
2142 net::post(
2143 self->strand_,
2144 [self, completion = std::move(completion), result = std::move(result)]() mutable {
2145 self->complete_accounts(
2146 std::move(completion), parse_accounts(std::move(result))
2147 );
2148 }
2149 );
2150 }
2151 );
2152 }
2153
2154 void request_holdings(std::int64_t account_sequence, HoldingsHandler completion) {
2155 network::HttpRequest request;
2156 // Holdings rank above polling but may not spend the trading reserve.
2157 request.group_ = network::RateLimitGroup::ACCOUNT;
2158 request.method_ = http::verb::get;
2159 request.target_ = "/api/v1/holdings";
2160 request.headers_ = {
2161 {"Authorization", "Bearer " + token_.access_token_},
2162 {"X-Tossinvest-Account", std::to_string(account_sequence)},
2163 };
2164 send(
2165 std::move(request),
2166 [self = shared_from_this(), completion = std::move(completion)](
2167 Result<network::HttpResponse> result
2168 ) mutable {
2169 net::post(
2170 self->strand_,
2171 [self, completion = std::move(completion), result = std::move(result)]() mutable {
2172 self->complete_holdings(
2173 std::move(completion), parse_holdings(std::move(result))
2174 );
2175 }
2176 );
2177 }
2178 );
2179 }
2180
2181 static Result<QuoteSnapshot> parse_quote(InstrumentId instrument, Result<network::HttpResponse> result) {
2182 if (!result) {
2183 return Result<QuoteSnapshot>(std::move(result.error()));
2184 }
2185 BrokerError parse_error;
2186 auto document = broker_detail::parse_object(result.value(), parse_error);
2187 if (!document) {
2188 return Result<QuoteSnapshot>(std::move(parse_error));
2189 }
2190 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2191 auto error = toss_error(result.value(), *document);
2192 return Result<QuoteSnapshot>(std::move(error));
2193 }
2194 try {
2195 const auto payload = document->if_contains("result");
2196 if (
2197 !payload || !payload->is_array() || payload->as_array().empty() ||
2198 !payload->as_array().front().is_object()
2199 ) {
2200 throw std::runtime_error("missing non-empty result array");
2201 }
2202 const auto& output = payload->as_array().front().as_object();
2203 if (broker_detail::json_string(output, "symbol") != instrument.symbol_) {
2204 throw std::runtime_error("quote response symbol does not match request");
2205 }
2206 QuoteSnapshot quote;
2207 quote.instrument_ = std::move(instrument);
2208 quote.last_price_ = Decimal64::parse(broker_detail::json_string(output, "lastPrice"));
2209 quote.currency_ = Currency(broker_detail::json_string(output, "currency"));
2210 if (
2211 const auto timestamp = output.if_contains("timestamp"); timestamp && timestamp->is_string()
2212 ) {
2213 quote.exchange_time_ = parse_iso8601_timestamp(timestamp->as_string().c_str());
2214 if (!quote.exchange_time_) {
2215 throw std::runtime_error("invalid timestamp");
2216 }
2217 }
2218 quote.received_at_ = std::chrono::system_clock::now();
2219 return Result<QuoteSnapshot>(std::move(quote));
2220 } catch (const std::exception& exception) {
2221 BrokerError error;
2222 error.category_ = ErrorCategory::PROTOCOL;
2223 error.broker_code_ = "invalid_quote_response";
2224 error.message_ = exception.what();
2225 return Result<QuoteSnapshot>(std::move(error));
2226 }
2227 }
2228
2229 static Result<std::vector<TossAccount>> parse_accounts(Result<network::HttpResponse> result) {
2230 if (!result) {
2231 return Result<std::vector<TossAccount>>(std::move(result.error()));
2232 }
2233 BrokerError parse_error;
2234 auto document = broker_detail::parse_object(result.value(), parse_error);
2235 if (!document) {
2236 return Result<std::vector<TossAccount>>(std::move(parse_error));
2237 }
2238 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2239 auto error = toss_error(result.value(), *document);
2240 return Result<std::vector<TossAccount>>(std::move(error));
2241 }
2242 try {
2243 const auto payload = document->if_contains("result");
2244 if (!payload || !payload->is_array()) {
2245 throw std::runtime_error("missing array field: result");
2246 }
2247 std::vector<TossAccount> accounts;
2248 accounts.reserve(payload->as_array().size());
2249 for (const auto& value : payload->as_array()) {
2250 if (!value.is_object()) {
2251 throw std::runtime_error("account item is not an object");
2252 }
2253 const auto& account = value.as_object();
2254 const auto sequence = broker_detail::json_int64(account, "accountSeq");
2255 if (sequence <= 0) {
2256 throw std::runtime_error("accountSeq must be positive");
2257 }
2258 accounts.push_back(
2259 TossAccount{
2260 broker_detail::json_string(account, "accountNo"),
2261 sequence,
2262 broker_detail::json_string(account, "accountType"),
2263 }
2264 );
2265 }
2266 return Result<std::vector<TossAccount>>(std::move(accounts));
2267 } catch (const std::exception& exception) {
2268 BrokerError error;
2269 error.category_ = ErrorCategory::PROTOCOL;
2270 error.broker_code_ = "invalid_accounts_response";
2271 error.message_ = exception.what();
2272 return Result<std::vector<TossAccount>>(std::move(error));
2273 }
2274 }
2275
2276 // Returns whether Toss prices buying power in `currency`.
2277 static bool priced_currency(const Currency& currency) {
2278 return currency == Currency::krw() || currency == Currency::usd();
2279 }
2280
2281 // Normalizes one buying-power reading.
2282 //
2283 // The currency comes from the response rather than from the request, so a broker answering in a
2284 // currency other than the one asked for is carried as it answered rather than relabelled.
2285 static Result<BuyingPower> parse_buying_power(Result<network::HttpResponse> result) {
2286 auto payload = account_payload(result);
2287 if (!payload) {
2288 return Result<BuyingPower>(std::move(payload.error()));
2289 }
2290 try {
2291 if (!payload.value().is_object()) {
2292 throw std::runtime_error("buying power result is not an object");
2293 }
2294 const auto& output = payload.value().as_object();
2295 BuyingPower power;
2296 power.cash_ = Money{
2297 Decimal64::parse(broker_detail::json_string(output, "cashBuyingPower")),
2298 Currency(broker_detail::json_string(output, "currency")),
2299 };
2300 // Toss states no timestamp for this figure, so the honest stamp is when it arrived.
2301 power.received_at_ = std::chrono::system_clock::now();
2302 return Result<BuyingPower>(std::move(power));
2303 } catch (const std::exception& exception) {
2304 return Result<BuyingPower>(account_error("invalid_buying_power_response", exception));
2305 }
2306 }
2307
2308 // Normalizes one instrument's sellable quantity, which Toss may report fractionally.
2309 static Result<Decimal64> parse_sellable_quantity(Result<network::HttpResponse> result) {
2310 auto payload = account_payload(result);
2311 if (!payload) {
2312 return Result<Decimal64>(std::move(payload.error()));
2313 }
2314 try {
2315 if (!payload.value().is_object()) {
2316 throw std::runtime_error("sellable quantity result is not an object");
2317 }
2318 const auto& output = payload.value().as_object();
2319 return Result<Decimal64>(
2320 Decimal64::parse(broker_detail::json_string(output, "sellableQuantity"))
2321 );
2322 } catch (const std::exception& exception) {
2323 return Result<Decimal64>(account_error("invalid_sellable_quantity_response", exception));
2324 }
2325 }
2326
2327 // Normalizes the account's commission schedule.
2328 //
2329 // A row for a market this library does not normalize is **skipped rather than refused**: the
2330 // schedule is a list of what the broker charges where, and one unfamiliar market must not deny a
2331 // caller the rate for the market they trade. That is the same rule the holdings and order paths
2332 // follow for an unrecognized row.
2333 static Result<CommissionSchedule> parse_commissions(Result<network::HttpResponse> result) {
2334 auto payload = account_payload(result);
2335 if (!payload) {
2336 return Result<CommissionSchedule>(std::move(payload.error()));
2337 }
2338 try {
2339 if (!payload.value().is_array()) {
2340 throw std::runtime_error("commission result is not an array");
2341 }
2342 CommissionSchedule schedule;
2343 schedule.rates_.reserve(payload.value().as_array().size());
2344 for (const auto& value : payload.value().as_array()) {
2345 if (!value.is_object()) {
2346 throw std::runtime_error("commission item is not an object");
2347 }
2348 const auto& row = value.as_object();
2349 const auto market = broker_detail::json_string(row, "marketCountry");
2350 CommissionRate rate;
2351 if (market == "KR") {
2352 rate.country_ = Country::KR;
2353 } else if (market == "US") {
2354 rate.country_ = Country::US;
2355 } else if (const auto preserved = market.empty() ? std::nullopt : Code<16>::try_make(market)) {
2356 // Toss's own specification requires a client to accept unknown values of this enum, so a
2357 // new market is a normal thing to be sent. Preserved rather than dropped: a caller can
2358 // see what the broker charges there even though nothing here can price it, and a
2359 // schedule that quietly omitted the row would be a successful, incomplete answer.
2360 //
2361 // The empty string is excluded before `try_make` sees it, and that exclusion is load
2362 // bearing: an empty code stores successfully, and an empty `other_` is how a row says it
2363 // names a *normalized* market -- so a row with no market at all would become a rate for
2364 // whatever `country_` defaults to, which is a Korean commission the broker never
2365 // published.
2366 rate.other_ = *preserved;
2367 } else {
2368 // Not storable, so it cannot be reported under any market honestly. One such row must not
2369 // fail the whole schedule, which is the rule every other listing here follows -- but it
2370 // is counted, so a caller can see the schedule is incomplete rather than reading a
2371 // silently shortened list as the whole answer.
2372 ++schedule.dropped_;
2373 continue;
2374 }
2375 rate.rate_ = Decimal64::parse(broker_detail::json_string(row, "commissionRate"));
2376 // `venue_` stays neutral and `institution_rate_` stays empty, and both are the honest
2377 // reading rather than defaults nobody chose. Toss's schema is
2378 // `[marketCountry, commissionRate, startDate, endDate]`: it names no venue, so the rate
2379 // applies whatever venue a fill reached; and it states one rate without saying whether an
2380 // institution charge sits inside it, which is exactly what an empty `institution_rate_`
2381 // means. A zero there would claim Toss charges no institution fee, which it never said.
2382 // Null means open-ended in that direction, which is how Toss publishes a rate with no
2383 // scheduled change. It is deliberately not read as "today".
2384 const auto from = calendar_bound(row, "startDate");
2385 const auto until = calendar_bound(row, "endDate");
2386 rate.from_ = from.date_;
2387 rate.until_ = until.date_;
2388 rate.from_unreadable_ = from.unreadable_;
2389 rate.until_unreadable_ = until.unreadable_;
2390 if (rate.from_unreadable_ || rate.until_unreadable_) {
2391 // Counted per row rather than per bound: what a caller does about it -- refetch, or state
2392 // the rates by hand -- is the same one decision whichever end of the window was lost.
2393 ++schedule.unreadable_bounds_;
2394 }
2395 schedule.rates_.push_back(std::move(rate));
2396 }
2397 schedule.received_at_ = std::chrono::system_clock::now();
2398 schedule.origin_ = CommissionOrigin::BROKER_PUBLISHED;
2399 // The broker is the source, so the tariff needs nothing from the caller -- but the report
2400 // prints this string, and "which tariff is this" has a real answer here worth printing.
2401 schedule.tariff_ = "tossinvest published commission schedule";
2402 return Result<CommissionSchedule>(std::move(schedule));
2403 } catch (const std::exception& exception) {
2404 return Result<CommissionSchedule>(account_error("invalid_commission_response", exception));
2405 }
2406 }
2407
2408 // Returns the `result` field of an account response, or the error that stopped it.
2409 //
2410 // The three account-capacity reads share one envelope, so they share one unwrapping: transport
2411 // failure, unparseable body, and a non-2xx status all become the error they are, and only a
2412 // successful response yields a payload.
2413 static Result<json::value> account_payload(Result<network::HttpResponse>& result) {
2414 if (!result) {
2415 return Result<json::value>(std::move(result.error()));
2416 }
2417 BrokerError parse_error;
2418 auto document = broker_detail::parse_object(result.value(), parse_error);
2419 if (!document) {
2420 return Result<json::value>(std::move(parse_error));
2421 }
2422 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2423 return Result<json::value>(toss_error(result.value(), *document));
2424 }
2425 const auto payload = document->if_contains("result");
2426 if (!payload) {
2427 BrokerError error;
2428 error.category_ = ErrorCategory::PROTOCOL;
2429 error.broker_code_ = "missing_result";
2430 error.message_ = "response carries no result field";
2431 return Result<json::value>(std::move(error));
2432 }
2433 return Result<json::value>(std::move(*payload));
2434 }
2435
2436 // Builds the protocol error an account-capacity parse failure reports.
2437 static BrokerError account_error(std::string code, const std::exception& exception) {
2438 BrokerError error;
2439 error.category_ = ErrorCategory::PROTOCOL;
2440 error.broker_code_ = std::move(code);
2441 error.message_ = exception.what();
2442 return error;
2443 }
2444
2445 static Result<HoldingsSnapshot> parse_holdings(Result<network::HttpResponse> result) {
2446 if (!result) {
2447 return Result<HoldingsSnapshot>(std::move(result.error()));
2448 }
2449 BrokerError parse_error;
2450 auto document = broker_detail::parse_object(result.value(), parse_error);
2451 if (!document) {
2452 return Result<HoldingsSnapshot>(std::move(parse_error));
2453 }
2454 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2455 auto error = toss_error(result.value(), *document);
2456 return Result<HoldingsSnapshot>(std::move(error));
2457 }
2458 try {
2459 const auto payload = document->if_contains("result");
2460 if (!payload || !payload->is_object()) {
2461 throw std::runtime_error("missing object field: result");
2462 }
2463 const auto& output = payload->as_object();
2464 const auto items = output.if_contains("items");
2465 if (!items || !items->is_array()) {
2466 throw std::runtime_error("missing array field: items");
2467 }
2468
2469 HoldingsSnapshot snapshot;
2470 snapshot.positions_.reserve(items->as_array().size());
2471 for (const auto& value : items->as_array()) {
2472 if (!value.is_object()) {
2473 throw std::runtime_error("holdings item is not an object");
2474 }
2475 snapshot.positions_.push_back(parse_position(value.as_object()));
2476 }
2477 append_totals(output, snapshot.totals_);
2478 snapshot.received_at_ = std::chrono::system_clock::now();
2479 // Left unestablished deliberately: a Toss holdings row carries no sellable quantity at all
2480 // (`sellable_source` is `INQUIRY`), so this read establishes nothing about a figure it never
2481 // reported. The inquiry that does report one carries its own basis --
2482 // `traits_detail::Normalizers::sellable_inquiry_basis`.
2483 snapshot.sellable_basis_ = SellableBasis{};
2484 return Result<HoldingsSnapshot>(std::move(snapshot));
2485 } catch (const std::exception& exception) {
2486 BrokerError error;
2487 error.category_ = ErrorCategory::PROTOCOL;
2488 error.broker_code_ = "invalid_holdings_response";
2489 error.message_ = exception.what();
2490 return Result<HoldingsSnapshot>(std::move(error));
2491 }
2492 }
2493
2494 // Returns the venue that identifies an instrument at Toss.
2495 //
2496 // One function with two callers — the holdings parse and the order parse —
2497 // because those two disagreed, and an `InstrumentId` compares its venue. Toss
2498 // publishes no per-order venue, so this is about identity only.
2499 static Venue instrument_venue(Country country) {
2500 switch (country) {
2501 case Country::KR:
2502 return Venue::COMBINED;
2503 case Country::US:
2504 return Venue::UNSPECIFIED;
2505 }
2506 return Venue::UNSPECIFIED;
2507 }
2508
2509 // Whether Toss trades `country`'s listings through this client. Each answer below follows from it.
2510 static constexpr bool trades_country(Country country) noexcept {
2511 switch (country) {
2512 case Country::KR:
2513 case Country::US:
2514 return true;
2515 }
2516 return false;
2517 }
2518
2519 // The path segment Toss names a market by (`/api/v1/market-calendar/{KR,US}`).
2520 static constexpr std::string_view market_code(Country country) noexcept {
2521 switch (country) {
2522 case Country::KR:
2523 return "KR";
2524 case Country::US:
2525 return "US";
2526 }
2527 return "";
2528 }
2529
2530 // The session a calendar day's open and close are read from: KR's integrated session, US's regular one.
2531 static constexpr std::string_view calendar_hours_field(Country country) noexcept {
2532 switch (country) {
2533 case Country::KR:
2534 return "integrated";
2535 case Country::US:
2536 return "regularMarket";
2537 }
2538 return "";
2539 }
2540
2541 // Whether Toss takes a limit price in `country` only as a whole number of its currency's units (won).
2542 static constexpr bool prices_in_whole_units(Country country) noexcept {
2543 switch (country) {
2544 case Country::KR:
2545 return true;
2546 case Country::US:
2547 return false;
2548 }
2549 return true;
2550 }
2551
2552 static HoldingPosition parse_position(const json::object& output) {
2553 const auto market_country = broker_detail::json_string(output, "marketCountry");
2554 const auto currency = Currency(broker_detail::json_string(output, "currency"));
2555 Country country;
2556 Venue venue = Venue::UNSPECIFIED;
2557 if (market_country == "KR") {
2558 country = Country::KR;
2559 venue = instrument_venue(Country::KR);
2560 if (!(currency == Currency::krw())) {
2561 throw std::runtime_error("KR holding currency is not KRW");
2562 }
2563 } else if (market_country == "US") {
2564 country = Country::US;
2565 if (!(currency == Currency::usd())) {
2566 throw std::runtime_error("US holding currency is not USD");
2567 }
2568 } else {
2569 throw std::runtime_error("unsupported marketCountry: " + market_country);
2570 }
2571
2572 const auto& market_value = required_object(output, "marketValue");
2573 const auto& profit_loss = required_object(output, "profitLoss");
2574 HoldingPosition position;
2575 position.instrument_ = InstrumentId{
2576 country, venue, broker_detail::json_string(output, "symbol")
2577 };
2578 position.name_ = broker_detail::json_string(output, "name");
2579 position.quantity_ = Decimal64::parse(broker_detail::json_string(output, "quantity"));
2580 position.average_purchase_price_ =
2581 Decimal64::parse(broker_detail::json_string(output, "averagePurchasePrice"));
2582 position.last_price_ = Decimal64::parse(broker_detail::json_string(output, "lastPrice"));
2583 position.purchase_value_ = Money{
2584 Decimal64::parse(broker_detail::json_string(market_value, "purchaseAmount")), currency
2585 };
2586 position.market_value_ = Money{
2587 Decimal64::parse(broker_detail::json_string(market_value, "amount")), currency
2588 };
2589 position.unrealized_profit_loss_ = Money{
2590 Decimal64::parse(broker_detail::json_string(profit_loss, "amount")), currency
2591 };
2592 position.unrealized_return_ =
2593 Decimal64::parse(broker_detail::json_string(profit_loss, "rate"));
2594 return position;
2595 }
2596
2597 static const json::object& required_object(const json::object& parent, std::string_view key) {
2598 const auto value = parent.if_contains(key);
2599 if (!value || !value->is_object()) {
2600 throw std::runtime_error("missing object field: " + std::string(key));
2601 }
2602 return value->as_object();
2603 }
2604
2605 static std::optional<Decimal64> optional_decimal(const json::object& object, std::string_view key) {
2606 const auto value = object.if_contains(key);
2607 if (!value) {
2608 throw std::runtime_error("missing decimal field: " + std::string(key));
2609 }
2610 if (value->is_null()) {
2611 return std::nullopt;
2612 }
2613 if (!value->is_string()) {
2614 throw std::runtime_error("invalid decimal field: " + std::string(key));
2615 }
2616 return Decimal64::parse(value->as_string().c_str());
2617 }
2618
2619 static void append_totals(const json::object& output, std::vector<HoldingsTotals>& totals) {
2620 const auto& purchase = required_object(output, "totalPurchaseAmount");
2621 const auto& market = required_object(required_object(output, "marketValue"), "amount");
2622 const auto& profit_loss = required_object(required_object(output, "profitLoss"), "amount");
2623 totals.push_back(
2624 HoldingsTotals{
2625 Currency::krw(),
2626 Decimal64::parse(broker_detail::json_string(purchase, "krw")),
2627 Decimal64::parse(broker_detail::json_string(market, "krw")),
2628 Decimal64::parse(broker_detail::json_string(profit_loss, "krw")),
2629 }
2630 );
2631
2632 const auto usd_purchase = optional_decimal(purchase, "usd");
2633 const auto usd_market = optional_decimal(market, "usd");
2634 const auto usd_profit_loss = optional_decimal(profit_loss, "usd");
2635 const bool any_usd = usd_purchase || usd_market || usd_profit_loss;
2636 const bool all_usd = usd_purchase && usd_market && usd_profit_loss;
2637 if (any_usd != all_usd) {
2638 throw std::runtime_error("USD overview fields have inconsistent nullability");
2639 }
2640 if (all_usd) {
2641 totals.push_back(
2642 HoldingsTotals{
2643 Currency::usd(), *usd_purchase, *usd_market, *usd_profit_loss,
2644 }
2645 );
2646 }
2647 }
2648
2649 static BrokerError toss_error(const network::HttpResponse& response, const json::object& document) {
2650 const int status = response.result_int();
2651 BrokerError result;
2652 result.http_status_ = status;
2653 result.category_ = status == 429 ? ErrorCategory::RATE_LIMIT : ErrorCategory::PROTOCOL;
2654 if (result.category_ == ErrorCategory::RATE_LIMIT) {
2655 // Both hints Toss documents on a refusal, read in the one place a refusal is
2656 // built so no call site can forget one of them.
2657 result.retry_after_ = retry_after(response);
2658 result.rate_limit_ = reported_rate_limit(response);
2659 }
2660 const auto error = document.if_contains("error");
2661 if (!error || !error->is_object()) {
2662 result.message_ = "Toss response has no error object";
2663 return result;
2664 }
2665 const auto& object = error->as_object();
2666 if (const auto value = object.if_contains("code"); value && value->is_string()) {
2667 result.broker_code_ = value->as_string().c_str();
2668 }
2669 if (const auto value = object.if_contains("requestId"); value && value->is_string()) {
2670 result.request_id_ = value->as_string().c_str();
2671 }
2672 if (const auto value = object.if_contains("message"); value && value->is_string()) {
2673 result.message_ = value->as_string().c_str();
2674 }
2675 return result;
2676 }
2677
2678 // Returns an error when the account sequence cannot address a Toss account.
2679 static std::optional<BrokerError> validate_account_sequence(std::int64_t account_sequence) {
2680 if (account_sequence > 0) {
2681 return std::nullopt;
2682 }
2683 BrokerError error;
2684 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2685 error.reached_broker_ = false;
2686 error.broker_code_ = "invalid_account";
2687 error.message_ = "Toss account sequence must be positive";
2688 return error;
2689 }
2690
2691 // Returns an error when an order identifier is empty.
2692 static std::optional<BrokerError> validate_order_id(std::string_view order_id) {
2693 if (!order_id.empty()) {
2694 return std::nullopt;
2695 }
2696 BrokerError error;
2697 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2698 error.reached_broker_ = false;
2699 error.broker_code_ = "invalid_order_reference";
2700 error.message_ = "Toss order identifier must not be empty";
2701 return error;
2702 }
2703
2704 // Returns an error when a client order identifier cannot be used as an
2705 // idempotency key.
2706 //
2707 // Toss accepts at most thirty-six characters drawn from letters, digits,
2708 // hyphen, and underscore. A key rejected on the wire would silently remove
2709 // the very guarantee the caller asked for, so it is checked here first.
2710 static std::optional<BrokerError> validate_client_order_id(std::string_view client_order_id) {
2711 bool valid = !client_order_id.empty() && client_order_id.size() <= 36;
2712 for (const char ch : client_order_id) {
2713 const bool allowed = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
2714 (ch >= '0' && ch <= '9') || ch == '-' || ch == '_';
2715 valid = valid && allowed;
2716 }
2717 if (valid) {
2718 return std::nullopt;
2719 }
2720 BrokerError error;
2721 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2722 error.reached_broker_ = false;
2723 error.broker_code_ = "invalid_client_order_id";
2724 error.message_ =
2725 "Toss client order id must be 1-36 characters of letters, digits, hyphen, or underscore";
2726 return error;
2727 }
2728
2729 // Returns an error when a quantity is not a positive whole number of shares, or, where
2730 // `fraction_allowed`, a positive quantity of at most six decimal places.
2731 //
2732 // The raw scale is checked rather than a normalized one, because the request sends
2733 // `quantity.str()`, which keeps it.
2734 static std::optional<BrokerError> validate_quantity(
2735 const Decimal64& quantity,
2736 bool fraction_allowed = false
2737 ) {
2738 const auto max_scale = fraction_allowed ? 6 : 0;
2739 if (quantity.coefficient() > 0 && quantity.scale() <= max_scale) {
2740 return std::nullopt;
2741 }
2742 BrokerError error;
2743 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2744 error.reached_broker_ = false;
2745 error.broker_code_ = "invalid_quantity";
2746 error.message_ =
2747 "Toss order quantity must be a positive whole number of shares; Toss accepts a fraction, "
2748 "to six decimal places, only on a US market sell";
2749 return error;
2750 }
2751
2752 // Returns an error when a price does not match its order type.
2753 static std::optional<BrokerError> validate_price(
2754 OrderType type,
2755 const std::optional<Decimal64>& price
2756 ) {
2757 BrokerError error;
2758 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2759 error.reached_broker_ = false;
2760 error.broker_code_ = "invalid_price";
2761 if (type == OrderType::LIMIT) {
2762 if (!price) {
2763 error.message_ = "Toss limit order requires a price";
2764 return error;
2765 }
2766 if (price->coefficient() <= 0) {
2767 error.message_ = "Toss limit order price must be positive";
2768 return error;
2769 }
2770 return std::nullopt;
2771 }
2772 if (type == OrderType::MARKET) {
2773 if (price) {
2774 error.message_ = "Toss market order must not carry a price";
2775 return error;
2776 }
2777 return std::nullopt;
2778 }
2779 // A backstop, not a branch describing a reachable state: both callers judge
2780 // the basis first (`validate_order_shape`, `validate_amendment_shape`), so
2781 // this fires only for a caller added without one. It stays because
2782 // returning `nullopt` for an unjudged basis would let the order through.
2783 error.broker_code_ = "unsupported_order";
2784 error.message_ = "Toss orders currently support limit and market orders only";
2785 return error;
2786 }
2787
2788 // Returns an error when Toss cannot express this order shape.
2789 //
2790 // Toss publishes two price bases (`LIMIT`, `MARKET`), one execution condition
2791 // (day-valid), and three sessions, of which two are restricted. Everything
2792 // else this library can name is KIS's, and is rejected here rather than
2793 // approximated: a fill-or-kill order downgraded to a day order is a different
2794 // order, and a mid-price order has no Toss spelling at all.
2795 //
2796 // The restrictions are the spec's own, checked before I/O so a caller learns
2797 // the rule without spending an order on it: the closing auction is US limit
2798 // orders only, and the opening auction is Korean orders only.
2799 static std::optional<BrokerError> validate_order_shape(
2800 OrderType type,
2801 TimeInForce time_in_force,
2802 OrderSession session,
2803 Country country
2804 ) {
2805 BrokerError error;
2806 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2807 error.reached_broker_ = false;
2808
2809 if (type != OrderType::LIMIT && type != OrderType::MARKET) {
2810 error.broker_code_ = "unsupported_order_type";
2811 error.message_ = "Toss accepts a limit or market order only";
2812 return error;
2813 }
2814 if (time_in_force != TimeInForce::DAY) {
2815 error.broker_code_ = "unsupported_time_in_force";
2816 error.message_ =
2817 "Toss orders are day-valid; it publishes no immediate-or-cancel or "
2818 "fill-or-kill condition";
2819 return error;
2820 }
2821
2822 error.broker_code_ = "unsupported_session";
2823 switch (session) {
2825 return std::nullopt;
2827 if (country == Country::US && type == OrderType::LIMIT) {
2828 return std::nullopt;
2829 }
2830 error.message_ =
2831 "Toss accepts a closing-auction order only for a US limit order";
2832 return error;
2834 if (country == Country::KR) {
2835 return std::nullopt;
2836 }
2837 error.message_ = "Toss accepts an opening-auction order only for a Korean order";
2838 return error;
2839 default:
2840 error.message_ = "Toss publishes no order code for this trading session";
2841 return error;
2842 }
2843 }
2844
2845 // Returns an error when the order cannot be represented as a Toss order.
2846 static std::optional<BrokerError> validate_order(const OrderRequest& order) {
2847 if (!trades_country(order.instrument_.country_)) {
2848 BrokerError error;
2849 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2850 error.reached_broker_ = false;
2851 error.broker_code_ = "unsupported_market";
2852 error.message_ = "Toss trades Korean and US listings only";
2853 return error;
2854 }
2855 if (!broker_detail::valid_symbol(order.instrument_.symbol_)) {
2856 BrokerError error;
2857 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2858 error.reached_broker_ = false;
2859 error.broker_code_ = "invalid_instrument";
2860 error.message_ = "Toss order symbol contains unsupported characters";
2861 return error;
2862 }
2863 // The identity venue Toss's own parses write, so an order is the same
2864 // `InstrumentId` as its holding and `Portfolio::working_quantity` finds it.
2865 // Anything else on the instrument means routing, and Toss routes nowhere.
2866 if (order.instrument_.venue_ != instrument_venue(order.instrument_.country_)) {
2867 BrokerError error;
2868 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2869 error.reached_broker_ = false;
2870 error.broker_code_ = "invalid_instrument";
2871 error.message_ =
2872 "Toss identifies an instrument by the venue its own listings report; it offers no venue "
2873 "choice, so an order may not name one";
2874 return error;
2875 }
2876 if (order.venue_ != Venue::UNSPECIFIED) {
2877 BrokerError error;
2878 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2879 error.reached_broker_ = false;
2880 error.broker_code_ = "venue_unavailable";
2881 error.message_ =
2882 "Toss publishes no venue choice on an order; leave OrderRequest::venue_ unspecified";
2883 return error;
2884 }
2885 if (order.client_order_id_) {
2886 if (auto error = validate_client_order_id(*order.client_order_id_)) {
2887 return error;
2888 }
2889 }
2890 // Toss's trigger orders are a separate conditional-order API this client does not use, and
2891 // `make_place_order_request` has no field for a trigger, so one stated here would be dropped
2892 // and the order acknowledged as a plain limit. The session refuses it too, but a caller of
2893 // this client does not go through the session.
2894 if (order.trigger_price_) {
2895 BrokerError error;
2896 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2897 error.reached_broker_ = false;
2898 error.broker_code_ = "unsupported_trigger_price";
2899 error.message_ = "this Toss client does not place conditional orders, so it takes no trigger price";
2900 return error;
2901 }
2902 // The shape is judged before the price, because whether a price is required
2903 // at all is a fact about the basis. Judged after, a mid-price order was
2904 // refused by `validate_price` as "limit and market orders only" -- the right
2905 // outcome under a code that names the wrong field, and it made
2906 // `validate_order_shape`'s own basis check unreachable from this path.
2907 if (auto error = validate_order_shape(
2908 order.type_, order.time_in_force_, order.session_, order.instrument_.country_
2909 )) {
2910 return error;
2911 }
2912 // The shape check above refuses every session without a code; this keeps the two from drifting, before
2913 // any token is asked for.
2914 if (session_code(order.session_)[0] == '\0') {
2915 BrokerError error;
2916 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2917 error.reached_broker_ = false;
2918 error.broker_code_ = "unsupported_session";
2919 error.message_ = "Toss publishes no order code for this trading session";
2920 return error;
2921 }
2922 // Toss's one fractional shape (`createOrder`, `fractionalQuantityUsSellOnly`). Its other rule,
2923 // the regular session until an hour before the close, is left to Toss's own `422`.
2924 const bool fraction_allowed = order.instrument_.country_ == Country::US &&
2925 order.type_ == OrderType::MARKET && order.side_ == OrderSide::SELL;
2926 if (auto error = validate_quantity(order.quantity_, fraction_allowed)) {
2927 return error;
2928 }
2929 if (auto error = validate_price(order.type_, order.price_)) {
2930 return error;
2931 }
2932 if (prices_in_whole_units(order.instrument_.country_) && order.price_ && order.price_->scale() != 0) {
2933 BrokerError error;
2934 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2935 error.reached_broker_ = false;
2936 error.broker_code_ = "invalid_price";
2937 error.message_ = "Toss Korean limit order price must be a whole number of won";
2938 return error;
2939 }
2940 return std::nullopt;
2941 }
2942
2943 // Returns Toss's wire code for a normalized price basis.
2944 //
2945 // This used to read `type == MARKET ? "MARKET" : "LIMIT"`, which was
2946 // harmless while those were the only two bases and is not now: it would send
2947 // a mid-price or best-limit amendment to Toss as a plain limit order, which
2948 // is a silent downgrade, which this library refuses, and is invisible in the
2949 // request. Every caller validates the basis first, so the remaining cases
2950 // cannot be reached from outside this class; reaching one means a new body
2951 // was built without a validation step, which is a defect here rather than a
2952 // caller's bad input.
2953 static const char* order_type_code(OrderType type) {
2954 switch (type) {
2955 case OrderType::MARKET:
2956 return "MARKET";
2957 case OrderType::LIMIT:
2958 return "LIMIT";
2959 default:
2960 break;
2961 }
2962 throw std::logic_error("Toss order body built from an unvalidated price basis");
2963 }
2964
2965 // Returns an error when Toss cannot express an amendment's price basis.
2966 //
2967 // A modification states a basis and Toss accepts two of them. The placement
2968 // path checks this inside `validate_order_shape`; the modification path had
2969 // no check at all, so the basis reached `order_type_code` unexamined.
2970 static std::optional<BrokerError> validate_amendment_shape(OrderType type) {
2971 if (type == OrderType::LIMIT || type == OrderType::MARKET) {
2972 return std::nullopt;
2973 }
2974 BrokerError error;
2975 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2976 error.reached_broker_ = false;
2977 error.broker_code_ = "unsupported_order_type";
2978 error.message_ = "Toss modifies an order to a limit or market basis only";
2979 return error;
2980 }
2981
2982 // Returns Toss's `timeInForce` wire value for a normalized session.
2983 //
2984 // Toss spells the session on the field it calls `timeInForce`: its own spec
2985 // defines `OPG` as 국내 시가단일가 and `CLS` as the closing auction, which are
2986 // sessions rather than validity conditions. This library keeps the two facts
2987 // apart -- KIS spells both inside one `ORD_DVSN` and would otherwise have to
2988 // put the same fact in a different field -- so the mapping to Toss's wire
2989 // shape happens here, at the adapter boundary, which is where a vendor
2990 // spelling belongs.
2991 //
2992 // A session Toss takes no order in has no code (`""`); `validate_order` refuses it before any I/O.
2993 static const char* session_code(OrderSession session) {
2994 switch (session) {
2996 return "DAY";
2998 return "CLS";
3000 return "OPG";
3001 case OrderSession::UNKNOWN:
3006 case OrderSession::GTP:
3007 return "";
3008 }
3009 return "";
3010 }
3011
3012 // The condition of an order Toss lists, from its session. Toss publishes no execution condition of its
3013 // own, so every session it names is day-valid; an unread one leaves the condition unknown too.
3014 static TimeInForce read_time_in_force(OrderSession session) {
3015 switch (session) {
3016 case OrderSession::UNKNOWN:
3017 return TimeInForce::UNKNOWN;
3025 case OrderSession::GTP:
3026 return TimeInForce::DAY;
3027 }
3028 return TimeInForce::UNKNOWN;
3029 }
3030
3031 // Builds the authenticated Toss order-placement POST request.
3032 static network::HttpRequest make_place_order_request(
3033 std::int64_t account_sequence,
3034 std::string_view access_token,
3035 const OrderRequest& order
3036 ) {
3037 json::object body{
3038 {"symbol", order.instrument_.symbol_},
3039 {"side", order.side_ == OrderSide::SELL ? "SELL" : "BUY"},
3040 {"orderType", order_type_code(order.type_)},
3041 {"timeInForce", session_code(order.session_)},
3042 {"quantity", order.quantity_.str()},
3043 };
3044 if (order.client_order_id_) {
3045 body.emplace("clientOrderId", *order.client_order_id_);
3046 }
3047 if (order.price_) {
3048 body.emplace("price", order.price_->str());
3049 }
3050 if (order.confirm_high_value_) {
3051 body.emplace("confirmHighValueOrder", true);
3052 }
3053
3054 network::HttpRequest request;
3055 // An order is what the reserve is held for.
3056 request.group_ = network::RateLimitGroup::TRADING;
3057 request.method_ = http::verb::post;
3058 request.target_ = "/api/v1/orders";
3059 request.body_ = json::serialize(body);
3060 request.headers_ = {
3061 {"Authorization", "Bearer " + std::string(access_token)},
3062 {"X-Tossinvest-Account", std::to_string(account_sequence)},
3063 {"Content-Type", "application/json"},
3064 };
3065 return request;
3066 }
3067
3068 // Builds the authenticated Toss order-modification POST request.
3069 static network::HttpRequest make_replace_order_request(
3070 std::int64_t account_sequence,
3071 std::string_view access_token,
3072 const TossAmendment& amendment
3073 ) {
3074 json::object body{{"orderType", order_type_code(amendment.type_)}};
3075 if (amendment.quantity_) {
3076 body.emplace("quantity", amendment.quantity_->str());
3077 }
3078 if (amendment.price_) {
3079 body.emplace("price", amendment.price_->str());
3080 }
3081 if (amendment.confirm_high_value_) {
3082 body.emplace("confirmHighValueOrder", true);
3083 }
3084
3085 network::HttpRequest request;
3086 // A modification carries an order instruction and ranks with one.
3087 request.group_ = network::RateLimitGroup::TRADING;
3088 request.method_ = http::verb::post;
3089 request.target_ =
3090 "/api/v1/orders/" + broker_detail::query_encode(amendment.order_id_) + "/modify";
3091 request.body_ = json::serialize(body);
3092 request.headers_ = {
3093 {"Authorization", "Bearer " + std::string(access_token)},
3094 {"X-Tossinvest-Account", std::to_string(account_sequence)},
3095 {"Content-Type", "application/json"},
3096 };
3097 return request;
3098 }
3099
3100 // Builds the authenticated Toss order-cancellation POST request.
3101 static network::HttpRequest make_cancel_order_request(
3102 std::int64_t account_sequence,
3103 std::string_view access_token,
3104 std::string_view order_id
3105 ) {
3106 network::HttpRequest request;
3107 // A cancellation is the request that must never queue behind polling.
3108 request.group_ = network::RateLimitGroup::TRADING;
3109 request.method_ = http::verb::post;
3110 request.target_ = "/api/v1/orders/" + broker_detail::query_encode(order_id) + "/cancel";
3111 request.body_ = "{}";
3112 request.headers_ = {
3113 {"Authorization", "Bearer " + std::string(access_token)},
3114 {"X-Tossinvest-Account", std::to_string(account_sequence)},
3115 {"Content-Type", "application/json"},
3116 };
3117 return request;
3118 }
3119
3120 // Builds a Toss order-list request from the supplied filters.
3121 static network::HttpRequest make_orders_request(
3122 std::string_view access_token,
3123 const TossOrderQuery& query
3124 ) {
3125 bool open = false;
3126 switch (query.scope_) {
3128 open = true;
3129 break;
3131 break;
3132 }
3133 std::string target = "/api/v1/orders?status=";
3134 target += open ? "OPEN" : "CLOSED";
3135 if (query.symbol_) {
3136 target += "&symbol=" + broker_detail::query_encode(*query.symbol_);
3137 }
3138 if (query.from_) {
3139 target += "&from=" + iso_date(*query.from_);
3140 }
3141 if (query.to_) {
3142 target += "&to=" + iso_date(*query.to_);
3143 }
3144 // Toss documents that an OPEN listing returns every pending order and
3145 // ignores paging, so the paging parameters are omitted rather than sent
3146 // and silently discarded.
3147 if (!open) {
3148 if (query.cursor_) {
3149 target += "&cursor=" + broker_detail::query_encode(*query.cursor_);
3150 }
3151 if (query.limit_) {
3152 target += "&limit=" + std::to_string(*query.limit_);
3153 }
3154 }
3155
3156 network::HttpRequest request;
3157 // Order listings are account monitoring, not order submission.
3158 request.group_ = network::RateLimitGroup::ACCOUNT;
3159 request.method_ = http::verb::get;
3160 request.target_ = std::move(target);
3161 request.headers_ = {
3162 {"Authorization", "Bearer " + std::string(access_token)},
3163 {"X-Tossinvest-Account", std::to_string(query.account_sequence_)},
3164 };
3165 return request;
3166 }
3167
3168 // Builds a Toss request for one order by broker identifier.
3169 static network::HttpRequest make_order_request(
3170 std::int64_t account_sequence,
3171 std::string_view access_token,
3172 std::string_view order_id
3173 ) {
3174 network::HttpRequest request;
3175 // A single-order inquiry is account monitoring.
3176 request.group_ = network::RateLimitGroup::ACCOUNT;
3177 request.method_ = http::verb::get;
3178 request.target_ = "/api/v1/orders/" + broker_detail::query_encode(order_id);
3179 request.headers_ = {
3180 {"Authorization", "Bearer " + std::string(access_token)},
3181 {"X-Tossinvest-Account", std::to_string(account_sequence)},
3182 };
3183 return request;
3184 }
3185
3186 // Validates and starts one Toss order submission.
3187 void start_place_order(
3188 std::int64_t account_sequence,
3189 OrderRequest order,
3190 OrderAckHandler completion
3191 ) {
3192 auto invalid = validate_account_sequence(account_sequence);
3193 if (!invalid) {
3194 invalid = validate_order(order);
3195 }
3196 if (invalid) {
3197 complete_order_ack(std::move(completion), Result<OrderAck>(std::move(*invalid)));
3198 return;
3199 }
3200
3201 ensure_token(
3203 [
3204 self = shared_from_this(),
3205 account_sequence,
3206 order = std::move(order),
3207 completion = std::move(completion)
3208 ](std::optional<BrokerError> token_error) mutable {
3209 if (token_error) {
3210 self->complete_order_ack(
3211 std::move(completion), Result<OrderAck>(std::move(*token_error))
3212 );
3213 return;
3214 }
3215 auto request = make_place_order_request(
3216 account_sequence, self->token_.access_token_, order
3217 );
3218 self->request_order_ack(std::move(request), std::move(completion));
3219 }
3220 )
3221 );
3222 }
3223
3224 // Validates and starts one Toss order modification.
3225 void start_replace_order(
3226 std::int64_t account_sequence,
3227 TossAmendment amendment,
3228 OrderAckHandler completion
3229 ) {
3230 auto invalid = validate_account_sequence(account_sequence);
3231 if (!invalid) {
3232 invalid = validate_order_id(amendment.order_id_);
3233 }
3234 if (!invalid && amendment.quantity_) {
3235 invalid = validate_quantity(*amendment.quantity_);
3236 }
3237 if (!invalid) {
3238 invalid = validate_amendment_shape(amendment.type_);
3239 }
3240 if (!invalid) {
3241 invalid = validate_price(amendment.type_, amendment.price_);
3242 }
3243 if (invalid) {
3244 complete_order_ack(std::move(completion), Result<OrderAck>(std::move(*invalid)));
3245 return;
3246 }
3247
3248 ensure_token(
3250 [
3251 self = shared_from_this(),
3252 account_sequence,
3253 amendment = std::move(amendment),
3254 completion = std::move(completion)
3255 ](std::optional<BrokerError> token_error) mutable {
3256 if (token_error) {
3257 self->complete_order_ack(
3258 std::move(completion), Result<OrderAck>(std::move(*token_error))
3259 );
3260 return;
3261 }
3262 auto request = make_replace_order_request(
3263 account_sequence, self->token_.access_token_, amendment
3264 );
3265 self->request_order_ack(std::move(request), std::move(completion));
3266 }
3267 )
3268 );
3269 }
3270
3271 // Validates and starts one Toss order cancellation.
3272 void start_cancel_order(
3273 std::int64_t account_sequence,
3274 std::string order_id,
3275 OrderAckHandler completion
3276 ) {
3277 auto invalid = validate_account_sequence(account_sequence);
3278 if (!invalid) {
3279 invalid = validate_order_id(order_id);
3280 }
3281 if (invalid) {
3282 complete_order_ack(std::move(completion), Result<OrderAck>(std::move(*invalid)));
3283 return;
3284 }
3285
3286 ensure_token(
3288 [
3289 self = shared_from_this(),
3290 account_sequence,
3291 order_id = std::move(order_id),
3292 completion = std::move(completion)
3293 ](std::optional<BrokerError> token_error) mutable {
3294 if (token_error) {
3295 self->complete_order_ack(
3296 std::move(completion), Result<OrderAck>(std::move(*token_error))
3297 );
3298 return;
3299 }
3300 auto request = make_cancel_order_request(
3301 account_sequence, self->token_.access_token_, order_id
3302 );
3303 self->request_order_ack(std::move(request), std::move(completion));
3304 }
3305 )
3306 );
3307 }
3308
3309 // Sends an order-writing request and dispatches its parsed acknowledgement.
3310 void request_order_ack(network::HttpRequest request, OrderAckHandler completion) {
3311 send(
3312 std::move(request),
3313 [self = shared_from_this(), completion = std::move(completion)](
3314 Result<network::HttpResponse> result
3315 ) mutable {
3316 net::post(
3317 self->strand_,
3318 [self, completion = std::move(completion), result = std::move(result)]() mutable {
3319 self->complete_order_ack(std::move(completion), parse_order_ack(std::move(result)));
3320 }
3321 );
3322 }
3323 );
3324 }
3325
3326 // Validates and starts one filtered Toss order listing.
3327 void start_orders(TossOrderQuery query, OrderPageHandler completion) {
3328 if (auto invalid = validate_account_sequence(query.account_sequence_)) {
3329 complete_order_page(std::move(completion), Result<OrderPage>(std::move(*invalid)));
3330 return;
3331 }
3332 if (query.symbol_ && !broker_detail::valid_symbol(*query.symbol_)) {
3333 BrokerError error;
3334 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3335 error.reached_broker_ = false;
3336 error.broker_code_ = "invalid_instrument";
3337 error.message_ = "Toss order filter symbol contains unsupported characters";
3338 complete_order_page(std::move(completion), Result<OrderPage>(std::move(error)));
3339 return;
3340 }
3341 if (query.limit_ && (*query.limit_ < 1 || *query.limit_ > 100)) {
3342 BrokerError error;
3343 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3344 error.reached_broker_ = false;
3345 error.broker_code_ = "invalid_limit";
3346 error.message_ = "Toss order listing accepts a page size between 1 and 100";
3347 complete_order_page(std::move(completion), Result<OrderPage>(std::move(error)));
3348 return;
3349 }
3350 if (
3351 (query.from_ && !is_valid_date(*query.from_)) ||
3352 (query.to_ && !is_valid_date(*query.to_)) ||
3353 (query.from_ && query.to_ && *query.to_ < *query.from_)
3354 ) {
3355 BrokerError error;
3356 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3357 error.reached_broker_ = false;
3358 error.broker_code_ = "invalid_date_range";
3359 error.message_ = "Toss order listing requires valid inclusive calendar bounds";
3360 complete_order_page(std::move(completion), Result<OrderPage>(std::move(error)));
3361 return;
3362 }
3363
3364 ensure_token(
3366 [self = shared_from_this(), query = std::move(query), completion = std::move(completion)](
3367 std::optional<BrokerError> token_error
3368 ) mutable {
3369 if (token_error) {
3370 self->complete_order_page(
3371 std::move(completion), Result<OrderPage>(std::move(*token_error))
3372 );
3373 return;
3374 }
3375 auto request = make_orders_request(self->token_.access_token_, query);
3376 self->send(
3377 std::move(request),
3378 [self, completion = std::move(completion)](
3379 Result<network::HttpResponse> result
3380 ) mutable {
3381 net::post(
3382 self->strand_,
3383 [self, completion = std::move(completion), result = std::move(result)]() mutable {
3384 self->complete_order_page(
3385 std::move(completion), parse_orders(std::move(result))
3386 );
3387 }
3388 );
3389 }
3390 );
3391 }
3392 )
3393 );
3394 }
3395
3396 // Validates and starts an inquiry for one Toss order.
3397 void start_order(std::int64_t account_sequence, std::string order_id, OrderHandler completion) {
3398 auto invalid = validate_account_sequence(account_sequence);
3399 if (!invalid) {
3400 invalid = validate_order_id(order_id);
3401 }
3402 if (invalid) {
3403 complete_order(std::move(completion), Result<OrderSnapshot>(std::move(*invalid)));
3404 return;
3405 }
3406
3407 ensure_token(
3409 [
3410 self = shared_from_this(),
3411 account_sequence,
3412 order_id = std::move(order_id),
3413 completion = std::move(completion)
3414 ](std::optional<BrokerError> token_error) mutable {
3415 if (token_error) {
3416 self->complete_order(
3417 std::move(completion), Result<OrderSnapshot>(std::move(*token_error))
3418 );
3419 return;
3420 }
3421 auto request = make_order_request(
3422 account_sequence, self->token_.access_token_, order_id
3423 );
3424 self->send(
3425 std::move(request),
3426 [self, completion = std::move(completion)](
3427 Result<network::HttpResponse> result
3428 ) mutable {
3429 net::post(
3430 self->strand_,
3431 [self, completion = std::move(completion), result = std::move(result)]() mutable {
3432 self->complete_order(std::move(completion), parse_order(std::move(result)));
3433 }
3434 );
3435 }
3436 );
3437 }
3438 )
3439 );
3440 }
3441
3442 // Parses a Toss order-instruction response into an acknowledgement.
3443 static Result<OrderAck> parse_order_ack(Result<network::HttpResponse> result) {
3444 if (!result) {
3445 return Result<OrderAck>(std::move(result.error()));
3446 }
3447 BrokerError parse_error;
3448 auto document = broker_detail::parse_object(result.value(), parse_error);
3449 if (!document) {
3450 return Result<OrderAck>(std::move(parse_error));
3451 }
3452 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3453 auto error = toss_error(result.value(), *document);
3454 return Result<OrderAck>(std::move(error));
3455 }
3456 try {
3457 const auto payload = document->if_contains("result");
3458 if (!payload || !payload->is_object()) {
3459 throw std::runtime_error("missing object field: result");
3460 }
3461 const auto& fields = payload->as_object();
3462
3463 OrderAck ack;
3464 ack.order_id_ = broker_detail::json_string(fields, "orderId");
3465 if (const auto value = fields.if_contains("clientOrderId"); value && value->is_string()) {
3466 ack.client_order_id_ = std::string(value->as_string());
3467 }
3468 ack.accepted_at_ = std::chrono::system_clock::now();
3469 return Result<OrderAck>(std::move(ack));
3470 } catch (const std::exception& exception) {
3471 BrokerError error;
3472 error.category_ = ErrorCategory::PROTOCOL;
3473 error.broker_code_ = "invalid_order_response";
3474 error.message_ = exception.what();
3475 return Result<OrderAck>(std::move(error));
3476 }
3477 }
3478
3479 // Parses one Toss order-list response and its continuation cursor.
3480 static Result<OrderPage> parse_orders(Result<network::HttpResponse> result) {
3481 if (!result) {
3482 return Result<OrderPage>(std::move(result.error()));
3483 }
3484 BrokerError parse_error;
3485 auto document = broker_detail::parse_object(result.value(), parse_error);
3486 if (!document) {
3487 return Result<OrderPage>(std::move(parse_error));
3488 }
3489 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3490 auto error = toss_error(result.value(), *document);
3491 return Result<OrderPage>(std::move(error));
3492 }
3493 try {
3494 const auto payload = document->if_contains("result");
3495 if (!payload || !payload->is_object()) {
3496 throw std::runtime_error("missing object field: result");
3497 }
3498 const auto& output = payload->as_object();
3499 const auto orders = output.if_contains("orders");
3500 if (!orders || !orders->is_array()) {
3501 throw std::runtime_error("missing array field: orders");
3502 }
3503
3504 OrderPage page;
3505 page.orders_.reserve(orders->as_array().size());
3506 for (const auto& value : orders->as_array()) {
3507 if (!value.is_object()) {
3508 throw std::runtime_error("order item is not an object");
3509 }
3510 page.orders_.push_back(parse_order_object(value.as_object()));
3511 }
3512 if (const auto cursor = output.if_contains("nextCursor"); cursor && cursor->is_string()) {
3513 page.next_cursor_ = std::string(cursor->as_string());
3514 }
3515 page.received_at_ = std::chrono::system_clock::now();
3516 return Result<OrderPage>(std::move(page));
3517 } catch (const std::exception& exception) {
3518 BrokerError error;
3519 error.category_ = ErrorCategory::PROTOCOL;
3520 error.broker_code_ = "invalid_orders_response";
3521 error.message_ = exception.what();
3522 return Result<OrderPage>(std::move(error));
3523 }
3524 }
3525
3526 // Parses a Toss single-order response into a normalized snapshot.
3527 static Result<OrderSnapshot> parse_order(Result<network::HttpResponse> result) {
3528 if (!result) {
3529 return Result<OrderSnapshot>(std::move(result.error()));
3530 }
3531 BrokerError parse_error;
3532 auto document = broker_detail::parse_object(result.value(), parse_error);
3533 if (!document) {
3534 return Result<OrderSnapshot>(std::move(parse_error));
3535 }
3536 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3537 auto error = toss_error(result.value(), *document);
3538 return Result<OrderSnapshot>(std::move(error));
3539 }
3540 try {
3541 const auto payload = document->if_contains("result");
3542 if (!payload || !payload->is_object()) {
3543 throw std::runtime_error("missing object field: result");
3544 }
3545 return Result<OrderSnapshot>(parse_order_object(payload->as_object()));
3546 } catch (const std::exception& exception) {
3547 BrokerError error;
3548 error.category_ = ErrorCategory::PROTOCOL;
3549 error.broker_code_ = "invalid_order_response";
3550 error.message_ = exception.what();
3551 return Result<OrderSnapshot>(std::move(error));
3552 }
3553 }
3554
3555 // Normalizes one Toss order object.
3556 //
3557 // Toss reports a currency rather than a market country, so the instrument's
3558 // country is taken from that currency. This is a stated broker fact, not an
3559 // inference from the symbol's shape.
3560 //
3561 // Unrecognized order-type, validity, and status codes become their `UNKNOWN`
3562 // value. Toss's specification requires clients to tolerate them, so one
3563 // unfamiliar order must not fail an entire listing.
3564 static OrderSnapshot parse_order_object(const json::object& row) {
3565 const auto currency = Currency(broker_detail::json_string(row, "currency"));
3566
3567 Country country;
3568 if (currency == Currency::krw()) {
3569 country = Country::KR;
3570 } else if (currency == Currency::usd()) {
3571 country = Country::US;
3572 } else {
3573 throw std::runtime_error("unsupported Toss order currency: " + currency.str());
3574 }
3575
3576 OrderSnapshot order;
3577 order.order_id_ = broker_detail::json_string(row, "orderId");
3578 // `instrument_venue`, not `UNSPECIFIED`: `parse_position` writes `COMBINED`
3579 // for the same Korean instrument, and an order and its holding must be the
3580 // same `InstrumentId`, or `Portfolio::working_quantity` finds nothing for a
3581 // Korean Toss order.
3582 order.instrument_ = InstrumentId{
3583 country,
3584 instrument_venue(country),
3585 broker_detail::json_string(row, "symbol"),
3586 };
3587 order.side_ = normalized_order_side(broker_detail::json_string(row, "side"));
3588 order.type_ = normalized_order_type(broker_detail::json_string(row, "orderType"));
3589 const auto time_in_force = broker_detail::json_string(row, "timeInForce");
3590 order.session_ = normalized_session(time_in_force);
3591 // Toss publishes no execution condition of its own, so every order it
3592 // recognizes is day-valid. A `timeInForce` it does not recognize leaves the
3593 // condition unknown as well as the session, rather than asserting `DAY`
3594 // about a value this library could not read.
3595 order.time_in_force_ = read_time_in_force(order.session_);
3596 if (const auto code = Code<8>::try_make(time_in_force)) {
3597 order.shape_code_ = *code;
3598 }
3599 order.status_ = normalized_status(broker_detail::json_string(row, "status"));
3600 order.price_ = nullable_decimal(row, "price");
3601 order.quantity_ = Decimal64::parse(broker_detail::json_string(row, "quantity"));
3602 // An amount-based order states dollars and lets the fill decide the
3603 // quantity. The library does not place one yet, but it must not drop the
3604 // field off one placed through another channel: without it the snapshot
3605 // says an order of some quantity and cannot say what was actually
3606 // committed.
3607 order.order_amount_ = nullable_rounded_decimal(row, "orderAmount");
3608 order.currency_ = currency;
3609 order.ordered_at_ = optional_timestamp(row, "orderedAt");
3610 order.canceled_at_ = optional_timestamp(row, "canceledAt");
3611
3612 const auto execution = row.if_contains("execution");
3613 if (!execution || !execution->is_object()) {
3614 throw std::runtime_error("missing object field: execution");
3615 }
3616 const auto& fills = execution->as_object();
3617 order.execution_.filled_quantity_ =
3618 Decimal64::parse(broker_detail::json_string(fills, "filledQuantity"));
3619 order.execution_.average_filled_price_ = optional_decimal(fills, "averageFilledPrice");
3620 order.execution_.filled_amount_ = optional_money(fills, "filledAmount", currency);
3621 order.execution_.commission_ = optional_money(fills, "commission", currency);
3622 order.execution_.tax_ = optional_money(fills, "tax", currency);
3623 order.execution_.filled_at_ = optional_timestamp(fills, "filledAt");
3624 order.execution_.settlement_date_ = optional_date(fills, "settlementDate");
3625 return order;
3626 }
3627
3628 // Maps a Toss order-type code onto a normalized order type.
3629 static OrderType normalized_order_type(std::string_view code) {
3630 if (code == "LIMIT") {
3631 return OrderType::LIMIT;
3632 }
3633 if (code == "MARKET") {
3634 return OrderType::MARKET;
3635 }
3636 return OrderType::UNKNOWN;
3637 }
3638
3639 // Maps a Toss validity code onto a normalized validity condition.
3640 static OrderSession normalized_session(std::string_view code) {
3641 if (code == "DAY") {
3642 return OrderSession::REGULAR;
3643 }
3644 if (code == "CLS") {
3646 }
3647 if (code == "OPG") {
3649 }
3650 return OrderSession::UNKNOWN;
3651 }
3652
3653 // Maps a Toss status code onto a normalized lifecycle state.
3654 static OrderStatus normalized_status(std::string_view code) {
3655 if (code == "PENDING") {
3656 return OrderStatus::PENDING;
3657 }
3658 if (code == "PENDING_CANCEL") {
3659 return OrderStatus::PENDING_CANCEL;
3660 }
3661 if (code == "PENDING_REPLACE") {
3662 return OrderStatus::PENDING_REPLACE;
3663 }
3664 if (code == "PARTIAL_FILLED") {
3665 return OrderStatus::PARTIAL_FILLED;
3666 }
3667 if (code == "FILLED") {
3668 return OrderStatus::FILLED;
3669 }
3670 if (code == "CANCELED") {
3671 return OrderStatus::CANCELED;
3672 }
3673 if (code == "REJECTED") {
3674 return OrderStatus::REJECTED;
3675 }
3676 if (code == "CANCEL_REJECTED") {
3677 return OrderStatus::CANCEL_REJECTED;
3678 }
3679 if (code == "REPLACE_REJECTED") {
3680 return OrderStatus::REPLACE_REJECTED;
3681 }
3682 if (code == "REPLACED") {
3683 return OrderStatus::REPLACED;
3684 }
3685 return OrderStatus::UNKNOWN;
3686 }
3687
3688 // Maps a Toss side code or rejects an invalid broker response.
3689 static OrderSide normalized_order_side(std::string_view code) {
3690 if (code == "BUY") {
3691 return OrderSide::BUY;
3692 }
3693 if (code == "SELL") {
3694 return OrderSide::SELL;
3695 }
3696 throw std::runtime_error("invalid Toss order side code");
3697 }
3698
3699 // Reads a decimal string field that Toss may omit entirely.
3700 //
3701 // Toss marks an order's price as neither required nor always present, unlike
3702 // the execution amounts, which are always sent and merely nullable. Those
3703 // keep the stricter `optional_decimal`, so a genuinely missing execution
3704 // field is still reported as a protocol error.
3705 static std::optional<Decimal64> nullable_decimal(
3706 const json::object& object,
3707 std::string_view key
3708 ) {
3709 const auto value = object.if_contains(key);
3710 if (!value || value->is_null()) {
3711 return std::nullopt;
3712 }
3713 if (!value->is_string()) {
3714 throw std::runtime_error("invalid decimal field: " + std::string(key));
3715 }
3716 return Decimal64::parse(std::string_view(value->as_string()));
3717 }
3718
3719 // Reads a nullable decimal string field that Toss computes, rounding a fraction too long for
3720 // `Decimal64`.
3721 //
3722 // Toss has sent `orderAmount` with twenty fractional digits, which `Decimal64::parse` refuses, and
3723 // the refusal failed every order list holding such an order. The fraction is rounded half to even
3724 // to the 18 significant digits a `Decimal64` always holds. The integer part is never rounded: a value
3725 // whose integer part does not fit is still refused.
3726 static std::optional<Decimal64> nullable_rounded_decimal(
3727 const json::object& object,
3728 std::string_view key
3729 ) {
3730 const auto value = object.if_contains(key);
3731 if (!value || value->is_null()) {
3732 return std::nullopt;
3733 }
3734 if (!value->is_string()) {
3735 throw std::runtime_error("invalid decimal field: " + std::string(key));
3736 }
3737 return rounded_decimal(std::string_view(value->as_string()));
3738 }
3739
3740 // Parses a decimal exactly, or, when only its fraction is too long to fit, to the nearest value
3741 // with 18 significant digits (ties to even).
3742 //
3743 // The digits are counted from the first nonzero one, so the leading zeros of an amount below one
3744 // cost no precision. A value that cannot keep 18 significant digits within `Decimal64`'s scale, or
3745 // whose integer part does not fit, is refused rather than rounded towards zero.
3746 static Decimal64 rounded_decimal(std::string_view text) {
3747 try {
3748 return Decimal64::parse(text);
3749 } catch (const std::out_of_range&) {
3750 const auto sign = !text.empty() && (text.front() == '-' || text.front() == '+') ? 1U : 0U;
3751 const auto point = text.find('.');
3752 if (point == std::string_view::npos) {
3753 throw;
3754 }
3755 auto whole = text.substr(sign, point - sign);
3756 const auto fraction = text.substr(point + 1);
3757 for (const auto part : {whole, fraction}) {
3758 if (part.find_first_not_of("0123456789") != std::string_view::npos) {
3759 throw std::invalid_argument("decimal value contains an invalid character");
3760 }
3761 }
3762 const auto significant = whole.find_first_not_of('0');
3763 whole = significant == std::string_view::npos ? std::string_view() : whole.substr(significant);
3764 constexpr std::size_t digits = 18;
3765 if (whole.size() > digits) {
3766 throw;
3767 }
3768 std::size_t kept = 0;
3769 if (!whole.empty()) {
3770 kept = std::min(fraction.size(), digits - whole.size());
3771 } else {
3772 const auto leading = fraction.find_first_not_of('0');
3773 if (leading == std::string_view::npos) {
3774 return Decimal64();
3775 }
3776 kept = std::min(fraction.size(), leading + digits);
3777 }
3778 if (kept > std::numeric_limits<std::uint8_t>::max()) {
3779 throw std::out_of_range("decimal scale exceeds Decimal64 range");
3780 }
3781 std::uint64_t magnitude = 0;
3782 for (const auto ch : whole) {
3783 magnitude = magnitude * 10U + static_cast<std::uint64_t>(ch - '0');
3784 }
3785 for (const auto ch : fraction.substr(0, kept)) {
3786 magnitude = magnitude * 10U + static_cast<std::uint64_t>(ch - '0');
3787 }
3788 const auto dropped = fraction.substr(kept);
3789 if (!dropped.empty()) {
3790 const bool beyond_half = dropped.find_first_not_of('0', 1) != std::string_view::npos;
3791 if (dropped.front() > '5' || (dropped.front() == '5' && (beyond_half || magnitude % 2U == 1U))) {
3792 ++magnitude;
3793 }
3794 }
3795 const auto coefficient = static_cast<std::int64_t>(magnitude);
3796 const Decimal64 rounded(
3797 sign == 1U && text.front() == '-' ? -coefficient : coefficient, static_cast<std::uint8_t>(kept)
3798 );
3799 return Decimal64::parse(rounded.str());
3800 }
3801 }
3802
3803 // Reads a nullable decimal string field as an amount in `currency`.
3804 static std::optional<Money> optional_money(
3805 const json::object& object,
3806 std::string_view key,
3807 const Currency& currency
3808 ) {
3809 const auto amount = optional_decimal(object, key);
3810 if (!amount) {
3811 return std::nullopt;
3812 }
3813 return Money{*amount, currency};
3814 }
3815
3816 // Reads a nullable ISO 8601 timestamp field.
3817 static std::optional<std::chrono::system_clock::time_point> optional_timestamp(
3818 const json::object& object,
3819 std::string_view key
3820 ) {
3821 const auto value = object.if_contains(key);
3822 if (!value || value->is_null()) {
3823 return std::nullopt;
3824 }
3825 if (!value->is_string()) {
3826 throw std::runtime_error("invalid timestamp field: " + std::string(key));
3827 }
3828 auto parsed = parse_iso8601_timestamp(std::string_view(value->as_string()));
3829 if (!parsed) {
3830 throw std::runtime_error("invalid timestamp field: " + std::string(key));
3831 }
3832 return parsed;
3833 }
3834
3835 // Reads a nullable `YYYY-MM-DD` calendar date field.
3836 static std::optional<Date> optional_date(const json::object& object, std::string_view key) {
3837 const auto value = object.if_contains(key);
3838 if (!value || value->is_null()) {
3839 return std::nullopt;
3840 }
3841 if (!value->is_string()) {
3842 throw std::runtime_error("invalid date field: " + std::string(key));
3843 }
3844 const std::string_view text(value->as_string());
3845 int year = 0;
3846 unsigned month = 0;
3847 unsigned day = 0;
3848 if (
3849 text.size() != 10 || text[4] != '-' || text[7] != '-' ||
3850 std::sscanf(std::string(text).c_str(), "%4d-%2u-%2u", &year, &month, &day) != 3
3851 ) {
3852 throw std::runtime_error("invalid date field: " + std::string(key));
3853 }
3854 Date date{year, month, day};
3855 if (!is_valid_date(date)) {
3856 throw std::runtime_error("invalid date field: " + std::string(key));
3857 }
3858 return date;
3859 }
3860
3861 // Formats a calendar date as Toss's `YYYY-MM-DD` filter bound.
3862 static std::string iso_date(const Date& date) {
3863 char buffer[11];
3864 std::snprintf(buffer, sizeof(buffer), "%04d-%02u-%02u", date.year_, date.month_, date.day_);
3865 return std::string(buffer);
3866 }
3867
3868 // Sends a request that carries the access token, and forgets that token if Toss refuses it.
3869 //
3870 // Toss answers 401 to a token that has expired or been revoked, and issuing a token revokes the
3871 // one issued before it with the same key ("새로 발급된 토큰으로 대체되어 더 이상 유효하지 않은
3872 // 토큰입니다"), so another program using the key ends this one's token. Kept, it would be sent
3873 // until its stated expiry and every request would fail; forgotten, the next request asks for a new
3874 // one. The refused request is not sent again. Only the token the request carried is forgotten: a
3875 // late refusal of an older token leaves a newer one alone, so a token one program takes refuses the
3876 // other's requests that carry the old token and starts no chain of refreshes.
3877 template <typename Handler>
3878 void send(network::HttpRequest request, Handler handler) {
3879 std::string authorization;
3880 for (const auto& [name, value] : request.headers_) {
3881 if (name == "Authorization") {
3882 authorization = value;
3883 }
3884 }
3885 http_->async_request(
3886 std::move(request),
3887 [
3888 self = shared_from_this(),
3889 authorization = std::move(authorization),
3890 handler = std::move(handler)
3891 ](Result<network::HttpResponse> result) mutable {
3892 if (result && result.value().result_int() == 401) {
3893 // Queued before the handler runs; every operation starts with a post to the strand, so
3894 // the next one finds the token gone.
3895 net::post(self->strand_, [self, authorization = std::move(authorization)] {
3896 self->forget_refused_token(authorization);
3897 });
3898 }
3899 std::move(handler)(std::move(result));
3900 }
3901 );
3902 }
3903
3904 // Forgets the held token if it is the one a refused request carried (its `Authorization` value).
3905 void forget_refused_token(const std::string& authorization) {
3906 if (!token_.access_token_.empty() && authorization == "Bearer " + token_.access_token_) {
3907 token_ = TossRest{};
3908 }
3909 }
3910
3911 // Dispatches one result through its associated executor, exactly once.
3912 //
3913 // Every operation's result passes through here, which makes it the one place a
3914 // broker's own rate-limit refusal can be fed back into this connection's pacer
3915 // without each operation having to remember to do it.
3916 template <typename Handler, typename T>
3917 void deliver(Handler handler, Result<T> result) {
3918 if (!result) {
3919 note_rate_limit(result.error());
3920 }
3921 auto executor = net::get_associated_executor(handler, strand_);
3922 net::dispatch(executor, [handler = std::move(handler), result = std::move(result)]() mutable {
3923 std::move(handler)(std::move(result));
3924 });
3925 }
3926
3927 // Corrects this connection's pacing from a Toss rate-limit refusal.
3928 //
3929 // Toss documents a token bucket and reports `X-RateLimit-Limit` with its
3930 // refusal, so the allowance it is enforcing is known and is applied directly.
3931 // The headers are documented on the refusal rather than promised on every
3932 // response, so an absent one only holds admission rather than guessing an allowance.
3933 void note_rate_limit(const BrokerError& error) {
3934 if (!error_category_is_rate_limit(error.category_)) {
3935 return;
3936 }
3937 auto* limiter = http_->rate_limits();
3938 if (!limiter) {
3939 return;
3940 }
3941 const auto retry = error.retry_after_ ?
3942 std::chrono::duration_cast<network::RateLimitController::Duration>(*error.retry_after_) :
3943 std::chrono::duration_cast<network::RateLimitController::Duration>(std::chrono::seconds(1));
3944 if (error.rate_limit_) {
3945 limiter->shrink(*error.rate_limit_, *error.rate_limit_, retry);
3946 return;
3947 }
3948
3949 // The headers are documented on the refusal rather than promised on it, so an
3950 // absent allowance means waiting rather than guessing a smaller one.
3951 limiter->hold(retry);
3952 }
3953
3954 void complete_order_ack(OrderAckHandler handler, Result<OrderAck> result) {
3955 deliver(std::move(handler), std::move(result));
3956 }
3957
3958 // Dispatches an order-list result through its associated executor.
3959 void complete_order_page(OrderPageHandler handler, Result<OrderPage> result) {
3960 deliver(std::move(handler), std::move(result));
3961 }
3962
3963 // Dispatches a single-order result through its associated executor.
3964 void complete_order(OrderHandler handler, Result<OrderSnapshot> result) {
3965 deliver(std::move(handler), std::move(result));
3966 }
3967
3968 void complete(QuoteHandler handler, Result<QuoteSnapshot> result) {
3969 deliver(std::move(handler), std::move(result));
3970 }
3971
3972 void complete_accounts(AccountsHandler handler, Result<std::vector<TossAccount>> result) {
3973 deliver(std::move(handler), std::move(result));
3974 }
3975
3976 void complete_holdings(HoldingsHandler handler, Result<HoldingsSnapshot> result) {
3977 deliver(std::move(handler), std::move(result));
3978 }
3979
3980 void complete_buying_power(BuyingPowerHandler handler, Result<BuyingPower> result) {
3981 deliver(std::move(handler), std::move(result));
3982 }
3983
3984 void complete_sellable_quantity(SellableQuantityHandler handler, Result<Decimal64> result) {
3985 deliver(std::move(handler), std::move(result));
3986 }
3987
3988 void complete_commissions(CommissionScheduleHandler handler, Result<CommissionSchedule> result) {
3989 deliver(std::move(handler), std::move(result));
3990 }
3991};
3992
3995public:
4003 ssl::context& context,
4004 TossConfig config,
4005 network::HttpClientOptions options = {}
4006 )
4007 : client_(
4008 std::make_shared<TossClient>(ioc_.get_executor(), context, std::move(config), options)
4009 ) {}
4010
4017 return run<QuoteSnapshot>(
4018 [&](auto&& sink) { client_->async_quote(std::move(instrument), std::move(sink)); },
4019 "quote"
4020 );
4021 }
4022
4028 return run<std::vector<TossAccount>>(
4029 [&](auto&& sink) { client_->async_accounts(std::move(sink)); },
4030 "accounts"
4031 );
4032 }
4033
4040 return run<Instrument>(
4041 [&](auto&& sink) { client_->async_instrument(std::move(subject), std::move(sink)); },
4042 "instrument"
4043 );
4044 }
4045
4052 Result<TradingCalendar> calendar(Country country, std::optional<Date> from = std::nullopt) {
4053 return run<TradingCalendar>(
4054 [&](auto&& sink) { client_->async_calendar(country, from, std::move(sink)); },
4055 "calendar"
4056 );
4057 }
4058
4065 return run<PriceLimits>(
4066 [&](auto&& sink) { client_->async_price_limits(std::move(instrument), std::move(sink)); },
4067 "price_limits"
4068 );
4069 }
4070
4078 return run<std::vector<TradePrint>>(
4079 [&](auto&& sink) {
4080 client_->async_trades(std::move(instrument), count, std::move(sink));
4081 },
4082 "trades"
4083 );
4084 }
4085
4091 Result<std::vector<QuoteSnapshot>> prices(std::vector<InstrumentId> instruments) {
4092 return run<std::vector<QuoteSnapshot>>(
4093 [&](auto&& sink) { client_->async_prices(std::move(instruments), std::move(sink)); },
4094 "prices"
4095 );
4096 }
4097
4104 return run<DepthSnapshot>(
4105 [&](auto&& sink) { client_->async_depth(std::move(instrument), std::move(sink)); },
4106 "depth"
4107 );
4108 }
4109
4116 return run<BarSeries>(
4117 [&](auto&& sink) { client_->async_bars(std::move(query), std::move(sink)); },
4118 "bars"
4119 );
4120 }
4121
4127 Result<HoldingsSnapshot> holdings(std::int64_t account_sequence) {
4128 return run<HoldingsSnapshot>(
4129 [&](auto&& sink) { client_->async_holdings(account_sequence, std::move(sink)); },
4130 "holdings"
4131 );
4132 }
4133
4140 Result<BuyingPower> buying_power(Currency currency, std::int64_t account_sequence) {
4141 return run<BuyingPower>(
4142 [&](auto&& sink) {
4143 client_->async_buying_power(currency, account_sequence, std::move(sink));
4144 },
4145 "buying_power"
4146 );
4147 }
4148
4156 return run<Decimal64>(
4157 [&](auto&& sink) {
4158 client_->async_sellable_quantity(std::move(instrument), account_sequence, std::move(sink));
4159 },
4160 "sellable_quantity"
4161 );
4162 }
4163
4169 Result<CommissionSchedule> commissions(std::int64_t account_sequence) {
4170 return run<CommissionSchedule>(
4171 [&](auto&& sink) { client_->async_commissions(account_sequence, std::move(sink)); },
4172 "commissions"
4173 );
4174 }
4175
4187 Result<OrderAck> place_order(std::int64_t account_sequence, OrderRequest order) {
4188 return run<OrderAck>(
4189 [&](auto&& sink) {
4190 client_->async_place_order(account_sequence, std::move(order), std::move(sink));
4191 },
4192 "order placement"
4193 );
4194 }
4195
4203 Result<OrderAck> replace_order(std::int64_t account_sequence, TossAmendment amendment) {
4204 return run<OrderAck>(
4205 [&](auto&& sink) {
4206 client_->async_replace_order(account_sequence, std::move(amendment), std::move(sink));
4207 },
4208 "order modification"
4209 );
4210 }
4211
4219 Result<OrderAck> cancel_order(std::int64_t account_sequence, std::string order_id) {
4220 return run<OrderAck>(
4221 [&](auto&& sink) {
4222 client_->async_cancel_order(account_sequence, std::move(order_id), std::move(sink));
4223 },
4224 "order cancellation"
4225 );
4226 }
4227
4234 return run<OrderPage>(
4235 [&](auto&& sink) { client_->async_orders(std::move(query), std::move(sink)); },
4236 "order listing"
4237 );
4238 }
4239
4246 Result<OrderSnapshot> order(std::int64_t account_sequence, std::string order_id) {
4247 return run<OrderSnapshot>(
4248 [&](auto&& sink) {
4249 client_->async_order(account_sequence, std::move(order_id), std::move(sink));
4250 },
4251 "order inquiry"
4252 );
4253 }
4254
4260 return client_->rate_limit_stats();
4261 }
4262
4263private:
4264 // Drives one asynchronous operation to completion on the private loop.
4265 template <typename T, typename Start>
4266 Result<T> run(Start start, std::string_view operation) {
4267 std::optional<Result<T>> result;
4268 ioc_.restart();
4269 start([&result](Result<T> value) { result.emplace(std::move(value)); });
4270 ioc_.run();
4271 if (!result) {
4272 BrokerError error;
4274 error.broker_code_ = "event_loop_stopped";
4275 error.message_ = "event loop stopped before " + std::string(operation) + " completion";
4276 return Result<T>(std::move(error));
4277 }
4278 return std::move(*result);
4279 }
4280
4281 net::io_context ioc_;
4282 std::shared_ptr<TossClient> client_;
4283};
4284
4285} // namespace algotradelib::tossinvest
static std::optional< Code > try_make(std::string_view text)
Makes a code without throwing.
Definition types.hpp:163
A three-letter currency code, such as KRW or USD (ISO 4217 style).
Definition types.hpp:234
static Currency usd()
Returns United States dollars.
Definition types.hpp:268
Currency()
Constructs Korean won.
Definition types.hpp:237
static Currency krw()
Returns Korean won.
Definition types.hpp:262
static Decimal64 parse(std::string_view input)
Parses a decimal string exactly (no binary floating point).
Definition types.hpp:323
Either a value or a BrokerError.
Definition types.hpp:797
Result(T value)
Constructs a successful result.
Definition types.hpp:803
static StatedDate unreadable()
Makes an unreadable date.
static StatedDate stated(Date date)
Makes a stated date.
Result< OrderAck > replace_order(std::int64_t account_sequence, TossAmendment amendment)
Amends an open order; the outcome can be unknown after an error. The acknowledgement carries a new id...
Definition client.hpp:4203
Result< BuyingPower > buying_power(Currency currency, std::int64_t account_sequence)
Returns what the account may spend in currency.
Definition client.hpp:4140
TossBlockingClient(ssl::context &context, TossConfig config, network::HttpClientOptions options={})
Constructs a blocking Toss Securities client.
Definition client.hpp:4002
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics (safe to read between calls).
Definition client.hpp:4259
Result< Decimal64 > sellable_quantity(InstrumentId instrument, std::int64_t account_sequence)
Returns how much of instrument the account may sell.
Definition client.hpp:4155
Result< OrderPage > orders(TossOrderQuery query)
Returns one page of orders; see TossClient::async_orders for which orders are listed.
Definition client.hpp:4233
Result< Instrument > instrument(InstrumentId subject)
Returns static reference data for one instrument.
Definition client.hpp:4039
Result< OrderAck > place_order(std::int64_t account_sequence, OrderRequest order)
Places an order; see TossClient::async_place_order.
Definition client.hpp:4187
Result< DepthSnapshot > depth(InstrumentId instrument)
Returns the resting orders on both sides of the book.
Definition client.hpp:4103
Result< HoldingsSnapshot > holdings(std::int64_t account_sequence)
Returns normalized positions and per-currency totals for one account.
Definition client.hpp:4127
Result< std::vector< QuoteSnapshot > > prices(std::vector< InstrumentId > instruments)
Returns a quote for every instrument in one request.
Definition client.hpp:4091
Result< QuoteSnapshot > quote(InstrumentId instrument)
Returns a quote for a Korean instrument (a US instrument is refused; use prices).
Definition client.hpp:4016
Result< CommissionSchedule > commissions(std::int64_t account_sequence)
Returns the commission rates in force for the account.
Definition client.hpp:4169
Result< OrderAck > cancel_order(std::int64_t account_sequence, std::string order_id)
Cancels an open order; the outcome can be unknown after an error. The acknowledgement carries a new i...
Definition client.hpp:4219
Result< PriceLimits > price_limits(InstrumentId instrument)
Returns today's upper and lower price bound.
Definition client.hpp:4064
Result< std::vector< TradePrint > > trades(InstrumentId instrument, int count)
Returns recent executions for one instrument.
Definition client.hpp:4077
Result< std::vector< TossAccount > > accounts()
Discovers the brokerage accounts available to the current credentials.
Definition client.hpp:4027
Result< OrderSnapshot > order(std::int64_t account_sequence, std::string order_id)
Returns one order by id; see TossClient::async_order for which orders can be read.
Definition client.hpp:4246
Result< BarSeries > bars(BarQuery query)
Returns up to count_ bars; see TossClient::async_bars.
Definition client.hpp:4115
Result< TradingCalendar > calendar(Country country, std::optional< Date > from=std::nullopt)
Returns the trading calendar around from for one market.
Definition client.hpp:4052
The asynchronous Toss Securities REST client: market data, accounts and orders.
Definition client.hpp:146
auto async_commissions(std::int64_t account_sequence, CompletionToken &&token)
Requests the commission rates in force for the account.
Definition client.hpp:516
auto async_instrument(InstrumentId instrument, CompletionToken &&token)
Requests static reference data for one instrument.
Definition client.hpp:274
auto async_orders(TossOrderQuery query, CompletionToken &&token)
Requests a page of orders.
Definition client.hpp:706
net::any_completion_handler< void(std::optional< BrokerError >)> TokenHandler
Internal completion handler for a shared access-token refresh.
Definition client.hpp:193
auto async_trades(InstrumentId instrument, int count, CompletionToken &&token)
Requests recent executions for one instrument, newest first.
Definition client.hpp:331
net::any_completion_handler< void(Result< HoldingsSnapshot >)> HoldingsHandler
Completion handler for one normalized holdings snapshot.
Definition client.hpp:175
net::any_completion_handler< void(Result< BarSeries >)> BarsHandler
Completion handler for one normalized bar series.
Definition client.hpp:152
TossClient(net::any_io_executor executor, ssl::context &context, TossConfig config, network::HttpClientOptions options={})
Constructs a client; nothing is sent until the first request.
Definition client.hpp:202
net::any_completion_handler< void(Result< std::vector< QuoteSnapshot > >)> PricesHandler
Completion handler for several instruments quoted at once.
Definition client.hpp:164
net::any_completion_handler< void(Result< CommissionSchedule >)> CommissionScheduleHandler
Completion handler for the account's commission schedule.
Definition client.hpp:181
net::any_completion_handler< void(Result< DepthSnapshot >)> DepthHandler
Completion handler for one normalized order book.
Definition client.hpp:155
auto async_quote(InstrumentId instrument, CompletionToken &&token)
Requests a quote for a Korean instrument (a US instrument is refused before sending; use async_prices...
Definition client.hpp:414
net::any_completion_handler< void(Result< std::vector< TossAccount > >)> AccountsHandler
Completion handler for Toss account discovery.
Definition client.hpp:172
net::any_completion_handler< void(Result< BuyingPower >)> BuyingPowerHandler
Completion handler for one normalized buying-power reading.
Definition client.hpp:177
auto async_bars(BarQuery query, CompletionToken &&token)
Requests up to count_ bars, oldest first.
Definition client.hpp:225
auto async_holdings(std::int64_t account_sequence, CompletionToken &&token)
Requests and normalizes holdings for one discovered account sequence.
Definition client.hpp:539
net::any_completion_handler< void(Result< QuoteSnapshot >)> QuoteHandler
Completion handler for one normalized quote.
Definition client.hpp:149
net::any_completion_handler< void(Result< OrderPage >)> OrderPageHandler
Completion handler for one normalized page of orders.
Definition client.hpp:187
net::any_completion_handler< void(Result< TradingCalendar >)> CalendarHandler
Completion handler for one trading calendar.
Definition client.hpp:167
auto async_accounts(CompletionToken &&token)
Discovers the brokerage accounts available to the current credentials.
Definition client.hpp:436
net::any_completion_handler< void(Result< PriceLimits >)> PriceLimitsHandler
Completion handler for one instrument's price bounds.
Definition client.hpp:158
net::any_completion_handler< void(Result< OrderAck >)> OrderAckHandler
Completion handler for one accepted Toss order instruction.
Definition client.hpp:184
net::any_completion_handler< void(Result< OrderSnapshot >)> OrderHandler
Completion handler for one normalized order.
Definition client.hpp:190
void close()
Cancels transport work and queued HTTP operations.
Definition client.hpp:771
auto async_cancel_order(std::int64_t account_sequence, std::string order_id, CompletionToken &&token)
Cancels an open order.
Definition client.hpp:664
auto async_place_order(std::int64_t account_sequence, OrderRequest order, CompletionToken &&token)
Places an order.
Definition client.hpp:578
auto async_depth(InstrumentId instrument, CompletionToken &&token)
Requests and normalizes the resting orders on both sides of the book.
Definition client.hpp:390
auto async_buying_power(Currency currency, std::int64_t account_sequence, CompletionToken &&token)
Requests the account's buying power in a currency.
Definition client.hpp:460
net::any_completion_handler< void(Result< Instrument >)> InstrumentHandler
Completion handler for one instrument's reference data.
Definition client.hpp:170
net::any_completion_handler< void(Result< Decimal64 >)> SellableQuantityHandler
Completion handler for one instrument's sellable quantity.
Definition client.hpp:179
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics.
Definition client.hpp:765
auto async_calendar(Country country, std::optional< Date > from, CompletionToken &&token)
Requests the trading calendar around a date: that date and the business days either side of it (three...
Definition client.hpp:251
auto async_prices(std::vector< InstrumentId > instruments, CompletionToken &&token)
Quotes up to 200 instruments in one request.
Definition client.hpp:366
auto async_price_limits(InstrumentId instrument, CompletionToken &&token)
Requests today's upper and lower price bound for one instrument.
Definition client.hpp:302
auto async_order(std::int64_t account_sequence, std::string order_id, CompletionToken &&token)
Requests one order by id, in any state.
Definition client.hpp:735
auto async_sellable_quantity(InstrumentId instrument, std::int64_t account_sequence, CompletionToken &&token)
Requests how much of one instrument the account may sell.
Definition client.hpp:484
net::any_completion_handler< void(Result< std::vector< TradePrint > >)> TradesHandler
Completion handler for recent executions.
Definition client.hpp:161
auto async_replace_order(std::int64_t account_sequence, TossAmendment amendment, CompletionToken &&token)
Amends an open order's price, and for a Korean order its quantity.
Definition client.hpp:621
HTTP and WebSocket transport, OAuth tokens, and request pacing.
@ MARKET_DATA
Quotes, bars, order book and trades.
@ ACCOUNT
Holdings, working orders, order history and buying power.
@ TRADING
Order placement, amendment and cancellation.
@ REFERENCE
Instrument reference data and trading calendars.
@ AUTH
Access-token issue and refresh.
http::response< http::string_body > HttpResponse
The HTTP response type the transport clients return.
void quote(S &strategy, Ctx &context, const QuoteEvent &event)
Calls strategy.on_quote(context, event) if the strategy declares on_quote; otherwise does nothing.
Definition concept.hpp:240
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
void bars(S &strategy, Ctx &context, const BarSlice &slice)
Calls strategy.on_bars(context, slice) if the strategy declares on_bars; otherwise does nothing.
Definition concept.hpp:218
The Toss Securities Open API client.
Definition auth.hpp:9
std::optional< BrokerError > unsupported_bar_interval(BarInterval interval)
Refuses a bar interval Toss does not publish (it publishes one-minute and one-day bars only; nothing ...
Definition client.hpp:123
TossOrderScope
Which orders a Toss order listing returns.
Definition client.hpp:41
@ CLOSED
Ended orders (including a partly filled order that ended).
Definition client.hpp:45
@ OPEN
Open orders: pending, partly filled, or with an amendment or cancellation pending.
Definition client.hpp:43
std::optional< std::chrono::seconds > retry_after(const network::HttpResponse &response)
Reads the Retry-After header of a Toss rate-limit refusal.
Definition client.hpp:96
std::optional< int > reported_rate_limit(const network::HttpResponse &response)
Reads the X-RateLimit-Limit header of a Toss rate-limit refusal (requests per second).
Definition client.hpp:109
PriceRule price_rule_for(Country country, SecurityType type)
Returns the library's price grid for a kind of security in a country.
@ CLOSE
The timestamp is the instant the period ended.
@ OPEN
The timestamp is the first instant of the period.
MarketSegment market_segment(std::string_view code)
Maps a broker's segment code to a MarketSegment.
SecurityType
What kind of security an instrument is.
OrderStatus
Where an order stands in its lifecycle.
Definition types.hpp:1879
@ BROKER_PUBLISHED
The broker published them, through an inquiry the library made.
Definition types.hpp:1376
std::optional< std::chrono::system_clock::time_point > parse_iso8601_timestamp(std::string_view value)
Parses an ISO 8601 timestamp, YYYY-MM-DDTHH:MM:SS[.fraction] followed by Z or a +hh:mm/-hh:mm offset.
Definition types.hpp:2478
@ RATE_LIMIT
The broker refused the request for its rate limit.
Definition types.hpp:697
@ PROTOCOL
The reply could not be read, or the broker declared a failure this client does not classify further: ...
Definition types.hpp:704
@ AUTHENTICATION
The credentials or token were refused.
Definition types.hpp:695
@ TRANSPORT
The connection failed (DNS, connect, TLS, write or read).
Definition types.hpp:691
@ INVALID_ARGUMENT
The request was invalid, refused by the client before sending or by the broker.
Definition types.hpp:713
OrderType
How an order's price is decided (its price basis).
Definition types.hpp:1707
@ LIMIT
지정가: fills at the stated price or better.
Definition types.hpp:1710
@ MARKET
시장가: fills at whatever the book offers.
Definition types.hpp:1712
InstrumentStatus
Whether an instrument is listed, awaiting listing, or delisted.
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
OrderSession
The trading session an order is submitted into.
Definition types.hpp:1839
@ OPENING_AUCTION
The opening single-price auction (시가단일가; Toss OPG).
Definition types.hpp:1844
@ AFTER_HOURS
After the close, at that session's close (장후 시간외).
Definition types.hpp:1850
@ CLOSING_AUCTION
The closing auction (Toss CLS, a US limit-on-close).
Definition types.hpp:1846
@ REGULAR
The continuous regular session.
Definition types.hpp:1842
@ AFTER_MARKET
KRX's after-market session (애프터마켓).
Definition types.hpp:1854
@ AFTER_HOURS_AUCTION
The after-hours single-price auction (시간외 단일가).
Definition types.hpp:1852
@ PRE_MARKET
Before the open, at the previous session's close (장전 시간외).
Definition types.hpp:1848
@ GTP
NXT's GTP session.
Definition types.hpp:1856
Venue
A trading venue: an exchange, or the combined view across exchanges.
Definition types.hpp:43
BarInterval
The period one bar covers.
bool is_valid_date(const Date &date)
Tests whether a date exists in the Gregorian calendar, in years 1 to 9999.
Definition types.hpp:1212
TimeInForce
How long an order stays valid and what happens to an unfilled remainder.
Definition types.hpp:1797
@ DAY
Rests until the session ends; an unfilled remainder is cancelled then.
Definition types.hpp:1800
OrderSide
The side of an order: its economic direction, buy or sell.
Definition types.hpp:1683
void order_depth(DepthSnapshot &book)
Sorts a book's levels best first: bids descending, asks ascending.
What bars to fetch.
std::optional< std::chrono::system_clock::time_point > before_
The newest bar time to return, inclusive; pass the oldest timestamp seen to page backwards.
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
Identifies an instrument: its country, venue and symbol.
Definition types.hpp:78
An order to place, in broker-neutral form.
Definition types.hpp:1925
std::string access_token_
The bearer token; do not log it.
Definition oauth.hpp:133
Deadlines, size limits and pacing for an HTTP client.
A rate limiter's counters per traffic class, its current budget, and the broker's refusals (from Rate...
A Toss account, as account discovery (async_accounts) returns it.
Definition client.hpp:29
std::int64_t sequence_
Toss's account sequence, which every account request names.
Definition client.hpp:33
std::string account_type_
The account type as Toss reports it, for display.
Definition client.hpp:35
std::string account_number_
The account number as Toss reports it (accountNo): the full number, not masked.
Definition client.hpp:31
An amendment of a Toss order: a new price, and for a Korean order a new quantity.
Definition client.hpp:78
std::string order_id_
Toss's order id.
Definition client.hpp:80
bool confirm_high_value_
Confirms a high-value order, which Toss otherwise refuses.
Definition client.hpp:88
std::optional< Decimal64 > price_
The new price.
Definition client.hpp:86
std::optional< Decimal64 > quantity_
The new quantity: required for a Korean order, empty for a US one.
Definition client.hpp:84
OrderType type_
The order type it should have.
Definition client.hpp:82
Toss Securities credentials (client id and secret). Toss has no demo environment.
Definition config.hpp:24
A Toss order-listing query.
Definition client.hpp:55
std::optional< Date > to_
The last order date to include; before from_ is refused.
Definition client.hpp:65
std::optional< Date > from_
The first order date to include (Korean date; any scope).
Definition client.hpp:63
std::optional< std::string > symbol_
Only this symbol, if set.
Definition client.hpp:61
std::int64_t account_sequence_
The account (TossAccount::sequence_).
Definition client.hpp:57
std::optional< std::string > cursor_
The cursor from a previous page (OrderPage::next_cursor_); ignored for OPEN.
Definition client.hpp:67
TossOrderScope scope_
Open or closed orders.
Definition client.hpp:59
std::optional< int > limit_
The most rows to return, 1 to 100 (outside that range is refused, even for OPEN); not sent for OPEN.
Definition client.hpp:69
A Toss REST access token and its expiry.
Definition config.hpp:88