- korea_investment_api: 주문 계열 엔드포인트는 내부 재시도 금지(_NON_IDEMPOTENT_ENDPOINTS). 상위 _place_order_with_retry 와 중첩돼 타임아웃 시 동일 주문 최대 12회 전송 가능했음. get_todays_orders() 추가. - trade_executor: 예외 후 재시도 전에 당일 주문내역으로 접수 여부 확인(_find_recent_order) → 접수됐으면 재주문하지 않고 기존 주문 반환. - daytrading/exit_service: 주문 체결 이후 상태 반영을 _finalize_exit 로 분리하고 asyncio.shield 로 보호 — 스케줄러 단계 타임아웃이 이 구간을 취소하면 브로커는 팔았는데 인메모리는 그대로라 다음 사이클이 재매도. - swing/position_manager: active 행이 2개면 MultipleResultsFound 로 해당 종목 청산이 통째로 스킵됐음(손절 미작동) → 최신 행 사용. - swing/exit_service: 큐 지연·DLQ 시 같은 분할매도가 반복되지 않도록 180초 인메모리 쿨다운. - portfolio_v2: 부분매도 ActiveTrade 잔량 갱신 + 전략명 매핑(daytrading→day_trade, us_swing→us_mean_reversion) — 매칭 실패로 active 행이 영구 잔존했음. - portfolio.sell_stock: safe_place_order 경유(세마포어·분산락) + DT 인메모리 잔량 반영. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 lines
4.4 KiB
Python
101 lines
4.4 KiB
Python
"""감사 P2 수정 검증: 주문 멱등성 / shield / 다중행 / v2·수동매도 / 스윙 쿨다운."""
|
|
import inspect
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.services import trade_executor as te
|
|
from app.services.korea_investment_api import KoreaInvestmentAPI
|
|
|
|
|
|
def test_order_endpoints_not_retried_internally():
|
|
"""주문은 내부 재시도 금지 — 상위 재시도와 중첩되면 동일 주문이 최대 12회 전송된다."""
|
|
src = inspect.getsource(KoreaInvestmentAPI._make_request_with_retry)
|
|
assert "_max_retries = 0 if endpoint in self._NON_IDEMPOTENT_ENDPOINTS" in src
|
|
# 재시도 판단은 전부 _max_retries 기준이어야 한다 (원본 self.max_retries 사용 금지)
|
|
body = src.split("for attempt in range(_max_retries + 1):", 1)[1]
|
|
assert "self.max_retries" not in body, "재시도 루프 안에 원본 max_retries 잔존"
|
|
assert body.count("attempt < _max_retries") == 2 # 5xx/429 경로 + 타임아웃 경로
|
|
eps = KoreaInvestmentAPI._NON_IDEMPOTENT_ENDPOINTS
|
|
assert "/uapi/domestic-stock/v1/trading/order-cash" in eps
|
|
assert "/uapi/overseas-stock/v1/trading/order" in eps
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_stops_when_order_already_accepted():
|
|
"""타임아웃 후 재시도 전에 접수된 주문을 발견하면 재주문하지 않는다."""
|
|
calls = {"n": 0}
|
|
|
|
async def _boom(*a, **k):
|
|
calls["n"] += 1
|
|
raise TimeoutError("no response")
|
|
|
|
dup = {"rt_cd": "0", "odno": "0000012345", "output": {"ODNO": "0000012345"}}
|
|
with patch.object(te.korea_investment_api, "place_order", _boom), \
|
|
patch.object(te, "_find_recent_order", AsyncMock(return_value=dup)):
|
|
out = await te._place_order_with_retry(
|
|
stock_code="005930", order_type="SELL", quantity=3, price=None, is_domestic=True,
|
|
)
|
|
assert out["odno"] == "0000012345"
|
|
assert calls["n"] == 1, "중복 확인 후에도 재주문이 나감"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_recent_order_matches_side_and_qty():
|
|
rows = [
|
|
{"pdno": "005930", "sll_buy_dvsn_cd": "01", "ord_qty": "3", "ord_tmd": "103000", "odno": "A1"},
|
|
{"pdno": "005930", "sll_buy_dvsn_cd": "02", "ord_qty": "3", "ord_tmd": "103000", "odno": "B1"},
|
|
]
|
|
from datetime import datetime as _dt
|
|
|
|
class _FakeDT(_dt):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return _dt(2026, 9, 23, 10, 30, 30)
|
|
|
|
with patch.object(te.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \
|
|
patch.object(te, "datetime", _FakeDT):
|
|
sell = await te._find_recent_order("005930", "SELL", 3)
|
|
buy = await te._find_recent_order("005930", "BUY", 3)
|
|
none = await te._find_recent_order("005930", "SELL", 5)
|
|
assert sell["odno"] == "A1" # 01 = 매도
|
|
assert buy["odno"] == "B1" # 02 = 매수
|
|
assert none is None # 수량 불일치
|
|
|
|
|
|
def test_dt_exit_state_update_is_shielded():
|
|
from app.services.daytrading import exit_service as es
|
|
src = inspect.getsource(es._execute_exit)
|
|
assert "asyncio.shield(_finalize_exit(" in src
|
|
fin = inspect.getsource(es._finalize_exit)
|
|
assert "remove_position" in fin and "enqueue_trade_history" in fin
|
|
|
|
|
|
def test_swing_active_trade_lookup_handles_multiple_rows():
|
|
from app.services.swing import position_manager as pm
|
|
src = inspect.getsource(pm.get_swing_active_trade)
|
|
code = "\n".join(l for l in src.splitlines() if "#" not in l and '"""' not in l)
|
|
assert "scalar_one_or_none" not in code, "행 2개면 예외 → 손절 통째 스킵"
|
|
assert "order_by(ActiveTrade.id.desc())" in src and "scalars().first()" in src
|
|
|
|
|
|
def test_swing_exit_has_repeat_guard():
|
|
from app.services.swing import exit_service as se
|
|
assert se.EXIT_COOLDOWN_SEC >= 60
|
|
src = inspect.getsource(se.check_and_execute_exit_v2)
|
|
assert "_recent_exits" in src and "쿨다운" in src
|
|
|
|
|
|
def test_v2_sell_maps_strategy_name_and_updates_quantity():
|
|
from app.api.endpoints import portfolio_v2 as v2
|
|
src = inspect.getsource(v2)
|
|
assert '"daytrading": "day_trade"' in src and '"us_swing": "us_mean_reversion"' in src
|
|
assert '"quantity": original_holding_qty - quantity' in src
|
|
|
|
|
|
def test_manual_sell_uses_lock_and_updates_dt_state():
|
|
from app.api.endpoints import portfolio as pf
|
|
src = inspect.getsource(pf.sell_stock)
|
|
assert "safe_place_order(" in src, "락 없는 직접 주문"
|
|
assert "day_trading_state" in src and "remove_position" in src
|