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>
412 lines
20 KiB
Python
412 lines
20 KiB
Python
"""[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"
|