"""[2026-10-01] get_order_execution 경로 결함 수정 + 처음 살아나는 _verify_and_sync_execution 분기. 결함: 국내 체결조회가 존재하지 않는 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출해 체결 확인이 한 번도 성공하지 못했다(실패는 debug 로 삼키거나 4xx 는 로그조차 없음). 검증 방식 — KIS 는 conftest 가 클래스 메서드 단위로 차단한다. 여기서는 그보다 아래인 **HTTP 계층**(`_get_http_client().request`)을 인스턴스 패치로 가로챈다. 요청 구성 검사는 예외 발생형 목(인자 캡처 후 raise)으로, 응답 파싱은 가짜 응답으로 한다. 토큰(`_get_access_token`)은 conftest 차단을 인스턴스 패치로 덮어 고정 토큰을 준다. """ import asyncio import logging from unittest.mock import AsyncMock, patch import pytest from app.services import korea_investment_api as kis_mod from app.services import trade_executor as te from app.services.korea_investment_api import ( CircuitBreaker, DAILY_CCLD_ENDPOINT, parse_order_execution_rows, ) # 모듈 싱글톤(`kis_mod.korea_investment_api`)은 쓰지 않는다 — test_api_endpoints.py 가 수집 시점에 # MagicMock 으로 바꿔 두고 되돌리지 않는다(세션 전체 누수). 테스트마다 새 인스턴스를 만든다. # conftest 는 테스트 실행 시점에 클래스 메서드를 차단 스텁으로 바꾼다. 수집(import) 시점의 # 원본을 잡아 두고, HTTP 계층만 목킹한 상태에서 **실제 공개 메서드**를 검증한다. _ORIG_GET_TODAYS = kis_mod.KoreaInvestmentAPI.get_todays_orders _ORIG_GET_EXEC = kis_mod.KoreaInvestmentAPI.get_order_execution class _HttpCaptured(Exception): """요청 인자를 캡처한 뒤 던지는 예외 — 실제 전송이 일어나지 않았음을 보장.""" class _Resp: def __init__(self, body: dict, headers: dict | None = None, status: int = 200): self._body = body self.headers = headers or {} self.status_code = status self.text = "" def json(self): return self._body class _FakeClient: """httpx.AsyncClient.request 대체. responses 가 비면 캡처 예외를 던진다.""" def __init__(self, responses=None): self.calls = [] self.responses = list(responses or []) self.is_closed = False async def request(self, method, url, headers=None, timeout=None, **kwargs): self.calls.append({"method": method, "url": url, "headers": dict(headers or {}), **kwargs}) if not self.responses: raise _HttpCaptured(url) return self.responses.pop(0) class _Api: """HTTP 계층만 가로챈 새 KoreaInvestmentAPI 인스턴스 (토큰 캐시 파일·네트워크 미사용).""" def __init__(self, client: _FakeClient, base_url: str): api = object.__new__(kis_mod.KoreaInvestmentAPI) api.base_url = base_url api.app_key = "TEST_APP_KEY" api.app_secret = "TEST_APP_SECRET" api.account_number = "1234567801" api.access_token = None api.token_expires_at = None api.last_request_time = None api._throttle_lock = asyncio.Lock() api.min_request_interval = 0.0 api.max_retries = 0 api.retry_delay = 0.0 api.retry_backoff = 1.0 api.timeout = 5.0 api.semaphore = asyncio.Semaphore(5) api.monitor = kis_mod.APICallMonitor(window_size=10) api.circuit_breaker = CircuitBreaker(failure_threshold=99, recovery_timeout=1) # conftest 의 클래스 차단(_get_access_token)을 인스턴스 속성으로 덮는다 api._get_access_token = AsyncMock(return_value="TEST_TOKEN") api._get_http_client = AsyncMock(return_value=client) self.api = api def __enter__(self): return self.api def __exit__(self, *exc): return False REAL = "https://openapi.koreainvestment.com:9443" VIRTUAL = "https://openapivts.koreainvestment.com:29443" # ───────────────────────────────────────────────────────────────────────────── # 1. 요청 구성 (URL·TR_ID·파라미터) — 예외 발생형 HTTP 목으로 인자만 검사 # ───────────────────────────────────────────────────────────────────────────── @pytest.mark.asyncio @pytest.mark.parametrize("base_url,expected_tr", [(REAL, "TTTC0081R"), (VIRTUAL, "VTTC0081R")]) async def test_order_execution_request_uses_daily_ccld(base_url, expected_tr, caplog): client = _FakeClient() with _Api(client, base_url) as API, caplog.at_level(logging.WARNING): out = await API._get_order_execution_domestic_impl("0000117057") assert len(client.calls) == 1, "예외 발생 시 대체 TR 재시도·내부 재시도 없이 1회만" call = client.calls[0] assert call["method"] == "GET" assert call["url"] == f"{base_url}{DAILY_CCLD_ENDPOINT}" assert call["url"].endswith("/uapi/domestic-stock/v1/trading/inquire-daily-ccld") assert "inquire-ccnl" not in call["url"] assert call["headers"]["tr_id"] == expected_tr assert "tr_cont" not in call["headers"], "첫 페이지는 tr_cont 헤더 없음" p = call["params"] assert p["CANO"] == "12345678" and p["ACNT_PRDT_CD"] == "01" assert p["INQR_STRT_DT"] == p["INQR_END_DT"] and len(p["INQR_STRT_DT"]) == 8 assert p["ODNO"] == "0000117057" assert p["SLL_BUY_DVSN_CD"] == "00" and p["CCLD_DVSN"] == "00" assert p["INQR_DVSN"] == "00" and p["INQR_DVSN_3"] == "00" assert p["CTX_AREA_FK100"] == "" and p["CTX_AREA_NK100"] == "" assert p["EXCG_ID_DVSN_CD"] == "KRX" # 실패 → 기존 계약대로 filled_qty=0 + warning(debug 금지) assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 and out["ok"] is False assert any("[get_order_execution]" in r.message and r.levelno >= logging.WARNING for r in caplog.records) @pytest.mark.asyncio async def test_public_get_order_execution_hits_daily_ccld(): """공개 메서드(원본) 경유로도 inquire-daily-ccld 를 부르고 기존 2키 계약을 지킨다.""" body = {"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3", "avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]} client = _FakeClient([_Resp(body)]) with _Api(client, REAL) as API: out = await _ORIG_GET_EXEC(API, "0000000042", True) assert client.calls[0]["url"].endswith("/trading/inquire-daily-ccld") assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0 @pytest.mark.asyncio async def test_get_todays_orders_request_and_warning(caplog): """get_todays_orders 도 같은 요청 구성(ODNO 공란, PDNO=종목)이며 실패는 warning.""" client = _FakeClient() with _Api(client, VIRTUAL) as API, caplog.at_level(logging.WARNING): rows = await _ORIG_GET_TODAYS(API, "005930") assert rows == [] call = client.calls[0] assert call["url"] == f"{VIRTUAL}{DAILY_CCLD_ENDPOINT}" assert call["headers"]["tr_id"] == "VTTC0081R" assert call["params"]["PDNO"] == "005930" and call["params"]["ODNO"] == "" assert any("[get_todays_orders]" in r.message and r.levelno >= logging.WARNING for r in caplog.records) _, p_exec = API._daily_ccld_request_v2(order_id="1") assert set(call["params"]) == set(p_exec), "두 메서드의 파라미터 키 집합이 같아야 한다" @pytest.mark.asyncio async def test_get_todays_orders_success_returns_rows(): rows = [{"odno": "1", "pdno": "005930"}] client = _FakeClient([_Resp({"rt_cd": "0", "output1": rows})]) with _Api(client, REAL) as API: assert await _ORIG_GET_TODAYS(API, "005930") == rows @pytest.mark.asyncio async def test_legacy_tr_fallback_on_error_rt_cd(): """현행 TR 이 rt_cd!=0 이면 구 TR(TTTC8001R) 1회 재시도, 성공하면 이후 구 TR 우선.""" ok = {"rt_cd": "0", "output1": [{"odno": "0000000777", "tot_ccld_qty": "5", "avg_prvs": "10100", "ord_qty": "5", "rmn_qty": "0"}]} client = _FakeClient([ _Resp({"rt_cd": "1", "msg_cd": "OPSQ0002", "msg1": "없는 서비스 코드"}), _Resp(ok), ]) with _Api(client, REAL) as API: out = await API._get_order_execution_domestic_impl("777") assert [c["headers"]["tr_id"] for c in client.calls] == ["TTTC0081R", "TTTC8001R"] assert out["filled_qty"] == 5 and out["filled_price"] == 10100.0 and out["found"] # 다음 호출은 구 TR 부터 client.responses.append(_Resp(ok)) await API._get_order_execution_domestic_impl("777") assert client.calls[-1]["headers"]["tr_id"] == "TTTC8001R" @pytest.mark.asyncio async def test_pagination_uses_tr_cont_n_and_ctx_keys(): page1 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000001", "tot_ccld_qty": "1"}], "ctx_area_fk100": "FK1", "ctx_area_nk100": "NK1"}, headers={"tr_cont": "M"}) page2 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3", "avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]}, headers={"tr_cont": "D"}) client = _FakeClient([page1, page2]) with _Api(client, VIRTUAL) as API: out = await API._get_order_execution_domestic_impl("0000000042") assert len(client.calls) == 2 assert client.calls[1]["headers"]["tr_cont"] == "N" assert client.calls[1]["params"]["CTX_AREA_FK100"] == "FK1" assert client.calls[1]["params"]["CTX_AREA_NK100"] == "NK1" assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0 @pytest.mark.asyncio async def test_404_like_response_logs_warning(caplog): """옛 경로처럼 4xx 가 돌아와도 로그 없이 0 을 돌려주던 동작 → warning.""" client = _FakeClient([_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404), _Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404)]) with _Api(client, REAL) as API, caplog.at_level(logging.WARNING): out = await API._get_order_execution_domestic_impl("123") assert out["filled_qty"] == 0 and out["ok"] is False assert any("체결조회 실패" in r.message for r in caplog.records) # ───────────────────────────────────────────────────────────────────────────── # 2. 응답 파싱 (순수 함수) # ───────────────────────────────────────────────────────────────────────────── def test_parse_full_fill_with_zero_padding(): rows = [{"odno": "0000117057", "tot_ccld_qty": "10", "avg_prvs": "71200", "ord_qty": "10", "rmn_qty": "0", "cncl_yn": "N"}] out = parse_order_execution_rows(rows, "117057") assert out == {"filled_qty": 10, "filled_price": 71200.0, "ordered_qty": 10, "remaining_qty": 0, "cancelled": False, "cancel_confirmed_qty": None, "rejected_qty": None} def test_parse_cancel_and_reject_quantities(): rows = [{"odno": "3", "tot_ccld_qty": "4", "avg_prvs": "100", "ord_qty": "10", "rmn_qty": "0", "cnc_cfrm_qty": "5", "rjct_qty": "1", "cncl_yn": "N"}] out = parse_order_execution_rows(rows, "3") assert out["cancel_confirmed_qty"] == 5 and out["rejected_qty"] == 1 assert out["cancelled"] is False def test_parse_partial_and_avg_fallback(): rows = [{"odno": "0000000099", "tot_ccld_qty": "4", "avg_prvs": "0", "tot_ccld_amt": "40400", "ord_qty": "10", "rmn_qty": "6"}] out = parse_order_execution_rows(rows, "0000000099") assert out["filled_qty"] == 4 and out["filled_price"] == 10100.0 assert out["remaining_qty"] == 6 def test_parse_unfilled_and_missing_remaining_field(): out = parse_order_execution_rows([{"odno": "5", "tot_ccld_qty": "0", "avg_prvs": "0", "ord_qty": "3"}], "5") assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 assert out["remaining_qty"] is None, "필드 없음은 0(소멸)이 아니라 None(모름)" def test_parse_empty_or_no_match(): assert parse_order_execution_rows([], "1") is None assert parse_order_execution_rows(None, "1") is None assert parse_order_execution_rows([{"odno": "2", "tot_ccld_qty": "1"}], "1") is None assert parse_order_execution_rows([{"odno": "1"}], "") is None # 첫 행이 아니라 주문번호가 일치하는 행을 고른다 (옛 구현은 output[0] 고정) rows = [{"odno": "8", "tot_ccld_qty": "9", "avg_prvs": "1"}, {"odno": "7", "tot_ccld_qty": "2", "avg_prvs": "300", "ord_qty": "2", "rmn_qty": "0"}] assert parse_order_execution_rows(rows, "7")["filled_qty"] == 2 @pytest.mark.asyncio async def test_overseas_returns_legacy_contract_without_http(): client = _FakeClient() with _Api(client, REAL) as API: out = await _ORIG_GET_EXEC(API, "123", False) assert out == {"filled_qty": 0, "filled_price": 0.0} and client.calls == [] # ───────────────────────────────────────────────────────────────────────────── # 3. _verify_and_sync_execution — 처음 살아나는 분기의 동작 # ───────────────────────────────────────────────────────────────────────────── class _ReorderCalled(BaseException): """자동 재주문 경로가 호출됨 — BaseException 이라 `except Exception` 에 삼켜지지 않는다.""" def _reorder_forbidden(*args, **kwargs): raise _ReorderCalled(f"_reorder_unfilled 호출됨: {kwargs}") class _VerifyHarness: def __init__(self, exec_results): self.exec_mock = AsyncMock(side_effect=list(exec_results)) self.upd_by_order = AsyncMock() self.upd_by_id = AsyncMock() self.sync = AsyncMock() self.reorder = _reorder_forbidden self._real_sleep = asyncio.sleep async def _fast_sleep(self, *_a, **_k): await self._real_sleep(0) async def run(self, ordered=10, order_type="BUY", order_price=10000.0): with patch.object(te.korea_investment_api, "get_order_execution", self.exec_mock), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", self.upd_by_order), \ patch("app.services.db_worker.enqueue_update_trade_history", self.upd_by_id), \ patch.object(te, "_delayed_sync_portfolio", self.sync), \ patch.object(te, "_reorder_unfilled", self.reorder), \ patch.object(te.asyncio, "sleep", self._fast_sleep): await te._verify_and_sync_execution( order_id="0000000042", stock_code="005930", stock_name="삼성전자", ordered_quantity=ordered, order_price=order_price, order_type=order_type, is_domestic=True, exchange_code="KRX", db_trade_id=0, delay=0, ) await self._real_sleep(0) # create_task 로 띄운 재주문 태스크 실행 기회 def _ex(filled, price=10050.0, remaining=None, cancelled=False, ordered=10): return {"filled_qty": filled, "filled_price": price if filled else 0.0, "found": True, "ok": True, "ordered_qty": ordered, "remaining_qty": remaining, "cancelled": cancelled} @pytest.mark.asyncio async def test_verify_full_fill_updates_completed_once(): h = _VerifyHarness([_ex(10, remaining=0)]) await h.run() assert h.exec_mock.await_count == 1 h.upd_by_order.assert_awaited_once() order_id, fields = h.upd_by_order.await_args.args assert order_id == "0000000042" assert fields["status"] == "COMPLETED" and fields["executed_quantity"] == 10 assert float(fields["executed_price"]) == 10050.0 assert float(fields["slippage"]) == pytest.approx(0.5) h.sync.assert_awaited_once() @pytest.mark.asyncio async def test_verify_partial_with_live_remainder_never_reorders(): """잔량이 KIS 에 살아 있으면 재주문 = 중복 주문. 옛 코드는 첫 부분체결에서 바로 재주문했다.""" h = _VerifyHarness([_ex(4, remaining=6)] * 5) await h.run() assert h.exec_mock.await_count == 5, "부분체결 첫 관측에서 멈추지 않고 계속 폴링" h.upd_by_order.assert_not_called() h.sync.assert_awaited_once() @pytest.mark.asyncio async def test_verify_partial_then_full_within_polls(): h = _VerifyHarness([_ex(4, remaining=6), _ex(10, remaining=0)]) await h.run() assert h.exec_mock.await_count == 2 fields = h.upd_by_order.await_args.args[1] assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED" @pytest.mark.asyncio async def test_verify_partial_unknown_remainder_is_treated_as_live(): h = _VerifyHarness([_ex(4, remaining=None)] * 5) await h.run() h.upd_by_order.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("order_type", ["BUY", "SELL"]) async def test_verify_partial_dead_remainder_records_and_never_reorders(order_type, caplog): """잔량 소멸(취소·거부·만료) — 체결분만 기록, 자동 재주문 금지(가드 전부 우회하므로), 수동 확인 warning.""" res = _ex(4, remaining=0) res.update({"cancel_confirmed_qty": 6, "rejected_qty": 0}) h = _VerifyHarness([res]) with caplog.at_level(logging.WARNING): await h.run(order_type=order_type) # 재주문 호출 시 _ReorderCalled 로 테스트 실패 fields = h.upd_by_order.await_args.args[1] assert fields["executed_quantity"] == 4 assert fields["status"] == "COMPLETED", "PARTIAL 은 재진입 가드·손익 집계에서 빠진다" msgs = [r.message for r in caplog.records if "[PARTIAL_FILL]" in r.message] assert msgs, "잔량 소멸 warning 누락" m = msgs[-1] for frag in ("005930", "0000000042", order_type, "4/10", "취소확인=6", "거부=0", "수동 확인"): assert frag in m, (frag, m) h.sync.assert_awaited_once() @pytest.mark.asyncio async def test_verify_partial_cancelled_does_not_reorder(): h = _VerifyHarness([_ex(4, remaining=0, cancelled=True)]) await h.run() h.upd_by_order.assert_awaited_once() def test_verify_source_has_no_reorder_call(): """정적 고정 — _verify_and_sync_execution 본문에서 _reorder_unfilled 호출 금지.""" import inspect body = inspect.getsource(te._verify_and_sync_execution).split('"""', 2)[2] assert "_reorder_unfilled(" not in body @pytest.mark.asyncio async def test_verify_unfilled_no_db_write_and_warns(caplog): h = _VerifyHarness([_ex(0, remaining=10)] * 5) with caplog.at_level(logging.WARNING): await h.run() h.upd_by_order.assert_not_called() h.sync.assert_awaited_once() assert any("체결 미확인" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_verify_overfill_is_clamped(): h = _VerifyHarness([_ex(15, remaining=0)]) await h.run() assert h.upd_by_order.await_args.args[1]["executed_quantity"] == 10 @pytest.mark.asyncio async def test_verify_legacy_two_key_result_still_works(): """get_order_execution 의 기존 2키 계약만 주는 목(해외/옛 호출부)과도 동작.""" h = _VerifyHarness([{"filled_qty": 10, "filled_price": 9950.0}]) await h.run(order_type="SELL") fields = h.upd_by_order.await_args.args[1] assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED"