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 "./errors.hpp"
5
6#include "../common.hpp"
7#include "../../core/instrument.hpp"
8#include "../../core/market_data.hpp"
9#include "../../core/types.hpp"
10
11#include <boost/asio/any_completion_handler.hpp>
12#include <boost/asio/any_io_executor.hpp>
13#include <boost/asio/async_result.hpp>
14#include <boost/asio/post.hpp>
15#include <boost/asio/strand.hpp>
16
17#include <array>
18#include <chrono>
19#include <cstdlib>
20#include <ctime>
21#include <deque>
22#include <memory>
23#include <optional>
24#include <string>
25#include <string_view>
26#include <utility>
27#include <vector>
28
29namespace algotradelib::kiwoom {
30
41 friend bool operator==(const KiwoomAccount&, const KiwoomAccount&) { return true; }
42};
43
47 std::string account_number_;
48};
49
58 std::string order_number_;
60 std::string symbol_;
61};
62
77 Venue venue_{Venue::UNSPECIFIED};
84 std::optional<Decimal64> price_;
89 std::optional<Decimal64> condition_price_;
90};
91
111
130
134 std::vector<KiwoomOpenOrder> orders_;
136 std::chrono::system_clock::time_point received_at_;
137};
138
147 std::string symbol_;
148};
149
155 const auto seoul = std::chrono::system_clock::now() + std::chrono::hours(9);
156 const std::time_t raw = std::chrono::system_clock::to_time_t(
157 std::chrono::time_point_cast<std::chrono::seconds>(seoul)
158 );
159 std::tm parts{};
160#ifdef _WIN32
161 gmtime_s(&parts, &raw);
162#else
163 gmtime_r(&raw, &parts);
164#endif
165 Date date;
166 date.year_ = parts.tm_year + 1900;
167 date.month_ = parts.tm_mon + 1;
168 date.day_ = parts.tm_mday;
169 return date;
170}
171
183inline std::optional<BrokerError> unestablished_holdings_scope() {
184 BrokerError error;
186 error.reached_broker_ = false;
187 error.broker_code_ = "holdings_scope_unestablished";
188 // **Names the way out, not only the gap.** A refusal that reports a fact about a vendor and no
189 // action leaves a caller with nothing to do about it, and this one has a remedy: state what the
190 // parameter means. Saying which setting that is turns a dead end into a decision.
191 error.message_ =
192 "this library cannot establish what a Kiwoom account holds -- kt00018 names one venue "
193 "(dmst_stex_tp: KRX or NXT) and publishes no all-venue value, and whether that filters the "
194 "positions or only values them is not stated -- so it will not place a new order, or reprice a "
195 "resting one into a fillable price, against a position it cannot read. Cancelling is "
196 "unaffected, including cancel_quantity(). To proceed, assert what the parameter means by "
197 "setting KiwoomConfig::holdings_position_scope_ to KiwoomPositionScope::VALUATION_ONLY, having "
198 "read its documentation -- an assertion that is wrong undercounts the account";
199 return error;
200}
201
208inline std::optional<BrokerError> unsupported_client_order_id(const OrderRequest& order) {
209 if (!order.client_order_id_) {
210 return std::nullopt;
211 }
212 BrokerError error;
214 error.reached_broker_ = false;
215 error.broker_code_ = "client_order_id_unsupported";
216 error.message_ =
217 "Kiwoom publishes no caller-supplied order identifier; its order endpoints accept no such "
218 "field and it would be silently dropped";
219 return error;
220}
221
227inline bool valid_trigger_value(const Decimal64& trigger) {
228 return trigger.coefficient() > 0 && trigger.scale() == 0;
229}
230
241inline std::optional<BrokerError> invalid_trigger(
242 OrderType type,
243 const std::optional<Decimal64>& trigger
244) {
245 BrokerError error;
247 error.reached_broker_ = false;
248 error.broker_code_ = "invalid_trigger_price";
249 if (carries_trigger_price(type)) {
250 if (!trigger) {
251 error.message_ = "a Kiwoom stop-limit order (28) requires a trigger price in cond_uv";
252 return error;
253 }
254 if (!valid_trigger_value(*trigger)) {
255 error.message_ = "a Kiwoom trigger price must be a positive whole number of won";
256 return error;
257 }
258 return std::nullopt;
259 }
260 if (trigger) {
261 error.message_ =
262 "Kiwoom takes a trigger price only on a stop-limit order (28); on this basis it would be "
263 "dropped";
264 return error;
265 }
266 return std::nullopt;
267}
268
270namespace detail {
271
272// Reads one of Kiwoom's signed magnitude strings as a price or a quantity.
273//
274// **Kiwoom prefixes a market-data number with a change *direction*, not a sign.** `"+181400"` and
275// `"-91200"` are prices of 181,400 and 91,200 won; the specification says so field by field
276// ("단위: 원, 부호가 포함된 숫자"). Parsing `-91200` as a negative price produces a number that is
277// wrong by a factor of minus one, is a perfectly valid `Decimal64`, and passes every type check
278// downstream — so every price and quantity this adapter reads from a market-data response goes
279// through here, and the direction is read separately from `pred_pre_sig` where it is wanted.
280//
281// Account responses pad to a fixed width with leading zeros (`"000000017598258"`), which
282// `Decimal64::parse` handles, and may carry a genuine minus on a profit-and-loss figure — so
283// `signed_value` is the entry point for those, and this one is for the market-data fields whose
284// sign is a direction.
285//
286// Returns nothing for an empty or unparsable field, which is how Kiwoom spells "no value".
287inline std::optional<Decimal64> magnitude(const json::object& row, std::string_view key) {
288 const auto value = row.if_contains(key);
289 if (!value || !value->is_string()) {
290 return std::nullopt;
291 }
292 std::string_view text(value->as_string());
293 if (!text.empty() && (text.front() == '+' || text.front() == '-')) {
294 text.remove_prefix(1);
295 }
296 if (text.empty()) {
297 return std::nullopt;
298 }
299 for (const char digit : text) {
300 if ((digit < '0' || digit > '9') && digit != '.') {
301 return std::nullopt;
302 }
303 }
304 return Decimal64::parse(text);
305}
306
307// Reads a magnitude, or zero where Kiwoom reported nothing.
308inline Decimal64 magnitude_or_zero(const json::object& row, std::string_view key) {
309 const auto value = magnitude(row, key);
310 return value ? *value : Decimal64();
311}
312
313// Reads a field whose minus sign is arithmetic rather than a direction.
314//
315// **Use this only where the value can genuinely be negative**: holdings' profit and loss and return
316// ratios, and the `kt00001` / `kt00010` deposit figures, which go negative when the account is in
317// 미수. Everything that is a price, a quantity or a holdings money total goes through `magnitude`,
318// because none of those can be negative and Kiwoom documents
319// *both* kinds with the same phrase: `kt00018`'s 현재가 is "단위: 원, 좌측 0-padding 처리된 부호 포함
320// 12자리 숫자", the identical wording it uses for 평가손익 on the row beside it.
321//
322// So the field description cannot tell them apart, and only the *meaning* can: a 현재가 of minus
323// 59,000 won is not a price, so a sign there is a direction — while a 평가손익 of minus 196,888 won
324// is exactly what it says. Reading a mark price as negative would put it straight into
325// `HoldingPosition::last_price_` and from there into every valuation that reads a holding.
326//
327// Values are zero-padded to a fixed width, sometimes with the sign inside the padding, and
328// `Decimal64::parse` reads both the sign and the leading zeros.
329inline std::optional<Decimal64> signed_value(const json::object& row, std::string_view key) {
330 const auto value = row.if_contains(key);
331 if (!value || !value->is_string()) {
332 return std::nullopt;
333 }
334 const std::string_view text(value->as_string());
335 if (text.empty()) {
336 return std::nullopt;
337 }
338 std::size_t index = (text.front() == '+' || text.front() == '-') ? 1 : 0;
339 if (index == text.size()) {
340 return std::nullopt;
341 }
342 for (; index < text.size(); ++index) {
343 if ((text[index] < '0' || text[index] > '9') && text[index] != '.') {
344 return std::nullopt;
345 }
346 }
347 // `Decimal64::parse` reads the sign itself, and the leading zeros Kiwoom pads with are digits
348 // like any other, so the padded and signed forms both go through unchanged.
349 return Decimal64::parse(text);
350}
351
352// Reads a magnitude that must be there, and throws when it is not.
353//
354// **`magnitude_or_zero` cannot be used for a figure a risk check consumes.** It turns a missing,
355// blank *or malformed* field into zero, and zero is a statement: a holdings row whose `rmnd_qty`
356// came back garbled would be a successful listing saying the account holds none of that
357// instrument, and `Portfolio::net_quantity_of` would undercount the exposure a position ceiling is
358// measured against. A working order whose `ord_qty` was unreadable would understate
359// `Portfolio::working_quantity`, which is what the sellable guard deducts a resting sell with.
360//
361// A *reported* zero is still zero and still fine. It is the unreadable one that must fail the row,
362// because the two are indistinguishable afterwards.
363//
364// **The line is drawn at what a risk check consumes, and no wider**, because the failure mode of
365// this guard is the opposite one: a field made required that Kiwoom may legitimately omit fails a
366// valid listing, and a listing that fails is a poll that fails. So it covers `rmnd_qty`
367// (`Portfolio::net_quantity_of`), and `ord_qty` and `cntr_qty` (`Portfolio::working_quantity`),
368// plus a bar's `trde_qty`, which a strategy reads directly. The reporting figures beside them --
369// cost basis, market value, profit and loss, the account totals, the deposit detail -- stay
370// tolerant: a missing one there is wrong in a report, not in an order, and an account with no
371// positions is the kind of edge case that should not fail a poll.
372inline Decimal64 required_magnitude(const json::object& row, std::string_view key) {
373 const auto value = magnitude(row, key);
374 if (!value) {
375 throw std::runtime_error("missing or unreadable numeric field: " + std::string(key));
376 }
377 return *value;
378}
379
380// Reads a genuinely signed figure that must be there, and throws when it is not.
381inline Decimal64 required_signed(const json::object& row, std::string_view key) {
382 const auto value = signed_value(row, key);
383 if (!value) {
384 throw std::runtime_error("missing or unreadable numeric field: " + std::string(key));
385 }
386 return *value;
387}
388
389// Reads a signed field, or zero where Kiwoom reported nothing.
390inline Decimal64 signed_or_zero(const json::object& row, std::string_view key) {
391 const auto value = signed_value(row, key);
392 return value ? *value : Decimal64();
393}
394
395// Reads a string field, or an empty string where Kiwoom reported none.
396inline std::string text_field(const json::object& row, std::string_view key) {
397 const auto value = row.if_contains(key);
398 if (!value || !value->is_string()) {
399 return {};
400 }
401 return std::string(value->as_string());
402}
403
404// Returns a symbol as Kiwoom's order endpoints spell it.
405//
406// **Kiwoom's account listings prefix a symbol with `A`** — `kt00018` reports `"A005930"` and
407// `kt00007` reports `"A069500"` — while `ka10075` and every market-data endpoint report the bare
408// six digits. An instrument identity built from the prefixed form would not compare equal to one
409// built from the bare form, so a holding and its own working order would stop matching. The prefix
410// is stripped once, here.
411//
412// The tape suffix is stripped for the same reason: `005930_AL` is the same instrument as `005930`,
413// read off a different book.
414inline std::string bare_symbol(std::string_view reported) {
415 std::string_view symbol = reported;
416 if (symbol.size() > 1 && symbol.front() == 'A') {
417 symbol.remove_prefix(1);
418 }
419 if (const auto underscore = symbol.find('_'); underscore != std::string_view::npos) {
420 symbol = symbol.substr(0, underscore);
421 }
422 return std::string(symbol);
423}
424
425// Reads Kiwoom's `HHmmss` or `HH:mm:ss` clock time as an instant on `date` in Seoul.
426inline std::optional<std::chrono::system_clock::time_point> seoul_timestamp(
427 const Date& date,
428 std::string_view clock
429) {
430 std::string digits;
431 for (const char character : clock) {
432 if (character >= '0' && character <= '9') {
433 digits.push_back(character);
434 } else if (character != ':') {
435 return std::nullopt;
436 }
437 }
438 if (digits.size() != 6) {
439 return std::nullopt;
440 }
441 if (!is_valid_date(date)) {
442 return std::nullopt;
443 }
444 std::tm parts{};
445 parts.tm_year = date.year_ - 1900;
446 parts.tm_mon = date.month_ - 1;
447 parts.tm_mday = date.day_;
448 parts.tm_hour = (digits[0] - '0') * 10 + (digits[1] - '0');
449 parts.tm_min = (digits[2] - '0') * 10 + (digits[3] - '0');
450 parts.tm_sec = (digits[4] - '0') * 10 + (digits[5] - '0');
451 parts.tm_isdst = 0;
452#ifdef _WIN32
453 const std::time_t utc = _mkgmtime(&parts) - 9 * 3600;
454#else
455 const std::time_t utc = timegm(&parts) - 9 * 3600;
456#endif
457 return std::chrono::system_clock::from_time_t(utc);
458}
459
460// Reads Kiwoom's `YYYYMMDD` as a calendar date, or nothing when it is not one.
461inline std::optional<Date> compact_date(std::string_view text) {
462 return broker_detail::compact_date(text);
463}
464
465// Reads a `YYYYMMDD` field that states a date or leaves it blank, as one of three answers.
466//
467// Reads the raw value rather than `text_field`, which maps every non-string to `""` and would so
468// report a number as a date not stated. Not stated: absent, `null` or `""`. Stated: eight digits
469// forming a real date. Unreadable: anything else -- **including `"00000000"`**, since nothing in
470// Kiwoom's specification uses a zero date as a blank, and KIS's use of one says nothing about Kiwoom.
471inline StatedDate stated_compact_date(const json::object& row, std::string_view key) {
472 const auto value = row.if_contains(key);
473 if (!value || value->is_null()) {
474 return StatedDate{};
475 }
476 if (!value->is_string()) {
477 return StatedDate::unreadable();
478 }
479 const std::string_view text(value->as_string());
480 if (text.empty()) {
481 return StatedDate{};
482 }
483 const auto date = compact_date(text);
484 return date ? StatedDate::stated(*date) : StatedDate::unreadable();
485}
486
487// Renders a calendar date as Kiwoom's `YYYYMMDD`.
488inline std::string compact_text(const Date& date) {
489 std::string text(8, '0');
490 int year = date.year_;
491 for (int index = 3; index >= 0; --index) {
492 text[static_cast<std::size_t>(index)] = static_cast<char>('0' + year % 10);
493 year /= 10;
494 }
495 text[4] = static_cast<char>('0' + date.month_ / 10);
496 text[5] = static_cast<char>('0' + date.month_ % 10);
497 text[6] = static_cast<char>('0' + date.day_ / 10);
498 text[7] = static_cast<char>('0' + date.day_ % 10);
499 return text;
500}
501
502} // namespace detail
504
529
537inline constexpr std::array<KiwoomOrderShapeRow, 21> kOrderShapes{{
538 // code name alternate type condition session
539 {"0", "보통", "지정가", OrderType::LIMIT, TimeInForce::DAY, OrderSession::REGULAR, true},
540 {"3", "시장가", nullptr, OrderType::MARKET, TimeInForce::DAY, OrderSession::REGULAR, true},
541 {"5", "조건부지정가", nullptr, OrderType::CONDITIONAL_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, true},
542 {"6", "최유리지정가", nullptr, OrderType::BEST_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, true},
543 {"7", "최우선지정가", nullptr, OrderType::PRIMARY_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, true},
544 {"10", "보통(IOC)", "지정가(IOC)", OrderType::LIMIT, TimeInForce::IMMEDIATE_OR_CANCEL, OrderSession::REGULAR, true},
545 {"13", "시장가(IOC)", nullptr, OrderType::MARKET, TimeInForce::IMMEDIATE_OR_CANCEL, OrderSession::REGULAR, true},
546 {"16", "최유리(IOC)", nullptr, OrderType::BEST_LIMIT, TimeInForce::IMMEDIATE_OR_CANCEL, OrderSession::REGULAR, true},
547 {"20", "보통(FOK)", "지정가(FOK)", OrderType::LIMIT, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, true},
548 {"23", "시장가(FOK)", nullptr, OrderType::MARKET, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, true},
549 {"26", "최유리(FOK)", nullptr, OrderType::BEST_LIMIT, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, true},
550 {"29", "중간가", nullptr, OrderType::MID_PRICE, TimeInForce::DAY, OrderSession::REGULAR, true},
551 {"30", "중간가(IOC)", nullptr, OrderType::MID_PRICE, TimeInForce::IMMEDIATE_OR_CANCEL, OrderSession::REGULAR, true},
552 {"31", "중간가(FOK)", nullptr, OrderType::MID_PRICE, TimeInForce::FILL_OR_KILL, OrderSession::REGULAR, true},
553 {"61", "장시작전시간외", nullptr, OrderType::CLOSING_PRICE, TimeInForce::DAY, OrderSession::PRE_MARKET, true},
554 {"81", "장마감후시간외", nullptr, OrderType::CLOSING_PRICE, TimeInForce::DAY, OrderSession::AFTER_HOURS, true},
555 {"40", "보통(GTP)", nullptr, OrderType::LIMIT, TimeInForce::DAY, OrderSession::GTP, true},
556 {"46", "최유리(GTP)", nullptr, OrderType::BEST_LIMIT, TimeInForce::DAY, OrderSession::GTP, true},
557 {"47", "최우선(GTP)", nullptr, OrderType::PRIMARY_LIMIT, TimeInForce::DAY, OrderSession::GTP, true},
558 {"28", "스톱지정가", nullptr, OrderType::STOP_LIMIT, TimeInForce::DAY, OrderSession::REGULAR, true},
559 {nullptr, "시간외단일가", nullptr, OrderType::LIMIT, TimeInForce::DAY, OrderSession::AFTER_HOURS_AUCTION, false},
560}};
561
571inline std::optional<BrokerError> order_division(
572 OrderType type,
573 TimeInForce time_in_force,
574 OrderSession session,
575 std::string& division
576) {
577 const KiwoomOrderShapeRow* matched = nullptr;
578 for (const auto& row : kOrderShapes) {
579 if (row.type_ == type && row.time_in_force_ == time_in_force && row.session_ == session) {
580 matched = &row;
581 break;
582 }
583 }
584 BrokerError error;
586 error.reached_broker_ = false;
587 error.broker_code_ = "unsupported_order";
588 if (matched == nullptr) {
589 error.message_ =
590 "Kiwoom publishes no order code for this combination of price basis, validity condition and "
591 "session";
592 return error;
593 }
594 if (!matched->requestable_ || matched->code_ == nullptr) {
595 error.message_ = std::string("Kiwoom reports ") + matched->name_ +
596 " on an inquiry but this library cannot place one" +
597 (matched->code_ == nullptr ?
598 ": it publishes no order code for that shape at all" :
599 ": the code is read on a response and not sent on a request");
600 return error;
601 }
602 division = matched->code_;
603 return std::nullopt;
604}
605
611inline const KiwoomOrderShapeRow* shape_by_name(std::string_view name) noexcept {
612 for (const auto& row : kOrderShapes) {
613 if (name == row.name_) {
614 return &row;
615 }
616 if (row.alternate_name_ != nullptr && name == row.alternate_name_) {
617 return &row;
618 }
619 }
620 return nullptr;
621}
622
628inline const KiwoomOrderShapeRow* shape_by_code(std::string_view code) noexcept {
629 for (const auto& row : kOrderShapes) {
630 if (row.code_ != nullptr && code == row.code_) {
631 return &row;
632 }
633 }
634 return nullptr;
635}
636
642inline const char* exchange_code(Venue venue) noexcept {
643 switch (venue) {
644 case Venue::KRX:
645 return "KRX";
646 case Venue::NXT:
647 return "NXT";
648 // Kiwoom calls the consolidated book SOR, which is what routes across both venues.
649 case Venue::COMBINED:
650 return "SOR";
651 case Venue::UNSPECIFIED:
652 return "";
653 }
654 return "";
655}
656
663inline constexpr Venue kiwoom_effective_route(Venue asked) noexcept { return asked; }
664
670inline Venue normalized_venue(std::string_view code) noexcept {
671 if (code == "KRX" || code == "1") {
672 return Venue::KRX;
673 }
674 if (code == "NXT" || code == "2") {
675 return Venue::NXT;
676 }
677 if (code == "SOR" || code == "0") {
678 return Venue::COMBINED;
679 }
680 return Venue::UNSPECIFIED;
681}
682
693inline Venue listed_venue(std::string_view stex_tp, std::string_view sor_yn) noexcept {
694 if (sor_yn == "Y") {
695 return Venue::COMBINED;
696 }
697 return normalized_venue(stex_tp);
698}
699
707inline std::optional<OrderSide> normalized_order_side(std::string_view text) noexcept {
708 if (text.find("매수") != std::string_view::npos) {
709 return OrderSide::BUY;
710 }
711 if (text.find("매도") != std::string_view::npos) {
712 return OrderSide::SELL;
713 }
714 return std::nullopt;
715}
716
723inline OrderStatus normalized_order_status(std::string_view text) noexcept {
724 if (text == "접수") {
725 return OrderStatus::PENDING;
726 }
727 if (text == "확인") {
728 return OrderStatus::PENDING;
729 }
730 if (text == "체결") {
731 return OrderStatus::FILLED;
732 }
733 if (text == "취소") {
734 return OrderStatus::CANCELED;
735 }
736 return OrderStatus::UNKNOWN;
737}
738
745inline constexpr const char* chart_api_id(BarInterval interval) noexcept {
746 switch (interval) {
747 case BarInterval::ONE_MINUTE:
748 return "ka10080";
749 case BarInterval::ONE_DAY:
750 return "ka10081";
751 case BarInterval::ONE_WEEK:
752 return "ka10082";
753 case BarInterval::ONE_MONTH:
754 return "ka10083";
755 case BarInterval::ONE_YEAR:
756 return "ka10094";
761 return nullptr;
762 }
763 return nullptr;
764}
765
771inline constexpr const char* chart_rows_field(BarInterval interval) noexcept {
772 switch (interval) {
773 case BarInterval::ONE_MINUTE:
774 return "stk_min_pole_chart_qry";
775 case BarInterval::ONE_DAY:
776 return "stk_dt_pole_chart_qry";
777 case BarInterval::ONE_WEEK:
778 return "stk_stk_pole_chart_qry";
779 case BarInterval::ONE_MONTH:
780 return "stk_mth_pole_chart_qry";
781 case BarInterval::ONE_YEAR:
782 return "stk_yr_pole_chart_qry";
787 return nullptr;
788 }
789 return nullptr;
790}
791
797inline std::optional<BrokerError> unsupported_bar_interval(BarInterval interval) {
798 if (chart_api_id(interval) != nullptr && chart_rows_field(interval) != nullptr) {
799 return std::nullopt;
800 }
801 BrokerError error;
803 error.reached_broker_ = false;
804 error.broker_code_ = "unsupported_interval";
805 error.message_ =
806 std::string("this Kiwoom client does not request ") + std::string(bar_interval_name(interval)) +
807 " bars yet (Kiwoom serves them through ka10080's tic_scope; how it stamps them is not established)";
808 return error;
809}
810
836
842 bool reduced_{false};
844 std::optional<Decimal64> amount_;
846 std::optional<Decimal64> quantity_;
847};
848
875
885class KiwoomClient : public std::enable_shared_from_this<KiwoomClient> {
886public:
888 using QuoteHandler = net::any_completion_handler<void(Result<QuoteSnapshot>)>;
890 using BarsHandler = net::any_completion_handler<void(Result<BarSeries>)>;
892 using DepthHandler = net::any_completion_handler<void(Result<DepthSnapshot>)>;
894 using PriceLimitsHandler = net::any_completion_handler<void(Result<PriceLimits>)>;
896 using TradesHandler = net::any_completion_handler<void(Result<std::vector<TradePrint>>)>;
898 using InstrumentHandler = net::any_completion_handler<void(Result<Instrument>)>;
900 using HoldingsHandler = net::any_completion_handler<void(Result<HoldingsSnapshot>)>;
902 using BuyingPowerHandler = net::any_completion_handler<void(Result<BuyingPower>)>;
904 using DepositHandler = net::any_completion_handler<void(Result<KiwoomDeposit>)>;
906 using OrderableHandler = net::any_completion_handler<void(Result<KiwoomOrderableAmount>)>;
908 using AccountsHandler = net::any_completion_handler<void(Result<std::vector<KiwoomAccountInfo>>)>;
910 using OrderAckHandler = net::any_completion_handler<void(Result<KiwoomOrderAck>)>;
912 using OpenOrderHandler = net::any_completion_handler<void(Result<KiwoomOpenOrderPage>)>;
914 using OrderPageHandler = net::any_completion_handler<void(Result<OrderPage>)>;
916 using TokenHandler = net::any_completion_handler<void(std::optional<BrokerError>)>;
917
926 net::any_io_executor executor,
927 ssl::context& context,
929 network::HttpClientOptions options = {}
930 )
931 : strand_(net::make_strand(std::move(executor))), config_(std::move(config)),
932 http_(
933 std::make_shared<network::AsyncHttpClient>(
934 strand_, context, config_.rest_host(), config_.rest_port(), options
935 )
936 ) {}
937
945 template <typename CompletionToken>
946 auto async_quote(InstrumentId instrument, CompletionToken&& token) {
947 return net::async_initiate<CompletionToken, void(Result<QuoteSnapshot>)>(
948 [self = shared_from_this()](auto handler, InstrumentId subject) mutable {
949 net::post(
950 self->strand_,
951 [self, subject = std::move(subject), handler = std::move(handler)]() mutable {
952 self->start_market_read<QuoteSnapshot>(
953 std::move(subject), "ka10007", "/api/dostk/mrkcond", QuoteHandler(std::move(handler)),
954 &KiwoomClient::parse_quote
955 );
956 }
957 );
958 },
959 token,
960 std::move(instrument)
961 );
962 }
963
971 template <typename CompletionToken>
972 auto async_price_limits(InstrumentId instrument, CompletionToken&& token) {
973 return net::async_initiate<CompletionToken, void(Result<PriceLimits>)>(
974 [self = shared_from_this()](auto handler, InstrumentId subject) mutable {
975 net::post(
976 self->strand_,
977 [self, subject = std::move(subject), handler = std::move(handler)]() mutable {
978 self->start_market_read<PriceLimits>(
979 std::move(subject), "ka10007", "/api/dostk/mrkcond",
980 PriceLimitsHandler(std::move(handler)), &KiwoomClient::parse_price_limits
981 );
982 }
983 );
984 },
985 token,
986 std::move(instrument)
987 );
988 }
989
997 template <typename CompletionToken>
998 auto async_depth(InstrumentId instrument, CompletionToken&& token) {
999 return net::async_initiate<CompletionToken, void(Result<DepthSnapshot>)>(
1000 [self = shared_from_this()](auto handler, InstrumentId subject) mutable {
1001 net::post(
1002 self->strand_,
1003 [self, subject = std::move(subject), handler = std::move(handler)]() mutable {
1004 self->start_market_read<DepthSnapshot>(
1005 std::move(subject), "ka10004", "/api/dostk/mrkcond", DepthHandler(std::move(handler)),
1006 &KiwoomClient::parse_depth
1007 );
1008 }
1009 );
1010 },
1011 token,
1012 std::move(instrument)
1013 );
1014 }
1015
1024 template <typename CompletionToken>
1025 auto async_trades(InstrumentId instrument, int count, CompletionToken&& token) {
1026 return net::async_initiate<CompletionToken, void(Result<std::vector<TradePrint>>)>(
1027 [self = shared_from_this()](auto handler, InstrumentId subject, int wanted) mutable {
1028 net::post(
1029 self->strand_,
1030 [self, subject = std::move(subject), wanted, handler = std::move(handler)]() mutable {
1031 self->start_trades(std::move(subject), wanted, TradesHandler(std::move(handler)));
1032 }
1033 );
1034 },
1035 token,
1036 std::move(instrument),
1037 count
1038 );
1039 }
1040
1048 template <typename CompletionToken>
1049 auto async_instrument(InstrumentId instrument, CompletionToken&& token) {
1050 return net::async_initiate<CompletionToken, void(Result<Instrument>)>(
1051 [self = shared_from_this()](auto handler, InstrumentId subject) mutable {
1052 net::post(
1053 self->strand_,
1054 [self, subject = std::move(subject), handler = std::move(handler)]() mutable {
1055 self->start_instrument(std::move(subject), InstrumentHandler(std::move(handler)));
1056 }
1057 );
1058 },
1059 token,
1060 std::move(instrument)
1061 );
1062 }
1063
1079 template <typename CompletionToken>
1080 auto async_bars(BarQuery query, CompletionToken&& token) {
1081 return net::async_initiate<CompletionToken, void(Result<BarSeries>)>(
1082 [self = shared_from_this()](auto handler, BarQuery wanted) mutable {
1083 net::post(
1084 self->strand_,
1085 [self, wanted = std::move(wanted), handler = std::move(handler)]() mutable {
1086 self->start_bars(std::move(wanted), BarsHandler(std::move(handler)));
1087 }
1088 );
1089 },
1090 token,
1091 std::move(query)
1092 );
1093 }
1094
1099 const KiwoomConfig& config() const noexcept { return config_; }
1100
1105 net::strand<net::any_io_executor> get_executor() const noexcept { return strand_; }
1106
1120 template <typename CompletionToken>
1121 auto async_holdings(KiwoomAccount account, CompletionToken&& token) {
1122 return net::async_initiate<CompletionToken, void(Result<HoldingsSnapshot>)>(
1123 [self = shared_from_this()](auto handler, KiwoomAccount) mutable {
1124 net::post(
1125 self->strand_,
1126 [self, handler = std::move(handler)]() mutable {
1127 self->start_holdings(HoldingsHandler(std::move(handler)));
1128 }
1129 );
1130 },
1131 token,
1132 account
1133 );
1134 }
1135
1144 template <typename CompletionToken>
1145 auto async_buying_power(Currency currency, KiwoomAccount account, CompletionToken&& token) {
1146 return net::async_initiate<CompletionToken, void(Result<BuyingPower>)>(
1147 [self = shared_from_this()](auto handler, Currency wanted, KiwoomAccount) mutable {
1148 net::post(
1149 self->strand_,
1150 [self, wanted, handler = std::move(handler)]() mutable {
1151 self->start_buying_power(wanted, BuyingPowerHandler(std::move(handler)));
1152 }
1153 );
1154 },
1155 token,
1156 currency,
1157 account
1158 );
1159 }
1160
1167 template <typename CompletionToken>
1168 auto async_deposit(CompletionToken&& token) {
1169 return net::async_initiate<CompletionToken, void(Result<KiwoomDeposit>)>(
1170 [self = shared_from_this()](auto handler) mutable {
1171 net::post(
1172 self->strand_,
1173 [self, handler = std::move(handler)]() mutable {
1174 self->start_deposit(DepositHandler(std::move(handler)));
1175 }
1176 );
1177 },
1178 token
1179 );
1180 }
1181
1191 template <typename CompletionToken>
1193 InstrumentId instrument,
1194 OrderSide side,
1195 std::optional<Decimal64> price,
1196 CompletionToken&& token
1197 ) {
1198 return net::async_initiate<CompletionToken, void(Result<KiwoomOrderableAmount>)>(
1199 [self = shared_from_this()](
1200 auto handler, InstrumentId subject, OrderSide direction, std::optional<Decimal64> limit
1201 ) mutable {
1202 net::post(
1203 self->strand_,
1204 [
1205 self, subject = std::move(subject), direction, limit = std::move(limit),
1206 handler = std::move(handler)
1207 ]() mutable {
1208 self->start_orderable_amount(
1209 std::move(subject), direction, std::move(limit), OrderableHandler(std::move(handler))
1210 );
1211 }
1212 );
1213 },
1214 token,
1215 std::move(instrument),
1216 side,
1217 std::move(price)
1218 );
1219 }
1220
1227 template <typename CompletionToken>
1228 auto async_accounts(CompletionToken&& token) {
1229 return net::async_initiate<CompletionToken, void(Result<std::vector<KiwoomAccountInfo>>)>(
1230 [self = shared_from_this()](auto handler) mutable {
1231 net::post(
1232 self->strand_,
1233 [self, handler = std::move(handler)]() mutable {
1234 self->start_accounts(AccountsHandler(std::move(handler)));
1235 }
1236 );
1237 },
1238 token
1239 );
1240 }
1241
1273 template <typename CompletionToken>
1274 auto async_place_order(KiwoomAccount account, OrderRequest order, CompletionToken&& token) {
1275 return net::async_initiate<CompletionToken, void(Result<KiwoomOrderAck>)>(
1276 [self = shared_from_this()](auto handler, KiwoomAccount, OrderRequest placed) mutable {
1277 net::post(
1278 self->strand_,
1279 [self, placed = std::move(placed), handler = std::move(handler)]() mutable {
1280 self->start_place_order(std::move(placed), OrderAckHandler(std::move(handler)));
1281 }
1282 );
1283 },
1284 token,
1285 account,
1286 std::move(order)
1287 );
1288 }
1289
1304 template <typename CompletionToken>
1306 KiwoomAccount account,
1307 KiwoomAmendment amendment,
1308 CompletionToken&& token
1309 ) {
1310 return net::async_initiate<CompletionToken, void(Result<KiwoomOrderAck>)>(
1311 [self = shared_from_this()](auto handler, KiwoomAccount, KiwoomAmendment change) mutable {
1312 net::post(
1313 self->strand_,
1314 [self, change = std::move(change), handler = std::move(handler)]() mutable {
1315 self->start_amend_order(
1316 std::move(change), false, OrderAckHandler(std::move(handler))
1317 );
1318 }
1319 );
1320 },
1321 token,
1322 account,
1323 std::move(amendment)
1324 );
1325 }
1326
1346 template <typename CompletionToken>
1348 KiwoomAccount account,
1349 KiwoomAmendment amendment,
1350 CompletionToken&& token
1351 ) {
1352 return net::async_initiate<CompletionToken, void(Result<KiwoomOrderAck>)>(
1353 [self = shared_from_this()](auto handler, KiwoomAccount, KiwoomAmendment change) mutable {
1354 net::post(
1355 self->strand_,
1356 [self, change = std::move(change), handler = std::move(handler)]() mutable {
1357 self->start_amend_order(std::move(change), true, OrderAckHandler(std::move(handler)));
1358 }
1359 );
1360 },
1361 token,
1362 account,
1363 std::move(amendment)
1364 );
1365 }
1366
1374 template <typename CompletionToken>
1375 auto async_open_orders(KiwoomAccount account, CompletionToken&& token) {
1376 return net::async_initiate<CompletionToken, void(Result<KiwoomOpenOrderPage>)>(
1377 [self = shared_from_this()](auto handler, KiwoomAccount) mutable {
1378 net::post(
1379 self->strand_,
1380 [self, handler = std::move(handler)]() mutable {
1381 self->start_open_orders(OpenOrderHandler(std::move(handler)));
1382 }
1383 );
1384 },
1385 token,
1386 account
1387 );
1388 }
1389
1398 template <typename CompletionToken>
1400 KiwoomAccount account,
1401 KiwoomHistoryQuery query,
1402 CompletionToken&& token
1403 ) {
1404 return net::async_initiate<CompletionToken, void(Result<OrderPage>)>(
1405 [self = shared_from_this()](auto handler, KiwoomAccount, KiwoomHistoryQuery wanted) mutable {
1406 net::post(
1407 self->strand_,
1408 [self, wanted = std::move(wanted), handler = std::move(handler)]() mutable {
1409 self->start_order_history(std::move(wanted), OrderPageHandler(std::move(handler)));
1410 }
1411 );
1412 },
1413 token,
1414 account,
1415 std::move(query)
1416 );
1417 }
1418
1428 const auto* limiter = http_->rate_limits();
1429 return limiter ? &limiter->stats() : nullptr;
1430 }
1431
1437 void close() { http_->close(); }
1438
1439private:
1440 friend struct KiwoomClientTestAccess;
1441
1442 net::strand<net::any_io_executor> strand_;
1443 KiwoomConfig config_;
1444 std::shared_ptr<network::AsyncHttpClient> http_;
1445 KiwoomRest token_;
1446 bool refreshing_{false};
1447 std::deque<TokenHandler> token_waiters_;
1448
1449 // Most pages one paged inquiry may consume, for the reason every paging path is bounded.
1450 static constexpr int page_budget_ = 50;
1451
1452 // Builds one Kiwoom request: the group path, the operation header, and the token.
1453 //
1454 // **Every Kiwoom endpoint has this shape**, which is why it is built once. The operation is the
1455 // `api-id` header rather than the path — `/api/dostk/acnt` alone serves the balance, the deposit,
1456 // the unfilled listing and the execution detail — so a request with the right path and the wrong
1457 // header is a valid request for the wrong thing, and there is exactly one place that pairs them.
1458 network::HttpRequest make_request(
1459 std::string_view api_id,
1460 std::string_view path,
1461 std::string body,
1463 std::string_view next_key = {}
1464 ) const {
1465 network::HttpRequest request;
1466 request.group_ = group;
1467 // Every Kiwoom read is a POST, including the market-data ones. A POST is never replayed after
1468 // a reconnect, which is the transport's own rule and the conservative one here.
1469 request.method_ = http::verb::post;
1470 request.target_ = std::string(path);
1471 request.body_ = std::move(body);
1472 request.headers_ = {
1473 {"authorization", "Bearer " + token_.access_token_},
1474 {"api-id", std::string(api_id)},
1475 {"content-type", "application/json;charset=UTF-8"},
1476 };
1477 if (!next_key.empty()) {
1478 request.headers_.emplace_back("cont-yn", "Y");
1479 request.headers_.emplace_back("next-key", std::string(next_key));
1480 }
1481 return request;
1482 }
1483
1484 // Returns the continuation a response carries: nothing when it reports no further page.
1485 //
1486 // **An absent value and an empty one are different answers**, which is why this is an optional
1487 // rather than a string. `cont-yn: Y` means Kiwoom has another page; `next-key` is how to ask for
1488 // it. A `Y` with a missing or blank key is therefore *another page this walk cannot fetch* — and
1489 // collapsing that into "no further page", which returning a bare empty string did, turned a
1490 // listing the adapter could not finish into one it reported as complete.
1491 //
1492 // - `std::nullopt` — Kiwoom offered no further page. The listing is complete.
1493 // - a non-empty string — another page, and the key to ask for it.
1494 // - an empty string — another page, and no usable key. Incomplete.
1495 static std::optional<std::string> continuation_key(const network::HttpResponse& response) {
1496 const auto flag = response.base().find("cont-yn");
1497 if (flag == response.base().end() || flag->value() != "Y") {
1498 return std::nullopt;
1499 }
1500 const auto key = response.base().find("next-key");
1501 if (key == response.base().end()) {
1502 return std::string();
1503 }
1504 return std::string(key->value());
1505 }
1506
1507 // Fetches a token when the held one has expired, coalescing concurrent waiters.
1508 //
1509 // Identical in shape to the KIS and Toss clients: at most one refresh is ever in flight, and
1510 // every other request waits behind it.
1511 void ensure_token(TokenHandler handler) {
1512 if (token_.valid()) {
1513 std::move(handler)(std::nullopt);
1514 return;
1515 }
1516 token_waiters_.push_back(std::move(handler));
1517 if (refreshing_) {
1518 return;
1519 }
1520 refreshing_ = true;
1521
1522 network::HttpRequest request;
1523 request.group_ = network::RateLimitGroup::AUTH;
1524 request.method_ = http::verb::post;
1525 request.target_ = "/oauth2/token";
1526 request.body_ = config_.to_jsonbody();
1527 request.headers_.emplace_back("content-type", "application/json;charset=UTF-8");
1528 http_->async_request(
1529 std::move(request),
1530 [self = shared_from_this()](Result<network::HttpResponse> result) mutable {
1531 net::post(self->strand_, [self, result = std::move(result)]() mutable {
1532 self->finish_token(std::move(result));
1533 });
1534 }
1535 );
1536 }
1537
1538 // Parses a token response and releases every waiter, with the failure if there was one.
1539 void finish_token(Result<network::HttpResponse> result) {
1540 refreshing_ = false;
1541 std::optional<BrokerError> failure;
1542 if (!result) {
1543 failure = std::move(result.error());
1544 } else if (result.value().result_int() < 200 || result.value().result_int() >= 300) {
1545 failure = kiwoom_http_error(result.value());
1546 failure->category_ = ErrorCategory::AUTHENTICATION;
1547 } else {
1548 BrokerError parse_error;
1549 auto document = broker_detail::parse_object(result.value(), parse_error);
1550 if (!document) {
1551 failure = std::move(parse_error);
1552 } else if (auto declared = kiwoom_declared_error(*document)) {
1553 // Kiwoom answers a refused token with HTTP 200 and a non-zero return_code, so the status
1554 // says nothing. `8020`, `8011` and `8012` all land here.
1555 failure = std::move(declared);
1556 } else {
1557 try {
1558 token_.from_json(*document);
1559 } catch (const std::exception& exception) {
1560 BrokerError error;
1561 error.category_ = ErrorCategory::AUTHENTICATION;
1562 error.broker_code_ = "invalid_token_response";
1563 error.message_ = exception.what();
1564 failure = std::move(error);
1565 }
1566 }
1567 }
1568
1569 if (failure) {
1570 // The waiters asked for something else, and none of their requests was sent. Same marking as
1571 // the KIS and Toss clients: an unmarked authentication failure reads to a runner as the
1572 // broker refusing an order it never received, spending rate-limit budget on nothing.
1573 failure->reached_broker_ = false;
1574 }
1575 auto waiters = std::move(token_waiters_);
1576 token_waiters_.clear();
1577 for (auto& waiter : waiters) {
1578 if (failure) {
1579 std::move(waiter)(*failure);
1580 } else {
1581 std::move(waiter)(std::nullopt);
1582 }
1583 }
1584 }
1585
1586 // Returns the error for a non-2xx reply: `broker_detail::http_error`, with Kiwoom's `return_code` read
1587 // from the whole body into `broker_code_` when the body is an object that carries one (the message
1588 // keeps only the body's start, so a code past it would otherwise be lost). The category stays what
1589 // the HTTP status says, unlike a 2xx reply's (`kiwoom_declared_error`): on an order, a rate-limit
1590 // category would turn an unknown 5xx outcome into a refusal (`kis_http_error` keeps orders out of its
1591 // mapping for that reason), and no Kiwoom non-2xx reply has been observed.
1592 static BrokerError kiwoom_http_error(const network::HttpResponse& response) {
1593 auto error = broker_detail::http_error(response);
1594 boost::system::error_code ec;
1595 const auto document = json::parse(response.body(), ec);
1596 if (ec || !document.is_object()) {
1597 return error;
1598 }
1599 if (const auto* code = document.as_object().if_contains("return_code")) {
1600 if (code->is_int64()) {
1601 error.broker_code_ = std::to_string(code->as_int64());
1602 } else if (code->is_uint64()) {
1603 error.broker_code_ = std::to_string(code->as_uint64());
1604 } else if (code->is_string()) {
1605 error.broker_code_ = std::string(code->as_string());
1606 }
1607 }
1608 return error;
1609 }
1610
1611 // Returns a response's declared payload, or the error ending it.
1612 //
1613 // Applies the HTTP status check, the JSON parse, and Kiwoom's own `return_code` before a parser
1614 // looks at any field, so a declared failure is never read as data. Only an order reply states an
1615 // operation: for every other caller the default keeps a refusal from being recognized.
1616 static Result<json::object> document(
1617 Result<network::HttpResponse> response,
1619 bool demo = false
1620 ) {
1621 if (!response) {
1622 return Result<json::object>(std::move(response.error()));
1623 }
1624 if (response.value().result_int() < 200 || response.value().result_int() >= 300) {
1625 return Result<json::object>(kiwoom_http_error(response.value()));
1626 }
1627 BrokerError parse_error;
1628 auto parsed = broker_detail::parse_object(response.value(), parse_error);
1629 if (!parsed) {
1630 return Result<json::object>(std::move(parse_error));
1631 }
1632 if (auto refused = kiwoom_recognized_refusal(response.value().result_int(), *parsed, operation, demo)) {
1633 return Result<json::object>(std::move(*refused));
1634 }
1635 if (auto declared = kiwoom_declared_error(*parsed)) {
1636 return Result<json::object>(std::move(*declared));
1637 }
1638 return Result<json::object>(std::move(*parsed));
1639 }
1640
1641 // Refuses an instrument this adapter's domestic endpoints cannot address.
1642 static std::optional<BrokerError> validate_instrument(const InstrumentId& instrument) {
1643 if (instrument.country_ == Country::KR && broker_detail::valid_symbol(instrument.symbol_) &&
1644 (instrument.symbol_.size() == 6 || instrument.symbol_.size() == 7)) {
1645 return std::nullopt;
1646 }
1647 BrokerError error;
1648 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1649 error.reached_broker_ = false;
1650 error.broker_code_ = "invalid_instrument";
1651 error.message_ =
1652 "this adapter covers Kiwoom's domestic market, whose instruments are addressed by a six- or "
1653 "seven-character code";
1654 return error;
1655 }
1656
1657 // Builds the `{"stk_cd": ...}` body every market-data endpoint takes, with the tape suffix.
1658 std::string symbol_body(const InstrumentId& instrument) const {
1659 json::object body;
1660 body["stk_cd"] = config_.tape_symbol(instrument.symbol_);
1661 return json::serialize(body);
1662 }
1663
1664 // Issues one market-data read and hands the response to `parse`.
1665 template <typename T, typename Handler, typename Parse>
1666 void start_market_read(
1667 InstrumentId instrument,
1668 std::string api_id,
1669 std::string path,
1670 Handler completion,
1671 Parse parse
1672 ) {
1673 if (auto invalid = validate_instrument(instrument)) {
1674 deliver(std::move(completion), Result<T>(std::move(*invalid)));
1675 return;
1676 }
1677 ensure_token(
1679 [
1680 self = shared_from_this(), instrument = std::move(instrument),
1681 api_id = std::move(api_id), path = std::move(path), completion = std::move(completion),
1682 parse
1683 ](std::optional<BrokerError> token_error) mutable {
1684 if (token_error) {
1685 self->deliver(std::move(completion), Result<T>(std::move(*token_error)));
1686 return;
1687 }
1688 auto request = self->make_request(
1689 api_id, path, self->symbol_body(instrument), network::RateLimitGroup::MARKET_DATA
1690 );
1691 self->http_->async_request(
1692 std::move(request),
1693 [self, instrument = std::move(instrument), completion = std::move(completion), parse](
1694 Result<network::HttpResponse> result
1695 ) mutable {
1696 self->deliver(
1697 std::move(completion), parse(std::move(instrument), std::move(result))
1698 );
1699 }
1700 );
1701 }
1702 )
1703 );
1704 }
1705
1706 // Normalizes `ka10007` into a quote.
1707 //
1708 // The last price, the volume and the top of the book all come off one response, so the quote
1709 // carries a `top_of_book_` — which neither shipped adapter fills, because neither KIS's nor
1710 // Toss's quote endpoint publishes one. Kiwoom's does, in the same response, at no extra request.
1711 static Result<QuoteSnapshot> parse_quote(
1712 InstrumentId instrument,
1713 Result<network::HttpResponse> result
1714 ) {
1715 auto body = document(std::move(result));
1716 if (!body) {
1717 return Result<QuoteSnapshot>(std::move(body.error()));
1718 }
1719 try {
1720 const auto& row = body.value();
1721 QuoteSnapshot quote;
1722 quote.instrument_ = std::move(instrument);
1723 quote.currency_ = Currency::krw();
1724 const auto last = detail::magnitude(row, "cur_prc");
1725 if (!last) {
1726 throw std::runtime_error("missing or unreadable field: cur_prc");
1727 }
1728 quote.last_price_ = *last;
1729 quote.volume_ = detail::magnitude(row, "trde_qty");
1730 quote.received_at_ = std::chrono::system_clock::now();
1731 const auto date = detail::compact_date(detail::text_field(row, "date"));
1732 if (date) {
1733 quote.exchange_time_ = detail::seoul_timestamp(*date, detail::text_field(row, "tm"));
1734 }
1735 TopOfBook top;
1736 top.ask_price_ = detail::magnitude(row, "sel_1bid");
1737 top.ask_quantity_ = detail::magnitude(row, "sel_1bid_req");
1738 top.bid_price_ = detail::magnitude(row, "buy_1bid");
1739 top.bid_quantity_ = detail::magnitude(row, "buy_1bid_req");
1740 if (top.ask_price_ || top.bid_price_) {
1741 quote.top_of_book_ = top;
1742 }
1743 return Result<QuoteSnapshot>(std::move(quote));
1744 } catch (const std::exception& exception) {
1745 return Result<QuoteSnapshot>(protocol_error("invalid_quote_response", exception));
1746 }
1747 }
1748
1749 // Normalizes `ka10007`'s price bounds.
1750 //
1751 // KRX sets a limit on every listed equity, so both bounds are expected; a blank or zero bound is
1752 // reported as absent rather than as a bound of nothing.
1753 static Result<PriceLimits> parse_price_limits(
1754 InstrumentId instrument,
1755 Result<network::HttpResponse> result
1756 ) {
1757 static_cast<void>(instrument);
1758 auto body = document(std::move(result));
1759 if (!body) {
1760 return Result<PriceLimits>(std::move(body.error()));
1761 }
1762 try {
1763 const auto& row = body.value();
1764 PriceLimits limits;
1765 limits.currency_ = Currency::krw();
1766 limits.upper_ = positive(detail::magnitude(row, "upl_pric"));
1767 limits.lower_ = positive(detail::magnitude(row, "lst_pric"));
1768 const auto date = detail::compact_date(detail::text_field(row, "date"));
1769 if (date) {
1770 limits.exchange_time_ = detail::seoul_timestamp(*date, detail::text_field(row, "tm"));
1771 }
1772 return Result<PriceLimits>(std::move(limits));
1773 } catch (const std::exception& exception) {
1774 return Result<PriceLimits>(protocol_error("invalid_price_limit_response", exception));
1775 }
1776 }
1777
1778 // Normalizes `ka10004` into the ten levels each side.
1779 //
1780 // Kiwoom names the first level differently from the rest — `sel_fpr_bid` and `buy_fpr_bid` for
1781 // level one, `sel_2th_pre_bid` through `sel_10th_pre_bid` for the others — so the field names are
1782 // built rather than listed, and level one is spelled out.
1783 static Result<DepthSnapshot> parse_depth(
1784 InstrumentId instrument,
1785 Result<network::HttpResponse> result
1786 ) {
1787 auto body = document(std::move(result));
1788 if (!body) {
1789 return Result<DepthSnapshot>(std::move(body.error()));
1790 }
1791 try {
1792 const auto& row = body.value();
1793 DepthSnapshot book;
1794 book.instrument_ = std::move(instrument);
1795 book.currency_ = Currency::krw();
1796 book.received_at_ = std::chrono::system_clock::now();
1797 book.exchange_time_ =
1798 detail::seoul_timestamp(today_in_seoul(), detail::text_field(row, "bid_req_base_tm"));
1799 append_level(book.asks_, row, "sel_fpr_bid", "sel_fpr_req");
1800 append_level(book.bids_, row, "buy_fpr_bid", "buy_fpr_req");
1801 for (int level = 2; level <= 10; ++level) {
1802 const std::string suffix = std::to_string(level) + "th_pre_";
1803 append_level(book.asks_, row, "sel_" + suffix + "bid", "sel_" + suffix + "req");
1804 append_level(book.bids_, row, "buy_" + suffix + "bid", "buy_" + suffix + "req");
1805 }
1806 order_depth(book);
1807 return Result<DepthSnapshot>(std::move(book));
1808 } catch (const std::exception& exception) {
1809 return Result<DepthSnapshot>(protocol_error("invalid_depth_response", exception));
1810 }
1811 }
1812
1813 // Appends one book level, skipping the empty ones Kiwoom pads a thin book with.
1814 static void append_level(
1815 std::vector<DepthLevel>& side,
1816 const json::object& row,
1817 const std::string& price_key,
1818 const std::string& quantity_key
1819 ) {
1820 const auto price = detail::magnitude(row, price_key);
1821 const auto quantity = detail::magnitude(row, quantity_key);
1822 if (!price || price->coefficient() == 0) {
1823 return;
1824 }
1825 DepthLevel level;
1826 level.price_ = *price;
1827 level.quantity_ = quantity ? *quantity : Decimal64();
1828 side.push_back(level);
1829 }
1830
1831 // Returns a decimal only when it is strictly positive, which is how Kiwoom spells "no value".
1832 static std::optional<Decimal64> positive(std::optional<Decimal64> value) {
1833 if (!value || value->coefficient() <= 0) {
1834 return std::nullopt;
1835 }
1836 return value;
1837 }
1838
1839 // Returns an order's trigger from `key`, or nothing.
1840 //
1841 // `ka10075`'s `stop_pric` and `kt00007`'s `cond_uv` are both documented as "스톱지정가주문
1842 // 스톱가", and `kt00007`'s own example carries `"0000000000"` on a market order -- so an ordinary
1843 // order reports zero, and a positive value is recorded **whatever the row's basis**, for the
1844 // reason `OrderSnapshot::trigger_price_` gives. An empty, zero or unreadable value leaves the
1845 // trigger unreported rather than failing the listing: a trigger is one fact about one order, and
1846 // the row is still the order, which must stay cancellable.
1847 static std::optional<Decimal64> reported_trigger(const json::object& row, std::string_view key) {
1848 try {
1849 return positive(detail::magnitude(row, key));
1850 } catch (const std::exception&) {
1851 return std::nullopt;
1852 }
1853 }
1854
1855 // Builds a `PROTOCOL` failure from a parser exception.
1856 static BrokerError protocol_error(const char* code, const std::exception& exception) {
1857 BrokerError error;
1858 error.category_ = ErrorCategory::PROTOCOL;
1859 error.broker_code_ = code;
1860 error.message_ = exception.what();
1861 return error;
1862 }
1863
1864 // Delivers `result` to `handler` on the strand, exactly once.
1865 template <typename Handler, typename T>
1866 void deliver(Handler handler, Result<T> result) {
1867 if (!result) {
1868 note_rate_limit(result.error());
1869 }
1870 auto executor = net::get_associated_executor(handler, strand_);
1871 net::dispatch(executor, [handler = std::move(handler), result = std::move(result)]() mutable {
1872 std::move(handler)(std::move(result));
1873 });
1874 }
1875
1876 // Holds admission after one of Kiwoom's three ceilings refused a request.
1877 //
1878 // Kiwoom documents the ceilings separately — per API (`1700`), account-wide (`1701`) and per API
1879 // group (`1702`) — but states **no allowance and no retry-after** with any of them, so there is
1880 // nothing to correct the budget *to*. Admission is held for a second and the refusal is counted;
1881 // the configured budget is left exactly as the caller set it, for the reason the KIS adapter
1882 // gives: reducing a stated budget from a guess is the silent downgrade this library refuses
1883 // everywhere else. `KiwoomRateLimitLayer` is what tells a caller watching the refusals *which*
1884 // ceiling to lower.
1885 void note_rate_limit(const BrokerError& error) {
1886 if (!error_category_is_rate_limit(error.category_)) {
1887 return;
1888 }
1889 auto* limiter = http_->rate_limits();
1890 if (limiter == nullptr) {
1891 return;
1892 }
1893 limiter->hold(
1894 error.retry_after_ ?
1895 std::chrono::duration_cast<network::RateLimitController::Duration>(*error.retry_after_) :
1896 std::chrono::duration_cast<network::RateLimitController::Duration>(std::chrono::seconds(1))
1897 );
1898 }
1899
1900 // Issues the recent-execution read, whose parser needs the requested count.
1901 void start_trades(InstrumentId instrument, int count, TradesHandler completion) {
1902 using Prints = std::vector<TradePrint>;
1903 if (count <= 0) {
1904 BrokerError error;
1905 error.category_ = ErrorCategory::INVALID_ARGUMENT;
1906 error.reached_broker_ = false;
1907 error.broker_code_ = "invalid_count";
1908 error.message_ = "a recent-execution request must ask for at least one print";
1909 deliver(std::move(completion), Result<Prints>(std::move(error)));
1910 return;
1911 }
1912 if (auto invalid = validate_instrument(instrument)) {
1913 deliver(std::move(completion), Result<Prints>(std::move(*invalid)));
1914 return;
1915 }
1916 ensure_token(
1918 [
1919 self = shared_from_this(), instrument = std::move(instrument), count,
1920 completion = std::move(completion)
1921 ](std::optional<BrokerError> token_error) mutable {
1922 if (token_error) {
1923 self->deliver(std::move(completion), Result<Prints>(std::move(*token_error)));
1924 return;
1925 }
1926 auto request = self->make_request(
1927 "ka10003", "/api/dostk/stkinfo", self->symbol_body(instrument),
1929 );
1930 self->http_->async_request(
1931 std::move(request),
1932 [self, count, completion = std::move(completion)](
1933 Result<network::HttpResponse> result
1934 ) mutable {
1935 self->deliver(std::move(completion), parse_trades(std::move(result), count));
1936 }
1937 );
1938 }
1939 )
1940 );
1941 }
1942
1943 // Normalizes `ka10003`'s recent executions, newest first as Kiwoom sends them.
1944 //
1945 // Kiwoom reports a print's time as `HHmmss` with no date, so the KST calendar day is supplied
1946 // from the clock. A print is intraday by construction, so this is the day it happened on — the
1947 // same reasoning the KIS adapter records for the same field.
1948 static Result<std::vector<TradePrint>> parse_trades(
1949 Result<network::HttpResponse> result,
1950 int count
1951 ) {
1952 using Prints = std::vector<TradePrint>;
1953 auto body = document(std::move(result));
1954 if (!body) {
1955 return Result<Prints>(std::move(body.error()));
1956 }
1957 try {
1958 const auto rows = body.value().if_contains("cntr_infr");
1959 if (!rows || !rows->is_array()) {
1960 throw std::runtime_error("missing array field: cntr_infr");
1961 }
1962 const auto today = today_in_seoul();
1963 Prints prints;
1964 for (const auto& value : rows->as_array()) {
1965 if (!value.is_object()) {
1966 throw std::runtime_error("cntr_infr row is not an object");
1967 }
1968 if (static_cast<int>(prints.size()) >= count) {
1969 break;
1970 }
1971 const auto& row = value.as_object();
1972 const auto price = detail::magnitude(row, "cur_prc");
1973 const auto quantity = detail::magnitude(row, "cntr_trde_qty");
1974 if (!price || !quantity) {
1975 continue;
1976 }
1977 TradePrint print;
1978 print.price_ = *price;
1979 print.quantity_ = *quantity;
1980 print.currency_ = Currency::krw();
1981 print.exchange_time_ = detail::seoul_timestamp(today, detail::text_field(row, "tm"));
1982 prints.push_back(print);
1983 }
1984 return Result<Prints>(std::move(prints));
1985 } catch (const std::exception& exception) {
1986 return Result<Prints>(protocol_error("invalid_trades_response", exception));
1987 }
1988 }
1989
1990 // Issues the reference-data read, which takes the bare symbol rather than a tape-suffixed one.
1991 void start_instrument(InstrumentId instrument, InstrumentHandler completion) {
1992 if (auto invalid = validate_instrument(instrument)) {
1993 deliver(std::move(completion), Result<Instrument>(std::move(*invalid)));
1994 return;
1995 }
1996 ensure_token(
1998 [
1999 self = shared_from_this(), instrument = std::move(instrument),
2000 completion = std::move(completion)
2001 ](std::optional<BrokerError> token_error) mutable {
2002 if (token_error) {
2003 self->deliver(std::move(completion), Result<Instrument>(std::move(*token_error)));
2004 return;
2005 }
2006 // `ka10100` documents `stk_cd` as 종목코드 6자리 and names no tape, unlike every
2007 // market-data endpoint. Reference data is not a price, so it has no book to come off.
2008 json::object body;
2009 body["stk_cd"] = instrument.symbol_;
2010 auto request = self->make_request(
2011 "ka10100", "/api/dostk/stkinfo", json::serialize(body),
2013 );
2014 self->http_->async_request(
2015 std::move(request),
2016 [self, instrument = std::move(instrument), completion = std::move(completion)](
2017 Result<network::HttpResponse> result
2018 ) mutable {
2019 self->deliver(
2020 std::move(completion), parse_instrument(std::move(instrument), std::move(result))
2021 );
2022 }
2023 );
2024 }
2025 )
2026 );
2027 }
2028
2029 // Normalizes `ka10100` into reference data.
2030 static Result<Instrument> parse_instrument(
2031 InstrumentId instrument,
2032 Result<network::HttpResponse> result
2033 ) {
2034 auto body = document(std::move(result));
2035 if (!body) {
2036 return Result<Instrument>(std::move(body.error()));
2037 }
2038 try {
2039 const auto& row = body.value();
2040 Instrument subject;
2041 subject.id_ = std::move(instrument);
2042 subject.name_ = detail::text_field(row, "name");
2043 subject.currency_ = Currency::krw();
2044 subject.segment_ = kiwoom_market_segment(detail::text_field(row, "marketCode"));
2045 subject.type_ = SecurityType::STOCK;
2046 subject.status_ = kiwoom_status(detail::text_field(row, "auditInfo"));
2047 subject.shares_outstanding_ = positive(detail::magnitude(row, "listCount"));
2048 subject.listed_on_ = detail::stated_compact_date(row, "regDay");
2049 subject.price_rule_ = price_rule_for(subject.id_.country_, subject.type_);
2050 return Result<Instrument>(std::move(subject));
2051 } catch (const std::exception& exception) {
2052 return Result<Instrument>(protocol_error("invalid_instrument_response", exception));
2053 }
2054 }
2055
2056 // Maps Kiwoom's `marketCode` to a normalized segment.
2057 //
2058 // `0` is 거래소 (KOSPI) and `10` is 코스닥, which `ka10099` documents as its 시장구분 values. The
2059 // rest of that list — K-OTC, 코넥스, ETN, ELW and the funds — has no `MarketSegment::Known`
2060 // member, so the raw code is preserved through `market_segment` rather than collapsed into one
2061 // of the two this library does know.
2062 static MarketSegment kiwoom_market_segment(std::string_view code) {
2063 if (code == "0") {
2064 return market_segment("KOSPI");
2065 }
2066 if (code == "10") {
2067 return market_segment("KOSDAQ");
2068 }
2069 return market_segment(code);
2070 }
2071
2072 // Reads a listing state from `ka10100`'s `auditInfo`.
2073 //
2074 // Kiwoom publishes 감리구분 as a Korean phrase — the field lists `정상`, `관리종목`, `거래정지`,
2075 // `단기과열`, `투자주의`, `투자경고`, and the `ka10099` example writes `투자주의환기종목` — and publishes no
2076 // delisting date and no `SCHEDULED` state at all. Everything it reports is a listed instrument
2077 // under some degree of supervision (a halt included), so the honest reading is `ACTIVE` for the
2078 // phrases it publishes and `UNKNOWN` for any other: supervision is not delisting, and inventing a
2079 // `DELISTED` from a warning would be worse than saying nothing.
2080 static InstrumentStatus kiwoom_status(std::string_view audit) {
2081 for (const std::string_view listed :
2082 {"정상", "관리종목", "거래정지", "단기과열", "투자주의", "투자경고", "투자주의환기종목"}) {
2083 if (audit == listed) {
2084 return InstrumentStatus::ACTIVE;
2085 }
2086 }
2087 return InstrumentStatus::UNKNOWN;
2088 }
2089
2090 // What a paged listing should do after one page.
2091 enum class PageStep {
2092 // Kiwoom offered no further page: the listing is complete.
2093 FINISH,
2094 // Kiwoom offered another page and this walk may fetch it.
2095 CONTINUE,
2096 // Kiwoom still offered another page and this walk stopped: the listing is incomplete.
2097 INCOMPLETE,
2098 };
2099
2100 // One bar request spanning however many Kiwoom pages it needs.
2101 struct BarOperation {
2102 BarQuery query_;
2103 std::vector<Bar> descending_;
2104 // The upper bound for the next page, as a KST calendar date.
2105 Date date_{};
2106 std::string next_key_;
2107 // Whether Kiwoom said another page exists, which is not the same as having its key.
2108 bool more_{false};
2109 int pages_{0};
2110 BarsHandler completion_;
2111
2112 BarOperation(BarQuery query, BarsHandler completion)
2113 : query_(std::move(query)), completion_(std::move(completion)) {}
2114 };
2115
2116 // Validates a bar request and starts walking Kiwoom's pages.
2117 void start_bars(BarQuery query, BarsHandler completion) {
2118 if (auto invalid = validate_bar_query(query)) {
2119 deliver(std::move(completion), Result<BarSeries>(std::move(*invalid)));
2120 return;
2121 }
2122 auto operation = std::make_shared<BarOperation>(std::move(query), std::move(completion));
2123 const auto bound = operation->query_.before_ ?
2124 *operation->query_.before_ :
2125 std::chrono::system_clock::now();
2126 operation->date_ = seoul_date_of(bound);
2127 request_bars(operation);
2128 }
2129
2130 // Refuses a bar request Kiwoom cannot answer, before any I/O.
2131 static std::optional<BrokerError> validate_bar_query(const BarQuery& query) {
2132 BrokerError error;
2133 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2134 error.reached_broker_ = false;
2135 if (auto invalid = validate_instrument(query.instrument_)) {
2136 return invalid;
2137 }
2138 if (query.count_ <= 0) {
2139 error.broker_code_ = "invalid_count";
2140 error.message_ = "a bar request must ask for at least one bar";
2141 return error;
2142 }
2143 return unsupported_bar_interval(query.interval_);
2144 }
2145
2146 // Issues one page of a bar request.
2147 void request_bars(std::shared_ptr<BarOperation> operation) {
2148 ensure_token(
2150 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
2151 if (token_error) {
2152 self->deliver(
2153 std::move(operation->completion_), Result<BarSeries>(std::move(*token_error))
2154 );
2155 return;
2156 }
2157 json::object body;
2158 body["stk_cd"] = self->config_.tape_symbol(operation->query_.instrument_.symbol_);
2159 if (operation->query_.interval_ == BarInterval::ONE_MINUTE) {
2160 body["tic_scope"] = "1";
2161 }
2162 // `upd_stkpc_tp`: `1` asks for prices adjusted for corporate actions, `0` for raw ones.
2163 // `BarQuery::adjusted_` exists because the brokers disagree about how to spell it, and
2164 // Kiwoom's spelling is the opposite of KIS's `FID_ORG_ADJ_PRC`.
2165 body["upd_stkpc_tp"] = operation->query_.adjusted_ ? "1" : "0";
2166 body["base_dt"] = detail::compact_text(operation->date_);
2167 auto request = self->make_request(
2168 chart_api_id(operation->query_.interval_), "/api/dostk/chart",
2169 json::serialize(body), network::RateLimitGroup::MARKET_DATA, operation->next_key_
2170 );
2171 self->http_->async_request(
2172 std::move(request),
2173 [self, operation](Result<network::HttpResponse> result) mutable {
2174 net::post(self->strand_, [self, operation, result = std::move(result)]() mutable {
2175 self->handle_bars_page(operation, std::move(result));
2176 });
2177 }
2178 );
2179 }
2180 )
2181 );
2182 }
2183
2184 // Accumulates one page of bars and either continues or finishes.
2185 void handle_bars_page(
2186 const std::shared_ptr<BarOperation>& operation,
2187 Result<network::HttpResponse> result
2188 ) {
2189 if (!result) {
2190 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(result.error())));
2191 return;
2192 }
2193 const auto next_key = continuation_key(result.value());
2194 auto body = document(std::move(result));
2195 if (!body) {
2196 deliver(std::move(operation->completion_), Result<BarSeries>(std::move(body.error())));
2197 return;
2198 }
2199 const std::size_t before = operation->descending_.size();
2200 try {
2201 const auto rows = body.value().if_contains(chart_rows_field(operation->query_.interval_));
2202 if (!rows || !rows->is_array()) {
2203 throw std::runtime_error(
2204 std::string("missing array field: ") + chart_rows_field(operation->query_.interval_)
2205 );
2206 }
2207 for (const auto& value : rows->as_array()) {
2208 if (!value.is_object()) {
2209 throw std::runtime_error("chart row is not an object");
2210 }
2211 operation->descending_.push_back(
2212 parse_bar(value.as_object(), operation->query_.interval_)
2213 );
2214 }
2215 } catch (const std::exception& exception) {
2216 deliver(
2217 std::move(operation->completion_),
2218 Result<BarSeries>(protocol_error("invalid_bars_response", exception))
2219 );
2220 return;
2221 }
2222
2223 switch (advance_bars(*operation, std::move(next_key), before)) {
2224 case PageStep::FINISH:
2225 finish_bars(operation);
2226 return;
2227 case PageStep::INCOMPLETE:
2228 deliver(
2229 std::move(operation->completion_),
2230 Result<BarSeries>(incomplete_listing("bars", operation->pages_))
2231 );
2232 return;
2233 case PageStep::CONTINUE:
2234 request_bars(operation);
2235 return;
2236 }
2237 }
2238
2239 // Records the page's continuation key and returns whether the walk should stop.
2240 //
2241 // **The key is recorded before the decision is taken, not after it**, and that ordering is the
2242 // point of this being one function. `finish_bars` reads `next_key_` to decide whether the series
2243 // carries a continuation cursor, and the walk stops with data still available precisely when
2244 // `count_` is satisfied -- which is exactly when a caller needs that cursor to page further
2245 // back. Assigning the key only on the path that continues left the *previous* page's key here,
2246 // so a satisfied request reported a stale cursor or none.
2247 //
2248 // A page that returned nothing also ends the walk. Without that, a broker answering `Y` with an
2249 // empty array would spend the whole page budget learning nothing.
2250 static PageStep advance_bars(
2251 BarOperation& operation,
2252 std::optional<std::string> next_key,
2253 std::size_t before
2254 ) {
2255 operation.more_ = next_key.has_value();
2256 operation.next_key_ = next_key ? std::move(*next_key) : std::string();
2257 ++operation.pages_;
2258 const bool grew = operation.descending_.size() > before;
2259 const bool satisfied =
2260 static_cast<int>(operation.descending_.size()) >= operation.query_.count_;
2261 if (satisfied || !operation.more_) {
2262 return PageStep::FINISH;
2263 }
2264 // **Kiwoom offered more and the count is not satisfied.** Either this walk fetches the next
2265 // page or the series is short of what was asked for -- and a short series is not a finished
2266 // one. Returning it would be the listing defect in another place: a caller reads `bars_.size()`
2267 // and cannot tell "this is all there is" from "the walk gave up".
2268 //
2269 // A keyless page is the same thing arriving differently: Kiwoom offered a page and no way to
2270 // ask for it.
2271 if (operation.next_key_.empty() || !grew || operation.pages_ >= page_budget_) {
2272 return PageStep::INCOMPLETE;
2273 }
2274 return PageStep::CONTINUE;
2275 }
2276
2277 // Normalizes one chart row.
2278 //
2279 // A minute row stamps `cntr_tm` as `yyyyMMddHHmmss`; every other interval stamps `dt` as
2280 // `yyyyMMdd`. `cur_prc` is the period's close and, like every market-data price, carries a
2281 // direction prefix rather than a sign.
2282 static Bar parse_bar(const json::object& row, BarInterval interval) {
2283 Bar bar;
2284 if (interval == BarInterval::ONE_MINUTE) {
2285 const auto stamp = detail::text_field(row, "cntr_tm");
2286 if (stamp.size() != 14) {
2287 throw std::runtime_error("invalid minute bar timestamp");
2288 }
2289 const auto date = detail::compact_date(std::string_view(stamp).substr(0, 8));
2290 const auto timestamp =
2291 date ? detail::seoul_timestamp(*date, std::string_view(stamp).substr(8)) : std::nullopt;
2292 if (!timestamp) {
2293 throw std::runtime_error("invalid minute bar timestamp");
2294 }
2295 bar.timestamp_ = *timestamp;
2296 } else {
2297 const auto date = detail::compact_date(detail::text_field(row, "dt"));
2298 const auto timestamp = date ? detail::seoul_timestamp(*date, "000000") : std::nullopt;
2299 if (!timestamp) {
2300 throw std::runtime_error("invalid bar date");
2301 }
2302 bar.timestamp_ = *timestamp;
2303 }
2304 const auto high = detail::magnitude(row, "high_pric");
2305 const auto low = detail::magnitude(row, "low_pric");
2306 const auto close = detail::magnitude(row, "cur_prc");
2307 if (!high || !low || !close) {
2308 throw std::runtime_error("bar row is missing a price");
2309 }
2310 bar.high_ = *high;
2311 bar.low_ = *low;
2312 bar.close_ = *close;
2313 bar.volume_ = detail::required_magnitude(row, "trde_qty");
2314 bar.open_ = detail::magnitude(row, "open_pric");
2315 bar.traded_value_ = detail::magnitude(row, "trde_prica");
2316 return bar;
2317 }
2318
2319 // Reverses the accumulation, trims to the requested count, and returns the series.
2320 //
2321 // Pure, and separate from the completion for that reason: what a walk *produced* is the thing
2322 // worth checking, and checking it should not require a strand, a socket or a completion token.
2323 static BarSeries compose_bars(BarOperation& operation) {
2324 BarSeries series;
2325 series.instrument_ = operation.query_.instrument_;
2326 series.interval_ = operation.query_.interval_;
2327 series.currency_ = Currency::krw();
2328 series.received_at_ = std::chrono::system_clock::now();
2329 // What was asked for is what was sent (`upd_stkpc_tp`), and the series has to say which prices
2330 // it carries: the field defaults to `true`, so a raw request whose result never set it would
2331 // hand a caller unadjusted prices labelled as adjusted.
2332 series.adjusted_ = operation.query_.adjusted_;
2333 // The same convention the KIS adapter records: Kiwoom stamps a minute bar with the time the
2334 // period ended and a period bar with its trading date, which is the period's open. Neither
2335 // specification states it in words, and an off-by-one-bar lookahead is the defect `BarStamp`
2336 // exists to make visible, so the two adapters agree on the reading rather than differing
2337 // silently.
2338 series.stamp_ = operation.query_.interval_ == BarInterval::ONE_MINUTE ?
2341
2342 auto& rows = operation.descending_;
2343 if (static_cast<int>(rows.size()) > operation.query_.count_) {
2344 rows.resize(static_cast<std::size_t>(operation.query_.count_));
2345 }
2346 series.bars_.assign(rows.rbegin(), rows.rend());
2347 // The oldest bar in hand, which is what a caller passes back as `before_` to page further
2348 // back. Emitted only when Kiwoom said there is more *and* a caller could act on it: an
2349 // exhausted history reports no cursor rather than one that would return nothing, and a minute
2350 // series reports none because `base_dt` is a date and cannot resume inside a day. A minute
2351 // walk that cannot satisfy its count fails instead; see `advance_bars`.
2352 const bool resumable = operation.query_.interval_ != BarInterval::ONE_MINUTE;
2353 if (!series.bars_.empty() && operation.more_ && resumable) {
2354 series.next_cursor_ = format_iso8601_utc(series.bars_.front().timestamp_);
2355 }
2356 return series;
2357 }
2358
2359 // Composes the series and delivers it.
2360 void finish_bars(const std::shared_ptr<BarOperation>& operation) {
2361 deliver(std::move(operation->completion_), Result<BarSeries>(compose_bars(*operation)));
2362 }
2363
2364 // Returns the KST calendar date an instant falls on.
2365 static Date seoul_date_of(std::chrono::system_clock::time_point instant) {
2366 const std::time_t raw = std::chrono::system_clock::to_time_t(
2367 std::chrono::time_point_cast<std::chrono::seconds>(instant + std::chrono::hours(9))
2368 );
2369 std::tm parts{};
2370#ifdef _WIN32
2371 gmtime_s(&parts, &raw);
2372#else
2373 gmtime_r(&raw, &parts);
2374#endif
2375 Date date;
2376 date.year_ = parts.tm_year + 1900;
2377 date.month_ = parts.tm_mon + 1;
2378 date.day_ = parts.tm_mday;
2379 return date;
2380 }
2381
2382 // Records the page's continuation key and says what the walk should do next.
2383 //
2384 // One function for the three account listings, because the decision is the same for all of them
2385 // and the consequence of getting it wrong is the same too. It is separate from the handlers so
2386 // that the decision can be exercised without a socket.
2387 static PageStep advance_page(
2388 std::string& next_key_slot,
2389 int& pages,
2390 bool grew,
2391 std::optional<std::string> next_key
2392 ) {
2393 ++pages;
2394 if (!next_key) {
2395 return PageStep::FINISH;
2396 }
2397 // Kiwoom offered another page. Either this walk fetches it or the listing is incomplete --
2398 // there is no third answer, including when the key it offered is unusable.
2399 if (next_key->empty() || !grew || pages >= page_budget_) {
2400 return PageStep::INCOMPLETE;
2401 }
2402 next_key_slot = std::move(*next_key);
2403 return PageStep::CONTINUE;
2404 }
2405
2406 // Returns the failure a listing that stopped while more remained should end with.
2407 //
2408 // **A guard that bounds a walk must not also decide the walk finished.** Kiwoom says `cont-yn: Y`
2409 // with a `next-key` exactly when there is another page, so stopping on the page budget -- or on a
2410 // page that added no rows while Kiwoom still offered another -- leaves rows unread. Returning
2411 // what was gathered would hand a *partial* answer to code that cannot tell: a working-order
2412 // listing missing a resting sell understates what `RiskGuard` deducts, and partial holdings
2413 // understate exposure, which is the permissive direction for a position ceiling.
2414 //
2415 // Bars are the exception and do not come through here: `BarSeries` carries `next_cursor_`, so a
2416 // bar walk can say "there is more" in the value itself. Holdings, working orders and order
2417 // history have nowhere to say it, so they fail instead.
2418 static BrokerError incomplete_listing(std::string_view what, int pages) {
2419 BrokerError error;
2420 error.category_ = ErrorCategory::PROTOCOL;
2421 error.broker_code_ = "incomplete_listing";
2422 error.reached_broker_ = true;
2423 error.message_ = std::string("Kiwoom still offered another page of ") + std::string(what) +
2424 " after " + std::to_string(pages) +
2425 " page(s); the listing is incomplete and is refused rather than returned in part";
2426 return error;
2427 }
2428
2429 // One holdings request spanning however many `kt00018` pages it needs.
2430 struct HoldingsOperation {
2431 HoldingsSnapshot snapshot_;
2432 std::string next_key_;
2433 int pages_{0};
2434 bool totals_seen_{false};
2435 HoldingsHandler completion_;
2436
2437 explicit HoldingsOperation(HoldingsHandler completion) : completion_(std::move(completion)) {}
2438 };
2439
2440 // Validates the configured holdings venue and starts paging.
2441 void start_holdings(HoldingsHandler completion) {
2442 if (auto invalid = validate_holdings_venue()) {
2443 deliver(std::move(completion), Result<HoldingsSnapshot>(std::move(*invalid)));
2444 return;
2445 }
2446 auto operation = std::make_shared<HoldingsOperation>(std::move(completion));
2447 request_holdings(operation);
2448 }
2449
2450 // Refuses a holdings venue `kt00018` cannot express.
2451 //
2452 // `kt00018`'s `dmst_stex_tp` documents `KRX` and `NXT` and no all-venue value, so there is
2453 // nothing for `COMBINED` or `UNSPECIFIED` to be sent as. Resolving either to one of the two
2454 // would answer a different question from the one asked and would do it silently, which this
2455 // library refuses to do — and holdings are what the sellable guard and every position ceiling are
2456 // measured against. See `KiwoomConfig::holdings_venue_`.
2457 // Refuses an instruction that can open or enlarge a position while the holdings scope is unstated.
2458 //
2459 // Nothing once the operator has asserted what `dmst_stex_tp` means: the assertion is what this
2460 // refusal exists to demand, so honouring it is the whole point rather than a weakening. It does
2461 // **not** promise a holdings read has succeeded -- see `KiwoomConfig::holdings_position_scope_`
2462 // for what an assertion does and does not claim, and note that the runner's own portfolio gate is
2463 // a separate rule that still applies.
2464 // Returns what `trde_able_qty` on a `kt00018` row is evidence of, given what the operator asserted.
2465 //
2466 // **A named decision with one caller**, rather than three lines inside the paging branch that
2467 // completes a snapshot. That branch is unreachable without a live HTTP round trip, so a rule
2468 // written there is a rule no test can drive. A decision worth making is worth naming
2469 // (`validate_amendment`), and this is the same shape.
2470 //
2471 // The coverage is the operator's assertion and nothing more. The resting treatment is this
2472 // library's own conservative rule either way and is deliberately not a caller's to state:
2473 // deducting a resting sell Kiwoom had also deducted refuses a trade, while not deducting one
2474 // sells the same shares twice, and only the first error is recoverable. `verified_` stays false
2475 // because an assertion is not a measurement.
2476 static SellableBasis holdings_sellable_basis(KiwoomSellableScope scope) {
2477 return SellableBasis{
2481 false,
2482 };
2483 }
2484
2485 static std::optional<BrokerError> holdings_scope_refusal(KiwoomPositionScope scope) {
2486 if (!kiwoom_position_scope_asserted(scope)) {
2488 }
2489 return std::nullopt;
2490 }
2491
2492 std::optional<BrokerError> validate_holdings_venue() const {
2493 if (config_.holdings_venue_ == Venue::KRX || config_.holdings_venue_ == Venue::NXT) {
2494 return std::nullopt;
2495 }
2496 BrokerError error;
2497 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2498 error.reached_broker_ = false;
2499 error.broker_code_ = "holdings_venue_required";
2500 error.message_ =
2501 "Kiwoom's balance inquiry names one venue and publishes no combined value, and whether that "
2502 "selects which positions are listed or only how they are valued is not stated; set "
2503 "KiwoomConfig::holdings_venue_ to Venue::KRX or Venue::NXT deliberately, having read its "
2504 "documentation";
2505 return error;
2506 }
2507
2508 // Issues one page of the holdings inquiry.
2509 void request_holdings(std::shared_ptr<HoldingsOperation> operation) {
2510 ensure_token(
2512 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
2513 if (token_error) {
2514 self->deliver(
2515 std::move(operation->completion_), Result<HoldingsSnapshot>(std::move(*token_error))
2516 );
2517 return;
2518 }
2519 auto request = self->make_request(
2520 "kt00018", "/api/dostk/acnt", self->holdings_body(),
2521 network::RateLimitGroup::ACCOUNT, operation->next_key_
2522 );
2523 self->http_->async_request(
2524 std::move(request),
2525 [self, operation](Result<network::HttpResponse> result) mutable {
2526 net::post(self->strand_, [self, operation, result = std::move(result)]() mutable {
2527 self->handle_holdings_page(operation, std::move(result));
2528 });
2529 }
2530 );
2531 }
2532 )
2533 );
2534 }
2535
2536 // Builds `kt00018`'s body: one summed listing, from the configured venue.
2537 std::string holdings_body() const {
2538 json::object body;
2539 // `1:합산` asks Kiwoom to sum the lots of one instrument into one row; `2:개별` reports them
2540 // separately. Summed is what this library asks for -- but the specification's own example
2541 // answers a `qry_tp: "1"` request with *two* `A005930` rows, so asking is not the same as
2542 // getting, and `append_holdings_page` refuses a listing that comes back split rather than
2543 // trusting the request. See `duplicate_symbol` there for why that matters.
2544 body["qry_tp"] = "1";
2545 body["dmst_stex_tp"] = exchange_code(config_.holdings_venue_);
2546 return json::serialize(body);
2547 }
2548
2549 // Accumulates one page of holdings and either continues or completes.
2550 void handle_holdings_page(
2551 const std::shared_ptr<HoldingsOperation>& operation,
2552 Result<network::HttpResponse> result
2553 ) {
2554 if (!result) {
2555 deliver(
2556 std::move(operation->completion_), Result<HoldingsSnapshot>(std::move(result.error()))
2557 );
2558 return;
2559 }
2560 const auto next_key = continuation_key(result.value());
2561 auto body = document(std::move(result));
2562 if (!body) {
2563 deliver(
2564 std::move(operation->completion_), Result<HoldingsSnapshot>(std::move(body.error()))
2565 );
2566 return;
2567 }
2568 const std::size_t before = operation->snapshot_.positions_.size();
2569 try {
2570 append_holdings_page(operation->snapshot_, operation->totals_seen_, body.value());
2571 } catch (const std::exception& exception) {
2572 deliver(
2573 std::move(operation->completion_),
2574 Result<HoldingsSnapshot>(protocol_error("invalid_holdings_response", exception))
2575 );
2576 return;
2577 }
2578 const bool grew = operation->snapshot_.positions_.size() > before;
2579 switch (advance_page(operation->next_key_, operation->pages_, grew, std::move(next_key))) {
2580 case PageStep::FINISH:
2581 operation->snapshot_.received_at_ = std::chrono::system_clock::now();
2582 // **What the `trde_able_qty` figures on these rows are evidence of, per the operator's own
2583 // assertion and nothing more.** `WHOLE_HOLDING` only when somebody has stated that the
2584 // venue parameter does not restrict the figure; unestablished otherwise, which makes
2585 // `RiskGuard` refuse a checked sell rather than measure one against a number that may
2586 // describe one book out of two. The *resting* treatment is this library's conservative rule
2587 // either way and is not a caller's to assert: deducting a resting sell Kiwoom had also
2588 // deducted refuses a trade, where the opposite sells the same shares twice.
2589 operation->snapshot_.sellable_basis_ =
2590 holdings_sellable_basis(config_.holdings_sellable_scope_);
2591 deliver(
2592 std::move(operation->completion_),
2593 Result<HoldingsSnapshot>(std::move(operation->snapshot_))
2594 );
2595 return;
2596 case PageStep::INCOMPLETE:
2597 deliver(
2598 std::move(operation->completion_),
2599 Result<HoldingsSnapshot>(incomplete_listing("holdings", operation->pages_))
2600 );
2601 return;
2602 case PageStep::CONTINUE:
2603 request_holdings(operation);
2604 return;
2605 }
2606 }
2607
2608 // Appends one `kt00018` page's rows and, on the first page only, its totals.
2609 //
2610 // **A symbol that appears twice fails the snapshot.** The request asks for a summed listing
2611 // (`qry_tp: "1"`), and the specification's own response example answers exactly that request
2612 // with two `A005930` rows carrying 3 and 8 shares -- so a split listing is a real possibility
2613 // and not a hypothetical. It is refused rather than accepted for a reason that costs money:
2614 // `Portfolio` reads the *first* matching row's `sellable_quantity_`, so a split holding would
2615 // report 3 sellable shares where Kiwoom said 11, and which 3 would depend on row order.
2616 // Summing them instead is not this library's call to make -- whether two lots' *sellable*
2617 // quantities add is a statement about what the broker meant, which the specification does not
2618 // settle. So the snapshot fails loudly, once, with the symbol named.
2619 //
2620 // **The totals come from Kiwoom's summary fields and are never summed from the rows**, which is
2621 // what `HoldingsTotals` documents: a paged listing can return part of the positions beside an
2622 // account-wide summary, so the two need not agree and the broker's own figure is the one to
2623 // report. Only the first page's summary is taken, because every page repeats it.
2624 static void append_holdings_page(
2625 HoldingsSnapshot& snapshot,
2626 bool& totals_seen,
2627 const json::object& page
2628 ) {
2629 if (!totals_seen) {
2630 HoldingsTotals totals;
2631 totals.currency_ = Currency::krw();
2632 totals.purchase_value_ = detail::magnitude_or_zero(page, "tot_pur_amt");
2633 totals.market_value_ = detail::magnitude_or_zero(page, "tot_evlt_amt");
2634 totals.unrealized_profit_loss_ = detail::signed_or_zero(page, "tot_evlt_pl");
2635 snapshot.totals_.push_back(totals);
2636 totals_seen = true;
2637 }
2638 const auto rows = page.if_contains("acnt_evlt_remn_indv_tot");
2639 if (!rows) {
2640 return;
2641 }
2642 if (!rows->is_array()) {
2643 throw std::runtime_error("acnt_evlt_remn_indv_tot is not an array");
2644 }
2645 for (const auto& value : rows->as_array()) {
2646 if (!value.is_object()) {
2647 throw std::runtime_error("holdings row is not an object");
2648 }
2649 auto position = parse_holding(value.as_object());
2650 if (duplicate_symbol(snapshot, position.instrument_.symbol_)) {
2651 throw std::runtime_error(
2652 "holdings listing reports " + position.instrument_.symbol_ +
2653 " on more than one row although it was requested summed (qry_tp=1)"
2654 );
2655 }
2656 snapshot.positions_.push_back(std::move(position));
2657 }
2658 }
2659
2660 // Returns whether `symbol` already has a row in `snapshot`.
2661 static bool duplicate_symbol(const HoldingsSnapshot& snapshot, const std::string& symbol) {
2662 for (const auto& position : snapshot.positions_) {
2663 if (position.instrument_.symbol_ == symbol) {
2664 return true;
2665 }
2666 }
2667 return false;
2668 }
2669
2670 // Normalizes one `kt00018` position row.
2671 static HoldingPosition parse_holding(const json::object& row) {
2672 HoldingPosition position;
2673 position.instrument_.country_ = Country::KR;
2674 // A holding is reported without a venue, so it carries the identity venue -- the combined one
2675 // -- and not whichever book `dmst_stex_tp` named. An identity that carried the request's venue
2676 // would stop matching the same instrument's working orders and quotes.
2677 position.instrument_.venue_ = Venue::COMBINED;
2678 position.instrument_.symbol_ = detail::bare_symbol(detail::text_field(row, "stk_cd"));
2679 position.name_ = detail::text_field(row, "stk_nm");
2680 position.quantity_ = detail::required_magnitude(row, "rmnd_qty");
2681 position.sellable_quantity_ = detail::magnitude(row, "trde_able_qty");
2682 position.average_purchase_price_ = detail::magnitude_or_zero(row, "pur_pric");
2683 position.last_price_ = detail::magnitude_or_zero(row, "cur_prc");
2684 position.purchase_value_ = Money{detail::magnitude_or_zero(row, "pur_amt"), Currency::krw()};
2685 position.market_value_ = Money{detail::magnitude_or_zero(row, "evlt_amt"), Currency::krw()};
2686 position.unrealized_profit_loss_ =
2687 Money{detail::signed_or_zero(row, "evltv_prft"), Currency::krw()};
2688 // `prft_rt` is a percentage with a sign; the normalized field is a fraction.
2689 if (const auto percent = detail::signed_value(row, "prft_rt")) {
2690 position.unrealized_return_ = broker_detail::percent_to_ratio(
2691 detail::text_field(row, "prft_rt")
2692 );
2693 }
2694 return position;
2695 }
2696
2697 // Issues the deposit inquiry and reduces it to what the port asked for.
2698 void start_buying_power(Currency currency, BuyingPowerHandler completion) {
2699 if (!(currency == Currency::krw())) {
2700 BrokerError error;
2701 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2702 error.reached_broker_ = false;
2703 error.broker_code_ = "unsupported_currency";
2704 error.message_ = "Kiwoom's domestic deposit inquiry answers in won";
2705 deliver(std::move(completion), Result<BuyingPower>(std::move(error)));
2706 return;
2707 }
2708 start_deposit(
2710 [self = shared_from_this(), completion = std::move(completion)](
2711 Result<KiwoomDeposit> result
2712 ) mutable {
2713 if (!result) {
2714 self->deliver(std::move(completion), Result<BuyingPower>(std::move(result.error())));
2715 return;
2716 }
2717 BuyingPower power;
2718 power.cash_ = Money{result.value().spendable_, result.value().currency_};
2719 power.received_at_ = result.value().received_at_;
2720 self->deliver(std::move(completion), Result<BuyingPower>(std::move(power)));
2721 }
2722 )
2723 );
2724 }
2725
2726 // Issues `kt00001` 예수금상세현황요청.
2727 void start_deposit(DepositHandler completion) {
2728 ensure_token(
2730 [self = shared_from_this(), completion = std::move(completion)](
2731 std::optional<BrokerError> token_error
2732 ) mutable {
2733 if (token_error) {
2734 self->deliver(std::move(completion), Result<KiwoomDeposit>(std::move(*token_error)));
2735 return;
2736 }
2737 json::object body;
2738 // `3:추정조회` includes today's unsettled activity; `2:일반조회` does not. The estimate is
2739 // what a figure named "what may be spent" has to mean intraday.
2740 body["qry_tp"] = "3";
2741 auto request = self->make_request(
2742 "kt00001", "/api/dostk/acnt", json::serialize(body),
2744 );
2745 self->http_->async_request(
2746 std::move(request),
2747 [self, completion = std::move(completion)](
2748 Result<network::HttpResponse> result
2749 ) mutable {
2750 self->deliver(std::move(completion), parse_deposit(std::move(result)));
2751 }
2752 );
2753 }
2754 )
2755 );
2756 }
2757
2758 // Normalizes `kt00001` into Kiwoom's own deposit detail.
2759 static Result<KiwoomDeposit> parse_deposit(Result<network::HttpResponse> result) {
2760 auto body = document(std::move(result));
2761 if (!body) {
2762 return Result<KiwoomDeposit>(std::move(body.error()));
2763 }
2764 try {
2765 const auto& row = body.value();
2766 KiwoomDeposit deposit;
2767 deposit.currency_ = Currency::krw();
2768 deposit.received_at_ = std::chrono::system_clock::now();
2769 const auto spendable = detail::signed_value(row, "100stk_ord_alow_amt");
2770 if (!spendable) {
2771 throw std::runtime_error("missing or unreadable field: 100stk_ord_alow_amt");
2772 }
2773 deposit.spendable_ = *spendable;
2774 deposit.orderable_ = detail::signed_or_zero(row, "ord_alow_amt");
2775 deposit.deposit_ = detail::signed_or_zero(row, "entr");
2776 deposit.settled_deposit_ = detail::signed_or_zero(row, "d2_entra");
2777 deposit.substitute_amount_ = detail::signed_or_zero(row, "repl_amt");
2778 deposit.withdrawable_ = detail::signed_or_zero(row, "pymn_alow_amt");
2779 return Result<KiwoomDeposit>(std::move(deposit));
2780 } catch (const std::exception& exception) {
2781 return Result<KiwoomDeposit>(protocol_error("invalid_deposit_response", exception));
2782 }
2783 }
2784
2785 // Issues `kt00010` 주문인출가능금액요청 for one instrument.
2786 void start_orderable_amount(
2787 InstrumentId instrument,
2788 OrderSide side,
2789 std::optional<Decimal64> price,
2790 OrderableHandler completion
2791 ) {
2792 if (auto invalid = validate_instrument(instrument)) {
2793 deliver(std::move(completion), Result<KiwoomOrderableAmount>(std::move(*invalid)));
2794 return;
2795 }
2796 ensure_token(
2798 [
2799 self = shared_from_this(), instrument = std::move(instrument), side,
2800 price = std::move(price), completion = std::move(completion)
2801 ](std::optional<BrokerError> token_error) mutable {
2802 if (token_error) {
2803 self->deliver(
2804 std::move(completion), Result<KiwoomOrderableAmount>(std::move(*token_error))
2805 );
2806 return;
2807 }
2808 json::object body;
2809 body["stk_cd"] = instrument.symbol_;
2810 body["trde_tp"] = side == OrderSide::SELL ? "1" : "2";
2811 body["uv"] = price ? price->str() : "";
2812 auto request = self->make_request(
2813 "kt00010", "/api/dostk/acnt", json::serialize(body),
2815 );
2816 self->http_->async_request(
2817 std::move(request),
2818 [self, completion = std::move(completion)](
2819 Result<network::HttpResponse> result
2820 ) mutable {
2821 self->deliver(std::move(completion), parse_orderable_amount(std::move(result)));
2822 }
2823 );
2824 }
2825 )
2826 );
2827 }
2828
2829 // Normalizes `kt00010` into the per-margin-tier amounts and quantities.
2830 static Result<KiwoomOrderableAmount> parse_orderable_amount(
2831 Result<network::HttpResponse> result
2832 ) {
2833 auto body = document(std::move(result));
2834 if (!body) {
2835 return Result<KiwoomOrderableAmount>(std::move(body.error()));
2836 }
2837 try {
2838 const auto& row = body.value();
2839 KiwoomOrderableAmount orderable;
2840 orderable.currency_ = Currency::krw();
2841 orderable.received_at_ = std::chrono::system_clock::now();
2842 // The amounts stay signed: a deposit figure can genuinely be negative when the account is
2843 // in 미수, and none of them is a price, so no direction prefix applies to them. The
2844 // quantities cannot be negative and are read as magnitudes.
2845 orderable.orderable_cash_ = detail::signed_or_zero(row, "ord_alowa");
2846 orderable.withdrawable_ = detail::signed_or_zero(row, "wthd_alowa");
2847 orderable.settled_deposit_ = detail::signed_or_zero(row, "d2entra");
2848 orderable.margin_reduction_applies_ =
2849 detail::text_field(row, "profa_rdex_aplc_tp") == "1";
2850 for (const int percent : {20, 30, 40, 50, 60, 100}) {
2851 KiwoomMarginTier tier;
2852 tier.margin_percent_ = percent;
2853 const std::string prefix = "profa_" + std::to_string(percent) + "ord_alow";
2854 tier.amount_ = detail::signed_value(row, prefix + "_amt");
2855 tier.quantity_ = detail::magnitude(row, prefix + "q");
2856 orderable.tiers_.push_back(tier);
2857 }
2858 KiwoomMarginTier reduced;
2859 reduced.margin_percent_ = 60;
2860 reduced.reduced_ = true;
2861 reduced.amount_ = detail::signed_value(row, "profa_rdex_60ord_alow_amt");
2862 reduced.quantity_ = detail::magnitude(row, "profa_rdex_60ord_alowq");
2863 orderable.tiers_.push_back(reduced);
2864 return Result<KiwoomOrderableAmount>(std::move(orderable));
2865 } catch (const std::exception& exception) {
2867 protocol_error("invalid_orderable_amount_response", exception)
2868 );
2869 }
2870 }
2871
2872 // Issues `ka00001` 계좌번호조회, which answers with the one account the credential drives.
2873 void start_accounts(AccountsHandler completion) {
2874 using Accounts = std::vector<KiwoomAccountInfo>;
2875 ensure_token(
2877 [self = shared_from_this(), completion = std::move(completion)](
2878 std::optional<BrokerError> token_error
2879 ) mutable {
2880 if (token_error) {
2881 self->deliver(std::move(completion), Result<Accounts>(std::move(*token_error)));
2882 return;
2883 }
2884 auto request = self->make_request(
2885 "ka00001", "/api/dostk/acnt", "{}", network::RateLimitGroup::ACCOUNT
2886 );
2887 self->http_->async_request(
2888 std::move(request),
2889 [self, completion = std::move(completion)](
2890 Result<network::HttpResponse> result
2891 ) mutable {
2892 self->deliver(std::move(completion), parse_accounts(std::move(result)));
2893 }
2894 );
2895 }
2896 )
2897 );
2898 }
2899
2900 // Normalizes `ka00001` into a listing of one.
2901 //
2902 // A listing rather than a single value, because that is the port's shape — and the shape is
2903 // honest here: Kiwoom answers with the account the credential drives and there is no other to
2904 // choose between.
2905 static Result<std::vector<KiwoomAccountInfo>> parse_accounts(
2906 Result<network::HttpResponse> result
2907 ) {
2908 using Accounts = std::vector<KiwoomAccountInfo>;
2909 auto body = document(std::move(result));
2910 if (!body) {
2911 return Result<Accounts>(std::move(body.error()));
2912 }
2913 try {
2914 const auto number = detail::text_field(body.value(), "acctNo");
2915 if (number.empty()) {
2916 throw std::runtime_error("missing string field: acctNo");
2917 }
2918 Accounts accounts;
2919 accounts.push_back(KiwoomAccountInfo{number});
2920 return Result<Accounts>(std::move(accounts));
2921 } catch (const std::exception& exception) {
2922 return Result<Accounts>(protocol_error("invalid_accounts_response", exception));
2923 }
2924 }
2925
2926 // Returns an error when a quantity is not a whole number of shares.
2927 //
2928 // `allow_zero` is Kiwoom's all-remaining value on an amendment, which it documents as the
2929 // quantity `0`. A *placement* of zero shares is still refused.
2930 static std::optional<BrokerError> validate_quantity(
2931 const Decimal64& quantity,
2932 bool allow_zero = false
2933 ) {
2934 const bool positive = quantity.coefficient() > 0;
2935 if ((positive || (allow_zero && quantity.coefficient() == 0)) && quantity.scale() == 0) {
2936 return std::nullopt;
2937 }
2938 BrokerError error;
2939 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2940 error.reached_broker_ = false;
2941 error.broker_code_ = "invalid_quantity";
2942 error.message_ = allow_zero ?
2943 "a Kiwoom amendment quantity must be a whole number of shares, or zero for all remaining" :
2944 "a Kiwoom order quantity must be a positive whole number of shares";
2945 return error;
2946 }
2947
2948 // Returns an error when a price does not match its price basis.
2949 //
2950 // `carries_caller_price`, not `== LIMIT`: 조건부지정가 is a limit instruction until the close, and
2951 // treating it as unpriced is how KIS's adapter once transmitted `ORD_UNPR="0"` — an order at zero
2952 // rather than at the stated limit.
2953 static std::optional<BrokerError> validate_price(
2954 OrderType type,
2955 const std::optional<Decimal64>& price
2956 ) {
2957 BrokerError error;
2958 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2959 error.reached_broker_ = false;
2960 error.broker_code_ = "invalid_price";
2961 if (carries_caller_price(type)) {
2962 if (!price) {
2963 error.message_ = "a Kiwoom order of this price basis requires a price";
2964 return error;
2965 }
2966 if (price->coefficient() <= 0) {
2967 error.message_ = "a Kiwoom order price must be positive";
2968 return error;
2969 }
2970 if (price->scale() != 0) {
2971 error.message_ = "a Kiwoom domestic order price must be a whole number of won";
2972 return error;
2973 }
2974 return std::nullopt;
2975 }
2976 if (price) {
2977 error.message_ =
2978 "Kiwoom takes no price for this basis; a market, best-limit, primary-limit, mid-price or "
2979 "closing-price order is priced by the book";
2980 return error;
2981 }
2982 return std::nullopt;
2983 }
2984
2985 // Returns an error when a Kiwoom order names no venue.
2986 //
2987 // **Kiwoom documents `dmst_stex_tp` with no default**, so unlike KIS there is no "omitted means
2988 // KRX" rule this adapter could record and journal. An order that names no venue is refused before
2989 // any I/O rather than sent to a venue this library chose, because recovery has to be able to say
2990 // where an order went and a guessed venue makes that a fabrication.
2991 static std::optional<BrokerError> validate_venue(Venue venue) {
2992 if (venue == Venue::KRX || venue == Venue::NXT || venue == Venue::COMBINED) {
2993 return std::nullopt;
2994 }
2995 BrokerError error;
2996 error.category_ = ErrorCategory::INVALID_ARGUMENT;
2997 error.reached_broker_ = false;
2998 error.broker_code_ = "venue_required";
2999 error.message_ =
3000 "Kiwoom requires dmst_stex_tp on every order and documents no default; set "
3001 "OrderRequest::venue_ to KRX, NXT or COMBINED (Kiwoom's SOR), or, for a strategy runner, "
3002 "bind it with a default route (live::bind / bind_journaled, SessionExecutor)";
3003 return error;
3004 }
3005
3006 // The identity venue this adapter reports every Korean instrument at.
3007 static constexpr Venue instrument_venue() { return Venue::COMBINED; }
3008
3009 // Returns an error when an order cannot be represented as a Kiwoom instruction.
3010 static std::optional<BrokerError> validate_order(
3011 KiwoomPositionScope scope,
3012 const OrderRequest& order,
3013 std::string& division
3014 ) {
3015 if (auto error = unsupported_client_order_id(order)) {
3016 return error;
3017 }
3018 if (auto error = validate_instrument(order.instrument_)) {
3019 return error;
3020 }
3021 // Kiwoom reports every Korean instrument at one identity venue, and a holding carries no venue
3022 // at all -- so an order whose `instrument_.venue_` said anything else would not be the same
3023 // `InstrumentId` as its own holding, and `Portfolio::working_quantity` would read zero resting
3024 // quantity for it. A caller who set it there means *routing*, which is `OrderRequest::venue_`.
3025 if (order.instrument_.venue_ != instrument_venue()) {
3026 BrokerError error;
3027 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3028 error.reached_broker_ = false;
3029 error.broker_code_ = "invalid_instrument";
3030 error.message_ =
3031 "this adapter identifies a Korean instrument by its combined venue; set "
3032 "OrderRequest::venue_ to route the order to a particular exchange instead of naming one on "
3033 "the instrument";
3034 return error;
3035 }
3036 if (auto error = validate_quantity(order.quantity_)) {
3037 return error;
3038 }
3039 if (auto error = validate_price(order.type_, order.price_)) {
3040 return error;
3041 }
3042 if (auto error = invalid_trigger(order.type_, order.trigger_price_)) {
3043 return error;
3044 }
3045 if (auto error = validate_venue(order.venue_)) {
3046 return error;
3047 }
3048 if (auto error = order_division(order.type_, order.time_in_force_, order.session_, division)) {
3049 return error;
3050 }
3051 // **Last**, so a caller hears about their own order's problems before this one. An order that
3052 // is malformed is malformed whatever this library knows about the account, and reporting the
3053 // holdings gap first would bury the mistake the caller can actually fix.
3054 return holdings_scope_refusal(scope);
3055 }
3056
3057 // Returns an error when an amendment cannot be represented as a Kiwoom instruction.
3058 //
3059 // Its own function for the reason the KIS adapter gives: the decision a test needs to exercise is
3060 // the one the submission path takes, not one a test re-derives from the same inputs.
3061 static std::optional<BrokerError> validate_amendment(
3062 KiwoomPositionScope scope,
3063 const KiwoomAmendment& amendment,
3064 bool cancel
3065 ) {
3066 if (auto error = validate_quantity(amendment.quantity_, true)) {
3067 return error;
3068 }
3069 if (auto error = validate_venue(amendment.venue_)) {
3070 return error;
3071 }
3072 BrokerError error;
3073 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3074 error.reached_broker_ = false;
3075 if (amendment.order_.order_number_.empty()) {
3076 error.broker_code_ = "invalid_order_reference";
3077 error.message_ = "a Kiwoom amendment requires the original order number";
3078 return error;
3079 }
3080 if (amendment.order_.symbol_.empty()) {
3081 // kt10002 and kt10003 both require `stk_cd` beside `orig_ord_no`. A reference that lost the
3082 // symbol cannot address an order at all, which is why `KiwoomOrderRef` carries both.
3083 error.broker_code_ = "invalid_order_reference";
3084 error.message_ =
3085 "a Kiwoom amendment requires the order's symbol beside its number; both are part of the "
3086 "reference this adapter issues";
3087 return error;
3088 }
3089 if (cancel && amendment.price_) {
3090 error.broker_code_ = "invalid_price";
3091 error.message_ = "Kiwoom's cancellation carries a quantity and no price";
3092 return error;
3093 }
3094 if (cancel && amendment.condition_price_) {
3095 // `kt10003` has no trigger field, so a stated trigger cannot be sent -- and acknowledging
3096 // the instruction after dropping it would report success for a change that did not happen,
3097 // which is the same failure refusing a price on a cancellation exists to stop. The
3098 // normalized helpers never set this, so no port path is affected.
3099 error.broker_code_ = "invalid_condition_price";
3100 error.message_ =
3101 "Kiwoom's cancellation carries a quantity and no trigger price, so it cannot change an "
3102 "order's stop condition on the way out; cancel the order and place a new one instead";
3103 return error;
3104 }
3105 if (!cancel && amendment.price_ && amendment.price_->scale() != 0) {
3106 error.broker_code_ = "invalid_price";
3107 error.message_ = "a Kiwoom domestic order price must be a whole number of won";
3108 return error;
3109 }
3110 // Only the value: whether the order's basis wants a trigger at all is decided where the basis
3111 // is known, `BrokerTraits<KiwoomClient>::to_amendment`, because `kt10002` carries none.
3112 if (!cancel && amendment.condition_price_ && !valid_trigger_value(*amendment.condition_price_)) {
3113 error.broker_code_ = "invalid_trigger_price";
3114 error.message_ = "a Kiwoom trigger price must be a positive whole number of won";
3115 return error;
3116 }
3117 if (!cancel) {
3118 // Last, and cancellations excepted; see `unestablished_holdings_scope`. A replacement can
3119 // move a resting buy to a fillable price, which opens or enlarges the position this library
3120 // cannot measure. A cancellation only ever reduces what is resting.
3121 return holdings_scope_refusal(scope);
3122 }
3123 return std::nullopt;
3124 }
3125
3126 // Builds `kt10000`/`kt10001`'s body. The side selects the endpoint, not a field.
3127 static network::HttpRequest place_order_request(
3128 const KiwoomClient& client,
3129 const OrderRequest& order,
3130 std::string_view division
3131 ) {
3132 json::object body;
3133 body["dmst_stex_tp"] = exchange_code(order.venue_);
3134 body["stk_cd"] = order.instrument_.symbol_;
3135 body["ord_qty"] = order.quantity_.str();
3136 // An unpriced basis is sent with an empty `ord_uv` rather than a zero: `carries_caller_price`
3137 // decides which, and a zero would be a price the caller did not state.
3138 body["ord_uv"] = order.price_ ? order.price_->str() : "";
3139 body["trde_tp"] = std::string(division);
3140 // `cond_uv` is the stop trigger. `invalid_trigger` has restricted it to `28`, so every other
3141 // body carries the `""` it always has.
3142 body["cond_uv"] = order.trigger_price_ ? order.trigger_price_->str() : "";
3143 return client.make_request(
3144 order.side_ == OrderSide::BUY ? "kt10000" : "kt10001",
3145 "/api/dostk/ordr",
3146 json::serialize(body),
3148 );
3149 }
3150
3151 // Builds `kt10002`'s or `kt10003`'s body.
3152 static network::HttpRequest amend_order_request(
3153 const KiwoomClient& client,
3154 const KiwoomAmendment& amendment,
3155 bool cancel
3156 ) {
3157 json::object body;
3158 body["dmst_stex_tp"] = exchange_code(amendment.venue_);
3159 body["orig_ord_no"] = amendment.order_.order_number_;
3160 body["stk_cd"] = amendment.order_.symbol_;
3161 if (cancel) {
3162 body["cncl_qty"] = amendment.quantity_.str();
3163 } else {
3164 body["mdfy_qty"] = amendment.quantity_.str();
3165 body["mdfy_uv"] = amendment.price_ ? amendment.price_->str() : "";
3166 body["mdfy_cond_uv"] =
3167 amendment.condition_price_ ? amendment.condition_price_->str() : "";
3168 }
3169 return client.make_request(
3170 cancel ? "kt10003" : "kt10002",
3171 "/api/dostk/ordr",
3172 json::serialize(body),
3174 );
3175 }
3176
3177 // Validates and submits a placement.
3178 void start_place_order(OrderRequest order, OrderAckHandler completion) {
3179 std::string division;
3180 if (auto invalid = validate_order(config_.holdings_position_scope_, order, division)) {
3181 deliver(std::move(completion), Result<KiwoomOrderAck>(std::move(*invalid)));
3182 return;
3183 }
3184 ensure_token(
3186 [
3187 self = shared_from_this(), order = std::move(order), division = std::move(division),
3188 completion = std::move(completion)
3189 ](std::optional<BrokerError> token_error) mutable {
3190 if (token_error) {
3191 self->deliver(
3192 std::move(completion), Result<KiwoomOrderAck>(std::move(*token_error))
3193 );
3194 return;
3195 }
3196 auto request = place_order_request(*self, order, division);
3197 const std::string symbol = order.instrument_.symbol_;
3198 self->http_->async_request(
3199 std::move(request),
3200 [self, symbol, demo = self->config_.is_demo_, completion = std::move(completion)](
3201 Result<network::HttpResponse> result
3202 ) mutable {
3203 self->deliver(
3204 std::move(completion),
3205 parse_order_ack(symbol, std::move(result), KiwoomOrderOperation::PLACE, demo)
3206 );
3207 }
3208 );
3209 }
3210 )
3211 );
3212 }
3213
3214 // Validates and submits a modification or a cancellation.
3215 void start_amend_order(KiwoomAmendment amendment, bool cancel, OrderAckHandler completion) {
3216 if (auto invalid = validate_amendment(config_.holdings_position_scope_, amendment, cancel)) {
3217 deliver(std::move(completion), Result<KiwoomOrderAck>(std::move(*invalid)));
3218 return;
3219 }
3220 ensure_token(
3222 [
3223 self = shared_from_this(), amendment = std::move(amendment), cancel,
3224 completion = std::move(completion)
3225 ](std::optional<BrokerError> token_error) mutable {
3226 if (token_error) {
3227 self->deliver(
3228 std::move(completion), Result<KiwoomOrderAck>(std::move(*token_error))
3229 );
3230 return;
3231 }
3232 auto request = amend_order_request(*self, amendment, cancel);
3233 const std::string symbol = amendment.order_.symbol_;
3234 const auto operation = cancel ? KiwoomOrderOperation::CANCEL : KiwoomOrderOperation::AMEND;
3235 self->http_->async_request(
3236 std::move(request),
3237 [self, symbol, operation, demo = self->config_.is_demo_, completion = std::move(completion)](
3238 Result<network::HttpResponse> result
3239 ) mutable {
3240 self->deliver(
3241 std::move(completion), parse_order_ack(symbol, std::move(result), operation, demo)
3242 );
3243 }
3244 );
3245 }
3246 )
3247 );
3248 }
3249
3250 // Normalizes an order response into an acknowledgement and a reference.
3251 //
3252 // The symbol comes from the request rather than the response, because Kiwoom's order responses
3253 // carry `ord_no` and the venue and no `stk_cd` at all — and the reference needs the symbol to be
3254 // usable for a later amendment. `operation` and `demo` describe the request this reply answers;
3255 // left out, no refusal is recognized.
3256 static Result<KiwoomOrderAck> parse_order_ack(
3257 const std::string& symbol,
3258 Result<network::HttpResponse> result,
3260 bool demo = false
3261 ) {
3262 auto body = document(std::move(result), operation, demo);
3263 if (!body) {
3264 return Result<KiwoomOrderAck>(std::move(body.error()));
3265 }
3266 try {
3267 const auto& row = body.value();
3268 const auto number = detail::text_field(row, "ord_no");
3269 if (number.empty()) {
3270 throw std::runtime_error("missing string field: ord_no");
3271 }
3272 KiwoomOrderAck ack;
3273 ack.ack_.order_id_ = number;
3274 ack.ack_.accepted_at_ = std::chrono::system_clock::now();
3275 ack.order_ = KiwoomOrderRef{number, symbol};
3276 ack.base_order_number_ = detail::text_field(row, "base_orig_ord_no");
3277 if (auto quantity = detail::magnitude(row, "mdfy_qty")) {
3278 ack.amended_quantity_ = *quantity;
3279 } else if (auto canceled = detail::magnitude(row, "cncl_qty")) {
3280 ack.amended_quantity_ = *canceled;
3281 }
3282 return Result<KiwoomOrderAck>(std::move(ack));
3283 } catch (const std::exception& exception) {
3284 return Result<KiwoomOrderAck>(protocol_error("invalid_order_response", exception));
3285 }
3286 }
3287
3288 // One amendable-order listing spanning however many `ka10075` pages it needs.
3289 struct OpenOrderOperation {
3290 KiwoomOpenOrderPage page_;
3291 std::string next_key_;
3292 int pages_{0};
3293 OpenOrderHandler completion_;
3294
3295 explicit OpenOrderOperation(OpenOrderHandler completion) : completion_(std::move(completion)) {}
3296 };
3297
3298 // Starts the amendable-order listing.
3299 void start_open_orders(OpenOrderHandler completion) {
3300 auto operation = std::make_shared<OpenOrderOperation>(std::move(completion));
3301 request_open_orders(operation);
3302 }
3303
3304 // Issues one page of `ka10075` 미체결요청.
3305 void request_open_orders(std::shared_ptr<OpenOrderOperation> operation) {
3306 ensure_token(
3308 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
3309 if (token_error) {
3310 self->deliver(
3311 std::move(operation->completion_),
3312 Result<KiwoomOpenOrderPage>(std::move(*token_error))
3313 );
3314 return;
3315 }
3316 json::object body;
3317 body["all_stk_tp"] = "0";
3318 body["trde_tp"] = "0";
3319 body["stk_cd"] = "";
3320 // `0 : 통합` -- unlike the balance inquiry, the unfilled listing *does* publish an
3321 // all-venue value, so a working order resting at either venue is listed. Nothing is
3322 // scoped here and nothing needs to be.
3323 body["stex_tp"] = "0";
3324 auto request = self->make_request(
3325 "ka10075", "/api/dostk/acnt", json::serialize(body),
3326 network::RateLimitGroup::ACCOUNT, operation->next_key_
3327 );
3328 self->http_->async_request(
3329 std::move(request),
3330 [self, operation](Result<network::HttpResponse> result) mutable {
3331 net::post(self->strand_, [self, operation, result = std::move(result)]() mutable {
3332 self->handle_open_orders_page(operation, std::move(result));
3333 });
3334 }
3335 );
3336 }
3337 )
3338 );
3339 }
3340
3341 // Accumulates one page of amendable orders and either continues or completes.
3342 void handle_open_orders_page(
3343 const std::shared_ptr<OpenOrderOperation>& operation,
3344 Result<network::HttpResponse> result
3345 ) {
3346 if (!result) {
3347 deliver(
3348 std::move(operation->completion_), Result<KiwoomOpenOrderPage>(std::move(result.error()))
3349 );
3350 return;
3351 }
3352 const auto next_key = continuation_key(result.value());
3353 auto body = document(std::move(result));
3354 if (!body) {
3355 deliver(
3356 std::move(operation->completion_), Result<KiwoomOpenOrderPage>(std::move(body.error()))
3357 );
3358 return;
3359 }
3360 const std::size_t before = operation->page_.orders_.size();
3361 try {
3362 const auto rows = body.value().if_contains("oso");
3363 if (rows != nullptr) {
3364 if (!rows->is_array()) {
3365 throw std::runtime_error("oso is not an array");
3366 }
3367 for (const auto& value : rows->as_array()) {
3368 if (!value.is_object()) {
3369 throw std::runtime_error("oso row is not an object");
3370 }
3371 operation->page_.orders_.push_back(parse_open_order(value.as_object()));
3372 }
3373 }
3374 } catch (const std::exception& exception) {
3375 deliver(
3376 std::move(operation->completion_),
3377 Result<KiwoomOpenOrderPage>(protocol_error("invalid_open_orders_response", exception))
3378 );
3379 return;
3380 }
3381 const bool grew = operation->page_.orders_.size() > before;
3382 switch (advance_page(operation->next_key_, operation->pages_, grew, std::move(next_key))) {
3383 case PageStep::FINISH:
3384 operation->page_.received_at_ = std::chrono::system_clock::now();
3385 deliver(
3386 std::move(operation->completion_),
3387 Result<KiwoomOpenOrderPage>(std::move(operation->page_))
3388 );
3389 return;
3390 case PageStep::INCOMPLETE:
3391 deliver(
3392 std::move(operation->completion_),
3393 Result<KiwoomOpenOrderPage>(incomplete_listing("working orders", operation->pages_))
3394 );
3395 return;
3396 case PageStep::CONTINUE:
3397 request_open_orders(operation);
3398 return;
3399 }
3400 }
3401
3402 // Returns the side a row states, and throws when it states none this adapter can read.
3403 //
3404 // Throwing fails the listing, which is deliberate and is the one place this adapter does *not*
3405 // tolerate an unrecognized value. Everywhere else an unknown wire value becomes `UNKNOWN` with
3406 // the raw text preserved, because both specifications require a client to tolerate one and a
3407 // single odd row must not cost a whole listing. `OrderSide` has no `UNKNOWN`: the choice is
3408 // between failing and asserting a side, and asserting the wrong one puts a resting sell into the
3409 // picture as a buy, where `RiskGuard` will not deduct it from what may be sold. A listing that
3410 // fails is a poll that fails, which the runner already treats as a reason to hold.
3411 static OrderSide read_side(const std::string& label) {
3412 if (const auto side = normalized_order_side(label)) {
3413 return *side;
3414 }
3415 throw std::runtime_error(
3416 "order row states a side this adapter cannot read: '" + label +
3417 "'; it names neither 매수 nor 매도"
3418 );
3419 }
3420
3421 // Normalizes one `ka10075` unfilled row.
3422 static KiwoomOpenOrder parse_open_order(const json::object& row) {
3423 KiwoomOpenOrder open_order;
3424 const auto number = detail::text_field(row, "ord_no");
3425 const auto symbol = detail::bare_symbol(detail::text_field(row, "stk_cd"));
3426 open_order.order_ref_ = KiwoomOrderRef{number, symbol};
3427 open_order.original_order_number_ = detail::text_field(row, "orig_ord_no");
3428 open_order.amendable_quantity_ = detail::magnitude(row, "oso_qty");
3429 open_order.shape_name_ = detail::text_field(row, "trde_tp");
3430
3431 OrderSnapshot& order = open_order.order_;
3432 order.order_id_ = number;
3433 order.instrument_.country_ = Country::KR;
3434 order.instrument_.venue_ = instrument_venue();
3435 order.instrument_.symbol_ = symbol;
3436 order.name_ = detail::text_field(row, "stk_nm");
3437 order.currency_ = Currency::krw();
3438 order.side_ = read_side(detail::text_field(row, "io_tp_nm"));
3439 apply_shape(open_order.shape_name_, order);
3440 order.status_ = normalized_order_status(detail::text_field(row, "ord_stt"));
3441 order.venue_ = listed_venue(
3442 detail::text_field(row, "stex_tp"), detail::text_field(row, "sor_yn")
3443 );
3444 order.quantity_ = detail::required_magnitude(row, "ord_qty");
3445 order.price_ = positive(detail::magnitude(row, "ord_pric"));
3446 order.trigger_price_ = reported_trigger(row, "stop_pric");
3447 // `tm` is `HHmmss` with no date. The calendar day is today in Seoul, and only assumed: the demo
3448 // server has listed an order of the previous day after midnight. The history path takes its date
3449 // from the request; where both carry the order, a sweep drops this row and keeps the dated one.
3450 order.ordered_at_ = detail::seoul_timestamp(today_in_seoul(), detail::text_field(row, "tm"));
3451 order.ordered_date_assumed_ = true;
3452 order.execution_.filled_quantity_ = detail::required_magnitude(row, "cntr_qty");
3453 resolve_open_status(open_order.amendable_quantity_, order);
3454 return open_order;
3455 }
3456
3457 // Refuses to report an order closed on a row that says quantity is still outstanding.
3458 //
3459 // ## The contradiction
3460 //
3461 // `normalized_order_status` is shared with the history parser, so it maps `체결` to `FILLED` and
3462 // `취소` to `CANCELED` -- correct there, and on *this* endpoint those words would contradict the
3463 // row they arrive on. `ka10075` is the 미체결 listing, its array is `oso`, and `oso_qty` is the
3464 // quantity still outstanding. The specification documents `ord_stt` here with the single value
3465 // `접수`, so nothing establishes that Kiwoom ever sends the other two on this inquiry.
3466 //
3467 // **This is therefore a defensive rule, not a repair of an observed incident.** What is
3468 // established is only that if such a row arrived, the adapter would mishandle it, and that the
3469 // response carries the field needed to refuse to.
3470 //
3471 // ## Why a wrong answer here is not cosmetic
3472 //
3473 // `strategy::order_is_open` is false for `FILLED` and `CANCELED`, and
3474 // `Portfolio::working_quantity` skips any row that is not open. A resting order reported closed
3475 // would read as **zero** resting quantity: no sellable deduction, no working-order component in
3476 // the position ceiling, and a reconciliation that plans a second order beside the one already
3477 // out.
3478 //
3479 // ## The rule
3480 //
3481 // A word and a number disagree, and only one of them can be checked, so the number wins:
3482 // a positive `oso_qty` means the order is still open whatever `ord_stt` says. Which open status
3483 // follows from the filled quantity, the same way KIS's parser answers the same question --
3484 // `PARTIAL_FILLED` where something has filled, `PENDING` where nothing has. A row reporting
3485 // nothing outstanding keeps whatever the broker said, so this narrows a contradiction rather than
3486 // refusing to believe the broker.
3487 //
3488 // Applied here rather than inside `normalized_order_status`, because the contradicting quantity
3489 // exists only on this endpoint and the history parser reads those words legitimately.
3490 //
3491 // The two statuses tested are the two `normalized_order_status` can produce that mean the order
3492 // is over, named here rather than asked of `strategy::order_is_open`: an adapter must not depend
3493 // on the strategy layer, and `core/` is where the two layers may meet.
3494 static void resolve_open_status(
3495 const std::optional<Decimal64>& amendable, OrderSnapshot& order
3496 ) {
3497 const bool reported_over =
3498 order.status_ == OrderStatus::FILLED || order.status_ == OrderStatus::CANCELED;
3499 if (!reported_over) {
3500 return;
3501 }
3502 if (!amendable || amendable->coefficient() <= 0) {
3503 return;
3504 }
3505 order.status_ = order.execution_.filled_quantity_.coefficient() > 0 ?
3506 OrderStatus::PARTIAL_FILLED :
3507 OrderStatus::PENDING;
3508 }
3509
3510 // Writes the three normalized shape facts from Kiwoom's Korean label.
3511 //
3512 // **`OrderSnapshot::shape_code_` is deliberately left empty.** Its documentation calls it "the
3513 // broker's own order-shape code, preserved verbatim", and Kiwoom does not send a code on a read
3514 // path at all — it sends a Korean display name, which `Code<8>` cannot hold. Writing the numeric
3515 // code this adapter *inferred* from the name would put something the broker never sent into a
3516 // field defined as what it did. The raw label is kept on `KiwoomOpenOrder::shape_name_` instead.
3517 //
3518 // A label the table does not know leaves all three fields `UNKNOWN` and does not fail the row,
3519 // because both specifications this library follows require a client to tolerate a value it does
3520 // not recognize, and one odd row must not fail a whole listing.
3521 static void apply_shape(std::string_view label, OrderSnapshot& order) {
3522 const auto* row = shape_by_name(label);
3523 if (row == nullptr) {
3524 return;
3525 }
3526 order.type_ = row->type_;
3527 order.time_in_force_ = row->time_in_force_;
3528 order.session_ = row->session_;
3529 }
3530
3531 // What `close_cancelled_originals` needs of a `kt00007` row beyond its normalized snapshot.
3532 struct HistoryLink {
3533 // The order a completed cancellation row names in `ori_ord`; empty for any other row.
3534 std::string cancels_;
3535 // Whether the row is an ordinary order (`mdfy_cncl` empty or 일반), not a cancel or a modification.
3536 bool plain_{false};
3537 // `ord_remnq`, or nothing if absent or unreadable.
3538 std::optional<Decimal64> remaining_;
3539 };
3540
3541 // One order-history listing spanning however many `kt00007` pages it needs.
3542 struct HistoryOperation {
3543 KiwoomHistoryQuery query_;
3544 OrderPage page_;
3545 // One per row of `page_.orders_`, for `close_cancelled_originals`.
3546 std::vector<HistoryLink> links_;
3547 std::string next_key_;
3548 int pages_{0};
3549 OrderPageHandler completion_;
3550
3551 HistoryOperation(KiwoomHistoryQuery query, OrderPageHandler completion)
3552 : query_(std::move(query)), completion_(std::move(completion)) {}
3553 };
3554
3555 // Starts the order-history listing for one calendar date.
3556 void start_order_history(KiwoomHistoryQuery query, OrderPageHandler completion) {
3557 if (!is_valid_date(query.date_)) {
3558 BrokerError error;
3559 error.category_ = ErrorCategory::INVALID_ARGUMENT;
3560 error.reached_broker_ = false;
3561 error.broker_code_ = "invalid_date";
3562 error.message_ = "a Kiwoom order-history inquiry requires a valid Gregorian date";
3563 deliver(std::move(completion), Result<OrderPage>(std::move(error)));
3564 return;
3565 }
3566 auto operation = std::make_shared<HistoryOperation>(std::move(query), std::move(completion));
3567 request_order_history(operation);
3568 }
3569
3570 // Issues one page of `kt00007` 계좌별주문체결내역상세요청.
3571 void request_order_history(std::shared_ptr<HistoryOperation> operation) {
3572 ensure_token(
3574 [self = shared_from_this(), operation](std::optional<BrokerError> token_error) mutable {
3575 if (token_error) {
3576 self->deliver(
3577 std::move(operation->completion_), Result<OrderPage>(std::move(*token_error))
3578 );
3579 return;
3580 }
3581 json::object body;
3582 body["ord_dt"] = detail::compact_text(operation->query_.date_);
3583 // `1:주문순` lists in order-number order, which is the order the rows were created in.
3584 body["qry_tp"] = "1";
3585 body["stk_bond_tp"] = "1";
3586 body["sell_tp"] = "0";
3587 body["stk_cd"] = operation->query_.symbol_;
3588 body["fr_ord_no"] = "";
3589 // `%` is 전체: every venue. Unlike the balance inquiry, this one publishes an all-venue
3590 // value, so a history listing is never scoped to one book.
3591 body["dmst_stex_tp"] = "%";
3592 auto request = self->make_request(
3593 "kt00007", "/api/dostk/acnt", json::serialize(body),
3594 network::RateLimitGroup::ACCOUNT, operation->next_key_
3595 );
3596 self->http_->async_request(
3597 std::move(request),
3598 [self, operation](Result<network::HttpResponse> result) mutable {
3599 net::post(self->strand_, [self, operation, result = std::move(result)]() mutable {
3600 self->handle_order_history_page(operation, std::move(result));
3601 });
3602 }
3603 );
3604 }
3605 )
3606 );
3607 }
3608
3609 // Accumulates one page of order history and either continues or completes.
3610 void handle_order_history_page(
3611 const std::shared_ptr<HistoryOperation>& operation,
3612 Result<network::HttpResponse> result
3613 ) {
3614 if (!result) {
3615 deliver(std::move(operation->completion_), Result<OrderPage>(std::move(result.error())));
3616 return;
3617 }
3618 const auto next_key = continuation_key(result.value());
3619 auto body = document(std::move(result));
3620 if (!body) {
3621 deliver(std::move(operation->completion_), Result<OrderPage>(std::move(body.error())));
3622 return;
3623 }
3624 const std::size_t before = operation->page_.orders_.size();
3625 try {
3626 const auto rows = body.value().if_contains("acnt_ord_cntr_prps_dtl");
3627 if (rows != nullptr) {
3628 if (!rows->is_array()) {
3629 throw std::runtime_error("acnt_ord_cntr_prps_dtl is not an array");
3630 }
3631 for (const auto& value : rows->as_array()) {
3632 if (!value.is_object()) {
3633 throw std::runtime_error("order history row is not an object");
3634 }
3635 operation->page_.orders_.push_back(
3636 parse_history_row(value.as_object(), operation->query_.date_, config_.is_demo_)
3637 );
3638 operation->links_.push_back(history_link(value.as_object(), config_.is_demo_));
3639 }
3640 }
3641 } catch (const std::exception& exception) {
3642 deliver(
3643 std::move(operation->completion_),
3644 Result<OrderPage>(protocol_error("invalid_order_history_response", exception))
3645 );
3646 return;
3647 }
3648 const bool grew = operation->page_.orders_.size() > before;
3649 switch (advance_page(operation->next_key_, operation->pages_, grew, std::move(next_key))) {
3650 case PageStep::FINISH:
3651 operation->page_.received_at_ = std::chrono::system_clock::now();
3652 // Every page has been walked, so there is no cursor for a caller to continue from.
3653 // `has_history_cursor` says the same thing at compile time.
3654 operation->page_.next_cursor_.reset();
3655 // After the last page: a cancellation and its original may be on different pages.
3656 close_cancelled_originals(operation->page_.orders_, operation->links_);
3657 deliver(std::move(operation->completion_), Result<OrderPage>(std::move(operation->page_)));
3658 return;
3659 case PageStep::INCOMPLETE:
3660 deliver(
3661 std::move(operation->completion_),
3662 Result<OrderPage>(incomplete_listing("order history", operation->pages_))
3663 );
3664 return;
3665 case PageStep::CONTINUE:
3666 request_order_history(operation);
3667 return;
3668 }
3669 }
3670
3671 // Reads a `kt00007` row's link to the order it cancels.
3672 //
3673 // A cancellation row is `mdfy_cncl` 취소확인, the documented word. The demo server writes 취소 with
3674 // `acpt_tp` 주문완료 instead (observed 2026-10-06 and 2026-10-08); that tuple counts on the demo only,
3675 // since a bare 취소 names the kind of amendment, not a completed one.
3676 static HistoryLink history_link(const json::object& row, bool demo) {
3677 HistoryLink link;
3678 const auto amendment = detail::text_field(row, "mdfy_cncl");
3679 link.plain_ = amendment.empty() || amendment == "일반";
3680 link.remaining_ = detail::magnitude(row, "ord_remnq");
3681 const bool cancellation = amendment == "취소확인" ||
3682 (demo && amendment == "취소" && detail::text_field(row, "acpt_tp") == "주문완료");
3683 if (cancellation) {
3684 const auto original = detail::text_field(row, "ori_ord");
3685 if (original.find_first_not_of('0') != std::string::npos) {
3686 link.cancels_ = original;
3687 }
3688 }
3689 return link;
3690 }
3691
3692 // Marks an original order `CANCELED` when a cancellation row names it and nothing of it remains.
3693 //
3694 // `kt00007` keeps the original's own row as an ordinary order with no state word for the
3695 // cancellation (`acpt_tp` 주문완료 on the demo), so without this it reads `UNKNOWN` and never ends.
3696 // The original must be the only row with that number, plain, of the same symbol and side, with
3697 // `ord_remnq` present and zero (a refused or pending cancel leaves a remainder) and less filled than
3698 // ordered (a cancel that lost to a full fill stays `FILLED`). Its `cntr_qty` stays the filled quantity.
3699 static void close_cancelled_originals(std::vector<OrderSnapshot>& orders, const std::vector<HistoryLink>& links) {
3700 for (std::size_t cancel = 0; cancel < orders.size() && cancel < links.size(); ++cancel) {
3701 if (links[cancel].cancels_.empty()) {
3702 continue;
3703 }
3704 std::optional<std::size_t> found;
3705 bool ambiguous = false;
3706 for (std::size_t at = 0; at < orders.size() && at < links.size(); ++at) {
3707 if (orders[at].order_id_ != links[cancel].cancels_) {
3708 continue;
3709 }
3710 ambiguous = ambiguous || found.has_value();
3711 found = at;
3712 }
3713 if (!found || ambiguous) {
3714 continue;
3715 }
3716 auto& original = orders[*found];
3717 const auto& link = links[*found];
3718 if (!link.plain_ || !link.remaining_ || link.remaining_->coefficient() != 0 ||
3719 original.instrument_.symbol_ != orders[cancel].instrument_.symbol_ ||
3720 original.side_ != orders[cancel].side_ ||
3721 !decimal_less(original.execution_.filled_quantity_, original.quantity_)) {
3722 continue;
3723 }
3724 original.status_ = OrderStatus::CANCELED;
3725 }
3726 }
3727
3728 // Normalizes one `kt00007` row.
3729 static OrderSnapshot parse_history_row(const json::object& row, const Date& date, bool demo = false) {
3730 OrderSnapshot order;
3731 order.order_id_ = detail::text_field(row, "ord_no");
3732 order.instrument_.country_ = Country::KR;
3733 order.instrument_.venue_ = instrument_venue();
3734 // `kt00007` prefixes the symbol with `A` where `ka10075` does not, so a listing that kept the
3735 // prefix would build an `InstrumentId` that did not compare equal to the same instrument's
3736 // working order or holding.
3737 order.instrument_.symbol_ = detail::bare_symbol(detail::text_field(row, "stk_cd"));
3738 order.name_ = detail::text_field(row, "stk_nm");
3739 order.currency_ = Currency::krw();
3740 order.side_ = read_side(detail::text_field(row, "io_tp_nm"));
3741 // The label vocabulary here differs from `ka10075`'s -- 지정가 rather than 보통 -- and
3742 // `shape_by_name` searches both columns so the two listings agree about one order. The demo
3743 // server writes 보통가 for an ordinary limit order (observed 2026-10-06 and 2026-10-13); that
3744 // spelling counts on the demo only, as its cancellation words do.
3745 const auto label = detail::text_field(row, "trde_tp");
3746 apply_shape(demo && label == "보통가" ? std::string_view("보통") : std::string_view(label), order);
3747 order.status_ = history_status(row);
3748 order.venue_ = normalized_venue(detail::text_field(row, "dmst_stex_tp"));
3749 order.quantity_ = detail::required_magnitude(row, "ord_qty");
3750 order.price_ = positive(detail::magnitude(row, "ord_uv"));
3751 order.trigger_price_ = reported_trigger(row, "cond_uv");
3752 order.ordered_at_ = detail::seoul_timestamp(date, detail::text_field(row, "ord_tm"));
3753 order.execution_.filled_quantity_ = detail::required_magnitude(row, "cntr_qty");
3754 if (const auto average = positive(detail::magnitude(row, "cntr_uv"))) {
3755 order.execution_.average_filled_price_ = average;
3756 }
3757 return order;
3758 }
3759
3760 // Reads a history row's lifecycle state.
3761 //
3762 // `kt00007` publishes two columns and neither alone is the answer: `acpt_tp` 접수구분 says the
3763 // order was received, and `mdfy_cncl` 정정취소 says whether this row is a 정정확인 or a 취소확인.
3764 // A cancellation confirmation is a cancelled order, and nothing else on the row says so.
3765 //
3766 // A fill is read from the quantities rather than from a word, because `kt00007` publishes no
3767 // 체결 state at all — `ord_remnq` 주문잔량 is what remains.
3768 static OrderStatus history_status(const json::object& row) {
3769 const auto amendment = detail::text_field(row, "mdfy_cncl");
3770 if (amendment == "취소확인") {
3771 return OrderStatus::CANCELED;
3772 }
3773 if (amendment == "정정확인") {
3774 return OrderStatus::REPLACED;
3775 }
3776 const auto filled = detail::required_magnitude(row, "cntr_qty");
3777 const auto remaining = detail::magnitude(row, "ord_remnq");
3778 if (filled.coefficient() > 0) {
3779 if (remaining && remaining->coefficient() == 0) {
3780 return OrderStatus::FILLED;
3781 }
3782 return OrderStatus::PARTIAL_FILLED;
3783 }
3784 return normalized_order_status(detail::text_field(row, "acpt_tp"));
3785 }
3786};
3787
3796public:
3804 ssl::context& context,
3806 network::HttpClientOptions options = {}
3807 )
3808 : client_(
3809 std::make_shared<KiwoomClient>(ioc_.get_executor(), context, std::move(config), options)
3810 ) {}
3811
3818 return run<QuoteSnapshot>(
3819 [&](auto&& sink) { client_->async_quote(std::move(instrument), std::move(sink)); },
3820 "quote"
3821 );
3822 }
3823
3830 return run<PriceLimits>(
3831 [&](auto&& sink) { client_->async_price_limits(std::move(instrument), std::move(sink)); },
3832 "price limits"
3833 );
3834 }
3835
3842 return run<DepthSnapshot>(
3843 [&](auto&& sink) { client_->async_depth(std::move(instrument), std::move(sink)); },
3844 "depth"
3845 );
3846 }
3847
3855 return run<std::vector<TradePrint>>(
3856 [&](auto&& sink) { client_->async_trades(std::move(instrument), count, std::move(sink)); },
3857 "trades"
3858 );
3859 }
3860
3867 return run<Instrument>(
3868 [&](auto&& sink) { client_->async_instrument(std::move(subject), std::move(sink)); },
3869 "instrument"
3870 );
3871 }
3872
3879 return run<BarSeries>(
3880 [&](auto&& sink) { client_->async_bars(std::move(query), std::move(sink)); },
3881 "bars"
3882 );
3883 }
3884
3889 const KiwoomConfig& config() const noexcept { return client_->config(); }
3890
3897 return run<HoldingsSnapshot>(
3898 [&](auto&& sink) { client_->async_holdings(account, std::move(sink)); },
3899 "holdings"
3900 );
3901 }
3902
3910 return run<BuyingPower>(
3911 [&](auto&& sink) { client_->async_buying_power(currency, account, std::move(sink)); },
3912 "buying power"
3913 );
3914 }
3915
3921 return run<KiwoomDeposit>(
3922 [&](auto&& sink) { client_->async_deposit(std::move(sink)); },
3923 "deposit"
3924 );
3925 }
3926
3936 OrderSide side,
3937 std::optional<Decimal64> price = std::nullopt
3938 ) {
3939 return run<KiwoomOrderableAmount>(
3940 [&](auto&& sink) {
3941 client_->async_orderable_amount(
3942 std::move(instrument), side, std::move(price), std::move(sink)
3943 );
3944 },
3945 "orderable amount"
3946 );
3947 }
3948
3954 return run<std::vector<KiwoomAccountInfo>>(
3955 [&](auto&& sink) { client_->async_accounts(std::move(sink)); },
3956 "accounts"
3957 );
3958 }
3959
3968 return run<KiwoomOrderAck>(
3969 [&](auto&& sink) {
3970 client_->async_place_order(account, std::move(order), std::move(sink));
3971 },
3972 "place order"
3973 );
3974 }
3975
3984 return run<KiwoomOrderAck>(
3985 [&](auto&& sink) {
3986 client_->async_replace_order(account, std::move(amendment), std::move(sink));
3987 },
3988 "replace order"
3989 );
3990 }
3991
4000 return run<KiwoomOrderAck>(
4001 [&](auto&& sink) {
4002 client_->async_cancel_order(account, std::move(amendment), std::move(sink));
4003 },
4004 "cancel order"
4005 );
4006 }
4007
4014 return run<KiwoomOpenOrderPage>(
4015 [&](auto&& sink) { client_->async_open_orders(account, std::move(sink)); },
4016 "open orders"
4017 );
4018 }
4019
4027 return run<OrderPage>(
4028 [&](auto&& sink) {
4029 client_->async_order_history(account, std::move(query), std::move(sink));
4030 },
4031 "order history"
4032 );
4033 }
4034
4036 void close() { client_->close(); }
4037
4046 const network::RateLimitStats* rate_limit_stats() const { return client_->rate_limit_stats(); }
4047
4048private:
4049 // Drives one asynchronous operation to completion on the private loop.
4050 template <typename T, typename Start>
4051 Result<T> run(Start start, std::string_view operation) {
4052 std::optional<Result<T>> result;
4053 ioc_.restart();
4054 start([&result](Result<T> value) { result.emplace(std::move(value)); });
4055 ioc_.run();
4056 if (!result) {
4057 BrokerError error;
4059 error.broker_code_ = "event_loop_stopped";
4060 error.message_ = "event loop stopped before " + std::string(operation) + " completion";
4061 return Result<T>(std::move(error));
4062 }
4063 return std::move(*result);
4064 }
4065
4066 net::io_context ioc_;
4067 std::shared_ptr<KiwoomClient> client_;
4068};
4069
4070} // namespace algotradelib::kiwoom
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::uint8_t scale() const noexcept
Returns the scale.
Definition types.hpp:405
std::int64_t coefficient() const noexcept
Returns the coefficient.
Definition types.hpp:399
Decimal64()=default
Constructs zero.
Either a value or a BrokerError.
Definition types.hpp:797
Result(T value)
Constructs a successful result.
Definition types.hpp:803
Result< PriceLimits > price_limits(InstrumentId instrument)
Returns today's upper and lower price bound.
Definition client.hpp:3829
Result< std::vector< TradePrint > > trades(InstrumentId instrument, int count)
Returns recent executions, newest first.
Definition client.hpp:3854
const KiwoomConfig & config() const noexcept
Returns the configuration; see KiwoomClient::config.
Definition client.hpp:3889
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics.
Definition client.hpp:4046
void close()
Closes the client for good; see KiwoomClient::close.
Definition client.hpp:4036
Result< BuyingPower > buying_power(Currency currency, KiwoomAccount account)
Returns what the bound credential's account may spend.
Definition client.hpp:3909
Result< KiwoomOrderAck > replace_order(KiwoomAccount account, KiwoomAmendment amendment)
Modifies an existing order. The outcome can be unknown after an error; see KiwoomClient::async_replac...
Definition client.hpp:3983
Result< OrderPage > order_history(KiwoomAccount account, KiwoomHistoryQuery query)
Returns one day of order history.
Definition client.hpp:4026
Result< DepthSnapshot > depth(InstrumentId instrument)
Returns the resting orders on both sides of the book.
Definition client.hpp:3841
Result< KiwoomOrderableAmount > orderable_amount(InstrumentId instrument, OrderSide side, std::optional< Decimal64 > price=std::nullopt)
Returns what may be ordered of one instrument, per margin tier.
Definition client.hpp:3934
Result< KiwoomOrderAck > cancel_order(KiwoomAccount account, KiwoomAmendment amendment)
Cancels an existing order. The outcome can be unknown after an error; see KiwoomClient::async_cancel_...
Definition client.hpp:3999
Result< BarSeries > bars(BarQuery query)
Returns bars; see KiwoomClient::async_bars.
Definition client.hpp:3878
Result< QuoteSnapshot > quote(InstrumentId instrument)
Returns the current normalized quote or a structured error.
Definition client.hpp:3817
Result< std::vector< KiwoomAccountInfo > > accounts()
Returns the one account the bound credential drives.
Definition client.hpp:3953
Result< KiwoomOpenOrderPage > open_orders(KiwoomAccount account)
Returns every order still open to amendment.
Definition client.hpp:4013
Result< KiwoomDeposit > deposit()
Returns Kiwoom's own fuller deposit detail.
Definition client.hpp:3920
Result< KiwoomOrderAck > place_order(KiwoomAccount account, OrderRequest order)
Submits a new order. The outcome can be unknown after an error; see KiwoomClient::async_place_order.
Definition client.hpp:3967
KiwoomBlockingClient(ssl::context &context, KiwoomConfig config, network::HttpClientOptions options={})
Constructs a client.
Definition client.hpp:3803
Result< Instrument > instrument(InstrumentId subject)
Returns static reference data for one instrument.
Definition client.hpp:3866
Result< HoldingsSnapshot > holdings(KiwoomAccount account)
Returns the holdings for KiwoomConfig::holdings_venue_; see KiwoomClient::async_holdings.
Definition client.hpp:3896
The asynchronous Kiwoom REST client: market data, account inquiries and orders for domestic stocks.
Definition client.hpp:885
void close()
Closes the client for good: pending requests, and later ones that would send, complete with CANCELLED...
Definition client.hpp:1437
auto async_instrument(InstrumentId instrument, CompletionToken &&token)
Requests static reference data for one instrument, from ka10100 종목정보 조회.
Definition client.hpp:1049
net::any_completion_handler< void(Result< KiwoomOrderAck >)> OrderAckHandler
Completion handler for one Kiwoom order acknowledgement.
Definition client.hpp:910
auto async_buying_power(Currency currency, KiwoomAccount account, CompletionToken &&token)
Requests what the account may spend, from kt00001 예수금상세현황요청.
Definition client.hpp:1145
net::any_completion_handler< void(Result< KiwoomDeposit >)> DepositHandler
Completion handler for Kiwoom's own fuller deposit detail.
Definition client.hpp:904
auto async_depth(InstrumentId instrument, CompletionToken &&token)
Requests the resting orders on both sides of the book, from ka10004 주식호가요청.
Definition client.hpp:998
net::any_completion_handler< void(Result< DepthSnapshot >)> DepthHandler
Completion handler for one normalized order book.
Definition client.hpp:892
auto async_open_orders(KiwoomAccount account, CompletionToken &&token)
Requests every order still open to amendment, paging ka10075 미체결요청.
Definition client.hpp:1375
auto async_order_history(KiwoomAccount account, KiwoomHistoryQuery query, CompletionToken &&token)
Requests one day of order history, paging kt00007 계좌별주문체결내역상세요청.
Definition client.hpp:1399
net::any_completion_handler< void(Result< PriceLimits >)> PriceLimitsHandler
Completion handler for one instrument's price bounds.
Definition client.hpp:894
net::any_completion_handler< void(Result< KiwoomOrderableAmount >)> OrderableHandler
Completion handler for Kiwoom's per-margin-tier orderable amounts and quantities.
Definition client.hpp:906
net::any_completion_handler< void(Result< std::vector< TradePrint > >)> TradesHandler
Completion handler for recent executions.
Definition client.hpp:896
auto async_cancel_order(KiwoomAccount account, KiwoomAmendment amendment, CompletionToken &&token)
Cancels an order, all or part of it (kt10003).
Definition client.hpp:1347
net::any_completion_handler< void(Result< BarSeries >)> BarsHandler
Completion handler for one normalized bar series.
Definition client.hpp:890
net::any_completion_handler< void(Result< HoldingsSnapshot >)> HoldingsHandler
Completion handler for one normalized holdings snapshot.
Definition client.hpp:900
net::any_completion_handler< void(Result< KiwoomOpenOrderPage >)> OpenOrderHandler
Completion handler for one page of modifiable or cancellable orders.
Definition client.hpp:912
net::any_completion_handler< void(std::optional< BrokerError >)> TokenHandler
Internal completion handler for a shared access-token refresh.
Definition client.hpp:916
net::any_completion_handler< void(Result< BuyingPower >)> BuyingPowerHandler
Completion handler for one normalized buying-power reading.
Definition client.hpp:902
net::any_completion_handler< void(Result< Instrument >)> InstrumentHandler
Completion handler for one instrument's reference data.
Definition client.hpp:898
auto async_deposit(CompletionToken &&token)
Requests Kiwoom's own fuller deposit detail, which async_buying_power reads one field of.
Definition client.hpp:1168
auto async_replace_order(KiwoomAccount account, KiwoomAmendment amendment, CompletionToken &&token)
Amends an order's quantity, price or trigger price (kt10002).
Definition client.hpp:1305
const KiwoomConfig & config() const noexcept
Returns the configuration (the session reads its holdings assertions from it).
Definition client.hpp:1099
auto async_bars(BarQuery query, CompletionToken &&token)
Requests bars, following Kiwoom's continuation pages from before_'s Korean date (or today).
Definition client.hpp:1080
auto async_place_order(KiwoomAccount account, OrderRequest order, CompletionToken &&token)
Places an order (kt10000 buy, kt10001 sell).
Definition client.hpp:1274
net::strand< net::any_io_executor > get_executor() const noexcept
Returns the client's strand.
Definition client.hpp:1105
net::any_completion_handler< void(Result< QuoteSnapshot >)> QuoteHandler
Completion handler for one normalized quote.
Definition client.hpp:888
auto async_price_limits(InstrumentId instrument, CompletionToken &&token)
Requests today's upper and lower price bound, from the same ka10007 response.
Definition client.hpp:972
auto async_orderable_amount(InstrumentId instrument, OrderSide side, std::optional< Decimal64 > price, CompletionToken &&token)
Requests what may be ordered of one instrument, per margin tier (kt00010).
Definition client.hpp:1192
auto async_accounts(CompletionToken &&token)
Requests the one account the bound credential drives, from ka00001 계좌번호조회.
Definition client.hpp:1228
auto async_holdings(KiwoomAccount account, CompletionToken &&token)
Requests the holdings kt00018 lists for KiwoomConfig::holdings_venue_ (refused before sending until i...
Definition client.hpp:1121
const network::RateLimitStats * rate_limit_stats() const
Returns the rate limiter's statistics.
Definition client.hpp:1427
KiwoomClient(net::any_io_executor executor, ssl::context &context, KiwoomConfig config, network::HttpClientOptions options={})
Constructs a client; nothing is sent until the first request.
Definition client.hpp:925
net::any_completion_handler< void(Result< std::vector< KiwoomAccountInfo > >)> AccountsHandler
Completion handler for the one account the bound credential drives.
Definition client.hpp:908
net::any_completion_handler< void(Result< OrderPage >)> OrderPageHandler
Completion handler for one normalized page of order history.
Definition client.hpp:914
auto async_trades(InstrumentId instrument, int count, CompletionToken &&token)
Requests recent executions, newest first, from ka10003 체결정보요청.
Definition client.hpp:1025
auto async_quote(InstrumentId instrument, CompletionToken &&token)
Requests and normalizes a current quote, from ka10007 시세표성정보요청.
Definition client.hpp:946
The Kiwoom Securities REST API client.
Definition auth.hpp:12
const char * exchange_code(Venue venue) noexcept
Returns Kiwoom's dmst_stex_tp value for a venue.
Definition client.hpp:642
Date today_in_seoul()
Returns today's date in Korea (UTC+9), the date Kiwoom requests are made in.
Definition client.hpp:154
KiwoomSellableScope
What a Kiwoom holdings row's tradable quantity (trde_able_qty) covers; undocumented,...
Definition config.hpp:138
@ WHOLE_HOLDING
Asserted: it covers every share held of the instrument, on any venue.
Definition config.hpp:142
std::optional< BrokerError > unsupported_bar_interval(BarInterval interval)
Refuses a bar interval this client requests no chart for (chart_api_id).
Definition client.hpp:797
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
std::optional< BrokerError > unestablished_holdings_scope()
Makes the error that refuses a new order or an amendment while KiwoomConfig::holdings_position_scope_...
Definition client.hpp:183
std::optional< BrokerError > kiwoom_declared_error(const boost::json::object &document)
Reads the failure a Kiwoom response declares in its body (Kiwoom reports business failures with HTTP ...
Definition errors.hpp:97
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
constexpr const char * chart_rows_field(BarInterval interval) noexcept
Returns the array field a bar interval's chart endpoint returns its rows in.
Definition client.hpp:771
Venue listed_venue(std::string_view stex_tp, std::string_view sor_yn) noexcept
Returns the venue of a working-order row (ka10075) from its stex_tp and sor_yn.
Definition client.hpp:693
KiwoomOrderOperation
Which order instruction a reply answers.
Definition errors.hpp:138
@ CANCEL
A cancellation (kt10003).
Definition errors.hpp:146
@ UNCLASSIFIED
Not an order request, or one this client does not classify.
Definition errors.hpp:140
const KiwoomOrderShapeRow * shape_by_name(std::string_view name) noexcept
Looks up an order shape by the Korean name a response gives (either listing's spelling).
Definition client.hpp:611
constexpr std::array< KiwoomOrderShapeRow, 21 > kOrderShapes
Kiwoom's order-type (trde_tp) table, from its API specification.
Definition client.hpp:537
std::optional< BrokerError > invalid_trigger(OrderType type, const std::optional< Decimal64 > &trigger)
Checks that a trigger price is present exactly when the order type takes one (only STOP_LIMIT,...
Definition client.hpp:241
std::optional< BrokerError > unsupported_client_order_id(const OrderRequest &order)
Refuses an idempotency key, which Kiwoom does not offer (an order carrying client_order_id_ is refuse...
Definition client.hpp:208
constexpr bool kiwoom_position_scope_asserted(KiwoomPositionScope scope) noexcept
Tests whether a holdings scope states what Kiwoom's holdings figure covers, so holdings may be read a...
Definition config.hpp:124
OrderStatus normalized_order_status(std::string_view text) noexcept
Reads an order status from Kiwoom's Korean word.
Definition client.hpp:723
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
const KiwoomOrderShapeRow * shape_by_code(std::string_view code) noexcept
Looks up an order shape by Kiwoom's numeric trde_tp code.
Definition client.hpp:628
KiwoomPositionScope
What the venue named on a Kiwoom holdings request (kt00018's dmst_stex_tp) means for the positions it...
Definition config.hpp:102
constexpr const char * chart_api_id(BarInterval interval) noexcept
Returns the api-id of the chart endpoint for a bar interval.
Definition client.hpp:745
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
std::optional< BrokerError > kiwoom_recognized_refusal(int http_status, const boost::json::object &document, KiwoomOrderOperation operation, bool demo)
Reads the refusal a cash-order reply states, for the replies this client recognizes as one.
Definition errors.hpp:204
constexpr Venue kiwoom_effective_route(Venue asked) noexcept
Returns the venue a Kiwoom order reaches: the one it names (Kiwoom requires one, so an order naming n...
Definition client.hpp:663
HTTP and WebSocket transport, OAuth tokens, and request pacing.
RateLimitGroup
The traffic class of a request, for rate limiting and queue priority. The broker adapter that builds ...
@ 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
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.
OrderStatus
Where an order stands in its lifecycle.
Definition types.hpp:1879
@ 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
InstrumentStatus
Whether an instrument is listed, awaiting listing, or delisted.
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_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
bool decimal_less(const Decimal64 &lhs, const Decimal64 &rhs)
Compares two decimals numerically, whatever their scales.
Definition types.hpp:665
BarInterval
The period one bar covers.
@ 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
unsigned month_
The month, 1-12; zero means unset.
Definition types.hpp:1146
int year_
The year.
Definition types.hpp:1144
unsigned day_
The day of the month, 1-31; zero means unset.
Definition types.hpp:1148
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
@ UNESTABLISHED
Not established; RiskGuard refuses.
Definition types.hpp:922
@ DEDUCT
Subtract every resting sell from the figure.
Definition types.hpp:942
One account Kiwoom's ka00001 reports for the bound credential.
Definition client.hpp:45
std::string account_number_
The account number as Kiwoom reported it, for display.
Definition client.hpp:47
The Kiwoom account selector, which is empty: no Kiwoom endpoint takes an account number,...
Definition client.hpp:35
friend bool operator==(const KiwoomAccount &, const KiwoomAccount &)
Compares two selectors (the arguments are not used); always equal, since a Kiwoom credential drives e...
Definition client.hpp:41
An amendment or cancellation of a Kiwoom order.
Definition client.hpp:70
Venue venue_
The venue the order rests on, as listed (Kiwoom's dmst_stex_tp): KRX, NXT or COMBINED (SOR)....
Definition client.hpp:77
KiwoomOrderRef order_
The order to amend, which carries the symbol Kiwoom requires beside the order number.
Definition client.hpp:72
Decimal64 quantity_
The quantity affected, or zero for everything still resting (Kiwoom evaluates 0 when it processes the...
Definition client.hpp:82
std::optional< Decimal64 > price_
The new price, on a modification; absent on an unpriced basis, and refused on a cancellation.
Definition client.hpp:84
std::optional< Decimal64 > condition_price_
The new trigger price (mdfy_cond_uv), for amending a stop-limit (28) order; must be empty on a cancel...
Definition client.hpp:89
Kiwoom credentials (app key and secret key), demo or real account, the market-data tape,...
Definition config.hpp:154
Kiwoom's deposit detail (kt00001), as reported; buying_power reads spendable_ from it,...
Definition client.hpp:815
Decimal64 orderable_
ord_alow_amt 주문가능금액: orderable including what margin and 대용금 allow.
Definition client.hpp:822
Decimal64 withdrawable_
pymn_alow_amt 출금가능금액: what may be withdrawn.
Definition client.hpp:830
Decimal64 spendable_
What the account can buy paying in full, without credit (100stk_ord_alow_amt); BuyingPower::cash_ is ...
Definition client.hpp:820
Decimal64 deposit_
entr 예수금: the cash deposit.
Definition client.hpp:824
std::chrono::system_clock::time_point received_at_
When the figures arrived, on this machine's clock.
Definition client.hpp:834
Decimal64 substitute_amount_
repl_amt 대용금: securities pledged as collateral, which is not cash.
Definition client.hpp:828
Currency currency_
The currency every figure above is in, which is won.
Definition client.hpp:832
Decimal64 settled_deposit_
d2_entra D+2 추정예수금: the deposit once today's trades settle.
Definition client.hpp:826
A Kiwoom order-history query: one date (kt00007 takes no range, so a multi-day query is refused).
Definition client.hpp:143
Date date_
The single 주문일자 to list.
Definition client.hpp:145
std::string symbol_
Restrict to one symbol, which Kiwoom expresses as stk_cd. Empty lists every symbol.
Definition client.hpp:147
One margin tier of Kiwoom's kt00010 answer: what may be ordered at that 증거금률.
Definition client.hpp:838
std::optional< Decimal64 > quantity_
profa_NNord_alowq: the orderable quantity at this rate. Empty as for amount_.
Definition client.hpp:846
int margin_percent_
The margin rate the row applies to, as a percentage: 20, 30, 40, 50, 60 or 100.
Definition client.hpp:840
bool reduced_
Whether this row is the 증거금감면 (reduced-margin) variant of its rate.
Definition client.hpp:842
std::optional< Decimal64 > amount_
The orderable amount at this margin rate (profa_NNord_alow_amt), if Kiwoom reported one.
Definition client.hpp:844
One page of Kiwoom orders that remain modifiable or cancellable.
Definition client.hpp:132
std::vector< KiwoomOpenOrder > orders_
The rows on this page.
Definition client.hpp:134
std::chrono::system_clock::time_point received_at_
When the page arrived, on this machine's clock.
Definition client.hpp:136
One Kiwoom order that is still open to modification or cancellation.
Definition client.hpp:113
std::optional< Decimal64 > amendable_quantity_
How much Kiwoom says is still unfilled, oso_qty, and therefore still amendable.
Definition client.hpp:121
KiwoomOrderRef order_ref_
The reference an amendment must address.
Definition client.hpp:117
OrderSnapshot order_
The order's normalized state.
Definition client.hpp:115
std::string original_order_number_
Kiwoom's orig_ord_no: which earlier order this one superseded. Lineage, not an address.
Definition client.hpp:119
std::string shape_name_
Kiwoom's order-type name on the row (trde_tp), verbatim, such as 보통 or 시장가.
Definition client.hpp:128
Kiwoom's acknowledgement of an accepted order, amendment or cancellation.
Definition client.hpp:98
OrderAck ack_
The normalized acknowledgement, whose accepted_at_ is local receipt.
Definition client.hpp:100
KiwoomOrderRef order_
The reference a later amendment must address.
Definition client.hpp:102
std::optional< Decimal64 > amended_quantity_
The quantity Kiwoom echoed on an amendment, mdfy_qty or cncl_qty. Empty on a placement.
Definition client.hpp:109
std::string base_order_number_
Kiwoom's base_orig_ord_no (모주문번호) on an amendment: the order the amendment chain began with,...
Definition client.hpp:107
A Kiwoom order's address: order number and symbol, both of which amendments and cancellations require...
Definition client.hpp:56
std::string order_number_
Kiwoom's ord_no.
Definition client.hpp:58
std::string symbol_
Kiwoom's stk_cd, which its amendment endpoints require beside the order number.
Definition client.hpp:60
One row of Kiwoom's order-type table: the numeric request code, the Korean names its listings report,...
Definition client.hpp:513
const char * code_
The numeric trde_tp a request carries, or null for a shape this library cannot send.
Definition client.hpp:515
bool requestable_
Whether this adapter may send the code, as opposed to reading it.
Definition client.hpp:527
OrderSession session_
The normalized session.
Definition client.hpp:525
TimeInForce time_in_force_
The normalized execution condition.
Definition client.hpp:523
OrderType type_
The normalized price basis.
Definition client.hpp:521
const char * alternate_name_
The name kt00007 and kt00009 use, or null where it is the same as name_.
Definition client.hpp:519
const char * name_
The name kt10000, ka10075 and ka10076 use.
Definition client.hpp:517
What may be ordered of one instrument, per margin tier (kt00010), reachable through session....
Definition client.hpp:856
std::vector< KiwoomMarginTier > tiers_
Always seven 증거금률 tiers: 20, 30, 40, 50, 60 and 100 %, then the 60 % 증거금감면 tier (reduced_)....
Definition client.hpp:861
Decimal64 settled_deposit_
d2entra D+2 추정예수금.
Definition client.hpp:867
bool margin_reduction_applies_
profa_rdex_aplc_tp: whether the 증거금감면 tiers are the ones in force (1) or not (0).
Definition client.hpp:869
Decimal64 withdrawable_
wthd_alowa 인출가능금액.
Definition client.hpp:865
Currency currency_
The currency every amount above is in, which is won.
Definition client.hpp:871
Decimal64 orderable_cash_
ord_alowa 주문가능현금: the cash available to order with.
Definition client.hpp:863
std::chrono::system_clock::time_point received_at_
When the figures arrived, on this machine's clock.
Definition client.hpp:873
A Kiwoom REST access token (au10001's token) and its expiry (expires_dt).
Definition config.hpp:354
std::string access_token_
The bearer token; do not log it.
Definition oauth.hpp:133
Deadlines, size limits and pacing for an HTTP client.
An HTTP request for AsyncHttpClient.
A rate limiter's counters per traffic class, its current budget, and the broker's refusals (from Rate...