""" main.py 단타 자동매매 시스템 메인 진입점 기획서 v2.1 기준 실행: python -m app.main (Docker 컨테이너) python app/main.py (로컬 테스트) 환경변수: KIS_MOCK=true → 모의투자 모드 DRY_RUN=true → 신호만 확인, 주문 전송 안 함 """ import io import json import os import sys import asyncio import logging from datetime import datetime, time, timedelta from pathlib import Path # 한글 로그 깨짐 방지 — stdout을 UTF-8로 강제 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") elif hasattr(sys.stdout, "buffer"): sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True) # .env 로드 def load_env(): env_path = Path(".env") if not env_path.exists(): return with open(env_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") k = k.strip() v = v.strip() # 인라인 주석 제거 (예: true # 모의투자 → true) if " #" in v: v = v[:v.index(" #")] v = v.strip().strip('"').strip("'") if k and v and k not in os.environ: os.environ[k] = v load_env() # 프로젝트 루트를 sys.path에 추가 (로컬 실행 시 필요) ROOT = Path(__file__).parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) # 로깅 설정 logging.basicConfig( level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler("logs/stockbot.log", encoding="utf-8"), ] ) logger = logging.getLogger(__name__) from app.execution.kis_client import KISClient from app.execution.order_executor import OrderExecutor from app.strategy.volatility_breakout import VolatilityBreakout from app.risk.manager import RiskManager from app.monitor.notifier import ( notify_buy, notify_tp1, notify_tp2, notify_sl, notify_force_exit, notify_risk, notify_daily_summary, notify_error, notify_ai_result, notify_ai_blocked, notify_ai_fallback, send ) from app.db.models import init_db, get_conn from app.ml.predictor import ScalpingModel from app.config import ( MAX_UNIVERSE, FORCE_EXIT, MAX_POSITIONS, MAX_HOLD_MIN, KOSPI_MIN_CHG, MAX_DAILY_ENTRIES, MAX_HOURLY_STOP_LOSS, ENTRY_PAUSE_WINDOWS, ENTRY_LIMIT_ENFORCE, SL_CASCADE_WINDOW_MIN, SL_CASCADE_HALT_THRESHOLD ) class SingleInstanceLock: """Process-wide lock so only one StockBot can run per workspace.""" def __init__(self, path: str | Path): self.path = Path(path) self._fh = None self._mode = None def acquire(self) -> bool: self.path.parent.mkdir(parents=True, exist_ok=True) self._fh = open(self.path, "a+", encoding="utf-8") if os.name == "nt": import msvcrt self._fh.seek(0, os.SEEK_END) if self._fh.tell() == 0: self._fh.write("0") self._fh.flush() self._fh.seek(0) try: msvcrt.locking(self._fh.fileno(), msvcrt.LK_NBLCK, 1) except OSError: self._fh.close() self._fh = None return False self._mode = "msvcrt" else: import fcntl try: fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: self._fh.close() self._fh = None return False self._mode = "fcntl" self._fh.seek(0) self._fh.truncate() self._fh.write(str(os.getpid())) self._fh.flush() return True def release(self): if self._fh is None: return try: if self._mode == "msvcrt": import msvcrt self._fh.seek(0) msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1) elif self._mode == "fcntl": import fcntl fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN) finally: self._fh.close() self._fh = None class StockBot: def __init__(self): self.kis = KISClient() self.executor = OrderExecutor(self.kis) self.strategy = VolatilityBreakout() self.positions = {} # ticker → {name, entry, qty, tp1_done, entry_time} self.universe = [] # 감시 종목 리스트 self.ticker_names = {} # ticker → 종목명 캐시 self.ticker_sectors = {} # ticker -> sector name cache self.sl_tickers = set() # 당일 SL 당한 종목 — 재진입 차단 self.risk = None # RiskManager (잔고 확인 후 초기화) self.running = False self.scalping_model = ScalpingModel() # 장중 컨텍스트 (midday_context.json 갱신 감지용) self._midday_ctx_mtime : float = 0.0 self._midday_pos_mult : float = 1.0 # midday position_size_multiplier self._midday_loaded : bool = False self._last_diag : float = 0.0 # 신호 진단 로그 마지막 시각 self._daily_summary_dates = set() mode = "모의투자" if self.kis.is_mock else "실거래" dry = " [DRY_RUN]" if os.getenv("DRY_RUN","true")=="true" else "" logger.info(f"StockBot 시작 [{mode}]{dry}") # ───────────────────────────────────────── # 장중 컨텍스트 감시 # ───────────────────────────────────────── def _check_midday_context(self): """midday_context.json 갱신 감지 → 즉시 점심 세션 파라미터 반영""" path = Path("data/midday_context.json") if not path.exists(): return try: mtime = path.stat().st_mtime except OSError: return if mtime <= self._midday_ctx_mtime: return try: ctx = json.loads(path.read_text(encoding="utf-8")) if ctx.get("date") != datetime.now().strftime("%Y-%m-%d"): return if not ctx.get("lunch_trade_allowed", True): logger.warning("midday_context: 점심 세션 진입 중단 설정") self._midday_pos_mult = float(ctx.get("position_size_multiplier", 1.0)) # 섹터·블랙리스트 업데이트 if "hot_sectors" in ctx: self.strategy.context["hot_sectors"] = ctx["hot_sectors"] if "avoid_sectors" in ctx: self.strategy.context["avoid_sectors"] = ctx["avoid_sectors"] for t in ctx.get("blacklist_tickers", []): bl = self.strategy.context.setdefault("blacklist_tickers", []) if t not in bl: bl.append(t) # lunch_trade_allowed=false이면 진입 자체를 막는 플래그 저장 self.strategy.context["lunch_trade_allowed"] = ctx.get("lunch_trade_allowed", True) self._midday_ctx_mtime = mtime self._midday_loaded = True logger.info( f"midday_context 로드 완료 — 점심 세션 시작 " f"(포지션 배율: ×{self._midday_pos_mult}, " f"진입허용: {ctx.get('lunch_trade_allowed', True)})" ) except Exception as e: logger.warning(f"midday_context 로드 실패: {e}") def _today_entry_count(self) -> int: today = datetime.now().strftime("%Y-%m-%d") with get_conn() as conn: row = conn.execute( "SELECT COUNT(DISTINCT ticker || '|' || entry_time) FROM trades WHERE date=? AND side='BUY'", (today,), ).fetchone() return int(row[0] or 0) def _recent_stop_loss_count(self, minutes: int = 60) -> int: cutoff = (datetime.now() - timedelta(minutes=minutes)).strftime("%H:%M:%S") today = datetime.now().strftime("%Y-%m-%d") with get_conn() as conn: row = conn.execute(""" SELECT COUNT(*) FROM trades WHERE date=? AND exit_reason='SL' AND exit_time >= ? """, (today, cutoff)).fetchone() return int(row[0] or 0) def _entry_gate_reason(self, now_str: str) -> str: for start, end in ENTRY_PAUSE_WINDOWS: if start <= now_str < end: return f"entry pause window {start}-{end}" entries = self._today_entry_count() if ENTRY_LIMIT_ENFORCE and entries >= MAX_DAILY_ENTRIES: return f"daily entry limit reached {entries}/{MAX_DAILY_ENTRIES}" cascade_stop_losses = self._recent_stop_loss_count(SL_CASCADE_WINDOW_MIN) if cascade_stop_losses >= SL_CASCADE_HALT_THRESHOLD: return ( f"cascade halt: {cascade_stop_losses} stop losses in last " f"{SL_CASCADE_WINDOW_MIN} minutes" ) stop_losses = self._recent_stop_loss_count(60) if ENTRY_LIMIT_ENFORCE and stop_losses >= MAX_HOURLY_STOP_LOSS: return f"{stop_losses} stop losses in last 60 minutes" return "" def _entry_warning_reason(self) -> str: warnings = [] entries = self._today_entry_count() if entries >= MAX_DAILY_ENTRIES: warnings.append(f"daily entries high {entries}/{MAX_DAILY_ENTRIES}") stop_losses = self._recent_stop_loss_count(60) if stop_losses >= MAX_HOURLY_STOP_LOSS: warnings.append(f"{stop_losses} stop losses in last 60 minutes") return "; ".join(warnings) def _log_entry_acceptance( self, ticker: str, name: str, current: float, target: float, qty: int, multiplier: float, reason: str, ): logger.info( "ENTRY accepted %s(%s) current=%s target=%.0f qty=%s mult=%.2f reason=%s", name, ticker, current, target, qty, multiplier, reason, ) def _save_entry_snapshot( self, trade_id: int | None, ticker: str, name: str, price_info: dict, target: float, entry_price: float, stop_price: float, qty: int, signal: dict, combined_mult: float, model_scores: dict | None = None, ): ctx = self.strategy.context prev = self.strategy.prev_data.get(ticker, {}) now = datetime.now() model_scores = model_scores or {} with get_conn() as conn: conn.execute(""" INSERT INTO entry_snapshots (trade_id, date, ticker, name, entry_time, current_price, entry_price, target_price, stop_price, today_open, prev_high, prev_low, prev_amount, volume, change_pct, market_sentiment, sentiment_score, risk_level, trade_allowed, hot_sectors, avoid_sectors, boosted_tickers, blacklist_tickers, ai_boosted, ai_win_score, ai_stop_loss_score, ai_model_version, position_size_multiplier, combined_multiplier, entry_reason, strategy, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( trade_id, now.strftime("%Y-%m-%d"), ticker, name, now.strftime("%H:%M:%S"), price_info.get("current"), entry_price, target, stop_price, self.strategy.today_open.get(ticker), prev.get("high"), prev.get("low"), prev.get("amount"), price_info.get("volume"), price_info.get("change_pct"), ctx.get("market_sentiment"), ctx.get("sentiment_score"), ctx.get("risk_level"), 1 if ctx.get("trade_allowed", True) else 0, json.dumps(ctx.get("hot_sectors", []), ensure_ascii=False), json.dumps(ctx.get("avoid_sectors", []), ensure_ascii=False), json.dumps(ctx.get("boosted_tickers", []), ensure_ascii=False), json.dumps(ctx.get("blacklist_tickers", []), ensure_ascii=False), 1 if signal.get("boosted") else 0, model_scores.get("label_win"), model_scores.get("label_stop_loss"), model_scores.get("model_version"), signal.get("multiplier", 1.0), combined_mult, signal.get("reason", ""), "VB", now.isoformat(timespec="seconds"), )) def _build_entry_feature_row( self, ticker: str, price_info: dict, target: float, entry_price: float, stop_price: float, signal: dict, combined_mult: float, ) -> dict: ctx = self.strategy.context prev = self.strategy.prev_data.get(ticker, {}) return { "current_price": price_info.get("current"), "entry_price": entry_price, "target_price": target, "stop_price": stop_price, "today_open": self.strategy.today_open.get(ticker), "prev_high": prev.get("high"), "prev_low": prev.get("low"), "prev_amount": prev.get("amount"), "volume": price_info.get("volume"), "change_pct": price_info.get("change_pct"), "sentiment_score": ctx.get("sentiment_score"), "trade_allowed": 1 if ctx.get("trade_allowed", True) else 0, "ai_boosted": 1 if signal.get("boosted") else 0, "position_size_multiplier": signal.get("multiplier", 1.0), "combined_multiplier": combined_mult, } def _score_entry_candidate( self, ticker: str, name: str, price_info: dict, target: float, entry_price: float, stop_price: float, signal: dict, combined_mult: float, ) -> dict: if not self.scalping_model.available: return {} try: row = self._build_entry_feature_row( ticker=ticker, price_info=price_info, target=target, entry_price=entry_price, stop_price=stop_price, signal=signal, combined_mult=combined_mult, ) scores = self.scalping_model.score(row) if scores: scores["model_version"] = self.scalping_model.version logger.info( "AI_SCORE %s(%s) win=%.3f stop_loss=%.3f", name, ticker, scores.get("label_win", -1), scores.get("label_stop_loss", -1), ) return scores except Exception as e: logger.warning("AI score failed %s(%s): %s", name, ticker, e) return {} def _save_post_entry_snapshot( self, trade_id: int | None, ticker: str, elapsed_sec: int, entry_price: float, price_info: dict, mfe_pct: float, mae_pct: float, position_open: bool, ): if trade_id is None: return current = price_info.get("current") if not entry_price or current is None: return now = datetime.now() return_pct = (current - entry_price) / entry_price * 100 with get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO post_entry_snapshots (trade_id, date, ticker, sample_time, elapsed_sec, entry_price, current_price, return_pct, mfe_pct, mae_pct, volume, change_pct, position_open, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( trade_id, now.strftime("%Y-%m-%d"), ticker, now.strftime("%H:%M:%S"), elapsed_sec, entry_price, current, return_pct, mfe_pct, mae_pct, price_info.get("volume"), price_info.get("change_pct"), 1 if position_open else 0, now.isoformat(timespec="seconds"), )) async def _track_post_entry( self, trade_id: int | None, ticker: str, name: str, entry_price: float, ): if trade_id is None: return checkpoints = (60, 180, 300, 600) start_ts = datetime.now().timestamp() high = entry_price low = entry_price for elapsed_sec in checkpoints: delay = max(0, start_ts + elapsed_sec - datetime.now().timestamp()) await asyncio.sleep(delay) try: price_info = await self.kis.get_price(ticker) current = price_info["current"] high = max(high, current) low = min(low, current) mfe_pct = (high - entry_price) / entry_price * 100 mae_pct = (low - entry_price) / entry_price * 100 self._save_post_entry_snapshot( trade_id=trade_id, ticker=ticker, elapsed_sec=elapsed_sec, entry_price=entry_price, price_info=price_info, mfe_pct=mfe_pct, mae_pct=mae_pct, position_open=ticker in self.positions, ) logger.info( "POST_ENTRY %s(%s) t=%ss current=%s mfe=%.2f%% mae=%.2f%% open=%s", name, ticker, elapsed_sec, current, mfe_pct, mae_pct, ticker in self.positions, ) except Exception as e: logger.warning("post-entry snapshot failed %s t=%ss: %s", ticker, elapsed_sec, e) # ───────────────────────────────────────── # 초기화 # ───────────────────────────────────────── async def initialize(self): """시스템 초기화""" init_db() await self.kis.get_access_token() # 잔고 조회 → RiskManager 초기화 balance = await self.kis.get_balance() cash = balance["cash"] self.risk = RiskManager(init_cash=cash) logger.info(f"초기 예수금: {cash:,}원") # DB에서 열린 포지션 복원 (재시작 시) self._restore_positions_from_db() # 당일 SL 종목 복원 (재시작 후에도 재진입 차단 유지) self._restore_sl_tickers_from_db() self._restore_reentry_controls_from_db() await send(f"[시작] 단타봇 가동 | 예수금: {cash:,}원 | " f"{'모의투자' if self.kis.is_mock else '실거래'}") def _restore_positions_from_db(self): """재시작 시 DB positions 테이블에서 인메모리 복원""" with get_conn() as conn: rows = conn.execute("SELECT * FROM positions").fetchall() for r in rows: ticker, name, entry_time, entry_price, qty, tp1_done, target_price, stop_price, ai_boosted = r self.positions[ticker] = { "name" : name, "entry" : entry_price, "qty" : qty, "tp1_done" : bool(tp1_done), "entry_time": datetime.strptime(entry_time, "%H:%M:%S").replace( year=datetime.now().year, month=datetime.now().month, day=datetime.now().day), "sl_price" : stop_price, "boosted" : bool(ai_boosted), } if self.positions: logger.info(f"DB 포지션 복원: {list(self.positions.keys())}") def _restore_sl_tickers_from_db(self): """재시작 시 당일 SL 종목 복원 — 재진입 차단 유지""" today = datetime.now().strftime("%Y-%m-%d") with get_conn() as conn: rows = conn.execute( "SELECT DISTINCT ticker FROM trades WHERE date=? AND exit_reason='SL'", (today,) ).fetchall() for (ticker,) in rows: self.sl_tickers.add(ticker) if self.sl_tickers: logger.info(f"당일 SL 종목 복원(재진입 차단): {self.sl_tickers}") def _restore_reentry_controls_from_db(self): """재시작 시 오늘 청산 이력 기반 재진입 제한 상태를 복원한다.""" today = datetime.now().strftime("%Y-%m-%d") with get_conn() as conn: rows = conn.execute(""" SELECT ticker, exit_time, exit_reason FROM trades WHERE date=? AND exit_time IS NOT NULL AND exit_reason IN ('TIME', 'FORCE', 'TP1', 'TP2') ORDER BY exit_time """, (today,)).fetchall() restored = [] for ticker, exit_time, reason in rows: if ticker in self.positions: continue try: exit_dt = datetime.strptime(exit_time, "%H:%M:%S").replace( year=datetime.now().year, month=datetime.now().month, day=datetime.now().day, ) except (TypeError, ValueError): continue self.strategy.mark_final_exit(ticker, reason, exit_dt) restored.append(f"{ticker}:{reason}") if restored: logger.info("재진입 제한 상태 복원: %s", ", ".join(restored)) def _db_save_position(self, ticker: str, pos: dict, target_price: float): with get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO positions (ticker, name, entry_time, entry_price, quantity, tp1_done, target_price, stop_price, ai_boosted) VALUES (?,?,?,?,?,?,?,?,?) """, ( ticker, pos["name"], pos["entry_time"].strftime("%H:%M:%S"), pos["entry"], pos["qty"], 1 if pos.get("tp1_done") else 0, target_price, pos["sl_price"], 1 if pos.get("boosted") else 0, )) def _db_delete_position(self, ticker: str): with get_conn() as conn: conn.execute("DELETE FROM positions WHERE ticker=?", (ticker,)) def _db_has_open_position(self, ticker: str) -> bool: with get_conn() as conn: row = conn.execute( "SELECT 1 FROM positions WHERE ticker=?", (ticker,), ).fetchone() return row is not None def _db_reserve_position( self, ticker: str, name: str, entry_price: float, qty: int, target_price: float, stop_price: float, ai_boosted: bool, ) -> bool: """Atomically reserve a ticker before sending a buy order.""" with get_conn() as conn: cur = conn.execute(""" INSERT OR IGNORE INTO positions (ticker, name, entry_time, entry_price, quantity, tp1_done, target_price, stop_price, ai_boosted) VALUES (?,?,?,?,?,?,?,?,?) """, ( ticker, name, datetime.now().strftime("%H:%M:%S"), entry_price, qty, 0, target_price, stop_price, 1 if ai_boosted else 0, )) return cur.rowcount == 1 @staticmethod def _is_rate_limit_error(err) -> bool: msg = str(err) return ( "초당" in msg or "거래건수" in msg or "rate limit" in msg.lower() or "too many" in msg.lower() ) @staticmethod def _is_retryable_price_error(err) -> bool: msg = str(err).lower() return StockBot._is_rate_limit_error(err) or "타임아웃" in msg or "timeout" in msg async def _get_price_with_retry(self, ticker: str, purpose: str, attempts: int = 4): delays = (1.3, 2.8, 5.0, 8.0) for attempt in range(1, attempts + 1): try: return await self.kis.get_price(ticker) except Exception as e: if attempt >= attempts or not self._is_retryable_price_error(e): raise wait = delays[min(attempt - 1, len(delays) - 1)] logger.warning( "%s price retry %s/%s for %s after transient KIS error: %s", purpose, attempt, attempts, ticker, e, ) await asyncio.sleep(wait) async def _sell_with_retry( self, ticker: str, name: str, qty: int, reason: str, fill_price: float | None = None, attempts: int = 3, ) -> dict: delays = (1.2, 2.5, 5.0) for attempt in range(1, attempts + 1): result = await self.executor.sell(ticker, name, qty, reason, fill_price=fill_price) if result.get("success"): return result error = result.get("error", "") if attempt >= attempts or not self._is_rate_limit_error(error): return result wait = delays[min(attempt - 1, len(delays) - 1)] logger.warning( "SELL retry %s/%s for %s after rate limit: %s", attempt, attempts, ticker, error, ) await asyncio.sleep(wait) # ───────────────────────────────────────── # 유니버스 갱신 (08:30) # ───────────────────────────────────────── # ETF/ETN/인버스/레버리지 종목 필터 _ETF_KEYWORDS = ('인버스', '레버리지', '선물', 'KODEX', 'TIGER', 'KBSTAR', 'HANARO', 'ARIRANG', 'KOSEF', 'SOL', 'ACE', 'RISE', 'PLUS') _SECTOR_FIELDS = ( "sector", "sector_name", "bstp_kor_isnm", "bstp_cls_name", "bstp_name", ) _AVOID_SECTOR_NAME_HINTS = ( ("\uac74\uc124", ("\uac74\uc124", "\ub300\uc6b0\uac74\uc124", "\ud604\ub300\uac74\uc124", "GS\uac74\uc124", "DL\uc774\uc564\uc528", "HDC\ud604\ub300\uc0b0\uc5c5\uac1c\ubc1c")), ("\uc804\uae30\uac00\uc2a4", ("\ud55c\uad6d\uc804\ub825", "\ud55c\uc804", "\ud55c\uad6d\uac00\uc2a4", "\uc9c0\uc5ed\ub09c\ubc29", "\uc804\uae30", "\uac00\uc2a4")), ("\uc8fc\ub958", ("\ud558\uc774\ud2b8\uc9c4\ub85c", "\ub86f\ub370\uce60\uc131", "\ubb34\ud559", "\ubcf4\ud574\uc591\uc870", "\uad6d\uc21c\ub2f9", "\uc8fc\ub958")), ) @staticmethod def _is_etf(ticker: str, name: str) -> bool: if ticker.startswith('Q') or len(ticker) != 6: # ETN or 비정상 코드 return True return any(kw in name for kw in StockBot._ETF_KEYWORDS) @classmethod def _sector_from_rank_row(cls, row: dict) -> str: for field in cls._SECTOR_FIELDS: value = row.get(field) if value: return str(value).strip() return "" def _infer_avoid_sector_from_name(self, name: str) -> str: compact_name = (name or "").replace(" ", "") if not compact_name: return "" for sector in self.strategy.context.get("avoid_sectors", []): sector_name = str(sector or "").strip() if not sector_name: continue compact_sector = sector_name.replace(" ", "") for key, hints in self._AVOID_SECTOR_NAME_HINTS: if key in compact_sector and any(hint in compact_name for hint in hints): return sector_name generic_token = compact_sector.replace("\uc5c5", "") if len(generic_token) >= 2 and generic_token in compact_name: return sector_name return "" async def update_universe(self): """종목 풀 갱신 + 전일 데이터 수집""" logger.info("유니버스 갱신 시작") try: # ETF 필터 후 MAX_UNIVERSE 확보 위해 여유분 요청 rank = await self.kis.get_volume_rank(top_n=MAX_UNIVERSE + 20) # 종목명 캐시 갱신 + ETF 필터 for r in rank: self.ticker_names[r["ticker"]] = r["name"] sector = self._sector_from_rank_row(r) if sector: self.ticker_sectors[r["ticker"]] = sector tickers = [r["ticker"] for r in rank if not self._is_etf(r["ticker"], r["name"])] ctx = self.strategy.context blacklist = ctx.get("blacklist_tickers", []) tickers = [t for t in tickers if t not in blacklist] boosted = ctx.get("boosted_tickers", []) tickers = ( [t for t in boosted if t in tickers] + [t for t in tickers if t not in boosted] )[:MAX_UNIVERSE] self.universe = tickers logger.info(f"유니버스: {len(tickers)}종목 (ETF 제외)") # 최근 7일 범위 조회 → 공휴일·대체공휴일 자동 처리 from datetime import timedelta today = datetime.now() start_dt = (today - timedelta(days=7)).strftime("%Y%m%d") end_dt = (today - timedelta(days=1)).strftime("%Y%m%d") for ticker in self.universe: # 이미 전일 데이터 있으면 skip if self.strategy.has_prev_data(ticker): continue try: ohlcv = await self.kis.get_ohlcv_daily( ticker, start=start_dt, end=end_dt, ) if ohlcv: prev = ohlcv[-1] # 가장 최근 거래일 self.strategy.set_prev_data( ticker, high = prev["high"], low = prev["low"], amount= prev.get("amount", prev.get("volume", 0) * prev.get("close", 0)) ) else: logger.warning(f"전일 OHLCV 없음 {ticker} ({start_dt}~{end_dt})") except Exception as e: logger.warning(f"전일 데이터 실패 {ticker}: {e}") await asyncio.sleep(1.1) # 초당 2.5건으로 제한 except Exception as e: logger.error(f"유니버스 갱신 실패: {e}") # ───────────────────────────────────────── # 시가 수집 + 목표가 계산 (08:50) # ───────────────────────────────────────── async def calc_targets(self): """당일 시가 기반 목표가 계산""" logger.info("목표가 계산 시작") self.strategy.targets.clear() self.strategy.today_open.clear() now_str = datetime.now().strftime("%H:%M") valid_count = 0 for ticker in self.universe: try: price_info = await self._get_price_with_retry(ticker, "TARGET") open_price = price_info.get("open") or 0 name = self.ticker_names.get(ticker, ticker) if open_price <= 0: current = price_info.get("current") or 0 if now_str >= "09:00" and current > 0: open_price = current logger.warning(f"시가 0 감지({name}/{ticker}) → 현재가 {current:,}를 임시 시가로 사용") else: logger.info(f"목표가 제외({name}/{ticker}): 시가 미확정(open=0)") await asyncio.sleep(1.1) continue self.strategy.set_today_open(ticker, open_price) target = self.strategy.get_target(ticker) if target > 0: logger.info(f"목표가: {name}({ticker}) {target:,.0f}원 [시가 {open_price:,}]") valid_count += 1 await asyncio.sleep(1.1) except Exception as e: logger.warning(f"시가 수집 실패 {ticker}: {e}") logger.info(f"목표가 계산 완료: {valid_count}/{len(self.universe)}종목 유효") # ───────────────────────────────────────── # 메인 매매 루프 (09:00~14:50) # ───────────────────────────────────────── async def trading_loop(self): """1초 단위 메인 루프""" logger.info("매매 루프 시작") self.running = True _consecutive_errors = 0 while self.running: try: now = datetime.now() now_str = now.strftime("%H:%M") # 14:50 강제 청산 if now_str >= FORCE_EXIT: await self.force_exit_all() self.running = False break # 14:00 이후 신규 진입 중단 (청산은 계속) if now_str > "14:00": await self.check_exits() await asyncio.sleep(1) continue # 09:00 이전 대기 if now_str < "09:00": await asyncio.sleep(1) continue # midday_context.json 갱신 감지 (점심 세션 이벤트 기반 시작) self._check_midday_context() # 리스크 체크 (L2/L4/L5 하드 중단) if not self.risk.can_trade(): await asyncio.sleep(5) continue # 보유 포지션 청산 체크 await self.check_exits() # 신규 진입 체크 if self.risk.can_add_position(len(self.positions)): await self.check_entries() _consecutive_errors = 0 await asyncio.sleep(1) except asyncio.CancelledError: raise except Exception as e: _consecutive_errors += 1 logger.error( f"매매 루프 오류 (연속 {_consecutive_errors}회): " f"{type(e).__name__}: {e}", exc_info=True, ) await notify_error(f"매매 루프 오류 {_consecutive_errors}회: {type(e).__name__}: {e}") # 연속 10회 오류 시 루프 종료 (무한 오류 방지) if _consecutive_errors >= 10: logger.critical("연속 오류 10회 — 매매 루프 강제 종료") await notify_error("연속 오류 10회 — 매매 루프 강제 종료") self.running = False break await asyncio.sleep(5) # ───────────────────────────────────────── # 진입 체크 # ───────────────────────────────────────── async def check_entries(self): """유니버스 전체 진입 신호 확인""" now_str = datetime.now().strftime("%H:%M") # 14:00 이후 진입 차단 if now_str > "14:00": return # midday_context 로드 전(11:20~) 11:00 이후 신규 진입 일시 중단 # — midday_context.json이 생성되면 _check_midday_context()가 자동 해제 if now_str >= "11:00" and not self._midday_loaded: return # lunch_trade_allowed=false이면 점심 세션 진입 차단 if self._midday_loaded and not self.strategy.context.get("lunch_trade_allowed", True): return gate_reason = self._entry_gate_reason(now_str) if gate_reason: logger.info("ENTRY blocked: %s", gate_reason) return warning_reason = self._entry_warning_reason() if warning_reason: logger.warning("ENTRY warning: %s", warning_reason) _now_ts = datetime.now().timestamp() _do_diag = (_now_ts - self._last_diag) >= 300 # 5분마다 진단 로그 _diag = [] for ticker in self.universe: if ticker in self.positions: if _do_diag: _diag.append(f"{ticker}:보유중") continue if self._db_has_open_position(ticker): if _do_diag: _diag.append(f"{ticker}:DB보유중") continue if ticker in self.sl_tickers: if _do_diag: _diag.append(f"{ticker}:SL차단") continue # 당일 SL 종목 재진입 차단 if len(self.positions) >= MAX_POSITIONS: break # 목표가 미계산 종목 스킵 (불필요한 API 호출 방지) target = self.strategy.get_target(ticker) if target <= 0: if _do_diag: _diag.append(f"{ticker}:목표가없음") continue try: reserved = False price_info = await self._get_price_with_retry(ticker, "ENTRY") current = price_info["current"] name = self.ticker_names.get(ticker, ticker) sector = ( self.ticker_sectors.get(ticker, "") or self._infer_avoid_sector_from_name(name) ) signal = self.strategy.check_entry( ticker=ticker, name=name, current_price=current, sector=sector, ) if not signal["signal"]: if _do_diag: _diag.append( f"{name}({ticker}):{signal['reason']}" f"[현재가{current:,}/목표가{target:,.0f}]" ) continue balance = await self.kis.get_balance() cash = balance["cash"] # AI 신호 배율 × B안(연속 손절) 배율 × midday 배율 combined_mult = ( signal.get("multiplier", 1.0) * self.risk.get_consec_multiplier() * self._midday_pos_mult ) invest = self.risk.get_pos_size(cash, combined_mult) qty = max(1, int(invest // current)) reserve_stop = current * (1 - self.risk.get_sl_pct()) if not self._db_reserve_position( ticker=ticker, name=name, entry_price=current, qty=qty, target_price=target, stop_price=reserve_stop, ai_boosted=signal.get("boosted", False), ): logger.info("ENTRY blocked: DB active position exists for %s", ticker) continue reserved = True self._log_entry_acceptance( ticker=ticker, name=name, current=current, target=target, qty=qty, multiplier=combined_mult, reason=signal["reason"], ) result = await self.executor.buy( ticker=ticker, name=name, qty=qty, reason=signal["reason"], ai_boosted=signal.get("boosted", False), fill_price=current, ) if result["success"]: reserved = False entry_price = result["price"] or current sl_price = entry_price * (1 - self.risk.get_sl_pct()) model_scores = self._score_entry_candidate( ticker=ticker, name=name, price_info=price_info, target=target, entry_price=entry_price, stop_price=sl_price, signal=signal, combined_mult=combined_mult, ) self._save_entry_snapshot( trade_id=result.get("trade_id"), ticker=ticker, name=name, price_info=price_info, target=target, entry_price=entry_price, stop_price=sl_price, qty=qty, signal=signal, combined_mult=combined_mult, model_scores=model_scores, ) asyncio.create_task( self._track_post_entry( trade_id=result.get("trade_id"), ticker=ticker, name=name, entry_price=entry_price, ) ) pos = { "name" : name, "entry" : entry_price, "qty" : qty, "tp1_done" : False, "entry_time": datetime.now(), "sl_price" : sl_price, "boosted" : signal.get("boosted", False), } self.positions[ticker] = pos self._db_save_position( ticker, pos, target_price=self.strategy.get_target(ticker), ) await notify_buy( ticker=ticker, name=name, price=entry_price, target=int(entry_price * 1.03), stop=int(sl_price), boosted=signal.get("boosted", False), ) else: if reserved: self._db_delete_position(ticker) reserved = False except Exception as e: if "reserved" in locals() and reserved: self._db_delete_position(ticker) logger.error(f"진입 체크 오류 {ticker}: {type(e).__name__}: {e}") if _do_diag: self._last_diag = _now_ts if _diag: logger.info(f"[신호진단] {' | '.join(_diag)}") else: logger.info("[신호진단] 전 종목 신호 없음 (유니버스 비어있거나 모두 필터됨)") # ───────────────────────────────────────── # 청산 체크 # ───────────────────────────────────────── async def check_exits(self): """보유 포지션 청산 신호 확인""" for ticker, pos in list(self.positions.items()): try: price_info = await self._get_price_with_retry(ticker, "EXIT") current = price_info["current"] name = pos["name"] # 시간 청산: MAX_HOLD_MIN 초과 hold_min = (datetime.now() - pos["entry_time"]).seconds / 60 if hold_min >= MAX_HOLD_MIN: await self._do_exit(ticker, pos, current, qty=pos["qty"], reason="TIME") continue # 전략 청산 신호 signal = self.strategy.check_exit( ticker=ticker, entry_price=pos["entry"], current_price=current, qty=pos["qty"], tp1_done=pos["tp1_done"], sl_pct=self.risk.get_sl_pct(), ) if signal["signal"]: await self._do_exit( ticker, pos, current, qty=signal["qty"], reason=signal["reason"], ) await asyncio.sleep(1.1) except Exception as e: logger.error(f"청산 체크 오류 {ticker}: {type(e).__name__}: {e}") await asyncio.sleep(5) async def _do_exit(self, ticker: str, pos: dict, current: float, qty: int, reason: str): """실제 청산 실행""" name = pos["name"] result = await self._sell_with_retry(ticker, name, qty, reason, fill_price=current) if not result["success"]: return exit_price = result["price"] or current pnl = (exit_price - pos["entry"]) * qty pnl_pct = (exit_price - pos["entry"]) / pos["entry"] * 100 self.risk.record_trade(pnl) # B안: 연속 손절 2회·3회 도달 시 Discord 알림 if reason == "SL": consec = self.risk.consec_loss if consec in (2, 3): mult = self.risk.get_consec_multiplier() await notify_risk( "L3-B", f"{consec}연속 손절 — 포지션 크기 {int(mult * 100)}%로 축소" ) if reason == "TP1": pos["tp1_done"] = True pos["qty"] -= qty if pos["qty"] <= 0: del self.positions[ticker] self._db_delete_position(ticker) self.strategy.mark_final_exit(ticker, reason) else: self._db_save_position(ticker, pos, self.strategy.get_target(ticker)) await notify_tp1(ticker, name, pnl_pct) elif reason in ("TP2", "SL", "TIME", "FORCE"): del self.positions[ticker] self._db_delete_position(ticker) self.strategy.mark_final_exit(ticker, reason) if reason == "TP2": await notify_tp2(ticker, name, pnl_pct) elif reason == "SL": self.sl_tickers.add(ticker) # 당일 재진입 차단 await notify_sl(ticker, name, pnl_pct) # L2/L3 체크 후 디스코드 경고 if not self.risk.can_trade(): await notify_risk( self.risk.stop_reason.split(":")[0], self.risk.stop_reason ) # ───────────────────────────────────────── # 강제 청산 (14:50) # ───────────────────────────────────────── async def force_exit_all(self): """14:50 전량 강제 청산""" logger.info("14:50 강제 청산 시작") for ticker, pos in list(self.positions.items()): try: price_info = await self._get_price_with_retry(ticker, "FORCE_EXIT") current = price_info["current"] await self._do_exit( ticker, pos, current, qty=pos["qty"], reason="FORCE" ) except Exception as e: logger.error(f"강제 청산 실패 {ticker}: {e}") await notify_force_exit() logger.info("강제 청산 완료") # ───────────────────────────────────────── # 일일 결산 (15:10) # ───────────────────────────────────────── async def daily_summary(self): """당일 결산 로그 및 디스코드 알림 + DB 저장""" today = datetime.now().strftime("%Y-%m-%d") if today in self._daily_summary_dates: logger.info("결산 이미 처리됨: %s", today) return with get_conn() as conn: rows = conn.execute(""" SELECT pnl, fee, exit_reason FROM trades WHERE date=? AND exit_time IS NOT NULL """, (today,)).fetchall() pnls = [r[0] for r in rows if r[0] is not None] fees = [r[1] for r in rows if r[1] is not None] total = len(pnls) wins = sum(1 for p in pnls if p > 0) losses = total - wins gross_pnl = sum(p for p in pnls if p > 0) - abs(sum(p for p in pnls if p < 0)) total_fee = sum(fees) net = sum(pnls) mdd = min(self.risk.daily_pnl / self.risk.init_cash * 100, 0.0) stopped = 0 if self.risk.can_trade() else 1 exit_counts = {} for _, _, reason in rows: key = reason or "UNKNOWN" exit_counts[key] = exit_counts.get(key, 0) + 1 # daily_summary 테이블 저장 with get_conn() as conn: conn.execute(""" INSERT OR REPLACE INTO daily_summary (date, total_trades, win_trades, lose_trades, gross_pnl, total_fee, net_pnl, max_drawdown, trading_stopped) VALUES (?,?,?,?,?,?,?,?,?) """, (today, total, wins, losses, gross_pnl, total_fee, net, mdd, stopped)) self._daily_summary_dates.add(today) try: await notify_daily_summary(total, wins, losses, net) except Exception as e: logger.error("결산 Discord 요약 전송 실패: %s", e) if exit_counts: dist = " / ".join(f"{k}:{v}" for k, v in sorted(exit_counts.items())) logger.info("Exit distribution: %s", dist) try: await send(f"[청산분포] {dist}") except Exception as e: logger.error("청산분포 Discord 전송 실패: %s", e) self.risk.reset_daily() logger.info(f"결산: {total}회 / 승{wins} 패{losses} / {net:+,.0f}원 (fee {total_fee:,.0f}원)") # ───────────────────────────────────────── # 스케줄러 # ───────────────────────────────────────── async def run(): bot = StockBot() await bot.initialize() now = datetime.now().strftime("%H:%M") if "09:00" <= now < "15:00": logger.info("장 중 재시작 감지 → AI 컨텍스트 로드 + 유니버스/목표가 즉시 계산") ctx = bot.strategy.load_ai_context() await notify_ai_result( ctx["market_sentiment"], ctx["sentiment_score"], ctx.get("hot_sectors", []), ctx.get("avoid_sectors", []), ctx.get("reason", ""), ) bot.risk.set_risk_level(ctx.get("risk_level", "보통")) await bot.update_universe() await bot.calc_targets() await bot.trading_loop() # 바로 매매루프 진입 # 매매루프 종료 후 15:10 결산까지 대기 while True: now = datetime.now().strftime("%H:%M") if now == "15:10": await bot.daily_summary() return if now > "15:10": return await asyncio.sleep(30) # 08:30 이후~09:00 이전 시작 시 컨텍스트·유니버스 즉시 로드 if "08:30" <= now < "09:00": logger.info("장 전 재시작 감지(08:30~09:00) → AI 컨텍스트 로드 + 유니버스 즉시 갱신") ctx = bot.strategy.load_ai_context() bot.risk.set_risk_level(ctx.get("risk_level", "보통")) await bot.update_universe() if now >= "08:50": logger.info("08:50 이후 장 전 재시작 감지 → 목표가 즉시 계산") await bot.calc_targets() while True: now = datetime.now().strftime("%H:%M") try: # 08:30 AI 컨텍스트 로드 + 유니버스 갱신 # (claude_morning이 08:15에 시작해 08:30 전에 daily_context.json 생성) if now == "08:30": ctx = bot.strategy.load_ai_context() await notify_ai_result( ctx["market_sentiment"], ctx["sentiment_score"], ctx.get("hot_sectors", []), ctx.get("avoid_sectors", []), ctx.get("reason", ""), ) bot.risk.set_risk_level(ctx.get("risk_level", "보통")) await bot.update_universe() # 08:50 목표가 계산 elif now == "08:50": await bot.calc_targets() # 09:00 매매 루프 시작 elif now == "09:00": await bot.calc_targets() await bot.trading_loop() # 15:10 결산 elif now == "15:10": await bot.daily_summary() except Exception as e: logger.error(f"스케줄러 루프 오류 ({now}): {e}", exc_info=True) await asyncio.sleep(30) if __name__ == "__main__": os.makedirs("logs", exist_ok=True) os.makedirs("data", exist_ok=True) instance_lock = SingleInstanceLock("logs/stockbot.lock") if not instance_lock.acquire(): logger.error("StockBot already running; duplicate process exiting") sys.exit(2) logger.info("=" * 50) logger.info("단타 자동매매 시스템 시작") logger.info(f"모드: {'모의투자' if os.getenv('KIS_MOCK','true')=='true' else '실거래'}") logger.info(f"DRY_RUN: {os.getenv('DRY_RUN','true')}") logger.info("=" * 50) asyncio.run(run())