652 lines
23 KiB
Python
652 lines
23 KiB
Python
"""
|
|
백테스트 엔진
|
|
|
|
3가지 주요 기능:
|
|
1. ML 학습 데이터 생성 (TensorFlow/PyTorch 학습용 라벨링)
|
|
2. 전략 성과 백테스트 (과거 수익률 시뮬레이션)
|
|
3. 파라미터 최적화 (VectorBT)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import random
|
|
from collections import defaultdict, deque
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
from decimal import Decimal
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from sqlalchemy import select, and_
|
|
|
|
from app.core.database import AsyncSessionLocal
|
|
from app.models.models import StockDailyPrice
|
|
from app.services.feature_engineering import (
|
|
db_data_to_dataframe,
|
|
extract_features,
|
|
prepare_training_data,
|
|
FEATURE_COLUMNS,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── 슬리피지 모델 (국내 주식 0.1%) ──────────────────────────────────────────────
|
|
# QA 검증 항목: 슬리피지 미반영 시 백테스트 수익률이 실전 대비 과대 추정됨
|
|
# kr_backtest_engine.py와 동일한 패턴 적용
|
|
KR_SLIPPAGE_PCT = 0.001 # +/-0.1% (국내 주식 평균 슬리피지)
|
|
|
|
|
|
def _apply_buy_slippage(price: float, seed: Optional[int] = None) -> float:
|
|
"""매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결
|
|
|
|
매수 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다
|
|
0~0.1% 높은 가격에 체결되는 현실을 반영합니다.
|
|
"""
|
|
rng = random.Random(seed)
|
|
slip = rng.uniform(0, KR_SLIPPAGE_PCT)
|
|
return price * (1 + slip)
|
|
|
|
|
|
def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float:
|
|
"""매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결
|
|
|
|
매도 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다
|
|
0~0.1% 낮은 가격에 체결되는 현실을 반영합니다.
|
|
"""
|
|
rng = random.Random(seed)
|
|
slip = rng.uniform(0, KR_SLIPPAGE_PCT)
|
|
return price * (1 - slip)
|
|
|
|
|
|
# ── 데이터 클래스 ────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class TrainingDataConfig:
|
|
"""ML 학습 데이터 생성 설정"""
|
|
stock_codes: List[str]
|
|
start_date: date
|
|
end_date: date
|
|
forward_days: int = 5 # N일 후 수익률로 라벨링
|
|
up_threshold: float = 0.03 # +3% 이상 = UP (label 2)
|
|
down_threshold: float = -0.03 # -3% 이하 = DOWN (label 0)
|
|
min_samples_per_stock: int = 50 # 종목당 최소 샘플 수
|
|
|
|
|
|
@dataclass
|
|
class BacktestConfig:
|
|
"""전략 성과 백테스트 설정"""
|
|
stock_codes: List[str]
|
|
start_date: date
|
|
end_date: date
|
|
initial_capital: float = 10_000_000
|
|
strategy_params: Dict[str, Any] = None # 프리셋 파라미터
|
|
max_positions: int = 5
|
|
position_size: float = 0.2 # 종목당 20%
|
|
commission_rate: float = 0.00015 # 매수/매도 수수료 0.015%
|
|
tax_rate: float = 0.0023 # 매도세 0.23%
|
|
|
|
|
|
@dataclass
|
|
class Trade:
|
|
"""거래 기록"""
|
|
date: date
|
|
stock_code: str
|
|
action: str # BUY | SELL
|
|
price: float
|
|
quantity: int
|
|
amount: float
|
|
commission: float
|
|
reason: str # 진입/청산 이유
|
|
|
|
|
|
@dataclass
|
|
class Position:
|
|
"""보유 포지션"""
|
|
stock_code: str
|
|
quantity: int
|
|
avg_price: float
|
|
entry_date: date
|
|
entry_reason: str
|
|
current_price: float = 0.0
|
|
unrealized_pnl: float = 0.0
|
|
unrealized_pnl_pct: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class BacktestResult:
|
|
"""백테스트 결과"""
|
|
config: Dict[str, Any]
|
|
total_trades: int
|
|
win_trades: int
|
|
lose_trades: int
|
|
win_rate: float
|
|
total_return: float
|
|
total_return_pct: float
|
|
mdd: float # Maximum Drawdown
|
|
mdd_pct: float
|
|
sharpe_ratio: float
|
|
sortino_ratio: float
|
|
profit_factor: float # 총이익 / 총손실
|
|
avg_win: float
|
|
avg_lose: float
|
|
max_win: float
|
|
max_lose: float
|
|
equity_curve: List[Dict[str, Any]] # 일별 자산 추이
|
|
trades: List[Dict[str, Any]] # 거래 내역
|
|
final_positions: List[Dict[str, Any]] # 최종 보유 종목
|
|
|
|
|
|
# ── 백테스트 엔진 ─────────────────────────────────────────────────────────────
|
|
|
|
class BacktestEngine:
|
|
"""백테스트 핵심 엔진"""
|
|
|
|
def __init__(self):
|
|
self.cash = 0.0
|
|
self.positions: Dict[str, Position] = {}
|
|
self.trades: List[Trade] = []
|
|
self.equity_curve: List[Dict[str, Any]] = []
|
|
self.daily_returns: List[float] = []
|
|
|
|
async def _fetch_daily_data(
|
|
self,
|
|
stock_codes: List[str],
|
|
start_date: date,
|
|
end_date: date
|
|
) -> Dict[str, pd.DataFrame]:
|
|
"""DB에서 일봉 데이터 조회"""
|
|
result = {}
|
|
async with AsyncSessionLocal() as session:
|
|
for code in stock_codes:
|
|
stmt = select(StockDailyPrice).where(
|
|
and_(
|
|
StockDailyPrice.stock_code == code,
|
|
StockDailyPrice.trade_date >= start_date,
|
|
StockDailyPrice.trade_date <= end_date
|
|
)
|
|
).order_by(StockDailyPrice.trade_date)
|
|
|
|
db_result = await session.execute(stmt)
|
|
rows = db_result.scalars().all()
|
|
|
|
if rows:
|
|
df = db_data_to_dataframe([{
|
|
"trade_date": r.trade_date,
|
|
"open_price": float(r.open_price),
|
|
"high_price": float(r.high_price),
|
|
"low_price": float(r.low_price),
|
|
"close_price": float(r.close_price),
|
|
"volume": float(r.volume),
|
|
} for r in rows])
|
|
result[code] = df
|
|
|
|
return result
|
|
|
|
def _calculate_commission(self, amount: float, is_sell: bool, config: BacktestConfig) -> float:
|
|
"""수수료 계산 (매수/매도 수수료 + 매도세)"""
|
|
commission = amount * config.commission_rate
|
|
if is_sell:
|
|
commission += amount * config.tax_rate
|
|
return commission
|
|
|
|
def _execute_buy(
|
|
self,
|
|
stock_code: str,
|
|
price: float,
|
|
amount: float,
|
|
current_date: date,
|
|
reason: str,
|
|
config: BacktestConfig
|
|
):
|
|
"""매수 실행 (슬리피지 적용: 실제 체결가는 신호가보다 높음)"""
|
|
# 슬리피지 적용: 매수 시 약간 높은 가격에 체결
|
|
exec_price = _apply_buy_slippage(price)
|
|
|
|
quantity = int(amount / exec_price)
|
|
if quantity == 0:
|
|
return
|
|
|
|
total_cost = exec_price * quantity
|
|
commission = self._calculate_commission(total_cost, False, config)
|
|
total_amount = total_cost + commission
|
|
|
|
if total_amount > self.cash:
|
|
return
|
|
|
|
self.cash -= total_amount
|
|
|
|
if stock_code in self.positions:
|
|
# 기존 포지션에 추가 매수
|
|
pos = self.positions[stock_code]
|
|
new_quantity = pos.quantity + quantity
|
|
new_avg_price = (pos.avg_price * pos.quantity + exec_price * quantity) / new_quantity
|
|
pos.quantity = new_quantity
|
|
pos.avg_price = new_avg_price
|
|
else:
|
|
self.positions[stock_code] = Position(
|
|
stock_code=stock_code,
|
|
quantity=quantity,
|
|
avg_price=exec_price,
|
|
entry_date=current_date,
|
|
entry_reason=reason
|
|
)
|
|
|
|
self.trades.append(Trade(
|
|
date=current_date,
|
|
stock_code=stock_code,
|
|
action="BUY",
|
|
price=exec_price,
|
|
quantity=quantity,
|
|
amount=total_cost,
|
|
commission=commission,
|
|
reason=reason
|
|
))
|
|
|
|
def _execute_sell(
|
|
self,
|
|
stock_code: str,
|
|
price: float,
|
|
current_date: date,
|
|
reason: str,
|
|
config: BacktestConfig
|
|
):
|
|
"""매도 실행 (전량, 슬리피지 적용: 실제 체결가는 신호가보다 낮음)"""
|
|
if stock_code not in self.positions:
|
|
return
|
|
|
|
# 슬리피지 적용: 매도 시 약간 낮은 가격에 체결
|
|
exec_price = _apply_sell_slippage(price)
|
|
|
|
pos = self.positions[stock_code]
|
|
total_amount = exec_price * pos.quantity
|
|
commission = self._calculate_commission(total_amount, True, config)
|
|
net_amount = total_amount - commission
|
|
|
|
self.cash += net_amount
|
|
|
|
self.trades.append(Trade(
|
|
date=current_date,
|
|
stock_code=stock_code,
|
|
action="SELL",
|
|
price=exec_price,
|
|
quantity=pos.quantity,
|
|
amount=total_amount,
|
|
commission=commission,
|
|
reason=reason
|
|
))
|
|
|
|
del self.positions[stock_code]
|
|
|
|
def _generate_signals(
|
|
self,
|
|
stock_code: str,
|
|
df: pd.DataFrame,
|
|
features_df: pd.DataFrame,
|
|
current_idx: int,
|
|
config: BacktestConfig
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""전략 신호 생성 (RSI + 볼린저밴드 평균회귀)"""
|
|
if current_idx < 60: # 충분한 데이터 필요
|
|
return None
|
|
|
|
# 현재 시점의 피처
|
|
rsi = features_df.loc[current_idx, "rsi_14"]
|
|
bb_pos = features_df.loc[current_idx, "bb_position"]
|
|
ma20_ratio = features_df.loc[current_idx, "ma20_ratio"]
|
|
|
|
if pd.isna(rsi) or pd.isna(bb_pos) or pd.isna(ma20_ratio):
|
|
return None
|
|
|
|
# 전략 파라미터 (프리셋 또는 기본값)
|
|
params = config.strategy_params or {}
|
|
rsi_threshold = params.get("MEAN_REVERSION_RSI_THRESHOLD", 30)
|
|
stop_loss = params.get("MEAN_REVERSION_STOP_LOSS_PCT", -6.0) / 100
|
|
take_profit = params.get("MEAN_REVERSION_TAKE_PROFIT_PCT", 12.0) / 100
|
|
rsi_overbought = params.get("RSI_OVERBOUGHT_LEVEL", 70.0)
|
|
|
|
current_price = df.loc[current_idx, "close"]
|
|
|
|
# 매수 신호 (과매도 + BB 하단)
|
|
if rsi < rsi_threshold and bb_pos < 0.2:
|
|
if stock_code not in self.positions:
|
|
return {
|
|
"action": "BUY",
|
|
"price": current_price,
|
|
"reason": f"RSI={rsi:.1f} BB하단"
|
|
}
|
|
|
|
# 매도 신호 (손절/익절)
|
|
if stock_code in self.positions:
|
|
pos = self.positions[stock_code]
|
|
pnl_pct = (current_price - pos.avg_price) / pos.avg_price
|
|
|
|
if pnl_pct <= stop_loss:
|
|
return {
|
|
"action": "SELL",
|
|
"price": current_price,
|
|
"reason": f"손절 {pnl_pct*100:.1f}%"
|
|
}
|
|
elif pnl_pct >= take_profit:
|
|
return {
|
|
"action": "SELL",
|
|
"price": current_price,
|
|
"reason": f"익절 {pnl_pct*100:.1f}%"
|
|
}
|
|
elif rsi > rsi_overbought and bb_pos > 0.8:
|
|
return {
|
|
"action": "SELL",
|
|
"price": current_price,
|
|
"reason": "과매수 청산"
|
|
}
|
|
|
|
return None
|
|
|
|
def _calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float:
|
|
"""현재 포트폴리오 가치 계산"""
|
|
position_value = sum(
|
|
pos.quantity * current_prices.get(code, pos.avg_price)
|
|
for code, pos in self.positions.items()
|
|
)
|
|
return self.cash + position_value
|
|
|
|
def _calculate_metrics(self, config: BacktestConfig) -> BacktestResult:
|
|
"""성과 지표 계산"""
|
|
if not self.trades:
|
|
return BacktestResult(
|
|
config=asdict(config),
|
|
total_trades=0,
|
|
win_trades=0,
|
|
lose_trades=0,
|
|
win_rate=0.0,
|
|
total_return=0.0,
|
|
total_return_pct=0.0,
|
|
mdd=0.0,
|
|
mdd_pct=0.0,
|
|
sharpe_ratio=0.0,
|
|
sortino_ratio=0.0,
|
|
profit_factor=0.0,
|
|
avg_win=0.0,
|
|
avg_lose=0.0,
|
|
max_win=0.0,
|
|
max_lose=0.0,
|
|
equity_curve=self.equity_curve,
|
|
trades=[],
|
|
final_positions=[]
|
|
)
|
|
|
|
# 거래별 수익률 계산 (FIFO 매칭: 동일 종목 복수 매수-매도 사이클 대응)
|
|
buy_queues: Dict[str, deque] = defaultdict(deque)
|
|
for t in self.trades:
|
|
if t.action == "BUY":
|
|
buy_queues[t.stock_code].append(t)
|
|
|
|
sell_trades = [t for t in self.trades if t.action == "SELL"]
|
|
|
|
trade_returns = []
|
|
for sell in sell_trades:
|
|
if buy_queues[sell.stock_code]:
|
|
buy = buy_queues[sell.stock_code].popleft()
|
|
pnl = (sell.price - buy.price) * sell.quantity - sell.commission - buy.commission
|
|
buy_cost = buy.price * buy.quantity
|
|
pnl_pct = pnl / buy_cost if buy_cost > 0 else 0.0
|
|
trade_returns.append(pnl_pct)
|
|
|
|
win_trades = [r for r in trade_returns if r > 0]
|
|
lose_trades = [r for r in trade_returns if r < 0]
|
|
|
|
# 최종 자산
|
|
final_value = self.equity_curve[-1]["value"] if self.equity_curve else config.initial_capital
|
|
total_return = final_value - config.initial_capital
|
|
total_return_pct = total_return / config.initial_capital
|
|
|
|
# MDD 계산
|
|
peak = config.initial_capital
|
|
mdd = 0.0
|
|
for point in self.equity_curve:
|
|
if point["value"] > peak:
|
|
peak = point["value"]
|
|
drawdown = point["value"] - peak
|
|
if drawdown < mdd:
|
|
mdd = drawdown
|
|
mdd_pct = mdd / peak if peak > 0 else 0.0
|
|
|
|
# Sharpe/Sortino (무위험 이자율 3% 차감 -- kr_backtest_engine.py와 일관성 유지)
|
|
if len(self.daily_returns) > 1:
|
|
rf_daily = 0.03 / 252
|
|
excess_returns = [r - rf_daily for r in self.daily_returns]
|
|
avg_excess = np.mean(excess_returns)
|
|
std_excess = np.std(excess_returns, ddof=1)
|
|
|
|
downside_excess = [min(r, 0) for r in excess_returns]
|
|
downside_var = np.mean([d ** 2 for d in downside_excess])
|
|
downside_std = np.sqrt(downside_var) if downside_var > 0 else 1e-10
|
|
|
|
sharpe = (avg_excess / std_excess) * np.sqrt(252) if std_excess > 0 else 0.0
|
|
sortino = (avg_excess / downside_std) * np.sqrt(252) if downside_std > 0 else 0.0
|
|
else:
|
|
sharpe = sortino = 0.0
|
|
|
|
# Profit Factor
|
|
total_win = sum(win_trades) if win_trades else 0.0
|
|
total_lose = abs(sum(lose_trades)) if lose_trades else 1e-10
|
|
profit_factor = total_win / total_lose if total_lose > 0 else 0.0
|
|
|
|
return BacktestResult(
|
|
config=asdict(config),
|
|
total_trades=len(sell_trades),
|
|
win_trades=len(win_trades),
|
|
lose_trades=len(lose_trades),
|
|
win_rate=len(win_trades) / len(sell_trades) if sell_trades else 0.0,
|
|
total_return=total_return,
|
|
total_return_pct=total_return_pct,
|
|
mdd=mdd,
|
|
mdd_pct=mdd_pct,
|
|
sharpe_ratio=sharpe,
|
|
sortino_ratio=sortino,
|
|
profit_factor=profit_factor,
|
|
avg_win=np.mean(win_trades) if win_trades else 0.0,
|
|
avg_lose=np.mean(lose_trades) if lose_trades else 0.0,
|
|
max_win=max(win_trades) if win_trades else 0.0,
|
|
max_lose=min(lose_trades) if lose_trades else 0.0,
|
|
equity_curve=self.equity_curve,
|
|
trades=[asdict(t) for t in self.trades],
|
|
final_positions=[asdict(p) for p in self.positions.values()]
|
|
)
|
|
|
|
async def run_backtest(self, config: BacktestConfig) -> BacktestResult:
|
|
"""전략 성과 백테스트 실행
|
|
|
|
[Look-ahead bias 수정]
|
|
T일 close로 신호 생성 -> T+1일 open으로 체결.
|
|
마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
|
|
"""
|
|
logger.info(f"백테스트 시작: {config.start_date} ~ {config.end_date}, {len(config.stock_codes)}종목")
|
|
|
|
self.cash = config.initial_capital
|
|
self.positions = {}
|
|
self.trades = []
|
|
self.equity_curve = []
|
|
self.daily_returns = []
|
|
|
|
# 데이터 로드
|
|
stock_data = await self._fetch_daily_data(
|
|
config.stock_codes,
|
|
config.start_date,
|
|
config.end_date
|
|
)
|
|
|
|
if not stock_data:
|
|
logger.warning("데이터 없음")
|
|
return self._calculate_metrics(config)
|
|
|
|
# 피처 추출
|
|
features_data = {}
|
|
for code, df in stock_data.items():
|
|
features_df = extract_features(df)
|
|
features_data[code] = features_df
|
|
|
|
# 날짜별 시뮬레이션
|
|
all_dates = sorted(set(
|
|
date_val for df in stock_data.values()
|
|
for date_val in df["date"].values
|
|
))
|
|
|
|
prev_portfolio_value = config.initial_capital
|
|
|
|
# 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
|
|
pending_signals: Dict[str, Dict[str, Any]] = {}
|
|
|
|
for date_idx, current_date in enumerate(all_dates):
|
|
fmt = "%Y%m%d" if len(current_date) == 8 else "%Y-%m-%d"
|
|
current_date_obj = datetime.strptime(current_date, fmt).date()
|
|
|
|
# 종목별 현재 봉 정보 수집 (close, open, idx)
|
|
current_prices = {}
|
|
current_bar_info: Dict[str, Dict[str, Any]] = {}
|
|
for code, df in stock_data.items():
|
|
date_mask = df["date"] == current_date
|
|
if not date_mask.any():
|
|
continue
|
|
|
|
current_idx = df.index[date_mask][0]
|
|
current_price = df.loc[current_idx, "close"]
|
|
current_prices[code] = current_price
|
|
current_bar_info[code] = {
|
|
"idx": current_idx,
|
|
"open": df.loc[current_idx, "open"],
|
|
"close": current_price,
|
|
}
|
|
|
|
# -- 1단계: 전일 신호를 오늘 open으로 체결 --
|
|
for code, signal in list(pending_signals.items()):
|
|
bar_info = current_bar_info.get(code)
|
|
if bar_info is None:
|
|
# 오늘 해당 종목 데이터 없음 -> 신호 폐기
|
|
continue
|
|
|
|
exec_price = bar_info["open"]
|
|
|
|
if signal["action"] == "BUY":
|
|
if len(self.positions) < config.max_positions and code not in self.positions:
|
|
position_amount = self.cash * config.position_size
|
|
self._execute_buy(
|
|
code, exec_price, position_amount,
|
|
current_date_obj, signal["reason"], config
|
|
)
|
|
elif signal["action"] == "SELL":
|
|
self._execute_sell(
|
|
code, exec_price, current_date_obj,
|
|
signal["reason"], config
|
|
)
|
|
pending_signals.clear()
|
|
|
|
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
|
|
is_last_bar = (date_idx == len(all_dates) - 1)
|
|
if not is_last_bar:
|
|
for code, df in stock_data.items():
|
|
if code not in current_bar_info:
|
|
continue
|
|
|
|
current_idx = current_bar_info[code]["idx"]
|
|
|
|
signal = self._generate_signals(
|
|
code, df, features_data[code], current_idx, config
|
|
)
|
|
|
|
if signal:
|
|
pending_signals[code] = signal
|
|
|
|
# 일별 자산 기록
|
|
portfolio_value = self._calculate_portfolio_value(current_prices)
|
|
daily_return = (portfolio_value - prev_portfolio_value) / prev_portfolio_value if prev_portfolio_value > 0 else 0.0
|
|
self.daily_returns.append(daily_return)
|
|
prev_portfolio_value = portfolio_value
|
|
|
|
self.equity_curve.append({
|
|
"date": current_date,
|
|
"value": portfolio_value,
|
|
"cash": self.cash,
|
|
"positions": len(self.positions),
|
|
"return_pct": (portfolio_value - config.initial_capital) / config.initial_capital
|
|
})
|
|
|
|
result = self._calculate_metrics(config)
|
|
logger.info(f"백테스트 완료: 수익률 {result.total_return_pct*100:.2f}%, 승률 {result.win_rate*100:.1f}%")
|
|
return result
|
|
|
|
|
|
# ── ML 학습 데이터 생성 ────────────────────────────────────────────────────────
|
|
|
|
async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
|
|
"""ML 학습 데이터 생성 (라벨링된 피처)
|
|
|
|
Returns:
|
|
{
|
|
"total_samples": int,
|
|
"label_distribution": {"UP": int, "HOLD": int, "DOWN": int},
|
|
"data": pd.DataFrame with columns [date, stock_code, features..., label]
|
|
}
|
|
"""
|
|
logger.info(f"ML 학습 데이터 생성 시작: {len(config.stock_codes)}종목")
|
|
|
|
engine = BacktestEngine()
|
|
stock_data = await engine._fetch_daily_data(
|
|
config.stock_codes,
|
|
config.start_date,
|
|
config.end_date
|
|
)
|
|
|
|
all_samples = []
|
|
|
|
for stock_code, df in stock_data.items():
|
|
if len(df) < config.forward_days + 60:
|
|
continue
|
|
|
|
# 피처 추출
|
|
features_df = extract_features(df)
|
|
|
|
# 라벨 생성
|
|
X_tabular, X_seq, y, valid_indices = prepare_training_data(
|
|
df, features_df,
|
|
forecast_days=config.forward_days,
|
|
up_threshold=config.up_threshold,
|
|
down_threshold=config.down_threshold
|
|
)
|
|
|
|
if len(valid_indices) < config.min_samples_per_stock:
|
|
logger.warning(f"{stock_code}: 샘플 부족 ({len(valid_indices)}개)")
|
|
continue
|
|
|
|
# DataFrame으로 변환
|
|
for i, idx in enumerate(valid_indices):
|
|
sample = {
|
|
"date": df.loc[idx, "date"],
|
|
"stock_code": stock_code,
|
|
**{col: X_tabular[i, j] for j, col in enumerate(FEATURE_COLUMNS)},
|
|
"label": int(y[i])
|
|
}
|
|
all_samples.append(sample)
|
|
|
|
result_df = pd.DataFrame(all_samples)
|
|
|
|
if len(result_df) == 0:
|
|
return {
|
|
"error": "학습 데이터 생성 실패",
|
|
"total_samples": 0,
|
|
"label_distribution": {}
|
|
}
|
|
|
|
label_dist = result_df["label"].value_counts().to_dict()
|
|
label_names = {0: "DOWN", 1: "HOLD", 2: "UP"}
|
|
|
|
logger.info(f"ML 학습 데이터 생성 완료: {len(result_df)}샘플, 분포 {label_dist}")
|
|
|
|
return {
|
|
"total_samples": len(result_df),
|
|
"label_distribution": {label_names[k]: int(v) for k, v in label_dist.items()},
|
|
"data": result_df,
|
|
"config": asdict(config)
|
|
}
|