324class KisClient :
public std::enable_shared_from_this<KisClient> {
362 using TokenHandler = net::any_completion_handler<void(std::optional<BrokerError>)>;
372 net::any_io_executor executor,
373 ssl::context& context,
377 : strand_(net::make_strand(std::move(executor))), config_(std::move(config)),
379 std::make_shared<
network::AsyncHttpClient>(
403 template <
typename CompletionToken>
406 [self = shared_from_this(), query = std::move(query)](
auto&& handler)
mutable {
407 auto completion =
BarsHandler(std::forward<
decltype(handler)>(handler));
410 [self, query = std::move(query), completion = std::move(completion)]()
mutable {
411 self->start_bars(std::move(query), std::move(completion));
426 template <
typename CompletionToken>
429 [self = shared_from_this(), instrument = std::move(instrument)](
auto&& handler)
mutable {
433 [self, instrument = std::move(instrument), completion = std::move(completion)]()
mutable {
434 self->start_instrument(std::move(instrument), std::move(completion));
454 template <
typename CompletionToken>
457 [self = shared_from_this(), from, refresh](
auto&& handler)
mutable {
458 auto completion =
CalendarHandler(std::forward<
decltype(handler)>(handler));
461 [self, from, refresh, completion = std::move(completion)]()
mutable {
462 self->start_calendar(from, refresh, std::move(completion));
477 template <
typename CompletionToken>
480 [self = shared_from_this(), instrument = std::move(instrument)](
auto&& handler)
mutable {
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",
489 std::move(completion),
508 template <
typename CompletionToken>
510 return net::async_initiate<CompletionToken, void(
Result<std::vector<TradePrint>>)>(
511 [self = shared_from_this(), instrument = std::move(instrument), count](
514 auto completion =
TradesHandler(std::forward<
decltype(handler)>(handler));
517 [self, instrument = std::move(instrument), count, completion = std::move(completion)]()
519 self->start_market_read(
520 std::move(instrument),
521 "/uapi/domestic-stock/v1/quotations/inquire-ccnl",
523 std::move(completion),
524 [count](InstrumentId subject, Result<network::HttpResponse> response) {
525 return parse_trades(std::move(subject), std::move(response), count);
542 template <
typename CompletionToken>
545 [self = shared_from_this(), instrument = std::move(instrument)](
auto&& handler)
mutable {
546 auto completion =
DepthHandler(std::forward<
decltype(handler)>(handler));
549 [self, instrument = std::move(instrument), completion = std::move(completion)]()
mutable {
550 self->start_depth(std::move(instrument), std::move(completion));
565 template <
typename CompletionToken>
568 [self = shared_from_this(), instrument = std::move(instrument)](
auto&& handler)
mutable {
569 auto completion =
QuoteHandler(std::forward<
decltype(handler)>(handler));
572 [self, instrument = std::move(instrument), completion = std::move(completion)]()
mutable {
573 self->start_quote(std::move(instrument), std::move(completion));
589 template <
typename CompletionToken>
592 [self = shared_from_this(), currency, account = std::move(account)](
auto&& handler)
mutable {
596 [self, currency, account = std::move(account), completion = std::move(completion)]()
598 self->start_buying_power(currency, std::move(account), std::move(completion));
613 template <
typename CompletionToken>
616 [self = shared_from_this(), account = std::move(account)](
auto&& handler)
mutable {
617 auto completion =
HoldingsHandler(std::forward<
decltype(handler)>(handler));
620 [self, account = std::move(account), completion = std::move(completion)]()
mutable {
621 self->start_holdings(std::move(account), std::move(completion));
655 template <
typename CompletionToken>
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));
668 account = std::move(account),
669 order = std::move(order),
670 completion = std::move(completion)
672 self->start_place_order(std::move(account), std::move(order), std::move(completion));
691 template <
typename CompletionToken>
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));
704 account = std::move(account),
705 amendment = std::move(amendment),
706 completion = std::move(completion)
708 self->start_amend_order(
709 std::move(account), std::move(amendment), false, std::move(completion)
736 template <
typename CompletionToken>
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));
749 account = std::move(account),
750 amendment = std::move(amendment),
751 completion = std::move(completion)
753 self->start_amend_order(
754 std::move(account), std::move(amendment), true, std::move(completion)
776 template <
typename CompletionToken>
779 [self = shared_from_this(), account = std::move(account)](
auto&& handler)
mutable {
780 auto completion =
OpenOrderHandler(std::forward<
decltype(handler)>(handler));
783 [self, account = std::move(account), completion = std::move(completion)]()
mutable {
784 self->start_open_orders(std::move(account), std::move(completion));
804 template <
typename CompletionToken>
808 self = shared_from_this(),
809 account = std::move(account),
811 ](
auto&& handler)
mutable {
812 auto completion =
OrderPageHandler(std::forward<
decltype(handler)>(handler));
817 account = std::move(account),
819 completion = std::move(completion)
821 self->start_order_history(std::move(account), range, std::move(completion));
838 const auto* limiter = http_->rate_limits();
839 return limiter ? &limiter->stats() :
nullptr;
872 token_ = std::move(
token);
885 friend struct KisClientTestAccess;
887 net::strand<net::any_io_executor> strand_;
889 std::shared_ptr<network::AsyncHttpClient> http_;
892 bool refreshing_{
false};
893 std::deque<TokenHandler> token_waiters_;
895 struct HoldingsOperation {
898 std::string foreign_cursor_;
899 std::string domestic_cursor_;
900 std::size_t page_count_{0};
901 HoldingsHandler completion_;
903 HoldingsOperation(KisAccount selected_account, HoldingsHandler selected_completion)
904 : account_(std::move(selected_account)), completion_(std::move(selected_completion)) {}
908 struct BarOperation {
910 std::vector<Bar> descending_;
915 bool minutes_{
false};
916 BarsHandler completion_;
918 BarOperation(BarQuery query, BarsHandler completion)
919 : query_(std::move(query)), completion_(std::move(completion)) {}
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;
928 static constexpr int bar_page_budget_ = 50;
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)));
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_);
946 [self = shared_from_this(), operation](std::optional<BrokerError> token_error)
mutable {
949 std::move(operation->completion_), Result<BarSeries>(std::move(*token_error))
953 self->request_bars(operation);
960 static std::optional<BrokerError> validate_bar_query(
const BarQuery& query) {
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";
969 if (!valid_domestic_symbol(query.instrument_.symbol_)) {
970 error.broker_code_ =
"invalid_instrument";
972 "KIS bar symbol must contain six letters or digits, or seven for an ETN";
975 if (query.count_ < 1) {
976 error.broker_code_ =
"invalid_count";
977 error.message_ =
"bar request must ask for at least one bar";
986 static const char* period_code(
BarInterval interval) {
988 case BarInterval::ONE_WEEK:
990 case BarInterval::ONE_MONTH:
992 case BarInterval::ONE_YEAR:
994 case BarInterval::ONE_DAY:
995 case BarInterval::ONE_MINUTE:
1020 static constexpr int shortest_days_per_bar(
BarInterval interval) {
1022 case BarInterval::ONE_WEEK:
1024 case BarInterval::ONE_MONTH:
1026 case BarInterval::ONE_YEAR:
1028 case BarInterval::ONE_DAY:
1029 case BarInterval::ONE_MINUTE:
1041 static void korea_calendar(
1042 std::chrono::system_clock::time_point value,
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)
1052 gmtime_s(&parts, &raw);
1054 gmtime_r(&raw, &parts);
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);
1060 std::snprintf(buffer,
sizeof(buffer),
"%02d%02d%02d", parts.tm_hour, parts.tm_min, parts.tm_sec);
1061 time.assign(buffer, 6);
1065 static Date previous_day(
const Date& date) {
1066 Date earlier = date;
1067 if (earlier.day_ > 1) {
1071 if (earlier.month_ > 1) {
1074 earlier.month_ = 12;
1077 static constexpr unsigned days_per_month[] = {
1078 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
1080 unsigned days = days_per_month[earlier.month_ - 1];
1082 earlier.year_ % 4 == 0 && (earlier.year_ % 100 != 0 || earlier.year_ % 400 == 0);
1083 if (earlier.month_ == 2 && leap) {
1086 earlier.day_ = days;
1091 void request_bars(
const std::shared_ptr<BarOperation>& operation) {
1092 network::HttpRequest request;
1095 request.method_ = http::verb::get;
1097 std::string transaction;
1098 if (!operation->minutes_) {
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);
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_) +
1115 "&FID_ORG_ADJ_PRC=" + std::string(operation->query_.adjusted_ ?
"0" :
"1");
1116 transaction =
"FHKST03010100";
1117 }
else if (today_in_korea(operation->date_)) {
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";
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";
1132 request.headers_ = {
1134 {
"appkey", config_.
key()},
1135 {
"appsecret", config_.
secret()},
1136 {
"tr_id", transaction},
1138 {
"content-type",
"application/json; charset=utf-8"},
1141 ++operation->pages_;
1142 http_->async_request(
1144 [self = shared_from_this(), operation](Result<network::HttpResponse> result)
mutable {
1145 self->handle_bars_page(operation, std::move(result));
1151 static bool today_in_korea(
const Date& date) {
1153 std::string ignored;
1154 korea_calendar(std::chrono::system_clock::now(), today, ignored);
1155 return today == date;
1159 void handle_bars_page(
1160 const std::shared_ptr<BarOperation>& operation,
1161 Result<network::HttpResponse> result
1163 const auto before = operation->descending_.size();
1164 if (
auto failure = parse_bars_page(
1165 std::move(result), operation->query_, operation->minutes_, operation->descending_
1167 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(*failure)));
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);
1186 const auto& oldest = operation->descending_.back();
1189 korea_calendar(oldest.timestamp_ - std::chrono::seconds(1), date, time);
1190 if (operation->minutes_ && !(date == operation->date_)) {
1191 finish_bars(operation);
1194 operation->date_ = date;
1195 operation->time_ = time;
1196 request_bars(operation);
1213 static std::optional<BrokerError> parse_bars_page(
1214 Result<network::HttpResponse> result,
1215 const BarQuery& query,
1217 std::vector<Bar>& into
1220 return std::move(result.error());
1222 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
1223 return kis_http_error(result.value());
1225 BrokerError parse_error;
1226 auto document = broker_detail::parse_object(result.value(), parse_error);
1231 if (
auto declared = kis_declared_error(*document)) {
1234 const auto rows = document->if_contains(
"output2");
1235 if (!rows || !rows->is_array()) {
1236 throw std::runtime_error(
"missing array field: output2");
1238 for (
const auto& value : rows->as_array()) {
1239 if (!value.is_object()) {
1240 throw std::runtime_error(
"bar entry is not an object");
1242 const auto& row = value.as_object();
1244 if (broker_detail::json_string(row,
"stck_bsop_date").empty()) {
1247 auto bar = parse_bar(row, query, minutes);
1248 if (!into.empty() && !(
bar.timestamp_ < into.back().timestamp_)) {
1251 into.push_back(std::move(bar));
1253 return std::nullopt;
1254 }
catch (
const std::exception& exception) {
1257 error.broker_code_ =
"invalid_bar_response";
1258 error.message_ = exception.what();
1272 static Bar parse_bar(
const json::object& row,
const BarQuery& query,
bool minutes) {
1273 static_cast<void>(query);
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);
1279 throw std::runtime_error(
"invalid bar timestamp");
1281 bar.timestamp_ = *timestamp;
1285 broker_detail::json_string(row, minutes ?
"stck_prpr" :
"stck_clpr")
1288 broker_detail::json_string(row, minutes ?
"cntg_vol" :
"acml_vol")
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()) {
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";
1306 void finish_bars(
const std::shared_ptr<BarOperation>& operation) {
1308 series.instrument_ = operation->query_.instrument_;
1309 series.interval_ = operation->query_.interval_;
1314 series.adjusted_ = operation->query_.adjusted_;
1315 series.received_at_ = std::chrono::system_clock::now();
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_));
1321 series.bars_.assign(
bars.rbegin(),
bars.rend());
1322 if (!series.bars_.empty() &&
static_cast<int>(series.bars_.size()) < operation->query_.count_) {
1325 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(series)));
1333 void start_instrument(InstrumentId instrument, InstrumentHandler completion) {
1334 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
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)));
1347 self = shared_from_this(),
1348 instrument = std::move(instrument),
1349 completion = std::move(completion)
1350 ](std::optional<BrokerError> token_error)
mutable {
1352 self->deliver(std::move(completion), Result<Instrument>(std::move(*token_error)));
1355 network::HttpRequest request;
1358 request.method_ = http::verb::get;
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"},
1370 {
"content-type",
"application/json; charset=utf-8"},
1372 self->http_->async_request(
1374 [self, instrument = std::move(instrument), completion = std::move(completion)](
1375 Result<network::HttpResponse> result
1378 std::move(completion), parse_instrument(std::move(instrument), std::move(result))
1401 static Result<Instrument> parse_instrument(
1402 InstrumentId requested,
1403 Result<network::HttpResponse> result
1405 auto document = market_document(std::move(result));
1410 const auto output = document.value().if_contains(
"output");
1411 if (!output || !output->is_object()) {
1412 throw std::runtime_error(
"missing object field: output");
1414 const auto& row = output->as_object();
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");
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);
1428 kis_security_type(broker_detail::json_string(row,
"scty_grp_id_cd"));
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_ =
1436 }
catch (
const std::exception& exception) {
1439 error.broker_code_ =
"invalid_reference_response";
1440 error.message_ = exception.what();
1446 static MarketSegment kis_market_segment(std::string_view code) {
1447 if (code ==
"STK") {
1450 if (code ==
"KSQ") {
1464 static SecurityType kis_security_type(std::string_view group) {
1465 if (group ==
"ST" || group ==
"KN") {
1466 return SecurityType::STOCK;
1468 if (group ==
"FS") {
1469 return SecurityType::FOREIGN_STOCK;
1471 if (group ==
"DR") {
1472 return SecurityType::DEPOSITARY_RECEIPT;
1474 if (group ==
"IF") {
1475 return SecurityType::INFRASTRUCTURE_FUND;
1477 if (group ==
"RT") {
1478 return SecurityType::REIT;
1480 if (group ==
"EF") {
1481 return SecurityType::ETF;
1483 if (group ==
"FE") {
1484 return SecurityType::FOREIGN_ETF;
1486 if (group ==
"EN") {
1487 return SecurityType::ETN;
1489 if (group ==
"SW" || group ==
"SR") {
1490 return SecurityType::STOCK_WARRANT;
1492 return SecurityType::UNKNOWN;
1496 static const char* listing_date_field(std::string_view market) {
1497 if (market ==
"KSQ") {
1498 return "kosdaq_mket_lstg_dt";
1500 if (market ==
"STK") {
1501 return "scts_mket_lstg_dt";
1503 return "frbd_mket_lstg_dt";
1507 static const char* delisting_date_field(std::string_view market) {
1508 if (market ==
"KSQ") {
1509 return "kosdaq_mket_lstg_abol_dt";
1511 if (market ==
"STK") {
1512 return "scts_mket_lstg_abol_dt";
1514 return "frbd_mket_lstg_abol_dt";
1530 static StatedDate compact_date_field(
1531 const json::object& row,
1532 std::string_view key
1534 const auto value = row.if_contains(key);
1535 if (!value || value->is_null()) {
1536 return StatedDate{};
1538 if (!value->is_string()) {
1541 const std::string_view text(value->as_string());
1542 if (text.empty() || text ==
"00000000") {
1543 return StatedDate{};
1545 const auto date = broker_detail::compact_date(text);
1550 struct CalendarOperation {
1552 TradingCalendar calendar_;
1553 std::string search_cursor_;
1554 std::string next_cursor_;
1556 CalendarHandler completion_;
1558 CalendarOperation(Date from, CalendarHandler completion)
1559 : from_(from), completion_(std::move(completion)) {}
1563 static constexpr int calendar_page_budget_ = 20;
1570 void start_calendar(Date from,
bool refresh, CalendarHandler completion) {
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)));
1580 if (!refresh && calendar_covers(calendar_, from)) {
1581 deliver(std::move(completion), Result<TradingCalendar>(calendar_));
1585 auto operation = std::make_shared<CalendarOperation>(from, std::move(completion));
1586 operation->calendar_.country_ = Country::KR;
1589 [self = shared_from_this(), operation](std::optional<BrokerError> token_error)
mutable {
1592 std::move(operation->completion_), Result<TradingCalendar>(std::move(*token_error))
1596 self->request_calendar(operation);
1603 static bool calendar_covers(
const TradingCalendar& calendar,
const Date& from) {
1604 const auto covered = calendar.covered();
1610 return covered && !(from < covered->first) && !(covered->second < from) &&
1611 calendar.is_trading_day(from).has_value();
1616 static std::size_t calendar_rows_consumed(
const TradingCalendar& calendar) {
1617 return calendar.days_.size() + calendar.unreadable_days_;
1621 void request_calendar(
const std::shared_ptr<CalendarOperation>& operation) {
1622 network::HttpRequest request;
1625 request.method_ = http::verb::get;
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_ = {
1633 {
"appkey", config_.
key()},
1634 {
"appsecret", config_.
secret()},
1635 {
"tr_id",
"CTCA0903R"},
1636 {
"tr_cont", operation->pages_ == 0 ?
"" :
"N"},
1638 {
"content-type",
"application/json; charset=utf-8"},
1641 ++operation->pages_;
1642 http_->async_request(
1644 [self = shared_from_this(), operation](Result<network::HttpResponse> result)
mutable {
1645 self->handle_calendar_page(operation, std::move(result));
1651 void handle_calendar_page(
1652 const std::shared_ptr<CalendarOperation>& operation,
1653 Result<network::HttpResponse> result
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_;
1663 if (
auto failure = parse_calendar_page(std::move(result), operation->calendar_, search, next)) {
1665 std::move(operation->completion_), Result<TradingCalendar>(std::move(*failure))
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_));
1679 operation->search_cursor_ = std::move(search);
1680 operation->next_cursor_ = std::move(next);
1681 request_calendar(operation);
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
1705 auto document = market_document(std::move(result));
1707 return std::move(document.error());
1710 const auto rows = document.value().if_contains(
"output");
1711 if (!rows || !rows->is_array()) {
1712 throw std::runtime_error(
"missing array field: output");
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");
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())
1731 day.open_ = broker_detail::json_string(row,
"opnd_yn") ==
"Y";
1732 staged.push_back(day);
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) {
1741 error.broker_code_ =
"invalid_calendar_response";
1742 error.message_ = exception.what();
1748 static void read_calendar_cursors(
1749 const json::object& document,
1750 std::string& search_cursor,
1751 std::string& next_cursor
1753 if (
const auto value = document.if_contains(
"ctx_area_fk"); value && value->is_string()) {
1754 search_cursor = std::string(value->as_string());
1756 if (
const auto value = document.if_contains(
"ctx_area_nk"); value && value->is_string()) {
1757 next_cursor = std::string(value->as_string());
1765 template <
typename Handler,
typename Parse>
1766 void start_market_read(
1767 InstrumentId instrument,
1769 std::string transaction,
1773 using ResultType =
decltype(parse(InstrumentId{}, Result<network::HttpResponse>(BrokerError{})));
1774 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
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)));
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),
1793 ](std::optional<BrokerError> token_error)
mutable {
1795 self->deliver(std::move(completion), ResultType(std::move(*token_error)));
1798 network::HttpRequest request;
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},
1809 {
"content-type",
"application/json; charset=utf-8"},
1811 self->http_->async_request(
1813 [self, instrument = std::move(instrument), completion = std::move(completion), parse](
1814 Result<network::HttpResponse> result
1817 std::move(completion), parse(std::move(instrument), std::move(result))
1830 static Result<json::object> market_document(Result<network::HttpResponse> response) {
1832 return Result<json::object>(std::move(response.error()));
1834 if (response.value().result_int() < 200 || response.value().result_int() >= 300) {
1835 return Result<json::object>(kis_http_error(response.value()));
1837 BrokerError parse_error;
1838 auto document = broker_detail::parse_object(response.value(), parse_error);
1840 return Result<json::object>(std::move(parse_error));
1843 if (
auto declared = kis_declared_error(*document)) {
1844 return Result<json::object>(std::move(*declared));
1846 }
catch (
const std::exception& exception) {
1849 error.broker_code_ =
"invalid_market_response";
1850 error.message_ = exception.what();
1851 return Result<json::object>(std::move(error));
1853 return Result<json::object>(std::move(*document));
1860 static Result<PriceLimits> parse_price_limits(
1861 InstrumentId instrument,
1862 Result<network::HttpResponse> result
1864 static_cast<void>(instrument);
1865 auto document = market_document(std::move(result));
1870 const auto output = document.value().if_contains(
"output");
1871 if (!output || !output->is_object()) {
1872 throw std::runtime_error(
"missing object field: output");
1874 const auto& row = output->as_object();
1877 limits.upper_ = positive_decimal(row,
"stck_mxpr");
1878 limits.lower_ = positive_decimal(row,
"stck_llam");
1880 }
catch (
const std::exception& exception) {
1883 error.broker_code_ =
"invalid_price_limit_response";
1884 error.message_ = exception.what();
1890 static std::optional<Decimal64> positive_decimal(
1891 const json::object& row,
1892 std::string_view key
1894 const auto value = row.if_contains(key);
1895 if (!value || !value->is_string()) {
1896 return std::nullopt;
1898 const std::string text(value->as_string());
1900 return std::nullopt;
1903 if (parsed.coefficient() == 0) {
1904 return std::nullopt;
1914 static Result<std::vector<TradePrint>> parse_trades(
1915 InstrumentId instrument,
1916 Result<network::HttpResponse> result,
1919 static_cast<void>(instrument);
1920 auto document = market_document(std::move(result));
1922 return Result<std::vector<TradePrint>>(std::move(document.error()));
1925 const auto rows = document.value().if_contains(
"output");
1926 if (!rows || !rows->is_array()) {
1927 throw std::runtime_error(
"missing array field: output");
1930 std::string ignored;
1931 korea_calendar(std::chrono::system_clock::now(), today, ignored);
1932 const auto date = compact_date(today);
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");
1940 const auto& row = value.as_object();
1941 const auto time = broker_detail::json_string(row,
"stck_cntg_hour");
1947 print.price_ =
Decimal64::parse(broker_detail::json_string(row,
"stck_prpr"));
1948 print.quantity_ =
Decimal64::parse(broker_detail::json_string(row,
"cntg_vol"));
1950 print.exchange_time_ = korea_timestamp(date, time);
1951 if (!print.exchange_time_) {
1952 throw std::runtime_error(
"invalid execution time");
1954 prints.push_back(std::move(print));
1955 if (count > 0 &&
static_cast<int>(prints.size()) >= count) {
1959 return Result<std::vector<TradePrint>>(std::move(prints));
1960 }
catch (
const std::exception& exception) {
1963 error.broker_code_ =
"invalid_execution_response";
1964 error.message_ = exception.what();
1965 return Result<std::vector<TradePrint>>(std::move(error));
1970 void start_depth(InstrumentId instrument, DepthHandler completion) {
1971 if (instrument.country_ != Country::KR || !valid_domestic_symbol(instrument.symbol_)) {
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)));
1984 self = shared_from_this(),
1985 instrument = std::move(instrument),
1986 completion = std::move(completion)
1987 ](std::optional<BrokerError> token_error)
mutable {
1990 std::move(completion), Result<DepthSnapshot>(std::move(*token_error))
1994 network::HttpRequest request;
1997 request.method_ = http::verb::get;
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"},
2007 {
"content-type",
"application/json; charset=utf-8"},
2009 self->http_->async_request(
2011 [self, instrument = std::move(instrument), completion = std::move(completion)](
2012 Result<network::HttpResponse> result
2015 std::move(completion), parse_depth(std::move(instrument), std::move(result))
2034 static Result<DepthSnapshot> parse_depth(
2035 InstrumentId instrument,
2036 Result<network::HttpResponse> result
2041 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2044 BrokerError parse_error;
2045 auto document = broker_detail::parse_object(result.value(), parse_error);
2050 if (
auto declared = kis_declared_error(*document)) {
2053 const auto output = document->if_contains(
"output1");
2054 if (!output || !output->is_object()) {
2055 throw std::runtime_error(
"missing object field: output1");
2057 const auto& row = output->as_object();
2060 book.instrument_ = std::move(instrument);
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_);
2067 if (
const auto hour = row.if_contains(
"aspr_acpt_hour"); hour && hour->is_string()) {
2068 const std::string time(hour->as_string());
2070 std::string ignored;
2071 korea_calendar(std::chrono::system_clock::now(), today, ignored);
2072 book.exchange_time_ = korea_timestamp(compact_date(today), time);
2074 book.received_at_ = std::chrono::system_clock::now();
2076 }
catch (
const std::exception& exception) {
2079 error.broker_code_ =
"invalid_orderbook_response";
2080 error.message_ = exception.what();
2086 static constexpr int depth_levels_ = 10;
2089 static void append_level(
2090 const json::object& row,
2091 std::string_view price_prefix,
2092 std::string_view quantity_prefix,
2094 std::vector<DepthLevel>& levels
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()) {
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()) {
2108 if (value.coefficient() == 0) {
2114 void start_quote(InstrumentId instrument, QuoteHandler completion) {
2115 if (instrument.country_ != Country::KR || !broker_detail::valid_symbol(instrument.symbol_)) {
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)));
2128 self = shared_from_this(),
2129 instrument = std::move(instrument),
2130 completion = std::move(completion)
2131 ](std::optional<BrokerError> token_error)
mutable {
2134 std::move(completion), Result<QuoteSnapshot>(std::move(*token_error))
2138 self->request_quote(std::move(instrument), std::move(completion));
2144 void start_buying_power(Currency currency,
KisAccount account, BuyingPowerHandler completion) {
2145 auto invalid = validate_account(account);
2152 error.reached_broker_ =
false;
2153 error.broker_code_ =
"unsupported_currency";
2155 "the KIS domestic buying-power inquiry answers in KRW only, not " + currency.str();
2156 invalid = std::move(error);
2159 complete_buying_power(std::move(completion), Result<BuyingPower>(std::move(*invalid)));
2164 [self = shared_from_this(), account = std::move(account),
2165 completion = std::move(completion)](std::optional<BrokerError> token_error)
mutable {
2167 self->complete_buying_power(
2168 std::move(completion), Result<BuyingPower>(std::move(*token_error))
2172 self->request_buying_power(std::move(account), std::move(completion));
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
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))
2211 static network::HttpRequest buying_power_request(
2213 std::string_view access_token,
2216 network::HttpRequest request;
2218 request.method_ = http::verb::get;
2220 "/uapi/domestic-stock/v1/trading/inquire-psbl-order"
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"},
2230 {
"content-type",
"application/json; charset=utf-8"},
2243 static Result<BuyingPower> parse_buying_power(Result<network::HttpResponse> response) {
2244 auto document = market_document(std::move(response));
2249 const auto output = document.value().if_contains(
"output");
2250 if (!output || !output->is_object()) {
2251 throw std::runtime_error(
"missing object field: output");
2254 power.cash_ = Money{
2255 Decimal64::parse(broker_detail::json_string(output->as_object(),
"nrcvb_buy_amt")),
2259 power.received_at_ = std::chrono::system_clock::now();
2261 }
catch (
const std::exception& exception) {
2264 error.broker_code_ =
"invalid_buying_power_response";
2265 error.message_ = exception.what();
2270 void complete_buying_power(BuyingPowerHandler handler, Result<BuyingPower> result) {
2271 deliver(std::move(handler), std::move(result));
2274 void start_holdings(
KisAccount account, HoldingsHandler completion) {
2275 if (
auto invalid = validate_account(account)) {
2277 std::move(completion), Result<HoldingsSnapshot>(std::move(*invalid))
2282 auto operation = std::make_shared<HoldingsOperation>(
2283 std::move(account), std::move(completion)
2287 [self = shared_from_this(), operation](std::optional<BrokerError> token_error)
mutable {
2289 self->finish_holdings(operation, Result<HoldingsSnapshot>(std::move(*token_error)));
2292 self->request_holdings(operation);
2298 void ensure_token(TokenHandler handler) {
2299 if (token_.
valid()) {
2300 std::move(handler)(std::nullopt);
2303 token_waiters_.push_back(std::move(handler));
2309 network::HttpRequest request;
2312 request.method_ = http::verb::post;
2313 request.target_ =
"/oauth2/tokenP";
2315 request.headers_.emplace_back(
"content-type",
"application/json");
2316 http_->async_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));
2326 void finish_token(Result<network::HttpResponse> result) {
2327 refreshing_ =
false;
2328 std::optional<BrokerError> failure;
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());
2335 BrokerError parse_error;
2336 auto document = broker_detail::parse_object(result.value(), parse_error);
2338 failure = std::move(parse_error);
2342 }
catch (
const std::exception& exception) {
2345 error.broker_code_ =
"invalid_token_response";
2346 error.message_ = exception.what();
2347 failure = std::move(error);
2356 failure->reached_broker_ =
false;
2358 auto waiters = std::move(token_waiters_);
2359 token_waiters_.clear();
2360 for (
auto& waiter : waiters) {
2362 std::move(waiter)(*failure);
2364 std::move(waiter)(std::nullopt);
2384 static std::string market_query(
const KisConfig& config,
const std::string& symbol) {
2386 "&FID_INPUT_ISCD=" + broker_detail::query_encode(symbol);
2389 void request_quote(InstrumentId instrument, QuoteHandler completion) {
2396 network::HttpRequest request;
2399 request.method_ = http::verb::get;
2401 "/uapi/domestic-stock/v1/quotations/inquire-price"
2402 + market_query(config_, instrument.symbol_);
2403 request.headers_ = {
2405 {
"appkey", config_.
key()},
2406 {
"appsecret", config_.
secret()},
2407 {
"tr_id",
"FHKST01010100"},
2409 {
"content-type",
"application/json; charset=utf-8"},
2411 http_->async_request(
2414 self = shared_from_this(),
2415 instrument = std::move(instrument),
2416 completion = std::move(completion)
2417 ](Result<network::HttpResponse> result)
mutable {
2422 instrument = std::move(instrument),
2423 completion = std::move(completion),
2424 result = std::move(result)
2427 std::move(completion), parse_quote(std::move(instrument), std::move(result))
2435 void request_holdings(
const std::shared_ptr<HoldingsOperation>& operation) {
2436 auto request = make_holdings_request(
2437 operation->account_,
2440 operation->foreign_cursor_,
2441 operation->domestic_cursor_,
2442 operation->page_count_ > 0
2444 http_->async_request(
2446 [self = shared_from_this(), operation](Result<network::HttpResponse> result)
mutable {
2449 [self, operation, result = std::move(result)]()
mutable {
2450 self->handle_holdings_page(operation, std::move(result));
2457 static network::HttpRequest make_holdings_request(
2460 std::string_view access_token,
2461 std::string_view foreign_cursor,
2462 std::string_view domestic_cursor,
2465 network::HttpRequest request;
2468 request.method_ = http::verb::get;
2470 "/uapi/domestic-stock/v1/trading/inquire-balance"
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" :
""},
2484 {
"content-type",
"application/json; charset=utf-8"},
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,
2500 const bool cursor_did_not_advance = next_foreign_cursor == foreign_cursor &&
2501 next_domestic_cursor == domestic_cursor;
2503 page_count + 1 >= 100 ||
2504 (next_foreign_cursor.empty() && next_domestic_cursor.empty()) ||
2505 cursor_did_not_advance
2509 error.broker_code_ =
"invalid_pagination";
2510 error.message_ =
"KIS holdings continuation did not advance";
2515 snapshot.positions_.insert(
2516 snapshot.positions_.end(),
2517 std::make_move_iterator(page.positions_.begin()),
2518 std::make_move_iterator(page.positions_.end())
2520 if (!page.totals_.empty()) {
2521 snapshot.totals_ = std::move(page.totals_);
2525 foreign_cursor = std::move(next_foreign_cursor);
2526 domestic_cursor = std::move(next_domestic_cursor);
2528 return std::nullopt;
2531 void handle_holdings_page(
const std::shared_ptr<HoldingsOperation>& operation, Result<network::HttpResponse> result) {
2533 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(result.error())));
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
2546 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(page.error())));
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),
2560 if (pagination_error) {
2562 operation, Result<HoldingsSnapshot>(std::move(*pagination_error))
2568 request_holdings(operation);
2572 operation->snapshot_.received_at_ = std::chrono::system_clock::now();
2579 operation->snapshot_.sellable_basis_ = SellableBasis{
2584 finish_holdings(operation, Result<HoldingsSnapshot>(std::move(operation->snapshot_)));
2587 static Result<QuoteSnapshot> parse_quote(InstrumentId instrument, Result<network::HttpResponse> result) {
2591 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2594 BrokerError parse_error;
2595 auto document = broker_detail::parse_object(result.value(), parse_error);
2600 const auto result_code = broker_detail::json_string(*document,
"rt_cd");
2601 if (result_code !=
"0") {
2604 if (
const auto code = document->if_contains(
"msg_cd"); code && code->is_string()) {
2605 error.broker_code_ = code->as_string().c_str();
2607 if (
const auto message = document->if_contains(
"msg1"); message && message->is_string()) {
2608 error.message_ = message->as_string().c_str();
2612 const auto output = document->if_contains(
"output");
2613 if (!output || !output->is_object()) {
2614 throw std::runtime_error(
"missing object field: output");
2617 const auto symbol = output->as_object().if_contains(
"stck_shrn_iscd");
2618 symbol && symbol->is_string() && symbol->as_string() != instrument.symbol_
2620 throw std::runtime_error(
"quote response symbol does not match request");
2622 QuoteSnapshot
quote;
2623 quote.instrument_ = std::move(instrument);
2625 Decimal64::parse(broker_detail::json_string(output->as_object(),
"stck_prpr"));
2627 quote.received_at_ = std::chrono::system_clock::now();
2629 }
catch (
const std::exception& exception) {
2632 error.broker_code_ =
"invalid_quote_response";
2633 error.message_ = exception.what();
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
2646 static Result<HoldingsSnapshot> parse_holdings_page(
2647 Result<network::HttpResponse> result,
2648 std::string& foreign_cursor,
2649 std::string& domestic_cursor
2654 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
2657 BrokerError parse_error;
2658 auto document = broker_detail::parse_object(result.value(), parse_error);
2663 const auto result_code = broker_detail::json_string(*document,
"rt_cd");
2664 if (result_code !=
"0") {
2667 if (
const auto code = document->if_contains(
"msg_cd"); code && code->is_string()) {
2668 error.broker_code_ = code->as_string().c_str();
2670 if (
const auto message = document->if_contains(
"msg1"); message && message->is_string()) {
2671 error.message_ = message->as_string().c_str();
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");
2681 if (!summaries || !summaries->is_array()) {
2682 throw std::runtime_error(
"missing array field: output2");
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");
2690 const auto& output = value.as_object();
2691 HoldingPosition position;
2692 position.instrument_ = InstrumentId{
2695 broker_detail::json_string(output,
"pdno"),
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_ =
2701 position.average_purchase_price_ =
2703 position.last_price_ =
Decimal64::parse(broker_detail::json_string(output,
"prpr"));
2704 position.purchase_value_ = Money{
2707 position.market_value_ = Money{
2710 position.unrealized_profit_loss_ = Money{
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));
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");
2723 const auto& output = value.as_object();
2724 snapshot.totals_.push_back(
2729 Decimal64::parse(broker_detail::json_string(output,
"evlu_pfls_smtl_amt")),
2735 const auto value = document->if_contains(
"ctx_area_fk100"); value && value->is_string()
2737 foreign_cursor = value->as_string().c_str();
2740 const auto value = document->if_contains(
"ctx_area_nk100"); value && value->is_string()
2742 domestic_cursor = value->as_string().c_str();
2745 }
catch (
const std::exception& exception) {
2748 error.broker_code_ =
"invalid_holdings_response";
2749 error.message_ = exception.what();
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_;
2764 OpenOrderOperation(KisAccount selected_account, OpenOrderHandler selected_completion)
2765 : account_(std::move(selected_account)), completion_(std::move(selected_completion)) {}
2769 struct OrderHistoryOperation {
2770 KisAccount account_;
2771 KisHistoryRange range_;
2773 std::string foreign_cursor_;
2774 std::string domestic_cursor_;
2775 std::size_t page_count_{0};
2776 OrderPageHandler completion_;
2779 OrderHistoryOperation(
2780 KisAccount selected_account,
2781 KisHistoryRange selected_range,
2782 OrderPageHandler selected_completion
2784 : account_(std::move(selected_account)), range_(selected_range),
2785 completion_(std::move(selected_completion)) {}
2789 static std::optional<BrokerError> validate_account(
const KisAccount& account) {
2791 broker_detail::decimal_digits(account.account_number_, 8) &&
2792 broker_detail::decimal_digits(account.product_code_, 2)
2794 return std::nullopt;
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";
2805 static std::optional<BrokerError> validate_quantity(
const Decimal64& quantity) {
2806 if (quantity.coefficient() > 0 && quantity.scale() == 0) {
2807 return std::nullopt;
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";
2822 static std::optional<BrokerError> validate_price(
2824 const std::optional<Decimal64>& price
2828 error.reached_broker_ =
false;
2829 error.broker_code_ =
"invalid_price";
2836 error.message_ =
"KIS order of this price basis requires a price";
2839 if (price->coefficient() <= 0) {
2840 error.message_ =
"KIS order price must be positive";
2843 if (price->scale() != 0) {
2844 error.message_ =
"KIS domestic order price must be a whole number of won";
2847 return std::nullopt;
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";
2855 return std::nullopt;
2860 return trigger.coefficient() > 0 && trigger.scale() == 0;
2872 static std::optional<BrokerError> validate_trigger(
2874 const std::optional<Decimal64>& trigger
2878 error.reached_broker_ =
false;
2879 error.broker_code_ =
"invalid_trigger_price";
2882 error.message_ =
"a KIS stop-limit order (22) requires a trigger price in CNDT_PRIC";
2886 error.message_ =
"a KIS trigger price must be a positive whole number of won";
2889 return std::nullopt;
2893 "KIS takes a trigger price only on a stop-limit order (22); on this basis it would be "
2897 return std::nullopt;
2914 struct OrderShapeRow {
2923 static constexpr unsigned kKrx = 1u << 0;
2924 static constexpr unsigned kNxt = 1u << 1;
2925 static constexpr unsigned kSor = 1u << 2;
2945 static constexpr std::array<OrderShapeRow, 28>
kOrderShapes{{
2983 static constexpr unsigned venue_bit(
Venue venue) {
2992 case Venue::COMBINED:
2997 case Venue::UNSPECIFIED:
3016 std::string& division
3020 error.reached_broker_ =
false;
3021 error.broker_code_ =
"unsupported_order";
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) {
3030 if (matched ==
nullptr) {
3032 std::string(
"KIS publishes no order code for this combination of price basis, ") +
3033 "validity condition and session";
3036 if (!matched->requestable_) {
3038 std::string(
"KIS reports order code ") + matched->code_ +
3039 " on an inquiry but does not publish it as a placeable order";
3042 if ((matched->venues_ & venue_bit(venue)) == 0) {
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";
3048 division = matched->code_;
3049 return std::nullopt;
3057 static bool valid_domestic_symbol(std::string_view symbol) {
3058 if (symbol.size() != 6 && symbol.size() != 7) {
3061 for (
const char ch : symbol) {
3062 const bool valid = (ch >=
'0' && ch <=
'9') || (ch >=
'A' && ch <=
'Z') ||
3063 (ch >=
'a' && ch <=
'z');
3078 static bool valid_order_reference(
const KisOrderRef& order) {
3079 if (!broker_detail::decimal_digits(order.order_number_, 10)) {
3082 return order.branch_number_.empty() ||
3083 broker_detail::decimal_digits(order.branch_number_, 5);
3087 static std::optional<BrokerError> validate_order(
3089 const OrderRequest& order,
3090 std::string& division
3095 if (order.instrument_.country_ != Country::KR) {
3098 error.reached_broker_ =
false;
3099 error.broker_code_ =
"invalid_instrument";
3100 error.message_ =
"KIS cash orders currently support Korean equities only";
3110 if (order.instrument_.venue_ != instrument_venue()) {
3113 error.reached_broker_ =
false;
3114 error.broker_code_ =
"invalid_instrument";
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";
3120 if (!valid_domestic_symbol(order.instrument_.symbol_)) {
3123 error.reached_broker_ =
false;
3124 error.broker_code_ =
"invalid_instrument";
3126 "KIS domestic order symbol must contain six letters or digits, or seven for an ETN";
3129 if (
auto error = validate_quantity(order.quantity_)) {
3132 if (
auto error = validate_price(order.type_, order.price_)) {
3135 if (
auto error = validate_trigger(order.type_, order.trigger_price_)) {
3138 if (
auto error = validate_venue(config, order.venue_)) {
3143 order.time_in_force_,
3159 static std::optional<BrokerError> validate_amendment(
3162 std::string& division,
3165 if (
auto error = validate_quantity(amendment.quantity_)) {
3168 if (
auto error = validate_price(amendment.type_, amendment.price_)) {
3176 if (
auto error = validate_trigger(amendment.type_, amendment.trigger_price_)) {
3183 if (
auto error = validate_venue(config, amendment.venue_)) {
3188 amendment.time_in_force_,
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;
3246 error.reached_broker_ =
false;
3247 error.broker_code_ =
"venue_unavailable";
3249 "KIS demo accounts trade on KRX only; the requested venue is unavailable on this "
3259 static const char* transmitted_exchange(
const KisConfig& config,
Venue venue) {
3269 case Venue::COMBINED:
3271 case Venue::UNSPECIFIED:
3278 static network::HttpRequest make_place_order_request(
3281 std::string_view access_token,
3282 const OrderRequest& order,
3283 std::string_view division
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")},
3296 if (order.trigger_price_) {
3297 body.emplace(
"CNDT_PRIC", order.trigger_price_->str());
3299 if (
const auto* exchange = transmitted_exchange(config, order.venue_);
3300 *exchange !=
'\0') {
3301 body.emplace(
"EXCG_ID_DVSN_CD", exchange);
3304 const bool buy = order.side_ == OrderSide::BUY;
3305 network::HttpRequest request;
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()},
3316 (config.is_demo_ ?
"VTTC0012U" :
"TTTC0012U") :
3317 (config.is_demo_ ?
"VTTC0011U" :
"TTTC0011U")},
3319 {
"content-type",
"application/json; charset=utf-8"},
3325 static network::HttpRequest make_amend_order_request(
3328 std::string_view access_token,
3331 std::string_view division
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"},
3348 body.emplace(
"CNDT_PRIC", amendment.trigger_price_->str());
3354 if (
const auto* exchange = transmitted_exchange(config, amendment.venue_);
3355 *exchange !=
'\0') {
3356 body.emplace(
"EXCG_ID_DVSN_CD", exchange);
3359 network::HttpRequest request;
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"},
3371 {
"content-type",
"application/json; charset=utf-8"},
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()) {
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();
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;
3411 if (
const auto code = document.if_contains(
"msg_cd"); code && code->is_string()) {
3412 error.broker_code_ = code->as_string().c_str();
3414 if (
const auto message = document.if_contains(
"msg1"); message && message->is_string()) {
3415 error.message_ = message->as_string().c_str();
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());
3440 static std::optional<std::string_view> recognized_refusal_status(
3441 OrderOperation operation,
3442 std::string_view code,
3446 if (operation == OrderOperation::PLACE && code ==
"APBK3061") {
3447 return std::string_view(
"7");
3449 return std::nullopt;
3451 if (operation == OrderOperation::PLACE && (code ==
"40240000" || code ==
"40570000" || code ==
"40580000")) {
3452 return std::string_view(
"1");
3454 if (operation == OrderOperation::CANCEL && code ==
"40330000") {
3455 return std::string_view(
"1");
3457 return std::nullopt;
3475 static std::optional<BrokerError> kis_recognized_refusal(
3477 const json::object& document,
3478 OrderOperation operation,
3481 if (http_status != 200) {
3482 return std::nullopt;
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;
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;
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;
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;
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 +
")";
3519 static Result<KisOrderAck> parse_order_ack(
3520 Result<network::HttpResponse> result,
3521 OrderOperation operation = OrderOperation::UNCLASSIFIED,
3527 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3530 BrokerError parse_error;
3531 auto document = broker_detail::parse_object(result.value(), parse_error);
3536 if (
auto refused = kis_recognized_refusal(result.value().result_int(), *document, operation, demo)) {
3539 if (
auto declared = kis_declared_error(*document)) {
3542 const auto output = document->if_contains(
"output");
3543 if (!output || !output->is_object()) {
3544 throw std::runtime_error(
"missing object field: output");
3546 const auto& fields = output->as_object();
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();
3555 }
catch (
const std::exception& exception) {
3558 error.broker_code_ =
"invalid_order_response";
3559 error.message_ = exception.what();
3565 void start_place_order(
KisAccount account, OrderRequest order, OrderAckHandler completion) {
3566 std::string division;
3567 auto invalid = validate_account(account);
3569 invalid = validate_order(config_, order, division);
3572 complete_order_ack(std::move(completion), Result<KisOrderAck>(std::move(*invalid)));
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 {
3586 self->complete_order_ack(
3587 std::move(completion), Result<KisOrderAck>(std::move(*token_error))
3591 auto request = make_place_order_request(
3592 account, self->config_, self->token_.access_token_, order, division
3594 self->request_order_ack(std::move(request), OrderOperation::PLACE, std::move(completion));
3601 void start_amend_order(
3605 OrderAckHandler completion
3607 std::string division;
3608 auto invalid = validate_account(account);
3609 if (!invalid && !valid_order_reference(amendment.order_)) {
3612 error.reached_broker_ =
false;
3613 error.broker_code_ =
"invalid_order_reference";
3615 "KIS amendment requires a ten-digit order number and an empty or "
3616 "five-digit branch number";
3617 invalid = std::move(error);
3620 invalid = validate_amendment(config_, amendment, division, cancel);
3623 complete_order_ack(std::move(completion), Result<KisOrderAck>(std::move(*invalid)));
3630 self = shared_from_this(),
3631 account = std::move(account),
3632 amendment = std::move(amendment),
3634 division = std::move(division),
3635 completion = std::move(completion)
3636 ](std::optional<BrokerError> token_error)
mutable {
3638 self->complete_order_ack(
3639 std::move(completion), Result<KisOrderAck>(std::move(*token_error))
3643 auto request = make_amend_order_request(
3644 account, self->config_, self->token_.access_token_, amendment, cancel, division
3646 self->request_order_ack(
3647 std::move(request), cancel ? OrderOperation::CANCEL : OrderOperation::AMEND, std::move(completion)
3656 void request_order_ack(
3657 network::HttpRequest request,
3658 OrderOperation operation,
3659 OrderAckHandler completion
3661 http_->async_request(
3663 [self = shared_from_this(), operation, demo = config_.
is_demo_, completion = std::move(completion)](
3664 Result<network::HttpResponse> result
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)
3679 void start_open_orders(
KisAccount account, OpenOrderHandler completion) {
3680 auto invalid = validate_account(account);
3682 complete_open_orders(std::move(completion), Result<KisOpenOrderPage>(std::move(*invalid)));
3686 auto operation = std::make_shared<OpenOrderOperation>(
3687 std::move(account), std::move(completion)
3691 [self = shared_from_this(), operation](std::optional<BrokerError> token_error)
mutable {
3693 self->complete_open_orders(
3694 std::move(operation->completion_),
3695 Result<KisOpenOrderPage>(std::move(*token_error))
3699 self->request_open_orders(operation);
3706 static network::HttpRequest make_open_orders_request(
3709 std::string_view access_token,
3710 std::string_view foreign_cursor,
3711 std::string_view domestic_cursor,
3714 if (config.is_demo_) {
3717 return make_order_history_request(
3718 account, config, access_token,
KisHistoryRange{today, today}, foreign_cursor,
3719 domestic_cursor, continuation,
"02"
3722 network::HttpRequest request;
3728 request.method_ = http::verb::get;
3730 "/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl"
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" :
""},
3743 {
"content-type",
"application/json; charset=utf-8"},
3749 void request_open_orders(
const std::shared_ptr<OpenOrderOperation>& operation) {
3750 auto request = make_open_orders_request(
3751 operation->account_,
3754 operation->foreign_cursor_,
3755 operation->domestic_cursor_,
3756 operation->page_count_ > 0
3758 http_->async_request(
3760 [self = shared_from_this(), operation](Result<network::HttpResponse> result)
mutable {
3763 [self, operation, result = std::move(result)]()
mutable {
3764 self->handle_open_orders_page(operation, std::move(result));
3772 void handle_open_orders_page(
3773 const std::shared_ptr<OpenOrderOperation>& operation,
3774 Result<network::HttpResponse> result
3777 complete_open_orders(
3778 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(result.error()))
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_
3790 complete_open_orders(
3791 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(page.error()))
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),
3803 "KIS modifiable-order continuation did not advance"
3805 if (pagination_error) {
3806 complete_open_orders(
3807 std::move(operation->completion_), Result<KisOpenOrderPage>(std::move(*pagination_error))
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())
3818 request_open_orders(operation);
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_))
3832 static Result<KisOpenOrderPage> parse_open_orders_page(
3833 Result<network::HttpResponse> result,
3834 std::string& foreign_cursor,
3835 std::string& domestic_cursor,
3841 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
3844 BrokerError parse_error;
3845 auto document = broker_detail::parse_object(result.value(), parse_error);
3850 if (
auto declared = kis_declared_error(*document)) {
3854 auto page = open_orders_from_history(*document);
3855 read_cursors(*document, foreign_cursor, domestic_cursor);
3858 const auto rows = document->if_contains(
"output");
3859 if (!rows || !rows->is_array()) {
3860 throw std::runtime_error(
"missing array field: output");
3864 for (
const auto& value : rows->as_array()) {
3865 if (!value.is_object()) {
3866 throw std::runtime_error(
"output item is not an object");
3868 const auto& row = value.as_object();
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_ =
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());
3882 auto& order = open_order.order_;
3883 order.order_id_ = open_order.order_ref_.order_number_;
3887 order.instrument_ = InstrumentId{
3890 broker_detail::json_string(row,
"pdno"),
3892 order.venue_ = reported_venue(row);
3893 order.name_ = broker_detail::json_string(row,
"prdt_name");
3895 broker_detail::json_string(row,
"sll_buy_dvsn_cd")
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"));
3900 order.price_ =
Decimal64::parse(broker_detail::json_string(row,
"ord_unpr"));
3902 order.trigger_price_ = reported_trigger(row);
3904 order.execution_.filled_quantity_ =
3906 order.execution_.filled_amount_ = Money{
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));
3915 read_cursors(*document, foreign_cursor, domestic_cursor);
3917 }
catch (
const std::exception& exception) {
3920 error.broker_code_ =
"invalid_open_order_response";
3921 error.message_ = exception.what();
3928 auto invalid = validate_account(account);
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);
3941 complete_order_page(std::move(completion), Result<OrderPage>(std::move(*invalid)));
3945 auto operation = std::make_shared<OrderHistoryOperation>(
3946 std::move(account), range, std::move(completion)
3950 [self = shared_from_this(), operation](std::optional<BrokerError> token_error)
mutable {
3952 self->complete_order_page(
3953 std::move(operation->completion_), Result<OrderPage>(std::move(*token_error))
3957 self->request_order_history(operation);
3964 static network::HttpRequest make_order_history_request(
3967 std::string_view access_token,
3969 std::string_view foreign_cursor,
3970 std::string_view domestic_cursor,
3972 std::string_view executions =
"00"
3974 network::HttpRequest request;
3977 request.method_ = http::verb::get;
3979 "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
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) +
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" :
""},
3996 {
"content-type",
"application/json; charset=utf-8"},
4002 void request_order_history(
const std::shared_ptr<OrderHistoryOperation>& operation) {
4003 auto request = make_order_history_request(
4004 operation->account_,
4008 operation->foreign_cursor_,
4009 operation->domestic_cursor_,
4010 operation->page_count_ > 0
4012 http_->async_request(
4014 [self = shared_from_this(), operation](Result<network::HttpResponse> result)
mutable {
4017 [self, operation, result = std::move(result)]()
mutable {
4018 self->handle_order_history_page(operation, std::move(result));
4026 void handle_order_history_page(
4027 const std::shared_ptr<OrderHistoryOperation>& operation,
4028 Result<network::HttpResponse> result
4031 complete_order_page(
4032 std::move(operation->completion_), Result<OrderPage>(std::move(result.error()))
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);
4042 complete_order_page(
4043 std::move(operation->completion_), Result<OrderPage>(std::move(page.error()))
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),
4055 "KIS order-history continuation did not advance"
4057 if (pagination_error) {
4058 complete_order_page(
4059 std::move(operation->completion_), Result<OrderPage>(std::move(*pagination_error))
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())
4070 request_order_history(operation);
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_))
4084 static OrderSnapshot history_row_snapshot(
const json::object& row) {
4085 OrderSnapshot order;
4086 order.order_id_ = broker_detail::json_string(row,
"odno");
4090 order.instrument_ = InstrumentId{
4093 broker_detail::json_string(row,
"pdno"),
4095 order.venue_ = reported_venue(row);
4096 order.name_ = broker_detail::json_string(row,
"prdt_name");
4098 broker_detail::json_string(row,
"sll_buy_dvsn_cd")
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"));
4103 order.price_ =
Decimal64::parse(broker_detail::json_string(row,
"ord_unpr"));
4105 order.trigger_price_ = reported_trigger(row);
4107 order.ordered_at_ = korea_timestamp(
4108 broker_detail::json_string(row,
"ord_dt"), broker_detail::json_string(row,
"ord_tmd")
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";
4116 order.execution_.filled_quantity_ = filled;
4117 order.execution_.filled_amount_ = Money{
4120 if (
const auto average =
Decimal64::parse(broker_detail::json_string(row,
"avg_prvs"));
4122 order.execution_.average_filled_price_ = average;
4125 derive_order_status(order.quantity_, filled, remaining, rejected, canceled);
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");
4136 for (
const auto& value : rows->as_array()) {
4137 if (!value.is_object()) {
4138 throw std::runtime_error(
"output1 item is not an object");
4140 const auto& row = value.as_object();
4145 const auto remaining =
Decimal64::parse(broker_detail::json_string(row,
"rmn_qty"));
4146 if (remaining.coefficient() <= 0) {
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_;
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);
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));
4167 static Result<OrderPage> parse_order_history_page(
4168 Result<network::HttpResponse> result,
4169 std::string& foreign_cursor,
4170 std::string& domestic_cursor
4175 if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
4178 BrokerError parse_error;
4179 auto document = broker_detail::parse_object(result.value(), parse_error);
4184 if (
auto declared = kis_declared_error(*document)) {
4187 const auto rows = document->if_contains(
"output1");
4188 if (!rows || !rows->is_array()) {
4189 throw std::runtime_error(
"missing array field: output1");
4193 for (
const auto& value : rows->as_array()) {
4194 if (!value.is_object()) {
4195 throw std::runtime_error(
"output1 item is not an object");
4197 page.orders_.push_back(history_row_snapshot(value.as_object()));
4200 read_cursors(*document, foreign_cursor, domestic_cursor);
4202 }
catch (
const std::exception& exception) {
4205 error.broker_code_ =
"invalid_order_history_response";
4206 error.message_ = exception.what();
4223 const Decimal64& ordered,
4224 const Decimal64& filled,
4225 const Decimal64& remaining,
4226 const Decimal64& rejected,
4229 if (rejected.coefficient() > 0 && filled.coefficient() == 0) {
4230 return OrderStatus::REJECTED;
4233 return OrderStatus::CANCELED;
4240 if (remaining.coefficient() == 0 && !(filled == ordered)) {
4241 return OrderStatus::CANCELED;
4243 if (filled.coefficient() == 0) {
4244 return OrderStatus::PENDING;
4246 return filled == ordered ? OrderStatus::FILLED : OrderStatus::PARTIAL_FILLED;
4263 static void apply_order_shape(std::string_view division, OrderSnapshot& order) {
4265 order.shape_code_ = *code;
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_;
4275 order.type_ = OrderType::UNKNOWN;
4276 order.time_in_force_ = TimeInForce::UNKNOWN;
4277 order.session_ = OrderSession::UNKNOWN;
4304 static constexpr Venue instrument_venue() {
return Venue::COMBINED; }
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;
4321 if (trigger.coefficient() > 0) {
4324 }
catch (
const std::exception&) {
4327 return std::nullopt;
4330 static Venue reported_venue(
const json::object& row) {
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()) {
4345 return Venue::UNSPECIFIED;
4349 if (code ==
"KRX") {
4352 if (code ==
"NXT") {
4355 if (code ==
"SOR") {
4356 return Venue::COMBINED;
4358 return Venue::UNSPECIFIED;
4364 return OrderSide::SELL;
4367 return OrderSide::BUY;
4369 throw std::runtime_error(
"invalid KIS order side code");
4374 const auto continuation = response.base().find(
"tr_cont");
4375 return continuation != response.base().end() &&
4376 (continuation->value() ==
"M" || continuation->value() ==
"F");
4380 static void read_cursors(
4381 const json::object& document,
4382 std::string& foreign_cursor,
4383 std::string& domestic_cursor
4385 if (
const auto value = document.if_contains(
"ctx_area_fk100"); value && value->is_string()) {
4386 foreign_cursor = value->as_string().c_str();
4388 if (
const auto value = document.if_contains(
"ctx_area_nk100"); value && value->is_string()) {
4389 domestic_cursor = value->as_string().c_str();
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,
4404 std::string_view message
4407 const bool cursor_did_not_advance = next_foreign_cursor == foreign_cursor &&
4408 next_domestic_cursor == domestic_cursor;
4410 page_count + 1 >= 100 ||
4411 (next_foreign_cursor.empty() && next_domestic_cursor.empty()) ||
4412 cursor_did_not_advance
4416 error.broker_code_ =
"invalid_pagination";
4417 error.message_ = std::string(message);
4423 foreign_cursor = std::move(next_foreign_cursor);
4424 domestic_cursor = std::move(next_domestic_cursor);
4426 return std::nullopt;
4430 static std::string compact_date(
const Date& date) {
4433 buffer,
sizeof(buffer),
"%04d%02u%02u", date.year_, date.month_, date.day_
4435 return std::string(buffer);
4443 static std::optional<std::chrono::system_clock::time_point> korea_timestamp(
4444 std::string_view date,
4445 std::string_view time
4447 if (date.size() != 8 || time.size() != 6) {
4448 return std::nullopt;
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");
4464 template <
typename Handler,
typename T>
4465 void deliver(Handler handler, Result<T> result) {
4467 note_rate_limit(result.error());
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));
4481 void note_rate_limit(
const BrokerError& error) {
4485 auto* limiter = http_->rate_limits();
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))
4503 void complete_order_ack(OrderAckHandler handler, Result<KisOrderAck> result) {
4504 deliver(std::move(handler), std::move(result));
4508 void complete_open_orders(OpenOrderHandler handler, Result<KisOpenOrderPage> result) {
4509 deliver(std::move(handler), std::move(result));
4513 void complete_order_page(OrderPageHandler handler, Result<OrderPage> result) {
4514 deliver(std::move(handler), std::move(result));
4517 void complete(QuoteHandler handler, Result<QuoteSnapshot> result) {
4518 deliver(std::move(handler), std::move(result));
4521 void finish_holdings(
4522 const std::shared_ptr<HoldingsOperation>& operation,
4523 Result<HoldingsSnapshot> result
4525 complete_holdings(std::move(operation->completion_), std::move(result));
4528 void complete_holdings(HoldingsHandler handler, Result<HoldingsSnapshot> result) {
4529 deliver(std::move(handler), std::move(result));