ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -220,6 +220,33 @@ class KISClient(SafeRequest):
j = r.json()
return j.get("output") if j.get("rt_cd") == "0" else None
def inquire_stock_name(self, code: str) -> Optional[str]:
"""국내 종목명 [주식기본조회 CTPF1002R]. 약명(prdt_abrv_name) 우선."""
code = str(code or "").strip()
if not code:
return None
try:
r = self._get(
"/uapi/domestic-stock/v1/quotations/search-stock-info",
"CTPF1002R",
{"PRDT_TYPE_CD": "300", "PDNO": code},
)
if r.status_code != 200:
return None
j = r.json()
if j.get("rt_cd") != "0":
return None
out = j.get("output") or {}
if isinstance(out, list):
out = out[0] if out else {}
name = (
str(out.get("prdt_abrv_name") or out.get("prdt_name") or "").strip()
)
return name or None
except Exception as e:
logger.debug("종목명 조회 실패(%s): %s", code, e)
return None
def inquire_multi_price(self, codes: List[str]) -> Dict[str, dict]:
"""
관심종목(멀티종목) 시세조회 [국내주식-205] — FHKST11300006.
@@ -765,10 +792,32 @@ class KISClient(SafeRequest):
"ORD_QTY": str(qty), "ORD_UNPR": str(price),
}
try:
from kis_trader.utils.api_reject_log import record_api_reject
r = self._post(path, tr_id, body)
if r.status_code != 200:
logger.error("주문 HTTP 에러 side=%s code=%s status=%s",
side, code, r.status_code)
body_snip = (r.text or "")[:200]
http_cd = f"HTTP_{int(r.status_code)}"
if side == "BUY":
self._last_order_msg_cd = http_cd
self._last_order_msg1 = body_snip
else:
self._last_sell_msg_cd = http_cd
self._last_sell_msg1 = body_snip
logger.error(
"주문 HTTP 에러 side=%s code=%s status=%s",
side, code, r.status_code,
)
record_api_reject(
kind="domestic_order_http",
side=side,
code=str(code or ""),
msg_cd=http_cd,
msg1=body_snip,
http=r.status_code,
path=str(path),
extra={"mock": bool(self.mock)},
)
return None
j = r.json()
if j.get("rt_cd") == "0":
@@ -797,9 +846,45 @@ class KISClient(SafeRequest):
code, j.get("rt_cd"),
self._last_sell_msg_cd, self._last_sell_msg1,
)
record_api_reject(
kind="domestic_order_reject",
side=side,
code=str(code or ""),
msg_cd=str(
(self._last_order_msg_cd if side == "BUY" else self._last_sell_msg_cd)
or ""
),
msg1=str(
(self._last_order_msg1 if side == "BUY" else self._last_sell_msg1)
or ""
),
rt_cd=str(j.get("rt_cd") or ""),
http=200,
path=str(path),
extra={"mock": bool(self.mock)},
)
return None
except Exception as e:
if side == "BUY":
self._last_order_msg_cd = "EXC"
self._last_order_msg1 = str(e)[:200]
else:
self._last_sell_msg_cd = "EXC"
self._last_sell_msg1 = str(e)[:200]
logger.error("주문 예외 side=%s code=%s: %s", side, code, e)
try:
from kis_trader.utils.api_reject_log import record_api_reject
record_api_reject(
kind="domestic_order_exception",
side=side,
code=str(code or ""),
msg_cd="EXC",
msg1=str(e)[:300],
path=str(path),
extra={"mock": bool(self.mock)},
)
except Exception:
pass
return None
def buy_market_order(self, code: str, qty: int) -> Optional[str]:
@@ -872,6 +957,323 @@ class KISClient(SafeRequest):
code=code, qty=qty, price=0, order_type="01", side="SELL"
)
# ------------------------------------------------------------------
# 해외주식 시세·주문 (미국 NASD/NYSE/AMEX)
# ※ 매수/매도는 body 가 아니라 헤더 tr_id 로만 구분 (국내 SLL_BUY 와 다름)
# ------------------------------------------------------------------
@staticmethod
def _ovrs_excg_cd(exchange: Optional[str] = None) -> str:
"""주문용 거래소 코드 (NASD/NYSE/AMEX). 시세 EXCD(NAS) 와 다름."""
raw = (
exchange
or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD")
or "NASD"
).strip().upper()
aliases = {
"NAS": "NASD", "NASDAQ": "NASD", "NASD": "NASD",
"NYS": "NYSE", "NYSE": "NYSE",
"AMS": "AMEX", "AMEX": "AMEX",
}
return aliases.get(raw, raw or "NASD")
@staticmethod
def _ovrs_price_excd(exchange: Optional[str] = None) -> str:
"""시세용 EXCD (NAS/NYS/AMS)."""
order_cd = KISClient._ovrs_excg_cd(exchange)
return {"NASD": "NAS", "NYSE": "NYS", "AMEX": "AMS"}.get(order_cd, "NAS")
def inquire_overseas_price(
self,
symbol: str,
*,
exchange: Optional[str] = None,
) -> Optional[dict]:
"""해외주식 현재가 [해외주식-029] HHDFS00000300."""
symb = str(symbol or "").strip().upper()
if not symb:
return None
excd = self._ovrs_price_excd(exchange)
try:
r = self._get(
"/uapi/overseas-price/v1/quotations/price",
"HHDFS00000300",
{"AUTH": "", "EXCD": excd, "SYMB": symb},
)
if r.status_code != 200:
logger.warning("해외시세 HTTP %s %s", r.status_code, symb)
return None
j = r.json()
if j.get("rt_cd") != "0":
logger.warning(
"해외시세 실패 %s rt_cd=%s msg=%s",
symb, j.get("rt_cd"), j.get("msg1"),
)
return None
return j.get("output") or {}
except Exception as e:
logger.warning("해외시세 예외 %s: %s", symb, e)
return None
def _overseas_order_tr_id(self, side: str, exchange: str) -> str:
"""미국 매수 TTTT1002U / 매도 TTTT1006U. 모의는 앞 T→V."""
side_u = (side or "").upper()
excg = self._ovrs_excg_cd(exchange)
if excg not in ("NASD", "NYSE", "AMEX"):
raise ValueError(f"unsupported overseas exchange for US order: {excg}")
if side_u == "BUY":
real_tr = get_env_from_db("KIS_OVRS_US_BUY_TR_ID", "TTTT1002U") or "TTTT1002U"
elif side_u == "SELL":
real_tr = get_env_from_db("KIS_OVRS_US_SELL_TR_ID", "TTTT1006U") or "TTTT1006U"
else:
raise ValueError(f"side must be BUY/SELL, got {side}")
real_tr = str(real_tr).strip().upper()
if self.mock:
# 공식 샘플: demo 시 'V' + tr_id[1:] (TTTT… → VTTT…)
return "V" + real_tr[1:]
return real_tr
def overseas_order(
self,
*,
symbol: str,
qty: int,
price: float,
side: str,
exchange: Optional[str] = None,
ord_dvsn: Optional[str] = None,
) -> Optional[str]:
"""
해외주식 주문 [v1_해외주식-001]. 성공 시 ODNO, 실패 시 None.
모의(VTTT*): ORD_DVSN=00 지정가만 가능. price>0 필수.
"""
symb = str(symbol or "").strip().upper()
qty_i = int(qty or 0)
if not symb or qty_i <= 0:
return None
excg = self._ovrs_excg_cd(exchange)
side_u = (side or "").upper()
if ord_dvsn is None or str(ord_dvsn).strip() == "":
ord_dvsn = get_env_from_db("KIS_OVRS_ORD_DVSN", "00") or "00"
ord_dvsn = str(ord_dvsn).strip()
px = float(price or 0)
if self.mock and (ord_dvsn != "00" or px <= 0):
logger.error(
"해외모의주문은 지정가(00)+가격>0 필수 side=%s %s",
side_u, symb,
)
return None
px_decimals = int(get_env_int("KIS_OVRS_PRICE_DECIMALS", 2))
px_str = f"{px:.{px_decimals}f}"
try:
tr_id = self._overseas_order_tr_id(side_u, excg)
except ValueError as e:
logger.error("해외주문 tr_id: %s", e)
return None
path = get_env_from_db(
"KIS_OVRS_ORDER_PATH",
"/uapi/overseas-stock/v1/trading/order",
) or "/uapi/overseas-stock/v1/trading/order"
# 매도만 SLL_TYPE=00 (공식 샘플). 매수는 공란.
sll_type = "00" if side_u == "SELL" else ""
body = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"OVRS_EXCG_CD": excg,
"PDNO": symb,
"ORD_QTY": str(qty_i),
"OVRS_ORD_UNPR": px_str,
"CTAC_TLNO": "",
"MGCO_APTM_ODNO": "",
"SLL_TYPE": sll_type,
"ORD_SVR_DVSN_CD": get_env_from_db("KIS_OVRS_ORD_SVR_DVSN_CD", "0") or "0",
"ORD_DVSN": ord_dvsn,
}
try:
from kis_trader.utils.api_reject_log import record_api_reject
r = self._post(path, tr_id, body)
if r.status_code != 200:
body_snip = (r.text or "")[:200]
http_cd = f"HTTP_{int(r.status_code)}"
if side_u == "BUY":
self._last_order_msg_cd = http_cd
self._last_order_msg1 = body_snip
else:
self._last_sell_msg_cd = http_cd
self._last_sell_msg1 = body_snip
logger.error(
"해외주문 HTTP side=%s %s status=%s body=%s",
side_u, symb, r.status_code, body_snip,
)
record_api_reject(
kind="overseas_order_http",
side=side_u,
code=symb,
msg_cd=http_cd,
msg1=body_snip,
http=r.status_code,
path=str(path),
extra={"tr_id": tr_id, "mock": bool(self.mock)},
)
return None
j = r.json()
if j.get("rt_cd") == "0":
if side_u == "BUY":
self._last_order_msg_cd = None
self._last_order_msg1 = None
else:
self._last_sell_msg_cd = None
self._last_sell_msg1 = None
out = j.get("output") or {}
ord_no = str(out.get("ODNO") or out.get("odno") or "").strip()
logger.info(
"✅ 해외주문 접수 side=%s %s qty=%s @%s excg=%s tr=%s odno=%s",
side_u, symb, qty_i, px_str, excg, tr_id, ord_no,
)
return ord_no or None
msg_cd = j.get("msg_cd", "")
msg1 = str(j.get("msg1", "") or "")
if side_u == "BUY":
self._last_order_msg_cd = msg_cd
self._last_order_msg1 = msg1
else:
self._last_sell_msg_cd = msg_cd
self._last_sell_msg1 = msg1
logger.error(
"[해외주문실패] side=%s %s rt_cd=%s msg_cd=%s msg1=%s",
side_u, symb, j.get("rt_cd"), msg_cd, msg1,
)
record_api_reject(
kind="overseas_order_reject",
side=side_u,
code=symb,
msg_cd=str(msg_cd or ""),
msg1=msg1,
rt_cd=str(j.get("rt_cd") or ""),
http=200,
path=str(path),
extra={"tr_id": tr_id, "mock": bool(self.mock)},
)
return None
except Exception as e:
if side_u == "BUY":
self._last_order_msg_cd = "EXC"
self._last_order_msg1 = str(e)[:200]
else:
self._last_sell_msg_cd = "EXC"
self._last_sell_msg1 = str(e)[:200]
logger.error("해외주문 예외 side=%s %s: %s", side_u, symb, e)
try:
from kis_trader.utils.api_reject_log import record_api_reject
record_api_reject(
kind="overseas_order_exception",
side=side_u,
code=symb,
msg_cd="EXC",
msg1=str(e)[:300],
path=str(path),
extra={"mock": bool(self.mock)},
)
except Exception:
pass
return None
def inquire_overseas_psamount(
self,
symbol: str,
price: float,
*,
exchange: Optional[str] = None,
) -> Dict:
"""
해외주식 매수가능금액조회 [v1_해외주식-014] (TTTS3007R / VTTS3007R).
Returns:
dict: ord_psbl_qty, max_ord_psbl_qty, ord_psbl_frcr_amt, exrt, ...
실패 시 빈 dict.
"""
symb = str(symbol or "").strip().upper()
px = float(price or 0)
if not symb or px <= 0:
return {}
excg = self._ovrs_excg_cd(exchange)
px_decimals = int(get_env_int("KIS_OVRS_PRICE_DECIMALS", 2))
px_str = f"{px:.{px_decimals}f}"
path = get_env_from_db(
"KIS_OVRS_PSAMOUNT_PATH",
"/uapi/overseas-stock/v1/trading/inquire-psamount",
) or "/uapi/overseas-stock/v1/trading/inquire-psamount"
tr_id = "VTTS3007R" if self.mock else "TTTS3007R"
params = {
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"OVRS_EXCG_CD": excg,
"OVRS_ORD_UNPR": px_str,
"ITEM_CD": symb,
}
try:
r = self._get(path, tr_id, params)
if r.status_code != 200:
logger.warning(
"해외매수가능 HTTP %s %s status=%s",
symb, excg, r.status_code,
)
return {}
j = r.json() if r is not None else {}
if j.get("rt_cd") != "0":
logger.warning(
"해외매수가능 실패 %s rt_cd=%s msg_cd=%s msg1=%s",
symb, j.get("rt_cd"), j.get("msg_cd"), j.get("msg1"),
)
return {}
out = j.get("output") or j.get("output1") or {}
if isinstance(out, list):
out = out[0] if out else {}
if not isinstance(out, dict):
return {}
# 수량 필드 정규화 (문자열→int)
def _qi(key: str) -> int:
try:
return int(float(str(out.get(key) or "0").replace(",", "") or 0))
except (TypeError, ValueError):
return 0
out = dict(out)
out["ord_psbl_qty"] = _qi("ord_psbl_qty")
out["max_ord_psbl_qty"] = _qi("max_ord_psbl_qty")
out["ovrs_max_ord_psbl_qty"] = _qi("ovrs_max_ord_psbl_qty")
return out
except Exception as e:
logger.warning("해외매수가능 예외 %s: %s", symb, e)
return {}
def buy_overseas_limit(
self,
symbol: str,
qty: int,
price: float,
*,
exchange: Optional[str] = None,
) -> Optional[str]:
"""해외 지정가 매수."""
return self.overseas_order(
symbol=symbol, qty=qty, price=price, side="BUY", exchange=exchange,
)
def sell_overseas_limit(
self,
symbol: str,
qty: int,
price: float,
*,
exchange: Optional[str] = None,
) -> Optional[str]:
"""해외 지정가 매도."""
return self.overseas_order(
symbol=symbol, qty=qty, price=price, side="SELL", exchange=exchange,
)
# ------------------------------------------------------------------
# 조건검색 (REST — 웹소켓은 공식 지원 안 함. 30초 폴링이 정석)
# ------------------------------------------------------------------
@@ -941,33 +1343,139 @@ class KISClient(SafeRequest):
def get_order_history_today(self, odno: str = "") -> Optional[dict]:
"""당일 주문 내역 조회 [국내주식-005 inquire-daily-ccld].
``odno`` 지정 시 해당 주문번호만 조회 → 응답 경량화로 장중 혼잡 시
모의서버 HTTP 500 회피율을 높인다. (모의가 ODNO 필터를 무시하면
기존처럼 전체를 받아 호출부에서 필터하므로 동작은 동일)
TR_ID: 공식 샘플 기준 3개월이내 = 실전 ``TTTC0081R`` / 모의 ``VTTC0081R``.
(레거시 ``*8001R`` 은 모의에서 '내역 없음'/빈맵이 나와 체결 재확인이 깨짐)
``odno`` 지정 시 해당 주문번호만 조회. 미지정 시 연속조회(모의 15건/페이지)로
output1 을 병합해 반환한다.
"""
tr_id = "VTTC8001R" if self.mock else "TTTC8001R"
# 공식 OpenAPI 샘플(inquire_daily_ccld) — env 로만 레거시 TR 오버라이드
default_tr = "VTTC0081R" if self.mock else "TTTC0081R"
tr_id = str(
get_env_from_db("DAILY_CCLD_TR_ID", default_tr) or default_tr
).strip() or default_tr
today = dt.now().strftime("%Y%m%d")
# 모의 1페이지≈15건 — 장중 체결 누락 방지. 과도 연속조회 방지 상한.
max_pages = max(1, int(get_env_int("DAILY_CCLD_MAX_PAGES", 20)))
try:
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
tr_id,
{
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"INQR_STRT_DT": today, "INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": str(odno or ""),
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
},
)
if r.status_code != 200:
merged_out1: list = []
last_j: Optional[dict] = None
fk100 = ""
nk100 = ""
tr_cont = ""
for _page in range(max_pages):
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
tr_id,
{
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": "",
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": str(odno or ""),
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": fk100,
"CTX_AREA_NK100": nk100,
"EXCG_ID_DVSN_CD": str(
get_env_from_db("DAILY_CCLD_EXCG_ID", "KRX") or "KRX"
),
},
tr_cont=tr_cont,
)
if r is None or r.status_code != 200:
if merged_out1 and last_j is not None:
break
return None
j = r.json()
if j.get("rt_cd") != "0":
if merged_out1 and last_j is not None:
break
return None
last_j = j
chunk = j.get("output1") or []
if isinstance(chunk, dict):
chunk = [chunk]
if isinstance(chunk, list):
merged_out1.extend(chunk)
# ODNO 단건이면 1페이지로 충분
if odno:
break
hdr_cont = ""
try:
hdr_cont = str(r.headers.get("tr_cont") or "").strip().upper()
except Exception:
hdr_cont = ""
body_fk = str(j.get("ctx_area_fk100") or "").strip()
body_nk = str(j.get("ctx_area_nk100") or "").strip()
if hdr_cont not in ("M", "F") or not body_nk:
break
fk100 = body_fk
nk100 = body_nk
tr_cont = "N"
# 연속조회 간 짧은 간격 (유량 존중)
import time as _t
_t.sleep(max(0.05, float(get_env_float("DAILY_CCLD_PAGE_GAP_SEC", 0.08))))
if last_j is None:
return None
j = r.json()
return j if j.get("rt_cd") == "0" else None
out = dict(last_j)
out["output1"] = merged_out1
return out
except Exception as e:
logger.debug("주문 내역 조회 실패: %s", e)
return None
def inquire_psbl_order(
self,
code: str = "005930",
*,
price: int = 0,
ord_dvsn: str = "01",
) -> Optional[Dict]:
"""매수가능조회 [국내주식-007] inquire-psbl-order (TTTC8908R / VTTC8908R).
주식잔고(inquire-balance) output2 에는 주문가능현금 필드가 없다.
주문가능현금·미수없는매수금액은 이 API 의
``ord_psbl_cash`` / ``nrcvb_buy_amt`` 를 쓴다 (공식 샘플 주석과 동일).
"""
pdno = str(code or "005930").strip()
tr_id = "VTTC8908R" if self.mock else "TTTC8908R"
try:
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-psbl-order",
tr_id,
{
"CANO": self.account_no,
"ACNT_PRDT_CD": self.account_code,
"PDNO": pdno,
"ORD_UNPR": str(int(price or 0)),
"ORD_DVSN": str(ord_dvsn or "01"),
"CMA_EVLU_AMT_ICLD_YN": str(
get_env_from_db("PSBL_ORDER_CMA_ICLD", "N") or "N"
),
"OVRS_ICLD_YN": str(
get_env_from_db("PSBL_ORDER_OVRS_ICLD", "N") or "N"
),
},
)
if r is None or r.status_code != 200:
return None
j = r.json()
if j.get("rt_cd") != "0":
return None
out = j.get("output") or j.get("output1") or {}
if isinstance(out, list) and out:
out = out[0]
return out if isinstance(out, dict) else None
except Exception as e:
logger.debug("매수가능조회 실패: %s", e)
return None
@staticmethod
def _execution_map_from_history(j: Optional[dict]) -> Dict[str, Dict]:
"""inquire-daily-ccld 응답 → {odno: {filled_qty, avg_price, code}}."""