fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -8,10 +8,15 @@ Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
|
||||
(submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll),
|
||||
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -27,7 +32,11 @@ from trex_core.application.broker_port import (
|
||||
)
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
_SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
|
||||
_MOCK_HINTS = ("openapivts", "virtual", "mock")
|
||||
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
|
||||
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
|
||||
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
|
||||
|
||||
|
||||
def _safe_int(v: object) -> Optional[int]:
|
||||
@@ -37,6 +46,55 @@ def _safe_int(v: object) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"slot_id": req.slot_id,
|
||||
"side": req.side,
|
||||
"symbol": req.symbol,
|
||||
"qty": req.qty,
|
||||
"limit_price": req.limit_price,
|
||||
"venue": req.venue,
|
||||
"submitted_at": submitted_at.astimezone(_KST).isoformat(),
|
||||
}
|
||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
||||
encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}"
|
||||
|
||||
|
||||
def _decode_submission_unknown(broker_order_id: str) -> Optional[dict]:
|
||||
if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return None
|
||||
encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :]
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
payload = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
required = {
|
||||
"v",
|
||||
"slot_id",
|
||||
"side",
|
||||
"symbol",
|
||||
"qty",
|
||||
"venue",
|
||||
"submitted_at",
|
||||
}
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("v") != 1
|
||||
or not required.issubset(payload)
|
||||
or payload.get("side") not in {"buy", "sell"}
|
||||
):
|
||||
return None
|
||||
try:
|
||||
datetime.fromisoformat(str(payload["submitted_at"]))
|
||||
int(payload["qty"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
class KISBroker:
|
||||
"""KIS REST broker adapter. Live account is gated behind `allow_live`."""
|
||||
|
||||
@@ -59,6 +117,7 @@ class KISBroker:
|
||||
self._token: Optional[str] = None
|
||||
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||
self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
|
||||
self._fill_poll_not_before: dict[str, float] = {}
|
||||
is_mock = any(h in self._base_url for h in _MOCK_HINTS)
|
||||
if not is_mock and not allow_live:
|
||||
raise RuntimeError(
|
||||
@@ -167,6 +226,7 @@ class KISBroker:
|
||||
# --- BrokerPort ---
|
||||
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||
cano, product = self._account()
|
||||
submitted_at = datetime.now(tz=_KST)
|
||||
body = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
@@ -188,18 +248,44 @@ class KISBroker:
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}",
|
||||
raw={"error": str(exc)})
|
||||
# A transport failure after POST does not prove that KIS rejected
|
||||
# the order. Keep the slot dispatched under a local sentinel so
|
||||
# the execution loop cannot submit the same order again.
|
||||
return OrderAck(
|
||||
accepted=True,
|
||||
broker_order_id=_encode_submission_unknown(req, submitted_at),
|
||||
reason=f"submit_outcome_unknown:{type(exc).__name__}",
|
||||
raw={"error_class": type(exc).__name__},
|
||||
)
|
||||
if getattr(resp, "status_code", 200) >= 500:
|
||||
return OrderAck(
|
||||
accepted=True,
|
||||
broker_order_id=_encode_submission_unknown(req, submitted_at),
|
||||
reason=f"submit_outcome_unknown:http_{resp.status_code}",
|
||||
raw={"http_status": resp.status_code},
|
||||
)
|
||||
if str(payload.get("rt_cd")) == "0":
|
||||
out = payload.get("output") or {}
|
||||
odno = out.get("ODNO") or out.get("odno")
|
||||
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
|
||||
bid = f"{orgno}:{odno}" if orgno else str(odno)
|
||||
return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
|
||||
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"),
|
||||
raw=payload)
|
||||
return OrderAck(
|
||||
accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload
|
||||
)
|
||||
|
||||
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
|
||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
|
||||
now = monotonic()
|
||||
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
|
||||
return FillStatus(
|
||||
state="accepted",
|
||||
raw={"reason": "fill_poll_backoff"},
|
||||
)
|
||||
# Set the normal gate before I/O so concurrent execution ticks cannot
|
||||
# open duplicate KIS inquiries for the same order.
|
||||
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
|
||||
cano, product = self._account()
|
||||
odno = broker_order_id.split(":")[-1]
|
||||
today = datetime.now(tz=_KST).strftime("%Y%m%d")
|
||||
@@ -208,25 +294,206 @@ class KISBroker:
|
||||
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
|
||||
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
|
||||
params = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"INQR_STRT_DT": today, "INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
|
||||
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno,
|
||||
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": odno,
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
headers=self._headers(tr_id), params=params, timeout=self._timeout,
|
||||
headers=self._headers(tr_id),
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(state="unknown", raw={"error": str(exc)})
|
||||
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "fill_inquiry_rejected"},
|
||||
)
|
||||
self._fill_poll_not_before[broker_order_id] = monotonic() + _FILL_POLL_MIN_INTERVAL_SECONDS
|
||||
rows = payload.get("output1") or []
|
||||
row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None)
|
||||
normalized_odno = odno.lstrip("0") or "0"
|
||||
row = next(
|
||||
(
|
||||
r
|
||||
for r in rows
|
||||
if (str(r.get("odno") or "").strip().lstrip("0") or "0") == normalized_odno
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
return FillStatus(state="accepted", raw=payload)
|
||||
return FillStatus(
|
||||
state="accepted",
|
||||
raw={"reason": "order_not_visible_in_fill_inquiry"},
|
||||
)
|
||||
return self._fill_status_from_row(row, side=side, today=today, fallback_odno=odno)
|
||||
|
||||
def _poll_unknown_submission(
|
||||
self,
|
||||
broker_order_id: str,
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
) -> FillStatus:
|
||||
metadata = _decode_submission_unknown(broker_order_id)
|
||||
if metadata is None:
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "submission_unknown_metadata_invalid"},
|
||||
)
|
||||
now_monotonic = monotonic()
|
||||
if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0):
|
||||
return FillStatus(
|
||||
state="accepted",
|
||||
raw={"reason": "submission_lookup_backoff"},
|
||||
)
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS
|
||||
)
|
||||
cano, product = self._account()
|
||||
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(_KST)
|
||||
today = submitted_at.strftime("%Y%m%d")
|
||||
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
|
||||
params = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": str(metadata["symbol"]),
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
headers=self._headers(tr_id),
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "submission_lookup_transport"},
|
||||
)
|
||||
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
|
||||
self._fill_poll_not_before[broker_order_id] = (
|
||||
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={"reason": "submission_lookup_rejected"},
|
||||
)
|
||||
matches = [
|
||||
row
|
||||
for row in (payload.get("output1") or [])
|
||||
if self._matches_unknown_submission(row, metadata, submitted_at)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
age = (datetime.now(tz=_KST) - submitted_at).total_seconds()
|
||||
if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS:
|
||||
return FillStatus(
|
||||
state="rejected",
|
||||
raw={"reason": "submission_confirmed_absent"},
|
||||
)
|
||||
return FillStatus(
|
||||
state="unknown",
|
||||
raw={
|
||||
"reason": (
|
||||
"submission_lookup_ambiguous" if matches else "submission_lookup_pending"
|
||||
)
|
||||
},
|
||||
)
|
||||
row = matches[0]
|
||||
resolved_order_id = self._broker_order_id_from_row(row)
|
||||
status = self._fill_status_from_row(
|
||||
row,
|
||||
side=side,
|
||||
today=today,
|
||||
fallback_odno=resolved_order_id.split(":")[-1],
|
||||
)
|
||||
return replace(
|
||||
status,
|
||||
raw={
|
||||
**(status.raw if isinstance(status.raw, dict) else {}),
|
||||
"resolved_broker_order_id": resolved_order_id,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _broker_order_id_from_row(row: dict) -> str:
|
||||
odno = str(row.get("odno") or "").strip()
|
||||
orgno = str(row.get("ord_gno_brno") or "").strip()
|
||||
return f"{orgno}:{odno}" if orgno else odno
|
||||
|
||||
@staticmethod
|
||||
def _matches_unknown_submission(
|
||||
row: dict,
|
||||
metadata: dict,
|
||||
submitted_at: datetime,
|
||||
) -> bool:
|
||||
if str(row.get("pdno") or "").strip() != str(metadata["symbol"]):
|
||||
return False
|
||||
expected_side = "02" if metadata["side"] == "buy" else "01"
|
||||
if str(row.get("sll_buy_dvsn_cd") or "").strip() not in {"", expected_side}:
|
||||
return False
|
||||
if (_safe_int(row.get("ord_qty")) or 0) != int(metadata["qty"]):
|
||||
return False
|
||||
expected_price = int(metadata.get("limit_price") or 0)
|
||||
row_price = _safe_int(row.get("ord_unpr"))
|
||||
if row_price is not None and row_price != expected_price:
|
||||
return False
|
||||
order_date = str(row.get("ord_dt") or "").strip()
|
||||
order_time = str(row.get("ord_tmd") or "").strip()[:6]
|
||||
if len(order_date) != 8 or len(order_time) != 6:
|
||||
return False
|
||||
try:
|
||||
ordered_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S").replace(
|
||||
tzinfo=_KST
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
delta = (ordered_at - submitted_at).total_seconds()
|
||||
return -5.0 <= delta <= 120.0
|
||||
|
||||
@staticmethod
|
||||
def _fill_status_from_row(
|
||||
row: dict,
|
||||
*,
|
||||
side: str,
|
||||
today: str,
|
||||
fallback_odno: str,
|
||||
) -> FillStatus:
|
||||
ord_qty = _safe_int(row.get("ord_qty")) or 0
|
||||
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
|
||||
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
|
||||
@@ -245,7 +512,7 @@ class KISBroker:
|
||||
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
ord_gno_brno = str(row.get("ord_gno_brno") or "")
|
||||
row_odno = str(row.get("odno") or odno)
|
||||
row_odno = str(row.get("odno") or fallback_odno)
|
||||
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
|
||||
fill_price = float(avg) if avg else 0.0
|
||||
# dcy-026: inquire-daily-ccld (output1) does not expose per-fill
|
||||
@@ -256,9 +523,13 @@ class KISBroker:
|
||||
fee = calculate_fee(side, amount)
|
||||
tax = calculate_tax(side, amount)
|
||||
return FillStatus(
|
||||
state="filled", filled_qty=ccld_qty,
|
||||
state="filled",
|
||||
filled_qty=ccld_qty,
|
||||
avg_price=fill_price or None,
|
||||
execution_id=execution_id, fee=fee, tax=tax, raw=row,
|
||||
execution_id=execution_id,
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
raw=row,
|
||||
)
|
||||
# 0 < ccld_qty < ord_qty
|
||||
if cancelled:
|
||||
@@ -268,20 +539,28 @@ class KISBroker:
|
||||
# holding instead of waiting for a fill that will never come.
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
ord_gno_brno = str(row.get("ord_gno_brno") or "")
|
||||
row_odno = str(row.get("odno") or odno)
|
||||
row_odno = str(row.get("odno") or fallback_odno)
|
||||
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
|
||||
fill_price = float(avg) if avg else 0.0
|
||||
amount = ccld_qty * fill_price
|
||||
fee = calculate_fee(side, amount)
|
||||
tax = calculate_tax(side, amount)
|
||||
return FillStatus(
|
||||
state="partially_filled", filled_qty=ccld_qty,
|
||||
state="partially_filled",
|
||||
filled_qty=ccld_qty,
|
||||
avg_price=fill_price or None,
|
||||
execution_id=execution_id, fee=fee, tax=tax,
|
||||
cancelled=True, raw=row,
|
||||
execution_id=execution_id,
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
cancelled=True,
|
||||
raw=row,
|
||||
)
|
||||
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None, raw=row)
|
||||
return FillStatus(
|
||||
state="partially_filled",
|
||||
filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None,
|
||||
raw=row,
|
||||
)
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
|
||||
@@ -294,12 +573,17 @@ class KISBroker:
|
||||
cano, product = self._account()
|
||||
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
|
||||
params = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"AFHR_FLPR_YN": "N", "OFL_YN": "",
|
||||
"INQR_DVSN": "02", "UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
headers = self._headers(tr_id)
|
||||
@@ -310,14 +594,18 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||
headers=headers, params=params, timeout=self._timeout,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
code = "authentication_failed" if status == 401 else (
|
||||
"authorization_failed" if status == 403 else "transport_failed"
|
||||
code = (
|
||||
"authentication_failed"
|
||||
if status == 401
|
||||
else ("authorization_failed" if status == 403 else "transport_failed")
|
||||
)
|
||||
raise BrokerBalanceError(code) from None
|
||||
except httpx.HTTPError:
|
||||
@@ -400,7 +688,9 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
headers=self._headers(self._tr_price()), params=params, timeout=self._timeout,
|
||||
headers=self._headers(self._tr_price()),
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
@@ -416,21 +706,30 @@ class KISBroker:
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
|
||||
def cancel_order(
|
||||
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
|
||||
) -> bool:
|
||||
"""Cancel a previously submitted order (REQ-118).
|
||||
|
||||
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
|
||||
used at submit time. Default "KRX" preserves backward compatibility.
|
||||
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
|
||||
"""
|
||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return False
|
||||
cano, product = self._account()
|
||||
parts = broker_order_id.split(":")
|
||||
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
|
||||
body = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
|
||||
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
|
||||
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"KRX_FWDG_ORD_ORGNO": orgno,
|
||||
"ORGN_ODNO": odno,
|
||||
"ORD_DVSN": "00",
|
||||
"RVSE_CNCL_DVSN_CD": "02",
|
||||
"ORD_QTY": str(qty),
|
||||
"ORD_UNPR": "0",
|
||||
"QTY_ALL_ORD_YN": "Y",
|
||||
# REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit.
|
||||
"EXCG_ID_DVSN_CD": venue,
|
||||
}
|
||||
@@ -438,7 +737,9 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
headers=self._headers(tr_id), json=body, timeout=self._timeout,
|
||||
headers=self._headers(tr_id),
|
||||
json=body,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return str(resp.json().get("rt_cd")) == "0"
|
||||
except (httpx.HTTPError, ValueError):
|
||||
|
||||
Reference in New Issue
Block a user