Reinforcement Learning — DQN & PPO
AI 에이전트가 스스로 매매 전략을 학습하는 법
이 글은 AI_Finance(ALCHEMETRIC) 프로젝트의 Phase 8B에서 추가된 강화학습 파이프라인을 다룬다. 이전 글에서 다룬 LSTM·Transformer는 "내일 오를까?"를 예측하는 지도학습이었다면, 강화학습은 "언제 사고 팔아야 이익인가?"를 에이전트가 스스로 탐색하는 완전히 다른 패러다임이다.
목차
1. 지도학습 vs 강화학습 — 근본적인 차이
ALCHEMETRIC 프로젝트의 ML/DL 모델(Random Forest, XGBoost, LSTM, Transformer)은 모두 지도학습(Supervised Learning)이다. "이 데이터의 정답은 상승(1)이야" 같은 레이블이 있고, 모델은 그 정답을 맞히도록 학습한다.
강화학습은 다르다. 정답이 없다. 대신 에이전트가 행동(BUY/SELL/HOLD)을 하면 환경이 보상(reward)을 돌려준다. 에이전트는 시행착오를 통해 보상을 최대화하는 전략을 스스로 찾아낸다.
| 구분 | 지도학습 (Phase 8A) | 강화학습 (Phase 8B) |
|---|---|---|
| 질문 | "내일 오를까?" | "언제 사고 팔까?" |
| 학습 방식 | 정답 레이블 (UP/DOWN) | 보상 기반 탐색 |
| 출력 | 확률 (UP 62%) | 행동 (BUY / SELL / HOLD) |
| 모델 | LSTM, Transformer | DQN, PPO (신경망 + RL) |
| 학습 데이터 | 과거 데이터 한 번 학습 | 환경과 반복 상호작용 |
| 비유 | 시험 문제 + 정답지로 공부 | 게임을 플레이하며 배우기 |
핵심: 지도학습이 "선생님이 정답을 알려주는 수업"이라면, 강화학습은 "게임을 직접 플레이하면서 점수를 높여가는 과정"이다.
2. 강화학습 핵심 개념
강화학습에는 4가지 핵심 구성 요소가 있다.
| 개념 | 주식 트레이딩에서의 의미 | 우리 프로젝트 |
|---|---|---|
| Agent | 매매 결정을 내리는 주체 | DQN / PPO 모델 |
| Environment | 에이전트가 상호작용하는 세계 | StockTradingEnv (Gymnasium) |
| Action | 에이전트가 할 수 있는 행동 | HOLD(0), BUY(1), SELL(2) |
| Reward | 행동의 결과에 대한 피드백 | 포트폴리오 수익률 - 수수료 - 낙폭 페널티 |
| Observation | 에이전트가 보는 정보 | 75개 피처 + cash_ratio + stock_ratio |
학습 사이클은 다음과 같이 돌아간다.
# 강화학습 사이클 (의사 코드) obs = env.reset() # 1. 환경 초기화 → 첫 관찰 while not done: action = agent.predict(obs) # 2. 관찰을 보고 행동 결정 obs, reward, done, info = env.step(action) # 3. 환경이 보상 + 다음 관찰 반환 agent.learn(obs, reward) # 4. 보상으로 전략 개선
이 사이클을 수만~수십만 번 반복하면 에이전트가 수익을 내는 매매 패턴을 학습한다.
3. Gymnasium — 환경 프레임워크
Gymnasium은 OpenAI가 만든 RL 환경 표준 라이브러리다(구 OpenAI Gym). 에이전트가 상호작용할 환경의 인터페이스를 정의한다.
모든 Gymnasium 환경은 두 가지 핵심 메서드를 구현한다.
import gymnasium as gym class MyEnv(gym.Env): def reset(self): # 에피소드 시작 → 초기 관찰 반환 return observation, info def step(self, action): # 행동 실행 → (관찰, 보상, 종료여부, truncated, info) 반환 return observation, reward, terminated, truncated, info
또한 환경은 관찰 공간(observation_space)과 액션 공간(action_space)을 정의해야 한다. 이것이 에이전트가 볼 수 있는 것과 할 수 있는 것의 범위다.
왜 Gymnasium? CartPole, Atari 같은 유명한 RL 벤치마크가 전부 Gymnasium 인터페이스를 따른다. Stable-Baselines3 같은 RL 프레임워크도 Gymnasium 호환 환경만 받기 때문에, 이 인터페이스를 따르면 어떤 RL 알고리즘이든 바로 연결할 수 있다.
4. StockTradingEnv — 우리만의 주식 환경
ALCHEMETRIC에서는 StockTradingEnv라는 커스텀 Gymnasium 환경을 만들었다. 이 환경이 주식 시장을 시뮬레이션한다.
4-1. 환경 설계
| 구성요소 | 설계 |
|---|---|
| 관찰 공간 | Box(77,) — 75개 피처 + cash_ratio + stock_ratio |
| 액션 공간 | Discrete(3) — HOLD(0), BUY(1), SELL(2) |
| 초기 자금 | 1,000만 원 |
| 수수료 | 매매 0.015% + 매도세 0.20% |
| 에피소드 종료 | 데이터 끝 도달 또는 자금 50% 이하(파산) |
4-2. 관찰(Observation) 벡터
에이전트는 매 스텝마다 77차원 벡터를 본다. feature_store의 75개 피처(기술적 지표, 재무, 뉴스 센티먼트, 거시경제 등)에 현재 포지션 정보 2개를 추가한다.
# environment.py — 관찰 벡터 구성 def _get_observation(self) -> np.ndarray: features = self.feature_array[self.current_step] # 75개 피처 # 포지션 정보 total = self.portfolio_value cash_ratio = self.cash / total # 현금 비중 stock_ratio = 1.0 - cash_ratio # 주식 비중 return np.concatenate([features, [cash_ratio, stock_ratio]])
포지션 정보를 넣는 이유는 간단하다. 이미 주식을 보유 중이면 BUY를 할 수 없고, 현금만 있으면 SELL을 할 수 없다. 에이전트가 현재 상태를 알아야 합리적인 결정을 내릴 수 있다.
4-3. 매매 실행 (step)
에이전트가 액션을 선택하면 환경이 매매를 실행한다. 전액 매수 / 전량 매도 방식으로 단순화했다.
# environment.py — step() 매매 로직 핵심 if action == BUY and self.cash > 0: max_shares = int(self.cash / (price * (1 + self.transaction_fee))) cost = max_shares * price fee = cost * self.transaction_fee self.cash -= (cost + fee) self.shares += max_shares elif action == SELL and self.shares > 0: revenue = self.shares * price fee = revenue * self.transaction_fee tax = revenue * self.tax_rate # 매도세 0.20% self.cash += (revenue - fee - tax) self.shares = 0
4-4. 보상(Reward) 설계
보상 설계는 RL에서 가장 중요한 부분이다. 잘못 설계하면 에이전트가 "항상 HOLD만 하기"나 "매일 사고 파는 단타" 같은 비합리적 전략을 학습한다.
# environment.py — 보상 함수 def _compute_reward(self, prev_value, fee_paid): # 1. 일간 포트폴리오 수익률 portfolio_return = self.portfolio_value / prev_value - 1.0 # 2. 거래비용 페널티 (빈번한 매매 억제) fee_penalty = fee_paid / prev_value # 3. 최대낙폭 페널티 (20% 초과 시) drawdown = (self.peak_value - self.portfolio_value) / self.peak_value drawdown_penalty = max(0, drawdown - 0.2) * 0.5 return portfolio_return - fee_penalty - drawdown_penalty
보상은 3가지 요소의 조합이다.
- 포트폴리오 수익률: 돈을 벌면 양(+), 잃으면 음(−)
- 거래비용 페널티: 매매할 때마다 수수료가 발생하므로, 무의미한 매매를 억제
- 낙폭 페널티: 최고점 대비 20% 이상 떨어지면 추가 페널티. 리스크 관리를 유도
파산 체크: 자금이 초기 대비 50% 이하로 떨어지면 에피소드를 즉시 종료하고 -1.0 페널티를 부여한다.
5. DQN — Deep Q-Network
DQN(Deep Q-Network)은 DeepMind가 Atari 게임을 인간 수준으로 플레이하면서 유명해진 알고리즘이다. "이 상황에서 이 행동을 하면 얼마나 가치 있을까?"를 Q-value로 추정한다.
Q-value란?
Q(s, a) = "상태 s에서 행동 a를 했을 때 앞으로 받을 총 보상의 기대값"
# DQN의 판단 과정 (개념) # 현재 관찰: RSI=65, 거래량 급증, 현금 100% Q(obs, HOLD) = 0.02 # HOLD하면 +0.02 기대 Q(obs, BUY) = 0.15 # BUY하면 +0.15 기대 ← 최고! Q(obs, SELL) = 0.00 # SELL 불가 (주식 없음) action = argmax(Q) = BUY # Q가 가장 높은 행동 선택
DQN의 핵심 요소
| 요소 | 역할 | 프로젝트 설정 |
|---|---|---|
| Replay Buffer | 과거 경험을 저장, 무작위로 꺼내 학습 | 100,000개 |
| Target Network | 안정적인 학습을 위한 별도 네트워크 | 1,000 스텝마다 업데이트 |
| Epsilon-Greedy | 탐험(랜덤) vs 활용(최적) 균형 | 10% 구간에서 2%까지 감소 |
| Gamma (γ) | 미래 보상의 할인율 | 0.99 (장기 수익 중시) |
6. PPO — Proximal Policy Optimization
PPO는 OpenAI가 개발한 알고리즘으로, DQN과는 접근 방식이 다르다. DQN이 "Q-value를 정확히 추정"하는 데 집중한다면, PPO는 정책(Policy) 자체를 직접 개선한다.
DQN vs PPO
| 구분 | DQN (Value-Based) | PPO (Policy-Based) |
|---|---|---|
| 핵심 아이디어 | Q-value가 가장 높은 행동 선택 | 행동 확률분포를 직접 학습 |
| 출력 | Q(s, HOLD)=0.02, Q(s, BUY)=0.15, ... | P(HOLD)=10%, P(BUY)=75%, P(SELL)=15% |
| 특징 | 경험 재활용 (Replay Buffer) | 안정적인 업데이트 (Clip 범위 제한) |
| 장점 | 데이터 효율적 (이전 경험 재사용) | 학습 안정적, 튜닝 쉬움 |
PPO의 Clipping 메커니즘
PPO의 핵심은 "정책을 너무 급격하게 바꾸지 않는 것"이다. 한 번 업데이트할 때 정책이 너무 많이 바뀌면 학습이 불안정해진다. PPO는 clip_range로 업데이트 폭을 제한한다.
# PPO Clipping (개념) ratio = 새정책(action) / 이전정책(action) # clip_range=0.2이면 # ratio는 0.8 ~ 1.2 범위로 제한 clipped_ratio = clip(ratio, 1 - 0.2, 1 + 0.2) # → 정책이 한 번에 최대 ±20%만 변경됨
비유: DQN은 "정답표(Q-table)를 만들어서 참고하는 방식"이고, PPO는 "직감(정책)을 조금씩 다듬어가는 방식"이다.
7. Stable-Baselines3 — RL 프레임워크
Stable-Baselines3(SB3)는 PyTorch 기반의 RL 프레임워크다. DQN, PPO, A2C 같은 RL 알고리즘을 직접 구현할 필요 없이, 몇 줄의 코드로 학습과 예측이 가능하다.
ML에서 scikit-learn이 하는 역할과 같다. 알고리즘의 내부 구현은 프레임워크가 처리하고, 개발자는 환경과 하이퍼파라미터에 집중하면 된다.
# SB3 사용법 — scikit-learn만큼 간단하다 from stable_baselines3 import DQN, PPO # 1. 모델 생성 (환경 + 정책 네트워크) model = PPO("MlpPolicy", env, learning_rate=0.0003) # 2. 학습 (환경과 10만 번 상호작용) model.learn(total_timesteps=100_000) # 3. 예측 action, _states = model.predict(obs, deterministic=True) # 4. 저장 / 로드 model.save("ppo_trading") # → ppo_trading.zip model = PPO.load("ppo_trading")
VecEnv — 종목별 병렬 환경
하나의 종목만으로 학습하면 과적합 위험이 있다. SB3의 DummyVecEnv를 사용하면 여러 종목의 환경을 동시에 돌릴 수 있다.
# rl_trainer.py — 종목별 병렬 환경 구성 from stable_baselines3.common.vec_env import DummyVecEnv def _make_vec_env(self, df, features, transaction_fee, tax_rate): codes = df["code"].unique().tolist() # 유효한 종목만 (최소 20행) + 최대 8개 제한 valid_codes = [c for c in codes if len(df[df["code"] == c]) >= 20] valid_codes = valid_codes[:8] # 메모리/속도 제한 env_fns = [_make_env(c) for c in valid_codes] return DummyVecEnv(env_fns) # 8개 종목 동시 학습
SB3 파라미터 필터링
DQN과 PPO는 받아들이는 파라미터가 다르다. ml_config.yaml에는 환경 파라미터(transaction_fee, total_timesteps 등)도 포함되어 있어, SB3에 전달할 때 유효한 키만 필터링한다.
# rl_trainer.py — SB3 파라미터 필터링 dqn_keys = {"learning_rate", "buffer_size", "batch_size", "gamma", "target_update_interval", ...} ppo_keys = {"learning_rate", "n_steps", "batch_size", "n_epochs", "gamma", "clip_range", "ent_coef", ...} allowed = dqn_keys if algorithm == "dqn" else ppo_keys sb3_params = {k: v for k, v in params.items() if k in allowed}
8. RL Optuna 튜닝
RL 모델의 하이퍼파라미터도 Optuna로 자동 튜닝한다. ML/DL의 Optuna 튜닝과 같은 원리지만, 목적 함수와 탐색 공간이 다르다.
| 구분 | ML/DL Optuna | RL Optuna |
|---|---|---|
| 목적 함수 | F1 Score 최대화 | 에피소드 수익률 최대화 |
| 학습 시간 | 데이터 한 번 학습 | 축소 timesteps (20,000) |
| 기본 trials | 50회 | 10회 (RL이 더 느려서 축소) |
DQN 탐색 공간
# rl_tuner.py — DQN 하이퍼파라미터 탐색 params["learning_rate"] = trial.suggest_float("learning_rate", 5e-5, 1e-3, log=True) params["buffer_size"] = trial.suggest_categorical("buffer_size", [50000, 100000, 200000]) params["batch_size"] = trial.suggest_categorical("batch_size", [32, 64, 128]) params["gamma"] = trial.suggest_float("gamma", 0.95, 0.999) params["target_update_interval"] = trial.suggest_categorical("target_update_interval", [500, 1000, 2000])
PPO 탐색 공간
# rl_tuner.py — PPO 하이퍼파라미터 탐색 params["learning_rate"] = trial.suggest_float("learning_rate", 5e-5, 1e-3, log=True) params["n_steps"] = trial.suggest_categorical("n_steps", [1024, 2048, 4096]) params["batch_size"] = trial.suggest_categorical("batch_size", [32, 64, 128]) params["n_epochs"] = trial.suggest_int("n_epochs", 3, 15) params["gamma"] = trial.suggest_float("gamma", 0.95, 0.999) params["clip_range"] = trial.suggest_float("clip_range", 0.1, 0.3) params["ent_coef"] = trial.suggest_float("ent_coef", 1e-3, 5e-2, log=True)
축소 학습: Optuna 각 trial에서 본 학습의 100,000 timesteps가 아닌 20,000 timesteps만 학습한다. 정확도는 떨어지지만, 파라미터 탐색 속도를 극적으로 높인다.
9. 프로젝트 적용 (Alchemetric)
9-1. 파일 구조
app/ml/ ├── trainer.py # ML 학습 진입점 (RL이면 RLTrainer로 위임) ├── predictor.py # 예측 진입점 (.zip이면 RLPredictor로 위임) ├── ml_config.yaml # DQN/PPO 기본 파라미터 + 탐색 공간 ├── ml_config_loader.py # is_reinforcement() 판별 └── reinforcement/ ├── environment.py # StockTradingEnv (Gymnasium) ├── rl_trainer.py # RL 학습 파이프라인 ├── rl_predictor.py # RL 예측 + 시그널 변환 └── rl_tuner.py # RL Optuna 튜닝
9-2. 라우팅 통합
기존 ML/DL 파이프라인과 동일한 API로 동작한다. trainer.py가 알고리즘 타입을 판별하여 적절한 학습기로 위임한다.
# trainer.py — 알고리즘 타입별 라우팅 class ModelTrainer: def train(self, market, algorithm, ...): # RL 알고리즘 → RLTrainer if is_reinforcement(algorithm): # dqn, ppo return RLTrainer().train(...) # DL 알고리즘 → DeepLearningTrainer if is_deep_learning(algorithm): # lstm, transformer return DeepLearningTrainer().train(...) # ML 알고리즘 → 그대로 실행 ... # rf, xgboost, lightgbm
# predictor.py — 모델 파일 확장자로 분기 def _run_prediction(self, ml_model, ...): if ml_model.model_path.endswith(".zip"): # RL 모델 return RLPredictor().predict_single(...) if ml_model.model_path.endswith(".pt"): # DL 모델 return DeepLearningPredictor().predict_single(...) ... # ML 모델 (.joblib)
9-3. Confidence 계산
ML/DL은 predict_proba()로 확률을 바로 얻지만, RL은 다르다. DQN과 PPO 각각 다른 방식으로 confidence를 계산한다.
# rl_predictor.py — confidence 계산 if algorithm == "ppo": # PPO: 정책 분포에서 최대 확률 추출 dist = model.policy.get_distribution(obs) probs = dist.distribution.probs # [P(HOLD), P(BUY), P(SELL)] confidence = max(probs) # 가장 높은 확률 = 확신도 elif algorithm == "dqn": # DQN: Q-value에 softmax 적용 q_values = model.q_net(obs) # [Q(HOLD), Q(BUY), Q(SELL)] probs = softmax(q_values) # 확률로 변환 confidence = max(probs)
9-4. RL 평가 지표
ML/DL은 F1, Accuracy 같은 분류 지표를 사용하지만, RL은 트레이딩 성과 지표로 평가한다.
| 지표 | 설명 |
|---|---|
| Total Return | 에피소드 종료 시 총 수익률 (평균) |
| Sharpe Ratio | 위험 대비 수익. 일간 수익률의 평균/표준편차 × √252 (연환산) |
| Max Drawdown | 최고점 대비 최대 하락폭. 리스크 관리 능력 측정 |
| Win Rate | 매매 승률 (매도가 > 매수가인 비율) |
9-5. 전체 학습~예측 흐름
# 학습 흐름 (rl_trainer.py) # 1. feature_store에서 75개 피처 + close + code 로드 df = _load_data(market="KOSPI", features=PHASE7_FEATURE_COLUMNS) # 2. 시계열 분할 (85% 학습 / 15% 검증) train_df, val_df = _split_data(df, train_ratio=0.85) # 3. NaN 처리 + 스케일링 (close는 원가 유지) imputer = SimpleImputer(strategy="median") scaler = RobustScaler() # 4. Optuna RL 튜닝 (축소 20k timesteps × 10 trials) best_params = tune_rl_hyperparameters(algorithm, train_df, features) # 5. 종목별 VecEnv 구성 → SB3 학습 (100k timesteps) env = DummyVecEnv([make_env(code) for code in valid_codes]) model = PPO("MlpPolicy", env, **sb3_params) model.learn(total_timesteps=100_000) # 6. val 데이터로 에피소드 실행 → 수익률/샤프/MDD/승률 metrics = _evaluate(model, val_df, features) # 7. 모델(.zip) + 메타(_meta.joblib) 저장 → DB 기록 model.save("ppo_rl_kospi_v1")
# 예측 흐름 (rl_predictor.py) # 1. .zip 모델 + _meta.joblib 로드 sb3_model = PPO.load("ppo_rl_kospi_v1") meta = joblib.load("ppo_rl_kospi_v1_meta.joblib") # scaler, imputer, features # 2. 최신 피처 1행 → impute → scale X = scaler.transform(imputer.transform(feature_values)) # 3. 포지션 정보 추가 (신규 = 현금 100%) obs = concat(X, [cash_ratio=1.0, stock_ratio=0.0]) # 4. 추론 → 액션 → 시그널 action = model.predict(obs) # 0=HOLD, 1=BUY, 2=SELL confidence = _compute_confidence(model, obs)
9-6. 모델 저장 형식
| 모델 타입 | 저장 형식 | 내용 |
|---|---|---|
| ML (RF, XGB, LGBM) | .joblib | model + scaler + imputer + 메타 (한 파일) |
| DL (LSTM, Transformer) | .pt | PyTorch state_dict + 메타 (한 파일) |
| RL (DQN, PPO) | .zip + _meta.joblib | SB3 모델(.zip) + scaler/imputer/features(별도) |
RL만 파일이 2개인 이유: SB3의 model.save()는 자체 .zip 포맷으로 저장하기 때문에, scaler/imputer 같은 전처리 객체는 별도의 _meta.joblib 파일에 분리 저장한다.
9-7. 요약 테이블
| 라이브러리 | 한 줄 요약 |
|---|---|
| Gymnasium | RL 환경 표준 인터페이스. StockTradingEnv의 기반 클래스 |
| Stable-Baselines3 | PyTorch 기반 RL 프레임워크. DQN/PPO 알고리즘 구현 + 학습/예측 API 제공 |
| Optuna | RL 하이퍼파라미터(learning_rate, gamma 등) 자동 탐색. 축소 timesteps로 빠른 trial |
| PyTorch | SB3 내부의 신경망 엔진. DQN의 Q-Net, PPO의 Policy-Net 모두 PyTorch 기반 |
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #30 [모듈4-6] 딥러닝과 강화학습에서 Optuna를 쓰는 법은 ML과 다르다 (0) | 2026.03.19 |
|---|---|
| #29 [모듈4-5] RL 에이전트의 행동을 결정하는 보상 함수 설계 (0) | 2026.03.19 |
| #27 [모듈4-3] CUDA, MPS, CPU - PyTorch 디바이스 감지와 혼합 정밀도 학습 (0) | 2026.03.19 |
| #26 [모듈4-2] DL Early Stopping & Overfitting (0) | 2026.03.18 |
| #25 [모듈4-1] PyTorch & Deep Learning (0) | 2026.03.18 |