import os import time import threading import argparse from typing import List, Dict, Tuple from dotenv import load_dotenv import logging # Modular imports from src.common import logger, setup_logger from src.config import load_config, read_symbols, get_symbols_file, build_runtime_config, RuntimeConfig from src.threading_utils import run_sequential, run_with_threads from src.notifications import send_telegram, report_error, send_startup_test_message from src.holdings import load_holdings from src.signals import check_sell_conditions, CheckType from src.kiwoom_api import get_kiwoom_api load_dotenv() def minutes_to_timeframe(minutes: int) -> str: """분 단위를 캔들봉 timeframe 문자열로 변환 (예: 60 -> '1h', 240 -> '4h')""" if minutes < 60: return f"{minutes}m" elif minutes % 1440 == 0: return f"{minutes // 1440}d" elif minutes % 60 == 0: return f"{minutes // 60}h" else: return f"{minutes}m" def process_symbols_and_holdings( cfg: RuntimeConfig, symbols: List[str], config: Dict[str, any], last_buy_check_time: float, last_sell_check_time: float, last_profit_taking_check_time: float ) -> Tuple[float, float, float]: """매수/매도 조건을 확인하고 실행. Args: cfg: 런타임 설정 symbols: 심볼 리스트 config: 설정 딕셔너리 last_buy_check_time: 마지막 매수 체크 시간 last_sell_check_time: 마지막 손절 체크 시간 last_profit_taking_check_time: 마지막 익절 체크 시간 Returns: (업데이트된 매수 체크 시간, 손절 체크 시간, 익절 체크 시간) """ holdings = load_holdings('holdings.json') held_symbols = set(holdings.keys()) if holdings else set() buy_candidate_symbols = [s for s in symbols if s not in held_symbols] current_time = time.time() buy_signal_count = 0 sell_signal_count = 0 buy_interval_minutes = config.get("buy_check_interval_minutes", 240) buy_interval = buy_interval_minutes * 60 buy_timeframe = minutes_to_timeframe(buy_interval_minutes) if current_time - last_buy_check_time >= buy_interval: logger.info("매수 조건 확인 시작 (주기: %d분, 데이터: %s)", buy_interval_minutes, buy_timeframe) from src.holdings import get_kiwoom_balances krw_balance = None try: balances = get_kiwoom_balances(cfg.kiwoom_account_number) krw_balance = balances.get('KRW', 0) except Exception as e: logger.warning("Kiwoom 잔고 조회 실패: %s", e) buy_amount = config.get('auto_trade', {}).get('buy_amount', 1000000) if krw_balance is not None and krw_balance < buy_amount: msg = f"[매수 건너뜀] Kiwoom 계좌 잔고 부족: 현재 KRW={krw_balance:.0f}, 필요={buy_amount:.0f}" logger.warning(msg) if cfg.telegram_bot_token and cfg.telegram_chat_id: send_telegram(cfg.telegram_bot_token, cfg.telegram_chat_id, msg, parse_mode=cfg.telegram_parse_mode or "HTML") else: from src.config import RuntimeConfig cfg_with_buy_timeframe = RuntimeConfig( timeframe=buy_timeframe, indicator_timeframe=buy_timeframe, candle_count=cfg.candle_count, symbol_delay=cfg.symbol_delay, interval=cfg.interval, loop=cfg.loop, dry_run=cfg.dry_run, max_threads=cfg.max_threads, telegram_parse_mode=cfg.telegram_parse_mode, trading_mode=cfg.trading_mode, telegram_bot_token=cfg.telegram_bot_token, telegram_chat_id=cfg.telegram_chat_id, kiwoom_account_number=cfg.kiwoom_account_number, aggregate_alerts=cfg.aggregate_alerts, benchmark=cfg.benchmark, telegram_test=getattr(cfg, 'telegram_test', False), config=cfg.config ) if cfg.max_threads > 1: buy_signal_count = run_with_threads(buy_candidate_symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=cfg.aggregate_alerts, cfg=cfg_with_buy_timeframe) else: buy_signal_count = run_sequential(buy_candidate_symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=cfg.aggregate_alerts, cfg=cfg_with_buy_timeframe) last_buy_check_time = current_time else: logger.debug("매수 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (buy_interval - (current_time - last_buy_check_time)) / 60) # 손절/익절 조건 체크 설정 stop_loss_interval_minutes = config.get("stop_loss_check_interval_minutes", 60) stop_loss_interval = stop_loss_interval_minutes * 60 profit_taking_interval_minutes = config.get("profit_taking_check_interval_minutes", 240) profit_taking_interval = profit_taking_interval_minutes * 60 sell_signal_count = 0 # 손절/익절 체크 여부 확인 should_check_stop_loss = (current_time - last_sell_check_time >= stop_loss_interval) should_check_profit_taking = (current_time - last_profit_taking_check_time >= profit_taking_interval) # Holdings 업데이트는 한 번만 수행 (손절/익절 체크 전) if should_check_stop_loss or should_check_profit_taking: account_number = cfg.kiwoom_account_number # Holdings 한 번만 업데이트 if account_number: try: from src.holdings import save_holdings, fetch_holdings_from_kiwoom updated_holdings = fetch_holdings_from_kiwoom(account_number) if updated_holdings is not None: holdings = updated_holdings save_holdings(holdings, 'holdings.json') except Exception as e: logger.exception("Kiwoom holdings 조회 중 오류: %s", e) report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] Holdings 조회 실패: {e}", cfg.dry_run) # 손절 체크 (1시간 주기): 조건1, 조건3, 조건4-2, 조건5-2 if should_check_stop_loss: logger.info("손절 조건 확인 시작 (주기: %d분)", stop_loss_interval_minutes) try: if holdings: sell_results, count = check_sell_conditions(holdings, cfg=cfg, config=config, check_type=CheckType.STOP_LOSS) sell_signal_count += count logger.info("보유 종목 손절 조건 확인 완료: %d개 검사, %d개 매도 신호", len(sell_results), count) else: logger.debug("보유 종목 없음") except Exception as e: logger.exception("보유 종목 손절 조건 확인 중 오류: %s", e) report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 손절 조건 확인 실패: {e}", cfg.dry_run) last_sell_check_time = current_time # 익절 체크 (4시간 주기): 조건2, 조건4-1, 조건5-1 if should_check_profit_taking: logger.info("익절 조건 확인 시작 (주기: %d분)", profit_taking_interval_minutes) try: if holdings: sell_results, count = check_sell_conditions(holdings, cfg=cfg, config=config, check_type=CheckType.PROFIT_TAKING) sell_signal_count += count logger.info("보유 종목 익절 조건 확인 완료: %d개 검사, %d개 매도 신호", len(sell_results), count) else: logger.debug("보유 종목 없음") except Exception as e: logger.exception("보유 종목 익절 조건 확인 중 오류: %s", e) report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 익절 조건 확인 실패: {e}", cfg.dry_run) last_profit_taking_check_time = current_time else: if not should_check_stop_loss: logger.debug("손절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (stop_loss_interval - (current_time - last_sell_check_time)) / 60) if not should_check_profit_taking: logger.debug("익절 조건 확인 대기 중 (다음 확인까지 %.1f분 남음)", (profit_taking_interval - (current_time - last_profit_taking_check_time)) / 60) logger.info("[요약] 매수 조건 충족 심볼: %d개, 매도 조건 충족 심볼: %d개", buy_signal_count, sell_signal_count) return last_buy_check_time, last_sell_check_time, last_profit_taking_check_time def execute_benchmark(cfg, symbols): """Execute benchmark to compare single-thread and multi-thread performance.""" logger.info("간단 벤치마크 시작: 심볼=%d", len(symbols)) start = time.time() run_sequential(symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=False, cfg=cfg) elapsed_single = time.time() - start logger.info("순차 처리 소요 시간: %.2f초", elapsed_single) start = time.time() run_with_threads(symbols, parse_mode=cfg.telegram_parse_mode, aggregate_enabled=False, cfg=cfg) elapsed_parallel = time.time() - start logger.info("병렬 처리(%d 스레드) 소요 시간: %.2f초", cfg.max_threads, elapsed_parallel) if elapsed_parallel < elapsed_single: reduction = (elapsed_single - elapsed_parallel) / elapsed_single * 100 logger.info("병렬로 %.1f%% 빨라졌습니다 (권장 스레드=%d).", reduction, cfg.max_threads) else: logger.info("병렬이 순차보다 빠르지 않습니다. 네트워크/IO 패턴을 점검하세요.") def main(): parser = argparse.ArgumentParser(description="주식 자동매매 프로그램") parser.add_argument("--benchmark", action="store_true", help="벤치마크 실행") args = parser.parse_args() config = load_config() if not config: logger.error("설정 로드 실패; 종료합니다") return cfg = build_runtime_config(config) setup_logger(cfg.dry_run) logger.info("=" * 80) logger.info("주식 자동매매 프로그램 시작") logger.info("=" * 80) # Kiwoom API 로그인 get_kiwoom_api() symbols = read_symbols(get_symbols_file(config)) if not symbols: logger.error("심볼 로드 실패; 종료합니다") return if cfg.telegram_test: send_startup_test_message(cfg.telegram_bot_token, cfg.telegram_chat_id, cfg.telegram_parse_mode, cfg.dry_run) if not cfg.dry_run and (not cfg.telegram_bot_token or not cfg.telegram_chat_id): logger.error("dry-run이 아닐 때 텔레그램 환경변수 필수: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID") return buy_check_minutes = config.get("buy_check_interval_minutes", 240) stop_loss_check_minutes = config.get("stop_loss_check_interval_minutes", 60) profit_taking_check_minutes = config.get("profit_taking_check_interval_minutes", 240) logger.info("설정: symbols=%d, symbol_delay=%.2f초, candle_count=%d, loop=%s, dry_run=%s, max_threads=%d, trading_mode=%s", len(symbols), cfg.symbol_delay, cfg.candle_count, cfg.loop, cfg.dry_run, cfg.max_threads, cfg.trading_mode) logger.info("매수 확인 주기: %d분 (%s봉), 손절 확인 주기: %d분 (%s봉), 익절 확인 주기: %d분 (%s봉)", buy_check_minutes, minutes_to_timeframe(buy_check_minutes), stop_loss_check_minutes, minutes_to_timeframe(stop_loss_check_minutes), profit_taking_check_minutes, minutes_to_timeframe(profit_taking_check_minutes)) if args.benchmark: execute_benchmark(cfg, symbols) return last_buy_check_time = 0 last_sell_check_time = 0 last_profit_taking_check_time = 0 if not cfg.loop: process_symbols_and_holdings(cfg, symbols, config, last_buy_check_time, last_sell_check_time, last_profit_taking_check_time) else: buy_check_minutes = config.get("buy_check_interval_minutes", 240) stop_loss_check_minutes = config.get("stop_loss_check_interval_minutes", 60) profit_taking_check_minutes = config.get("profit_taking_check_interval_minutes", 240) loop_interval_minutes = min(buy_check_minutes, stop_loss_check_minutes, profit_taking_check_minutes) interval_seconds = max(10, loop_interval_minutes * 60) logger.info("루프 모드 시작: %d분 간격 (매수확인: %d분마다, 손절확인: %d분마다, 익절확인: %d분마다)", loop_interval_minutes, buy_check_minutes, stop_loss_check_minutes, profit_taking_check_minutes) try: while True: try: last_buy_check_time, last_sell_check_time, last_profit_taking_check_time = process_symbols_and_holdings( cfg, symbols, config, last_buy_check_time, last_sell_check_time, last_profit_taking_check_time) except Exception as e: logger.exception("루프 내 작업 중 오류: %s", e) report_error(cfg.telegram_bot_token, cfg.telegram_chat_id, f"[오류] 루프 내 작업 실패: {e}", cfg.dry_run) logger.info("다음 실행까지 %d초 대기", interval_seconds) time.sleep(interval_seconds) except KeyboardInterrupt: logger.info("사용자가 루프를 중단함") if __name__ == "__main__": main()