268 lines
12 KiB
Python
268 lines
12 KiB
Python
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() |