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>
This commit is contained in:
2026-09-23 10:57:40 +09:00
parent 9cdd12b41e
commit 8971a648e2
8 changed files with 296 additions and 14 deletions

View File

@@ -1477,11 +1477,15 @@ async def sell_stock(
original_avg_price = float(holding.average_price or 0) 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, stock_code=stock_code,
order_type='SELL', order_type='SELL',
quantity=quantity, quantity=quantity,
price=price # None이면 시장가 price=price, # None이면 시장가
is_domestic=True,
) )
if not order_result or order_result.get('rt_cd') != '0': if not order_result or order_result.get('rt_cd') != '0':
@@ -1547,6 +1551,23 @@ async def sell_stock(
except Exception as _at_err: except Exception as _at_err:
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_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 재동기화 전에도 화면에서 즉시 제거) # ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거)
if quantity >= original_holding_qty: if quantity >= original_holding_qty:
portfolio_cache.holdings = [ portfolio_cache.holdings = [

View File

@@ -485,18 +485,35 @@ async def sell_stock_v2(
sell_history_payload["user_id"] = ctx.user_id sell_history_payload["user_id"] = ctx.user_id
await enqueue_trade_history(sell_history_payload) 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: if quantity >= original_holding_qty:
await db.delete(holding) await db.delete(holding)
if ctx.is_legacy: if ctx.is_legacy:
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용 # M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로 # (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: 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: else:
holding.quantity -= quantity holding.quantity -= quantity
holding.current_price = Decimal(str(executed_price)) 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() await db.commit()
logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원") logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원")

View File

@@ -244,6 +244,21 @@ async def _execute_exit(
logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}") logger.error(f"[DayExit] 매도 주문 실패 ({stock_code}): {err}")
return None 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", "") order_id = order_resp.get("output", {}).get("ODNO", "")
profit_loss = (price - entry_price) * quantity profit_loss = (price - entry_price) * quantity
profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0

View File

@@ -500,13 +500,24 @@ class KoreaInvestmentAPI:
async with self.semaphore: async with self.semaphore:
return await self._make_request_with_retry(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs) 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): 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() start_time = time.time()
retry_count = 0 retry_count = 0
last_exception = None 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: try:
# 쓰로틀링: 최소 요청 간격 보장 # 쓰로틀링: 최소 요청 간격 보장
# _throttle_lock으로 "간격 계산 + sleep + last_request_time 갱신"을 직렬화하여 # _throttle_lock으로 "간격 계산 + sleep + last_request_time 갱신"을 직렬화하여
@@ -556,9 +567,9 @@ class KoreaInvestmentAPI:
# 서버 오류 (5xx, 429) — 재시도 # 서버 오류 (5xx, 429) — 재시도
elif response.status_code in [429, 500, 502, 503, 504]: elif response.status_code in [429, 500, 502, 503, 504]:
retry_count += 1 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) 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) await asyncio.sleep(delay)
continue continue
else: else:
@@ -577,9 +588,9 @@ class KoreaInvestmentAPI:
retry_count += 1 retry_count += 1
last_exception = e 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) 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) await asyncio.sleep(delay)
continue continue
else: else:
@@ -1092,6 +1103,46 @@ class KoreaInvestmentAPI:
logger.error(f"Error fetching balance: {e}") logger.error(f"Error fetching balance: {e}")
raise 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: async def get_buyable_cash(self, stock_code: str = "005930") -> float:
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API. """주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.

View File

@@ -20,6 +20,7 @@ strategy_type='swing' 포지션만 처리하도록 분리한 서비스.
- 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호) - 매도 주문: safe_place_order() 사용 (세마포어+분산락 보호)
""" """
import logging import logging
import time
from datetime import datetime from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -41,6 +42,10 @@ from app.core.json_helpers import _dumps
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드
_recent_exits: dict[str, float] = {}
EXIT_COOLDOWN_SEC = 180
def _check_momentum_exit( def _check_momentum_exit(
current_price: float, current_price: float,
@@ -160,6 +165,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
try: try:
# strategy_type='swing' 포지션만 조회 # strategy_type='swing' 포지션만 조회
_now_ts = time.time()
holdings = await get_swing_holdings(db) holdings = await get_swing_holdings(db)
if not holdings: if not holdings:
logger.debug("[SwingExit] 스윙 보유 종목 없음") logger.debug("[SwingExit] 스윙 보유 종목 없음")
@@ -285,6 +291,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
if not exit_signal: if not exit_signal:
continue 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) sell_pct = getattr(exit_signal, "sell_pct", 1.0)
if sell_pct < 1.0 and quantity <= 3: 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", "strategy_type": "swing",
}) })
_recent_exits[stock_code] = time.time()
# 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시) # 포트폴리오 화면 즉시 반영 (Portfolio 수량 차감 + 캐시)
from app.services.portfolio_cache_bridge import reflect_sell from app.services.portfolio_cache_bridge import reflect_sell
await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty) await reflect_sell(stock_code, sell_qty, latest_price, remaining_qty=quantity - sell_qty)

View File

@@ -78,14 +78,20 @@ async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTra
Returns: Returns:
ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None
""" """
# [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고,
# 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다.
# db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다.
result = await db.execute( result = await db.execute(
select(ActiveTrade).where( select(ActiveTrade)
.where(
ActiveTrade.stock_code == stock_code, ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active", ActiveTrade.status == "active",
ActiveTrade.strategy_name != "day_trade", 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: async def count_swing_positions(db: AsyncSession) -> int:

View File

@@ -11,7 +11,7 @@ import asyncio
import logging import logging
import time import time
from decimal import Decimal from decimal import Decimal
from datetime import timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
@@ -155,7 +155,17 @@ async def _place_order_with_retry(
await asyncio.sleep(wait) await asyncio.sleep(wait)
except Exception as e: except Exception as e:
# 네트워크 타임아웃 등 예외 → 재시도 대상 # 네트워크 타임아웃 등 예외 → 재시도 대상
# [2026-09-23] 단, KIS가 주문을 접수한 뒤 응답만 못 받은 경우가 있으므로
# 재시도 전에 당일 체결 내역으로 중복 여부를 확인한다(멱등성 보강).
last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} 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: if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) wait = 1 * (2 ** attempt)
logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}") 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} 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) ── # ── [C1] 공통 주문 래퍼 — 분산락+세마포어 보호 (daytrading/swing에서 import) ──
async def safe_place_order( async def safe_place_order(
stock_code: str, stock_code: str,

View File

@@ -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