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 <array>
14#include <cstdlib>
15#include <deque>
16#include <fstream>
17#include <iterator>
18#include <memory>
19#include <optional>
20#include <stdexcept>
21#include <string>
22#include <string_view>
23#include <utility>
24#include <vector>
25
26namespace algotradelib::kis {
27
29struct KisAccount {
31 std::string account_number_;
33 std::string product_code_;
34
40 void from_env() {
41 const char* account_number = std::getenv("KIS_CANO");
42 if (!account_number) {
43 throw std::runtime_error("KIS_CANO not set (8-digit account number)");
44 }
45 const char* product_code = std::getenv("KIS_ACNT_PRDT_CD");
46 account_number_ = account_number;
47 product_code_ = product_code ? product_code : "01";
48 }
49
61 void from_json(const std::string& filename) {
62 std::ifstream ifs(filename);
63 if (!ifs.is_open()) {
64 throw std::runtime_error("Cannot open KIS credential file: " + filename);
65 }
66 const std::string content(
67 std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>{}
68 );
69 boost::system::error_code ec;
70 const auto document = boost::json::parse(content, ec);
71 if (ec || !document.is_object()) {
72 throw std::runtime_error("Failed to parse KIS credentials file: " + filename);
73 }
74 const auto field = [&](const char* name, std::size_t digits) -> std::optional<std::string> {
75 const auto* value = document.as_object().if_contains(name);
76 if (value == nullptr) {
77 return std::nullopt;
78 }
79 if (!value->is_string() || !broker_detail::decimal_digits(value->get_string(), digits)) {
80 throw std::runtime_error(
81 "KIS credential file " + filename + " has a " + name + " that is not " +
82 std::to_string(digits) + " digits in a string"
83 );
84 }
85 return std::string(value->get_string());
86 };
87 auto number = field("account_number", 8);
88 if (!number) {
89 throw std::runtime_error("KIS credential file " + filename + " has no account_number");
90 }
91 const auto product = field("account_product_code", 2);
92 account_number_ = std::move(*number);
93 product_code_ = product ? *product : "01";
94 }
95};
96
100 std::string branch_number_;
102 std::string order_number_;
103};
104
141
156
179
183 std::vector<KisOpenOrder> orders_;
185 std::chrono::system_clock::time_point received_at_;
186};
187
195
201 const auto seoul = std::chrono::system_clock::now() + std::chrono::hours(9);
202 const std::time_t raw = std::chrono::system_clock::to_time_t(
203 std::chrono::time_point_cast<std::chrono::seconds>(seoul)
204 );
205 std::tm parts{};
206#ifdef _WIN32
207 gmtime_s(&parts, &raw);
208#else
209 gmtime_r(&raw, &parts);
210#endif
211 return Date{
212 parts.tm_year + 1900,
213 static_cast<unsigned>(parts.tm_mon + 1),
214 static_cast<unsigned>(parts.tm_mday),
215 };
216}
217
230
239inline KisRateLimitLayer kis_rate_limit_layer(std::string_view code) {
240 if (code == "EGW00201") {
242 }
243 return code == "EGW00215" ? KisRateLimitLayer::LEDGER : KisRateLimitLayer::NONE;
244}
245
257inline std::optional<BrokerError> unsupported_client_order_id(const OrderRequest& order) {
258 if (!order.client_order_id_) {
259 return std::nullopt;
260 }
261 BrokerError error;
263 error.reached_broker_ = false;
264 error.broker_code_ = "idempotency_key_unsupported";
265 error.message_ =
266 "KIS offers no client-supplied idempotency key for individual customers; clear "
267 "client_order_id_ and reconcile through an order inquiry after a transport failure";
268 return error;
269}
270
277inline constexpr bool publishes_bar_interval(BarInterval interval) noexcept {
278 switch (interval) {
279 case BarInterval::ONE_MINUTE:
280 case BarInterval::ONE_DAY:
281 case BarInterval::ONE_WEEK:
282 case BarInterval::ONE_MONTH:
283 case BarInterval::ONE_YEAR:
284 return true;
289 return false;
290 }
291 return false;
292}
293
299inline std::optional<BrokerError> unsupported_bar_interval(BarInterval interval) {
300 if (publishes_bar_interval(interval)) {
301 return std::nullopt;
302 }
303 BrokerError error;
305 error.reached_broker_ = false;
306 error.broker_code_ = "unsupported_interval";
307 error.message_ = std::string("KIS publishes no chart for ") + std::string(bar_interval_name(interval)) +
308 " bars; its minute chart is one minute only";
309 return error;
310}
311
324class KisClient : public std::enable_shared_from_this<KisClient> {
325public:
327 using QuoteHandler = net::any_completion_handler<void(Result<QuoteSnapshot>)>;
328
330 using BarsHandler = net::any_completion_handler<void(Result<BarSeries>)>;
331
333 using DepthHandler = net::any_completion_handler<void(Result<DepthSnapshot>)>;
334
336 using PriceLimitsHandler = net::any_completion_handler<void(Result<PriceLimits>)>;
337
339 using TradesHandler = net::any_completion_handler<void(Result<std::vector<TradePrint>>)>;
340
342 using CalendarHandler = net::any_completion_handler<void(Result<TradingCalendar>)>;
343
345 using InstrumentHandler = net::any_completion_handler<void(Result<Instrument>)>;
346
348 using HoldingsHandler = net::any_completion_handler<void(Result<HoldingsSnapshot>)>;
350 using BuyingPowerHandler = net::any_completion_handler<void(Result<BuyingPower>)>;
351
353 using OrderAckHandler = net::any_completion_handler<void(Result<KisOrderAck>)>;
354
356 using OpenOrderHandler = net::any_completion_handler<void(Result<KisOpenOrderPage>)>;
357
359 using OrderPageHandler = net::any_completion_handler<void(Result<OrderPage>)>;
360
362 using TokenHandler = net::any_completion_handler<void(std::optional<BrokerError>)>;
363
372 net::any_io_executor executor,
373 ssl::context& context,
374 KisConfig config,
375 network::HttpClientOptions options = {}
376 )
377 : strand_(net::make_strand(std::move(executor))), config_(std::move(config)),
378 http_(
379 std::make_shared<network::AsyncHttpClient>(
380 strand_,
381 context,
382 config_.rest_host(),
383 config_.rest_port(),
384 options
385 )
386 ) {}
387
403 template <typename CompletionToken>
404 auto async_bars(BarQuery query, CompletionToken&& token) {
405 return net::async_initiate<CompletionToken, void(Result<BarSeries>)>(
406 [self = shared_from_this(), query = std::move(query)](auto&& handler) mutable {
407 auto completion = BarsHandler(std::forward<decltype(handler)>(handler));
408 net::post(
409 self->strand_,
410 [self, query = std::move(query), completion = std::move(completion)]() mutable {
411 self->start_bars(std::move(query), std::move(completion));
412 }
413 );
414 },
415 token
416 );
417 }
418
426 template <typename CompletionToken>
427 auto async_instrument(InstrumentId instrument, CompletionToken&& token) {
428 return net::async_initiate<CompletionToken, void(Result<Instrument>)>(
429 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
430 auto completion = InstrumentHandler(std::forward<decltype(handler)>(handler));
431 net::post(
432 self->strand_,
433 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
434 self->start_instrument(std::move(instrument), std::move(completion));
435 }
436 );
437 },
438 token
439 );
440 }
441
454 template <typename CompletionToken>
455 auto async_calendar(Date from, bool refresh, CompletionToken&& token) {
456 return net::async_initiate<CompletionToken, void(Result<TradingCalendar>)>(
457 [self = shared_from_this(), from, refresh](auto&& handler) mutable {
458 auto completion = CalendarHandler(std::forward<decltype(handler)>(handler));
459 net::post(
460 self->strand_,
461 [self, from, refresh, completion = std::move(completion)]() mutable {
462 self->start_calendar(from, refresh, std::move(completion));
463 }
464 );
465 },
466 token
467 );
468 }
469
477 template <typename CompletionToken>
478 auto async_price_limits(InstrumentId instrument, CompletionToken&& token) {
479 return net::async_initiate<CompletionToken, void(Result<PriceLimits>)>(
480 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
481 auto completion = PriceLimitsHandler(std::forward<decltype(handler)>(handler));
482 net::post(
483 self->strand_,
484 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
485 self->start_market_read(
486 std::move(instrument),
487 "/uapi/domestic-stock/v1/quotations/inquire-price",
488 "FHKST01010100",
489 std::move(completion),
490 parse_price_limits
491 );
492 }
493 );
494 },
495 token
496 );
497 }
498
508 template <typename CompletionToken>
509 auto async_trades(InstrumentId instrument, int count, CompletionToken&& token) {
510 return net::async_initiate<CompletionToken, void(Result<std::vector<TradePrint>>)>(
511 [self = shared_from_this(), instrument = std::move(instrument), count](
512 auto&& handler
513 ) mutable {
514 auto completion = TradesHandler(std::forward<decltype(handler)>(handler));
515 net::post(
516 self->strand_,
517 [self, instrument = std::move(instrument), count, completion = std::move(completion)]()
518 mutable {
519 self->start_market_read(
520 std::move(instrument),
521 "/uapi/domestic-stock/v1/quotations/inquire-ccnl",
522 "FHKST01010300",
523 std::move(completion),
524 [count](InstrumentId subject, Result<network::HttpResponse> response) {
525 return parse_trades(std::move(subject), std::move(response), count);
526 }
527 );
528 }
529 );
530 },
531 token
532 );
533 }
534
542 template <typename CompletionToken>
543 auto async_depth(InstrumentId instrument, CompletionToken&& token) {
544 return net::async_initiate<CompletionToken, void(Result<DepthSnapshot>)>(
545 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
546 auto completion = DepthHandler(std::forward<decltype(handler)>(handler));
547 net::post(
548 self->strand_,
549 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
550 self->start_depth(std::move(instrument), std::move(completion));
551 }
552 );
553 },
554 token
555 );
556 }
557
565 template <typename CompletionToken>
566 auto async_quote(InstrumentId instrument, CompletionToken&& token) {
567 return net::async_initiate<CompletionToken, void(Result<QuoteSnapshot>)>(
568 [self = shared_from_this(), instrument = std::move(instrument)](auto&& handler) mutable {
569 auto completion = QuoteHandler(std::forward<decltype(handler)>(handler));
570 net::post(
571 self->strand_,
572 [self, instrument = std::move(instrument), completion = std::move(completion)]() mutable {
573 self->start_quote(std::move(instrument), std::move(completion));
574 }
575 );
576 },
577 token
578 );
579 }
580
589 template <typename CompletionToken>
590 auto async_buying_power(Currency currency, KisAccount account, CompletionToken&& token) {
591 return net::async_initiate<CompletionToken, void(Result<BuyingPower>)>(
592 [self = shared_from_this(), currency, account = std::move(account)](auto&& handler) mutable {
593 auto completion = BuyingPowerHandler(std::forward<decltype(handler)>(handler));
594 net::post(
595 self->strand_,
596 [self, currency, account = std::move(account), completion = std::move(completion)]()
597 mutable {
598 self->start_buying_power(currency, std::move(account), std::move(completion));
599 }
600 );
601 },
602 token
603 );
604 }
605
613 template <typename CompletionToken>
614 auto async_holdings(KisAccount account, CompletionToken&& token) {
615 return net::async_initiate<CompletionToken, void(Result<HoldingsSnapshot>)>(
616 [self = shared_from_this(), account = std::move(account)](auto&& handler) mutable {
617 auto completion = HoldingsHandler(std::forward<decltype(handler)>(handler));
618 net::post(
619 self->strand_,
620 [self, account = std::move(account), completion = std::move(completion)]() mutable {
621 self->start_holdings(std::move(account), std::move(completion));
622 }
623 );
624 },
625 token
626 );
627 }
628
655 template <typename CompletionToken>
656 auto async_place_order(KisAccount account, OrderRequest order, CompletionToken&& token) {
657 return net::async_initiate<CompletionToken, void(Result<KisOrderAck>)>(
658 [
659 self = shared_from_this(),
660 account = std::move(account),
661 order = std::move(order)
662 ](auto&& handler) mutable {
663 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
664 net::post(
665 self->strand_,
666 [
667 self,
668 account = std::move(account),
669 order = std::move(order),
670 completion = std::move(completion)
671 ]() mutable {
672 self->start_place_order(std::move(account), std::move(order), std::move(completion));
673 }
674 );
675 },
676 token
677 );
678 }
679
691 template <typename CompletionToken>
692 auto async_replace_order(KisAccount account, KisAmendment amendment, CompletionToken&& token) {
693 return net::async_initiate<CompletionToken, void(Result<KisOrderAck>)>(
694 [
695 self = shared_from_this(),
696 account = std::move(account),
697 amendment = std::move(amendment)
698 ](auto&& handler) mutable {
699 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
700 net::post(
701 self->strand_,
702 [
703 self,
704 account = std::move(account),
705 amendment = std::move(amendment),
706 completion = std::move(completion)
707 ]() mutable {
708 self->start_amend_order(
709 std::move(account), std::move(amendment), false, std::move(completion)
710 );
711 }
712 );
713 },
714 token
715 );
716 }
717
736 template <typename CompletionToken>
737 auto async_cancel_order(KisAccount account, KisAmendment amendment, CompletionToken&& token) {
738 return net::async_initiate<CompletionToken, void(Result<KisOrderAck>)>(
739 [
740 self = shared_from_this(),
741 account = std::move(account),
742 amendment = std::move(amendment)
743 ](auto&& handler) mutable {
744 auto completion = OrderAckHandler(std::forward<decltype(handler)>(handler));
745 net::post(
746 self->strand_,
747 [
748 self,
749 account = std::move(account),
750 amendment = std::move(amendment),
751 completion = std::move(completion)
752 ]() mutable {
753 self->start_amend_order(
754 std::move(account), std::move(amendment), true, std::move(completion)
755 );
756 }
757 );
758 },
759 token
760 );
761 }
762
776 template <typename CompletionToken>
777 auto async_open_orders(KisAccount account, CompletionToken&& token) {
778 return net::async_initiate<CompletionToken, void(Result<KisOpenOrderPage>)>(
779 [self = shared_from_this(), account = std::move(account)](auto&& handler) mutable {
780 auto completion = OpenOrderHandler(std::forward<decltype(handler)>(handler));
781 net::post(
782 self->strand_,
783 [self, account = std::move(account), completion = std::move(completion)]() mutable {
784 self->start_open_orders(std::move(account), std::move(completion));
785 }
786 );
787 },
788 token
789 );
790 }
791
804 template <typename CompletionToken>
805 auto async_order_history(KisAccount account, KisHistoryRange range, CompletionToken&& token) {
806 return net::async_initiate<CompletionToken, void(Result<OrderPage>)>(
807 [
808 self = shared_from_this(),
809 account = std::move(account),
810 range
811 ](auto&& handler) mutable {
812 auto completion = OrderPageHandler(std::forward<decltype(handler)>(handler));
813 net::post(
814 self->strand_,
815 [
816 self,
817 account = std::move(account),
818 range,
819 completion = std::move(completion)
820 ]() mutable {
821 self->start_order_history(std::move(account), range, std::move(completion));
822 }
823 );
824 },
825 token
826 );
827 }
828
838 const auto* limiter = http_->rate_limits();
839 return limiter ? &limiter->stats() : nullptr;
840 }
841
853 KisRest token() const { return token_; }
854
869 if (!token.valid()) {
870 return false;
871 }
872 token_ = std::move(token);
873 return true;
874 }
875
882 void close() { http_->close(); }
883
884private:
885 friend struct KisClientTestAccess;
886
887 net::strand<net::any_io_executor> strand_;
888 KisConfig config_;
889 std::shared_ptr<network::AsyncHttpClient> http_;
890 KisRest token_;
891 TradingCalendar calendar_;
892 bool refreshing_{false};
893 std::deque<TokenHandler> token_waiters_;
894
895 struct HoldingsOperation {
896 KisAccount account_;
897 HoldingsSnapshot snapshot_;
898 std::string foreign_cursor_;
899 std::string domestic_cursor_;
900 std::size_t page_count_{0};
901 HoldingsHandler completion_;
902
903 HoldingsOperation(KisAccount selected_account, HoldingsHandler selected_completion)
904 : account_(std::move(selected_account)), completion_(std::move(selected_completion)) {}
905 };
906
907 // One bar request spanning however many KIS pages it needs.
908 struct BarOperation {
909 BarQuery query_;
910 std::vector<Bar> descending_;
911 // Upper bound for the next page, as a KST calendar date and time.
912 Date date_{};
913 std::string time_;
914 int pages_{0};
915 bool minutes_{false};
916 BarsHandler completion_;
917
918 BarOperation(BarQuery query, BarsHandler completion)
919 : query_(std::move(query)), completion_(std::move(completion)) {}
920 };
921
922 // Bars KIS returns per page, per endpoint.
923 static constexpr int period_page_limit_ = 100;
924 static constexpr int today_minute_page_limit_ = 30;
925 static constexpr int daily_minute_page_limit_ = 120;
926
927 // Most pages one bar request may consume, for the reason paging is bounded.
928 static constexpr int bar_page_budget_ = 50;
929
930 // Validates a bar request and starts walking KIS's inputs.
931 void start_bars(BarQuery query, BarsHandler completion) {
932 if (auto invalid = validate_bar_query(query)) {
933 deliver(std::move(completion), Result<BarSeries>(std::move(*invalid)));
934 return;
935 }
936
937 auto operation = std::make_shared<BarOperation>(std::move(query), std::move(completion));
938 operation->minutes_ = operation->query_.interval_ == BarInterval::ONE_MINUTE;
939 const auto bound = operation->query_.before_ ?
940 *operation->query_.before_ :
941 std::chrono::system_clock::now();
942 korea_calendar(bound, operation->date_, operation->time_);
943
944 ensure_token(
945 TokenHandler(
946 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
947 if (token_error) {
948 self->deliver(
949 std::move(operation->completion_), Result<BarSeries>(std::move(*token_error))
950 );
951 return;
952 }
953 self->request_bars(operation);
954 }
955 )
956 );
957 }
958
959 // Returns an error when KIS cannot answer the request as described.
960 static std::optional<BrokerError> validate_bar_query(const BarQuery& query) {
961 BrokerError error;
962 error.category_ = ErrorCategory::INVALID_ARGUMENT;
963 error.reached_broker_ = false;
964 if (query.instrument_.country_ != Country::KR) {
965 error.broker_code_ = "invalid_instrument";
966 error.message_ = "KIS bars currently cover Korean equities only";
967 return error;
968 }
969 if (!valid_domestic_symbol(query.instrument_.symbol_)) {
970 error.broker_code_ = "invalid_instrument";
971 error.message_ =
972 "KIS bar symbol must contain six letters or digits, or seven for an ETN";
973 return error;
974 }
975 if (query.count_ < 1) {
976 error.broker_code_ = "invalid_count";
977 error.message_ = "bar request must ask for at least one bar";
978 return error;
979 }
980 // The session's guard (`BrokerTraits::validate_bars`) asks the same; a caller of this client does not go
981 // through the session.
982 return unsupported_bar_interval(query.interval_);
983 }
984
985 // Returns KIS's `FID_PERIOD_DIV_CODE` for a period interval.
986 static const char* period_code(BarInterval interval) {
987 switch (interval) {
988 case BarInterval::ONE_WEEK:
989 return "W";
990 case BarInterval::ONE_MONTH:
991 return "M";
992 case BarInterval::ONE_YEAR:
993 return "Y";
994 case BarInterval::ONE_DAY:
995 case BarInterval::ONE_MINUTE:
996 // Refused before any request (`unsupported_bar_interval`).
1001 break;
1002 }
1003 return "D";
1004 }
1005
1006 // Returns the fewest calendar days one bar of `interval` can span.
1007 //
1008 // A period request asks KIS for a *date range* and KIS answers with at most 100
1009 // rows, so the range has to be sized to hold about one page of bars. Sizing it in
1010 // bars is not possible and sizing it as a fixed number of days is wrong: 100
1011 // calendar days is at most 100 daily bars, but only about fourteen weekly bars,
1012 // three monthly ones, and a quarter of a yearly one. A fixed span would have made
1013 // a request for a hundred monthly bars take thirty pages and a hundred yearly
1014 // bars exceed the page budget outright, returning a fraction of what was asked
1015 // for and reporting nothing wrong.
1016 //
1017 // The multiplier is the *shortest* a bar of that interval can be, so a range
1018 // never holds more than a page and the row cap cannot truncate it from an end
1019 // this code has not established.
1020 static constexpr int shortest_days_per_bar(BarInterval interval) {
1021 switch (interval) {
1022 case BarInterval::ONE_WEEK:
1023 return 7;
1024 case BarInterval::ONE_MONTH:
1025 return 28;
1026 case BarInterval::ONE_YEAR:
1027 return 365;
1028 case BarInterval::ONE_DAY:
1029 case BarInterval::ONE_MINUTE:
1030 // Refused before any request (`unsupported_bar_interval`).
1035 break;
1036 }
1037 return 1;
1038 }
1039
1040 // Splits an instant into the KST calendar date and `HHMMSS` KIS asks for.
1041 static void korea_calendar(
1042 std::chrono::system_clock::time_point value,
1043 Date& date,
1044 std::string& time
1045 ) {
1046 const auto seoul = value + std::chrono::hours(9);
1047 const std::time_t raw = std::chrono::system_clock::to_time_t(
1048 std::chrono::time_point_cast<std::chrono::seconds>(seoul)
1049 );
1050 std::tm parts{};
1051#ifdef _WIN32
1052 gmtime_s(&parts, &raw);
1053#else
1054 gmtime_r(&raw, &parts);
1055#endif
1056 date.year_ = parts.tm_year + 1900;
1057 date.month_ = static_cast<unsigned>(parts.tm_mon + 1);
1058 date.day_ = static_cast<unsigned>(parts.tm_mday);
1059 char buffer[7];
1060 std::snprintf(buffer, sizeof(buffer), "%02d%02d%02d", parts.tm_hour, parts.tm_min, parts.tm_sec);
1061 time.assign(buffer, 6);
1062 }
1063
1064 // Returns the calendar day before `date`, without a calendar of trading days.
1065 static Date previous_day(const Date& date) {
1066 Date earlier = date;
1067 if (earlier.day_ > 1) {
1068 --earlier.day_;
1069 return earlier;
1070 }
1071 if (earlier.month_ > 1) {
1072 --earlier.month_;
1073 } else {
1074 earlier.month_ = 12;
1075 --earlier.year_;
1076 }
1077 static constexpr unsigned days_per_month[] = {
1078 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
1079 };
1080 unsigned days = days_per_month[earlier.month_ - 1];
1081 const bool leap =
1082 earlier.year_ % 4 == 0 && (earlier.year_ % 100 != 0 || earlier.year_ % 400 == 0);
1083 if (earlier.month_ == 2 && leap) {
1084 ++days;
1085 }
1086 earlier.day_ = days;
1087 return earlier;
1088 }
1089
1090 // Sends the next page, choosing the endpoint the interval and bound imply.
1091 void request_bars(const std::shared_ptr<BarOperation>& operation) {
1092 network::HttpRequest request;
1093 // Bars are the traffic a trading reserve exists to hold back.
1094 request.group_ = network::RateLimitGroup::MARKET_DATA;
1095 request.method_ = http::verb::get;
1096
1097 std::string transaction;
1098 if (!operation->minutes_) {
1099 // A period request selects bars by date range, oldest bound included. The
1100 // range has to be sized in calendar days, and how many calendar days a page
1101 // of bars spans depends entirely on the interval.
1102 Date from = operation->date_;
1103 const int span = period_page_limit_ * shortest_days_per_bar(operation->query_.interval_);
1104 for (int step = 0; step < span && from.year_ > 1; ++step) {
1105 from = previous_day(from);
1106 }
1107 request.target_ =
1108 "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" +
1109 market_query(config_, operation->query_.instrument_.symbol_) +
1110 "&FID_INPUT_DATE_1=" + compact_date(from) +
1111 "&FID_INPUT_DATE_2=" + compact_date(operation->date_) +
1112 "&FID_PERIOD_DIV_CODE=" + period_code(operation->query_.interval_) +
1113 // KIS spells this the other way round from every reading of its name:
1114 // `0` asks for adjusted prices and `1` for raw ones.
1115 "&FID_ORG_ADJ_PRC=" + std::string(operation->query_.adjusted_ ? "0" : "1");
1116 transaction = "FHKST03010100";
1117 } else if (today_in_korea(operation->date_)) {
1118 request.target_ =
1119 "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" +
1120 market_query(config_, operation->query_.instrument_.symbol_) +
1121 "&FID_INPUT_HOUR_1=" + operation->time_ + "&FID_PW_DATA_INCU_YN=Y&FID_ETC_CLS_CODE=";
1122 transaction = "FHKST03010200";
1123 } else {
1124 request.target_ =
1125 "/uapi/domestic-stock/v1/quotations/inquire-time-dailychartprice" +
1126 market_query(config_, operation->query_.instrument_.symbol_) +
1127 "&FID_INPUT_DATE_1=" + compact_date(operation->date_) +
1128 "&FID_INPUT_HOUR_1=" + operation->time_ + "&FID_PW_DATA_INCU_YN=Y&FID_FAKE_TICK_INCU_YN=N";
1129 transaction = "FHKST03010230";
1130 }
1131
1132 request.headers_ = {
1133 {"authorization", "Bearer " + token_.access_token_},
1134 {"appkey", config_.key()},
1135 {"appsecret", config_.secret()},
1136 {"tr_id", transaction},
1137 {"custtype", "P"},
1138 {"content-type", "application/json; charset=utf-8"},
1139 };
1140
1141 ++operation->pages_;
1142 http_->async_request(
1143 std::move(request),
1144 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
1145 self->handle_bars_page(operation, std::move(result));
1146 }
1147 );
1148 }
1149
1150 // Returns whether `date` is today in Seoul, which decides the minute endpoint.
1151 static bool today_in_korea(const Date& date) {
1152 Date today{};
1153 std::string ignored;
1154 korea_calendar(std::chrono::system_clock::now(), today, ignored);
1155 return today == date;
1156 }
1157
1158 // Accumulates one page and either continues or finishes the request.
1159 void handle_bars_page(
1160 const std::shared_ptr<BarOperation>& operation,
1161 Result<network::HttpResponse> result
1162 ) {
1163 const auto before = operation->descending_.size();
1164 if (auto failure = parse_bars_page(
1165 std::move(result), operation->query_, operation->minutes_, operation->descending_
1166 )) {
1167 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(*failure)));
1168 return;
1169 }
1170
1171 const bool empty_page = operation->descending_.size() == before;
1172 const int collected = static_cast<int>(operation->descending_.size());
1173 const bool satisfied = collected >= operation->query_.count_;
1174 const bool spent = operation->pages_ >= bar_page_budget_;
1175 if (satisfied || empty_page || spent) {
1176 finish_bars(operation);
1177 return;
1178 }
1179
1180 // Step the bound to just before the oldest bar this page returned. Within a
1181 // session that walks backwards a page at a time; once the session start is
1182 // passed KIS returns nothing and the empty-page guard above ends the request.
1183 // The date check below is the second line of defence, for a bound that crosses
1184 // midnight: a minute request never steps to another calendar day, because
1185 // choosing the previous *trading* day needs a calendar this request does not have.
1186 const auto& oldest = operation->descending_.back();
1187 Date date{};
1188 std::string time;
1189 korea_calendar(oldest.timestamp_ - std::chrono::seconds(1), date, time);
1190 if (operation->minutes_ && !(date == operation->date_)) {
1191 finish_bars(operation);
1192 return;
1193 }
1194 operation->date_ = date;
1195 operation->time_ = time;
1196 request_bars(operation);
1197 }
1198
1199 // Parses one page into `into`, or returns the error that ends the request.
1200 //
1201 // Takes the accumulation by reference rather than an operation, so the
1202 // normalization is a pure function of a response and can be tested as one.
1203 //
1204 // **A row not strictly older than the oldest already accumulated is dropped.** Pages arrive newest
1205 // first and each is bounded just before the previous page's oldest bar, but KIS's minute bound does not
1206 // always exclude that bar: a read of 005930 before 2026-09-01 13:22 KST (주식일별분봉조회, 120 a page)
1207 // returned the bar at each page join twice, identical, at 11:23 and 09:24, while reads starting at other
1208 // minutes repeated nothing. A series with a repeated timestamp is not a series:
1209 // the runner's bar cache refuses a bar that is not later than the last (`SeriesBuffer::push` throws), so
1210 // a backtest fed it stopped on the first repeat. Dropping the repeat is safe whatever the bound's rule
1211 // is, and a page that brings nothing older counts as empty, which ends the request rather than asking
1212 // for the same page again.
1213 static std::optional<BrokerError> parse_bars_page(
1214 Result<network::HttpResponse> result,
1215 const BarQuery& query,
1216 bool minutes,
1217 std::vector<Bar>& into
1218 ) {
1219 if (!result) {
1220 return std::move(result.error());
1221 }
1222 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
1223 return kis_http_error(result.value());
1224 }
1225 BrokerError parse_error;
1226 auto document = broker_detail::parse_object(result.value(), parse_error);
1227 if (!document) {
1228 return parse_error;
1229 }
1230 try {
1231 if (auto declared = kis_declared_error(*document)) {
1232 return declared;
1233 }
1234 const auto rows = document->if_contains("output2");
1235 if (!rows || !rows->is_array()) {
1236 throw std::runtime_error("missing array field: output2");
1237 }
1238 for (const auto& value : rows->as_array()) {
1239 if (!value.is_object()) {
1240 throw std::runtime_error("bar entry is not an object");
1241 }
1242 const auto& row = value.as_object();
1243 // KIS pads a page with blank rows rather than shortening it.
1244 if (broker_detail::json_string(row, "stck_bsop_date").empty()) {
1245 continue;
1246 }
1247 auto bar = parse_bar(row, query, minutes);
1248 if (!into.empty() && !(bar.timestamp_ < into.back().timestamp_)) {
1249 continue;
1250 }
1251 into.push_back(std::move(bar));
1252 }
1253 return std::nullopt;
1254 } catch (const std::exception& exception) {
1255 BrokerError error;
1256 error.category_ = ErrorCategory::PROTOCOL;
1257 error.broker_code_ = "invalid_bar_response";
1258 error.message_ = exception.what();
1259 return error;
1260 }
1261 }
1262
1263 // Normalizes one KIS bar.
1264 //
1265 // A period bar is reported by its trading date, so its timestamp marks the
1266 // period's open. A minute bar carries `stck_cntg_hour`, the execution time the
1267 // bar was aggregated to, so it marks the close.
1268 //
1269 // `mod_yn` marks a trading day on which nothing traded, so the bar has no open.
1270 // An absent open is preserved as absent rather than becoming zero or the
1271 // previous close, which is the only honest reading of a day with no trade.
1272 static Bar parse_bar(const json::object& row, const BarQuery& query, bool minutes) {
1273 static_cast<void>(query);
1274 Bar bar;
1275 const auto date = broker_detail::json_string(row, "stck_bsop_date");
1276 const auto time = minutes ? broker_detail::json_string(row, "stck_cntg_hour") : "000000";
1277 const auto timestamp = korea_timestamp(date, time);
1278 if (!timestamp) {
1279 throw std::runtime_error("invalid bar timestamp");
1280 }
1281 bar.timestamp_ = *timestamp;
1282 bar.high_ = Decimal64::parse(broker_detail::json_string(row, "stck_hgpr"));
1283 bar.low_ = Decimal64::parse(broker_detail::json_string(row, "stck_lwpr"));
1284 bar.close_ = Decimal64::parse(
1285 broker_detail::json_string(row, minutes ? "stck_prpr" : "stck_clpr")
1286 );
1287 bar.volume_ = Decimal64::parse(
1288 broker_detail::json_string(row, minutes ? "cntg_vol" : "acml_vol")
1289 );
1290 if (const auto value = row.if_contains("acml_tr_pbmn"); value && value->is_string()) {
1291 const std::string traded(value->as_string());
1292 if (!traded.empty()) {
1293 bar.traded_value_ = Decimal64::parse(traded);
1294 }
1295 }
1296 const auto unchanged = row.if_contains("mod_yn");
1297 const bool no_trade =
1298 unchanged && unchanged->is_string() && std::string_view(unchanged->as_string()) == "Y";
1299 if (!no_trade) {
1300 bar.open_ = Decimal64::parse(broker_detail::json_string(row, "stck_oprc"));
1301 }
1302 return bar;
1303 }
1304
1305 // Reverses the accumulation, trims to the requested count, and completes.
1306 void finish_bars(const std::shared_ptr<BarOperation>& operation) {
1307 BarSeries series;
1308 series.instrument_ = operation->query_.instrument_;
1309 series.interval_ = operation->query_.interval_;
1310 // A period bar is reported by its trading date, so its timestamp marks the
1311 // period's open; a minute bar carries the execution time it aggregated to.
1312 series.stamp_ = operation->minutes_ ? BarStamp::CLOSE : BarStamp::OPEN;
1313 series.currency_ = Currency::krw();
1314 series.adjusted_ = operation->query_.adjusted_;
1315 series.received_at_ = std::chrono::system_clock::now();
1316
1317 auto& bars = operation->descending_;
1318 if (static_cast<int>(bars.size()) > operation->query_.count_) {
1319 bars.resize(static_cast<std::size_t>(operation->query_.count_));
1320 }
1321 series.bars_.assign(bars.rbegin(), bars.rend());
1322 if (!series.bars_.empty() && static_cast<int>(series.bars_.size()) < operation->query_.count_) {
1323 series.next_cursor_ = format_iso8601_utc(series.bars_.front().timestamp_);
1324 }
1325 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(series)));
1326 }
1327
1328 // Requests reference data, which KIS addresses differently from market data.
1329 //
1330 // `search-stock-info` takes a product-type code and a product number rather than
1331 // the market division every quotation endpoint takes, so it does not go through
1332 // the shared market-data helper.
1333 void start_instrument(InstrumentId instrument, InstrumentHandler completion) {
1334 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
1335 BrokerError error;
1336 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1337 error.reached_broker_ = false;
1338 error.broker_code_ = "invalid_instrument";
1339 error.message_ = "KIS reference data covers Korean equities addressed by product number";
1340 deliver(std::move(completion), Result<Instrument>(std::move(error)));
1341 return;
1342 }
1343
1344 ensure_token(
1345 TokenHandler(
1346 [
1347 self = shared_from_this(),
1348 instrument = std::move(instrument),
1349 completion = std::move(completion)
1350 ](std::optional<BrokerError> token_error) mutable {
1351 if (token_error) {
1352 self->deliver(std::move(completion), Result<Instrument>(std::move(*token_error)));
1353 return;
1354 }
1355 network::HttpRequest request;
1356 // Reference data is the lowest-priority traffic there is.
1357 request.group_ = network::RateLimitGroup::REFERENCE;
1358 request.method_ = http::verb::get;
1359 // 300 covers 주식, ETF, ETN, and ELW, which is the whole equity universe
1360 // this library addresses.
1361 request.target_ =
1362 "/uapi/domestic-stock/v1/quotations/search-stock-info?PRDT_TYPE_CD=300&PDNO=" +
1363 broker_detail::query_encode(instrument.symbol_);
1364 request.headers_ = {
1365 {"authorization", "Bearer " + self->token_.access_token_},
1366 {"appkey", self->config_.key()},
1367 {"appsecret", self->config_.secret()},
1368 {"tr_id", "CTPF1002R"},
1369 {"custtype", "P"},
1370 {"content-type", "application/json; charset=utf-8"},
1371 };
1372 self->http_->async_request(
1373 std::move(request),
1374 [self, instrument = std::move(instrument), completion = std::move(completion)](
1375 Result<network::HttpResponse> result
1376 ) mutable {
1377 self->deliver(
1378 std::move(completion), parse_instrument(std::move(instrument), std::move(result))
1379 );
1380 }
1381 );
1382 }
1383 )
1384 );
1385 }
1386
1387 // Normalizes KIS's reference data for one instrument.
1388 //
1389 // The identity comes from the instrument the caller asked about, not from `pdno`:
1390 // KIS returns that zero-padded and prefixed — `00000A000660` for `000660` — so
1391 // parsing a symbol back out of it would be inventing a rule KIS has not stated.
1392 //
1393 // KIS reports a listing date per market rather than one date, and blanks the ones
1394 // that do not apply, so the date is taken from the market the instrument is listed
1395 // on. It publishes no listing *status* at all, so `status_` stays `UNKNOWN` rather
1396 // than being inferred from the presence of a delisting date — a future delisting
1397 // date belongs to an instrument that is still trading.
1398 //
1399 // `price_rule_` is the library's own grid: the quote's `aspr_unit` is the tick at one price, not
1400 // the ladder.
1401 static Result<Instrument> parse_instrument(
1402 InstrumentId requested,
1403 Result<network::HttpResponse> result
1404 ) {
1405 auto document = market_document(std::move(result));
1406 if (!document) {
1407 return Result<Instrument>(std::move(document.error()));
1408 }
1409 try {
1410 const auto output = document.value().if_contains("output");
1411 if (!output || !output->is_object()) {
1412 throw std::runtime_error("missing object field: output");
1413 }
1414 const auto& row = output->as_object();
1415
1416 Instrument instrument;
1417 instrument.id_ = std::move(requested);
1418 instrument.name_ = broker_detail::json_string(row, "prdt_name");
1419 instrument.english_name_ = broker_detail::json_string(row, "prdt_eng_name");
1420 // KIS's 표준상품번호 is the KRX standard code, which is also the ISIN. KIS does not
1421 // say so in as many words, but it documents the field as twelve characters for
1422 // both domestic and foreign instruments, which is the ISIN's length and not the
1423 // length of any domestic-only code.
1424 instrument.isin_ = broker_detail::json_string(row, "std_pdno");
1425 const auto market = broker_detail::json_string(row, "mket_id_cd");
1426 instrument.segment_ = kis_market_segment(market);
1427 instrument.type_ =
1428 kis_security_type(broker_detail::json_string(row, "scty_grp_id_cd"));
1429 instrument.currency_ = Currency::krw();
1430 instrument.shares_outstanding_ = positive_decimal(row, "lstg_stqt");
1431 instrument.listed_on_ = compact_date_field(row, listing_date_field(market));
1432 instrument.delisted_on_ = compact_date_field(row, delisting_date_field(market));
1433 instrument.price_rule_ =
1434 price_rule_for(instrument.id_.country_, instrument.type_);
1435 return Result<Instrument>(std::move(instrument));
1436 } catch (const std::exception& exception) {
1437 BrokerError error;
1438 error.category_ = ErrorCategory::PROTOCOL;
1439 error.broker_code_ = "invalid_reference_response";
1440 error.message_ = exception.what();
1441 return Result<Instrument>(std::move(error));
1442 }
1443 }
1444
1445 // Maps KIS's market identifier, preserving one this library has not normalized.
1446 static MarketSegment kis_market_segment(std::string_view code) {
1447 if (code == "STK") {
1448 return market_segment("KOSPI");
1449 }
1450 if (code == "KSQ") {
1451 return market_segment("KOSDAQ");
1452 }
1453 // KONEX has no normalized segment, so its own code is preserved instead of
1454 // being collapsed into one of the two that do.
1455 return market_segment(code);
1456 }
1457
1458 // Maps KIS's security group to a normalized type.
1459 //
1460 // KIS's group covers instruments far beyond equities — futures, options, gold,
1461 // ship-investment companies — and the ones with no normalized counterpart stay
1462 // `UNKNOWN` rather than being forced into a nearby value. An ELW is the notable
1463 // one: it is a warrant, but not the 신주인수권 Toss's `STOCK_WARRANTS` means.
1464 static SecurityType kis_security_type(std::string_view group) {
1465 if (group == "ST" || group == "KN") {
1466 return SecurityType::STOCK;
1467 }
1468 if (group == "FS") {
1469 return SecurityType::FOREIGN_STOCK;
1470 }
1471 if (group == "DR") {
1472 return SecurityType::DEPOSITARY_RECEIPT;
1473 }
1474 if (group == "IF") {
1475 return SecurityType::INFRASTRUCTURE_FUND;
1476 }
1477 if (group == "RT") {
1478 return SecurityType::REIT;
1479 }
1480 if (group == "EF") {
1481 return SecurityType::ETF;
1482 }
1483 if (group == "FE") {
1484 return SecurityType::FOREIGN_ETF;
1485 }
1486 if (group == "EN") {
1487 return SecurityType::ETN;
1488 }
1489 if (group == "SW" || group == "SR") {
1490 return SecurityType::STOCK_WARRANT;
1491 }
1492 return SecurityType::UNKNOWN;
1493 }
1494
1495 // Returns which listing-date column applies to `market`.
1496 static const char* listing_date_field(std::string_view market) {
1497 if (market == "KSQ") {
1498 return "kosdaq_mket_lstg_dt";
1499 }
1500 if (market == "STK") {
1501 return "scts_mket_lstg_dt";
1502 }
1503 return "frbd_mket_lstg_dt";
1504 }
1505
1506 // Returns which delisting-date column applies to `market`.
1507 static const char* delisting_date_field(std::string_view market) {
1508 if (market == "KSQ") {
1509 return "kosdaq_mket_lstg_abol_dt";
1510 }
1511 if (market == "STK") {
1512 return "scts_mket_lstg_abol_dt";
1513 }
1514 return "frbd_mket_lstg_abol_dt";
1515 }
1516
1517 // Reads a `YYYYMMDD` field that KIS blanks when it does not apply.
1518 //
1519 // Three answers. **Not stated**: the key is absent, `null`, `""` -- the `주식기본조회` sheet's own
1520 // example for an inapplicable date -- or `"00000000"`. The last is an inference, not a documented
1521 // convention for this sheet: the same workbook uses `"00000000"` as its no-date value in
1522 // `장내채권 기본조회` and `해외주식 상품기본정보`, and a zero date is no calendar date under any
1523 // reading, so treating it as KIS's blank invents nothing that could be mistaken for a date.
1524 // **Stated**: eight ASCII digits forming a real date. **Unreadable**: anything else, including a
1525 // value that is not a string.
1526 //
1527 // Every character is checked as a digit before any arithmetic. This used `std::stoi` per
1528 // component, which takes a numeric prefix, so `"1996122x"` read as 1996-12-02 -- a different,
1529 // valid date instead of an unreadable one.
1530 static StatedDate compact_date_field(
1531 const json::object& row,
1532 std::string_view key
1533 ) {
1534 const auto value = row.if_contains(key);
1535 if (!value || value->is_null()) {
1536 return StatedDate{};
1537 }
1538 if (!value->is_string()) {
1539 return StatedDate::unreadable();
1540 }
1541 const std::string_view text(value->as_string());
1542 if (text.empty() || text == "00000000") {
1543 return StatedDate{};
1544 }
1545 const auto date = broker_detail::compact_date(text);
1546 return date ? StatedDate::stated(*date) : StatedDate::unreadable();
1547 }
1548
1549 // One calendar request spanning however many continuation pages it needs.
1550 struct CalendarOperation {
1551 Date from_{};
1552 TradingCalendar calendar_;
1553 std::string search_cursor_;
1554 std::string next_cursor_;
1555 int pages_{0};
1556 CalendarHandler completion_;
1557
1558 CalendarOperation(Date from, CalendarHandler completion)
1559 : from_(from), completion_(std::move(completion)) {}
1560 };
1561
1562 // Most continuation pages one calendar request may consume.
1563 static constexpr int calendar_page_budget_ = 20;
1564
1565 // Answers from the cache when it covers `from`, and otherwise fetches once.
1566 //
1567 // The cache exists because of the broker's once-a-day request, not as an
1568 // optimization, so it is consulted before anything else and is only bypassed when
1569 // the caller asks for a refresh explicitly.
1570 void start_calendar(Date from, bool refresh, CalendarHandler completion) {
1571 if (!is_valid_date(from)) {
1572 BrokerError error;
1573 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1574 error.reached_broker_ = false;
1575 error.broker_code_ = "invalid_date";
1576 error.message_ = "calendar request requires a valid Gregorian date";
1577 deliver(std::move(completion), Result<TradingCalendar>(std::move(error)));
1578 return;
1579 }
1580 if (!refresh && calendar_covers(calendar_, from)) {
1581 deliver(std::move(completion), Result<TradingCalendar>(calendar_));
1582 return;
1583 }
1584
1585 auto operation = std::make_shared<CalendarOperation>(from, std::move(completion));
1586 operation->calendar_.country_ = Country::KR;
1587 ensure_token(
1588 TokenHandler(
1589 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
1590 if (token_error) {
1591 self->deliver(
1592 std::move(operation->completion_), Result<TradingCalendar>(std::move(*token_error))
1593 );
1594 return;
1595 }
1596 self->request_calendar(operation);
1597 }
1598 )
1599 );
1600 }
1601
1602 // Returns whether the cached calendar already answers for `from` onwards.
1603 static bool calendar_covers(const TradingCalendar& calendar, const Date& from) {
1604 const auto covered = calendar.covered();
1605 // A row for `from` itself, not only a span around it: a row dropped as unreadable leaves a hole
1606 // inside the span, and serving the cache for that day would keep answering "not covered" at
1607 // every poll without asking KIS again -- so a repaired response could never restore the day.
1608 // This relies on KIS listing *every* calendar day, weekends and holidays included, which its
1609 // response does (each row carries a weekday code); a weekend `from` is therefore still covered.
1610 return covered && !(from < covered->first) && !(covered->second < from) &&
1611 calendar.is_trading_day(from).has_value();
1612 }
1613
1614 // How many rows of the listing have been consumed: the days kept and the rows dropped as
1615 // unreadable. Pagination measures progress by this, not by days kept alone.
1616 static std::size_t calendar_rows_consumed(const TradingCalendar& calendar) {
1617 return calendar.days_.size() + calendar.unreadable_days_;
1618 }
1619
1620 // Sends one calendar page, continuing from the cursors KIS returned.
1621 void request_calendar(const std::shared_ptr<CalendarOperation>& operation) {
1622 network::HttpRequest request;
1623 // The calendar is reference data, the lowest-priority traffic there is.
1624 request.group_ = network::RateLimitGroup::REFERENCE;
1625 request.method_ = http::verb::get;
1626 request.target_ =
1627 "/uapi/domestic-stock/v1/quotations/chk-holiday?BASS_DT=" +
1628 compact_date(operation->from_) +
1629 "&CTX_AREA_NK=" + broker_detail::query_encode(operation->next_cursor_) +
1630 "&CTX_AREA_FK=" + broker_detail::query_encode(operation->search_cursor_);
1631 request.headers_ = {
1632 {"authorization", "Bearer " + token_.access_token_},
1633 {"appkey", config_.key()},
1634 {"appsecret", config_.secret()},
1635 {"tr_id", "CTCA0903R"},
1636 {"tr_cont", operation->pages_ == 0 ? "" : "N"},
1637 {"custtype", "P"},
1638 {"content-type", "application/json; charset=utf-8"},
1639 };
1640
1641 ++operation->pages_;
1642 http_->async_request(
1643 std::move(request),
1644 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
1645 self->handle_calendar_page(operation, std::move(result));
1646 }
1647 );
1648 }
1649
1650 // Accumulates one page and either continues or finishes and caches.
1651 void handle_calendar_page(
1652 const std::shared_ptr<CalendarOperation>& operation,
1653 Result<network::HttpResponse> result
1654 ) {
1655 // Progress is rows *consumed*, not rows kept: a page whose every date was unreadable adds no
1656 // day, and reading that as the end of the listing would drop the pages after it -- today's row
1657 // among them, possibly.
1658 const auto before = calendar_rows_consumed(operation->calendar_);
1659 const bool more = result && continuation_pending(result.value());
1660 std::string search = operation->search_cursor_;
1661 std::string next = operation->next_cursor_;
1662
1663 if (auto failure = parse_calendar_page(std::move(result), operation->calendar_, search, next)) {
1664 deliver(
1665 std::move(operation->completion_), Result<TradingCalendar>(std::move(*failure))
1666 );
1667 return;
1668 }
1669
1670 const bool empty_page = calendar_rows_consumed(operation->calendar_) == before;
1671 const bool repeated = next == operation->next_cursor_ && search == operation->search_cursor_;
1672 if (!more || empty_page || repeated || operation->pages_ >= calendar_page_budget_) {
1673 operation->calendar_.received_at_ = std::chrono::system_clock::now();
1674 calendar_ = operation->calendar_;
1675 deliver(std::move(operation->completion_), Result<TradingCalendar>(calendar_));
1676 return;
1677 }
1678
1679 operation->search_cursor_ = std::move(search);
1680 operation->next_cursor_ = std::move(next);
1681 request_calendar(operation);
1682 }
1683
1684 // Parses one calendar page.
1685 //
1686 // KIS reports four related flags per date — a banking business day, a securities
1687 // business day, a market-open day, and a settlement day. Only `opnd_yn` is
1688 // normalized, because it is the one KIS itself directs callers to use before
1689 // placing an order: "주문을 넣고자 할 경우 개장일여부(opnd_yn)를 사용". The other three
1690 // stay unread rather than being folded into one meaning.
1691 //
1692 // **A row whose `bass_dt` is not a readable date produces no day and is counted** in
1693 // `TradingCalendar::unreadable_days_`. The sheet documents `bass_dt` as required, eight characters,
1694 // `YYYYMMDD`, so there is no blank to honour: empty, absent, non-string, a wrong length, a numeric
1695 // prefix and an impossible day are all damage. Dropping the row rather than failing the page is
1696 // deliberate -- a page carries weeks, and failing it for one future row would lose today's row with
1697 // it. Rows are staged and appended only once the whole page parses, so a page that does fail
1698 // leaves `calendar` as it was.
1699 static std::optional<BrokerError> parse_calendar_page(
1700 Result<network::HttpResponse> result,
1701 TradingCalendar& calendar,
1702 std::string& search_cursor,
1703 std::string& next_cursor
1704 ) {
1705 auto document = market_document(std::move(result));
1706 if (!document) {
1707 return std::move(document.error());
1708 }
1709 try {
1710 const auto rows = document.value().if_contains("output");
1711 if (!rows || !rows->is_array()) {
1712 throw std::runtime_error("missing array field: output");
1713 }
1714 std::vector<TradingDay> staged;
1715 std::size_t unreadable = 0;
1716 for (const auto& value : rows->as_array()) {
1717 if (!value.is_object()) {
1718 throw std::runtime_error("calendar entry is not an object");
1719 }
1720 const auto& row = value.as_object();
1721 const auto stated = row.if_contains("bass_dt");
1722 const auto date = stated && stated->is_string()
1723 ? broker_detail::compact_date(stated->as_string())
1724 : std::nullopt;
1725 if (!date) {
1726 ++unreadable;
1727 continue;
1728 }
1729 TradingDay day;
1730 day.date_ = *date;
1731 day.open_ = broker_detail::json_string(row, "opnd_yn") == "Y";
1732 staged.push_back(day);
1733 }
1734 read_calendar_cursors(document.value(), search_cursor, next_cursor);
1735 calendar.days_.insert(calendar.days_.end(), staged.begin(), staged.end());
1736 calendar.unreadable_days_ += unreadable;
1737 return std::nullopt;
1738 } catch (const std::exception& exception) {
1739 BrokerError error;
1740 error.category_ = ErrorCategory::PROTOCOL;
1741 error.broker_code_ = "invalid_calendar_response";
1742 error.message_ = exception.what();
1743 return error;
1744 }
1745 }
1746
1747 // Reads the calendar continuation cursors, which KIS names without the 100 suffix.
1748 static void read_calendar_cursors(
1749 const json::object& document,
1750 std::string& search_cursor,
1751 std::string& next_cursor
1752 ) {
1753 if (const auto value = document.if_contains("ctx_area_fk"); value && value->is_string()) {
1754 search_cursor = std::string(value->as_string());
1755 }
1756 if (const auto value = document.if_contains("ctx_area_nk"); value && value->is_string()) {
1757 next_cursor = std::string(value->as_string());
1758 }
1759 }
1760
1761 // Issues one authenticated market-data GET for a single instrument.
1762 //
1763 // The single-instrument reads differ only in path, transaction id, and parser, so
1764 // the validation, token handling, and traffic class live here once.
1765 template <typename Handler, typename Parse>
1766 void start_market_read(
1767 InstrumentId instrument,
1768 std::string path,
1769 std::string transaction,
1770 Handler completion,
1771 Parse parse
1772 ) {
1773 using ResultType = decltype(parse(InstrumentId{}, Result<network::HttpResponse>(BrokerError{})));
1774 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
1775 BrokerError error;
1776 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1777 error.reached_broker_ = false;
1778 error.broker_code_ = "invalid_instrument";
1779 error.message_ = "KIS market data covers Korean equities addressed by product number";
1780 deliver(std::move(completion), ResultType(std::move(error)));
1781 return;
1782 }
1783
1784 ensure_token(
1785 TokenHandler(
1786 [
1787 self = shared_from_this(),
1788 instrument = std::move(instrument),
1789 path = std::move(path),
1790 transaction = std::move(transaction),
1791 completion = std::move(completion),
1792 parse
1793 ](std::optional<BrokerError> token_error) mutable {
1794 if (token_error) {
1795 self->deliver(std::move(completion), ResultType(std::move(*token_error)));
1796 return;
1797 }
1798 network::HttpRequest request;
1799 // Market data is the traffic the trading reserve holds back.
1800 request.group_ = network::RateLimitGroup::MARKET_DATA;
1801 request.method_ = http::verb::get;
1802 request.target_ = path + market_query(self->config_, instrument.symbol_);
1803 request.headers_ = {
1804 {"authorization", "Bearer " + self->token_.access_token_},
1805 {"appkey", self->config_.key()},
1806 {"appsecret", self->config_.secret()},
1807 {"tr_id", transaction},
1808 {"custtype", "P"},
1809 {"content-type", "application/json; charset=utf-8"},
1810 };
1811 self->http_->async_request(
1812 std::move(request),
1813 [self, instrument = std::move(instrument), completion = std::move(completion), parse](
1814 Result<network::HttpResponse> result
1815 ) mutable {
1816 self->deliver(
1817 std::move(completion), parse(std::move(instrument), std::move(result))
1818 );
1819 }
1820 );
1821 }
1822 )
1823 );
1824 }
1825
1826 // Returns a market-data response's declared payload, or the error ending it.
1827 //
1828 // Applies the HTTP status check, the JSON parse, and KIS's own `rt_cd` before a
1829 // parser looks at any field, so a declared failure is never read as data.
1830 static Result<json::object> market_document(Result<network::HttpResponse> response) {
1831 if (!response) {
1832 return Result<json::object>(std::move(response.error()));
1833 }
1834 if (response.value().result_int() < 200 || response.value().result_int() >= 300) {
1835 return Result<json::object>(kis_http_error(response.value()));
1836 }
1837 BrokerError parse_error;
1838 auto document = broker_detail::parse_object(response.value(), parse_error);
1839 if (!document) {
1840 return Result<json::object>(std::move(parse_error));
1841 }
1842 try {
1843 if (auto declared = kis_declared_error(*document)) {
1844 return Result<json::object>(std::move(*declared));
1845 }
1846 } catch (const std::exception& exception) {
1847 BrokerError error;
1848 error.category_ = ErrorCategory::PROTOCOL;
1849 error.broker_code_ = "invalid_market_response";
1850 error.message_ = exception.what();
1851 return Result<json::object>(std::move(error));
1852 }
1853 return Result<json::object>(std::move(*document));
1854 }
1855
1856 // Normalizes KIS's price bounds from its current-price inquiry.
1857 //
1858 // KRX sets a limit on every listed equity, so both bounds are expected here; a
1859 // blank or zero bound is reported as absent rather than as a bound of nothing.
1860 static Result<PriceLimits> parse_price_limits(
1861 InstrumentId instrument,
1862 Result<network::HttpResponse> result
1863 ) {
1864 static_cast<void>(instrument);
1865 auto document = market_document(std::move(result));
1866 if (!document) {
1867 return Result<PriceLimits>(std::move(document.error()));
1868 }
1869 try {
1870 const auto output = document.value().if_contains("output");
1871 if (!output || !output->is_object()) {
1872 throw std::runtime_error("missing object field: output");
1873 }
1874 const auto& row = output->as_object();
1875 PriceLimits limits;
1876 limits.currency_ = Currency::krw();
1877 limits.upper_ = positive_decimal(row, "stck_mxpr");
1878 limits.lower_ = positive_decimal(row, "stck_llam");
1879 return Result<PriceLimits>(std::move(limits));
1880 } catch (const std::exception& exception) {
1881 BrokerError error;
1882 error.category_ = ErrorCategory::PROTOCOL;
1883 error.broker_code_ = "invalid_price_limit_response";
1884 error.message_ = exception.what();
1885 return Result<PriceLimits>(std::move(error));
1886 }
1887 }
1888
1889 // Reads a decimal that KIS leaves blank or zero when it has no value.
1890 static std::optional<Decimal64> positive_decimal(
1891 const json::object& row,
1892 std::string_view key
1893 ) {
1894 const auto value = row.if_contains(key);
1895 if (!value || !value->is_string()) {
1896 return std::nullopt;
1897 }
1898 const std::string text(value->as_string());
1899 if (text.empty()) {
1900 return std::nullopt;
1901 }
1902 const auto parsed = Decimal64::parse(text);
1903 if (parsed.coefficient() == 0) {
1904 return std::nullopt;
1905 }
1906 return parsed;
1907 }
1908
1909 // Normalizes KIS's recent executions, newest first as KIS sends them.
1910 //
1911 // KIS reports an execution's time as `HHMMSS` with no date, so the KST calendar
1912 // day is supplied from the clock. A print is intraday by construction, so this is
1913 // the day it happened on.
1914 static Result<std::vector<TradePrint>> parse_trades(
1915 InstrumentId instrument,
1916 Result<network::HttpResponse> result,
1917 int count
1918 ) {
1919 static_cast<void>(instrument);
1920 auto document = market_document(std::move(result));
1921 if (!document) {
1922 return Result<std::vector<TradePrint>>(std::move(document.error()));
1923 }
1924 try {
1925 const auto rows = document.value().if_contains("output");
1926 if (!rows || !rows->is_array()) {
1927 throw std::runtime_error("missing array field: output");
1928 }
1929 Date today{};
1930 std::string ignored;
1931 korea_calendar(std::chrono::system_clock::now(), today, ignored);
1932 const auto date = compact_date(today);
1933
1934 std::vector<TradePrint> prints;
1935 prints.reserve(rows->as_array().size());
1936 for (const auto& value : rows->as_array()) {
1937 if (!value.is_object()) {
1938 throw std::runtime_error("execution entry is not an object");
1939 }
1940 const auto& row = value.as_object();
1941 const auto time = broker_detail::json_string(row, "stck_cntg_hour");
1942 // KIS pads a short window with blank rows rather than shortening it.
1943 if (time.empty()) {
1944 continue;
1945 }
1946 TradePrint print;
1947 print.price_ = Decimal64::parse(broker_detail::json_string(row, "stck_prpr"));
1948 print.quantity_ = Decimal64::parse(broker_detail::json_string(row, "cntg_vol"));
1949 print.currency_ = Currency::krw();
1950 print.exchange_time_ = korea_timestamp(date, time);
1951 if (!print.exchange_time_) {
1952 throw std::runtime_error("invalid execution time");
1953 }
1954 prints.push_back(std::move(print));
1955 if (count > 0 && static_cast<int>(prints.size()) >= count) {
1956 break;
1957 }
1958 }
1959 return Result<std::vector<TradePrint>>(std::move(prints));
1960 } catch (const std::exception& exception) {
1961 BrokerError error;
1962 error.category_ = ErrorCategory::PROTOCOL;
1963 error.broker_code_ = "invalid_execution_response";
1964 error.message_ = exception.what();
1965 return Result<std::vector<TradePrint>>(std::move(error));
1966 }
1967 }
1968
1969 // Validates the instrument and requests its book.
1970 void start_depth(InstrumentId instrument, DepthHandler completion) {
1971 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
1972 BrokerError error;
1973 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1974 error.reached_broker_ = false;
1975 error.broker_code_ = "invalid_instrument";
1976 error.message_ = "KIS order book covers Korean equities addressed by product number";
1977 deliver(std::move(completion), Result<DepthSnapshot>(std::move(error)));
1978 return;
1979 }
1980
1981 ensure_token(
1982 TokenHandler(
1983 [
1984 self = shared_from_this(),
1985 instrument = std::move(instrument),
1986 completion = std::move(completion)
1987 ](std::optional<BrokerError> token_error) mutable {
1988 if (token_error) {
1989 self->deliver(
1990 std::move(completion), Result<DepthSnapshot>(std::move(*token_error))
1991 );
1992 return;
1993 }
1994 network::HttpRequest request;
1995 // The book is polling traffic, held back by the trading reserve.
1996 request.group_ = network::RateLimitGroup::MARKET_DATA;
1997 request.method_ = http::verb::get;
1998 request.target_ =
1999 "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
2000 + market_query(self->config_, instrument.symbol_);
2001 request.headers_ = {
2002 {"authorization", "Bearer " + self->token_.access_token_},
2003 {"appkey", self->config_.key()},
2004 {"appsecret", self->config_.secret()},
2005 {"tr_id", "FHKST01010200"},
2006 {"custtype", "P"},
2007 {"content-type", "application/json; charset=utf-8"},
2008 };
2009 self->http_->async_request(
2010 std::move(request),
2011 [self, instrument = std::move(instrument), completion = std::move(completion)](
2012 Result<network::HttpResponse> result
2013 ) mutable {
2014 self->deliver(
2015 std::move(completion), parse_depth(std::move(instrument), std::move(result))
2016 );
2017 }
2018 );
2019 }
2020 )
2021 );
2022 }
2023
2024 // Normalizes one KIS order book.
2025 //
2026 // KIS numbers its levels into flat fields — `askp1` through `askp10` with
2027 // `askp_rsqn1` through `askp_rsqn10`, and the same for bids — rather than sending
2028 // arrays. A level whose price is absent, blank, or zero is a level that is not
2029 // resting, and is skipped rather than recorded as an offer at nothing.
2030 //
2031 // The after-hours totals KIS also reports (`ovtm_total_askp_rsqn` and its
2032 // siblings) and its expected-execution columns are deliberately not normalized:
2033 // Toss publishes no counterpart. They are dropped here, not exposed.
2034 static Result<DepthSnapshot> parse_depth(
2035 InstrumentId instrument,
2036 Result<network::HttpResponse> result
2037 ) {
2038 if (!result) {
2039 return Result<DepthSnapshot>(std::move(result.error()));
2040 }
2041 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2042 return Result<DepthSnapshot>(kis_http_error(result.value()));
2043 }
2044 BrokerError parse_error;
2045 auto document = broker_detail::parse_object(result.value(), parse_error);
2046 if (!document) {
2047 return Result<DepthSnapshot>(std::move(parse_error));
2048 }
2049 try {
2050 if (auto declared = kis_declared_error(*document)) {
2051 return Result<DepthSnapshot>(std::move(*declared));
2052 }
2053 const auto output = document->if_contains("output1");
2054 if (!output || !output->is_object()) {
2055 throw std::runtime_error("missing object field: output1");
2056 }
2057 const auto& row = output->as_object();
2058
2059 DepthSnapshot book;
2060 book.instrument_ = std::move(instrument);
2061 book.currency_ = Currency::krw();
2062 for (int level = 1; level <= depth_levels_; ++level) {
2063 append_level(row, "askp", "askp_rsqn", level, book.asks_);
2064 append_level(row, "bidp", "bidp_rsqn", level, book.bids_);
2065 }
2066 order_depth(book);
2067 if (const auto hour = row.if_contains("aspr_acpt_hour"); hour && hour->is_string()) {
2068 const std::string time(hour->as_string());
2069 Date today{};
2070 std::string ignored;
2071 korea_calendar(std::chrono::system_clock::now(), today, ignored);
2072 book.exchange_time_ = korea_timestamp(compact_date(today), time);
2073 }
2074 book.received_at_ = std::chrono::system_clock::now();
2075 return Result<DepthSnapshot>(std::move(book));
2076 } catch (const std::exception& exception) {
2077 BrokerError error;
2078 error.category_ = ErrorCategory::PROTOCOL;
2079 error.broker_code_ = "invalid_orderbook_response";
2080 error.message_ = exception.what();
2081 return Result<DepthSnapshot>(std::move(error));
2082 }
2083 }
2084
2085 // How many resting levels KIS publishes on each side.
2086 static constexpr int depth_levels_ = 10;
2087
2088 // Reads one numbered level, skipping a level that is not resting.
2089 static void append_level(
2090 const json::object& row,
2091 std::string_view price_prefix,
2092 std::string_view quantity_prefix,
2093 int level,
2094 std::vector<DepthLevel>& levels
2095 ) {
2096 const auto suffix = std::to_string(level);
2097 const auto price = row.if_contains(std::string(price_prefix) + suffix);
2098 const auto quantity = row.if_contains(std::string(quantity_prefix) + suffix);
2099 if (!price || !price->is_string() || !quantity || !quantity->is_string()) {
2100 return;
2101 }
2102 const std::string price_text(price->as_string());
2103 const std::string quantity_text(quantity->as_string());
2104 if (price_text.empty() || quantity_text.empty()) {
2105 return;
2106 }
2107 const auto value = Decimal64::parse(price_text);
2108 if (value.coefficient() == 0) {
2109 return;
2110 }
2111 levels.push_back(DepthLevel{value, Decimal64::parse(quantity_text)});
2112 }
2113
2114 void start_quote(InstrumentId instrument, QuoteHandler completion) {
2115 if (instrument.country_ != Country::KR || !broker_detail::valid_symbol(instrument.symbol_)) {
2116 BrokerError error;
2117 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2118 error.reached_broker_ = false;
2119 error.broker_code_ = "invalid_instrument";
2120 error.message_ = "KIS quote currently supports Korean equity symbols only";
2121 complete(std::move(completion), Result<QuoteSnapshot>(std::move(error)));
2122 return;
2123 }
2124
2125 ensure_token(
2126 TokenHandler(
2127 [
2128 self = shared_from_this(),
2129 instrument = std::move(instrument),
2130 completion = std::move(completion)
2131 ](std::optional<BrokerError> token_error) mutable {
2132 if (token_error) {
2133 self->complete(
2134 std::move(completion), Result<QuoteSnapshot>(std::move(*token_error))
2135 );
2136 return;
2137 }
2138 self->request_quote(std::move(instrument), std::move(completion));
2139 }
2140 )
2141 );
2142 }
2143
2144 void start_buying_power(Currency currency, KisAccount account, BuyingPowerHandler completion) {
2145 auto invalid = validate_account(account);
2146 if (!invalid && !(currency == Currency::krw())) {
2147 // The domestic inquiry answers in won. Answering a dollar request with a won figure under a
2148 // dollar label is a silent downgrade, which this library refuses, and KIS's foreign-currency
2149 // buying power is a different endpoint answering a different question.
2150 BrokerError error;
2151 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2152 error.reached_broker_ = false;
2153 error.broker_code_ = "unsupported_currency";
2154 error.message_ =
2155 "the KIS domestic buying-power inquiry answers in KRW only, not " + currency.str();
2156 invalid = std::move(error);
2157 }
2158 if (invalid) {
2159 complete_buying_power(std::move(completion), Result<BuyingPower>(std::move(*invalid)));
2160 return;
2161 }
2162 ensure_token(
2163 TokenHandler(
2164 [self = shared_from_this(), account = std::move(account),
2165 completion = std::move(completion)](std::optional<BrokerError> token_error) mutable {
2166 if (token_error) {
2167 self->complete_buying_power(
2168 std::move(completion), Result<BuyingPower>(std::move(*token_error))
2169 );
2170 return;
2171 }
2172 self->request_buying_power(std::move(account), std::move(completion));
2173 }
2174 )
2175 );
2176 }
2177
2178 void request_buying_power(KisAccount account, BuyingPowerHandler completion) {
2179 http_->async_request(
2180 buying_power_request(config_, token_.access_token_, account),
2181 [self = shared_from_this(), completion = std::move(completion)](
2182 Result<network::HttpResponse> result
2183 ) mutable {
2184 net::post(
2185 self->strand_,
2186 [self, completion = std::move(completion), result = std::move(result)]() mutable {
2187 self->complete_buying_power(
2188 std::move(completion), parse_buying_power(std::move(result))
2189 );
2190 }
2191 );
2192 }
2193 );
2194 }
2195
2196 // Builds the account-level buying-power request.
2197 //
2198 // **`PDNO` and `ORD_UNPR` are deliberately empty**, which is how KIS is asked for the amount
2199 // without a quantity: "PDNO, ORD_UNPR 공란 입력 시, 매수수량 없이 매수금액만 조회됨". Both are
2200 // marked required, so they are sent as empty values rather than omitted.
2201 //
2202 // `ORD_DVSN` is `01`, market. The division only shapes the *quantity* answer, through the
2203 // instrument's margin rate, and no quantity is being asked for here -- but the field is required,
2204 // and `01` is the one KIS's own documentation tells a caller to use when the answer must reflect
2205 // margin rather than ignore it.
2206 //
2207 // `CMA_EVLU_AMT_ICLD_YN` and `OVRS_ICLD_YN` are both `N`, and that is a decision rather than a
2208 // default. Including a CMA valuation or overseas balances would make the figure larger than the
2209 // won an order can actually spend, and a buying-power check exists to be conservative. A caller
2210 // wanting the wider figure asks this client directly.
2211 static network::HttpRequest buying_power_request(
2212 const KisConfig& config,
2213 std::string_view access_token,
2214 const KisAccount& account
2215 ) {
2216 network::HttpRequest request;
2217 request.group_ = network::RateLimitGroup::ACCOUNT;
2218 request.method_ = http::verb::get;
2219 request.target_ =
2220 "/uapi/domestic-stock/v1/trading/inquire-psbl-order"
2221 "?CANO=" +
2222 account.account_number_ + "&ACNT_PRDT_CD=" + account.product_code_ +
2223 "&PDNO=&ORD_UNPR=&ORD_DVSN=01&CMA_EVLU_AMT_ICLD_YN=N&OVRS_ICLD_YN=N";
2224 request.headers_ = {
2225 {"authorization", "Bearer " + std::string(access_token)},
2226 {"appkey", config.key()},
2227 {"appsecret", config.secret()},
2228 {"tr_id", config.is_demo_ ? "VTTC8908R" : "TTTC8908R"},
2229 {"custtype", "P"},
2230 {"content-type", "application/json; charset=utf-8"},
2231 };
2232 return request;
2233 }
2234
2235 // Normalizes KIS's buying power.
2236 //
2237 // **`nrcvb_buy_amt` is the field, not `max_buy_amt`.** The 미수-free amount is what Toss's
2238 // `cashBuyingPower` means -- both answer "what can be bought without credit" -- while the maximum
2239 // includes 미수 and means something the other broker never reports. Normalizing the larger number
2240 // would make one library type mean cash at one broker and cash-plus-credit at the other, which is
2241 // exactly what a broker-neutral type must not do, and it would do it in the direction that
2242 // permits more.
2243 static Result<BuyingPower> parse_buying_power(Result<network::HttpResponse> response) {
2244 auto document = market_document(std::move(response));
2245 if (!document) {
2246 return Result<BuyingPower>(std::move(document.error()));
2247 }
2248 try {
2249 const auto output = document.value().if_contains("output");
2250 if (!output || !output->is_object()) {
2251 throw std::runtime_error("missing object field: output");
2252 }
2253 BuyingPower power;
2254 power.cash_ = Money{
2255 Decimal64::parse(broker_detail::json_string(output->as_object(), "nrcvb_buy_amt")),
2256 Currency::krw(),
2257 };
2258 // KIS states no timestamp for the figure, so the honest stamp is when it arrived.
2259 power.received_at_ = std::chrono::system_clock::now();
2260 return Result<BuyingPower>(std::move(power));
2261 } catch (const std::exception& exception) {
2262 BrokerError error;
2263 error.category_ = ErrorCategory::PROTOCOL;
2264 error.broker_code_ = "invalid_buying_power_response";
2265 error.message_ = exception.what();
2266 return Result<BuyingPower>(std::move(error));
2267 }
2268 }
2269
2270 void complete_buying_power(BuyingPowerHandler handler, Result<BuyingPower> result) {
2271 deliver(std::move(handler), std::move(result));
2272 }
2273
2274 void start_holdings(KisAccount account, HoldingsHandler completion) {
2275 if (auto invalid = validate_account(account)) {
2276 complete_holdings(
2277 std::move(completion), Result<HoldingsSnapshot>(std::move(*invalid))
2278 );
2279 return;
2280 }
2281
2282 auto operation = std::make_shared<HoldingsOperation>(
2283 std::move(account), std::move(completion)
2284 );
2285 ensure_token(
2286 TokenHandler(
2287 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
2288 if (token_error) {
2289 self->finish_holdings(operation, Result<HoldingsSnapshot>(std::move(*token_error)));
2290 return;
2291 }
2292 self->request_holdings(operation);
2293 }
2294 )
2295 );
2296 }
2297
2298 void ensure_token(TokenHandler handler) {
2299 if (token_.valid()) {
2300 std::move(handler)(std::nullopt);
2301 return;
2302 }
2303 token_waiters_.push_back(std::move(handler));
2304 if (refreshing_) {
2305 return;
2306 }
2307 refreshing_ = true;
2308
2309 network::HttpRequest request;
2310 // Every other request waits behind a token refresh, including a cancellation.
2311 request.group_ = network::RateLimitGroup::AUTH;
2312 request.method_ = http::verb::post;
2313 request.target_ = "/oauth2/tokenP";
2314 request.body_ = config_.to_jsonbody();
2315 request.headers_.emplace_back("content-type", "application/json");
2316 http_->async_request(
2317 std::move(request),
2318 [self = shared_from_this()](Result<network::HttpResponse> result) mutable {
2319 net::post(self->strand_, [self, result = std::move(result)]() mutable {
2320 self->finish_token(std::move(result));
2321 });
2322 }
2323 );
2324 }
2325
2326 void finish_token(Result<network::HttpResponse> result) {
2327 refreshing_ = false;
2328 std::optional<BrokerError> failure;
2329 if (!result) {
2330 failure = std::move(result.error());
2331 } else if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2332 failure = kis_http_error(result.value());
2333 failure->category_ = ErrorCategory::AUTHENTICATION;
2334 } else {
2335 BrokerError parse_error;
2336 auto document = broker_detail::parse_object(result.value(), parse_error);
2337 if (!document) {
2338 failure = std::move(parse_error);
2339 } else {
2340 try {
2341 token_.from_json(*document);
2342 } catch (const std::exception& exception) {
2343 BrokerError error;
2344 error.category_ = ErrorCategory::AUTHENTICATION;
2345 error.broker_code_ = "invalid_token_response";
2346 error.message_ = exception.what();
2347 failure = std::move(error);
2348 }
2349 }
2350 }
2351
2352 if (failure) {
2353 // The waiters asked for something else, and none of their requests was sent. See the same
2354 // marking in the Toss client: an unmarked authentication failure reads to a runner as the
2355 // broker refusing an order it never received, spending rate-limit budget on nothing.
2356 failure->reached_broker_ = false;
2357 }
2358 auto waiters = std::move(token_waiters_);
2359 token_waiters_.clear();
2360 for (auto& waiter : waiters) {
2361 if (failure) {
2362 std::move(waiter)(*failure);
2363 } else {
2364 std::move(waiter)(std::nullopt);
2365 }
2366 }
2367 }
2368
2369 // Returns the query fragment naming the tape to read and the instrument to read.
2370 //
2371 // **Every domestic market-data request in this adapter builds this pair, and after
2372 // this function exists there is exactly one place that does** -- so
2373 // `FID_COND_MRKT_DIV_CODE` appears once in this file rather than six times, and a
2374 // seventh endpoint cannot quietly disagree with the other six about which book it
2375 // reads. That is what went wrong before: five verbs sent `J` unconditionally while
2376 // the quote derived its code from the instrument, so a caller's last price and
2377 // their bars described different markets, and `Context::last_price` -- which the
2378 // exposure ceiling is measured against -- was written from both.
2379 //
2380 // The symbol is percent-encoded here for every caller. The quote path used to
2381 // interpolate it raw; a Korean six-digit code encodes to itself, so this changes
2382 // no request that has ever been sent, and it removes a difference that had no
2383 // reason to exist.
2384 static std::string market_query(const KisConfig& config, const std::string& symbol) {
2385 return "?FID_COND_MRKT_DIV_CODE=" + std::string(market_division_code(config.market_tape_)) +
2386 "&FID_INPUT_ISCD=" + broker_detail::query_encode(symbol);
2387 }
2388
2389 void request_quote(InstrumentId instrument, QuoteHandler completion) {
2390 // **The tape is a setting, not a property of the instrument.** This used to read
2391 // `instrument.venue_`, which made the quote the one verb that honoured it while the
2392 // other six sent `J` -- so a caller's last price and their bars described different
2393 // books. It was also unreachable for anything tradeable: `validate_order` refuses an
2394 // order whose instrument names a venue other than the combined one, so an instrument
2395 // marked KRX or NXT could be quoted and never traded.
2396 network::HttpRequest request;
2397 // Quote polling is the traffic a reserve exists to hold back.
2398 request.group_ = network::RateLimitGroup::MARKET_DATA;
2399 request.method_ = http::verb::get;
2400 request.target_ =
2401 "/uapi/domestic-stock/v1/quotations/inquire-price"
2402 + market_query(config_, instrument.symbol_);
2403 request.headers_ = {
2404 {"authorization", "Bearer " + token_.access_token_},
2405 {"appkey", config_.key()},
2406 {"appsecret", config_.secret()},
2407 {"tr_id", "FHKST01010100"},
2408 {"custtype", "P"},
2409 {"content-type", "application/json; charset=utf-8"},
2410 };
2411 http_->async_request(
2412 std::move(request),
2413 [
2414 self = shared_from_this(),
2415 instrument = std::move(instrument),
2416 completion = std::move(completion)
2417 ](Result<network::HttpResponse> result) mutable {
2418 net::post(
2419 self->strand_,
2420 [
2421 self,
2422 instrument = std::move(instrument),
2423 completion = std::move(completion),
2424 result = std::move(result)
2425 ]() mutable {
2426 self->complete(
2427 std::move(completion), parse_quote(std::move(instrument), std::move(result))
2428 );
2429 }
2430 );
2431 }
2432 );
2433 }
2434
2435 void request_holdings(const std::shared_ptr<HoldingsOperation>& operation) {
2436 auto request = make_holdings_request(
2437 operation->account_,
2438 config_,
2439 token_.access_token_,
2440 operation->foreign_cursor_,
2441 operation->domestic_cursor_,
2442 operation->page_count_ > 0
2443 );
2444 http_->async_request(
2445 std::move(request),
2446 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
2447 net::post(
2448 self->strand_,
2449 [self, operation, result = std::move(result)]() mutable {
2450 self->handle_holdings_page(operation, std::move(result));
2451 }
2452 );
2453 }
2454 );
2455 }
2456
2457 static network::HttpRequest make_holdings_request(
2458 const KisAccount& account,
2459 const KisConfig& config,
2460 std::string_view access_token,
2461 std::string_view foreign_cursor,
2462 std::string_view domestic_cursor,
2463 bool continuation
2464 ) {
2465 network::HttpRequest request;
2466 // Holdings rank above polling but may not spend the trading reserve.
2467 request.group_ = network::RateLimitGroup::ACCOUNT;
2468 request.method_ = http::verb::get;
2469 request.target_ =
2470 "/uapi/domestic-stock/v1/trading/inquire-balance"
2471 "?CANO=" +
2472 account.account_number_ + "&ACNT_PRDT_CD=" + account.product_code_ +
2473 "&AFHR_FLPR_YN=Y&OFL_YN=&INQR_DVSN=01&UNPR_DVSN=01&FUND_STTL_ICLD_YN=N"
2474 "&FNCG_AMT_AUTO_RDPT_YN=N&PRCS_DVSN=00&CTX_AREA_FK100=" +
2475 broker_detail::query_encode(foreign_cursor) + "&CTX_AREA_NK100=" +
2476 broker_detail::query_encode(domestic_cursor);
2477 request.headers_ = {
2478 {"authorization", "Bearer " + std::string(access_token)},
2479 {"appkey", config.key()},
2480 {"appsecret", config.secret()},
2481 {"tr_id", config.is_demo_ ? "VTTC8434R" : "TTTC8434R"},
2482 {"tr_cont", continuation ? "N" : ""},
2483 {"custtype", "P"},
2484 {"content-type", "application/json; charset=utf-8"},
2485 };
2486 return request;
2487 }
2488
2489 static std::optional<BrokerError> append_holdings_page(
2490 HoldingsSnapshot& snapshot,
2491 std::size_t& page_count,
2492 std::string& foreign_cursor,
2493 std::string& domestic_cursor,
2494 HoldingsSnapshot page,
2495 std::string next_foreign_cursor,
2496 std::string next_domestic_cursor,
2497 bool has_next
2498 ) {
2499 if (has_next) {
2500 const bool cursor_did_not_advance = next_foreign_cursor == foreign_cursor &&
2501 next_domestic_cursor == domestic_cursor;
2502 if (
2503 page_count + 1 >= 100 ||
2504 (next_foreign_cursor.empty() && next_domestic_cursor.empty()) ||
2505 cursor_did_not_advance
2506 ) {
2507 BrokerError error;
2508 error.category_ = ErrorCategory::PROTOCOL;
2509 error.broker_code_ = "invalid_pagination";
2510 error.message_ = "KIS holdings continuation did not advance";
2511 return error;
2512 }
2513 }
2514
2515 snapshot.positions_.insert(
2516 snapshot.positions_.end(),
2517 std::make_move_iterator(page.positions_.begin()),
2518 std::make_move_iterator(page.positions_.end())
2519 );
2520 if (!page.totals_.empty()) {
2521 snapshot.totals_ = std::move(page.totals_);
2522 }
2523 ++page_count;
2524 if (has_next) {
2525 foreign_cursor = std::move(next_foreign_cursor);
2526 domestic_cursor = std::move(next_domestic_cursor);
2527 }
2528 return std::nullopt;
2529 }
2530
2531 void handle_holdings_page(const std::shared_ptr<HoldingsOperation>& operation, Result<network::HttpResponse> result) {
2532 if (!result) {
2533 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(result.error())));
2534 return;
2535 }
2536
2537 const auto continuation = result.value().base().find("tr_cont");
2538 const bool has_next = continuation != result.value().base().end() &&
2539 (continuation->value() == "M" || continuation->value() == "F");
2540 std::string foreign_cursor;
2541 std::string domestic_cursor;
2542 auto page = parse_holdings_page(
2543 std::move(result), foreign_cursor, domestic_cursor
2544 );
2545 if (!page) {
2546 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(page.error())));
2547 return;
2548 }
2549
2550 auto pagination_error = append_holdings_page(
2551 operation->snapshot_,
2552 operation->page_count_,
2553 operation->foreign_cursor_,
2554 operation->domestic_cursor_,
2555 std::move(page.value()),
2556 std::move(foreign_cursor),
2557 std::move(domestic_cursor),
2558 has_next
2559 );
2560 if (pagination_error) {
2561 finish_holdings(
2562 operation, Result<HoldingsSnapshot>(std::move(*pagination_error))
2563 );
2564 return;
2565 }
2566
2567 if (has_next) {
2568 request_holdings(operation);
2569 return;
2570 }
2571
2572 operation->snapshot_.received_at_ = std::chrono::system_clock::now();
2573 // **What `ord_psbl_qty` on these rows is evidence of, and it is a treatment rather than a
2574 // measurement.** The workbook gives 주문가능수량 an empty Description column, so whether the
2575 // figure is already reduced by resting sell orders is unestablished. `DEDUCT` is therefore the conservative rule
2576 // this library applies, not a claim that KIS reports a gross figure: deducting resting sells
2577 // that the broker had also deducted over-refuses a trade, where the other error sells the same
2578 // shares twice. `verified_` stays false to say exactly that nobody has measured it.
2579 operation->snapshot_.sellable_basis_ = SellableBasis{
2582 false,
2583 };
2584 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(operation->snapshot_)));
2585 }
2586
2587 static Result<QuoteSnapshot> parse_quote(InstrumentId instrument, Result<network::HttpResponse> result) {
2588 if (!result) {
2589 return Result<QuoteSnapshot>(std::move(result.error()));
2590 }
2591 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2592 return Result<QuoteSnapshot>(kis_http_error(result.value()));
2593 }
2594 BrokerError parse_error;
2595 auto document = broker_detail::parse_object(result.value(), parse_error);
2596 if (!document) {
2597 return Result<QuoteSnapshot>(std::move(parse_error));
2598 }
2599 try {
2600 const auto result_code = broker_detail::json_string(*document, "rt_cd");
2601 if (result_code != "0") {
2602 BrokerError error;
2603 error.category_ = ErrorCategory::PROTOCOL;
2604 if (const auto code = document->if_contains("msg_cd"); code && code->is_string()) {
2605 error.broker_code_ = code->as_string().c_str();
2606 }
2607 if (const auto message = document->if_contains("msg1"); message && message->is_string()) {
2608 error.message_ = message->as_string().c_str();
2609 }
2610 return Result<QuoteSnapshot>(std::move(error));
2611 }
2612 const auto output = document->if_contains("output");
2613 if (!output || !output->is_object()) {
2614 throw std::runtime_error("missing object field: output");
2615 }
2616 if (
2617 const auto symbol = output->as_object().if_contains("stck_shrn_iscd");
2618 symbol && symbol->is_string() && symbol->as_string() != instrument.symbol_
2619 ) {
2620 throw std::runtime_error("quote response symbol does not match request");
2621 }
2622 QuoteSnapshot quote;
2623 quote.instrument_ = std::move(instrument);
2624 quote.last_price_ =
2625 Decimal64::parse(broker_detail::json_string(output->as_object(), "stck_prpr"));
2626 quote.currency_ = Currency::krw();
2627 quote.received_at_ = std::chrono::system_clock::now();
2628 return Result<QuoteSnapshot>(std::move(quote));
2629 } catch (const std::exception& exception) {
2630 BrokerError error;
2631 error.category_ = ErrorCategory::PROTOCOL;
2632 error.broker_code_ = "invalid_quote_response";
2633 error.message_ = exception.what();
2634 return Result<QuoteSnapshot>(std::move(error));
2635 }
2636 }
2637
2638 static Result<HoldingsSnapshot> parse_holdings(Result<network::HttpResponse> result) {
2639 std::string foreign_cursor;
2640 std::string domestic_cursor;
2641 return parse_holdings_page(
2642 std::move(result), foreign_cursor, domestic_cursor
2643 );
2644 }
2645
2646 static Result<HoldingsSnapshot> parse_holdings_page(
2647 Result<network::HttpResponse> result,
2648 std::string& foreign_cursor,
2649 std::string& domestic_cursor
2650 ) {
2651 if (!result) {
2652 return Result<HoldingsSnapshot>(std::move(result.error()));
2653 }
2654 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2655 return Result<HoldingsSnapshot>(kis_http_error(result.value()));
2656 }
2657 BrokerError parse_error;
2658 auto document = broker_detail::parse_object(result.value(), parse_error);
2659 if (!document) {
2660 return Result<HoldingsSnapshot>(std::move(parse_error));
2661 }
2662 try {
2663 const auto result_code = broker_detail::json_string(*document, "rt_cd");
2664 if (result_code != "0") {
2665 BrokerError error;
2666 error.category_ = ErrorCategory::PROTOCOL;
2667 if (const auto code = document->if_contains("msg_cd"); code && code->is_string()) {
2668 error.broker_code_ = code->as_string().c_str();
2669 }
2670 if (const auto message = document->if_contains("msg1"); message && message->is_string()) {
2671 error.message_ = message->as_string().c_str();
2672 }
2673 return Result<HoldingsSnapshot>(std::move(error));
2674 }
2675
2676 const auto positions = document->if_contains("output1");
2677 const auto summaries = document->if_contains("output2");
2678 if (!positions || !positions->is_array()) {
2679 throw std::runtime_error("missing array field: output1");
2680 }
2681 if (!summaries || !summaries->is_array()) {
2682 throw std::runtime_error("missing array field: output2");
2683 }
2684
2685 HoldingsSnapshot snapshot;
2686 for (const auto& value : positions->as_array()) {
2687 if (!value.is_object()) {
2688 throw std::runtime_error("output1 item is not an object");
2689 }
2690 const auto& output = value.as_object();
2691 HoldingPosition position;
2692 position.instrument_ = InstrumentId{
2693 Country::KR,
2694 instrument_venue(),
2695 broker_detail::json_string(output, "pdno"),
2696 };
2697 position.name_ = broker_detail::json_string(output, "prdt_name");
2698 position.quantity_ = Decimal64::parse(broker_detail::json_string(output, "hldg_qty"));
2699 position.sellable_quantity_ =
2700 Decimal64::parse(broker_detail::json_string(output, "ord_psbl_qty"));
2701 position.average_purchase_price_ =
2702 Decimal64::parse(broker_detail::json_string(output, "pchs_avg_pric"));
2703 position.last_price_ = Decimal64::parse(broker_detail::json_string(output, "prpr"));
2704 position.purchase_value_ = Money{
2705 Decimal64::parse(broker_detail::json_string(output, "pchs_amt")), Currency::krw()
2706 };
2707 position.market_value_ = Money{
2708 Decimal64::parse(broker_detail::json_string(output, "evlu_amt")), Currency::krw()
2709 };
2710 position.unrealized_profit_loss_ = Money{
2711 Decimal64::parse(broker_detail::json_string(output, "evlu_pfls_amt")), Currency::krw()
2712 };
2713 position.unrealized_return_ =
2714 broker_detail::percent_to_ratio(broker_detail::json_string(output, "evlu_pfls_rt"));
2715 snapshot.positions_.push_back(std::move(position));
2716 }
2717
2718 if (!summaries->as_array().empty()) {
2719 const auto& value = summaries->as_array().front();
2720 if (!value.is_object()) {
2721 throw std::runtime_error("output2 item is not an object");
2722 }
2723 const auto& output = value.as_object();
2724 snapshot.totals_.push_back(
2725 HoldingsTotals{
2726 Currency::krw(),
2727 Decimal64::parse(broker_detail::json_string(output, "pchs_amt_smtl_amt")),
2728 Decimal64::parse(broker_detail::json_string(output, "evlu_amt_smtl_amt")),
2729 Decimal64::parse(broker_detail::json_string(output, "evlu_pfls_smtl_amt")),
2730 }
2731 );
2732 }
2733
2734 if (
2735 const auto value = document->if_contains("ctx_area_fk100"); value && value->is_string()
2736 ) {
2737 foreign_cursor = value->as_string().c_str();
2738 }
2739 if (
2740 const auto value = document->if_contains("ctx_area_nk100"); value && value->is_string()
2741 ) {
2742 domestic_cursor = value->as_string().c_str();
2743 }
2744 return Result<HoldingsSnapshot>(std::move(snapshot));
2745 } catch (const std::exception& exception) {
2746 BrokerError error;
2747 error.category_ = ErrorCategory::PROTOCOL;
2748 error.broker_code_ = "invalid_holdings_response";
2749 error.message_ = exception.what();
2750 return Result<HoldingsSnapshot>(std::move(error));
2751 }
2752 }
2753
2754 // Mutable state shared across a paginated modifiable-order inquiry.
2755 struct OpenOrderOperation {
2756 KisAccount account_;
2757 KisOpenOrderPage page_;
2758 std::string foreign_cursor_;
2759 std::string domestic_cursor_;
2760 std::size_t page_count_{0};
2761 OpenOrderHandler completion_;
2762
2763 // Captures the selected account and final completion handler.
2764 OpenOrderOperation(KisAccount selected_account, OpenOrderHandler selected_completion)
2765 : account_(std::move(selected_account)), completion_(std::move(selected_completion)) {}
2766 };
2767
2768 // Mutable state shared across a paginated order-history inquiry.
2769 struct OrderHistoryOperation {
2770 KisAccount account_;
2771 KisHistoryRange range_;
2772 OrderPage page_;
2773 std::string foreign_cursor_;
2774 std::string domestic_cursor_;
2775 std::size_t page_count_{0};
2776 OrderPageHandler completion_;
2777
2778 // Captures the inquiry inputs and final completion handler.
2779 OrderHistoryOperation(
2780 KisAccount selected_account,
2781 KisHistoryRange selected_range,
2782 OrderPageHandler selected_completion
2783 )
2784 : account_(std::move(selected_account)), range_(selected_range),
2785 completion_(std::move(selected_completion)) {}
2786 };
2787
2788 // Returns an error when the account selector cannot address a KIS account.
2789 static std::optional<BrokerError> validate_account(const KisAccount& account) {
2790 if (
2791 broker_detail::decimal_digits(account.account_number_, 8) &&
2792 broker_detail::decimal_digits(account.product_code_, 2)
2793 ) {
2794 return std::nullopt;
2795 }
2796 BrokerError error;
2797 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2798 error.reached_broker_ = false;
2799 error.broker_code_ = "invalid_account";
2800 error.message_ = "KIS account number must contain eight digits and product code two digits";
2801 return error;
2802 }
2803
2804 // Returns an error when a quantity is not a positive whole number of shares.
2805 static std::optional<BrokerError> validate_quantity(const Decimal64& quantity) {
2806 if (quantity.coefficient() > 0 && quantity.scale() == 0) {
2807 return std::nullopt;
2808 }
2809 BrokerError error;
2810 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2811 error.reached_broker_ = false;
2812 error.broker_code_ = "invalid_quantity";
2813 error.message_ = "KIS order quantity must be a positive whole number of shares";
2814 return error;
2815 }
2816
2817 // Returns an error when a price does not match its order type.
2818 //
2819 // A limit order without a price and a market order carrying a price are both
2820 // rejected before any network I/O, because either would make the transmitted
2821 // order differ from the one the caller described.
2822 static std::optional<BrokerError> validate_price(
2823 OrderType type,
2824 const std::optional<Decimal64>& price
2825 ) {
2826 BrokerError error;
2827 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2828 error.reached_broker_ = false;
2829 error.broker_code_ = "invalid_price";
2830 // `carries_caller_price`, not `== LIMIT`. 조건부지정가 (`02`) is a limit
2831 // instruction until the close and this refused the price it needs while
2832 // admitting one without, which `make_place_order_request` then sent as
2833 // `ORD_UNPR="0"` -- an order at zero rather than at the stated limit.
2834 if (carries_caller_price(type)) {
2835 if (!price) {
2836 error.message_ = "KIS order of this price basis requires a price";
2837 return error;
2838 }
2839 if (price->coefficient() <= 0) {
2840 error.message_ = "KIS order price must be positive";
2841 return error;
2842 }
2843 if (price->scale() != 0) {
2844 error.message_ = "KIS domestic order price must be a whole number of won";
2845 return error;
2846 }
2847 return std::nullopt;
2848 }
2849 if (price) {
2850 error.message_ =
2851 "KIS takes no price for this basis; a market, best-limit, primary-limit, "
2852 "mid-price or closing-price order is priced by the book";
2853 return error;
2854 }
2855 return std::nullopt;
2856 }
2857
2858 // Returns whether `trigger` is a value KIS's `CNDT_PRIC` can carry: positive, whole won.
2859 static bool valid_trigger_value(const Decimal64& trigger) {
2860 return trigger.coefficient() > 0 && trigger.scale() == 0;
2861 }
2862
2863 // Returns an error when a trigger price does not match its order type.
2864 //
2865 // `22` 스톱지정가 needs one -- the 주식주문(현금) sheet documents `CNDT_PRIC` as "스탑지정가호가
2866 // 주문 (ORD_DVSN이 22) 사용 시에만 필수" -- and every other code has nowhere to put one, so a
2867 // trigger stated on it would be dropped and the order sent as something the caller did not
2868 // describe. Neither is sent: a stated condition is honoured or refused, never dropped.
2869 //
2870 // Nothing about the trigger's direction or its relation to the limit is checked: the sheet
2871 // documents neither, and a local rule KIS does not state would refuse orders KIS accepts.
2872 static std::optional<BrokerError> validate_trigger(
2873 OrderType type,
2874 const std::optional<Decimal64>& trigger
2875 ) {
2876 BrokerError error;
2877 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2878 error.reached_broker_ = false;
2879 error.broker_code_ = "invalid_trigger_price";
2880 if (carries_trigger_price(type)) {
2881 if (!trigger) {
2882 error.message_ = "a KIS stop-limit order (22) requires a trigger price in CNDT_PRIC";
2883 return error;
2884 }
2885 if (!valid_trigger_value(*trigger)) {
2886 error.message_ = "a KIS trigger price must be a positive whole number of won";
2887 return error;
2888 }
2889 return std::nullopt;
2890 }
2891 if (trigger) {
2892 error.message_ =
2893 "KIS takes a trigger price only on a stop-limit order (22); on this basis it would be "
2894 "dropped";
2895 return error;
2896 }
2897 return std::nullopt;
2898 }
2899
2900 // One row of KIS's `ORD_DVSN` table: a wire code and the three facts it means.
2901 //
2902 // `ORD_DVSN` is a product, not a name. `16` is a best-limit order that is
2903 // filled-or-killed in the regular session; `45` is a best-limit order that is
2904 // immediate-or-cancel in the after-market. Holding the table as rows of the
2905 // product is what lets an adapter answer "which code is this combination" and
2906 // "which combination is this code" from one transcription, in both
2907 // directions, without either being derived from the other.
2908 //
2909 // `venues_` is a bitmask because the three venues publish *different* code
2910 // sets for the same combination set: the sheet lists `01` (시장가) under
2911 // `[KRX]` and `[SOR]` and not under `[NXT]`, and lists `27`-`29` (GTP) under
2912 // `[NXT]` alone. A combination is not "supported by KIS"; it is supported at a
2913 // venue.
2914 struct OrderShapeRow {
2915 const char* code_;
2916 OrderType type_;
2917 TimeInForce time_in_force_;
2918 OrderSession session_;
2919 unsigned venues_;
2920 bool requestable_;
2921 };
2922
2923 static constexpr unsigned kKrx = 1u << 0;
2924 static constexpr unsigned kNxt = 1u << 1;
2925 static constexpr unsigned kSor = 1u << 2;
2926
2927 // KIS's `ORD_DVSN` table, transcribed from the API workbook dated `20260916`.
2928 //
2929 // Two sheets contribute and they do not agree, which is why both are named
2930 // here rather than one being trusted for both directions:
2931 //
2932 // - `주식주문(현금)` is the **request** table. It carries `41`-`47` (KRX
2933 // 애프터마켓) and `27`-`29` (NXT GTP), and does *not* carry `07`.
2934 // - `주식정정취소가능주문조회` is a **response** table. It carries `07`
2935 // (시간외 단일가) and carries neither `41`-`47` nor `27`-`29`.
2936 //
2937 // So the set this adapter must *read* is the union and the set it may *send*
2938 // is the request sheet's alone. `requestable_` is that distinction: a row with
2939 // `false` is normalized on a response and refused on a request, because
2940 // sending a code the order sheet does not publish is guessing.
2941 //
2942 // A combination absent from this table is refused before any network I/O, and
2943 // a code absent from it reads as `UNKNOWN` with the raw code preserved. Neither
2944 // is approximated by a neighbouring value.
2945 static constexpr std::array<OrderShapeRow, 28> kOrderShapes{{
2946 // code type condition session
2947 {"00", OrderType::LIMIT, TimeInForce::DAY, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2948 {"01", OrderType::MARKET, TimeInForce::DAY, OrderSession::REGULAR, kKrx | kSor, true},
2950 {"03", OrderType::BEST_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2951 {"04", OrderType::PRIMARY_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2954 // Response-only: the order sheet does not publish 07. Reading 시간외 단일가
2955 // as a *limit* in an auction session is an inference, not something either
2956 // sheet states -- they name the auction without giving its price rule. It is
2957 // the reading that makes the code factor, and the field it would be wrong
2958 // about is one this library cannot send anyway.
2961 {"12", OrderType::LIMIT, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2963 {"14", OrderType::MARKET, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2965 {"16", OrderType::BEST_LIMIT, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, kKrx | kNxt | kSor, true},
2966 {"21", OrderType::MID_PRICE, TimeInForce::DAY, OrderSession::REGULAR, kKrx | kNxt, true},
2980 }};
2981
2982 // Returns the venue bit an `EXCG_ID_DVSN_CD` value stands for.
2983 static constexpr unsigned venue_bit(Venue venue) {
2984 switch (venue) {
2985 case Venue::KRX:
2986 return kKrx;
2987 case Venue::NXT:
2988 return kNxt;
2989 // The combined book is routed across both venues, which is what KIS calls
2990 // SOR. See `exchange_code` for why this is an explicit value rather than
2991 // an omitted field.
2992 case Venue::COMBINED:
2993 return kSor;
2994 // Nothing was asked for, so nothing is sent, and the sheet says an
2995 // omitted `EXCG_ID_DVSN_CD` proceeds as KRX. The code set checked is
2996 // therefore KRX's -- the one the order will actually meet.
2997 case Venue::UNSPECIFIED:
2998 return kKrx;
2999 }
3000 return kKrx;
3001 }
3002
3003 // Maps a normalized order shape to KIS's `ORD_DVSN` code for a venue.
3004 //
3005 // A combination this library has no code for, or has a code for at another
3006 // venue, is rejected with `INVALID_ARGUMENT` before any token fetch or socket
3007 // write. It is never sent under a neighbouring code: a condition the caller
3008 // stated is honoured or refused, and the failure
3009 // this replaces was silent — a market order bound for NXT was sent as `01`,
3010 // which the sheet lists under `[KRX]` and `[SOR]` and not under `[NXT]`.
3011 static std::optional<BrokerError> order_division(
3012 OrderType type,
3013 TimeInForce time_in_force,
3014 OrderSession session,
3015 Venue venue,
3016 std::string& division
3017 ) {
3018 BrokerError error;
3019 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3020 error.reached_broker_ = false;
3021 error.broker_code_ = "unsupported_order";
3022
3023 const auto* matched = static_cast<const OrderShapeRow*>(nullptr);
3024 for (const auto& row : kOrderShapes) {
3025 if (row.type_ == type && row.time_in_force_ == time_in_force && row.session_ == session) {
3026 matched = &row;
3027 break;
3028 }
3029 }
3030 if (matched == nullptr) {
3031 error.message_ =
3032 std::string("KIS publishes no order code for this combination of price basis, ") +
3033 "validity condition and session";
3034 return error;
3035 }
3036 if (!matched->requestable_) {
3037 error.message_ =
3038 std::string("KIS reports order code ") + matched->code_ +
3039 " on an inquiry but does not publish it as a placeable order";
3040 return error;
3041 }
3042 if ((matched->venues_ & venue_bit(venue)) == 0) {
3043 error.message_ =
3044 std::string("KIS order code ") + matched->code_ + " is not offered at " +
3045 exchange_code(venue) + "; the venue publishes its own set of order codes";
3046 return error;
3047 }
3048 division = matched->code_;
3049 return std::nullopt;
3050 }
3051
3052 // Returns whether a symbol has one of KIS's domestic product-number forms.
3053 //
3054 // KIS documents `PDNO` as a six-character stock code, or seven characters for
3055 // an ETN. Both lengths are accepted so an ETN order is not rejected locally
3056 // for a length the broker itself documents.
3057 static bool valid_domestic_symbol(std::string_view symbol) {
3058 if (symbol.size() != 6 && symbol.size() != 7) {
3059 return false;
3060 }
3061 for (const char ch : symbol) {
3062 const bool valid = (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z') ||
3063 (ch >= 'a' && ch <= 'z');
3064 if (!valid) {
3065 return false;
3066 }
3067 }
3068 return true;
3069 }
3070
3071 // Returns whether an order reference can address a KIS order for amendment.
3072 //
3073 // The order number is always required and always ten digits. The branch
3074 // number may be empty: KIS's own documented `order-rvsecncl` request example
3075 // sends `KRX_FWDG_ORD_ORGNO` blank, so requiring it would reject a usage the
3076 // broker publishes. When it is supplied it must be the documented five
3077 // digits, which is what an acknowledgement or an open-order row returns.
3078 static bool valid_order_reference(const KisOrderRef& order) {
3079 if (!broker_detail::decimal_digits(order.order_number_, 10)) {
3080 return false;
3081 }
3082 return order.branch_number_.empty() ||
3083 broker_detail::decimal_digits(order.branch_number_, 5);
3084 }
3085
3086 // Returns an error when the order cannot be represented as a KIS cash order.
3087 static std::optional<BrokerError> validate_order(
3088 const KisConfig& config,
3089 const OrderRequest& order,
3090 std::string& division
3091 ) {
3092 if (auto error = unsupported_client_order_id(order)) {
3093 return error;
3094 }
3095 if (order.instrument_.country_ != Country::KR) {
3096 BrokerError error;
3097 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3098 error.reached_broker_ = false;
3099 error.broker_code_ = "invalid_instrument";
3100 error.message_ = "KIS cash orders currently support Korean equities only";
3101 return error;
3102 }
3103 // KIS reports every Korean instrument at one identity venue, and a holding
3104 // carries no venue at all -- so an order whose `instrument_.venue_` said
3105 // anything else would not be the same `InstrumentId` as its own holding, and
3106 // `Portfolio::working_quantity` would read zero resting quantity for it.
3107 // A caller who set it there means *routing*, which is `OrderRequest::venue_`;
3108 // refused rather than routed on, because routing on it silently is exactly
3109 // how the resting quantity would go missing.
3110 if (order.instrument_.venue_ != instrument_venue()) {
3111 BrokerError error;
3112 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3113 error.reached_broker_ = false;
3114 error.broker_code_ = "invalid_instrument";
3115 error.message_ =
3116 "KIS identifies a Korean instrument by its combined venue; set OrderRequest::venue_ to "
3117 "route the order to a particular exchange instead of naming one on the instrument";
3118 return error;
3119 }
3120 if (!valid_domestic_symbol(order.instrument_.symbol_)) {
3121 BrokerError error;
3122 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3123 error.reached_broker_ = false;
3124 error.broker_code_ = "invalid_instrument";
3125 error.message_ =
3126 "KIS domestic order symbol must contain six letters or digits, or seven for an ETN";
3127 return error;
3128 }
3129 if (auto error = validate_quantity(order.quantity_)) {
3130 return error;
3131 }
3132 if (auto error = validate_price(order.type_, order.price_)) {
3133 return error;
3134 }
3135 if (auto error = validate_trigger(order.type_, order.trigger_price_)) {
3136 return error;
3137 }
3138 if (auto error = validate_venue(config, order.venue_)) {
3139 return error;
3140 }
3141 return order_division(
3142 order.type_,
3143 order.time_in_force_,
3144 order.session_,
3145 order.venue_,
3146 division
3147 );
3148 }
3149
3150 // Returns an error when an amendment cannot be represented as a KIS instruction.
3151 //
3152 // The counterpart of `validate_order`, and it exists as its own function for
3153 // the same reason: the decision a test needs to exercise is the one the
3154 // submission path takes, not one a test re-derives from the same inputs. A
3155 // shim that called `order_division` directly would agree with a submission
3156 // path that had stopped calling it -- which is not hypothetical, because the
3157 // first version of the amendment test did exactly that and survived the
3158 // mutation it had been written to catch.
3159 static std::optional<BrokerError> validate_amendment(
3160 const KisConfig& config,
3161 const KisAmendment& amendment,
3162 std::string& division,
3163 bool cancel = false
3164 ) {
3165 if (auto error = validate_quantity(amendment.quantity_)) {
3166 return error;
3167 }
3168 if (auto error = validate_price(amendment.type_, amendment.price_)) {
3169 return error;
3170 }
3171 // A modification states the order it leaves behind, so its trigger is held to the placement
3172 // rule. A cancellation is not refused for its trigger at all: `make_amend_order_request` sends
3173 // `CNDT_PRIC` only when it is a valid trigger for a `22` shape, so a stop order whose trigger
3174 // KIS never reported back can still be withdrawn.
3175 if (!cancel) {
3176 if (auto error = validate_trigger(amendment.type_, amendment.trigger_price_)) {
3177 return error;
3178 }
3179 }
3180 // The amendment carries its own condition and session. Sending `DAY` here,
3181 // as this did, re-shaped the order: an immediate-or-cancel order amended to
3182 // a new price arrived as a plain day order at that price.
3183 if (auto error = validate_venue(config, amendment.venue_)) {
3184 return error;
3185 }
3186 return order_division(
3187 amendment.type_,
3188 amendment.time_in_force_,
3189 amendment.session_,
3190 amendment.venue_,
3191 division
3192 );
3193 }
3194
3195 // Returns KIS's `EXCG_ID_DVSN_CD` value for a normalized venue, or `""` to
3196 // omit the field.
3197 //
3198 // `Venue::COMBINED` maps to `SOR`, which is what KIS calls routing an order
3199 // across both books. `Venue::UNSPECIFIED` omits the field and takes the
3200 // broker's documented default of KRX, and those are not the same thing: one
3201 // asks for something and the other asks for nothing.
3202 //
3203 // This reads `OrderRequest::venue_`, never the instrument's. Routing is not
3204 // part of an instrument's identity, and an order whose identity carried a
3205 // route would stop matching its own holding.
3206 //
3207 // The sheet's demo restriction -- 모의투자는 KRX만 가능 -- **is** enforced,
3208 // by `validate_venue` above, and `transmitted_exchange` omits the field
3209 // entirely on a demo account. It was not enforced until it became clear
3210 // that making `COMBINED` mean `SOR` had turned the most ordinary demo order
3211 // into one the account would refuse, after its POST.
3212 // Returns an error when a venue is unusable on this configuration.
3213 //
3214 // Both KIS order sheets say `모의투자는 KRX만 가능`: a demo account routes to
3215 // the Korea Exchange and nowhere else. That restriction is per credential
3216 // rather than per broker, so it is a **run-time** error
3217 // rather than a compile-time capability -- one binary must not claim
3218 // different things for a live key and a demo key.
3219 //
3220 // It is checked here because it became reachable. `Venue::COMBINED` is the
3221 // default `venue_` of every `InstrumentId`, and it now sends `SOR`; before
3222 // that it omitted the field and KIS routed the order to KRX, so an ordinary
3223 // demo order worked by accident. Sending `SOR` and learning the restriction
3224 // from the response would break every default demo placement, and would
3225 // break it *after* a POST this library may never replay.
3226 //
3227 // Routing is opt-in, so a demo account that names **no** venue is the ordinary
3228 // case and is fine: the field is omitted and KIS applies its documented
3229 // default of KRX. An explicit `KRX` is fine for the same reason. `NXT` and
3230 // `COMBINED` are refused, because each names something the account cannot
3231 // reach -- `COMBINED` is SOR, which routes across venues the demo
3232 // environment does not have.
3233 //
3234 // **This is why there is no separate "which code set does a demo request
3235 // satisfy" step.** There was one, and a surviving mutation showed it could not
3236 // change any outcome: once this refuses `NXT` and `COMBINED`, the only venues a
3237 // demo request can carry are `UNSPECIFIED` and `KRX`, and both already select
3238 // KRX's code set. A branch describing a state that cannot happen is worse than
3239 // no branch, because a reader who sees it believes it can.
3240 static std::optional<BrokerError> validate_venue(const KisConfig& config, Venue venue) {
3241 if (!config.is_demo_ || venue == Venue::UNSPECIFIED || venue == Venue::KRX) {
3242 return std::nullopt;
3243 }
3244 BrokerError error;
3245 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3246 error.reached_broker_ = false;
3247 error.broker_code_ = "venue_unavailable";
3248 error.message_ =
3249 "KIS demo accounts trade on KRX only; the requested venue is unavailable on this "
3250 "configuration";
3251 return error;
3252 }
3253
3254 // Returns the `EXCG_ID_DVSN_CD` value to transmit, or `""` to omit the field.
3255 //
3256 // Omitted on a demo account: there is one venue, KIS documents an omitted
3257 // field as proceeding at KRX, and naming a venue the sheet says demo does not
3258 // support is how the default demo order broke.
3259 static const char* transmitted_exchange(const KisConfig& config, Venue venue) {
3260 return config.is_demo_ ? "" : exchange_code(venue);
3261 }
3262
3263 static const char* exchange_code(Venue venue) {
3264 switch (venue) {
3265 case Venue::KRX:
3266 return "KRX";
3267 case Venue::NXT:
3268 return "NXT";
3269 case Venue::COMBINED:
3270 return "SOR";
3271 case Venue::UNSPECIFIED:
3272 return "";
3273 }
3274 return "";
3275 }
3276
3277 // Builds the authenticated KIS cash-order POST request.
3278 static network::HttpRequest make_place_order_request(
3279 const KisAccount& account,
3280 const KisConfig& config,
3281 std::string_view access_token,
3282 const OrderRequest& order,
3283 std::string_view division
3284 ) {
3285 json::object body{
3286 {"CANO", account.account_number_},
3287 {"ACNT_PRDT_CD", account.product_code_},
3288 {"PDNO", order.instrument_.symbol_},
3289 {"ORD_DVSN", std::string(division)},
3290 {"ORD_QTY", order.quantity_.str()},
3291 {"ORD_UNPR", order.price_ ? order.price_->str() : std::string("0")},
3292 };
3293 // Only when stated, which `validate_trigger` has restricted to `22`. The sheet marks the field
3294 // optional, and leaving it off every other body keeps those byte-identical to what KIS has
3295 // always been sent.
3296 if (order.trigger_price_) {
3297 body.emplace("CNDT_PRIC", order.trigger_price_->str());
3298 }
3299 if (const auto* exchange = transmitted_exchange(config, order.venue_);
3300 *exchange != '\0') {
3301 body.emplace("EXCG_ID_DVSN_CD", exchange);
3302 }
3303
3304 const bool buy = order.side_ == OrderSide::BUY;
3305 network::HttpRequest request;
3306 // An order is what the reserve is held for.
3307 request.group_ = network::RateLimitGroup::TRADING;
3308 request.method_ = http::verb::post;
3309 request.target_ = "/uapi/domestic-stock/v1/trading/order-cash";
3310 request.body_ = json::serialize(body);
3311 request.headers_ = {
3312 {"authorization", "Bearer " + std::string(access_token)},
3313 {"appkey", config.key()},
3314 {"appsecret", config.secret()},
3315 {"tr_id", buy ?
3316 (config.is_demo_ ? "VTTC0012U" : "TTTC0012U") :
3317 (config.is_demo_ ? "VTTC0011U" : "TTTC0011U")},
3318 {"custtype", "P"},
3319 {"content-type", "application/json; charset=utf-8"},
3320 };
3321 return request;
3322 }
3323
3324 // Builds the authenticated KIS modification or cancellation POST request.
3325 static network::HttpRequest make_amend_order_request(
3326 const KisAccount& account,
3327 const KisConfig& config,
3328 std::string_view access_token,
3329 const KisAmendment& amendment,
3330 bool cancel,
3331 std::string_view division
3332 ) {
3333 json::object body{
3334 {"CANO", account.account_number_},
3335 {"ACNT_PRDT_CD", account.product_code_},
3336 {"KRX_FWDG_ORD_ORGNO", amendment.order_.branch_number_},
3337 {"ORGN_ODNO", amendment.order_.order_number_},
3338 {"ORD_DVSN", std::string(division)},
3339 {"RVSE_CNCL_DVSN_CD", cancel ? "02" : "01"},
3340 {"ORD_QTY", amendment.quantity_.str()},
3341 {"ORD_UNPR", amendment.price_ ? amendment.price_->str() : std::string("0")},
3342 {"QTY_ALL_ORD_YN", amendment.all_remaining_ ? "Y" : "N"},
3343 };
3344 // On a modification `validate_amendment` has already required this exactly for `22`. On a
3345 // cancellation nothing was refused, so the same test decides whether to restate it.
3346 if (amendment.trigger_price_ && carries_trigger_price(amendment.type_) &&
3347 valid_trigger_value(*amendment.trigger_price_)) {
3348 body.emplace("CNDT_PRIC", amendment.trigger_price_->str());
3349 }
3350 // The 주식주문(정정취소) sheet publishes `EXCG_ID_DVSN_CD` and the same
3351 // per-venue `ORD_DVSN` sets as the placement sheet, and this never sent it:
3352 // an amendment to an order living at NXT was addressed to KRX, the sheet's
3353 // documented default for an omitted field.
3354 if (const auto* exchange = transmitted_exchange(config, amendment.venue_);
3355 *exchange != '\0') {
3356 body.emplace("EXCG_ID_DVSN_CD", exchange);
3357 }
3358
3359 network::HttpRequest request;
3360 // A modification and a cancellation share the order endpoint and its priority.
3361 request.group_ = network::RateLimitGroup::TRADING;
3362 request.method_ = http::verb::post;
3363 request.target_ = "/uapi/domestic-stock/v1/trading/order-rvsecncl";
3364 request.body_ = json::serialize(body);
3365 request.headers_ = {
3366 {"authorization", "Bearer " + std::string(access_token)},
3367 {"appkey", config.key()},
3368 {"appsecret", config.secret()},
3369 {"tr_id", config.is_demo_ ? "VTTC0013U" : "TTTC0013U"},
3370 {"custtype", "P"},
3371 {"content-type", "application/json; charset=utf-8"},
3372 };
3373 return request;
3374 }
3375
3376 // Builds the error for a non-2xx KIS response: the generic one, plus KIS's own code from the body.
3377 //
3378 // KIS answers its per-second limit (`EGW00201`) with HTTP 500 and a JSON body, so the generic error
3379 // left `broker_code_` empty and the limit unclassified (observed on reads, 2026-10-02). The code is
3380 // read from `msg_cd`, or the token endpoint's `error_code`. `classify_rate` also maps a rate code to
3381 // `RATE_LIMIT`, as `kis_declared_error` does for a 2xx body. **The order path passes `false`:** an
3382 // HTTP 5xx on an order stays indeterminate, held until the order list resolves it, because a rate
3383 // limit is retried and a retry of an order KIS had in fact processed would double it. An order
3384 // refused this way has not been observed.
3385 static BrokerError kis_http_error(const network::HttpResponse& response, bool classify_rate = true) {
3386 auto error = broker_detail::http_error(response);
3387 boost::system::error_code ec;
3388 const auto document = json::parse(response.body(), ec);
3389 if (ec || !document.is_object()) {
3390 return error;
3391 }
3392 for (const char* field : {"msg_cd", "error_code"}) {
3393 if (const auto* code = document.as_object().if_contains(field); code && code->is_string()) {
3394 error.broker_code_ = code->as_string().c_str();
3395 break;
3396 }
3397 }
3398 if (classify_rate && kis_rate_limit_layer(error.broker_code_) != KisRateLimitLayer::NONE) {
3399 error.category_ = ErrorCategory::RATE_LIMIT;
3400 }
3401 return error;
3402 }
3403
3404 // Returns KIS's declared failure for a response whose `rt_cd` is not `0`.
3405 static std::optional<BrokerError> kis_declared_error(const json::object& document) {
3406 if (broker_detail::json_string(document, "rt_cd") == "0") {
3407 return std::nullopt;
3408 }
3409 BrokerError error;
3410 error.category_ = ErrorCategory::PROTOCOL;
3411 if (const auto code = document.if_contains("msg_cd"); code && code->is_string()) {
3412 error.broker_code_ = code->as_string().c_str();
3413 }
3414 if (const auto message = document.if_contains("msg1"); message && message->is_string()) {
3415 error.message_ = message->as_string().c_str();
3416 }
3417
3418 // KIS reports a rate limit in the response body rather than as HTTP 429 with
3419 // a Retry-After header, so it has to be recognized here or it reaches the
3420 // caller as an ordinary protocol failure.
3421 if (kis_rate_limit_layer(error.broker_code_) != KisRateLimitLayer::NONE) {
3422 error.category_ = ErrorCategory::RATE_LIMIT;
3423 }
3424 return error;
3425 }
3426
3427 // Which order instruction a reply answers. Known where the request is built and carried to the
3428 // parser with it; never recovered from the reply.
3429 enum class OrderOperation { UNCLASSIFIED, PLACE, AMEND, CANCEL };
3430
3431 // Tests whether a field is absent or the empty string. Any other type or value is not "nothing".
3432 static bool absent_or_empty(const json::object& object, std::string_view key) {
3433 const auto value = object.if_contains(key);
3434 return !value || (value->is_string() && value->as_string().empty());
3435 }
3436
3437 // Returns the `rt_cd` a refusal this client recognizes carries, for `code` answering `operation`
3438 // from the demo server (`demo`) or the real one, or nothing if `code` is not such a refusal. Each
3439 // code is recognized from the server it was seen from only, with the `rt_cd` it was seen with.
3440 static std::optional<std::string_view> recognized_refusal_status(
3441 OrderOperation operation,
3442 std::string_view code,
3443 bool demo
3444 ) {
3445 if (!demo) {
3446 if (operation == OrderOperation::PLACE && code == "APBK3061") {
3447 return std::string_view("7");
3448 }
3449 return std::nullopt;
3450 }
3451 if (operation == OrderOperation::PLACE && (code == "40240000" || code == "40570000" || code == "40580000")) {
3452 return std::string_view("1");
3453 }
3454 if (operation == OrderOperation::CANCEL && code == "40330000") {
3455 return std::string_view("1");
3456 }
3457 return std::nullopt;
3458 }
3459
3460 // Returns the refusal a cash-order reply states, for the replies this client recognizes as one.
3461 // From the demo server: a placement answered `40240000` (nothing held to sell), `40570000` (the
3462 // market has not opened) or `40580000` (the market has closed), and a cancellation answered
3463 // `40330000` (nothing left to amend or cancel).
3464 // From the real server: a placement answered `APBK3061` (during the after-market session, only
3465 // after-market order types are accepted). Such a reply is `INVALID_ARGUMENT` with
3466 // `reached_broker_` set, which a runner reads as a rejection; any other declared failure of an
3467 // order keeps the reading `kis_declared_error` gives it (`PROTOCOL`, an unknown outcome, unless it
3468 // is a rate limit).
3469 //
3470 // Each code is recognized from the server it was seen from only. The reply must be HTTP 200 with
3471 // the code, the `rt_cd` it was seen with (`"1"` from the demo server, `"7"` for `APBK3061`), a message, and none of the fields an acknowledgement carries (`output`
3472 // absent, or an object whose `ODNO`, `KRX_FWDG_ORD_ORGNO` and `ORD_TMD` are absent or empty): a
3473 // reply acknowledging an order is never read as a refusal. A refused cancellation says only that the cancellation did nothing, not what
3474 // became of the order it named.
3475 static std::optional<BrokerError> kis_recognized_refusal(
3476 int http_status,
3477 const json::object& document,
3478 OrderOperation operation,
3479 bool demo
3480 ) {
3481 if (http_status != 200) {
3482 return std::nullopt;
3483 }
3484 const auto status = document.if_contains("rt_cd");
3485 const auto code = document.if_contains("msg_cd");
3486 const auto message = document.if_contains("msg1");
3487 if (!code || !code->is_string()) {
3488 return std::nullopt;
3489 }
3490 const auto expected = recognized_refusal_status(operation, std::string_view(code->as_string()), demo);
3491 if (!expected || !status || !status->is_string() || std::string_view(status->as_string()) != *expected ||
3492 !message || !message->is_string() || message->as_string().empty()) {
3493 return std::nullopt;
3494 }
3495 const std::string stated(code->as_string());
3496 const char* name = operation == OrderOperation::PLACE ? "placement" : "cancellation";
3497 if (const auto output = document.if_contains("output")) {
3498 if (!output->is_object()) {
3499 return std::nullopt;
3500 }
3501 for (const char* field : {"ODNO", "KRX_FWDG_ORD_ORGNO", "ORD_TMD"}) {
3502 if (!absent_or_empty(output->as_object(), field)) {
3503 return std::nullopt;
3504 }
3505 }
3506 }
3507 BrokerError error;
3508 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3509 error.reached_broker_ = true;
3510 error.http_status_ = http_status;
3511 error.broker_code_ = stated;
3512 error.message_ = std::string(message->as_string()) + " (recognized refusal: KIS " + (demo ? "demo" : "real") +
3513 ", cash " + name + ", " + stated + ")";
3514 return error;
3515 }
3516
3517 // Parses a KIS order-instruction response into its composite acknowledgement. `operation` and
3518 // `demo` describe the request this reply answers; left out, no refusal is recognized.
3519 static Result<KisOrderAck> parse_order_ack(
3520 Result<network::HttpResponse> result,
3521 OrderOperation operation = OrderOperation::UNCLASSIFIED,
3522 bool demo = false
3523 ) {
3524 if (!result) {
3525 return Result<KisOrderAck>(std::move(result.error()));
3526 }
3527 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3528 return Result<KisOrderAck>(kis_http_error(result.value(), /*classify_rate=*/false));
3529 }
3530 BrokerError parse_error;
3531 auto document = broker_detail::parse_object(result.value(), parse_error);
3532 if (!document) {
3533 return Result<KisOrderAck>(std::move(parse_error));
3534 }
3535 try {
3536 if (auto refused = kis_recognized_refusal(result.value().result_int(), *document, operation, demo)) {
3537 return Result<KisOrderAck>(std::move(*refused));
3538 }
3539 if (auto declared = kis_declared_error(*document)) {
3540 return Result<KisOrderAck>(std::move(*declared));
3541 }
3542 const auto output = document->if_contains("output");
3543 if (!output || !output->is_object()) {
3544 throw std::runtime_error("missing object field: output");
3545 }
3546 const auto& fields = output->as_object();
3547
3548 KisOrderAck ack;
3549 ack.order_.branch_number_ = broker_detail::json_string(fields, "KRX_FWDG_ORD_ORGNO");
3550 ack.order_.order_number_ = broker_detail::json_string(fields, "ODNO");
3551 ack.broker_order_time_ = broker_detail::json_string(fields, "ORD_TMD");
3552 ack.ack_.order_id_ = ack.order_.order_number_;
3553 ack.ack_.accepted_at_ = std::chrono::system_clock::now();
3554 return Result<KisOrderAck>(std::move(ack));
3555 } catch (const std::exception& exception) {
3556 BrokerError error;
3557 error.category_ = ErrorCategory::PROTOCOL;
3558 error.broker_code_ = "invalid_order_response";
3559 error.message_ = exception.what();
3560 return Result<KisOrderAck>(std::move(error));
3561 }
3562 }
3563
3564 // Validates and starts one KIS cash-order submission.
3565 void start_place_order(KisAccount account, OrderRequest order, OrderAckHandler completion) {
3566 std::string division;
3567 auto invalid = validate_account(account);
3568 if (!invalid) {
3569 invalid = validate_order(config_, order, division);
3570 }
3571 if (invalid) {
3572 complete_order_ack(std::move(completion), Result<KisOrderAck>(std::move(*invalid)));
3573 return;
3574 }
3575
3576 ensure_token(
3577 TokenHandler(
3578 [
3579 self = shared_from_this(),
3580 account = std::move(account),
3581 order = std::move(order),
3582 division = std::move(division),
3583 completion = std::move(completion)
3584 ](std::optional<BrokerError> token_error) mutable {
3585 if (token_error) {
3586 self->complete_order_ack(
3587 std::move(completion), Result<KisOrderAck>(std::move(*token_error))
3588 );
3589 return;
3590 }
3591 auto request = make_place_order_request(
3592 account, self->config_, self->token_.access_token_, order, division
3593 );
3594 self->request_order_ack(std::move(request), OrderOperation::PLACE, std::move(completion));
3595 }
3596 )
3597 );
3598 }
3599
3600 // Validates and starts one KIS modification or cancellation submission.
3601 void start_amend_order(
3602 KisAccount account,
3603 KisAmendment amendment,
3604 bool cancel,
3605 OrderAckHandler completion
3606 ) {
3607 std::string division;
3608 auto invalid = validate_account(account);
3609 if (!invalid && !valid_order_reference(amendment.order_)) {
3610 BrokerError error;
3611 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3612 error.reached_broker_ = false;
3613 error.broker_code_ = "invalid_order_reference";
3614 error.message_ =
3615 "KIS amendment requires a ten-digit order number and an empty or "
3616 "five-digit branch number";
3617 invalid = std::move(error);
3618 }
3619 if (!invalid) {
3620 invalid = validate_amendment(config_, amendment, division, cancel);
3621 }
3622 if (invalid) {
3623 complete_order_ack(std::move(completion), Result<KisOrderAck>(std::move(*invalid)));
3624 return;
3625 }
3626
3627 ensure_token(
3628 TokenHandler(
3629 [
3630 self = shared_from_this(),
3631 account = std::move(account),
3632 amendment = std::move(amendment),
3633 cancel,
3634 division = std::move(division),
3635 completion = std::move(completion)
3636 ](std::optional<BrokerError> token_error) mutable {
3637 if (token_error) {
3638 self->complete_order_ack(
3639 std::move(completion), Result<KisOrderAck>(std::move(*token_error))
3640 );
3641 return;
3642 }
3643 auto request = make_amend_order_request(
3644 account, self->config_, self->token_.access_token_, amendment, cancel, division
3645 );
3646 self->request_order_ack(
3647 std::move(request), cancel ? OrderOperation::CANCEL : OrderOperation::AMEND, std::move(completion)
3648 );
3649 }
3650 )
3651 );
3652 }
3653
3654 // Sends an order-writing request and dispatches its parsed acknowledgement. The operation and the
3655 // environment are fixed here, with the request, and travel with its reply.
3656 void request_order_ack(
3657 network::HttpRequest request,
3658 OrderOperation operation,
3659 OrderAckHandler completion
3660 ) {
3661 http_->async_request(
3662 std::move(request),
3663 [self = shared_from_this(), operation, demo = config_.is_demo_, completion = std::move(completion)](
3664 Result<network::HttpResponse> result
3665 ) mutable {
3666 net::post(
3667 self->strand_,
3668 [self, operation, demo, completion = std::move(completion), result = std::move(result)]() mutable {
3669 self->complete_order_ack(
3670 std::move(completion), parse_order_ack(std::move(result), operation, demo)
3671 );
3672 }
3673 );
3674 }
3675 );
3676 }
3677
3678 // Validates and starts a complete modifiable-order inquiry.
3679 void start_open_orders(KisAccount account, OpenOrderHandler completion) {
3680 auto invalid = validate_account(account);
3681 if (invalid) {
3682 complete_open_orders(std::move(completion), Result<KisOpenOrderPage>(std::move(*invalid)));
3683 return;
3684 }
3685
3686 auto operation = std::make_shared<OpenOrderOperation>(
3687 std::move(account), std::move(completion)
3688 );
3689 ensure_token(
3690 TokenHandler(
3691 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
3692 if (token_error) {
3693 self->complete_open_orders(
3694 std::move(operation->completion_),
3695 Result<KisOpenOrderPage>(std::move(*token_error))
3696 );
3697 return;
3698 }
3699 self->request_open_orders(operation);
3700 }
3701 )
3702 );
3703 }
3704
3705 // Builds one page request for KIS orders that remain modifiable.
3706 static network::HttpRequest make_open_orders_request(
3707 const KisAccount& account,
3708 const KisConfig& config,
3709 std::string_view access_token,
3710 std::string_view foreign_cursor,
3711 std::string_view domestic_cursor,
3712 bool continuation
3713 ) {
3714 if (config.is_demo_) {
3715 // Demo has no `TTTC0084R`: today's unfilled orders (`CCLD_DVSN=02`) from the daily inquiry.
3716 const auto today = today_in_seoul();
3717 return make_order_history_request(
3718 account, config, access_token, KisHistoryRange{today, today}, foreign_cursor,
3719 domestic_cursor, continuation, "02"
3720 );
3721 }
3722 network::HttpRequest request;
3723 // KIS recommends this inquiry before an amendment, but does not require it: a
3724 // caller tracking its own working orders already holds the amendable quantity,
3725 // and KIS's own examples amend without re-reading it. It ranks above polling and
3726 // stays outside the reserve, which is held for the amendment itself.
3727 request.group_ = network::RateLimitGroup::ACCOUNT;
3728 request.method_ = http::verb::get;
3729 request.target_ =
3730 "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
3731 "?CANO=" +
3732 account.account_number_ + "&ACNT_PRDT_CD=" + account.product_code_ +
3733 "&INQR_DVSN_1=0&INQR_DVSN_2=0&CTX_AREA_FK100=" +
3734 broker_detail::query_encode(foreign_cursor) + "&CTX_AREA_NK100=" +
3735 broker_detail::query_encode(domestic_cursor);
3736 request.headers_ = {
3737 {"authorization", "Bearer " + std::string(access_token)},
3738 {"appkey", config.key()},
3739 {"appsecret", config.secret()},
3740 {"tr_id", "TTTC0084R"},
3741 {"tr_cont", continuation ? "N" : ""},
3742 {"custtype", "P"},
3743 {"content-type", "application/json; charset=utf-8"},
3744 };
3745 return request;
3746 }
3747
3748 // Sends the next modifiable-order page request for `operation`.
3749 void request_open_orders(const std::shared_ptr<OpenOrderOperation>& operation) {
3750 auto request = make_open_orders_request(
3751 operation->account_,
3752 config_,
3753 token_.access_token_,
3754 operation->foreign_cursor_,
3755 operation->domestic_cursor_,
3756 operation->page_count_ > 0
3757 );
3758 http_->async_request(
3759 std::move(request),
3760 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
3761 net::post(
3762 self->strand_,
3763 [self, operation, result = std::move(result)]() mutable {
3764 self->handle_open_orders_page(operation, std::move(result));
3765 }
3766 );
3767 }
3768 );
3769 }
3770
3771 // Accumulates one modifiable-order page and advances its continuation.
3772 void handle_open_orders_page(
3773 const std::shared_ptr<OpenOrderOperation>& operation,
3774 Result<network::HttpResponse> result
3775 ) {
3776 if (!result) {
3777 complete_open_orders(
3778 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(result.error()))
3779 );
3780 return;
3781 }
3782
3783 const bool has_next = continuation_pending(result.value());
3784 std::string foreign_cursor;
3785 std::string domestic_cursor;
3786 auto page = parse_open_orders_page(
3787 std::move(result), foreign_cursor, domestic_cursor, config_.is_demo_
3788 );
3789 if (!page) {
3790 complete_open_orders(
3791 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(page.error()))
3792 );
3793 return;
3794 }
3795
3796 auto pagination_error = advance_cursors(
3797 operation->page_count_,
3798 operation->foreign_cursor_,
3799 operation->domestic_cursor_,
3800 std::move(foreign_cursor),
3801 std::move(domestic_cursor),
3802 has_next,
3803 "KIS modifiable-order continuation did not advance"
3804 );
3805 if (pagination_error) {
3806 complete_open_orders(
3807 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(*pagination_error))
3808 );
3809 return;
3810 }
3811
3812 operation->page_.orders_.insert(
3813 operation->page_.orders_.end(),
3814 std::make_move_iterator(page.value().orders_.begin()),
3815 std::make_move_iterator(page.value().orders_.end())
3816 );
3817 if (has_next) {
3818 request_open_orders(operation);
3819 return;
3820 }
3821
3822 operation->page_.received_at_ = std::chrono::system_clock::now();
3823 complete_open_orders(
3824 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(operation->page_))
3825 );
3826 }
3827
3828 // Parses one KIS modifiable-order response page and its cursors.
3829 //
3830 // `demo` reads the daily order inquiry's `output1` instead, which is what the request asked for on
3831 // demo, and lists each row with a remaining quantity.
3832 static Result<KisOpenOrderPage> parse_open_orders_page(
3833 Result<network::HttpResponse> result,
3834 std::string& foreign_cursor,
3835 std::string& domestic_cursor,
3836 bool demo = false
3837 ) {
3838 if (!result) {
3839 return Result<KisOpenOrderPage>(std::move(result.error()));
3840 }
3841 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3842 return Result<KisOpenOrderPage>(kis_http_error(result.value()));
3843 }
3844 BrokerError parse_error;
3845 auto document = broker_detail::parse_object(result.value(), parse_error);
3846 if (!document) {
3847 return Result<KisOpenOrderPage>(std::move(parse_error));
3848 }
3849 try {
3850 if (auto declared = kis_declared_error(*document)) {
3851 return Result<KisOpenOrderPage>(std::move(*declared));
3852 }
3853 if (demo) {
3854 auto page = open_orders_from_history(*document);
3855 read_cursors(*document, foreign_cursor, domestic_cursor);
3856 return Result<KisOpenOrderPage>(std::move(page));
3857 }
3858 const auto rows = document->if_contains("output");
3859 if (!rows || !rows->is_array()) {
3860 throw std::runtime_error("missing array field: output");
3861 }
3862
3863 KisOpenOrderPage page;
3864 for (const auto& value : rows->as_array()) {
3865 if (!value.is_object()) {
3866 throw std::runtime_error("output item is not an object");
3867 }
3868 const auto& row = value.as_object();
3869
3870 KisOpenOrder open_order;
3871 open_order.order_ref_.branch_number_ = broker_detail::json_string(row, "ord_gno_brno");
3872 open_order.order_ref_.order_number_ = broker_detail::json_string(row, "odno");
3873 open_order.original_order_.branch_number_ = open_order.order_ref_.branch_number_;
3874 open_order.original_order_.order_number_ = broker_detail::json_string(row, "orgn_odno");
3875 open_order.amendable_quantity_ =
3876 Decimal64::parse(broker_detail::json_string(row, "psbl_qty"));
3877 if (const auto* released = row.if_contains("stpm_efct_occr_yn");
3878 released != nullptr && released->is_string()) {
3879 open_order.stop_released_ = std::string(released->get_string());
3880 }
3881
3882 auto& order = open_order.order_;
3883 order.order_id_ = open_order.order_ref_.order_number_;
3884 // `Venue::COMBINED`, exactly as the holdings path writes it, so an order
3885 // and the holding it belongs to remain the same instrument. Where the
3886 // order was actually routed is a different fact and goes in `venue_`.
3887 order.instrument_ = InstrumentId{
3888 Country::KR,
3889 instrument_venue(),
3890 broker_detail::json_string(row, "pdno"),
3891 };
3892 order.venue_ = reported_venue(row);
3893 order.name_ = broker_detail::json_string(row, "prdt_name");
3894 order.side_ = normalized_order_side(
3895 broker_detail::json_string(row, "sll_buy_dvsn_cd")
3896 );
3897 apply_order_shape(broker_detail::json_string(row, "ord_dvsn_cd"), order);
3898 order.quantity_ = Decimal64::parse(broker_detail::json_string(row, "ord_qty"));
3899 if (carries_caller_price(order.type_)) {
3900 order.price_ = Decimal64::parse(broker_detail::json_string(row, "ord_unpr"));
3901 }
3902 order.trigger_price_ = reported_trigger(row);
3903 order.currency_ = Currency::krw();
3904 order.execution_.filled_quantity_ =
3905 Decimal64::parse(broker_detail::json_string(row, "tot_ccld_qty"));
3906 order.execution_.filled_amount_ = Money{
3907 Decimal64::parse(broker_detail::json_string(row, "tot_ccld_amt")), Currency::krw()
3908 };
3909 order.status_ = order.execution_.filled_quantity_.coefficient() > 0 ?
3910 OrderStatus::PARTIAL_FILLED :
3911 OrderStatus::PENDING;
3912 page.orders_.push_back(std::move(open_order));
3913 }
3914
3915 read_cursors(*document, foreign_cursor, domestic_cursor);
3916 return Result<KisOpenOrderPage>(std::move(page));
3917 } catch (const std::exception& exception) {
3918 BrokerError error;
3919 error.category_ = ErrorCategory::PROTOCOL;
3920 error.broker_code_ = "invalid_open_order_response";
3921 error.message_ = exception.what();
3922 return Result<KisOpenOrderPage>(std::move(error));
3923 }
3924 }
3925
3926 // Validates and starts a complete KIS order-history inquiry.
3927 void start_order_history(KisAccount account, KisHistoryRange range, OrderPageHandler completion) {
3928 auto invalid = validate_account(account);
3929 if (
3930 !invalid &&
3931 (!is_valid_date(range.from_) || !is_valid_date(range.to_) || range.to_ < range.from_)
3932 ) {
3933 BrokerError error;
3934 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3935 error.reached_broker_ = false;
3936 error.broker_code_ = "invalid_date_range";
3937 error.message_ = "KIS order history requires a valid inclusive calendar range";
3938 invalid = std::move(error);
3939 }
3940 if (invalid) {
3941 complete_order_page(std::move(completion), Result<OrderPage>(std::move(*invalid)));
3942 return;
3943 }
3944
3945 auto operation = std::make_shared<OrderHistoryOperation>(
3946 std::move(account), range, std::move(completion)
3947 );
3948 ensure_token(
3949 TokenHandler(
3950 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
3951 if (token_error) {
3952 self->complete_order_page(
3953 std::move(operation->completion_), Result<OrderPage>(std::move(*token_error))
3954 );
3955 return;
3956 }
3957 self->request_order_history(operation);
3958 }
3959 )
3960 );
3961 }
3962
3963 // Builds one page request for KIS daily order and execution history.
3964 static network::HttpRequest make_order_history_request(
3965 const KisAccount& account,
3966 const KisConfig& config,
3967 std::string_view access_token,
3968 const KisHistoryRange& range,
3969 std::string_view foreign_cursor,
3970 std::string_view domestic_cursor,
3971 bool continuation,
3972 std::string_view executions = "00"
3973 ) {
3974 network::HttpRequest request;
3975 // Order history is account monitoring, not order submission.
3976 request.group_ = network::RateLimitGroup::ACCOUNT;
3977 request.method_ = http::verb::get;
3978 request.target_ =
3979 "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
3980 "?CANO=" +
3981 account.account_number_ + "&ACNT_PRDT_CD=" + account.product_code_ + "&INQR_STRT_DT=" +
3982 compact_date(range.from_) + "&INQR_END_DT=" + compact_date(range.to_) +
3983 "&SLL_BUY_DVSN_CD=00&PDNO=&ORD_GNO_BRNO=&ODNO=&CCLD_DVSN=" + std::string(executions) +
3984 "&INQR_DVSN=00"
3985 "&INQR_DVSN_1=&INQR_DVSN_3=00&EXCG_ID_DVSN_CD=" +
3986 (config.is_demo_ ? "KRX" : "ALL") + "&CTX_AREA_FK100=" +
3987 broker_detail::query_encode(foreign_cursor) + "&CTX_AREA_NK100=" +
3988 broker_detail::query_encode(domestic_cursor);
3989 request.headers_ = {
3990 {"authorization", "Bearer " + std::string(access_token)},
3991 {"appkey", config.key()},
3992 {"appsecret", config.secret()},
3993 {"tr_id", config.is_demo_ ? "VTTC0081R" : "TTTC0081R"},
3994 {"tr_cont", continuation ? "N" : ""},
3995 {"custtype", "P"},
3996 {"content-type", "application/json; charset=utf-8"},
3997 };
3998 return request;
3999 }
4000
4001 // Sends the next order-history page request for `operation`.
4002 void request_order_history(const std::shared_ptr<OrderHistoryOperation>& operation) {
4003 auto request = make_order_history_request(
4004 operation->account_,
4005 config_,
4006 token_.access_token_,
4007 operation->range_,
4008 operation->foreign_cursor_,
4009 operation->domestic_cursor_,
4010 operation->page_count_ > 0
4011 );
4012 http_->async_request(
4013 std::move(request),
4014 [self = shared_from_this(), operation](Result<network::HttpResponse> result) mutable {
4015 net::post(
4016 self->strand_,
4017 [self, operation, result = std::move(result)]() mutable {
4018 self->handle_order_history_page(operation, std::move(result));
4019 }
4020 );
4021 }
4022 );
4023 }
4024
4025 // Accumulates one order-history page and advances its continuation.
4026 void handle_order_history_page(
4027 const std::shared_ptr<OrderHistoryOperation>& operation,
4028 Result<network::HttpResponse> result
4029 ) {
4030 if (!result) {
4031 complete_order_page(
4032 std::move(operation->completion_), Result<OrderPage>(std::move(result.error()))
4033 );
4034 return;
4035 }
4036
4037 const bool has_next = continuation_pending(result.value());
4038 std::string foreign_cursor;
4039 std::string domestic_cursor;
4040 auto page = parse_order_history_page(std::move(result), foreign_cursor, domestic_cursor);
4041 if (!page) {
4042 complete_order_page(
4043 std::move(operation->completion_), Result<OrderPage>(std::move(page.error()))
4044 );
4045 return;
4046 }
4047
4048 auto pagination_error = advance_cursors(
4049 operation->page_count_,
4050 operation->foreign_cursor_,
4051 operation->domestic_cursor_,
4052 std::move(foreign_cursor),
4053 std::move(domestic_cursor),
4054 has_next,
4055 "KIS order-history continuation did not advance"
4056 );
4057 if (pagination_error) {
4058 complete_order_page(
4059 std::move(operation->completion_), Result<OrderPage>(std::move(*pagination_error))
4060 );
4061 return;
4062 }
4063
4064 operation->page_.orders_.insert(
4065 operation->page_.orders_.end(),
4066 std::make_move_iterator(page.value().orders_.begin()),
4067 std::make_move_iterator(page.value().orders_.end())
4068 );
4069 if (has_next) {
4070 request_order_history(operation);
4071 return;
4072 }
4073
4074 operation->page_.received_at_ = std::chrono::system_clock::now();
4075 complete_order_page(
4076 std::move(operation->completion_), Result<OrderPage>(std::move(operation->page_))
4077 );
4078 }
4079
4080 // Builds the order one daily-inquiry row describes.
4081 //
4082 // Shared by the history read and, on demo, the working-order read, so the two cannot describe one
4083 // order differently.
4084 static OrderSnapshot history_row_snapshot(const json::object& row) {
4085 OrderSnapshot order;
4086 order.order_id_ = broker_detail::json_string(row, "odno");
4087 // `Venue::COMBINED`, exactly as the holdings path writes it, so an order
4088 // and the holding it belongs to remain the same instrument. Where the
4089 // order was actually routed is a different fact and goes in `venue_`.
4090 order.instrument_ = InstrumentId{
4091 Country::KR,
4092 instrument_venue(),
4093 broker_detail::json_string(row, "pdno"),
4094 };
4095 order.venue_ = reported_venue(row);
4096 order.name_ = broker_detail::json_string(row, "prdt_name");
4097 order.side_ = normalized_order_side(
4098 broker_detail::json_string(row, "sll_buy_dvsn_cd")
4099 );
4100 apply_order_shape(broker_detail::json_string(row, "ord_dvsn_cd"), order);
4101 order.quantity_ = Decimal64::parse(broker_detail::json_string(row, "ord_qty"));
4102 if (carries_caller_price(order.type_)) {
4103 order.price_ = Decimal64::parse(broker_detail::json_string(row, "ord_unpr"));
4104 }
4105 order.trigger_price_ = reported_trigger(row);
4106 order.currency_ = Currency::krw();
4107 order.ordered_at_ = korea_timestamp(
4108 broker_detail::json_string(row, "ord_dt"), broker_detail::json_string(row, "ord_tmd")
4109 );
4110
4111 const auto filled = Decimal64::parse(broker_detail::json_string(row, "tot_ccld_qty"));
4112 const auto remaining = Decimal64::parse(broker_detail::json_string(row, "rmn_qty"));
4113 const auto rejected = Decimal64::parse(broker_detail::json_string(row, "rjct_qty"));
4114 const bool canceled = broker_detail::json_string(row, "cncl_yn") == "Y";
4115
4116 order.execution_.filled_quantity_ = filled;
4117 order.execution_.filled_amount_ = Money{
4118 Decimal64::parse(broker_detail::json_string(row, "tot_ccld_amt")), Currency::krw()
4119 };
4120 if (const auto average = Decimal64::parse(broker_detail::json_string(row, "avg_prvs"));
4121 average.coefficient() > 0) {
4122 order.execution_.average_filled_price_ = average;
4123 }
4124 order.status_ =
4125 derive_order_status(order.quantity_, filled, remaining, rejected, canceled);
4126 return order;
4127 }
4128
4129 // Lists each daily-inquiry row with a remaining quantity as a working order, for demo.
4130 static KisOpenOrderPage open_orders_from_history(const json::object& document) {
4131 const auto rows = document.if_contains("output1");
4132 if (!rows || !rows->is_array()) {
4133 throw std::runtime_error("missing array field: output1");
4134 }
4135 KisOpenOrderPage page;
4136 for (const auto& value : rows->as_array()) {
4137 if (!value.is_object()) {
4138 throw std::runtime_error("output1 item is not an object");
4139 }
4140 const auto& row = value.as_object();
4141 // `rmn_qty` alone, deliberately not `cncl_yn`. Observed on demo (2026-10-02): an amended
4142 // original and a cancelled order both drop to `rmn_qty` 0 and keep `cncl_yn` `N`; `Y` marks the
4143 // cancel request's own row, which has `rmn_qty` 0 too. A partial cancel has not been seen, and
4144 // hiding a resting order is the error that sends it twice.
4145 const auto remaining = Decimal64::parse(broker_detail::json_string(row, "rmn_qty"));
4146 if (remaining.coefficient() <= 0) {
4147 continue;
4148 }
4149 KisOpenOrder open_order;
4150 open_order.order_ref_.branch_number_ = broker_detail::json_string(row, "ord_gno_brno");
4151 open_order.order_ref_.order_number_ = broker_detail::json_string(row, "odno");
4152 open_order.original_order_.branch_number_ = open_order.order_ref_.branch_number_;
4153 // All zeros (`0000000000`) for an order that amends nothing, not empty (observed on demo).
4154 open_order.original_order_.order_number_ = broker_detail::json_string(row, "orgn_odno");
4155 open_order.amendable_quantity_ = remaining;
4156 open_order.order_ = history_row_snapshot(row);
4157 // As the live listing states it: a row still resting is pending, or partly filled.
4158 open_order.order_.status_ = open_order.order_.execution_.filled_quantity_.coefficient() > 0 ?
4159 OrderStatus::PARTIAL_FILLED :
4160 OrderStatus::PENDING;
4161 page.orders_.push_back(std::move(open_order));
4162 }
4163 return page;
4164 }
4165
4166 // Parses one KIS daily order-history response page and its cursors.
4167 static Result<OrderPage> parse_order_history_page(
4168 Result<network::HttpResponse> result,
4169 std::string& foreign_cursor,
4170 std::string& domestic_cursor
4171 ) {
4172 if (!result) {
4173 return Result<OrderPage>(std::move(result.error()));
4174 }
4175 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
4176 return Result<OrderPage>(kis_http_error(result.value()));
4177 }
4178 BrokerError parse_error;
4179 auto document = broker_detail::parse_object(result.value(), parse_error);
4180 if (!document) {
4181 return Result<OrderPage>(std::move(parse_error));
4182 }
4183 try {
4184 if (auto declared = kis_declared_error(*document)) {
4185 return Result<OrderPage>(std::move(*declared));
4186 }
4187 const auto rows = document->if_contains("output1");
4188 if (!rows || !rows->is_array()) {
4189 throw std::runtime_error("missing array field: output1");
4190 }
4191
4192 OrderPage page;
4193 for (const auto& value : rows->as_array()) {
4194 if (!value.is_object()) {
4195 throw std::runtime_error("output1 item is not an object");
4196 }
4197 page.orders_.push_back(history_row_snapshot(value.as_object()));
4198 }
4199
4200 read_cursors(*document, foreign_cursor, domestic_cursor);
4201 return Result<OrderPage>(std::move(page));
4202 } catch (const std::exception& exception) {
4203 BrokerError error;
4204 error.category_ = ErrorCategory::PROTOCOL;
4205 error.broker_code_ = "invalid_order_history_response";
4206 error.message_ = exception.what();
4207 return Result<OrderPage>(std::move(error));
4208 }
4209 }
4210
4211 // Derives a normalized lifecycle state for one order-history row.
4212 //
4213 // KIS's daily order inquiry reports no status column, so the state is
4214 // reconstructed from the ordered, filled, remaining, and rejected quantities
4215 // together with the cancellation flag. This is the one place where the
4216 // adapter states something KIS did not, so it is isolated and directly
4217 // tested rather than inlined into the response loop.
4218 //
4219 // A cancelled or rejected order keeps its partial fills visible through
4220 // `OrderExecution`, so the terminal state is reported without discarding
4221 // what was executed before it.
4222 static OrderStatus derive_order_status(
4223 const Decimal64& ordered,
4224 const Decimal64& filled,
4225 const Decimal64& remaining,
4226 const Decimal64& rejected,
4227 bool canceled
4228 ) {
4229 if (rejected.coefficient() > 0 && filled.coefficient() == 0) {
4230 return OrderStatus::REJECTED;
4231 }
4232 if (canceled) {
4233 return OrderStatus::CANCELED;
4234 }
4235 // **Nothing remaining and not all filled is an order that ended.** Observed on demo (2026-10-02):
4236 // a cancelled order, and an amended original, drop to `rmn_qty` 0 and keep `cncl_yn` `N` -- the `Y`
4237 // is on the cancel request's own row. Read as open, a cancelled order never looked finished to
4238 // anything waiting on its history (a deferred target, a sent order's confirmation). What filled is
4239 // still read from the fill column.
4240 if (remaining.coefficient() == 0 && !(filled == ordered)) {
4241 return OrderStatus::CANCELED;
4242 }
4243 if (filled.coefficient() == 0) {
4244 return OrderStatus::PENDING;
4245 }
4246 return filled == ordered ? OrderStatus::FILLED : OrderStatus::PARTIAL_FILLED;
4247 }
4248
4249 // Writes the three normalized order facts a KIS `ORD_DVSN` code carries, and
4250 // preserves the code itself.
4251 //
4252 // The code set read here is the **union** of the order sheet's and the
4253 // inquiry sheets', because they differ: the inquiry publishes `07`
4254 // (시간외 단일가), which cannot be placed, and the order sheet publishes
4255 // `41`-`47`, which an inquiry has not been observed to return. A reader that
4256 // knew only the request table would report `UNKNOWN` for a code KIS itself
4257 // had sent.
4258 //
4259 // An unrecognized code leaves all three fields `UNKNOWN` and still reaches
4260 // the caller through `shape_code_`. It does not throw: both specifications
4261 // require a client to tolerate unknown codes, and one odd row must not fail a
4262 // whole listing.
4263 static void apply_order_shape(std::string_view division, OrderSnapshot& order) {
4264 if (const auto code = Code<8>::try_make(division)) {
4265 order.shape_code_ = *code;
4266 }
4267 for (const auto& row : kOrderShapes) {
4268 if (division == row.code_) {
4269 order.type_ = row.type_;
4270 order.time_in_force_ = row.time_in_force_;
4271 order.session_ = row.session_;
4272 return;
4273 }
4274 }
4275 order.type_ = OrderType::UNKNOWN;
4276 order.time_in_force_ = TimeInForce::UNKNOWN;
4277 order.session_ = OrderSession::UNKNOWN;
4278 }
4279
4280 // Maps KIS's `excg_id_dvsn_cd` onto a normalized venue.
4281 //
4282 // Both order inquiries publish this field as required, and this adapter used
4283 // to ignore it and write `Venue::COMBINED` onto every parsed order. That was
4284 // an assertion about where an order lived, made without reading the field
4285 // that says so -- and it is not inert, because an amendment now composes its
4286 // `ORD_DVSN` and its `EXCG_ID_DVSN_CD` from the order's venue.
4287 //
4288 // An unrecognized or absent value is `UNSPECIFIED`, which omits the field on
4289 // a later amendment and so takes the broker's own default, rather than
4290 // naming a venue nothing reported.
4291 // Returns the venue that identifies a KIS instrument.
4292 //
4293 // One function with three callers -- the holdings parse and both order
4294 // parses -- rather than the literal each of them used to hold. `InstrumentId`
4295 // compares its venue, so a holding and an order of the same stock are the
4296 // same instrument only while those three agree, and
4297 // `Portfolio::working_quantity` reads zero resting quantity the moment they
4298 // do not. Toss had exactly that drift between its two parses and nothing
4299 // caught it, so the value is named once here rather than written three times.
4300 //
4301 // KIS reports no venue on a holding, so this is about identity only. Where an
4302 // order *was* routed is `reported_venue` below, and it lives on
4303 // `OrderSnapshot::venue_` rather than on the instrument.
4304 static constexpr Venue instrument_venue() { return Venue::COMBINED; }
4305
4306 // Returns an order's trigger from `stpm_cndt_pric`, or nothing.
4307 //
4308 // Both 정정취소가능주문조회 and 주식일별주문체결조회 publish the field on every row, with an
4309 // empty Description column. A positive value is recorded **whatever the row's code**, for the
4310 // reason `OrderSnapshot::trigger_price_` gives: on anything but `22` it is KIS contradicting its
4311 // own code, which a replacement then refuses rather than carrying along. A missing, empty, zero
4312 // or unreadable value is **not** a failure of the listing -- a trigger is one fact about one
4313 // order, and the row is still the order -- and leaves the trigger unreported.
4314 static std::optional<Decimal64> reported_trigger(const json::object& row) {
4315 const auto* value = row.if_contains("stpm_cndt_pric");
4316 if (value == nullptr || !value->is_string()) {
4317 return std::nullopt;
4318 }
4319 try {
4320 const auto trigger = Decimal64::parse(value->get_string());
4321 if (trigger.coefficient() > 0) {
4322 return trigger;
4323 }
4324 } catch (const std::exception&) {
4325 // Unreadable is "not reported", for the reason above.
4326 }
4327 return std::nullopt;
4328 }
4329
4330 static Venue reported_venue(const json::object& row) {
4331 // The workbook spells this field two ways across the sheets that publish
4332 // it -- `excg_id_dvsn_cd` on 주식정정취소가능주문조회 and `excg_id_dvsn_Cd`
4333 // on 주식일별주문체결조회 -- and one of them is presumably a typo in the
4334 // document. Which one is not knowable from here, so both are read.
4335 for (const auto* key : {"excg_id_dvsn_cd", "excg_id_dvsn_Cd"}) {
4336 const auto* value = row.if_contains(key);
4337 if (value != nullptr && value->is_string()) {
4338 return normalized_venue(value->get_string());
4339 }
4340 }
4341 // Absent rather than unrecognized. The sheets mark the field required, so
4342 // this should not happen against a live account; it must not fail the
4343 // listing if it does, because a venue is metadata about an order and not
4344 // the order.
4345 return Venue::UNSPECIFIED;
4346 }
4347
4348 static Venue normalized_venue(std::string_view code) {
4349 if (code == "KRX") {
4350 return Venue::KRX;
4351 }
4352 if (code == "NXT") {
4353 return Venue::NXT;
4354 }
4355 if (code == "SOR") {
4356 return Venue::COMBINED;
4357 }
4358 return Venue::UNSPECIFIED;
4359 }
4360
4361 // Maps a KIS side code or rejects an invalid broker response.
4362 static OrderSide normalized_order_side(std::string_view code) {
4363 if (code == "01") {
4364 return OrderSide::SELL;
4365 }
4366 if (code == "02") {
4367 return OrderSide::BUY;
4368 }
4369 throw std::runtime_error("invalid KIS order side code");
4370 }
4371
4372 // Returns whether the response header requests another continuation page.
4373 static bool continuation_pending(const network::HttpResponse& response) {
4374 const auto continuation = response.base().find("tr_cont");
4375 return continuation != response.base().end() &&
4376 (continuation->value() == "M" || continuation->value() == "F");
4377 }
4378
4379 // Copies KIS's continuation cursors out of a response document.
4380 static void read_cursors(
4381 const json::object& document,
4382 std::string& foreign_cursor,
4383 std::string& domestic_cursor
4384 ) {
4385 if (const auto value = document.if_contains("ctx_area_fk100"); value && value->is_string()) {
4386 foreign_cursor = value->as_string().c_str();
4387 }
4388 if (const auto value = document.if_contains("ctx_area_nk100"); value && value->is_string()) {
4389 domestic_cursor = value->as_string().c_str();
4390 }
4391 }
4392
4393 // Advances continuation cursors, rejecting a continuation that cannot end.
4394 //
4395 // An empty, unchanged, or excessively long continuation would loop forever
4396 // against a misbehaving response, so each is reported as a protocol error.
4397 static std::optional<BrokerError> advance_cursors(
4398 std::size_t& page_count,
4399 std::string& foreign_cursor,
4400 std::string& domestic_cursor,
4401 std::string next_foreign_cursor,
4402 std::string next_domestic_cursor,
4403 bool has_next,
4404 std::string_view message
4405 ) {
4406 if (has_next) {
4407 const bool cursor_did_not_advance = next_foreign_cursor == foreign_cursor &&
4408 next_domestic_cursor == domestic_cursor;
4409 if (
4410 page_count + 1 >= 100 ||
4411 (next_foreign_cursor.empty() && next_domestic_cursor.empty()) ||
4412 cursor_did_not_advance
4413 ) {
4414 BrokerError error;
4415 error.category_ = ErrorCategory::PROTOCOL;
4416 error.broker_code_ = "invalid_pagination";
4417 error.message_ = std::string(message);
4418 return error;
4419 }
4420 }
4421 ++page_count;
4422 if (has_next) {
4423 foreign_cursor = std::move(next_foreign_cursor);
4424 domestic_cursor = std::move(next_domestic_cursor);
4425 }
4426 return std::nullopt;
4427 }
4428
4429 // Formats a calendar date as KIS's `YYYYMMDD` inquiry bound.
4430 static std::string compact_date(const Date& date) {
4431 char buffer[9];
4432 std::snprintf(
4433 buffer, sizeof(buffer), "%04d%02u%02u", date.year_, date.month_, date.day_
4434 );
4435 return std::string(buffer);
4436 }
4437
4438 // Combines KIS's `YYYYMMDD` order date and `HHMMSS` order time.
4439 //
4440 // KIS's domestic trading endpoints report Korea Standard Time, so the fixed
4441 // `+09:00` offset is documented broker behaviour rather than an assumption
4442 // about the caller's locale.
4443 static std::optional<std::chrono::system_clock::time_point> korea_timestamp(
4444 std::string_view date,
4445 std::string_view time
4446 ) {
4447 if (date.size() != 8 || time.size() != 6) {
4448 return std::nullopt;
4449 }
4450 std::string iso;
4451 iso.reserve(25);
4452 iso.append(date.substr(0, 4)).append("-").append(date.substr(4, 2)).append("-")
4453 .append(date.substr(6, 2)).append("T").append(time.substr(0, 2)).append(":")
4454 .append(time.substr(2, 2)).append(":").append(time.substr(4, 2)).append("+09:00");
4455 return parse_iso8601_timestamp(iso);
4456 }
4457
4458 // Dispatches an order acknowledgement through its associated executor.
4459 // Dispatches one result through its associated executor, exactly once.
4460 //
4461 // Every operation's result passes through here, which makes it the one place a
4462 // broker's own rate-limit refusal can be fed back into this connection's pacer
4463 // without each operation having to remember to do it.
4464 template <typename Handler, typename T>
4465 void deliver(Handler handler, Result<T> result) {
4466 if (!result) {
4467 note_rate_limit(result.error());
4468 }
4469 auto executor = net::get_associated_executor(handler, strand_);
4470 net::dispatch(executor, [handler = std::move(handler), result = std::move(result)]() mutable {
4471 std::move(handler)(std::move(result));
4472 });
4473 }
4474
4475 // Corrects this connection's pacing from a KIS rate-limit refusal.
4476 //
4477 // KIS has two limit layers. A ledger refusal is a cap shared with every other
4478 // customer, so the budget was not the problem and only a wait helps. A personal
4479 // refusal means this key's own allowance was exceeded, but KIS reports no number
4480 // to correct to; so both only hold admission.
4481 void note_rate_limit(const BrokerError& error) {
4482 if (!error_category_is_rate_limit(error.category_)) {
4483 return;
4484 }
4485 auto* limiter = http_->rate_limits();
4486 if (!limiter) {
4487 return;
4488 }
4489
4490 // KIS states no allowance with a refusal, of either layer, so there is nothing
4491 // to correct the budget *to*. Admission is held and the refusal is counted; the
4492 // configured budget is left exactly as the caller set it, because reducing a
4493 // stated budget from a guess is the kind of silent downgrade this library
4494 // refuses everywhere else. A caller watching `broker_rejections_` climb is being
4495 // told to lower it.
4496 limiter->hold(
4497 error.retry_after_ ?
4498 std::chrono::duration_cast<network::RateLimitController::Duration>(*error.retry_after_) :
4499 std::chrono::duration_cast<network::RateLimitController::Duration>(std::chrono::seconds(1))
4500 );
4501 }
4502
4503 void complete_order_ack(OrderAckHandler handler, Result<KisOrderAck> result) {
4504 deliver(std::move(handler), std::move(result));
4505 }
4506
4507 // Dispatches a modifiable-order result through its associated executor.
4508 void complete_open_orders(OpenOrderHandler handler, Result<KisOpenOrderPage> result) {
4509 deliver(std::move(handler), std::move(result));
4510 }
4511
4512 // Dispatches an order-history result through its associated executor.
4513 void complete_order_page(OrderPageHandler handler, Result<OrderPage> result) {
4514 deliver(std::move(handler), std::move(result));
4515 }
4516
4517 void complete(QuoteHandler handler, Result<QuoteSnapshot> result) {
4518 deliver(std::move(handler), std::move(result));
4519 }
4520
4521 void finish_holdings(
4522 const std::shared_ptr<HoldingsOperation>& operation,
4523 Result<HoldingsSnapshot> result
4524 ) {
4525 complete_holdings(std::move(operation->completion_), std::move(result));
4526 }
4527
4528 void complete_holdings(HoldingsHandler handler, Result<HoldingsSnapshot> result) {
4529 deliver(std::move(handler), std::move(result));
4530 }
4531};
4532
4541public:
4549 ssl::context& context,
4550 KisConfig config,
4551 network::HttpClientOptions options = {}
4552 )
4553 : client_(std::make_shared<KisClient>(ioc_.get_executor(), context, std::move(config), options)) {}
4554
4561 return run<QuoteSnapshot>(
4562 [&](auto&& sink) { client_->async_quote(std::move(instrument), std::move(sink)); },
4563 "quote"
4564 );
4565 }
4566
4573 return run<Instrument>(
4574 [&](auto&& sink) { client_->async_instrument(std::move(subject), std::move(sink)); },
4575 "instrument"
4576 );
4577 }
4578
4585 Result<TradingCalendar> calendar(Date from, bool refresh = false) {
4586 return run<TradingCalendar>(
4587 [&](auto&& sink) { client_->async_calendar(from, refresh, std::move(sink)); },
4588 "calendar"
4589 );
4590 }
4591
4598 return run<PriceLimits>(
4599 [&](auto&& sink) { client_->async_price_limits(std::move(instrument), std::move(sink)); },
4600 "price_limits"
4601 );
4602 }
4603
4611 return run<std::vector<TradePrint>>(
4612 [&](auto&& sink) {
4613 client_->async_trades(std::move(instrument), count, std::move(sink));
4614 },
4615 "trades"
4616 );
4617 }
4618
4625 return run<DepthSnapshot>(
4626 [&](auto&& sink) { client_->async_depth(std::move(instrument), std::move(sink)); },
4627 "depth"
4628 );
4629 }
4630
4637 return run<BarSeries>(
4638 [&](auto&& sink) { client_->async_bars(std::move(query), std::move(sink)); },
4639 "bars"
4640 );
4641 }
4642
4649 return run<HoldingsSnapshot>(
4650 [&](auto&& sink) { client_->async_holdings(std::move(account), std::move(sink)); },
4651 "holdings"
4652 );
4653 }
4654
4662 return run<BuyingPower>(
4663 [&](auto&& sink) {
4664 client_->async_buying_power(currency, std::move(account), std::move(sink));
4665 },
4666 "buying_power"
4667 );
4668 }
4669
4670
4682 return run<KisOrderAck>(
4683 [&](auto&& sink) {
4684 client_->async_place_order(std::move(account), std::move(order), std::move(sink));
4685 },
4686 "order placement"
4687 );
4688 }
4689
4698 return run<KisOrderAck>(
4699 [&](auto&& sink) {
4700 client_->async_replace_order(std::move(account), std::move(amendment), std::move(sink));
4701 },
4702 "order modification"
4703 );
4704 }
4705
4714 return run<KisOrderAck>(
4715 [&](auto&& sink) {
4716 client_->async_cancel_order(std::move(account), std::move(amendment), std::move(sink));
4717 },
4718 "order cancellation"
4719 );
4720 }
4721
4729 return run<KisOpenOrderPage>(
4730 [&](auto&& sink) { client_->async_open_orders(std::move(account), std::move(sink)); },
4731 "modifiable-order inquiry"
4732 );
4733 }
4734
4742 return run<OrderPage>(
4743 [&](auto&& sink) {
4744 client_->async_order_history(std::move(account), range, std::move(sink));
4745 },
4746 "order-history inquiry"
4747 );
4748 }
4749
4755 return client_->rate_limit_stats();
4756 }
4757
4762 KisRest token() const { return client_->token(); }
4763
4769 bool adopt_token(KisRest token) { return client_->adopt_token(std::move(token)); }
4770
4771private:
4772 // Drives one asynchronous operation to completion on the private loop.
4773 template <typename T, typename Start>
4774 Result<T> run(Start start, std::string_view operation) {
4775 std::optional<Result<T>> result;
4776 ioc_.restart();
4777 start([&result](Result<T> value) { result.emplace(std::move(value)); });
4778 ioc_.run();
4779 if (!result) {
4780 BrokerError error;
4782 error.broker_code_ = "event_loop_stopped";
4783 error.message_ = "event loop stopped before " + std::string(operation) + " completion";
4784 return Result<T>(std::move(error));
4785 }
4786 return std::move(*result);
4787 }
4788
4789 net::io_context ioc_;
4790 std::shared_ptr<KisClient> client_;
4791};
4792
4793} // namespace algotradelib::kis
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 krw()
Returns Korean won.
Definition types.hpp:262
An exact signed decimal: an int64 coefficient times 10^-scale.
Definition types.hpp:297
std::int64_t coefficient() const noexcept
Returns the coefficient.
Definition types.hpp:399
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< TradingCalendar > calendar(Date from, bool refresh=false)
Returns the trading calendar from a date on; see KisClient::async_calendar (cached).
Definition client.hpp:4585
KisBlockingClient(ssl::context &context, KisConfig config, network::HttpClientOptions options={})
Constructs a client.
Definition client.hpp:4548
Result< OrderPage > order_history(KisAccount account, KisHistoryRange range)
Returns the orders placed over a date range, with their fills.
Definition client.hpp:4741
Result< QuoteSnapshot > quote(InstrumentId instrument)
Returns a quote; see KisClient::async_quote.
Definition client.hpp:4560
Result< KisOrderAck > replace_order(KisAccount account, KisAmendment amendment)
Amends an order's price or quantity; the outcome can be unknown after an error (see KisClient::async_...
Definition client.hpp:4697
Result< BuyingPower > buying_power(Currency currency, KisAccount account)
Returns the account's buying power, in won.
Definition client.hpp:4661
Result< std::vector< TradePrint > > trades(InstrumentId instrument, int count)
Returns an instrument's recent trades.
Definition client.hpp:4610
Result< KisOpenOrderPage > open_orders(KisAccount account)
Returns the orders that can still be amended or cancelled; see KisClient::async_open_orders (includin...
Definition client.hpp:4728
Result< HoldingsSnapshot > holdings(KisAccount account)
Returns the account's holdings.
Definition client.hpp:4648
Result< Instrument > instrument(InstrumentId subject)
Returns an instrument's reference data.
Definition client.hpp:4572
Result< PriceLimits > price_limits(InstrumentId instrument)
Returns an instrument's price band for today.
Definition client.hpp:4597
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics (safe to read between calls).
Definition client.hpp:4754
Result< DepthSnapshot > depth(InstrumentId instrument)
Returns the order book (ten levels a side).
Definition client.hpp:4624
bool adopt_token(KisRest token)
Uses a token issued earlier; see KisClient::adopt_token.
Definition client.hpp:4769
Result< BarSeries > bars(BarQuery query)
Returns bars; see KisClient::async_bars.
Definition client.hpp:4636
KisRest token() const
Returns the access token; see KisClient::token.
Definition client.hpp:4762
Result< KisOrderAck > place_order(KisAccount account, OrderRequest order)
Places a cash order; see KisClient::async_place_order.
Definition client.hpp:4681
Result< KisOrderAck > cancel_order(KisAccount account, KisAmendment amendment)
Cancels an order; the outcome can be unknown after an error, and the order may still rest (see KisCli...
Definition client.hpp:4713
The asynchronous KIS REST client: market data, account inquiries and orders for domestic stocks.
Definition client.hpp:324
auto async_place_order(KisAccount account, OrderRequest order, CompletionToken &&token)
Places a cash order.
Definition client.hpp:656
auto async_quote(InstrumentId instrument, CompletionToken &&token)
Requests a quote.
Definition client.hpp:566
net::any_completion_handler< void(std::optional< BrokerError >)> TokenHandler
The completion handler type of a token refresh (internal).
Definition client.hpp:362
net::any_completion_handler< void(Result< QuoteSnapshot >)> QuoteHandler
The completion handler type of async_quote.
Definition client.hpp:327
KisRest token() const
Returns the access token the client holds, to reuse in a later process (adopt_token).
Definition client.hpp:853
void close()
Closes the client for good: pending requests, and later ones that would send, complete with CANCELLED...
Definition client.hpp:882
auto async_calendar(Date from, bool refresh, CompletionToken &&token)
Requests the trading calendar from a date on, and caches it for the client's life.
Definition client.hpp:455
auto async_cancel_order(KisAccount account, KisAmendment amendment, CompletionToken &&token)
Cancels an order (all or part of it, as amendment says).
Definition client.hpp:737
net::any_completion_handler< void(Result< PriceLimits >)> PriceLimitsHandler
The completion handler type of async_price_limits.
Definition client.hpp:336
auto async_trades(InstrumentId instrument, int count, CompletionToken &&token)
Requests an instrument's recent trades.
Definition client.hpp:509
bool adopt_token(KisRest token)
Uses a token issued earlier for the same app key and environment instead of requesting one.
Definition client.hpp:868
net::any_completion_handler< void(Result< KisOpenOrderPage >)> OpenOrderHandler
The completion handler type of async_open_orders.
Definition client.hpp:356
net::any_completion_handler< void(Result< BarSeries >)> BarsHandler
The completion handler type of async_bars.
Definition client.hpp:330
auto async_holdings(KisAccount account, CompletionToken &&token)
Requests the account's holdings.
Definition client.hpp:614
net::any_completion_handler< void(Result< HoldingsSnapshot >)> HoldingsHandler
The completion handler type of async_holdings.
Definition client.hpp:348
net::any_completion_handler< void(Result< OrderPage >)> OrderPageHandler
The completion handler type of async_order_history.
Definition client.hpp:359
auto async_order_history(KisAccount account, KisHistoryRange range, CompletionToken &&token)
Requests the orders placed over a date range, with their fills.
Definition client.hpp:805
net::any_completion_handler< void(Result< Instrument >)> InstrumentHandler
The completion handler type of async_instrument.
Definition client.hpp:345
KisClient(net::any_io_executor executor, ssl::context &context, KisConfig config, network::HttpClientOptions options={})
Constructs a client; nothing is sent until the first request.
Definition client.hpp:371
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics.
Definition client.hpp:837
net::any_completion_handler< void(Result< KisOrderAck >)> OrderAckHandler
The completion handler type of the order requests.
Definition client.hpp:353
auto async_open_orders(KisAccount account, CompletionToken &&token)
Requests every order that can still be amended or cancelled.
Definition client.hpp:777
net::any_completion_handler< void(Result< DepthSnapshot >)> DepthHandler
The completion handler type of async_depth.
Definition client.hpp:333
net::any_completion_handler< void(Result< std::vector< TradePrint > >)> TradesHandler
The completion handler type of async_trades.
Definition client.hpp:339
net::any_completion_handler< void(Result< TradingCalendar >)> CalendarHandler
The completion handler type of async_calendar.
Definition client.hpp:342
auto async_instrument(InstrumentId instrument, CompletionToken &&token)
Requests an instrument's reference data.
Definition client.hpp:427
net::any_completion_handler< void(Result< BuyingPower >)> BuyingPowerHandler
The completion handler type of async_buying_power.
Definition client.hpp:350
auto async_replace_order(KisAccount account, KisAmendment amendment, CompletionToken &&token)
Amends an order's price or quantity.
Definition client.hpp:692
auto async_buying_power(Currency currency, KisAccount account, CompletionToken &&token)
Requests the account's buying power (the account-level amount, in won).
Definition client.hpp:590
auto async_price_limits(InstrumentId instrument, CompletionToken &&token)
Requests an instrument's price band for today (from its current-price inquiry).
Definition client.hpp:478
auto async_depth(InstrumentId instrument, CompletionToken &&token)
Requests the order book (ten levels a side).
Definition client.hpp:543
auto async_bars(BarQuery query, CompletionToken &&token)
Requests bars, reading KIS pages until count_ bars are collected.
Definition client.hpp:404
const std::string & key() const noexcept
Returns the app key (client id).
Definition oauth.hpp:62
std::string to_jsonbody() const
Builds a JSON token-request body (grant type, key and secret under the broker's field names).
Definition oauth.hpp:75
const std::string & secret() const noexcept
Returns the app secret.
Definition oauth.hpp:68
The Korea Investment & Securities (KIS) Open API client.
Definition auth.hpp:9
constexpr const char * market_division_code(MarketTape tape) noexcept
Returns KIS's FID_COND_MRKT_DIV_CODE for a tape.
Definition config.hpp:39
KisRateLimitLayer
Which of KIS's two request-rate limits a refusal reports.
Definition client.hpp:219
@ LEDGER
An endpoint's own cap shared by all customers (EGW00215; 120 per second for holdings and ETF quotes)....
Definition client.hpp:226
@ NONE
Not a rate-limit refusal.
Definition client.hpp:221
@ PERSONAL
This app key's own per-second limit (EGW00201); lower the budget.
Definition client.hpp:228
KisRateLimitLayer kis_rate_limit_layer(std::string_view code)
Classifies a KIS error code as a rate-limit refusal.
Definition client.hpp:239
Date today_in_seoul()
Returns today's date in Korea (UTC+9), the date KIS requests are made in.
Definition client.hpp:200
std::optional< BrokerError > unsupported_client_order_id(const OrderRequest &order)
Refuses an idempotency key, which KIS does not offer to individual customers.
Definition client.hpp:257
constexpr bool publishes_bar_interval(BarInterval interval) noexcept
Tests whether KIS publishes a chart at an interval.
Definition client.hpp:277
std::optional< BrokerError > unsupported_bar_interval(BarInterval interval)
Refuses a bar interval KIS publishes no chart for (publishes_bar_interval).
Definition client.hpp:299
const char * exchange_code(Venue venue) noexcept
Returns Kiwoom's dmst_stex_tp value for a venue.
Definition client.hpp:642
bool valid_trigger_value(const Decimal64 &trigger)
Tests whether a trigger price is one Kiwoom can carry (cond_uv, mdfy_cond_uv).
Definition client.hpp:227
Venue normalized_venue(std::string_view code) noexcept
Converts a venue Kiwoom names on a response (stex_tp, dmst_stex_tp).
Definition client.hpp:670
@ CANCEL
A cancellation (kt10003).
Definition errors.hpp:146
@ UNCLASSIFIED
Not an order request, or one this client does not classify.
Definition errors.hpp:140
@ PLACE
A placement (kt10000, kt10001).
Definition errors.hpp:142
constexpr std::array< KiwoomOrderShapeRow, 21 > kOrderShapes
Kiwoom's order-type (trde_tp) table, from its API specification.
Definition client.hpp:537
std::optional< BrokerError > order_division(OrderType type, TimeInForce time_in_force, OrderSession session, std::string &division)
Finds Kiwoom's order-type code for an order shape.
Definition client.hpp:571
std::optional< OrderSide > normalized_order_side(std::string_view text) noexcept
Reads the side from Kiwoom's free-text io_tp_nm (such as +매수, 현금매도, 매수정정), by whether it contains 매수 ...
Definition client.hpp:707
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
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
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
@ BEST_LIMIT
최유리지정가: priced at the best quote on the opposite side.
Definition types.hpp:1716
@ PRIMARY_LIMIT
최우선지정가: priced at the best quote on the order's own side.
Definition types.hpp:1718
@ CONDITIONAL_LIMIT
조건부지정가: a limit that becomes a market order at the close.
Definition types.hpp:1714
@ MID_PRICE
중간가: priced at the midpoint of the spread.
Definition types.hpp:1720
@ STOP_LIMIT
스톱지정가: a limit released when a trigger price is reached.
Definition types.hpp:1722
@ CLOSING_PRICE
시간외종가: priced at a session's closing price.
Definition types.hpp:1724
@ MARKET
시장가: fills at whatever the book offers.
Definition types.hpp:1712
constexpr bool carries_trigger_price(OrderType type) noexcept
Tests whether an order type takes a trigger price.
Definition types.hpp:1774
constexpr bool carries_caller_price(OrderType type) noexcept
Tests whether an order type takes a price from the caller.
Definition types.hpp:1748
constexpr std::string_view bar_interval_name(BarInterval interval) noexcept
Returns a short name for an interval, such as "1m", "5m", "1h" or "1d".
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
OrderSession
The trading session an order is submitted into.
Definition types.hpp:1839
@ AFTER_HOURS
After the close, at that session's close (장후 시간외).
Definition types.hpp:1850
@ 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.
@ FIVE_MINUTES
Five minutes.
@ THIRTY_MINUTES
Thirty minutes.
@ FIFTEEN_MINUTES
Fifteen minutes.
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
@ FILL_OR_KILL
Fills entirely and immediately, or not at all.
Definition types.hpp:1804
@ DAY
Rests until the session ends; an unfilled remainder is cancelled then.
Definition types.hpp:1800
@ IMMEDIATE_OR_CANCEL
Fills what it can immediately and cancels the remainder.
Definition types.hpp:1802
OrderSide
The side of an order: its economic direction, buy or sell.
Definition types.hpp:1683
std::string format_iso8601_utc(std::chrono::system_clock::time_point value)
Formats an instant as an ISO 8601 timestamp in UTC, such as 2026-10-06T00:30:00Z.
Definition types.hpp:2453
void order_depth(DepthSnapshot &book)
Sorts a book's levels best first: bids descending, asks ascending.
What bars to fetch.
The error a broker or transport operation returns.
Definition types.hpp:742
std::string message_
A human-readable description, for logs; do not parse it.
Definition types.hpp:772
ErrorCategory category_
The kind of failure; the field to branch on.
Definition types.hpp:744
std::string broker_code_
The broker's own error code, verbatim (for example KIS's EGW00201).
Definition types.hpp:761
bool reached_broker_
Whether a request was sent before this error.
Definition types.hpp:753
A calendar date with no time of day or time zone.
Definition types.hpp:1142
An account's positions and per-currency totals at one point in time.
Definition types.hpp:1075
Identifies an instrument: its country, venue and symbol.
Definition types.hpp:78
The broker's acknowledgement that it accepted an instruction.
Definition types.hpp:2092
An order to place, in broker-neutral form.
Definition types.hpp:1925
std::optional< std::string > client_order_id_
An idempotency key, if the broker offers one; never generated by the library.
Definition types.hpp:1966
The state of one order, as a broker listing reported it.
Definition types.hpp:2008
@ WHOLE_HOLDING
Every share held of the instrument, whatever venue or lot.
Definition types.hpp:924
@ DEDUCT
Subtract every resting sell from the figure.
Definition types.hpp:942
The trading days a broker reported for one market, ascending by date.
Definition calendar.hpp:44
A KIS account: the account number and product code every account request names.
Definition client.hpp:29
void from_env()
Loads the account from the KIS_CANO and (optional, default "01") KIS_ACNT_PRDT_CD environment variabl...
Definition client.hpp:40
std::string product_code_
The two-digit product code (KIS's ACNT_PRDT_CD); "01" for a cash equity account.
Definition client.hpp:33
std::string account_number_
The eight-digit account number (KIS's CANO).
Definition client.hpp:31
void from_json(const std::string &filename)
Loads the account from a credential file's account_number (eight digits) and account_product_code (tw...
Definition client.hpp:61
An amendment or cancellation of a KIS order.
Definition client.hpp:115
bool all_remaining_
Whether the whole remaining quantity is affected (KIS's QTY_ALL_ORD_YN), rather than quantity_.
Definition client.hpp:139
OrderType type_
The order type it should have.
Definition client.hpp:119
KisOrderRef order_
The order to amend or cancel.
Definition client.hpp:117
std::optional< Decimal64 > trigger_price_
The trigger price (KIS's CNDT_PRIC), for a STOP_LIMIT order.
Definition client.hpp:137
OrderSession session_
The session it should be in.
Definition client.hpp:123
Decimal64 quantity_
The quantity affected; KIS requires a positive whole number even with all_remaining_.
Definition client.hpp:127
TimeInForce time_in_force_
The execution condition it should have.
Definition client.hpp:121
std::optional< Decimal64 > price_
The price the order should have: required for an order type that takes one, on a cancellation too.
Definition client.hpp:129
Venue venue_
The venue where the order rests (the codes differ by venue).
Definition client.hpp:125
KIS credentials (app key and secret), demo or real account, and the market-data tape.
Definition config.hpp:90
bool is_demo_
Whether to use KIS's demo (모의투자) endpoints; true unless stated otherwise.
Definition config.hpp:101
The date range of a KIS order-history inquiry, both ends included (Korean dates).
Definition client.hpp:189
KIS's list of orders that can still be amended or cancelled.
Definition client.hpp:181
std::vector< KisOpenOrder > orders_
The orders.
Definition client.hpp:183
std::chrono::system_clock::time_point received_at_
When the list arrived, on this machine's clock.
Definition client.hpp:185
A KIS order that can still be amended or cancelled.
Definition client.hpp:158
KisOrderRef original_order_
The order this one superseded by amendment (KIS's orgn_odno); for no such order, all zeros on demo (d...
Definition client.hpp:167
std::string stop_released_
KIS's stpm_efct_occr_yn (whether a stop order has triggered), verbatim; empty if the row has none.
Definition client.hpp:177
Decimal64 amendable_quantity_
The quantity KIS says may still be amended or cancelled.
Definition client.hpp:169
OrderSnapshot order_
The order.
Definition client.hpp:160
KisOrderRef order_ref_
The reference an amendment or cancellation must address.
Definition client.hpp:162
KIS's acknowledgement of an accepted order, amendment or cancellation.
Definition client.hpp:148
std::string broker_order_time_
KIS's ORD_TMD, verbatim: an HHMMSS time with no date or zone (Korean time).
Definition client.hpp:154
KisOrderRef order_
The reference an amendment or cancellation must address.
Definition client.hpp:152
OrderAck ack_
The acknowledgement; accepted_at_ is when this machine received it.
Definition client.hpp:150
A KIS order's address: branch number and order number, both kept.
Definition client.hpp:98
std::string order_number_
The order number within that branch (KIS's ODNO).
Definition client.hpp:102
std::string branch_number_
The branch that recorded the order (KIS's KRX_FWDG_ORD_ORGNO).
Definition client.hpp:100
A KIS REST access token and its expiry.
Definition config.hpp:263
bool valid() const noexcept
Tests whether the token is set and stays valid for at least five more minutes.
Definition config.hpp:305
void from_json(const json::object &document)
Reads a token response.
Definition oauth.hpp:147
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...