Files
auto-stock/backend/app/api/endpoints/backtest.py
shuim 1b9257c4d2 fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경
"전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라
전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다.

## 파라미터 소스 단일화
- 우선순위가 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>
2026-09-28 08:58:18 +09:00

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,
}