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

@@ -0,0 +1,411 @@
"""[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"