투자의 두 가지 무기: 기계적 퀀트 스윙 & 중장기 가치투자 기준선 매매
감정을 완전히 배제하고 오로지 국내주식으로 기계적 트레이딩을 수행하는 [라인업 A: 퀀트 스윙]과,
프로그램 없이 사용자 관점에서 종목 불문 중장기 가치주를 투자할 때 언제 들어가고 빠져나올지 기준을 세우는 [라인업 B: 가치투자 기준선]의 통합 안내서입니다.
⭐ [공통] 15세 눈높이 기초 용어 수업 — 주린이라면 이 부분부터!
💡 주식 시장을 이해하는 3가지 기본 원리
1. 이동평균선(이평선): 주가의 평균 성적표. 최근 가격에 점수를 더 많이 쳐주는 EMA(지수이평)와 똑같이 나누는 SMA(단순이평)가 있습니다.
2. 역배열: 큰형(2년선)이 맨 위에 있고 막내(반년선)가 바닥에 깔려 있는 상태. 즉 1~2년 동안 물려 있던 지하실 상태를 뜻합니다.
3. 단순보유(존버): 첫날 사서 아무것도 안 하고 눈감고 끝까지 들고 있는 방법입니다.
숫자로 보는 이평선 지도
- 5일선: 1주일 사람들의 단기 심리선
- 20일선: 1달 평균. 주가의 '생명선'
- 60일선: 3달(분기) 중기 추세 기준선
- 112일/120일선: 약 반 년(6개월) 평균선. 추세의 절대 기준선!
- 224일선 / 448일선: 1년선 및 2년선 (거대한 천장 보스)
패턴과 캔들 용어
- 양봉(빨강): 시가보다 종가가 오른 날
- 음봉(파랑): 시가보다 종가가 내린 날 (숨고르기)
- 눌림목: 높이뛰기 선수가 점프하기 전 무릎을 살짝 굽히는 순간
- 이격도: 강아지와 주인의 산책 목줄 거리 (너무 멀면 지쳐서 돌아옴)
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일선에 바짝 붙어 있는 안전한 눌림목 구간 포착 |
2. 계좌 리스크 사이징 및 기계적 다단계 청산 룰
0.5% 계좌 리스크 포지션 사이징
한 번의 매매에서 계좌 전체가 감당할 최대 손실을 0.5% (risk_per_trade = 0.005)로 엄격히 고정합니다.
주식수 = floor( (계좌자산 * 0.005) / (진입가 * 0.035) )
※ 단일 종목 최대 비중 20% 한도와 가용 현금을 동시에 체크하여 안전한 정수 주수만 매수합니다.
청산 우선순위 상태머신
[장중 실시간 감시]
- -3.5% 절대 손절: 현재가 ≤ 진입가 * 0.965 즉시 전량 매도 (갭하락 시 시가 청산)
- TP1 익절: 현재가 ≥ EMA224 * 0.995 (최초수량 40% 시장가 매도)
- TP2 익절: 현재가 ≥ EMA448 * 0.995 (최초수량 30% 시장가 매도)
[EOD 확정봉 감시 (D+1 시가 체결)]
- EMA112/SMA112 하향 이탈 → 전량 청산
- EMA20 하향 이탈 → 전량 청산
- 수익률 ≥ 3% 상태에서 EMA5 하향 이탈 → 잔여 50% 트레일링 익절
- 8영업일 경과 및 MFE < 3% → Time Cut 전량 청산
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원까지의 추가 급락 피해를 완벽히 차단함. |
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)
5. 가치투자자가 마주하는 딜레마: "존버(단순보유)는 왜 위험할까?"
이것은 퀀트나 프로그램 매매가 아닙니다! 사용자가 직접 종목을 분석하고 "이 기업은 앞으로 2~3년 동안 세상을 바꿀 엄청난 가치가 있다"고 판단하여 중장기 가치투자를 할 때, 맹목적으로 무식하게 존버하다가 -50% 폭락을 맞고 쓰러지지 않도록 "어느 선에서 빠져나오고, 어느 타이밍에 다시 탈 것인가?" 기준선을 세워주는 사람 중심의 가이드입니다.
강세장의 착시현상
2024~2026년처럼 엔비디아나 하이닉스가 10배씩 오르는 초강세장에서는, 아무 생각 없이 그냥 쥐고 있던 존버가 최고의 수익률을 냅니다.
이때 사람들은 "가치투자는 그냥 사서 잊어버리는 거야!"라는 위험한 착각에 빠집니다.
2022년 하락장의 잔혹한 진실
하지만 2022년 같은 대세 하락장이 오면 어떻게 될까요?
아무리 위대한 1등 가치주라도 주가가 -50%~-70% 반토막 납니다.
원금 회복에만 3년이 걸리고, 대부분의 투자자는 바닥에서 공포에 질려 패닉셀(손절)하고 시장을 떠납니다.
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년 동안 굴리는 것이
감정의 소모 없이 단순보유(존버)를 완벽하게 제압하는 프로페셔널 투자자의 완성된 포트폴리오입니다!