## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
569 lines
22 KiB
Python
569 lines
22 KiB
Python
"""
|
|
백테스트 API 엔드포인트
|
|
|
|
POST /api/backtest/generate-training-data — ML 학습 데이터 생성
|
|
POST /api/backtest/run — 전략 성과 백테스트
|
|
POST /api/backtest/compare-presets — 프리셋 비교
|
|
POST /api/backtest/train-model — 생성된 데이터로 ML 모델 학습
|
|
GET /api/backtest/universe-presets — 종목 유니버스 프리셋 목록
|
|
POST /api/backtest/run-universe — 전체 종목 백테스트 (최상위 N개)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import date, datetime
|
|
from typing import Dict, List, Any, Optional
|
|
|
|
import numpy as np
|
|
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.services.backtest_engine import (
|
|
BacktestEngine,
|
|
BacktestConfig,
|
|
TrainingDataConfig,
|
|
generate_training_data,
|
|
)
|
|
from app.services.stock_universe import StockUniverse, StockFilter
|
|
from app.services.ml_ensemble import get_ensemble
|
|
from app.services.feature_engineering import FEATURE_COLUMNS
|
|
from app.services.strategy_config_service import strategy_cfg
|
|
from app.core.config import settings
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 전략 프리셋 정의 (모듈 레벨) ───────────────────────────────────────────────
|
|
|
|
_STRATEGY_PRESETS: Dict[str, Dict[str, Any]] = {
|
|
"conservative": {
|
|
"name": "보수적",
|
|
"params": {
|
|
"MEAN_REVERSION_STOP_LOSS_PCT": -5.0,
|
|
"MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0,
|
|
"MAX_TOTAL_POSITIONS": 3,
|
|
"MEAN_REVERSION_RSI_THRESHOLD": 25,
|
|
},
|
|
},
|
|
"balanced": {
|
|
"name": "균형형",
|
|
"params": {
|
|
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
|
|
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
|
|
"MAX_TOTAL_POSITIONS": 5,
|
|
"MEAN_REVERSION_RSI_THRESHOLD": 30,
|
|
},
|
|
},
|
|
"aggressive": {
|
|
"name": "공격적",
|
|
"params": {
|
|
"MEAN_REVERSION_STOP_LOSS_PCT": -8.0,
|
|
"MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0,
|
|
"MAX_TOTAL_POSITIONS": 8,
|
|
"MEAN_REVERSION_RSI_THRESHOLD": 35,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]:
|
|
"""프리셋 이름으로 파라미터 반환. 'current'이면 DB 현재 설정 로드."""
|
|
if preset_key == "current":
|
|
await strategy_cfg._refresh()
|
|
params = {
|
|
# [2026-09-27] 하드코딩 폴백 → config.py 정합 (백테스트가 실거래 기본값을 모사하도록)
|
|
# 우선순위: strategy_cfg(DB) > settings(config.py). 하드코딩 금지.
|
|
"MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -abs(settings.STOP_LOSS_PERCENT)), # 옛 폴백 -6.0
|
|
"MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", settings.TAKE_PROFIT_PERCENT), # 옛 폴백 12.0 → config 5.0
|
|
"MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", settings.MAX_HOLDINGS_COUNT), # 옛 폴백 5 → config 20
|
|
"MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", settings.MEAN_REVERSION_RSI_THRESHOLD), # 옛 폴백 30.0 → config 46.0
|
|
"RSI_OVERBOUGHT_LEVEL": strategy_cfg.get("RSI_OVERBOUGHT_LEVEL", settings.RSI_OVERBOUGHT_LEVEL), # 70.0 동일
|
|
"ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", settings.ATR_STOP_MULTIPLIER), # 2.5 동일
|
|
}
|
|
return {"name": "현재 설정", "params": params}
|
|
return _STRATEGY_PRESETS.get(preset_key)
|
|
|
|
# 전역 변수: 생성된 학습 데이터 임시 저장
|
|
_cached_training_data: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────
|
|
|
|
class GenerateTrainingDataRequest(BaseModel):
|
|
stock_codes: List[str] = Field(..., description="대상 종목 코드 목록")
|
|
start_date: str = Field(..., description="시작일 (YYYY-MM-DD)")
|
|
end_date: str = Field(..., description="종료일 (YYYY-MM-DD)")
|
|
forward_days: int = Field(5, description="N일 후 수익률로 라벨링")
|
|
up_threshold: float = Field(0.03, description="UP 레이블 임계값 (e.g., 0.03 = +3%)")
|
|
down_threshold: float = Field(-0.03, description="DOWN 레이블 임계값 (e.g., -0.03 = -3%)")
|
|
min_samples_per_stock: int = Field(50, description="종목당 최소 샘플 수")
|
|
|
|
|
|
class RunBacktestRequest(BaseModel):
|
|
stock_codes: List[str] = Field(..., description="대상 종목 코드")
|
|
start_date: str = Field(..., description="시작일 (YYYY-MM-DD)")
|
|
end_date: str = Field(..., description="종료일 (YYYY-MM-DD)")
|
|
initial_capital: float = Field(10_000_000, description="초기 자본")
|
|
strategy_params: Optional[Dict[str, Any]] = Field(None, description="전략 파라미터 (프리셋 값)")
|
|
max_positions: int = Field(5, description="최대 보유 종목 수")
|
|
position_size: float = Field(0.2, description="종목당 포지션 비율 (0.2 = 20%)")
|
|
|
|
|
|
class ComparePresetsRequest(BaseModel):
|
|
stock_codes: List[str]
|
|
start_date: str
|
|
end_date: str
|
|
initial_capital: float = 10_000_000
|
|
presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록")
|
|
|
|
|
|
class ComparePresetsUniverseRequest(BaseModel):
|
|
universe_preset: str = Field("large_cap", description="유니버스 프리셋")
|
|
start_date: str
|
|
end_date: str
|
|
initial_capital: float = 10_000_000
|
|
presets: List[str] = Field(["conservative", "balanced", "aggressive"], description="비교할 프리셋 목록")
|
|
|
|
|
|
class RunUniverseBacktestRequest(BaseModel):
|
|
"""전체 종목 백테스트 요청"""
|
|
universe_preset: str = Field("large_cap", description="유니버스 프리셋 (large_cap, kospi_all, ...)")
|
|
start_date: str
|
|
end_date: str
|
|
strategy_preset: str = Field("balanced", description="전략 프리셋")
|
|
initial_capital: float = 10_000_000
|
|
max_positions: int = 5
|
|
top_n_results: int = Field(20, description="성과 상위 N개만 반환")
|
|
|
|
|
|
class GenerateTrainingDataUniverseRequest(BaseModel):
|
|
"""전체 종목 ML 학습 데이터 생성 요청"""
|
|
universe_preset: str = Field("large_cap", description="유니버스 프리셋")
|
|
start_date: str = Field(..., description="시작일 (YYYY-MM-DD)")
|
|
end_date: str = Field(..., description="종료일 (YYYY-MM-DD)")
|
|
forward_days: int = Field(5, description="N일 후 수익률로 라벨링")
|
|
up_threshold: float = Field(0.03, description="UP 레이블 임계값")
|
|
down_threshold: float = Field(-0.03, description="DOWN 레이블 임계값")
|
|
min_samples_per_stock: int = Field(50, description="종목당 최소 샘플 수")
|
|
|
|
|
|
# ── API 엔드포인트 ─────────────────────────────────────────────────────
|
|
|
|
@router.get("/universe-presets")
|
|
async def api_get_universe_presets() -> Dict[str, Any]:
|
|
"""
|
|
사전 정의된 종목 유니버스 프리셋 목록
|
|
|
|
Returns:
|
|
{'large_cap': {'description': '...', 'filter': {...}}, ...}
|
|
"""
|
|
presets = StockUniverse.get_preset_universes()
|
|
|
|
# StockFilter를 dict로 변환
|
|
from dataclasses import asdict
|
|
result = {}
|
|
for key, preset_data in presets.items():
|
|
result[key] = {
|
|
"description": preset_data["description"],
|
|
"filter": asdict(preset_data["filter"])
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/run-universe")
|
|
async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str, Any]:
|
|
"""
|
|
전체 종목 백테스트 (병렬 실행)
|
|
|
|
선택한 유니버스의 모든 종목을 각각 백테스트하여 성과 상위 N개를 반환합니다.
|
|
|
|
예: large_cap 100종목을 모두 테스트 → 수익률 Top 20 반환
|
|
"""
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
|
|
|
# 1. 유니버스 프리셋에서 종목 리스트 가져오기
|
|
presets = StockUniverse.get_preset_universes()
|
|
if req.universe_preset not in presets:
|
|
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
|
|
|
|
# 새로운 구조: {'description': '...', 'filter': StockFilter(...)}
|
|
stock_filter = presets[req.universe_preset]["filter"]
|
|
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
|
|
|
|
if not stock_codes:
|
|
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
|
|
|
|
logger.info(f"전체 백테스트 시작: {len(stock_codes)}종목")
|
|
|
|
# 2. 전략 파라미터 ('current'이면 DB에서 로드)
|
|
preset = await _get_preset(req.strategy_preset) or await _get_preset("balanced")
|
|
strategy_params = preset["params"]
|
|
|
|
# 3. 각 종목별로 백테스트 (병렬)
|
|
no_data_count = 0
|
|
|
|
async def backtest_single(code: str) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
config = BacktestConfig(
|
|
stock_codes=[code],
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
initial_capital=req.initial_capital,
|
|
strategy_params=strategy_params,
|
|
max_positions=1, # 단일 종목
|
|
position_size=1.0,
|
|
)
|
|
engine = BacktestEngine()
|
|
result = await engine.run_backtest(config)
|
|
|
|
# DB에 해당 종목/기간 데이터 없으면 제외
|
|
if not result.equity_curve:
|
|
return None
|
|
|
|
return {
|
|
"stock_code": code,
|
|
"total_return_pct": result.total_return_pct,
|
|
"mdd_pct": result.mdd_pct,
|
|
"win_rate": result.win_rate,
|
|
"sharpe_ratio": result.sharpe_ratio,
|
|
"total_trades": result.total_trades,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"{code} 백테스트 실패: {e}")
|
|
return None
|
|
|
|
# 병렬 실행 (10개씩 배치)
|
|
results = []
|
|
batch_size = 10
|
|
for i in range(0, len(stock_codes), batch_size):
|
|
batch = stock_codes[i:i + batch_size]
|
|
batch_results = await asyncio.gather(*[backtest_single(code) for code in batch])
|
|
valid = [r for r in batch_results if r is not None]
|
|
no_data_count += len(batch) - len(valid)
|
|
results.extend(valid)
|
|
logger.info(f"진행률: {len(results)}/{len(stock_codes)} (데이터없음: {no_data_count})")
|
|
|
|
# 4. 수익률 상위 N개 정렬
|
|
results_sorted = sorted(results, key=lambda x: x["total_return_pct"], reverse=True)
|
|
top_results = results_sorted[:req.top_n_results]
|
|
|
|
logger.info(f"전체 백테스트 완료: {len(results)}종목 테스트, 상위 {len(top_results)}개 반환")
|
|
|
|
return {
|
|
"total_tested": len(results),
|
|
"no_data_count": no_data_count,
|
|
"top_performers": top_results,
|
|
"universe_preset": req.universe_preset,
|
|
"strategy_preset": req.strategy_preset,
|
|
"period": f"{req.start_date} ~ {req.end_date}",
|
|
"average_return": np.mean([r["total_return_pct"] for r in results]) if results else 0.0,
|
|
}
|
|
|
|
|
|
@router.post("/generate-training-data-universe")
|
|
async def api_generate_training_data_universe(req: GenerateTrainingDataUniverseRequest) -> Dict[str, Any]:
|
|
"""
|
|
전체 종목 ML 학습 데이터 생성 (유니버스 프리셋 기반)
|
|
|
|
유니버스에 속한 모든 종목의 과거 데이터를 병렬로 수집하여 학습 데이터를 생성합니다.
|
|
"""
|
|
global _cached_training_data
|
|
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류 (YYYY-MM-DD)")
|
|
|
|
# 유니버스 프리셋에서 종목 리스트
|
|
presets = StockUniverse.get_preset_universes()
|
|
if req.universe_preset not in presets:
|
|
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
|
|
|
|
stock_filter = presets[req.universe_preset]["filter"]
|
|
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
|
|
|
|
if not stock_codes:
|
|
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
|
|
|
|
logger.info(f"전체 종목 학습 데이터 생성 시작: {len(stock_codes)}종목")
|
|
|
|
config = TrainingDataConfig(
|
|
stock_codes=stock_codes,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
forward_days=req.forward_days,
|
|
up_threshold=req.up_threshold,
|
|
down_threshold=req.down_threshold,
|
|
min_samples_per_stock=req.min_samples_per_stock,
|
|
)
|
|
|
|
result = await generate_training_data(config)
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=500, detail=result["error"])
|
|
|
|
_cached_training_data = result
|
|
|
|
return {
|
|
"total_samples": result["total_samples"],
|
|
"label_distribution": result["label_distribution"],
|
|
"config": result["config"],
|
|
"universe_preset": req.universe_preset,
|
|
"total_stocks_requested": len(stock_codes),
|
|
"message": f"{len(stock_codes)}종목에서 {result['total_samples']}개 샘플 생성 완료. /train-model로 학습하거나 /download-training-data로 다운로드하세요.",
|
|
}
|
|
|
|
|
|
@router.post("/generate-training-data")
|
|
async def api_generate_training_data(req: GenerateTrainingDataRequest) -> Dict[str, Any]:
|
|
"""
|
|
ML 학습 데이터 생성 (TensorFlow/PyTorch 학습용)
|
|
|
|
과거 데이터를 분석하여 "N일 후 +X% 상승 = UP" 형태로 라벨링합니다.
|
|
생성된 데이터는 /train-model 엔드포인트로 학습할 수 있습니다.
|
|
"""
|
|
global _cached_training_data
|
|
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류 (YYYY-MM-DD 형식 사용)")
|
|
|
|
config = TrainingDataConfig(
|
|
stock_codes=req.stock_codes,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
forward_days=req.forward_days,
|
|
up_threshold=req.up_threshold,
|
|
down_threshold=req.down_threshold,
|
|
min_samples_per_stock=req.min_samples_per_stock,
|
|
)
|
|
|
|
result = await generate_training_data(config)
|
|
|
|
if "error" in result:
|
|
raise HTTPException(status_code=500, detail=result["error"])
|
|
|
|
# 데이터 캐싱 (CSV 다운로드용)
|
|
_cached_training_data = result
|
|
|
|
# DataFrame을 직렬화할 수 없으므로 통계만 반환
|
|
return {
|
|
"total_samples": result["total_samples"],
|
|
"label_distribution": result["label_distribution"],
|
|
"config": result["config"],
|
|
"message": "학습 데이터 생성 완료. /train-model로 학습하거나 /download-training-data로 다운로드하세요."
|
|
}
|
|
|
|
|
|
@router.get("/download-training-data")
|
|
async def api_download_training_data():
|
|
"""생성된 학습 데이터 CSV 다운로드"""
|
|
from fastapi.responses import StreamingResponse
|
|
import io
|
|
|
|
if _cached_training_data is None or "data" not in _cached_training_data:
|
|
raise HTTPException(status_code=404, detail="생성된 데이터가 없습니다. 먼저 /generate-training-data를 호출하세요.")
|
|
|
|
df = _cached_training_data["data"]
|
|
csv_buffer = io.StringIO()
|
|
df.to_csv(csv_buffer, index=False, encoding="utf-8-sig")
|
|
csv_buffer.seek(0)
|
|
|
|
return StreamingResponse(
|
|
iter([csv_buffer.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=training_data.csv"}
|
|
)
|
|
|
|
|
|
@router.post("/train-model")
|
|
async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
|
"""
|
|
생성된 학습 데이터로 ML 모델 학습 (LightGBM + LSTM + RF)
|
|
|
|
백그라운드에서 실행되며, /ml-ensemble/status로 진행 상황을 확인할 수 있습니다.
|
|
"""
|
|
global _cached_training_data
|
|
|
|
if _cached_training_data is None or "data" not in _cached_training_data:
|
|
raise HTTPException(status_code=400, detail="학습 데이터가 없습니다. 먼저 /generate-training-data를 호출하세요.")
|
|
|
|
df = _cached_training_data["data"]
|
|
|
|
# 피처와 레이블 분리
|
|
X_tabular = df[FEATURE_COLUMNS].values
|
|
y = df["label"].values
|
|
|
|
# LSTM 시퀀스는 간략화 (실제로는 시계열 데이터 필요)
|
|
X_seq = np.zeros((len(X_tabular), 60, 5)) # Placeholder
|
|
|
|
async def train_task():
|
|
ensemble = get_ensemble()
|
|
metrics = await ensemble.train_async(X_tabular, X_seq, y)
|
|
logger.info(f"ML 모델 학습 완료: {metrics}")
|
|
|
|
background_tasks.add_task(train_task)
|
|
|
|
return {
|
|
"status": "training_started",
|
|
"message": "모델 학습이 백그라운드에서 실행 중입니다. /ml-ensemble/status로 확인하세요.",
|
|
"total_samples": len(X_tabular)
|
|
}
|
|
|
|
|
|
@router.post("/run")
|
|
async def api_run_backtest(req: RunBacktestRequest) -> Dict[str, Any]:
|
|
"""
|
|
전략 성과 백테스트 실행
|
|
|
|
strategy_params가 없으면 전략 설정 페이지의 현재 DB 값을 자동 사용합니다.
|
|
"""
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
|
|
|
# strategy_params 없으면 DB 현재 설정 자동 로드
|
|
strategy_params = req.strategy_params
|
|
if strategy_params is None:
|
|
preset = await _get_preset("current")
|
|
strategy_params = preset["params"]
|
|
logger.info(f"strategy_params 미지정 → DB 현재 설정 사용: {strategy_params}")
|
|
|
|
config = BacktestConfig(
|
|
stock_codes=req.stock_codes,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
initial_capital=req.initial_capital,
|
|
strategy_params=strategy_params,
|
|
max_positions=req.max_positions,
|
|
position_size=req.position_size,
|
|
)
|
|
|
|
engine = BacktestEngine()
|
|
result = await engine.run_backtest(config)
|
|
|
|
# BacktestResult를 dict로 변환
|
|
from dataclasses import asdict
|
|
return asdict(result)
|
|
|
|
|
|
@router.post("/compare-presets")
|
|
async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]:
|
|
"""
|
|
프리셋 비교 백테스트
|
|
|
|
보수적/균형형/공격적 프리셋의 과거 성과를 비교합니다.
|
|
"""
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
|
|
|
results = {}
|
|
|
|
for preset_key in req.presets:
|
|
preset = await _get_preset(preset_key)
|
|
if preset is None:
|
|
continue
|
|
|
|
config = BacktestConfig(
|
|
stock_codes=req.stock_codes,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
initial_capital=req.initial_capital,
|
|
strategy_params=preset["params"],
|
|
max_positions=preset["params"]["MAX_TOTAL_POSITIONS"],
|
|
)
|
|
|
|
engine = BacktestEngine()
|
|
result = await engine.run_backtest(config)
|
|
|
|
results[preset_key] = {
|
|
"name": preset["name"],
|
|
"total_return_pct": result.total_return_pct,
|
|
"mdd_pct": result.mdd_pct,
|
|
"win_rate": result.win_rate,
|
|
"sharpe_ratio": result.sharpe_ratio,
|
|
"total_trades": result.total_trades,
|
|
"equity_curve": result.equity_curve,
|
|
}
|
|
|
|
return {
|
|
"presets": results,
|
|
"period": f"{req.start_date} ~ {req.end_date}",
|
|
"stock_count": len(req.stock_codes),
|
|
}
|
|
|
|
|
|
@router.post("/compare-presets-universe")
|
|
async def api_compare_presets_universe(req: ComparePresetsUniverseRequest) -> Dict[str, Any]:
|
|
"""
|
|
프리셋 비교 백테스트 (유니버스 프리셋 기반)
|
|
|
|
선택한 유니버스의 모든 종목을 대상으로 보수적/균형형/공격적 프리셋을 비교합니다.
|
|
"""
|
|
try:
|
|
start_date = datetime.strptime(req.start_date, "%Y-%m-%d").date()
|
|
end_date = datetime.strptime(req.end_date, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="날짜 형식 오류")
|
|
|
|
presets_map = StockUniverse.get_preset_universes()
|
|
if req.universe_preset not in presets_map:
|
|
raise HTTPException(status_code=400, detail=f"잘못된 유니버스 프리셋: {req.universe_preset}")
|
|
|
|
stock_filter = presets_map[req.universe_preset]["filter"]
|
|
stock_codes = await StockUniverse.get_filtered_stocks(stock_filter)
|
|
|
|
if not stock_codes:
|
|
raise HTTPException(status_code=404, detail="해당 유니버스에 종목이 없습니다")
|
|
|
|
logger.info(f"유니버스 프리셋 비교 시작: {req.universe_preset} ({len(stock_codes)}종목)")
|
|
|
|
results = {}
|
|
|
|
for preset_key in req.presets:
|
|
preset = await _get_preset(preset_key)
|
|
if preset is None:
|
|
continue
|
|
|
|
config = BacktestConfig(
|
|
stock_codes=stock_codes,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
initial_capital=req.initial_capital,
|
|
strategy_params=preset["params"],
|
|
max_positions=preset["params"]["MAX_TOTAL_POSITIONS"],
|
|
)
|
|
|
|
engine = BacktestEngine()
|
|
result = await engine.run_backtest(config)
|
|
|
|
results[preset_key] = {
|
|
"name": preset["name"],
|
|
"total_return_pct": result.total_return_pct,
|
|
"mdd_pct": result.mdd_pct,
|
|
"win_rate": result.win_rate,
|
|
"sharpe_ratio": result.sharpe_ratio,
|
|
"total_trades": result.total_trades,
|
|
"equity_curve": result.equity_curve,
|
|
}
|
|
|
|
return {
|
|
"presets": results,
|
|
"period": f"{req.start_date} ~ {req.end_date}",
|
|
"stock_count": len(stock_codes),
|
|
"universe_preset": req.universe_preset,
|
|
}
|