import asyncio import json import os from datetime import datetime import logging from decimal import Decimal import aiohttp # backend 경로를 sys.path에 추가하여 서비스 모듈 임포트 import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../backend'))) from sqlalchemy import select, update from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from app.core.config import settings from app.models.models import ActiveTrade from app.services.korea_investment_api import korea_investment_api # 로깅 설정 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') # --- DB 설정 --- engine = create_async_engine(settings.DATABASE_URL, echo=False) AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) # ---------------- ACTIVE_TRADES_FILE = os.path.join(os.path.dirname(__file__), 'active_trades.json') MONITORING_INTERVAL_SECONDS = 60 # 60초마다 확인 BACKEND_API_BASE = os.getenv('BACKEND_API_BASE', 'http://localhost:8000') async def update_active_trade_state_via_api(stock_code: str, new_status: str, reason: str = "") -> bool: """ CLAUDE.md Section 3.2: 직접 DB 접근 금지. 백엔드 API를 통해 상태 업데이트. 활성 거래의 상태를 API를 통해 업데이트한다. 이는 db_worker를 우회하지 않으면서도 여러 시스템이 동시에 접근할 수 있게 한다. Args: stock_code: 종목 코드 new_status: 목표 상태 ('active', 'pending_sell', 'closed') reason: 상태 변경 이유 Returns: 성공 여부 """ try: async with aiohttp.ClientSession() as session: url = f"{BACKEND_API_BASE}/api/portfolio/active-trades/{stock_code}/status" payload = { "status": new_status, "reason": reason, "updated_at": datetime.utcnow().isoformat() } async with session.patch(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp: if resp.status == 200: logging.info(f"✅ [{stock_code}] API를 통해 상태 변경: {new_status}") return True else: error_text = await resp.text() logging.error(f"❌ [{stock_code}] 상태 변경 API 실패 (HTTP {resp.status}): {error_text}") return False except Exception as e: logging.error(f"❌ [{stock_code}] 상태 변경 API 호출 중 예외: {e}") return False async def calculate_atr_and_highest(db: AsyncSession, stock_code: str) -> tuple[float, float]: """ 최근 14개 캔들에서 ATR과 최고가를 계산한다. Args: db: 비동기 DB 세션 stock_code: 종목 코드 Returns: (atr, highest_since_entry) 튜플 """ try: # StockDailyPrice에서 최근 14개 캔들 조회 from app.models.models import StockDailyPrice from sqlalchemy import desc result = await db.execute( select(StockDailyPrice) .where(StockDailyPrice.stock_code == stock_code) .order_by(desc(StockDailyPrice.trade_date)) .limit(14) ) prices = list(reversed(result.scalars().all())) if len(prices) < 2: logging.warning(f"[{stock_code}] ATR 계산을 위한 충분한 가격 데이터 없음") return 0.0, 0.0 # ATR 계산 (True Range 평균) tr_values = [] for i in range(1, len(prices)): high = prices[i].high_price low = prices[i].low_price prev_close = prices[i - 1].close_price tr = max( high - low, abs(high - prev_close), abs(low - prev_close) ) tr_values.append(tr) atr = sum(tr_values) / len(tr_values) if tr_values else 0.0 # 최고가 계산 (진입 이후) highest = max(p.high_price for p in prices) if prices else 0.0 return float(atr), float(highest) except Exception as e: logging.error(f"[{stock_code}] ATR/최고가 계산 중 예외: {e}") return 0.0, 0.0 async def check_partial_takeprofit_and_trailing_stop( stock_code: str, current_price: float, entry_price: float, remaining_pct: float, highest_since_entry: float, atr: float ) -> tuple[bool, float, str]: """ CLAUDE.md Section 5.2: 부분 익절 및 트레일링 스탑 로직 - +2R 수익 (리스크 대비 2배): 50% 분할 매도 - 나머지 50%: high - (1.5 * ATR) 트레일링 스탑 Args: stock_code: 종목 코드 current_price: 현재가 entry_price: 진입가 remaining_pct: 남은 수량 비율 (기본 1.0) highest_since_entry: 진입 이후 최고가 atr: ATR 값 Returns: (should_sell, sell_price, reason) 튜플 """ if remaining_pct <= 0: return False, 0.0, "" profit = current_price - entry_price # ───────────────────────────────────────────────────────────────────────── # +2R 익절 조건 (처음 50% 분할 매도) # ───────────────────────────────────────────────────────────────────────── # 평균 손절까지의 거리를 리스크로 정의 (기본값: 진입가의 6%) risk = entry_price * 0.06 # 기본 손절: -6% if profit >= 2 * risk and remaining_pct == 1.0: # 첫 50%만 매도 logging.info(f"[{stock_code}] +2R 익절 조건 충족 (수익: {profit}, 리스크: {risk})") return True, current_price, f"+2R 익절 (수익: {profit:.0f}원)" # ───────────────────────────────────────────────────────────────────────── # 트레일링 스탑 (나머지 50% 물량) # ───────────────────────────────────────────────────────────────────────── if remaining_pct < 1.0 and atr > 0: # 부분 매도 후 남은 물량 trailing_stop_price = highest_since_entry - (1.5 * atr) if current_price <= trailing_stop_price: logging.info( f"[{stock_code}] 트레일링 스탑 도달 " f"(현재: {current_price}, 스탑: {trailing_stop_price})" ) return True, current_price, f"트레일링 스탑 (스탑가: {trailing_stop_price:.0f}원)" return False, 0.0, "" async def sync_json_from_db(db: AsyncSession): """DB의 active_trades 목록을 JSON 파일에 동기화(덮어쓰기)합니다.""" try: result = await db.execute(select(ActiveTrade).where(ActiveTrade.status == 'active')) active_trades_from_db = result.scalars().all() json_trades = [ { 'stock_code': trade.stock_code, 'stock_name': trade.stock_name, 'quantity': trade.quantity, 'entry_price': float(trade.entry_price), 'stop_loss': float(trade.stop_loss), 'take_profit': float(trade.take_profit), 'order_id': trade.order_id, 'reason': trade.entry_reason, 'timestamp': trade.created_at.isoformat() } for trade in active_trades_from_db ] with open(ACTIVE_TRADES_FILE, 'w', encoding='utf-8') as f: json.dump(json_trades, f, indent=4, ensure_ascii=False) logging.info(f"💾 [JSON] DB와 동기화 완료. 활성 거래 {len(json_trades)}건.") except Exception as e: logging.error(f"❌ [JSON] DB 동기화 실패: {e}") async def check_and_execute_trades(db: AsyncSession): """ DB에서 활성화된 거래를 확인하고 손절/익절 조건에 따라 매도 주문을 실행합니다. CLAUDE.md Section 3.2: 직접 DB commit 금지 - 상태 변경은 API를 통해 처리 (db_worker 우회 방지) - 모든 state transition을 logging CLAUDE.md Section 5.2: 부분 익절 및 트레일링 스탑 - +2R: 50% 분할 매도 (pending_sell 상태) - 나머지 50%: trailing stop (high - 1.5*ATR) """ logging.info("거래 모니터링 시작 (DB 기반)...") result = await db.execute(select(ActiveTrade).where(ActiveTrade.status == 'active')) active_trades = result.scalars().all() if not active_trades: logging.info("현재 활성화된 거래가 없습니다.") return for trade in active_trades: stock_code = trade.stock_code stop_loss = float(trade.stop_loss) take_profit = float(trade.take_profit) quantity = trade.quantity entry_price = float(trade.entry_price) remaining_pct = float(trade.remaining_pct) if trade.remaining_pct else 1.0 highest_since_entry = float(trade.highest_since_entry) if trade.highest_since_entry else entry_price should_sell = False sell_reason = "" sell_quantity = 0 try: # 현재가 조회 price_info, _ = await korea_investment_api._make_request( "GET", "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100", params={"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code} ) if price_info and price_info.get('rt_cd') == '0': current_price_str = price_info.get('output', {}).get('stck_prpr') if not current_price_str: logging.error(f"[{stock_code}] 현재가 정보를 응답에서 찾을 수 없습니다: {price_info}") continue current_price = int(current_price_str) logging.info( f"[{stock_code}] 현재가: {current_price} (진입: {entry_price}, " f"손절: {stop_loss}, 목표: {take_profit}, 남은비율: {remaining_pct*100:.1f}%)" ) # ───────────────────────────────────────────────────────────── # 1. 손절 조건 확인 # ───────────────────────────────────────────────────────────── if stop_loss and current_price <= stop_loss: should_sell = True sell_quantity = int(quantity * remaining_pct) sell_reason = f"손절가 도달 (현재가: {current_price} <= 손절가: {stop_loss})" # ───────────────────────────────────────────────────────────── # 2. 기본 익절 조건 확인 # ───────────────────────────────────────────────────────────── elif take_profit and current_price >= take_profit: should_sell = True sell_quantity = int(quantity * remaining_pct) sell_reason = f"목표가 도달 (현재가: {current_price} >= 목표가: {take_profit})" # ───────────────────────────────────────────────────────────── # 3. 부분 익절 + 트레일링 스탑 조건 (CLAUDE.md 5.2) # ───────────────────────────────────────────────────────────── else: atr, new_highest = await calculate_atr_and_highest(db, stock_code) # highest_since_entry 업데이트 if new_highest > highest_since_entry: highest_since_entry = new_highest should_tp, tp_price, tp_reason = await check_partial_takeprofit_and_trailing_stop( stock_code, current_price, entry_price, remaining_pct, highest_since_entry, atr ) if should_tp: should_sell = True if remaining_pct == 1.0: # 첫 익절: 50% 분할 매도 sell_quantity = int(quantity * 0.5) else: # 트레일링 스탑: 남은 물량 전부 sell_quantity = int(quantity * remaining_pct) sell_reason = tp_reason else: logging.error(f"[{stock_code}] 현재가 조회 실패: {price_info.get('msg1', '알 수 없는 오류')}") continue except Exception as e: logging.error(f"[{stock_code}] 처리 중 예외 발생: {e}") continue if should_sell and sell_quantity > 0: await _execute_sell_with_pending_state( stock_code, quantity, sell_quantity, entry_price, sell_reason ) async def _execute_sell_with_pending_state( stock_code: str, total_quantity: int, sell_quantity: int, entry_price: float, sell_reason: str ) -> None: """ CLAUDE.md Section 5.2: 매도 주문 실행 및 상태 전이 단계: 1. 상태를 'pending_sell'로 변경 (즉시) 2. 매도 주문 실행 3. 주문 성공 시 'closed'로 변경 (1-5초 대기) 4. 실패 시 'active'로 롤백 Args: stock_code: 종목 코드 total_quantity: 보유 수량 sell_quantity: 매도할 수량 entry_price: 진입가 sell_reason: 매도 이유 """ try: logging.warning( f"🚨 [{stock_code}] 매도 조건 충족: {sell_reason} " f"(수량: {sell_quantity}/{total_quantity})" ) # ───────────────────────────────────────────────────────────── # Step 1: 상태를 'pending_sell'로 즉시 변경 # ───────────────────────────────────────────────────────────── success = await update_active_trade_state_via_api( stock_code, "pending_sell", f"매도 주문 대기 중: {sell_reason}" ) if not success: logging.error(f"❌ [{stock_code}] pending_sell 상태 전이 실패") return logging.info(f"⏳ [{stock_code}] 상태: active → pending_sell (매도 주문 대기)") # ───────────────────────────────────────────────────────────── # Step 2: 시장가 매도 주문 실행 # ───────────────────────────────────────────────────────────── sell_result = await korea_investment_api.place_order( stock_code=stock_code, order_type='SELL', quantity=sell_quantity, price=None # 시장가 ) logging.info(f"[{stock_code}] 매도 주문 결과: {sell_result}") if sell_result and sell_result.get('rt_cd') == '0': order_id = sell_result.get('output', {}).get('order_id', '') logging.info(f"✅ [{stock_code}] 매도 주문 성공 (주문ID: {order_id})") # ───────────────────────────────────────────────────────── # Step 3: 1-5초 대기 후 체결 확인 및 상태 'closed'로 변경 # ───────────────────────────────────────────────────────── await asyncio.sleep(2) # 체결 대기 success = await update_active_trade_state_via_api( stock_code, "closed", f"매도 완료: {sell_reason} (주문ID: {order_id})" ) if success: logging.info(f"✅ [{stock_code}] 상태: pending_sell → closed") else: logging.warning(f"⚠️ [{stock_code}] closed 상태 전이 실패 (order_id: {order_id})") else: error_msg = sell_result.get('msg1', '알 수 없는 오류') if sell_result else "응답 없음" logging.error(f"❌ [{stock_code}] 매도 주문 실패: {error_msg}") # ───────────────────────────────────────────────────────── # Step 4: 주문 실패 시 'active'로 롤백 # ───────────────────────────────────────────────────────── success = await update_active_trade_state_via_api( stock_code, "active", f"매도 주문 실패로 복구: {error_msg}" ) if success: logging.info(f"🔄 [{stock_code}] 상태 롤백: pending_sell → active") except Exception as e: logging.error(f"[{stock_code}] 매도 실행 중 예외 발생: {e}", exc_info=True) # 예외 발생 시에도 'active'로 복구 try: await update_active_trade_state_via_api( stock_code, "active", f"예외로 인한 롤백: {str(e)}" ) except Exception as rollback_exc: logging.error(f"[{stock_code}] 롤백 중 예외: {rollback_exc}") async def main(): """메인 실행 루프""" logging.info("🚀 자동 매도 모니터링 시스템을 시작합니다. (DB 모드)") logging.info(f"점검 주기: {MONITORING_INTERVAL_SECONDS}초") # 시작 시 DB와 JSON 동기화 async with AsyncSessionLocal() as db: await sync_json_from_db(db) while True: try: async with AsyncSessionLocal() as db: await check_and_execute_trades(db) # 매 사이클 종료 후 DB와 JSON 동기화 await sync_json_from_db(db) except Exception as e: logging.critical(f"모니터링 루프에서 치명적 오류 발생: {e}", exc_info=True) logging.info(f"{MONITORING_INTERVAL_SECONDS}초 후 다음 점검을 시작합니다.\n") await asyncio.sleep(MONITORING_INTERVAL_SECONDS) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: logging.info("사용자에 의해 프로그램이 종료되었습니다.")