Files
auto-stock/backend/tests/test_p2_audit_fixes.py
shuim 8971a648e2 fix(P2): 주문 멱등성·취소 보호·다중행·v2/수동매도 정합
- 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>
2026-09-23 10:57:40 +09:00

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