"""Helper functions for trade_executor.py — Buy/Sell logic decomposition. This module extracts complex logic from _execute_buy() and _execute_sell() into reusable helper functions, keeping the executor focused on orchestration. """ import logging from decimal import Decimal from datetime import datetime as _dt_utc from app.core.config import settings from app.services.korea_investment_api import korea_investment_api from app.services.mean_reversion_strategy import ( calculate_rsi, calculate_bollinger, calculate_atr, MarketRegime, resolve_swing_trailing_atr_mult_v2, ) from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema, ) from app.core.market_schedule import is_opening_range_period from app.services.market_analyzer import _get_kst_now from app.services.exchange_detector import get_market_info from app.core.timeutil import now_kst from app.core.log_throttle import warn_throttled # noqa: E402 logger = logging.getLogger(__name__) # ──────────────────────────────────────────────────────────────────────────── # BUY HELPERS # ──────────────────────────────────────────────────────────────────────────── async def check_opening_range_volatility() -> bool: """Check if we're in opening range period (09:00~09:30) — volatility avoidance. Returns: bool: True if in opening range (SKIP BUY), False otherwise. """ return is_opening_range_period() def _bars_oldest_first(rows, date_key: str) -> list: """KIS 일봉 output2 를 과거→최근 순으로 정렬한다. KIS 는 최신 봉이 앞(newest-first)이고 30행을 준다. 날짜 키가 모두 있으면 날짜 오름차순으로, 없으면 KIS 기본 순서(최신 우선)로 보고 뒤집는다. """ rows = [r for r in (rows or []) if isinstance(r, dict)] if rows and all(r.get(date_key) for r in rows): return sorted(rows, key=lambda r: str(r.get(date_key))) return list(reversed(rows)) async def check_surge_prevention( stock_code: str, is_domestic: bool, exchange_code: str, current_price: float, ) -> dict: """Check 3-day surge (ATR-based dynamic threshold) — avoid chasing rallies. [동작] 3일 전 종가 대비 현재가 상승률 > threshold(최소 10%, ATR% 기반 동적) 이면 최근 급등(chase_buy_prevention) 판정 → HOLD 반환. Args: stock_code: Stock code (KRX code or ticker) is_domestic: True if domestic stock, False if overseas exchange_code: Exchange code for overseas stocks current_price: Current price Returns: dict: {"skip": False} (통과) or {"skip": True, "reason": "chase_buy_prevention"} """ try: if is_domestic: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10) chart_data = chart_resp.get("output2", []) else: chart_resp = await korea_investment_api.get_overseas_daily_chart( stock_code, exchange_code, "D" ) chart_data = chart_resp.get("output2", []) chart_data = _bars_oldest_first( chart_data, "stck_bsop_date" if is_domestic else "xymd" ) # 오늘 봉(장중 미완성)이 포함되면 [-4:] 첫 봉 = 3거래일 전, # 아직 없으면 [-3:] 첫 봉 = 3거래일 전(오늘 기준). _win = 4 if is_domestic and chart_data: _today = now_kst().strftime("%Y%m%d") if str(chart_data[-1].get("stck_bsop_date")) != _today: _win = 3 chart_data = chart_data[-_win:] if len(chart_data) >= _win: closes = ( [float(item.get("stck_clpr", 0)) for item in chart_data] if is_domestic else [float(item.get("clos", 0)) for item in chart_data] ) if len(closes) >= _win: price_3days_ago = closes[0] recent_gain = ( ((current_price - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 ) try: highs = ( [float(item.get("stck_hgpr", 0)) for item in chart_data] if is_domestic else [float(item.get("high", 0)) for item in chart_data] ) lows = ( [float(item.get("stck_lwpr", 0)) for item in chart_data] if is_domestic else [float(item.get("low", 0)) for item in chart_data] ) daily_ranges = [ abs(highs[i] - lows[i]) for i in range(len(closes)) if closes[i] > 0 and highs[i] > 0 and lows[i] > 0 ] atr_pct = ( (sum(daily_ranges) / len(daily_ranges) / price_3days_ago * 100) if daily_ranges and price_3days_ago > 0 else 0 ) surge_threshold = max(10.0, atr_pct * 2) except Exception: surge_threshold = 10.0 if recent_gain > surge_threshold: return {"skip": True, "reason": "chase_buy_prevention"} except Exception as e: warn_throttled(logger, "trade_helpers:108", f"[SURGE_CHECK] {stock_code} chart fetch failed, bypassed: {type(e).__name__}: {e}") return {"skip": False} async def check_trend_filter( stock_code: str, stock_name: str, is_domestic: bool, exchange_code: str, current_price: float, stock: dict, ) -> dict: """Apply trend filter (MA + EMA) — skip if downtrend, except MEAN_REVERSION. [동작] MEAN_REVERSION 전략은 과매도 진입이므로 필터 제외. MA20 >= MA60 AND current >= MA20 (TREND_TOLERANCE_PCT 범위 내) 통과 조건. Args: stock_code: Stock code stock_name: Stock name is_domestic: True if domestic exchange_code: Exchange code (for overseas) current_price: Current price stock: Stock dict with pre-calculated MA values Returns: dict: {"skip": False} (통과) or {"skip": True, "reason": "trend_filter"} """ _rule_name = stock.get("rule_name", "") or "" _rule_type = stock.get("rule_type", "") or "" _is_mean_reversion = ( "MEAN_REVERSION" in _rule_type or "평균회귀" in _rule_name or "mean_reversion" in _rule_name.lower() ) if not TREND_FILTER_ENABLED or _is_mean_reversion: return {"skip": False} # 필터 미적용 try: ma20 = stock.get("ma20") ma60 = stock.get("ma60") ema20 = ema60 = None closes_local = [] if ma20 is None or ma60 is None: if is_domestic: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") _rows = _bars_oldest_first( chart_resp.get("output2") or chart_resp.get("output") or [], "stck_bsop_date" ) closes_local = [float(it.get("stck_clpr", 0)) for it in _rows] else: chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D") _rows = _bars_oldest_first(chart_resp.get("output2") or [], "xymd") closes_local = [float(it.get("clos", 0)) for it in _rows] 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) elif not (ma20 and ma60): # 데이터 부족(KIS 폴백 30봉)은 하락추세 증거가 아니다. 산출 불가 시 통과시키되 # 로그를 남긴다(전 비-평균회귀 매수 전면 차단 방지). warn_throttled( logger, f"trade_helpers:trend_nodata:{stock_code}", f"[TREND_FILTER] {stock_code} MA{TREND_MA_LONG} 산출 불가" f"(봉 {len(closes_local)}<{max(TREND_MA_SHORT, TREND_MA_LONG)}) -> 필터 통과(데이터 부족)", ) return {"skip": False} 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 {"skip": True, "reason": "trend_filter"} return {"skip": False} except Exception as e: warn_throttled(logger, "trade_helpers:185", f"[TREND_FILTER] {stock_code}: {type(e).__name__}: {e}") return {"skip": True, "reason": "trend_filter_error"} def calculate_cooldown_minutes(stock: dict, current_price: float) -> int: """Calculate dynamic cooldown minutes based on ATR (higher volatility → shorter cooldown). [동작] ATR%가 높을수록(고변동) 쿨다운 단축 → max(15, 30 - atr_pct×3)분 예: ATR% = 5% → 쿨다운 = 15분 ATR% = 1% → 쿨다운 = 27분 Args: stock: Stock dict with "atr" field current_price: Current price (for ATR% calculation) Returns: int: Cooldown minutes """ atr_val = float(stock.get("atr", 0) or 0) if atr_val > 0 and current_price > 0: atr_pct = atr_val / current_price * 100 return max(15, int(30 - atr_pct * 3)) return int(stock.get("add_buy_cooldown_min", 30) or 30) def get_daily_cutoff_utc() -> _dt_utc: """Get daily cutoff (00:00:00 UTC) for same-day order filtering. [이슈1 수정] TradeHistory.created_at은 UTC 저장이므로 daily_cutoff도 UTC 기준 계산. Returns: datetime: Today's 00:00:00 UTC """ return now_kst().replace(hour=0, minute=0, second=0, microsecond=0) def get_leverage_check_keywords() -> set: """Get keywords for leverage/inverse ETF detection. Returns: set: Leverage/inverse ETF keywords """ return { "레버리지", "2X", "3X", "인버스", "곱버스", "TIGER 200선물", "KODEX 인버스" } def is_leverage_etf(stock_name: str, keywords: set = None) -> bool: """Check if stock is leverage/inverse ETF. Args: stock_name: Stock name keywords: Leverage keywords (optional, uses default if None) Returns: bool: True if leverage/inverse ETF """ if keywords is None: keywords = get_leverage_check_keywords() _name_upper = (stock_name or "").upper() return ( any(kw in (stock_name or "") for kw in keywords) or "LEVERAGE" in _name_upper or "INVERSE" in _name_upper ) # ──────────────────────────────────────────────────────────────────────────── # SELL HELPERS # ──────────────────────────────────────────────────────────────────────────── def calculate_trade_costs( entry_price: float, exit_price: float, quantity: int, is_domestic: bool = True, market: str = "KOSPI", ) -> dict: """매매 수수료 + 세금 계산 (net PnL 산출용). Returns: dict: {"buy_commission", "sell_commission", "sell_tax", "total_cost"} """ buy_commission = entry_price * quantity * settings.KR_COMMISSION_RATE sell_commission = exit_price * quantity * settings.KR_COMMISSION_RATE if not is_domestic: sell_tax = 0.0 elif market in ("KOSDAQ", "kosdaq"): sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSDAQ else: sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSPI total_cost = buy_commission + sell_commission + sell_tax return { "buy_commission": round(buy_commission, 0), "sell_commission": round(sell_commission, 0), "sell_tax": round(sell_tax, 0), "total_cost": round(total_cost, 0), } def calculate_profit_loss_rate( current_price: float, average_price: float, holding_profit_loss_rate: float = None, ) -> float: """Calculate realized profit/loss rate as percentage. Args: current_price: Current price average_price: Average entry price holding_profit_loss_rate: Fallback value if calculation fails Returns: float: Profit/loss rate (%) """ if average_price > 0: return ((current_price - average_price) / average_price) * 100 return float(holding_profit_loss_rate or 0) def calculate_atr_based_stops( average_price: float, current_price: float, atr: float, stock_dict: dict, base_stop_loss: float = -6.0, ) -> dict: """Calculate ATR-based stop loss and trailing stop. [CLAUDE.md 5.2] - ATR Stop: average_price - 2.5×ATR → TIGHTER(더 작은) 값 적용 - Trailing Stop: high_price - 1.5×ATR (수익 구간에서만) Args: average_price: Average entry price current_price: Current price atr: ATR value stock_dict: Stock dict with optional "high_price" base_stop_loss: Base stop loss percentage (default -6%) Returns: dict: { "stop_loss": float (%), "trailing_stop_price": float, "exit_reason_detail": str } """ stop_loss = base_stop_loss trailing_stop_price = None exit_reason_detail = "" if atr > 0 and average_price > 0: atr_stop_price = average_price - 2.5 * atr atr_stop_pct = ((atr_stop_price / average_price) - 1) * 100 stop_loss = max(stop_loss, atr_stop_pct) # TIGHTER = 0에 가까운 쪽 (음수이므로 max) high_price = float(stock_dict.get("high_price") or current_price) trailing_stop_price = high_price - resolve_swing_trailing_atr_mult_v2() * atr # 단일 출처(기본 1.5) profit_loss_rate = calculate_profit_loss_rate(current_price, average_price) if trailing_stop_price and current_price <= trailing_stop_price and profit_loss_rate > 0: exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f}" return { "stop_loss": stop_loss, "trailing_stop_price": trailing_stop_price, "exit_reason_detail": exit_reason_detail, } def build_kis_order_id(order_result: dict) -> str: """Extract KIS order ID from API response. [이슈3 수정] KIS response structure: output.ODNO preferred, fallback to top-level odno. Args: order_result: Order API response dict Returns: str: Order ID or empty string if not found """ return ( order_result.get("output", {}).get("ODNO", "") or order_result.get("odno", "") ) def get_kst_now() -> _dt_utc: """Get current time in KST (convenience wrapper for compatibility). Returns: datetime: Current time in KST """ return _get_kst_now()