From 8971a648e2d1e0adae2e7312e355d2af70e8b7fa Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 10:57:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(P2):=20=EC=A3=BC=EB=AC=B8=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=EC=84=B1=C2=B7=EC=B7=A8=EC=86=8C=20=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=C2=B7=EB=8B=A4=EC=A4=91=ED=96=89=C2=B7v2/=EC=88=98=EB=8F=99?= =?UTF-8?q?=EB=A7=A4=EB=8F=84=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/app/api/endpoints/portfolio.py | 25 ++++- backend/app/api/endpoints/portfolio_v2.py | 21 +++- .../app/services/daytrading/exit_service.py | 15 +++ backend/app/services/korea_investment_api.py | 65 ++++++++++-- backend/app/services/swing/exit_service.py | 19 ++++ .../app/services/swing/position_manager.py | 10 +- backend/app/services/trade_executor.py | 55 +++++++++- backend/tests/test_p2_audit_fixes.py | 100 ++++++++++++++++++ 8 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 backend/tests/test_p2_audit_fixes.py diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index b3a642c..11ca3c9 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -1477,11 +1477,15 @@ async def sell_stock( original_avg_price = float(holding.average_price or 0) # 매도 주문 실행 - order_result = await korea_investment_api.place_order( + # [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로 + # 스윙/데이트레이딩 자동 청산과 동시에 매도되는 것을 막는다. + from app.services.trade_executor import safe_place_order + order_result = await safe_place_order( stock_code=stock_code, order_type='SELL', quantity=quantity, - price=price # None이면 시장가 + price=price, # None이면 시장가 + is_domestic=True, ) if not order_result or order_result.get('rt_cd') != '0': @@ -1547,6 +1551,23 @@ async def sell_stock( except Exception as _at_err: logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}") + # 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다. + # (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도) + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + async with _dts.trade_lock: + _dt_pos = _dts.positions.get(stock_code) + if _dt_pos: + _left = max(0, int(_dt_pos.get("quantity", 0) or 0) - quantity) + if _left <= 0: + _dts.remove_position(stock_code) + logger.info(f"[SELL] DT 포지션 제거(수동 매도): {stock_code}") + else: + _dt_pos["quantity"] = _left + logger.info(f"[SELL] DT 포지션 잔량 갱신(수동 매도): {stock_code} → {_left}주") + except Exception as _dt_err: + logger.warning(f"[SELL] DT 인메모리 반영 실패 {stock_code}: {_dt_err}") + # ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거) if quantity >= original_holding_qty: portfolio_cache.holdings = [ diff --git a/backend/app/api/endpoints/portfolio_v2.py b/backend/app/api/endpoints/portfolio_v2.py index cb4b560..e854311 100644 --- a/backend/app/api/endpoints/portfolio_v2.py +++ b/backend/app/api/endpoints/portfolio_v2.py @@ -485,18 +485,35 @@ async def sell_stock_v2( sell_history_payload["user_id"] = ctx.user_id await enqueue_trade_history(sell_history_payload) + # Portfolio.strategy_type('daytrading'/'us_swing')과 ActiveTrade.strategy_name + # ('day_trade'/'us_mean_reversion')은 값 체계가 달라 그대로 넘기면 매칭에 실패해 + # active 행이 영구 잔존한다(2026-09-23). + _AT_STRATEGY = {"daytrading": "day_trade", "us_swing": "us_mean_reversion"} + _at_name = _AT_STRATEGY.get(holding.strategy_type or "swing", holding.strategy_type or "swing") + if quantity >= original_holding_qty: await db.delete(holding) if ctx.is_legacy: # M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용 # (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로 # 남는 결함 방지). - await enqueue_close_active_trade(stock_code, strategy_name=holding.strategy_type) + await enqueue_close_active_trade(stock_code, strategy_name=_at_name) else: - await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=holding.strategy_type) + await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=_at_name) else: holding.quantity -= quantity holding.current_price = Decimal(str(executed_price)) + # 부분매도 시 ActiveTrade 잔량 갱신 — 누락 시 다음 청산이 매도 전 수량으로 판다 + from app.services.db_worker import enqueue_active_trade_upsert as _enq_at + _at_payload = { + "stock_code": stock_code, + "strategy_name": _at_name, + "quantity": original_holding_qty - quantity, + "current_price": float(executed_price), + } + if not ctx.is_legacy: + _at_payload["user_id"] = ctx.user_id + await _enq_at(_at_payload) await db.commit() logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원") diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 8fb83a4..a6e73d3 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -244,6 +244,21 @@ async def _execute_exit( logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}") return None + # [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계 + # 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가 + # 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다. + return await asyncio.shield(_finalize_exit( + stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit + )) + + +async def _finalize_exit( + stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, + order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool, +) -> dict | None: + """체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간.""" + entry_price = pos.get("entry_price", 0) + stock_name = pos.get("stock_name", stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") profit_loss = (price - entry_price) * quantity profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 928cf45..022ba61 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -500,13 +500,24 @@ class KoreaInvestmentAPI: async with self.semaphore: return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs) + # 주문 계열 엔드포인트 — 재전송 시 중복 체결 위험이 있어 내부 재시도를 하지 않는다. + # (상위 trade_executor._place_order_with_retry 가 체결 확인 후 재시도를 판단한다) + _NON_IDEMPOTENT_ENDPOINTS = ( + "/uapi/domestic-stock/v1/trading/order-cash", + "/uapi/domestic-stock/v1/trading/order-rvsecncl", + "/uapi/overseas-stock/v1/trading/order", + ) + async def _make_request_with_retry(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): - """재시도 로직을 포함한 실제 요청 실행""" + """재시도 로직을 포함한 실제 요청 실행 (주문 엔드포인트는 재시도 없음)""" start_time = time.time() retry_count = 0 last_exception = None - - for attempt in range(self.max_retries + 1): + + # 주문은 1회만 전송 — 타임아웃/5xx 에도 재전송하지 않는다(중복 주문 방지) + _max_retries = 0 if endpoint in self._NON_IDEMPOTENT_ENDPOINTS else self.max_retries + + for attempt in range(_max_retries + 1): try: # 쓰로틀링: 최소 요청 간격 보장 # _throttle_lock으로 "간격 계산 + sleep + last_request_time 갱신"을 직렬화하여 @@ -556,9 +567,9 @@ class KoreaInvestmentAPI: # 서버 오류 (5xx, 429) — 재시도 elif response.status_code in [429, 500, 502, 503, 504]: retry_count += 1 - if attempt < self.max_retries: + if attempt < _max_retries: delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3) - logger.warning(f"⏳ API 재시도 {attempt + 1}/{self.max_retries}: {response.status_code}, {delay:.1f}초 후") + logger.warning(f"⏳ API 재시도 {attempt + 1}/{_max_retries}: {response.status_code}, {delay:.1f}초 후") await asyncio.sleep(delay) continue else: @@ -577,9 +588,9 @@ class KoreaInvestmentAPI: retry_count += 1 last_exception = e - if attempt < self.max_retries: + if attempt < _max_retries: delay = self.retry_delay * (self.retry_backoff ** attempt) + random.uniform(0, 0.3) - logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{self.max_retries})") + logger.warning(f"⏳ API 타임아웃, {delay:.1f}초 후 재시도 ({attempt + 1}/{_max_retries})") await asyncio.sleep(delay) continue else: @@ -1092,6 +1103,46 @@ class KoreaInvestmentAPI: logger.error(f"Error fetching balance: {e}") raise + async def get_todays_orders(self, stock_code: str = "") -> list[dict]: + """당일 주문체결 내역 조회 (inquire-daily-ccld). + + 주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도. + 실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트. + """ + try: + account_number_prefix = self.account_number[:8] + account_number_suffix = self.account_number[8:] + is_virtual = "vts" in self.base_url.lower() + today = datetime.now().strftime("%Y%m%d") + result, _ = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + "VTTC8001R" if is_virtual else "TTTC8001R", + params={ + "CANO": account_number_prefix, + "ACNT_PRDT_CD": account_number_suffix, + "INQR_STRT_DT": today, + "INQR_END_DT": today, + "SLL_BUY_DVSN_CD": "00", + "INQR_DVSN": "00", + "PDNO": stock_code or "", + "CCLD_DVSN": "00", + "ORD_GNO_BRNO": "", + "ODNO": "", + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if result.get("rt_cd") != "0": + return [] + out = result.get("output1") or [] + return out if isinstance(out, list) else [out] + except Exception as e: + logger.debug(f"[get_todays_orders] 조회 실패: {e}") + return [] + async def get_buyable_cash(self, stock_code: str = "005930") -> float: """주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API. diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index eda8896..35439d8 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -20,6 +20,7 @@ strategy_type='swing' 포지션만 처리하도록 분리한 서비스. - 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호) """ import logging +import time from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -41,6 +42,10 @@ from app.core.json_helpers import _dumps logger = logging.getLogger(__name__) +# 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드 +_recent_exits: dict[str, float] = {} +EXIT_COOLDOWN_SEC = 180 + def _check_momentum_exit( current_price: float, @@ -160,6 +165,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: try: # strategy_type='swing' 포지션만 조회 + _now_ts = time.time() holdings = await get_swing_holdings(db) if not holdings: logger.debug("[SwingExit] 스윙 보유 종목 없음") @@ -285,6 +291,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: if not exit_signal: continue + # [2026-09-23] 상태 저장(remaining_pct·Portfolio)이 db_worker 큐 경유라 + # 큐 지연·DLQ 시 다음 사이클이 옛 값을 읽어 같은 분할매도를 반복한다. + # 인메모리 쿨다운으로 직전 청산 직후 재매도를 차단한다. + _last_exit = _recent_exits.get(stock_code) + if _last_exit is not None and (_now_ts - _last_exit) < EXIT_COOLDOWN_SEC: + logger.info( + f"[SwingExit] {stock_code}: 직전 청산 {int(_now_ts - _last_exit)}초 전 " + f"— 쿨다운({EXIT_COOLDOWN_SEC}초) 중 스킵" + ) + continue + # ── 매도 수량 결정 (소량 분할 방지) ───────────────────── sell_pct = getattr(exit_signal, "sell_pct", 1.0) if sell_pct < 1.0 and quantity <= 3: @@ -339,6 +356,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: "strategy_type": "swing", }) + _recent_exits[stock_code] = time.time() + # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) from app.services.portfolio_cache_bridge import reflect_sell await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty) diff --git a/backend/app/services/swing/position_manager.py b/backend/app/services/swing/position_manager.py index 3971c44..cded6d1 100644 --- a/backend/app/services/swing/position_manager.py +++ b/backend/app/services/swing/position_manager.py @@ -78,14 +78,20 @@ async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTra Returns: ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None """ + # [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고, + # 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다. + # db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다. result = await db.execute( - select(ActiveTrade).where( + select(ActiveTrade) + .where( ActiveTrade.stock_code == stock_code, ActiveTrade.status == "active", ActiveTrade.strategy_name != "day_trade", ) + .order_by(ActiveTrade.id.desc()) + .limit(1) ) - return result.scalar_one_or_none() + return result.scalars().first() async def count_swing_positions(db: AsyncSession) -> int: diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 08867f3..d3621d5 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -11,7 +11,7 @@ import asyncio import logging import time from decimal import Decimal -from datetime import timedelta +from datetime import datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -155,7 +155,17 @@ async def _place_order_with_retry( await asyncio.sleep(wait) except Exception as e: # 네트워크 타임아웃 등 예외 → 재시도 대상 + # [2026-09-23] 단, KIS가 주문을 접수한 뒤 응답만 못 받은 경우가 있으므로 + # 재시도 전에 당일 체결 내역으로 중복 여부를 확인한다(멱등성 보강). last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} + if is_domestic: + _dup = await _find_recent_order(stock_code, order_type, quantity) + if _dup: + logger.warning( + f"[ORDER_DUP] {stock_code} {order_type} 예외 발생했으나 주문 접수 확인됨 " + f"(odno={_dup.get('odno')}) → 재시도 중단" + ) + return _dup if attempt < max_retries - 1: wait = 1 * (2 ** attempt) logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}") @@ -165,6 +175,49 @@ async def _place_order_with_retry( return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None} +async def _find_recent_order(stock_code: str, order_type: str, quantity: int) -> dict | None: + """직전 주문이 실제로 KIS에 접수됐는지 당일 주문체결 내역으로 확인. + + 타임아웃/연결 끊김으로 응답을 못 받았지만 주문은 접수된 경우, 그대로 재시도하면 + 중복 체결이 된다. 최근 2분 내 같은 종목·매매구분·수량 주문이 있으면 그 주문을 반환한다. + 조회 실패 시 None (판단 불가 → 호출측의 기존 재시도 로직을 따른다). + """ + try: + rows = await korea_investment_api.get_todays_orders(stock_code) + except Exception as e: + logger.debug(f"[ORDER_DUP] 주문내역 조회 실패 ({stock_code}): {e}") + return None + if not rows: + return None + + _now = datetime.now() + _side = "02" if order_type.upper() == "BUY" else "01" # KIS: 01=매도, 02=매수 + for row in rows: + try: + if row.get("pdno") != stock_code: + continue + if row.get("sll_buy_dvsn_cd") not in (_side, None, ""): + continue + if int(row.get("ord_qty", 0) or 0) != int(quantity): + continue + _t = str(row.get("ord_tmd") or "") # HHMMSS + if len(_t) == 6: + _ordered = _now.replace( + hour=int(_t[:2]), minute=int(_t[2:4]), second=int(_t[4:]), microsecond=0 + ) + if (_now - _ordered).total_seconds() > 120: + continue + return { + "rt_cd": "0", + "msg1": "기존 주문 확인(중복 방지)", + "odno": row.get("odno"), + "output": {"ODNO": row.get("odno"), "ord_prc": row.get("ord_unpr")}, + } + except Exception: + continue + return None + + # ── [C1] 공통 주문 래퍼 — 분산락+세마포어 보호 (daytrading/swing에서 import) ── async def safe_place_order( stock_code: str, diff --git a/backend/tests/test_p2_audit_fixes.py b/backend/tests/test_p2_audit_fixes.py new file mode 100644 index 0000000..2a7e5da --- /dev/null +++ b/backend/tests/test_p2_audit_fixes.py @@ -0,0 +1,100 @@ +"""감사 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