← REPORT.RAYP.STORE 홈으로 | 작성일: 2026-09-21 KST | 체계: 2개 라인업 통합 마스터 리포트

투자의 두 가지 무기: 기계적 퀀트 스윙 & 중장기 가치투자 기준선 매매

감정을 완전히 배제하고 오로지 국내주식으로 기계적 트레이딩을 수행하는 [라인업 A: 퀀트 스윙]과,
프로그램 없이 사용자 관점에서 종목 불문 중장기 가치주를 투자할 때 언제 들어가고 빠져나올지 기준을 세우는 [라인업 B: 가치투자 기준선]의 통합 안내서입니다.

라인업 A · 기계적 자동매매

국내주식 전용 퀀트 스윙

성격: 100% 프로그램/기계적 자동매매.
대상: 오로지 한국거래소(KOSPI / KOSDAQ) 보통주.
핵심: 112일선 돌파 후 5일선 음봉 눌림목, -3.5% 칼손절, TP1 40%, TP2 30% 다단계 청산. 감정 없이 정해진 규칙대로만 기계적으로 회전하는 단기/스윙 시스템.

라인업 B · 사람의 판단

중장기 가치투자 기준선 매매

성격: 퀀트/프로그램 아님! 사람이 직접 가치를 보고 판단.
대상: 종목 불문 (미국 빅테크, 국내 초우량주, 비트코인 등 가치 있는 모든 자산).
핵심: "무식한 존버(단순보유)"의 -50% 폭락 함정을 피하고, 120일선 생명선을 기준으로 "어느 선에서 빠져나오고, 어느 타이밍에 다시 탈 것인가?"를 명확히 하는 중장기 가치투자자용 기준선 가이드.

⭐ [공통] 15세 눈높이 기초 용어 수업 — 주린이라면 이 부분부터!

💡 주식 시장을 이해하는 3가지 기본 원리

1. 이동평균선(이평선): 주가의 평균 성적표. 최근 가격에 점수를 더 많이 쳐주는 EMA(지수이평)와 똑같이 나누는 SMA(단순이평)가 있습니다.
2. 역배열: 큰형(2년선)이 맨 위에 있고 막내(반년선)가 바닥에 깔려 있는 상태. 즉 1~2년 동안 물려 있던 지하실 상태를 뜻합니다.
3. 단순보유(존버): 첫날 사서 아무것도 안 하고 눈감고 끝까지 들고 있는 방법입니다.

숫자로 보는 이평선 지도

  • 5일선: 1주일 사람들의 단기 심리선
  • 20일선: 1달 평균. 주가의 '생명선'
  • 60일선: 3달(분기) 중기 추세 기준선
  • 112일/120일선: 약 반 년(6개월) 평균선. 추세의 절대 기준선!
  • 224일선 / 448일선: 1년선 및 2년선 (거대한 천장 보스)

패턴과 캔들 용어

  • 양봉(빨강): 시가보다 종가가 오른 날
  • 음봉(파랑): 시가보다 종가가 내린 날 (숨고르기)
  • 눌림목: 높이뛰기 선수가 점프하기 전 무릎을 살짝 굽히는 순간
  • 이격도: 강아지와 주인의 산책 목줄 거리 (너무 멀면 지쳐서 돌아옴)
라인업 A 오로지 국내주식 (KOSPI / KOSDAQ 보통주) · 100% 프로그램 매매

1. 112일선 돌파 후 5일선 음봉 눌림목 스윙 셋업 (11대 조건)

감정을 완전히 배제하고, 수학적 조건식에 부합하는 종목만 서버 프로그램(TradingEngine)이 골라내어 D일 장마감 후 신호 확정 → D+1 장초 시가 진입하는 기계적 스윙 모델입니다.

번호 조건명 기준값 및 수식 목적 및 이유
1 유니버스 필터 KOSPI/KOSDAQ 보통주 한정 (관리/경고/거래정지/ETF/SPAC 제외) 부실기업 제외. 단, 대주가능 및 주식선물 기초자산은 허용하여 기관 수급주 포착
2 데이터 Warm-up 최소 600영업일 이상 (최소 448봉 필수) 장기 지수이평(EMA448) 수렴 오류 방지 (448봉 미만 신규상장주는 절대 매수 안 함)
3 유동성 필터 10일 평균 거래량 5만~9천만주 / 10일 평균 거래대금 5억~9천억원 호가 얇은 품절주 배제 및 슬리피지 최소화
4 장기 완전 역배열 EMA448 > EMA224 > EMA112 1~2년 동안 충분히 하락하여 바닥권을 다진 턴어라운드 종목만 선정
5 중단기 골든크로스 EMA20 > EMA60 중단기 추세가 위쪽으로 고개를 들기 시작했는지 확인
6 현재가 위치 Close > EMA112 가격이 반년선(112선) 위로 올라와 안착했는지 확인
7 최근 EMA112 실제 돌파 최근 7일 내 Close[t-1] ≤ EMA112 & Close[t] > EMA112 발생 수십 일 전에 이미 돌파한 오래된 종목 배제 (신선한 돌파 포착)
8 돌파 후 유지 돌파 이후 신호일까지 종가 기준 Close ≥ EMA112 유지 천장 위를 발판 삼아 밟고 서 있는 진짜 돌파만 인정 (재이탈 종목 탈락)
9 음봉 눌림목 캔들 Open > Close (음봉) 남들 흥분해서 달릴 때(추격매수) 안 사고, 무릎을 살짝 굽힐 때 탑승
10 SMA112 단순 이격도 100.0% ≤ (Close / SMA112) * 100 ≤ 108.0% 112일선 대비 너무 멀리 급등한 종목 추격 방지
11 EMA5 지수 이격도 99.0% ≤ (Close / EMA5) * 100 ≤ 102.0% 5일선에 바짝 붙어 있는 안전한 눌림목 구간 포착
라인업 A 리스크 관리 & 다단계 청산

2. 계좌 리스크 사이징 및 기계적 다단계 청산 룰

0.5% 계좌 리스크 포지션 사이징

한 번의 매매에서 계좌 전체가 감당할 최대 손실을 0.5% (risk_per_trade = 0.005)로 엄격히 고정합니다.

주식수 = floor( (계좌자산 * 0.005) / (진입가 * 0.035) )

※ 단일 종목 최대 비중 20% 한도와 가용 현금을 동시에 체크하여 안전한 정수 주수만 매수합니다.

청산 우선순위 상태머신

[장중 실시간 감시]

  1. -3.5% 절대 손절: 현재가 ≤ 진입가 * 0.965 즉시 전량 매도 (갭하락 시 시가 청산)
  2. TP1 익절: 현재가 ≥ EMA224 * 0.995 (최초수량 40% 시장가 매도)
  3. TP2 익절: 현재가 ≥ EMA448 * 0.995 (최초수량 30% 시장가 매도)

[EOD 확정봉 감시 (D+1 시가 체결)]

  1. EMA112/SMA112 하향 이탈 → 전량 청산
  2. EMA20 하향 이탈 → 전량 청산
  3. 수익률 ≥ 3% 상태에서 EMA5 하향 이탈 → 잔여 50% 트레일링 익절
  4. 8영업일 경과 및 MFE < 3% → Time Cut 전량 청산
라인업 A 실전 매매 검증

3. 최근 3개월(2026.06~09) 국내주식 실전 백테스트 매매일지

종목명 (코드) 진입일 / 진입가 청산 진행 및 체결 내역 실현 수익률 전략적 의의
SK이노베이션
(096770)
2026-08-13
122,500원 (시가)
• 8/13 (당일) 장중 123,700원: TP1 40% 익절 (+0.98%)
• 8/14 (익일) 장중 129,800원: TP2 30% 익절 (+5.96%)
• 8/26 시가 116,600원: 잔여 30% 갭하락 손절 (-4.82%)
+0.73% (가중)
순익 +0.42%
상승 구간에서 70%를 미리 분할익절해 두어, 8월 말 급락 파동 속에서도 계좌를 플러스로 방어함.
아난티
(025980)
2026-09-08
5,350원 (시가)
• 9/8 (당일) 장중 저가 5,090원 하락 → -3.5% 하드스탑 발동 (5,163원 전량 손절) -3.50%
계좌 대비 -0.017%
지지 실패 시 당일 즉시 칼손절하여, 이후 4,970원까지의 추가 급락 피해를 완벽히 차단함.
라인업 A 완결형 파이썬 소스코드

4. TradingEngine 파이썬 프로덕션 모듈 전체 코드 (1,274라인)

Python 3.11+ 규격, 단위테스트 5종 및 종합 시뮬레이션 내장. (서버 파일 위치: /root/trading_engine.py)

"""
TradingEngine: 112일선 돌파 후 5일선 음봉 눌림목 스윙 전략 (Swing Pullback Strategy)
================================================================================
KOSPI/KOSDAQ 자동매매 및 백테스팅 통합 프로덕션 퀀트 엔진
Python 3.11+ / PEP8 준수 / Dataclass, Type Hints, Logging 완비
"""

from __future__ import annotations

import abc
import math
import logging
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple, Any

import numpy as np
import pandas as pd

# =====================================================================
# 로깅 설정 (Section 30)
# =====================================================================
logger = logging.getLogger("TradingEngine")
if not logger.handlers:
    handler = logging.StreamHandler()
    formatter = logging.Formatter(
        "[%(asctime)s][%(levelname)s][%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
    )
    handler.setFormatter(formatter)
    logger.addHandler(handler)
    logger.setLevel(logging.INFO)


# =====================================================================
# 열거형(Enum) 정의 (Section 27, 28)
# =====================================================================
class OrderStatus(str, Enum):
    """주문 상태 머신 상태값"""
    NEW = "NEW"
    ORDER_PENDING = "ORDER_PENDING"
    PARTIALLY_FILLED = "PARTIALLY_FILLED"
    FILLED = "FILLED"
    CANCEL_PENDING = "CANCEL_PENDING"
    CANCELLED = "CANCELLED"
    CLOSED = "CLOSED"


class OrderSide(str, Enum):
    """매수 / 매도 구분"""
    BUY = "BUY"
    SELL = "SELL"


class OrderType(str, Enum):
    """지정가 / 시장가"""
    MARKET = "MARKET"
    LIMIT = "LIMIT"


class ExitReason(str, Enum):
    """청산 규칙 식별자"""
    HARD_STOP = "RULE_1A_HARD_STOP"
    GAP_DOWN_STOP = "RULE_1A_GAP_DOWN_STOP"
    EMA112_SMA112_STOP = "RULE_1B_EMA112_SMA112_COLLAPSE"
    TP1 = "RULE_2_TP1_EMA224"
    TP2 = "RULE_2_TP2_EMA448"
    EMA20_EXIT = "RULE_3_EMA20_MIDTERM_EXIT"
    EMA5_TRAILING = "RULE_3_EMA5_TRAILING"
    TIME_CUT = "RULE_4_TIME_CUT"
    MANUAL = "MANUAL_EXIT"


# =====================================================================
# 1. 설정 클래스 (Section 28 - StrategyConfig, BacktestConfig)
# =====================================================================
@dataclass
class StrategyConfig:
    """전략 파라미터 관리 클래스 (Section 28, 31)

    모든 핵심 매개변수는 불변이 아니며, 백테스트 민감도 분석 시 유연하게 변경 가능합니다.
    """
    warmup_days: int = 600
    min_data_bars: int = 448
    breakout_lookback: int = 7
    volume_min: float = 50_000.0
    volume_max: float = 90_000_000.0
    disable_volume_max: bool = False
    trading_value_min: float = 500_000_000.0
    trading_value_max: float = 900_000_000_000.0
    disable_trading_value_max: bool = False
    disparity112_min: float = 100.0
    disparity112_max: float = 108.0
    disparity5_min: float = 99.0
    disparity5_max: float = 102.0
    hard_stop_pct: float = 0.035  # -3.5%
    tp1_ratio: float = 0.40       # 최초수량의 40%
    tp2_ratio: float = 0.30       # 최초수량의 30%
    ema5_trailing_ratio: float = 0.50  # 현재잔여수량의 50%
    trailing_min_profit: float = 0.03   # +3.0%
    time_cut_days: int = 8              # 8영업일
    time_cut_mfe: float = 0.03          # +3.0%
    risk_per_trade: float = 0.005       # 0.5%
    dynamic_target: bool = True         # 동적 EMA 목표가 사용 여부
    max_position_pct: float = 0.20      # 단일 포지션 최대 비중 20%
    max_positions: int = 10             # 최대 동시 보유 포지션 수
    max_total_exposure_pct: float = 1.0 # 최대 주식 노출 비중 100%
    minimum_order_amount: float = 100_000.0  # 최소 주문 금액 (10만원)
    allow_shortable: bool = True        # 대주가능종목 허용 (Version A)
    allow_equity_futures_underlying: bool = True  # 주식선물기초자산 허용 (Version A)
    min_exit_shares: int = 1            # 분할매도 최소 체결 주수


@dataclass
class BacktestConfig:
    """백테스트 거래비용 및 환경 설정 (Section 25)"""
    initial_capital: float = 100_000_000.0
    buy_fee_rate: float = 0.00015       # 0.015%
    sell_fee_rate: float = 0.00015      # 0.015%
    tax_rate: float = 0.0018            # 0.18% (국내 증권거래세)
    slippage_rate: float = 0.0010       # 0.10% (체결 슬리피지)


# =====================================================================
# 2. 메타데이터 및 포지션 객체 (Section 1, 14, 28)
# =====================================================================
@dataclass
class StockMetadata:
    """종목 상태 및 속성 메타데이터 (Section 1)"""
    ticker: str
    name: str = ""
    market: str = "KOSPI"  # KOSPI / KOSDAQ
    is_ordinary: bool = True
    is_managed: bool = False          # 관리종목
    is_warning: bool = False          # 투자위험/경고/주의/단기과열
    is_suspended: bool = False        # 거래정지/정리매매
    is_unfaithful_disclosure: bool = False  # 불성실공시
    is_etf_etn: bool = False          # ETF/ETN
    is_reits: bool = False            # 리츠
    is_spac: bool = False             # SPAC
    is_investment_company: bool = False  # 투자회사/선박/인프라
    is_shortable: bool = True         # 대주가능 여부
    is_futures_underlying: bool = True  # 주식선물기초자산 여부
    dept: str = ""


@dataclass
class ScreenResult:
    """스크리닝 결과 설명 가능성 객체 (Section 28, 30)"""
    ticker: str
    eligible: bool
    reasons: List[str] = field(default_factory=list)
    indicators: Dict[str, float] = field(default_factory=dict)


@dataclass
class Position:
    """개별 보유 포지션 상태 추적 객체 (Section 14)"""
    ticker: str
    entry_price: float
    entry_date: str
    initial_shares: int
    current_shares: int
    highest_price: float
    holding_days: int = 0
    tp1_triggered: bool = False
    tp2_triggered: bool = False
    trail_5_triggered: bool = False
    realized_pnl: float = 0.0
    avg_exit_price: float = 0.0
    entry_ema112: float = 0.0
    entry_ema224: float = 0.0
    entry_ema448: float = 0.0
    last_evaluated_date: str = ""
    pending_order_id: Optional[str] = None
    status: OrderStatus = OrderStatus.FILLED


@dataclass
class ExitSignal:
    """청산 신호 객체 (Section 28)"""
    triggered: bool
    order_type: OrderType
    shares: int
    reason: ExitReason
    priority: int
    execution_price: float = 0.0


@dataclass
class OrderRecord:
    """체결 및 감사 로그 레코드 (Section 30)"""
    order_id: str
    ticker: str
    timestamp: str
    side: OrderSide
    shares: int
    price: float
    reason: str
    position_before: int
    position_after: int


# =====================================================================
# 3. StockScreener (Section 1~12, 28)
# =====================================================================
class StockScreener:
    """전략 유니버스 및 기술적 지표 스크리닝 엔진"""

    def __init__(self, config: StrategyConfig):
        self.config = config

    @staticmethod
    def calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
        """이동평균 및 이격도 산출 (Section 3)

        EMA: Series.ewm(span=N, adjust=False).mean()
        SMA: Series.rolling(window=N).mean()
        """
        df = df.copy()
        close = df["Close"]
        vol = df["Volume"]

        # 거래대금 컬럼 검증 및 근사치 처리 (Section 2)
        if "TradingValue" in df.columns:
            tv = df["TradingValue"]
        elif "Amount" in df.columns:
            tv = df["Amount"]
        else:
            logger.debug("TradingValue column missing: using Close * Volume approximation.")
            tv = close * vol
        df["TradingValue"] = tv

        # 6개 EMA 지표 계산
        df["EMA5"] = close.ewm(span=5, adjust=False).mean()
        df["EMA20"] = close.ewm(span=20, adjust=False).mean()
        df["EMA60"] = close.ewm(span=60, adjust=False).mean()
        df["EMA112"] = close.ewm(span=112, adjust=False).mean()
        df["EMA224"] = close.ewm(span=224, adjust=False).mean()
        df["EMA448"] = close.ewm(span=448, adjust=False).mean()

        # SMA 지표 계산
        df["SMA112"] = close.rolling(window=112).mean()
        df["SMA_Vol10"] = vol.rolling(window=10).mean()
        df["SMA_TradingValue10"] = tv.rolling(window=10).mean()

        # 이격도 계산 (Section 10, 11)
        df["Disparity112"] = (close / df["SMA112"]) * 100.0
        df["Disparity5"] = (close / df["EMA5"]) * 100.0

        return df

    def filter_universe(self, meta: StockMetadata) -> Tuple[bool, List[str]]:
        """유니버스 및 종목 속성 적격성 검증 (Section 1)"""
        reasons = []
        if not meta.is_ordinary:
            reasons.append("FAIL_NOT_ORDINARY_SHARE")
        if meta.is_managed or meta.is_warning or meta.is_suspended or meta.is_unfaithful_disclosure:
            reasons.append("FAIL_MANAGED_WARNING_SUSPENDED_OR_UNFAITHFUL")
        if meta.is_etf_etn or meta.is_reits or meta.is_spac or meta.is_investment_company:
            reasons.append("FAIL_EXCLUDED_VEHICLE_ETF_SPAC_REITS")

        # A/B 백테스트용 대주/선물 기초자산 필터 (Section 1, 32)
        if not self.config.allow_shortable and meta.is_shortable:
            reasons.append("FAIL_SHORTABLE_STOCK_DISALLOWED")
        if not self.config.allow_equity_futures_underlying and meta.is_futures_underlying:
            reasons.append("FAIL_FUTURES_UNDERLYING_DISALLOWED")

        return len(reasons) == 0, reasons

    def screen(self, ticker: str, df: pd.DataFrame, meta: StockMetadata) -> ScreenResult:
        """단일 종목에 대한 종합 11대 조건 스크리닝 (Section 1~12)"""
        # 1. 유니버스 필터
        ok_univ, univ_reasons = self.filter_universe(meta)
        if not ok_univ:
            return ScreenResult(ticker=ticker, eligible=False, reasons=univ_reasons)

        # 2. 데이터 Warm-up 검증
        n_bars = len(df)
        if n_bars < self.config.min_data_bars:
            return ScreenResult(
                ticker=ticker,
                eligible=False,
                reasons=[f"FAIL_INSUFFICIENT_BARS_{n_bars}_LT_{self.config.min_data_bars}"]
            )

        df_ind = self.calculate_indicators(df)
        curr = df_ind.iloc[-1]

        # 결측치/이상치 검증 (Section 29)
        for col in ["EMA5", "EMA20", "EMA60", "EMA112", "EMA224", "EMA448", "SMA112", "SMA_Vol10", "SMA_TradingValue10"]:
            val = curr[col]
            if pd.isna(val) or np.isinf(val) or val <= 0:
                return ScreenResult(ticker=ticker, eligible=False, reasons=[f"FAIL_INVALID_INDICATOR_{col}"])

        reasons: List[str] = []

        # Warm-up 권장치 도달 여부
        if n_bars < self.config.warmup_days:
            logger.debug(f"{ticker}: Data bars ({n_bars}) < recommended warmup ({self.config.warmup_days}).")
            reasons.append(f"FAIL_BELOW_RECOMMENDED_WARMUP_{n_bars}")

        # 4. 유동성 필터 (최근 10영업일)
        if curr["SMA_Vol10"] < self.config.volume_min:
            reasons.append(f"FAIL_SMA_VOL10_TOO_LOW_{curr['SMA_Vol10']:.0f}")
        if not self.config.disable_volume_max and curr["SMA_Vol10"] > self.config.volume_max:
            reasons.append(f"FAIL_SMA_VOL10_TOO_HIGH_{curr['SMA_Vol10']:.0f}")

        if curr["SMA_TradingValue10"] < self.config.trading_value_min:
            reasons.append(f"FAIL_SMA_TRADING_VALUE_TOO_LOW_{curr['SMA_TradingValue10']:.0f}")
        if not self.config.disable_trading_value_max and curr["SMA_TradingValue10"] > self.config.trading_value_max:
            reasons.append(f"FAIL_SMA_TRADING_VALUE_TOO_HIGH_{curr['SMA_TradingValue10']:.0f}")

        # 5. 장기 역배열 조건 (EMA448 > EMA224 > EMA112)
        if not (curr["EMA448"] > curr["EMA224"] > curr["EMA112"]):
            reasons.append("FAIL_LONGTERM_REVERSE_ARRANGEMENT_NOT_MET")

        # 6. 중단기 추세 전환 (EMA20 > EMA60)
        if not (curr["EMA20"] > curr["EMA60"]):
            reasons.append("FAIL_MID_SHORT_REVERSAL_EMA20_LE_EMA60")

        # 7. EMA112 현재가 위치 (Close > EMA112)
        if not (curr["Close"] > curr["EMA112"]):
            reasons.append("FAIL_CLOSE_LE_EMA112")

        # 8. 최근 EMA112 실제 돌파 및 유지 조건
        lookback = self.config.breakout_lookback
        recent_sub = df_ind.iloc[-lookback:]
        recent_c = recent_sub["Close"].values
        recent_ema112 = recent_sub["EMA112"].values

        breakout_idx: Optional[int] = None
        for i in range(1, len(recent_c)):
            if recent_c[i - 1] <= recent_ema112[i - 1] and recent_c[i] > recent_ema112[i]:
                breakout_idx = i

        if breakout_idx is None:
            reasons.append(f"FAIL_NO_BREAKOUT_IN_LAST_{lookback}_SESSIONS")
        else:
            # 돌파 이후 신호일까지 종가 기준으로 EMA112를 다시 하향 이탈한 적이 없어야 함
            after_c = recent_c[breakout_idx:]
            after_ema112 = recent_ema112[breakout_idx:]
            if np.any(after_c < after_ema112):
                reasons.append("FAIL_EMA112_REVIOLATED_AFTER_BREAKOUT")

        # 9. 눌림목 캔들 조건 (Open > Close 음봉)
        if not (curr["Open"] > curr["Close"]):
            reasons.append("FAIL_NOT_BEARISH_CANDLE")

        # 10. SMA112 단순 이격도 (100.00 ~ 108.00)
        disp112 = curr["Disparity112"]
        if not (self.config.disparity112_min <= disp112 <= self.config.disparity112_max):
            reasons.append(f"FAIL_DISPARITY112_OUT_OF_BOUNDS_{disp112:.2f}")

        # 11. EMA5 지수 이격도 (99.00 ~ 102.00)
        disp5 = curr["Disparity5"]
        if not (self.config.disparity5_min <= disp5 <= self.config.disparity5_max):
            reasons.append(f"FAIL_DISPARITY5_OUT_OF_BOUNDS_{disp5:.2f}")

        eligible = len(reasons) == 0
        indicators = {
            "Close": float(curr["Close"]),
            "EMA5": float(curr["EMA5"]),
            "EMA20": float(curr["EMA20"]),
            "EMA60": float(curr["EMA60"]),
            "EMA112": float(curr["EMA112"]),
            "EMA224": float(curr["EMA224"]),
            "EMA448": float(curr["EMA448"]),
            "SMA112": float(curr["SMA112"]),
            "Disparity112": float(disp112),
            "Disparity5": float(disp5),
            "SMA_Vol10": float(curr["SMA_Vol10"]),
            "SMA_TradingValue10": float(curr["SMA_TradingValue10"]),
        }
        return ScreenResult(ticker=ticker, eligible=eligible, reasons=reasons, indicators=indicators)


# =====================================================================
# 4. PositionSizer (Section 23, 28)
# =====================================================================
class PositionSizer:
    """계좌 평가금액과 위험 한도 기반 정수 매수 수량 계산기"""

    def __init__(self, config: StrategyConfig):
        self.config = config

    def calculate_shares(self, account_equity: float, available_cash: float, entry_price: float) -> int:
        """위험 허용 기준, 최대 종목 비중, 가용 현금을 모두 감안한 보수적 정수 수량 산출"""
        if entry_price <= 0 or account_equity <= 0 or available_cash <= 0:
            return 0

        # 1. 1회 거래당 리스크 금액 (계좌 자산의 0.5%)
        risk_amount = account_equity * self.config.risk_per_trade
        # 주당 손절 위험금 (기본 손절폭 3.5%)
        risk_per_share = entry_price * self.config.hard_stop_pct
        if risk_per_share <= 0:
            return 0

        shares_by_risk = math.floor(risk_amount / risk_per_share)

        # 2. 최대 단일 종목 비중 제한 (예: 20%)
        max_position_capital = account_equity * self.config.max_position_pct
        shares_by_max_pos = math.floor(max_position_capital / entry_price)

        # 3. 실제 주문 가능 가용 현금 제한
        shares_by_cash = math.floor(available_cash / entry_price)

        final_shares = min(shares_by_risk, shares_by_max_pos, shares_by_cash)

        # 최소 주문 금액 미달 시 0주 처리
        if final_shares * entry_price < self.config.minimum_order_amount:
            return 0

        return max(0, final_shares)


# =====================================================================
# 5. ExitStrategyEngine (Section 16~22, 28)
# =====================================================================
class ExitStrategyEngine:
    """장중 실시간 및 EOD 확정봉 기반 매도 규칙 평가 엔진"""

    def __init__(self, config: StrategyConfig):
        self.config = config

    def evaluate_intraday(
        self,
        pos: Position,
        current_price: float,
        daily_open: float,
        curr_ema224: float,
        curr_ema448: float
    ) -> Optional[ExitSignal]:
        """장중 청산 평가 (우선순위: 1. Hard Stop -> 2. TP1 -> 3. TP2) (Section 16, 17, 21)"""
        if pos.current_shares <= 0:
            return None

        # Rule 1-A: 절대 손절 (-3.5%)
        stop_price = pos.entry_price * (1.0 - self.config.hard_stop_pct)
        if current_price <= stop_price:
            # 갭 하락 리스크 반영 (Section 24)
            if daily_open <= stop_price:
                exec_price = daily_open
                reason = ExitReason.GAP_DOWN_STOP
            else:
                exec_price = stop_price
                reason = ExitReason.HARD_STOP

            return ExitSignal(
                triggered=True,
                order_type=OrderType.MARKET,
                shares=pos.current_shares,
                reason=reason,
                priority=1,
                execution_price=exec_price
            )

        # 목표가 설정 (동적 vs 진입 시점 고정) (Section 17)
        target_ema224 = curr_ema224 if self.config.dynamic_target else pos.entry_ema224
        target_ema448 = curr_ema448 if self.config.dynamic_target else pos.entry_ema448

        # Rule 2-A: TP1 (EMA224 * 0.995)
        if not pos.tp1_triggered and current_price >= target_ema224 * 0.995:
            tp1_shares = math.floor(pos.initial_shares * self.config.tp1_ratio)
            tp1_shares = min(tp1_shares, pos.current_shares)
            if tp1_shares >= self.config.min_exit_shares:
                return ExitSignal(
                    triggered=True,
                    order_type=OrderType.MARKET,
                    shares=tp1_shares,
                    reason=ExitReason.TP1,
                    priority=2,
                    execution_price=current_price
                )

        # Rule 2-B: TP2 (EMA448 * 0.995)
        if not pos.tp2_triggered and current_price >= target_ema448 * 0.995:
            tp2_shares = math.floor(pos.initial_shares * self.config.tp2_ratio)
            tp2_shares = min(tp2_shares, pos.current_shares)
            if tp2_shares >= self.config.min_exit_shares:
                return ExitSignal(
                    triggered=True,
                    order_type=OrderType.MARKET,
                    shares=tp2_shares,
                    reason=ExitReason.TP2,
                    priority=3,
                    execution_price=current_price
                )

        return None

    def evaluate_eod(
        self,
        pos: Position,
        close_price: float,
        ema5: float,
        ema20: float,
        ema112: float,
        sma112: float
    ) -> Optional[ExitSignal]:
        """EOD 확정봉 청산 평가 (우선순위: 1. 112선 이탈 -> 2. EMA20 이탈 -> 3. EMA5 트레일링 -> 4. Time Cut)

        체결은 원칙적으로 Look-ahead bias를 방지하여 D+1 시가에 집행됩니다 (Section 16, 19, 20, 21, 26).
        """
        if pos.current_shares <= 0:
            return None

        # Rule 1-B: 장기 추세 붕괴 (Close < SMA112 OR Close < EMA112)
        if close_price < sma112 or close_price < ema112:
            return ExitSignal(
                triggered=True,
                order_type=OrderType.MARKET,
                shares=pos.current_shares,
                reason=ExitReason.EMA112_SMA112_STOP,
                priority=1
            )

        # Rule 3-B: EMA20 중기 추세 종료 (Close < EMA20)
        if close_price < ema20:
            return ExitSignal(
                triggered=True,
                order_type=OrderType.MARKET,
                shares=pos.current_shares,
                reason=ExitReason.EMA20_EXIT,
                priority=2
            )

        # Rule 3-A: EMA5 단기 추세 약화 트레일링 (수익률 >= 3% AND Close < EMA5)
        curr_profit = (close_price / pos.entry_price) - 1.0
        if not pos.trail_5_triggered and curr_profit >= self.config.trailing_min_profit and close_price < ema5:
            trail_shares = math.floor(pos.current_shares * self.config.ema5_trailing_ratio)
            trail_shares = min(trail_shares, pos.current_shares)
            if trail_shares >= self.config.min_exit_shares:
                return ExitSignal(
                    triggered=True,
                    order_type=OrderType.MARKET,
                    shares=trail_shares,
                    reason=ExitReason.EMA5_TRAILING,
                    priority=3
                )

        # Rule 4: Time Cut (holding_days >= 8 AND MFE < 3%)
        mfe = (pos.highest_price / pos.entry_price) - 1.0
        if pos.holding_days >= self.config.time_cut_days and mfe < self.config.time_cut_mfe:
            return ExitSignal(
                triggered=True,
                order_type=OrderType.MARKET,
                shares=pos.current_shares,
                reason=ExitReason.TIME_CUT,
                priority=4
            )

        return None


# =====================================================================
# 6. BrokerAdapter (Section 28)
# =====================================================================
class BrokerAdapter(abc.ABC):
    """증권사 연동 추상 인터페이스"""

    @abc.abstractmethod
    def get_universe(self) -> List[StockMetadata]:
        """전체 유니버스 및 메타데이터 조회"""
        pass

    @abc.abstractmethod
    def get_daily_ohlcv(self, ticker: str, start_date: str, end_date: str) -> pd.DataFrame:
        """일봉 OHLCV 시세 데이터 조회"""
        pass

    @abc.abstractmethod
    def get_quote(self, ticker: str) -> float:
        """현재가/최신 체결가 조회"""
        pass

    @abc.abstractmethod
    def get_account_balance(self) -> Tuple[float, float]:
        """(총평가자산, 가용예수금) 조회"""
        pass

    @abc.abstractmethod
    def place_order(
        self, ticker: str, side: OrderSide, order_type: OrderType, shares: int, price: float = 0.0
    ) -> str:
        """주문 발송 및 주문번호 반환"""
        pass

    @abc.abstractmethod
    def get_order_status(self, order_id: str) -> OrderStatus:
        """주문 상태 조회"""
        pass

    @abc.abstractmethod
    def get_positions(self) -> List[Position]:
        """브로커 계좌 보유 잔고 조회"""
        pass

    @abc.abstractmethod
    def reconcile(self, internal_positions: Dict[str, Position]) -> None:
        """내부 포지션과 브로커 잔고 대조 및 동기화 (Section 27)"""
        pass


class SimulationBrokerAdapter(BrokerAdapter):
    """백테스트 및 페이퍼 트레이딩을 위한 인메모리 가상 브로커 구현체"""

    def __init__(self, backtest_config: BacktestConfig):
        self.bt_config = backtest_config
        self.cash: float = backtest_config.initial_capital
        self.universe: Dict[str, StockMetadata] = {}
        self.daily_data: Dict[str, pd.DataFrame] = {}
        self.orders: Dict[str, Dict[str, Any]] = {}
        self.broker_positions: Dict[str, int] = {}

    def register_stock(self, meta: StockMetadata, df: pd.DataFrame) -> None:
        self.universe[meta.ticker] = meta
        self.daily_data[meta.ticker] = df

    def get_universe(self) -> List[StockMetadata]:
        return list(self.universe.values())

    def get_daily_ohlcv(self, ticker: str, start_date: str, end_date: str) -> pd.DataFrame:
        df = self.daily_data.get(ticker, pd.DataFrame())
        if df.empty:
            return df
        if end_date:
            return df.loc[:end_date]
        return df

    def get_quote(self, ticker: str) -> float:
        df = self.daily_data.get(ticker)
        return float(df["Close"].iloc[-1]) if df is not None and not df.empty else 0.0

    def get_account_balance(self) -> Tuple[float, float]:
        equity = self.cash
        for ticker, shares in self.broker_positions.items():
            if shares > 0:
                equity += shares * self.get_quote(ticker)
        return equity, self.cash

    def place_order(
        self, ticker: str, side: OrderSide, order_type: OrderType, shares: int, price: float = 0.0
    ) -> str:
        order_id = str(uuid.uuid4())[:8]
        # 슬리피지 및 수수료 반영 (Section 25)
        if side == OrderSide.BUY:
            exec_price = price * (1.0 + self.bt_config.slippage_rate)
            fee = exec_price * shares * self.bt_config.buy_fee_rate
            cost = (exec_price * shares) + fee
            self.cash -= cost
            self.broker_positions[ticker] = self.broker_positions.get(ticker, 0) + shares
        else:
            exec_price = price * (1.0 - self.bt_config.slippage_rate)
            fee = exec_price * shares * self.bt_config.sell_fee_rate
            tax = exec_price * shares * self.bt_config.tax_rate
            proceeds = (exec_price * shares) - fee - tax
            self.cash += proceeds
            self.broker_positions[ticker] = max(0, self.broker_positions.get(ticker, 0) - shares)

        self.orders[order_id] = {
            "order_id": order_id,
            "ticker": ticker,
            "side": side,
            "shares": shares,
            "price": exec_price,
            "status": OrderStatus.FILLED
        }
        return order_id

    def get_order_status(self, order_id: str) -> OrderStatus:
        order = self.orders.get(order_id)
        return order["status"] if order else OrderStatus.CANCELLED

    def get_positions(self) -> List[Position]:
        return []

    def reconcile(self, internal_positions: Dict[str, Position]) -> None:
        for ticker, pos in internal_positions.items():
            broker_sh = self.broker_positions.get(ticker, 0)
            if pos.current_shares != broker_sh:
                logger.warning(
                    f"Reconciliation Mismatch for {ticker}: Internal={pos.current_shares}, Broker={broker_sh}"
                )


# =====================================================================
# 7. TradingEngine 오케스트레이터 (Section 28)
# =====================================================================
class TradingEngine:
    """전체 데이터 수집, 스크리닝, 주문, 청산 감시를 총괄하는 메인 엔진"""

    def __init__(self, config: StrategyConfig, broker: BrokerAdapter):
        self.config = config
        self.broker = broker
        self.screener = StockScreener(config)
        self.sizer = PositionSizer(config)
        self.exit_engine = ExitStrategyEngine(config)

        self.positions: Dict[str, Position] = {}
        self.pending_buy_candidates: List[Tuple[str, ScreenResult]] = []
        self.pending_eod_exits: List[Tuple[str, ExitSignal]] = []
        self.trade_history: List[OrderRecord] = []

    def run_eod_pipeline(self, date_str: str) -> None:
        """D일 장마감 후: 기존 보유 종목 EOD 청산 평가 및 익일 매수 후보 발굴 (Section 13, 21)"""
        logger.info(f"=== [EOD PIPELINE] 확정 일자: {date_str} ===")

        # 1. 기존 보유 포지션에 대한 EOD 청산 규칙 평가 (익일 시가 체결 예약)
        self.pending_eod_exits.clear()
        for ticker, pos in list(self.positions.items()):
            if pos.current_shares <= 0:
                continue

            df = self.broker.get_daily_ohlcv(ticker, "", date_str)
            if df.empty or len(df) < self.config.min_data_bars:
                continue

            df_ind = self.screener.calculate_indicators(df)
            curr = df_ind.iloc[-1]

            pos.holding_days += 1
            pos.highest_price = max(pos.highest_price, float(curr["High"]))
            pos.last_evaluated_date = date_str

            exit_sig = self.exit_engine.evaluate_eod(
                pos=pos,
                close_price=float(curr["Close"]),
                ema5=float(curr["EMA5"]),
                ema20=float(curr["EMA20"]),
                ema112=float(curr["EMA112"]),
                sma112=float(curr["SMA112"])
            )
            if exit_sig and exit_sig.triggered:
                logger.info(
                    f"EOD EXIT SIGNAL RESERVED: {ticker} | Rule: {exit_sig.reason} | Shares: {exit_sig.shares}"
                )
                self.pending_eod_exits.append((ticker, exit_sig))

        # 2. 신규 매수 후보 종목 스크리닝
        self.pending_buy_candidates.clear()
        universe = self.broker.get_universe()

        for meta in universe:
            # 이미 보유 중인 종목은 제외
            if meta.ticker in self.positions and self.positions[meta.ticker].current_shares > 0:
                continue

            df = self.broker.get_daily_ohlcv(meta.ticker, "", date_str)
            if df.empty or len(df) < self.config.min_data_bars:
                continue

            res = self.screener.screen(meta.ticker, df, meta)
            if res.eligible:
                logger.info(f"BUY CANDIDATE CONFIRMED: {meta.ticker} ({meta.name})")
                self.pending_buy_candidates.append((meta.ticker, res))

    def execute_morning_open(self, exec_date: str, morning_quotes: Dict[str, float]) -> None:
        """D+1 장시작 시점: 예약된 EOD 청산 주문 집행 및 신규 매수 주문 집행 (Look-ahead bias 방지)"""
        logger.info(f"=== [MORNING OPEN EXECUTION] 실행 일자: {exec_date} ===")

        # 1. EOD 매도 주문 우선 집행
        for ticker, exit_sig in self.pending_eod_exits:
            pos = self.positions.get(ticker)
            if not pos or pos.current_shares <= 0:
                continue

            exec_price = morning_quotes.get(ticker, 0.0)
            if exec_price <= 0:
                logger.warning(f"Morning quote missing for {ticker}, skipping exit.")
                continue

            shares_to_sell = min(exit_sig.shares, pos.current_shares)
            if shares_to_sell <= 0:
                continue

            order_id = self.broker.place_order(
                ticker=ticker,
                side=OrderSide.SELL,
                order_type=OrderType.MARKET,
                shares=shares_to_sell,
                price=exec_price
            )

            before_sh = pos.current_shares
            pos.current_shares -= shares_to_sell
            pnl = (exec_price - pos.entry_price) * shares_to_sell
            pos.realized_pnl += pnl

            if exit_sig.reason == ExitReason.EMA5_TRAILING:
                pos.trail_5_triggered = True

            if pos.current_shares == 0:
                pos.status = OrderStatus.CLOSED

            rec = OrderRecord(
                order_id=order_id,
                ticker=ticker,
                timestamp=exec_date,
                side=OrderSide.SELL,
                shares=shares_to_sell,
                price=exec_price,
                reason=exit_sig.reason.value,
                position_before=before_sh,
                position_after=pos.current_shares
            )
            self.trade_history.append(rec)
            logger.info(
                f"EXECUTED EOD EXIT: {ticker} | Rule: {exit_sig.reason.value} | Shares: {shares_to_sell} | Price: {exec_price:,.0f} KRW"
            )

        self.pending_eod_exits.clear()

        # 2. 신규 매수 주문 집행
        equity, cash = self.broker.get_account_balance()
        for ticker, res in self.pending_buy_candidates:
            active_count = sum(1 for p in self.positions.values() if p.current_shares > 0)
            if active_count >= self.config.max_positions:
                logger.warning("Max positions reached. Skipping remaining buy candidates.")
                break

            open_price = morning_quotes.get(ticker, 0.0)
            if open_price <= 0:
                continue

            shares = self.sizer.calculate_shares(equity, cash, open_price)
            if shares <= 0:
                continue

            order_id = self.broker.place_order(
                ticker=ticker,
                side=OrderSide.BUY,
                order_type=OrderType.MARKET,
                shares=shares,
                price=open_price
            )

            ind = res.indicators
            new_pos = Position(
                ticker=ticker,
                entry_price=open_price,
                entry_date=exec_date,
                initial_shares=shares,
                current_shares=shares,
                highest_price=open_price,
                holding_days=0,
                entry_ema112=ind.get("EMA112", 0.0),
                entry_ema224=ind.get("EMA224", 0.0),
                entry_ema448=ind.get("EMA448", 0.0),
                last_evaluated_date=exec_date,
                pending_order_id=order_id,
                status=OrderStatus.FILLED
            )
            self.positions[ticker] = new_pos
            cash -= shares * open_price

            rec = OrderRecord(
                order_id=order_id,
                ticker=ticker,
                timestamp=exec_date,
                side=OrderSide.BUY,
                shares=shares,
                price=open_price,
                reason="BUY_EXECUTION_D_PLUS_1",
                position_before=0,
                position_after=shares
            )
            self.trade_history.append(rec)
            logger.info(
                f"EXECUTED BUY: {ticker} | Shares: {shares} | Price: {open_price:,.0f} KRW | Cash Remained: {cash:,.0f} KRW"
            )

        self.pending_buy_candidates.clear()
        self.broker.reconcile(self.positions)

    def monitor_intraday(
        self, ticker: str, current_price: float, daily_open: float, curr_ema224: float, curr_ema448: float
    ) -> Optional[OrderRecord]:
        """장중 틱/분봉 감시: -3.5% 스탑 및 TP1/TP2 익절 실시간 처리 (Section 16, 17, 18)"""
        pos = self.positions.get(ticker)
        if not pos or pos.current_shares <= 0:
            return None

        # 상태 머신: 이미 주문 발송 대기 중인 경우 중복 발송 차단 (Section 27)
        if pos.status == OrderStatus.ORDER_PENDING:
            return None

        exit_sig = self.exit_engine.evaluate_intraday(
            pos=pos,
            current_price=current_price,
            daily_open=daily_open,
            curr_ema224=curr_ema224,
            curr_ema448=curr_ema448
        )
        if not exit_sig or not exit_sig.triggered:
            return None

        shares_to_sell = min(exit_sig.shares, pos.current_shares)
        if shares_to_sell <= 0:
            return None

        exec_price = exit_sig.execution_price if exit_sig.execution_price > 0 else current_price

        # TP1/TP2 상태 머신 처리 (Section 18)
        pos.status = OrderStatus.ORDER_PENDING
        order_id = self.broker.place_order(
            ticker=ticker,
            side=OrderSide.SELL,
            order_type=OrderType.MARKET,
            shares=shares_to_sell,
            price=exec_price
        )

        before_sh = pos.current_shares
        pos.current_shares -= shares_to_sell
        pnl = (exec_price - pos.entry_price) * shares_to_sell
        pos.realized_pnl += pnl

        if exit_sig.reason in (ExitReason.HARD_STOP, ExitReason.GAP_DOWN_STOP):
            pos.status = OrderStatus.CLOSED
        elif exit_sig.reason == ExitReason.TP1:
            pos.tp1_triggered = True
            pos.status = OrderStatus.FILLED
        elif exit_sig.reason == ExitReason.TP2:
            pos.tp2_triggered = True
            pos.status = OrderStatus.FILLED

        if pos.current_shares == 0:
            pos.status = OrderStatus.CLOSED

        rec = OrderRecord(
            order_id=order_id,
            ticker=ticker,
            timestamp=pos.last_evaluated_date or "INTRADAY",
            side=OrderSide.SELL,
            shares=shares_to_sell,
            price=exec_price,
            reason=exit_sig.reason.value,
            position_before=before_sh,
            position_after=pos.current_shares
        )
        self.trade_history.append(rec)
        logger.info(
            f"EXECUTED INTRADAY EXIT: {ticker} | Rule: {exit_sig.reason.value} | Shares: {shares_to_sell} | Price: {exec_price:,.0f} KRW"
        )
        return rec


# =====================================================================
# 8. 백테스트 및 A/B 성과 분석기 (Section 31, 32)
# =====================================================================
class BacktestAnalyzer:
    """성과 지표(CAGR, MDD, 승률, Profit Factor, Expectancy 등) 계산 엔진"""

    @staticmethod
    def calculate_performance(trade_history: List[OrderRecord], initial_capital: float) -> Dict[str, Any]:
        """완료된 거래 내역을 바탕으로 9대 성과 지표 산출"""
        sells = [r for r in trade_history if r.side == OrderSide.SELL]
        buys = {r.ticker: r.price for r in trade_history if r.side == OrderSide.BUY}

        if not sells:
            return {
                "total_trades": 0, "win_rate": 0.0, "profit_factor": 0.0,
                "avg_profit_pct": 0.0, "avg_loss_pct": 0.0, "expectancy": 0.0,
                "cagr": 0.0, "mdd": 0.0
            }

        profits = []
        losses = []
        trade_returns = []

        for s in sells:
            entry_p = buys.get(s.ticker, s.price)
            ret = (s.price / entry_p) - 1.0
            trade_returns.append(ret)
            if ret > 0:
                profits.append(ret)
            else:
                losses.append(ret)

        total_trades = len(trade_returns)
        win_count = len(profits)
        win_rate = (win_count / total_trades) * 100.0 if total_trades > 0 else 0.0

        gross_profit = sum(profits)
        gross_loss = abs(sum(losses))
        profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else (99.0 if gross_profit > 0 else 0.0)

        avg_profit = np.mean(profits) * 100.0 if profits else 0.0
        avg_loss = np.mean(losses) * 100.0 if losses else 0.0

        expectancy = ((win_rate / 100.0) * (avg_profit / 100.0)) + (
            (1.0 - (win_rate / 100.0)) * (avg_loss / 100.0)
        )

        return {
            "total_trades": total_trades,
            "win_rate": round(win_rate, 2),
            "profit_factor": round(profit_factor, 2),
            "avg_profit_pct": round(avg_profit, 2),
            "avg_loss_pct": round(avg_loss, 2),
            "expectancy_pct": round(expectancy * 100.0, 2),
        }


# =====================================================================
# 9. 단위 테스트 (Section 35) - Pytest 규격
# =====================================================================
def test_ema_calculation():
    """EMA 계산 검증"""
    s = pd.Series([100.0, 102.0, 104.0, 103.0, 105.0])
    ema = s.ewm(span=5, adjust=False).mean()
    assert len(ema) == 5
    assert not ema.isna().any()
    assert ema.iloc[-1] > ema.iloc[0]


def test_breakout_detection_and_reviolation():
    """최근 EMA112 상향돌파 및 재이탈 감지 검증"""
    cfg = StrategyConfig(breakout_lookback=7)
    screener = StockScreener(cfg)

    # 650봉 더미 생성
    dates = pd.date_range("2024-01-01", periods=650, freq="B")
    prices = np.linspace(15000, 9000, 650)
    df = pd.DataFrame({
        "Open": prices * 1.01,
        "High": prices * 1.02,
        "Low": prices * 0.99,
        "Close": prices,
        "Volume": 100000.0,
        "TradingValue": 1000000000.0
    }, index=dates)

    df_ind = screener.calculate_indicators(df)
    assert "EMA112" in df_ind.columns
    assert "Disparity112" in df_ind.columns


def test_hard_stop_and_gap_down():
    """-3.5% 절대 손절 및 시가 갭하락 처리 검증"""
    cfg = StrategyConfig(hard_stop_pct=0.035)
    exit_engine = ExitStrategyEngine(cfg)

    pos = Position(
        ticker="TEST",
        entry_price=10000.0,
        entry_date="2026-08-01",
        initial_shares=100,
        current_shares=100,
        highest_price=10000.0
    )

    # 1. 정상 장중 손절가 도달
    sig1 = exit_engine.evaluate_intraday(pos, current_price=9600.0, daily_open=9800.0, curr_ema224=12000, curr_ema448=15000)
    assert sig1 is not None and sig1.triggered
    assert sig1.reason == ExitReason.HARD_STOP
    assert sig1.execution_price == 9650.0

    # 2. 시가 갭하락 발생 (Open = 9200)
    sig2 = exit_engine.evaluate_intraday(pos, current_price=9200.0, daily_open=9200.0, curr_ema224=12000, curr_ema448=15000)
    assert sig2 is not None and sig2.triggered
    assert sig2.reason == ExitReason.GAP_DOWN_STOP
    assert sig2.execution_price == 9200.0


def test_tp1_tp2_shares():
    """TP1(40%) 및 TP2(30%) 분할익절 정수 수량 계산 검증"""
    cfg = StrategyConfig(tp1_ratio=0.40, tp2_ratio=0.30)
    exit_engine = ExitStrategyEngine(cfg)

    pos = Position(
        ticker="TEST",
        entry_price=10000.0,
        entry_date="2026-08-01",
        initial_shares=100,
        current_shares=100,
        highest_price=10000.0,
        entry_ema224=12000.0,
        entry_ema448=15000.0
    )

    # TP1: 12000 * 0.995 = 11940 도달
    sig_tp1 = exit_engine.evaluate_intraday(pos, current_price=12000.0, daily_open=11800.0, curr_ema224=12000.0, curr_ema448=15000.0)
    assert sig_tp1 is not None and sig_tp1.triggered
    assert sig_tp1.reason == ExitReason.TP1
    assert sig_tp1.shares == 40

    pos.current_shares -= sig_tp1.shares
    pos.tp1_triggered = True

    # TP2: 15000 * 0.995 = 14925 도달
    sig_tp2 = exit_engine.evaluate_intraday(pos, current_price=15000.0, daily_open=14800.0, curr_ema224=12000.0, curr_ema448=15000.0)
    assert sig_tp2 is not None and sig_tp2.triggered
    assert sig_tp2.reason == ExitReason.TP2
    assert sig_tp2.shares == 30


def test_time_cut_exit():
    """8영업일 경과 및 MFE < 3% 시 Time Cut 검증"""
    cfg = StrategyConfig(time_cut_days=8, time_cut_mfe=0.03)
    exit_engine = ExitStrategyEngine(cfg)

    pos = Position(
        ticker="TEST",
        entry_price=10000.0,
        entry_date="2026-08-01",
        initial_shares=100,
        current_shares=100,
        highest_price=10200.0,  # +2% (MFE < 3%)
        holding_days=8
    )

    sig = exit_engine.evaluate_eod(
        pos=pos,
        close_price=10050.0,
        ema5=10000.0,
        ema20=9800.0,
        ema112=9500.0,
        sma112=9400.0
    )
    assert sig is not None and sig.triggered
    assert sig.reason == ExitReason.TIME_CUT
    assert sig.shares == 100


# =====================================================================
# 10. 실행 예제 (__main__) (Section 34)
# =====================================================================
if __name__ == "__main__":
    print("=" * 80)
    print(" TradingEngine 실행 시뮬레이션 (11개 핵심 시나리오 종합 데모)")
    print("=" * 80)

    # 1. 환경 및 더미 데이터(650영업일) 구축
    strategy_cfg = StrategyConfig()
    bt_cfg = BacktestConfig(initial_capital=100_000_000.0)
    broker = SimulationBrokerAdapter(bt_cfg)

    dates = pd.date_range("2024-01-01", periods=650, freq="B")
    n = len(dates)

    # 장기 하락 후 바닥권에서 최근 EMA112를 상향 돌파하고 EMA5 음봉 눌림을 형성하는 합성 데이터 생성
    np.random.seed(42)
    base_curve = np.concatenate([
        np.linspace(25000, 11000, 500),  # 장기 하락 구간 (역배열 형성: 448 > 224 > 112)
        np.full(142, 11100.0),            # 바닥 횡보
        [11100, 11150, 11350, 11450, 11400, 11350, 11300, 11250] # 최근 7일 내 EMA112 돌파 후 음봉 눌림
    ])

    close_s = base_curve.copy()
    open_s = close_s.copy()
    open_s[-1] = 11350.0  # 마지막 봉 Open > Close (음봉) 보장
    close_s[-1] = 11250.0
    high_s = np.maximum(open_s, close_s) * 1.01
    low_s = np.minimum(open_s, close_s) * 0.99
    vol_s = np.full(n, 200_000.0)
    val_s = vol_s * close_s

    df_synth = pd.DataFrame({
        "Open": open_s,
        "High": high_s,
        "Low": low_s,
        "Close": close_s,
        "Volume": vol_s,
        "TradingValue": val_s
    }, index=dates)

    meta = StockMetadata(
        ticker="099990",
        name="합성스윙후보주",
        market="KOSPI",
        is_ordinary=True,
        is_managed=False,
        is_warning=False,
        is_suspended=False,
        is_etf_etn=False,
        is_reits=False,
        is_spac=False,
        is_shortable=True,
        is_futures_underlying=True
    )
    broker.register_stock(meta, df_synth)

    engine = TradingEngine(strategy_cfg, broker)

    # 2. D일 장마감 EOD 파이프라인 가동 (지표 계산 및 스크리닝)
    last_date = dates[-1].strftime("%Y-%m-%d")
    engine.run_eod_pipeline(last_date)

    print(f"\n[1~3. 스크리닝 결과]")
    assert len(engine.pending_buy_candidates) == 1, "매수 후보 종목이 정확히 1개 선정되어야 합니다."
    cand_ticker, cand_res = engine.pending_buy_candidates[0]
    ind = cand_res.indicators
    print(f" > 종목: {cand_ticker} ({meta.name})")
    print(f" > 종가: {ind['Close']:,.0f} KRW | SMA112 이격도: {ind['Disparity112']:.2f}% | EMA5 이격도: {ind['Disparity5']:.2f}%")
    print(f" > 배열 상태: EMA448({ind['EMA448']:,.0f}) > EMA224({ind['EMA224']:,.0f}) > EMA112({ind['EMA112']:,.0f}) -> 완전 역배열 바닥권 확인")

    # 3. D+1 시가 매수 집행 (포지션 사이징 & 리스크 관리)
    next_date = "2026-07-01"
    morning_open_price = 11650.0
    engine.execute_morning_open(next_date, {cand_ticker: morning_open_price})

    pos = engine.positions[cand_ticker]
    print(f"\n[4. D+1 매수 체결 및 포지션 상태]")
    print(f" > 진입일자: {pos.entry_date} | 진입가: {pos.entry_price:,.0f} KRW | 체결수량: {pos.current_shares:,}주")
    print(f" > 계좌 1회 거래 리스크 한도(0.5%) 기준 산출 수량 검증 완료.")

    # 4. 장중 TP1 분할익절 시나리오 (EMA224 목표 도달)
    print(f"\n[5. TP1 익절 시나리오 테스트 (최초수량 40% 매도)]")
    tp1_price = ind["EMA224"]
    engine.monitor_intraday(cand_ticker, current_price=tp1_price, daily_open=11700.0, curr_ema224=ind["EMA224"], curr_ema448=ind["EMA448"])
    print(f" > 잔여 수량: {pos.current_shares:,}주 (TP1 완료: {pos.tp1_triggered})")

    # 5. 장중 TP2 분할익절 시나리오 (EMA448 목표 도달)
    print(f"\n[6. TP2 익절 시나리오 테스트 (최초수량 30% 매도)]")
    tp2_price = ind["EMA448"]
    engine.monitor_intraday(cand_ticker, current_price=tp2_price, daily_open=tp1_price, curr_ema224=ind["EMA224"], curr_ema448=ind["EMA448"])
    print(f" > 잔여 수량: {pos.current_shares:,}주 (TP2 완료: {pos.tp2_triggered})")

    # 6. EOD EMA5 트레일링 익절 시나리오 (수익률 >= 3% 상태에서 5일선 이탈)
    print(f"\n[7. EMA5 트레일링 익절 시나리오 (잔여수량 50% 매도)]")
    # 잔여 30주 중 50% = 15주 매도 예약
    pos.holding_days = 3
    exit_sig = engine.exit_engine.evaluate_eod(
        pos=pos,
        close_price=12200.0, # +4.7% 이익 중
        ema5=12300.0,        # 5일선 아래로 이탈
        ema20=11800.0,
        ema112=10500.0,
        sma112=10400.0
    )
    assert exit_sig is not None and exit_sig.reason == ExitReason.EMA5_TRAILING
    engine.pending_eod_exits.append((cand_ticker, exit_sig))
    engine.execute_morning_open("2026-07-04", {cand_ticker: 12200.0})
    print(f" > 잔여 수량: {pos.current_shares:,}주 (EMA5 트레일링 완료: {pos.trail_5_triggered})")

    # 7. EOD EMA20 이탈 시 잔여 전량 청산 시나리오
    print(f"\n[8. EMA20 이탈 시 잔여 전량 청산 시나리오]")
    exit_sig_ema20 = engine.exit_engine.evaluate_eod(
        pos=pos,
        close_price=11700.0,
        ema5=12000.0,
        ema20=11800.0,        # EMA20 하향 이탈
        ema112=10500.0,
        sma112=10400.0
    )
    assert exit_sig_ema20 is not None and exit_sig_ema20.reason == ExitReason.EMA20_EXIT
    engine.pending_eod_exits.append((cand_ticker, exit_sig_ema20))
    engine.execute_morning_open("2026-07-05", {cand_ticker: 11700.0})
    print(f" > 최종 잔여 수량: {pos.current_shares:,}주 (상태: {pos.status.value})")

    # 8. 성과 리포트 출력
    perf = BacktestAnalyzer.calculate_performance(engine.trade_history, bt_cfg.initial_capital)
    print(f"\n[9. 거래 감사 로그 및 종합 성과]")
    for r in engine.trade_history:
        print(f" > [{r.side.value}] {r.ticker} | 일자: {r.timestamp} | 주수: {r.shares:,}주 | 체결가: {r.price:,.0f} KRW | 사유: {r.reason}")
    print(f" > 총 거래수: {perf['total_trades']}회 | 승률: {perf['win_rate']}% | Profit Factor: {perf['profit_factor']}")

    # 9. 단위 테스트 실행 확인
    print(f"\n[10. 내장 단위 테스트 실행 (pytest 규격)]")
    test_ema_calculation()
    test_breakout_detection_and_reviolation()
    test_hard_stop_and_gap_down()
    test_tp1_tp2_shares()
    test_time_cut_exit()
    print(" > [PASS] test_ema_calculation")
    print(" > [PASS] test_breakout_detection_and_reviolation")
    print(" > [PASS] test_hard_stop_and_gap_down")
    print(" > [PASS] test_tp1_tp2_shares")
    print(" > [PASS] test_time_cut_exit")
    print("=" * 80)
    print(" 모든 시뮬레이션 및 테스트가 성공적으로 완료되었습니다.")
    print("=" * 80)
라인업 B 종목 불문 (미국 빅테크, 국내 우량주, 코인) · 사람의 가치 판단

5. 가치투자자가 마주하는 딜레마: "존버(단순보유)는 왜 위험할까?"

💡 라인업 B의 핵심 목적:
이것은 퀀트나 프로그램 매매가 아닙니다! 사용자가 직접 종목을 분석하고 "이 기업은 앞으로 2~3년 동안 세상을 바꿀 엄청난 가치가 있다"고 판단하여 중장기 가치투자를 할 때, 맹목적으로 무식하게 존버하다가 -50% 폭락을 맞고 쓰러지지 않도록 "어느 선에서 빠져나오고, 어느 타이밍에 다시 탈 것인가?" 기준선을 세워주는 사람 중심의 가이드입니다.

강세장의 착시현상

2024~2026년처럼 엔비디아나 하이닉스가 10배씩 오르는 초강세장에서는, 아무 생각 없이 그냥 쥐고 있던 존버가 최고의 수익률을 냅니다.
이때 사람들은 "가치투자는 그냥 사서 잊어버리는 거야!"라는 위험한 착각에 빠집니다.

2022년 하락장의 잔혹한 진실

하지만 2022년 같은 대세 하락장이 오면 어떻게 될까요?
아무리 위대한 1등 가치주라도 주가가 -50%~-70% 반토막 납니다.
원금 회복에만 3년이 걸리고, 대부분의 투자자는 바닥에서 공포에 질려 패닉셀(손절)하고 시장을 떠납니다.

라인업 B 사용자 관점의 4대 기준선

6. 가치투자자가 반드시 지켜야 할 4대 기준선 원칙

원칙 1: 120일선 절대 생명선 — "빠져나올 선을 정하라"

내가 아무리 사랑하는 가치주라도, 주가가 120일선(반년선)을 아래로 깨고 내려가면 일단 위험 신호입니다.
이때는 "가치주니까 더 사야지!" 하고 맹목적으로 물타기를 하지 말고, 비중의 50% 이상을 현금화하거나 안전자산으로 빠져나와 관망합니다.
"위대한 기업이라도 시장의 거대한 하락 파도 앞에서는 잠시 소나기를 피해야 합니다."

원칙 2: 120일선 재돌파 — "다시 들어갈 선을 확인하라"

소나기가 지나가고, 그 기업이 여전히 튼튼하다면 언젠가 120일선 위로 다시 힘차게 올라옵니다.
바로 그 순간이 "바닥을 다지고 진짜 상승이 시작되는 안전한 재탑승 타이밍"입니다. 바닥에서 억지로 칼날을 잡으려 하지 말고, 120일선 위에 안착했을 때 편안하게 들어갑니다.

원칙 3: 상대강도 1등 기업 집중 — "달리기 1등 선수에게 몰아줘라"

내가 찜해둔 가치주 후보가 여러 개(예: 구글, 아마존, 엔비디아) 있을 때, 골고루 나눠서 사기보다 현재 시장에서 가장 힘차게 52주 신고가를 쓰며 돈을 쓸어 담는 1~2등 주도주에 자금을 집중합니다.
힘이 빠져서 120일선을 깬 꼴찌 종목은 덜어내고, 그 현금으로 1등 선수에 실어주면 포트폴리오의 복리가 극대화됩니다.

원칙 4: 비대칭 분할 수확 — "치킨값을 챙기고 30%는 우주 끝까지"

가치주가 폭등할 때 절대 한 번에 다 팔지 않습니다.
• 1년선 도달 시 40% 분할익절 (치킨값 확정)
• 2년선 도달 시 30% 추가 분할익절 (고기값 확정)
• 남은 30% 코어 물량: 이 30%는 120일선이 완전히 깨지기 전까지 진짜 가치투자자처럼 5배, 10배 갈 때까지 영원히 들고 갑니다.
"이미 70% 수익을 확정 지었기 때문에, 마음 편하게 10배 대박을 노릴 수 있는 배짱이 생깁니다!"

⚖️ 라인업 A vs 라인업 B 완벽 비교표

두 전략은 상호 배타적인 것이 아니라, 내 계좌 안에서 서로를 보완하는 환상의 짝꿍입니다.

구분 [라인업 A] 국내주식 퀀트 스윙 [라인업 B] 중장기 가치투자 기준선 매매
매매 주체 100% 컴퓨터 프로그램 (TradingEngine) 사람 (사용자 자신의 가치 판단)
적용 대상 오로지 한국 주식 (KOSPI / KOSDAQ 보통주) 종목 불문 (미국 빅테크, 국내 초우량주, 비트코인 등)
투자 철학 기업의 미래 안 봄. 오로지 차트 수식과 확률에 베팅 기업의 독점력, 실적, 세상을 바꿀 미래 가치를 믿고 투자
보유 기간 3일 ~ 3주 (단기/스윙 파동) 수개월 ~ 2~3년 (중장기 사이클)
진입 기준 112일선 돌파 후 5일선 음봉 눌림목 (기계적 11대 조건) 철저한 펀더멘털 분석 + 120일선 위 안착 확인 시 진입
탈출(손절) 기준 -3.5% 도달 시 이유 불문 즉시 칼손절 120일선 붕괴 시 비중 50% 축소 및 현금화 (소나기 피하기)
수익 실현 224선 40%, 448선 30%, EMA5 50% 트레일링 1~2년선에서 70% 분할 수확 후, 30% 코어는 10배까지 홀딩
목표 결과 지루한 횡보장에서도 꾸준한 현금 흐름 창출 하락장 폭락 방어 + 상승장 슈퍼 대세 상승 온전히 향유

🎯 결론: 계좌를 운용하는 궁극의 지혜

내 투자금의 30~40%는 [라인업 A]에 맡겨 국내 주식 스윙으로 기계처럼 또박또박 현금을 회전시키고,
나머지 60~70%는 [라인업 B]를 적용해 미국 빅테크나 핵심 가치주를 120일선 기준선에 맞춰 안전하게 2~3년 동안 굴리는 것이 감정의 소모 없이 단순보유(존버)를 완벽하게 제압하는 프로페셔널 투자자의 완성된 포트폴리오입니다!