import asyncio import logging from decimal import Decimal from datetime import timedelta from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from app.core.config import settings from app.core.database import AsyncSessionLocal from app.models.models import TradeHistory, Portfolio from app.services.korea_investment_api import korea_investment_api from app.services.mdd_manager import mdd_manager 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 ) logger = logging.getLogger(__name__) # 동시 주문 1개로 제한 — 중복 주문 및 레이스 컨디션 방지 _order_semaphore = asyncio.Semaphore(1) async def _mcp_validate_buy(stock: dict) -> tuple: # 전략 엔진(RSI/BB/MA/ATR 등)이 이미 신호를 검증하므로 AI 호출 불필요. # RISK_ASSESSMENT는 매매 직전 mcp.py에서 별도 수행. return True, "strategy_validated" async def _execute_buy( stock: dict, db: AsyncSession, holding, current_price: float, signals: list, score_val: float, is_domestic: bool, exchange_code: str, stock_code: str, stock_name: str, ) -> dict: """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync.""" # ── 1. 3일 급등 체크 ───────────────────────────────────────────────────── 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", []) if len(chart_data) >= 4: closes = ( [float(item.get("stck_clpr", 0)) for item in chart_data[-4:]] if is_domestic else [float(item.get("clos", 0)) for item in chart_data[-4:]] ) if len(closes) >= 4: price_3days_ago = closes[0] recent_gain = ( ((current_price - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 ) if recent_gain > 10: return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} except Exception as e: logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}") # ── 2. 트렌드 필터 ──────────────────────────────────────────────────────── if TREND_FILTER_ENABLED: try: ma20 = stock.get("ma20") ma60 = stock.get("ma60") if ma20 is None or ma60 is None: if is_domestic: chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])] else: chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D") closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])] 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) if not ( ma20 and ma60 and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT) and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT) ): return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"} except Exception: return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} # ── 3. 현금·자산 조회 ──────────────────────────────────────────────────── try: from app.api.endpoints.portfolio import portfolio_cache available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0 total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0 except Exception: available_cash, total_assets = 0.0, 0.0 daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) # ── 4. PENDING 포함 오늘 중복 주문 차단 ────────────────────────────────── pending_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PENDING"]), TradeHistory.created_at >= daily_cutoff, ) ) if pending_check.scalars().first(): return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} # ── 5. 일 매수 횟수 제한 ───────────────────────────────────────────────── daily_buy_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), TradeHistory.created_at >= daily_cutoff, ) ) if len(daily_buy_check.scalars().all()) >= 2: return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} # ── 6. 쿨다운 체크 ──────────────────────────────────────────────────────── cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) cooldown_check = await db.execute( select(TradeHistory) .where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "BUY", TradeHistory.status.in_(["EXECUTED", "COMPLETED"]), TradeHistory.created_at >= daily_cutoff, ) .order_by(desc(TradeHistory.created_at)) ) recent_buys = cooldown_check.scalars().all() 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. MCP 검증 ─────────────────────────────────────────────────────────── mcp_ok, mcp_reason = await _mcp_validate_buy(stock) if not mcp_ok: return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason} # ── 8. 포지션 사이징 ────────────────────────────────────────────────────── 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"), ) if invest_amount <= 0 and total_assets <= 0: invest_amount = available_cash * 0.04 quantity = int(invest_amount / current_price) if quantity <= 0: return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name} # ── 9. 주문 실행 (semaphore로 동시성 제어) ─────────────────────────────── try: async with _order_semaphore: if is_domestic: order_result = await korea_investment_api.place_order( stock_code, "BUY", quantity, int(current_price) ) else: order_result = await korea_investment_api.place_overseas_order( stock_code, "BUY", quantity, float(current_price), exchange_code ) total_amount = Decimal(str(current_price)) * quantity entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점" success = order_result.get("rt_cd") == "0" status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED") trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, stock_name=stock_name, order_type="BUY", order_price=Decimal(str(current_price)), order_quantity=quantity, executed_price=Decimal(str(current_price)) if success else None, executed_quantity=quantity if success else 0, total_amount=total_amount, status=status, order_id=order_result.get("odno"), message=f"자동매수: {'추가매수' if holding else '신규'} | {' | '.join(signals[:2])}", entry_reason=entry_reason, signals=signals, created_at=_get_kst_now(), executed_at=_get_kst_now() if success else None, ) db.add(trade) 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 # KIS 체결 확인 후 포트폴리오 DB 동기화 (3초 딜레이) asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) return { "action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success, } except Exception as e: return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} async def _execute_sell( stock: dict, db: AsyncSession, holding, current_price: float, signals: list, is_domestic: bool, exchange_code: str, stock_code: str, stock_name: str, ) -> dict: """SELL 주문 실행 로직 — 중복체크 선행 → semaphore → 주문 → TradeHistory 기록 → sync.""" quantity = holding.quantity _avg = float(holding.average_price or 0) profit_loss_rate = ( ((current_price - _avg) / _avg) * 100 if _avg > 0 else float(holding.profit_loss_rate or 0) ) take_profit = stock.get("take_profit", 3.0) stop_loss = stock.get("stop_loss", -1.5) if not (profit_loss_rate >= take_profit or profit_loss_rate <= stop_loss or True): return {"action": "HOLD", "stock": stock_name} try: # ── 1. 중복 주문 체크 — 주문 전에 먼저 확인 ──────────────────────── recent_cutoff = _get_kst_now() - timedelta(seconds=10) duplicate_check = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", TradeHistory.created_at >= recent_cutoff, ) ) if duplicate_check.scalar_one_or_none(): return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} # ── 2. 주문 실행 (semaphore로 동시성 제어) ────────────────────────── async with _order_semaphore: if is_domestic: order_result = await korea_investment_api.place_order( stock_code, "SELL", quantity, int(current_price) ) else: order_result = await korea_investment_api.place_overseas_order( stock_code, "SELL", quantity, float(current_price), exchange_code ) total_amount = Decimal(str(current_price)) * quantity profit_loss = (current_price - float(holding.average_price or current_price)) * quantity success = order_result.get("rt_cd") == "0" status = "COMPLETED" if success else ("PENDING" if order_result.get("rt_cd") == "1" else "FAILED") trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, stock_name=stock_name, order_type="SELL", order_price=Decimal(str(current_price)), order_quantity=quantity, executed_price=Decimal(str(current_price)) if success else None, executed_quantity=quantity if success else 0, total_amount=total_amount, status=status, order_id=order_result.get("odno"), message=f"자동매도: 손익률 {profit_loss_rate:.2f}%", exit_reason=f"손익: {profit_loss:,.0f}원", signals=signals, net_profit_loss=Decimal(str(profit_loss)) if success else None, profit_loss_rate=Decimal(str(profit_loss_rate)) if success else None, created_at=_get_kst_now(), executed_at=_get_kst_now() if success else None, ) db.add(trade) await db.commit() if success: # 즉시 캐시 반영 try: from app.api.endpoints.portfolio import portfolio_cache avg = float(holding.average_price or current_price) portfolio_cache.apply_sell(stock_code, quantity, current_price, avg) portfolio_cache.holdings = [ h for h in portfolio_cache.holdings if h.stock_code != stock_code ] except Exception: pass # KIS 체결 확인 후 포트폴리오 DB 동기화 asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) return { "action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success, } except Exception as e: return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} 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) return try: total_processed = 0 trade_results = [] market_regime = None if settings.BEAR_MARKET_FILTER_ENABLED: market_regime = await check_market_regime() regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20 position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0 max_trades = min(20, regime_max_trades) result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) held_stocks = {p.stock_code for p in result.scalars().all()} rec_groups = recommendations_response.get("recommendations_by_rule", []) if isinstance(rec_groups, dict): normalized_list = [] for name, group in rec_groups.items(): if isinstance(group, dict): normalized_list.append(group) else: normalized_list.append({"rule_name": name, "stocks": group}) rec_groups = normalized_list unique_stocks: dict = {} for rule_group in rec_groups: if not isinstance(rule_group, dict): logger.warning("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) continue for stock in stocks_list: if not isinstance(stock, dict): logger.warning("Skipping non-dict stock entry: %r", stock) continue stock_code = stock.get("stock_code") if stock_code not in unique_stocks: unique_stocks[stock_code] = stock elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999): unique_stocks[stock_code] = stock sorted_stocks = sorted( unique_stocks.values(), key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0)), ) for stock in sorted_stocks[:max_trades]: if not isinstance(stock, dict): logger.warning("Skipping invalid stock entry: %r", stock) continue try: stock["_position_multiplier"] = position_multiplier stock["_regime_str"] = ( market_regime.get("regime_200d", "bear") if market_regime else "bear" ) result = await execute_auto_trade(stock, db) total_processed += 1 if result["action"] in ["BUY", "SELL"]: trade_results.append(result) await asyncio.sleep(0.5) except Exception as e: logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") continue except Exception as e: logger.error( f"❌ Background auto-trade error: {e} — " f"recommendations_response={recommendations_response!r}" ) async def _background_sync_portfolio(): try: from app.api.endpoints.portfolio import 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}") 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}") except Exception as e: logger.error(f"Delayed portfolio sync failed: {e}") async def execute_auto_trade(stock: dict, db: AsyncSession): """종목 단건 매매 판단 및 실행. 공통 필터(가격·MDD·손실추적·보유수·당일매도쿨다운·신호분석)를 통과한 후 _execute_buy / _execute_sell 에 위임한다. """ try: stock_code = stock.get("stock_code") stock_name = stock.get("stock_name") current_price = float(stock.get("current_price", 0)) signals = stock.get("signals", []) if not stock_code or current_price <= 0: return {"action": "SKIP", "reason": "invalid_data"} is_domestic = stock_code.isdigit() and len(stock_code) == 6 exchange_code = stock.get("exchange_code", "NASD") # ── 가격 필터 ───────────────────────────────────────────────────────── if is_domestic: if current_price < settings.MIN_PRICE_KRW: return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} else: if current_price < settings.MIN_PRICE_USD: return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name} # ── MDD / 손실추적 / 보유수 ────────────────────────────────────────── if not await mdd_manager.can_open_new_position(): return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name} if loss_tracker.is_banned(stock_code): return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name} held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) if len(held_result.scalars().all()) >= settings.MAX_HOLDINGS_COUNT: return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name} # ── 당일 매도 후 재매수 쿨다운 ─────────────────────────────────────── cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) recent_sell = await db.execute( select(TradeHistory).where( TradeHistory.stock_code == stock_code, TradeHistory.order_type == "SELL", TradeHistory.status == "EXECUTED", TradeHistory.executed_at >= cutoff_time, ) ) if recent_sell.first(): return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name} # ── 현재 보유 조회 ──────────────────────────────────────────────────── result = await db.execute( select(Portfolio).where( Portfolio.stock_code == stock_code, Portfolio.quantity > 0, ) ) holding = result.scalar_one_or_none() # ── 신호 분석 ───────────────────────────────────────────────────────── 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) 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) 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 has_rule = stock.get("rule_id") is not None if has_rule: if "이격도 과열" in text_signals or "매수금지" in text_signals: 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: 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) if not has_strong_signal and (matched_count < 2 or score_val < 2): should_buy = False # ── 매매 실행 위임 ──────────────────────────────────────────────────── if should_buy: return await _execute_buy( stock, db, holding, current_price, signals, score_val, is_domestic, exchange_code, stock_code, stock_name, ) if should_sell and holding: return await _execute_sell( stock, db, holding, current_price, signals, is_domestic, exchange_code, stock_code, stock_name, ) return {"action": "HOLD", "stock": stock_name} except Exception as e: return {"action": "ERROR", "error": str(e)}