fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,79 @@ async def _cache_set(key: str, ttl: int, data: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── 국내 주문체결 조회 (inquire-daily-ccld) ────────────────────────────────
|
||||
# KIS 공식 "주식일별주문체결조회"(3개월 이내): GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld
|
||||
# TR — 현행 공식 샘플(koreainvestment/open-trading-api examples_llm/domestic_stock/inquire_daily_ccld):
|
||||
# 실전 TTTC0081R / 모의 VTTC0081R. 구 TR(legacy/ 샘플·Postman v2.6): 실전 TTTC8001R / 모의 VTTC8001R.
|
||||
# 구 TR 의 현재 지원 여부는 레포 근거로 확정 불가 → 현행 TR 우선, rt_cd != "0" 이면 구 TR 1회 폴백
|
||||
# (성공한 쪽을 인스턴스에 기억). 응답 output1[] = 주문 단위 행
|
||||
# (odno, pdno, sll_buy_dvsn_cd, ord_qty, tot_ccld_qty, avg_prvs, tot_ccld_amt, rmn_qty, cncl_yn ...).
|
||||
# 연속조회: 응답 헤더 tr_cont F/M = 다음 있음 → 요청 헤더 tr_cont=N + CTX_AREA_FK100/NK100.
|
||||
DAILY_CCLD_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
|
||||
DAILY_CCLD_TR_IDS = { # (실전, 모의)
|
||||
"current": ("TTTC0081R", "VTTC0081R"),
|
||||
"legacy": ("TTTC8001R", "VTTC8001R"),
|
||||
}
|
||||
ORDER_EXEC_MAX_PAGES = 5 # 단건 체결조회 연속조회 상한 (ODNO 필터가 무시돼도 무한루프 방지)
|
||||
|
||||
|
||||
def _norm_kis_odno(value) -> str:
|
||||
"""KIS 주문번호 정규화 — 주문 응답(ODNO)과 조회 응답(odno)의 0 패딩 차이를 흡수."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0") or ("0" if s else "")
|
||||
|
||||
|
||||
def _to_int_or_none(value) -> Optional[int]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).replace(",", "")))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_order_execution_rows(rows, order_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행을 체결 dict 로 변환 (순수 함수).
|
||||
|
||||
반환 (일치 행 없으면 None):
|
||||
filled_qty : 총체결수량(tot_ccld_qty)
|
||||
filled_price : 평균체결가(avg_prvs). 0 이면 tot_ccld_amt / tot_ccld_qty 로 보정
|
||||
ordered_qty : 주문수량(ord_qty) — 모르면 None
|
||||
remaining_qty : 잔여수량(rmn_qty) — **필드가 없으면 None**(0 과 구분: 0 은 '잔량 소멸')
|
||||
cancelled : cncl_yn == "Y" (취소는 별도 행(orgn_odno)이라 원주문은 N 일 수 있음 — 로그용)
|
||||
cancel_confirmed_qty : 취소확인수량(cnc_cfrm_qty) — 없으면 None (로그용)
|
||||
rejected_qty : 거부수량(rjct_qty) — 없으면 None (로그용)
|
||||
"""
|
||||
target = _norm_kis_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_kis_odno(row.get("odno")) != target:
|
||||
continue
|
||||
filled_qty = _to_int_or_none(row.get("tot_ccld_qty")) or 0
|
||||
try:
|
||||
filled_price = float(str(row.get("avg_prvs") or 0).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
filled_price = 0.0
|
||||
if filled_price <= 0 and filled_qty > 0:
|
||||
amt = _to_int_or_none(row.get("tot_ccld_amt")) or 0
|
||||
filled_price = (amt / filled_qty) if amt > 0 else 0.0
|
||||
return {
|
||||
"filled_qty": max(0, filled_qty),
|
||||
"filled_price": float(filled_price) if filled_qty > 0 else 0.0,
|
||||
"ordered_qty": _to_int_or_none(row.get("ord_qty")),
|
||||
"remaining_qty": _to_int_or_none(row.get("rmn_qty")),
|
||||
"cancelled": str(row.get("cncl_yn") or "").upper() == "Y",
|
||||
"cancel_confirmed_qty": _to_int_or_none(row.get("cnc_cfrm_qty")),
|
||||
"rejected_qty": _to_int_or_none(row.get("rjct_qty")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _round_to_krx_tick(price: int) -> int:
|
||||
"""KRX 호가단위에 맞춰 가격을 올림(ceil) 처리. 모의투자 호가단위 오류 방지."""
|
||||
if price < 1_000:
|
||||
@@ -1278,45 +1351,105 @@ class KoreaInvestmentAPI:
|
||||
raise
|
||||
|
||||
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld).
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
|
||||
|
||||
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
|
||||
실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트.
|
||||
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
"VTTC8001R" if is_virtual else "TTTC8001R",
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
)
|
||||
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code)
|
||||
if result.get("rt_cd") != "0":
|
||||
# [2026-10-01] 이전에는 조용히 [] — 조회 실패와 '주문 없음' 이 구분되지 않았다.
|
||||
logger.warning(
|
||||
f"[get_todays_orders] {stock_code or '*'} 조회 실패 "
|
||||
f"rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}"
|
||||
)
|
||||
return []
|
||||
out = result.get("output1") or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_todays_orders] 조회 실패: {e}")
|
||||
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
|
||||
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
def _daily_ccld_request_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
tr_generation: str = "current",
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 요청 (tr_id, params) 구성 — 당일 조회. 모의/실전 TR 분기 포함.
|
||||
|
||||
`get_todays_orders` 와 `get_order_execution` 이 같은 요청 구성을 공유한다
|
||||
(2026-10-01 이전에는 후자가 존재하지 않는 `/trading/inquire-ccnl` 경로로 따로 조립했다).
|
||||
tr_generation: "current"(TTTC0081R/VTTC0081R) | "legacy"(TTTC8001R/VTTC8001R)
|
||||
"""
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
real_tr, virtual_tr = DAILY_CCLD_TR_IDS[tr_generation]
|
||||
tr_id = virtual_tr if is_virtual else real_tr
|
||||
params = {
|
||||
"CANO": self.account_number[:8],
|
||||
"ACNT_PRDT_CD": self.account_number[8:],
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00", # 00 전체 / 01 매도 / 02 매수
|
||||
"INQR_DVSN": "00", # 00 역순(최신 먼저)
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00", # 00 전체 / 01 체결 / 02 미체결
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id or "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": ctx_fk100 or "",
|
||||
"CTX_AREA_NK100": ctx_nk100 or "",
|
||||
"EXCG_ID_DVSN_CD": "KRX", # 거래소ID구분 (공식 샘플 기본값 KRX / NXT / SOR / ALL)
|
||||
}
|
||||
return tr_id, params
|
||||
|
||||
async def _daily_ccld_fetch_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
next_page: bool = False,
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 1페이지 호출 → (result, headers). 예외는 그대로 올린다.
|
||||
|
||||
현행 TR 이 rt_cd != "0" 이면 구 TR 로 1회 재시도하고, 구 TR 이 성공하면 이후 구 TR 을
|
||||
우선 쓴다(인스턴스 상태). 둘 다 실패하면 마지막 결과를 그대로 반환(호출부가 warning).
|
||||
연속조회는 요청 헤더 tr_cont=N (공식 샘플 규약).
|
||||
"""
|
||||
preferred = getattr(self, "_daily_ccld_tr_generation", "current")
|
||||
order = [preferred] + [g for g in ("current", "legacy") if g != preferred]
|
||||
extra = {"tr_cont": "N"} if next_page else None
|
||||
result, headers = {}, None
|
||||
for i, gen in enumerate(order):
|
||||
tr_id, params = self._daily_ccld_request_v2(
|
||||
stock_code=stock_code, order_id=order_id,
|
||||
ctx_fk100=ctx_fk100, ctx_nk100=ctx_nk100, tr_generation=gen,
|
||||
)
|
||||
result, headers = await self._make_request(
|
||||
"GET", DAILY_CCLD_ENDPOINT, tr_id, extra_headers=extra, params=params,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("rt_cd") == "0":
|
||||
if gen != preferred:
|
||||
logger.warning(
|
||||
f"[daily-ccld] TR {gen} 로 전환 (선호 TR {preferred} 실패) — tr_id={tr_id}"
|
||||
)
|
||||
self._daily_ccld_tr_generation = gen
|
||||
return result, headers
|
||||
if i == 0:
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[daily-ccld] tr_id={tr_id} 실패 rt_cd={_r.get('rt_cd')} "
|
||||
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 대체 TR 재시도"
|
||||
)
|
||||
return result, headers
|
||||
|
||||
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
|
||||
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.
|
||||
|
||||
@@ -1448,51 +1581,63 @@ class KoreaInvestmentAPI:
|
||||
async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]:
|
||||
"""주문 체결 내역 단건 조회.
|
||||
|
||||
국내: TTTC8001R (실전) / VTTC8001R (모의)
|
||||
국내: GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld,
|
||||
TTTC0081R/VTTC0081R (현행) → 실패 시 TTTC8001R/VTTC8001R (구)
|
||||
Returns:
|
||||
{"filled_qty": int, "filled_price": float}
|
||||
체결 미확인 시 filled_qty=0 반환.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
{"filled_qty": int, "filled_price": float} ← 기존 계약(불변)
|
||||
+ 추가 키(국내, 하위호환): "found"(주문행 발견), "ok"(조회 성공),
|
||||
"ordered_qty", "remaining_qty"(None=모름), "cancelled",
|
||||
"cancel_confirmed_qty", "rejected_qty"
|
||||
체결 미확인·조회 실패 시 filled_qty=0 반환.
|
||||
|
||||
if is_domestic:
|
||||
tr_id = "VTTC8001R" if is_virtual else "TTTC8001R"
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-ccnl",
|
||||
tr_id,
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id,
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
[2026-10-01] 이전 구현은 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출했다 — KIS 에
|
||||
그런 경로는 없다(국내 `inquire-ccnl` 은 `/quotations/` 아래 시세 체결 FHKST01010300,
|
||||
`/trading/inquire-ccnl` 은 해외 `/overseas-stock/` 의 체결조회 TTTS3035R). 그리고 실패를 debug 로
|
||||
삼키거나(예외) 아예 로그 없이(4xx 응답) filled_qty=0 을 돌려줬다 → 체결 확인이 항상 실패.
|
||||
"""
|
||||
if not is_domestic:
|
||||
# 해외 체결 조회(TTTS3035R)는 미구현 — 포트폴리오 동기화로 대체
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
return await self._get_order_execution_domestic_impl(order_id)
|
||||
|
||||
async def _get_order_execution_domestic_impl(self, order_id: str) -> Dict[str, Any]:
|
||||
"""국내 단건 체결조회 본체 — inquire-daily-ccld 를 ODNO 로 조회 + 클라이언트측 ODNO 매칭.
|
||||
|
||||
서버측 ODNO 필터가 기대대로 동작하지 않아도(전체 반환) 응답에서 주문번호로 다시 고르고,
|
||||
연속조회(tr_cont F/M → N)로 최대 ORDER_EXEC_MAX_PAGES 페이지까지 찾는다.
|
||||
"""
|
||||
empty = {"filled_qty": 0, "filled_price": 0.0, "found": False, "ok": False,
|
||||
"ordered_qty": None, "remaining_qty": None, "cancelled": False,
|
||||
"cancel_confirmed_qty": None, "rejected_qty": None}
|
||||
if not order_id:
|
||||
return empty
|
||||
try:
|
||||
ctx_fk, ctx_nk = "", ""
|
||||
for page in range(ORDER_EXEC_MAX_PAGES):
|
||||
result, headers = await self._daily_ccld_fetch_v2(
|
||||
order_id=order_id, ctx_fk100=ctx_fk, ctx_nk100=ctx_nk, next_page=page > 0,
|
||||
)
|
||||
output = result.get("output1", [])
|
||||
if output:
|
||||
row = output[0] if isinstance(output, list) else output
|
||||
filled_qty = int(row.get("tot_ccld_qty", 0) or 0)
|
||||
filled_price = float(row.get("avg_prvs", 0) or 0)
|
||||
return {"filled_qty": filled_qty, "filled_price": filled_price}
|
||||
# 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체)
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
if not isinstance(result, dict) or result.get("rt_cd") != "0":
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 실패 "
|
||||
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
|
||||
)
|
||||
return empty
|
||||
parsed = parse_order_execution_rows(result.get("output1") or [], order_id)
|
||||
if parsed is not None:
|
||||
return {**parsed, "found": True, "ok": True}
|
||||
tr_cont = (headers.get("tr_cont") if headers is not None else None) or ""
|
||||
ctx_fk = str(result.get("ctx_area_fk100") or "").strip()
|
||||
ctx_nk = str(result.get("ctx_area_nk100") or "").strip()
|
||||
if tr_cont not in ("F", "M") or not ctx_nk:
|
||||
break
|
||||
return {**empty, "ok": True} # 조회는 성공했으나 주문행 없음(접수 전/다른 날)
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}")
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 예외: {type(e).__name__}: {e}"
|
||||
)
|
||||
return empty
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
|
||||
Reference in New Issue
Block a user