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:
2026-10-01 17:37:29 +09:00
parent 599de3b52e
commit 45677c8fbb
19 changed files with 2873 additions and 166 deletions

View File

@@ -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]:
"""주문 취소"""