diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ec7bd38..a7267bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -147,6 +147,7 @@ class Settings(BaseSettings): # Trading Settings (CLAUDE.md 섹션 6 기반) MAX_POSITION_SIZE: float = 0.04 # 1종목당 최대 3-4% (섹션 6.1) MAX_DAILY_TRADES: int = 5 + MAX_DAILY_BUYS_PER_STOCK: int = 2 # 종목당 일일 최대 매수 횟수 (.env로 조정 가능) STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2) TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값 AUTO_TRADE_ENABLED: bool = True diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 1adec60..31b0590 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -1030,6 +1030,55 @@ class KoreaInvestmentAPI: "odno": None } + async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]: + """주문 체결 내역 단건 조회. + + 국내: TTTC8001R (실전) / VTTC8001R (모의) + Returns: + {"filled_qty": int, "filled_price": float} + 체결 미확인 시 filled_qty=0 반환. + """ + 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") + + if is_domestic: + tr_id = "VTTC8001R" if is_virtual else "TTTC8001R" + result, _ = await self._make_request( + "GET", + "/uapi/domestic-stock/v1/trading/inquire-ccnl", + tr_id, + 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": "", + "CCLD_DVSN": "00", + "ORD_GNO_BRNO": "", + "ODNO": order_id, + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + output = result.get("output1", []) + if output: + row = output[0] if isinstance(output, list) else output + filled_qty = int(row.get("tot_ccld_qty", 0) or 0) + filled_price = float(row.get("avg_prvs", 0) or 0) + return {"filled_qty": filled_qty, "filled_price": filled_price} + # 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체) + return {"filled_qty": 0, "filled_price": 0.0} + except Exception as e: + logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}") + return {"filled_qty": 0, "filled_price": 0.0} + async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: """주문 취소""" try: diff --git a/backend/app/services/order_dispatcher.py b/backend/app/services/order_dispatcher.py new file mode 100644 index 0000000..605b721 --- /dev/null +++ b/backend/app/services/order_dispatcher.py @@ -0,0 +1,86 @@ +"""주문 실행 디스패처 — 국내/해외 공통 인터페이스. + +trade_executor.py 의 is_domestic 분기를 여기서 캡슐화. +신규 시장(예: 일본, 홍콩) 추가 시 이 모듈만 수정. +""" +from __future__ import annotations + +import logging +from typing import Protocol + +from app.services.korea_investment_api import korea_investment_api + +logger = logging.getLogger(__name__) + + +class OrderDispatcher(Protocol): + """주문 실행 인터페이스.""" + + async def place( + self, + stock_code: str, + order_type: str, + quantity: int, + price: float, + ) -> dict: ... + + async def get_execution(self, order_id: str) -> dict: ... + + def adjust_price(self, price: float, order_type: str) -> float: ... + + +class DomesticOrderDispatcher: + """국내 주식 주문 실행.""" + + async def place( + self, + stock_code: str, + order_type: str, + quantity: int, + price: float, + **_, + ) -> dict: + return await korea_investment_api.place_order(stock_code, order_type, quantity, price) + + async def get_execution(self, order_id: str) -> dict: + return await korea_investment_api.get_order_execution(order_id, is_domestic=True) + + def adjust_price(self, price: float, order_type: str) -> float: + """BUY +0.5% / SELL -0.5%, 원화 정수 반환.""" + adjusted = price * 1.005 if order_type == "BUY" else price * 0.995 + return float(int(adjusted)) + + +class OverseasOrderDispatcher: + """해외 주식 주문 실행.""" + + def __init__(self, exchange_code: str = "NASD"): + self.exchange_code = exchange_code + + async def place( + self, + stock_code: str, + order_type: str, + quantity: int, + price: float, + **_, + ) -> dict: + return await korea_investment_api.place_overseas_order( + stock_code, order_type, quantity, float(price), self.exchange_code + ) + + async def get_execution(self, order_id: str) -> dict: + # 해외 체결 조회 미지원 — 포트폴리오 동기화로 대체 + return {"filled_qty": 0, "filled_price": 0.0} + + def adjust_price(self, price: float, order_type: str) -> float: + """BUY +0.5% / SELL -0.5%, USD 소수점 2자리 반환.""" + adjusted = price * 1.005 if order_type == "BUY" else price * 0.995 + return round(adjusted, 2) + + +def get_dispatcher(is_domestic: bool, exchange_code: str = "NASD") -> DomesticOrderDispatcher | OverseasOrderDispatcher: + """is_domestic 에 따라 적절한 디스패처 반환.""" + if is_domestic: + return DomesticOrderDispatcher() + return OverseasOrderDispatcher(exchange_code=exchange_code) diff --git a/backend/app/services/signal_types.py b/backend/app/services/signal_types.py new file mode 100644 index 0000000..3089222 --- /dev/null +++ b/backend/app/services/signal_types.py @@ -0,0 +1,53 @@ +"""신호 타입 Enum — 텍스트 키워드 오타/대소문자 오류 방지. + +technical_indicators.py 의 description 문자열과 1:1 매핑. +trade_executor.py 에서 하드코딩 리스트 대신 사용. +""" +from enum import Enum + + +class BuySignal(str, Enum): + 정배열눌림목 = "정배열 눌림목 매수" + 눌림목진입 = "눌림목 진입 매수타점" + RSI바닥탈출 = "RSI 바닥 탈출 반등" + RSI안정상승 = "RSI 안정 상승" + 스토캐스틱과매도 = "스토캐스틱 과매도" + MA5상향 = "5일선 > 20일선" + MA20상향 = "20일선 > 60일선" + 골든크로스 = "골든크로스" + 볼린저하한선 = "볼린저 하한선 터치" + 횡보장평균회귀 = "횡보장 — 평균회귀 유리" + 거래량증가상승 = "거래량 증가와 상승" + + +class SellSignal(str, Enum): + 역배열하락 = "역배열 하락 매도타점" + RSI과매수 = "RSI 과매수" + 스토캐스틱과매수 = "스토캐스틱 과매수" + 데드크로스 = "데드크로스" + 이동평균역배열 = "이동평균 역배열" + 볼린저상한선 = "볼린저 상한선 돌파" + 손절라인이탈 = "손절라인 이탈" + + +class StrongBuySignal(str, Enum): + """매수 신호 중 '강한 신호' — 단독 매수 요건 충족.""" + RSI바닥탈출 = "RSI 바닥 탈출 반등" + 볼린저하한선 = "볼린저 하한선 터치" + 눌림목진입 = "눌림목 진입 매수타점" + 정배열눌림목 = "정배열 눌림목 매수" + + +class BlockSignal(str, Enum): + """발생 시 매수를 즉시 차단하는 신호.""" + 이격도과열 = "이격도 과열" + 매수금지 = "매수금지" + 급등추격금지 = "급등 종목 추격 금지" + 급등과열 = "급등과열" + + +# 편의 상수 — 반복 list comprehension 없이 바로 사용 가능 +BUY_KEYWORDS: list[str] = [s.value for s in BuySignal] +SELL_KEYWORDS: list[str] = [s.value for s in SellSignal] +STRONG_BUY_KEYWORDS: list[str] = [s.value for s in StrongBuySignal] +BLOCK_KEYWORDS: list[str] = [s.value for s in BlockSignal] diff --git a/backend/app/services/technical_indicators.py b/backend/app/services/technical_indicators.py index bdea914..7436fca 100644 --- a/backend/app/services/technical_indicators.py +++ b/backend/app/services/technical_indicators.py @@ -29,6 +29,24 @@ def _ma(values: list, window: int) -> Optional[float]: return None +def _ema(values: list, window: int) -> Optional[float]: + """EMA(Exponential Moving Average) 계산. + + SMA 시드 → 이후 지수 가중 평균 순차 적용. + values 길이가 window 미만이면 None 반환. + """ + try: + if len(values) < window: + return None + k = 2.0 / (window + 1) + ema = sum(values[:window]) / window + for v in values[window:]: + ema = v * k + ema * (1 - k) + return ema + except Exception: + return None + + def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]: if not closes or len(closes) < max(ma_short, ma_long): return None diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index e2ba524..8d6855f 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -15,8 +15,9 @@ from app.services.loss_tracker import loss_tracker from app.services.market_analyzer import check_market_regime, _get_kst_now from app.services.mean_reversion_strategy import MarketRegime, calculate_position_size_v2 from app.services.technical_indicators import ( - TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma + TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema, ) +from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS logger = logging.getLogger(__name__) @@ -86,18 +87,18 @@ async def _place_order_with_retry( if attempt < max_retries - 1: wait = 1 * (2 ** attempt) # 1s → 2s → 4s logger.warning( - f"⚠️ 주문 실패 (rt_cd={result.get('rt_cd')}) " - f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries}): {stock_code}" + f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} " + f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})" ) await asyncio.sleep(wait) except Exception as e: last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} if attempt < max_retries - 1: wait = 1 * (2 ** attempt) - logger.warning(f"⚠️ 주문 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {e}") + logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}") await asyncio.sleep(wait) else: - logger.error(f"❌ 주문 최대 재시도 초과: {stock_code} {order_type} — {e}") + logger.error(f"[ORDER_MAX_RETRY] {stock_code} {order_type} 최대 재시도 초과: {type(e).__name__}: {e}") return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None} @@ -108,11 +109,11 @@ async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int, try: result = await korea_investment_api.cancel_order(order_id, quantity) if result.get("rt_cd") == "0": - logger.info(f"미체결 자동 취소 완료: {stock_code} order_id={order_id}") + logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}") else: - logger.debug(f"[auto_cancel] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") + logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") except Exception as e: - logger.debug(f"[auto_cancel] {stock_code} — {e}") + logger.debug(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {e}") # ── 계좌 정보 30초 인메모리 캐시 ────────────────────────────────────────────── @@ -132,12 +133,179 @@ def _set_account_cache(available_cash: float, total_assets: float) -> None: _account_cache["ts"] = time.monotonic() -# ── 지정가 ±0.5% 조정 — 체결률 향상 ────────────────────────────────────────── -def _adjust_order_price(price: float, order_type: str) -> int: - """BUY +0.5% / SELL -0.5% 지정가 조정.""" +# ── 지정가 조정 — 체결률 향상 ────────────────────────────────────────────────── +def _adjust_order_price(price: float, order_type: str, is_domestic: bool = True) -> float: + """BUY +0.5% / SELL -0.5% 지정가 조정. + + 국내: 원화 정수 반환 (KIS는 원화 단위 정수만 허용). + 해외: 소수점 2자리 반환 (USD 단위). + """ if order_type == "BUY": - return int(price * 1.005) - return int(price * 0.995) + adjusted = price * 1.005 + else: + adjusted = price * 0.995 + + if is_domestic: + return float(int(adjusted)) # 원화 정수 + return round(adjusted, 2) # USD 소수점 2자리 + + +# ── Kelly Criterion 포지션 사이징 ───────────────────────────────────────────── +async def _calculate_kelly_position( + db: AsyncSession, + stock_code: str, + total_assets: float, + available_cash: float, + max_per_position: float = 0.04, + lookback: int = 20, +) -> float: + """Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용). + + f* = (p*b - (1-p)) / b + - p: 최근 N거래 승률 + - b: 평균이익률 / 평균손실률 + 데이터 부족(< 5개)이면 0.0 반환 → 호출측에서 v2 fallback. + """ + try: + recent = await db.execute( + select(TradeHistory) + .where( + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "SELL", + TradeHistory.status == "COMPLETED", + TradeHistory.profit_loss_rate.isnot(None), + ) + .order_by(desc(TradeHistory.executed_at)) + .limit(lookback) + ) + trades = recent.scalars().all() + if len(trades) < 5: + return 0.0 + + rates = [float(t.profit_loss_rate) for t in trades] + wins = [r for r in rates if r > 0] + losses = [abs(r) for r in rates if r < 0] + if not wins or not losses: + return 0.0 + + p = len(wins) / len(rates) + b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) + kelly_f = (p * b - (1 - p)) / b + half_kelly = max(0.0, kelly_f / 2) + clamped = min(half_kelly, max_per_position) + return min(total_assets * clamped, available_cash, total_assets * max_per_position) + except Exception as e: + logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}") + return 0.0 + + +# ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ─────────────────────────────── +async def _verify_and_sync_execution( + order_id: str, + stock_code: str, + stock_name: str, + ordered_quantity: int, + order_price: float, + order_type: str, + is_domestic: bool, + exchange_code: str, + db_trade_id: int, + delay: int = 3, +) -> None: + """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.""" + await asyncio.sleep(delay) + + if not order_id: + await _delayed_sync_portfolio(0) + return + + try: + exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) + filled_qty = int(exec_result.get("filled_qty", 0)) + filled_price = float(exec_result.get("filled_price", order_price) or order_price) + + # ── 슬리피지 계산 및 로깅 ───────────────────────────────────────────── + slippage_pct = 0.0 + if order_price > 0 and filled_price > 0: + slippage_pct = ((filled_price - order_price) / order_price) * 100 + logger.info( + f"[SLIPPAGE] {stock_code} {order_type} " + f"주문가={order_price:.2f} 체결가={filled_price:.2f} " + f"슬리피지={slippage_pct:+.3f}%" + ) + + # ── DB 업데이트 (별도 세션) ─────────────────────────────────────────── + if filled_qty > 0: + async with AsyncSessionLocal() as session: + row = await session.execute( + select(TradeHistory).where(TradeHistory.id == db_trade_id) + ) + trade = row.scalar_one_or_none() + if trade: + partial = filled_qty < ordered_quantity + trade.executed_quantity = filled_qty + trade.executed_price = Decimal(str(filled_price)) + trade.status = "PARTIAL" if partial else "COMPLETED" + trade.executed_at = _get_kst_now() + trade.slippage = Decimal(str(round(slippage_pct, 4))) + await session.commit() + + # ── 부분 체결 → 미체결 수량 재주문 ────────────────────── + if partial: + remain = ordered_quantity - filled_qty + logger.warning( + f"[PARTIAL_FILL] {stock_code} {order_type} " + f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문" + ) + asyncio.create_task( + _reorder_unfilled( + stock_code=stock_code, + stock_name=stock_name, + order_type=order_type, + quantity=remain, + price=order_price, + is_domestic=is_domestic, + exchange_code=exchange_code, + ) + ) + + await _delayed_sync_portfolio(0) + + except Exception as e: + logger.warning( + f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — " + f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)" + ) + await _delayed_sync_portfolio(0) + + +# ── 부분 체결 후 미체결 수량 재주문 ────────────────────────────────────────── +async def _reorder_unfilled( + stock_code: str, + stock_name: str, + order_type: str, + quantity: int, + price: float, + is_domestic: bool, + exchange_code: str = "NASD", +) -> None: + """부분 체결 잔여 수량 재주문 (1회 한정).""" + try: + result = await _place_order_with_retry( + is_domestic=is_domestic, + stock_code=stock_code, + order_type=order_type, + quantity=quantity, + price=price, + exchange_code=exchange_code, + max_retries=2, + ) + if result.get("rt_cd") == "0": + logger.info(f"[REORDER] {stock_code} {order_type} 미체결 {quantity}주 재주문 성공") + else: + logger.warning(f"[REORDER] {stock_code} {order_type} 미체결 재주문 실패: {result.get('msg1')}") + except Exception as e: + logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}") async def _execute_buy( @@ -152,7 +320,7 @@ async def _execute_buy( stock_code: str, stock_name: str, ) -> dict: - """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync.""" + """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → 체결 확인.""" # ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ─────────────────────────────── try: @@ -175,7 +343,6 @@ async def _execute_buy( ((current_price - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 ) - # ATR 기반 동적 임계값: 최근 3일 평균 일간 변동폭 * 2, 최소 10% try: highs = ( [float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]] @@ -201,13 +368,16 @@ async def _execute_buy( if recent_gain > surge_threshold: return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} except Exception as e: - logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}") + logger.debug(f"[SURGE_CHECK] {stock_code} 차트 조회 실패, 우회: {type(e).__name__}: {e}") - # ── 2. 트렌드 필터 ──────────────────────────────────────────────────────── + # ── 2. 트렌드 필터 (MA + EMA 병행) ─────────────────────────────────────── if TREND_FILTER_ENABLED: try: ma20 = stock.get("ma20") ma60 = stock.get("ma60") + ema20 = ema60 = None + closes_local: list = [] + if ma20 is None or ma60 is None: if is_domestic: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") @@ -219,14 +389,24 @@ async def _execute_buy( if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG): ma20 = _ma(closes_local, TREND_MA_SHORT) ma60 = _ma(closes_local, TREND_MA_LONG) + ema20 = _ema(closes_local, TREND_MA_SHORT) + ema60 = _ema(closes_local, TREND_MA_LONG) - if not ( + ma_ok = bool( ma20 and ma60 and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT) and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT) - ): + ) + ema_ok = bool( + ema20 and ema60 + and ema20 >= ema60 * (1 - TREND_TOLERANCE_PCT) + and current_price >= ema20 * (1 - TREND_TOLERANCE_PCT) + ) if ema20 and ema60 else False + + if not (ma_ok or ema_ok): return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"} - except Exception: + except Exception as e: + logger.debug(f"[TREND_FILTER] {stock_code}: {type(e).__name__}: {e}") return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} # ── 3. 현금·자산 조회 (30초 캐시 활용) ────────────────────────────────── @@ -256,7 +436,7 @@ async def _execute_buy( if pending_check.scalars().first(): return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} - # ── 5. 일 매수 횟수 제한 ───────────────────────────────────────────────── + # ── 5. 일 매수 횟수 제한 (settings 기반) ───────────────────────────────── daily_buy_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, @@ -265,11 +445,18 @@ async def _execute_buy( TradeHistory.created_at >= daily_cutoff, ) ) - if len(daily_buy_check.scalars().all()) >= 2: + if len(daily_buy_check.scalars().all()) >= settings.MAX_DAILY_BUYS_PER_STOCK: return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} - # ── 6. 쿨다운 체크 ──────────────────────────────────────────────────────── - cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) + # ── 6. 쿨다운 체크 (ATR 기반 동적) ────────────────────────────────────── + atr_val = float(stock.get("atr", 0) or 0) + if atr_val > 0 and current_price > 0: + atr_pct = atr_val / current_price * 100 + # 고변동(ATR% > 3%) → 쿨다운 단축, 저변동 → 기본 30분 + cooldown_min = max(15, int(30 - atr_pct * 3)) + else: + cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) + cooldown_check = await db.execute( select(TradeHistory) .where( @@ -284,18 +471,24 @@ async def _execute_buy( if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} - # ── 7. 포지션 사이징 ────────────────────────────────────────────────────── + # ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ──────────────────────────── current_value = float(holding.quantity * current_price) if holding else 0.0 regime_str = stock.get("_regime_str", "bear") regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR - invest_amount = calculate_position_size_v2( - total_assets=total_assets, - available_cash=available_cash, - current_strategy_allocation=current_value, - regime=regime, - rsi=stock.get("rsi"), - adx=stock.get("adx_14"), + + invest_amount = await _calculate_kelly_position( + db, stock_code, total_assets, available_cash ) + if invest_amount <= 0: + # Kelly 데이터 부족 → v2 fallback + invest_amount = calculate_position_size_v2( + total_assets=total_assets, + available_cash=available_cash, + current_strategy_allocation=current_value, + regime=regime, + rsi=stock.get("rsi"), + adx=stock.get("adx_14"), + ) if invest_amount <= 0 and total_assets <= 0: invest_amount = available_cash * 0.04 @@ -309,7 +502,7 @@ async def _execute_buy( # ── 9. 주문 실행 (종목별 세마포어 + 재시도) ─────────────────────────────── try: - order_price = _adjust_order_price(current_price, "BUY") + order_price = _adjust_order_price(current_price, "BUY", is_domestic) async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, @@ -345,21 +538,34 @@ async def _execute_buy( executed_at=_get_kst_now() if success else None, ) db.add(trade) + await db.flush() # id 확보 + trade_id = trade.id await db.commit() if success: - # 즉시 캐시 반영 (UI 실시간성) try: from app.api.endpoints.portfolio import portfolio_cache portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) except Exception: pass - asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) + asyncio.create_task( + _verify_and_sync_execution( + order_id=order_result.get("odno", ""), + stock_code=stock_code, + stock_name=stock_name, + ordered_quantity=quantity, + order_price=order_price, + order_type="BUY", + is_domestic=is_domestic, + exchange_code=exchange_code, + db_trade_id=trade_id, + delay=3, + ) + ) elif is_pending: - # PENDING 주문 → 1분 후 자동 취소 - order_id = order_result.get("odno") - if order_id: - asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity)) + order_id_val = order_result.get("odno") + if order_id_val: + asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) return { "action": "BUY", @@ -369,7 +575,11 @@ async def _execute_buy( "success": success, } + except ValueError as e: + logger.error(f"[BUY_VALIDATION] {stock_code}: {e}") + return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} except Exception as e: + logger.error(f"[BUY_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True) return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} finally: await _release_order_lock(stock_code, "BUY") @@ -386,7 +596,7 @@ async def _execute_sell( stock_code: str, stock_name: str, ) -> dict: - """SELL 주문 실행 로직 — 중복체크 선행 → semaphore → 주문 → TradeHistory 기록 → sync.""" + """SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인.""" quantity = holding.quantity _avg = float(holding.average_price or 0) @@ -402,12 +612,10 @@ async def _execute_sell( _avg_price = float(holding.average_price or 0) exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%" if atr > 0 and _avg_price > 0: - # ATR 손절: 진입가 - 2.5*ATR (CLAUDE.md 규칙) atr_stop_price = _avg_price - 2.5 * atr atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100 - stop_loss = max(stop_loss, atr_stop_pct) # 더 보수적인 값 사용 + stop_loss = max(stop_loss, atr_stop_pct) - # 트레일링 스탑: 고점 - 1.5*ATR (CLAUDE.md 규칙) high_price = float(stock.get("high_price") or current_price) trailing_stop_price = high_price - 1.5 * atr if current_price <= trailing_stop_price and profit_loss_rate > 0: @@ -418,7 +626,7 @@ async def _execute_sell( return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} try: - # ── 1. 중복 주문 체크 (DB + Redis 락 이중 방어) ────────────────────── + # ── 1. 중복 주문 체크 (10초 이내) ──────────────────────────────────── recent_cutoff = _get_kst_now() - timedelta(seconds=10) duplicate_check = await db.execute( select(TradeHistory).where( @@ -431,7 +639,7 @@ async def _execute_sell( return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── - order_price = _adjust_order_price(current_price, "SELL") + order_price = _adjust_order_price(current_price, "SELL", is_domestic) async with _get_order_semaphore(stock_code): order_result = await _place_order_with_retry( is_domestic=is_domestic, @@ -469,10 +677,11 @@ async def _execute_sell( executed_at=_get_kst_now() if success else None, ) db.add(trade) + await db.flush() # id 확보 + trade_id = trade.id await db.commit() if success: - # 즉시 캐시 반영 try: from app.api.endpoints.portfolio import portfolio_cache avg = float(holding.average_price or current_price) @@ -482,11 +691,24 @@ async def _execute_sell( ] except Exception: pass - asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) + asyncio.create_task( + _verify_and_sync_execution( + order_id=order_result.get("odno", ""), + stock_code=stock_code, + stock_name=stock_name, + ordered_quantity=quantity, + order_price=order_price, + order_type="SELL", + is_domestic=is_domestic, + exchange_code=exchange_code, + db_trade_id=trade_id, + delay=3, + ) + ) elif is_pending: - order_id = order_result.get("odno") - if order_id: - asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity)) + order_id_val = order_result.get("odno") + if order_id_val: + asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity)) return { "action": "SELL", @@ -497,7 +719,11 @@ async def _execute_sell( "success": success, } + except ValueError as e: + logger.error(f"[SELL_VALIDATION] {stock_code}: {e}") + return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} except Exception as e: + logger.error(f"[SELL_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True) return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} finally: await _release_order_lock(stock_code, "SELL") @@ -506,7 +732,7 @@ async def _execute_sell( async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.""" if not isinstance(recommendations_response, dict): - logger.error("recommendations_response is not a dict: %r", recommendations_response) + logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response) return try: @@ -537,15 +763,15 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn unique_stocks: dict = {} for rule_group in rec_groups: if not isinstance(rule_group, dict): - logger.warning("Invalid rule_group: %r", rule_group) + logger.warning("[AUTO_TRADE_BG] Invalid rule_group: %r", rule_group) continue stocks_list = rule_group.get("stocks") if not isinstance(stocks_list, list): - logger.warning("Invalid stocks list: %r", stocks_list) + logger.warning("[AUTO_TRADE_BG] Invalid stocks list: %r", stocks_list) continue for stock in stocks_list: if not isinstance(stock, dict): - logger.warning("Skipping non-dict stock entry: %r", stock) + logger.warning("[AUTO_TRADE_BG] Skipping non-dict stock entry: %r", stock) continue stock_code = stock.get("stock_code") if stock_code not in unique_stocks: @@ -560,7 +786,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn for stock in sorted_stocks[:max_trades]: if not isinstance(stock, dict): - logger.warning("Skipping invalid stock entry: %r", stock) + logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock) continue try: stock["_position_multiplier"] = position_multiplier @@ -576,13 +802,13 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn await asyncio.sleep(0.5) except Exception as e: - logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") + logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}") continue except Exception as e: logger.error( - f"❌ Background auto-trade error: {e} — " - f"recommendations_response={recommendations_response!r}" + f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — " + f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}" ) @@ -592,21 +818,20 @@ async def _background_sync_portfolio(): async with AsyncSessionLocal() as session: await sync_portfolio(session, force=True) except Exception as e: - logger.error(f"Background portfolio sync failed: {e}") + logger.error(f"[PORTFOLIO_SYNC] 백그라운드 동기화 실패: {type(e).__name__}: {e}") async def _delayed_sync_portfolio(delay_seconds: int = 3): try: await asyncio.sleep(delay_seconds) from app.api.endpoints.portfolio import sync_portfolio - async with AsyncSessionLocal() as session: try: await sync_portfolio(session, force=True) except Exception as inner_e: - logger.error(f"Sync execution failed: {inner_e}") + logger.error(f"[PORTFOLIO_SYNC] 실행 실패: {type(inner_e).__name__}: {inner_e}") except Exception as e: - logger.error(f"Delayed portfolio sync failed: {e}") + logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}") async def execute_auto_trade(stock: dict, db: AsyncSession): @@ -668,67 +893,64 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ) holding = result.scalar_one_or_none() - # ── 신호 분석 ───────────────────────────────────────────────────────── + # ── 신호 분석 (Enum 기반) ───────────────────────────────────────────── text_signals = " ".join(signals) try: score_val = float(stock.get("score", 0) or 0) except Exception: score_val = 0.0 - buy_keywords = [ - "정배열 눌림목 매수", "눌림목 진입 매수타점", "RSI 바닥 탈출 반등", - "RSI 안정 상승", "스토캐스틱 과매도", "5일선 > 20일선", "20일선 > 60일선", - "골든크로스", "볼린저 하한선 터치", "횡보장 — 평균회귀 유리", "거래량 증가와 상승", - ] signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] text_signals_filtered = " ".join(signals_without_volume_weak) - should_buy = any(k in text_signals_filtered for k in buy_keywords) + + should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS) if not signals_without_volume_weak and signals: should_buy = False - sell_keywords = [ - "역배열 하락 매도타점", "RSI 과매수", "스토캐스틱 과매수", "데드크로스", - "이동평균 역배열", "볼린저 상한선 돌파", "손절라인 이탈", - ] - should_sell = any(k in text_signals for k in sell_keywords) + should_sell = any(k in text_signals for k in SELL_KEYWORDS) + # ── ML 가중치 통합 (60% ML + 40% 규칙) ────────────────────────────── ml_score_trade = stock.get("ml_score") ml_confidence_trade = stock.get("ml_confidence") ml_signal_trade = stock.get("ml_signal") if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None: - if ( - ml_signal_trade == "BUY" - and ml_score_trade >= settings.ML_ENSEMBLE_BUY_THRESHOLD - and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN - ): - should_buy = True - elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5: - should_sell = True - elif ml_signal_trade == "HOLD" and (ml_confidence_trade or 0) < settings.ML_ENSEMBLE_CONFIDENCE_MIN: - should_buy = False + try: + weighted_score = ( + score_val * settings.ML_ENSEMBLE_RULE_WEIGHT + + float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT + ) + if ( + ml_signal_trade == "BUY" + and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD + and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN + ): + should_buy = True + elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5: + should_sell = True + elif ( + ml_signal_trade == "HOLD" + and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7 + ): + should_buy = False + except Exception as e: + logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}") has_rule = stock.get("rule_id") is not None if has_rule: - if "이격도 과열" in text_signals or "매수금지" in text_signals: + if any(b in text_signals for b in BLOCK_KEYWORDS): return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} - if "급등 종목 추격 금지" in text_signals or "급등과열" in text_signals: - return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} ml_hold_active = settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy if not ml_hold_active: should_buy = True else: - if "급등과열" in text_signals or "이격도 과열" in text_signals or "매수금지" in text_signals: + if any(b in text_signals for b in BLOCK_KEYWORDS): return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} if should_buy and not has_rule: - strong_buy_kw = [ - "RSI 바닥 탈출 반등", "볼린저 하한선 터치", - "눌림목 진입 매수타점", "정배열 눌림목 매수", - ] - has_strong_signal = any(k in text_signals_filtered for k in strong_buy_kw) - matched_count = sum(1 for k in buy_keywords if k in text_signals_filtered) + has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS) + matched_count = sum(1 for k in BUY_KEYWORDS if k in text_signals_filtered) if not has_strong_signal and (matched_count < 2 or score_val < 2): should_buy = False @@ -757,5 +979,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): return {"action": "HOLD", "stock": stock_name} - except Exception as e: + except ValueError as e: + logger.error(f"[TRADE_VALIDATION] {stock.get('stock_code')}: {e}") + return {"action": "ERROR", "error": str(e)} + except Exception as e: + logger.error(f"[TRADE_ERROR] {stock.get('stock_code')}: {type(e).__name__}: {e}", exc_info=True) return {"action": "ERROR", "error": str(e)}