diff --git a/backend/app/api/endpoints/backtest.py b/backend/app/api/endpoints/backtest.py index 6ea568f..fc8b9bd 100644 --- a/backend/app/api/endpoints/backtest.py +++ b/backend/app/api/endpoints/backtest.py @@ -27,10 +27,57 @@ from app.services.backtest_engine import ( 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 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 = { + "MEAN_REVERSION_STOP_LOSS_PCT": strategy_cfg.get("STOP_LOSS_PERCENT", -6.0), + "MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0), + "MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5), + "MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0), + } + return {"name": "현재 설정", "params": params} + return _STRATEGY_PRESETS.get(preset_key) + # 전역 변수: 생성된 학습 데이터 임시 저장 _cached_training_data: Optional[Dict[str, Any]] = None @@ -65,6 +112,14 @@ class ComparePresetsRequest(BaseModel): 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, ...)") @@ -76,6 +131,17 @@ class RunUniverseBacktestRequest(BaseModel): 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") @@ -129,31 +195,13 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str logger.info(f"전체 백테스트 시작: {len(stock_codes)}종목") - # 2. 전략 파라미터 - PRESETS = { - "conservative": { - "MEAN_REVERSION_STOP_LOSS_PCT": -5.0, - "MEAN_REVERSION_TAKE_PROFIT_PCT": 8.0, - "MAX_TOTAL_POSITIONS": 3, - "MEAN_REVERSION_RSI_THRESHOLD": 25, - }, - "balanced": { - "MEAN_REVERSION_STOP_LOSS_PCT": -6.0, - "MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0, - "MAX_TOTAL_POSITIONS": 5, - "MEAN_REVERSION_RSI_THRESHOLD": 30, - }, - "aggressive": { - "MEAN_REVERSION_STOP_LOSS_PCT": -8.0, - "MEAN_REVERSION_TAKE_PROFIT_PCT": 20.0, - "MAX_TOTAL_POSITIONS": 8, - "MEAN_REVERSION_RSI_THRESHOLD": 35, - }, - } - - strategy_params = PRESETS.get(req.strategy_preset, PRESETS["balanced"]) + # 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( @@ -167,7 +215,11 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str ) 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, @@ -179,15 +231,17 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str 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]) - results.extend([r for r in batch_results if r is not None]) - logger.info(f"진행률: {len(results)}/{len(stock_codes)}") + 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) @@ -197,6 +251,7 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str 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, @@ -205,6 +260,61 @@ async def api_run_universe_backtest(req: RunUniverseBacktestRequest) -> Dict[str } +@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]: """ @@ -308,28 +418,35 @@ async def api_train_model(background_tasks: BackgroundTasks) -> Dict[str, Any]: async def api_run_backtest(req: RunBacktestRequest) -> Dict[str, Any]: """ 전략 성과 백테스트 실행 - - 과거 데이터로 전략을 실행하여 수익률, MDD, 승률 등을 계산합니다. + + 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=req.strategy_params, + 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) @@ -348,44 +465,13 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]: except ValueError: raise HTTPException(status_code=400, detail="날짜 형식 오류") - # 프리셋 정의 (frontend/src/utils/strategyPresets.ts와 동기화) - PRESETS = { - "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, - } - }, - } - results = {} - + for preset_key in req.presets: - if preset_key not in PRESETS: + preset = await _get_preset(preset_key) + if preset is None: continue - - preset = PRESETS[preset_key] + config = BacktestConfig( stock_codes=req.stock_codes, start_date=start_date, @@ -394,10 +480,10 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]: 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, @@ -407,9 +493,71 @@ async def api_compare_presets(req: ComparePresetsRequest) -> Dict[str, Any]: "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, + } diff --git a/backend/app/services/backtest_engine.py b/backend/app/services/backtest_engine.py index d74b36b..3f5d830 100644 --- a/backend/app/services/backtest_engine.py +++ b/backend/app/services/backtest_engine.py @@ -334,7 +334,7 @@ class BacktestEngine: avg_lose=0.0, max_win=0.0, max_lose=0.0, - equity_curve=[], + equity_curve=self.equity_curve, trades=[], final_positions=[] ) @@ -445,7 +445,8 @@ class BacktestEngine: prev_portfolio_value = config.initial_capital for current_date in all_dates: - current_date_obj = datetime.strptime(current_date, "%Y%m%d").date() + fmt = "%Y%m%d" if len(current_date) == 8 else "%Y-%m-%d" + current_date_obj = datetime.strptime(current_date, fmt).date() # 각 종목별 신호 생성 및 주문 실행 current_prices = {} diff --git a/backend/app/services/ml_ensemble.py b/backend/app/services/ml_ensemble.py index 195b1fb..5daee2c 100644 --- a/backend/app/services/ml_ensemble.py +++ b/backend/app/services/ml_ensemble.py @@ -111,8 +111,8 @@ class StockLSTM: self.seq_std = X_seq.std(axis=(0, 1)) + 1e-8 X_norm = (X_seq - self.seq_mean) / self.seq_std - X_tensor = torch.FloatTensor(X_norm).to(self.device) - y_tensor = torch.LongTensor(y).to(self.device) + X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device) + y_tensor = torch.LongTensor(y.copy()).to(self.device) dataset = TensorDataset(X_tensor, y_tensor) loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) @@ -157,7 +157,7 @@ class StockLSTM: self.model.eval() X_norm = (X_seq - self.seq_mean) / self.seq_std - X_tensor = torch.FloatTensor(X_norm).to(self.device) + X_tensor = torch.FloatTensor(X_norm.copy()).to(self.device) with torch.no_grad(): outputs = self.model(X_tensor) diff --git a/backend/app/services/stock_universe.py b/backend/app/services/stock_universe.py index 339e782..969a19b 100644 --- a/backend/app/services/stock_universe.py +++ b/backend/app/services/stock_universe.py @@ -274,8 +274,24 @@ async def get_all_krx_stocks_from_fdr( return stocks except ImportError: - logger.error("FinanceDataReader 미설치. pip install finance-datareader") - return [] + logger.warning("FinanceDataReader 미설치. DB 폴백 사용") + return await _get_stocks_from_db(market) except Exception as e: - logger.error(f"FDR 조회 실패: {e}") + logger.warning(f"FDR 조회 실패: {e}. DB 폴백 사용") + return await _get_stocks_from_db(market) + + +async def _get_stocks_from_db(market: Optional[str] = None) -> List[Dict[str, Any]]: + """FDR 실패 시 DB의 StockDailyPrice에서 고유 종목 코드 조회""" + try: + from sqlalchemy import distinct + async with AsyncSessionLocal() as session: + stmt = select(distinct(StockDailyPrice.stock_code)) + result = await session.execute(stmt) + codes = [row[0] for row in result.all()] + + logger.info(f"DB 폴백: {len(codes)}개 종목 조회 (market 필터 미적용)") + return [{'code': c, 'name': c, 'market': market or 'KOSPI'} for c in codes] + except Exception as e: + logger.error(f"DB 폴백 조회 실패: {e}") return [] diff --git a/backend/models/ensemble/lgbm_model.pkl b/backend/models/ensemble/lgbm_model.pkl new file mode 100644 index 0000000..620d65c Binary files /dev/null and b/backend/models/ensemble/lgbm_model.pkl differ diff --git a/backend/models/ensemble/lstm_model.pt b/backend/models/ensemble/lstm_model.pt new file mode 100644 index 0000000..45a2c29 Binary files /dev/null and b/backend/models/ensemble/lstm_model.pt differ diff --git a/backend/models/ensemble/metrics.json b/backend/models/ensemble/metrics.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/backend/models/ensemble/metrics.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/backend/models/ensemble/rf_model.pkl b/backend/models/ensemble/rf_model.pkl new file mode 100644 index 0000000..2c839fa Binary files /dev/null and b/backend/models/ensemble/rf_model.pkl differ diff --git a/backend/models/ensemble/scaler.pkl b/backend/models/ensemble/scaler.pkl new file mode 100644 index 0000000..f90a8ae Binary files /dev/null and b/backend/models/ensemble/scaler.pkl differ diff --git a/backend/requirements.txt b/backend/requirements.txt index ee159f1..04e06e8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,6 +29,7 @@ feedparser==6.0.11 joblib>=1.3.0 beautifulsoup4>=4.12.0 httpx>=0.27.1 +finance-datareader>=0.9.50 # ML Ensemble torch>=2.1.0 lightgbm>=4.1.0 diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 40261f5..57c0aad 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -20,7 +20,9 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T // ── ML 학습 데이터 생성 탭 ─────────────────────────────────────────────── function MLTrainingTab() { + const [mode, setMode] = useState<'manual' | 'universe'>('manual') const [stockCodes, setStockCodes] = useState('005930,000660,035720') + const [universePreset, setUniversePreset] = useState('large_cap') const [startDate, setStartDate] = useState('2023-01-01') const [endDate, setEndDate] = useState('2025-12-31') const [forwardDays, setForwardDays] = useState(5) @@ -28,17 +30,34 @@ function MLTrainingTab() { const [downThreshold, setDownThreshold] = useState(-3.0) const [result, setResult] = useState(null) + const { data: universePresets } = useQuery({ + queryKey: ['universe-presets'], + queryFn: async () => (await api.get('/backtest/universe-presets')).data, + }) + const generateMutation = useMutation({ mutationFn: async () => { - const res = await api.post('/backtest/generate-training-data', { - stock_codes: stockCodes.split(',').map(c => c.trim()), - start_date: startDate, - end_date: endDate, - forward_days: forwardDays, - up_threshold: upThreshold / 100, - down_threshold: downThreshold / 100, - }) - return res.data + if (mode === 'universe') { + const res = await api.post('/backtest/generate-training-data-universe', { + universe_preset: universePreset, + start_date: startDate, + end_date: endDate, + forward_days: forwardDays, + up_threshold: upThreshold / 100, + down_threshold: downThreshold / 100, + }) + return res.data + } else { + const res = await api.post('/backtest/generate-training-data', { + stock_codes: stockCodes.split(',').map(c => c.trim()), + start_date: startDate, + end_date: endDate, + forward_days: forwardDays, + up_threshold: upThreshold / 100, + down_threshold: downThreshold / 100, + }) + return res.data + } }, onSuccess: (data) => { setResult(data) @@ -71,16 +90,61 @@ function MLTrainingTab() { ML 학습 데이터 생성 설정 + + {/* 종목 선택 모드 토글 */} +
+ + +
+
- - setStockCodes(e.target.value)} - className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm" - placeholder="005930,000660,035720" - /> + {mode === 'manual' ? ( + <> + + setStockCodes(e.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm" + placeholder="005930,000660,035720" + /> + + ) : ( + <> + + + + )}
@@ -140,7 +204,11 @@ function MLTrainingTab() {
+ {result.total_stocks_requested && ( +
+

대상 종목 수

+

{result.total_stocks_requested}개

+
+ )} {Object.entries(result.label_distribution || {}).map(([label, count]: any) => (

{label}

@@ -198,49 +272,68 @@ function MLTrainingTab() { // ── 전략 성과 테스트 탭 ────────────────────────────────────────────────────── +const PRESET_CHART_CONFIG = [ + { key: 'conservative', label: '보수적', color: 'rgb(34, 197, 94)', bg: 'rgba(34, 197, 94, 0.1)' }, + { key: 'balanced', label: '균형형', color: 'rgb(59, 130, 246)', bg: 'rgba(59, 130, 246, 0.1)' }, + { key: 'aggressive', label: '공격적', color: 'rgb(239, 68, 68)', bg: 'rgba(239, 68, 68, 0.1)' }, + { key: 'current', label: '현재 설정', color: 'rgb(168, 85, 247)', bg: 'rgba(168, 85, 247, 0.1)' }, +] + function StrategyTestTab() { + const [mode, setMode] = useState<'manual' | 'universe'>('manual') const [stockCodes, setStockCodes] = useState('005930,000660,035720') + const [universePreset, setUniversePreset] = useState('large_cap') const [startDate, setStartDate] = useState('2024-01-01') const [endDate, setEndDate] = useState('2024-12-31') + const [includeCurrentSettings, setIncludeCurrentSettings] = useState(false) const [compareResults, setCompareResults] = useState(null) + const { data: universePresets } = useQuery({ + queryKey: ['universe-presets'], + queryFn: async () => (await api.get('/backtest/universe-presets')).data, + }) + const compareMutation = useMutation({ mutationFn: async () => { - const res = await api.post('/backtest/compare-presets', { - stock_codes: stockCodes.split(',').map(c => c.trim()), - start_date: startDate, - end_date: endDate, - presets: ['conservative', 'balanced', 'aggressive'], - }) - return res.data + const presets = ['conservative', 'balanced', 'aggressive', ...(includeCurrentSettings ? ['current'] : [])] + if (mode === 'universe') { + const res = await api.post('/backtest/compare-presets-universe', { + universe_preset: universePreset, + start_date: startDate, + end_date: endDate, + presets, + }) + return res.data + } else { + const res = await api.post('/backtest/compare-presets', { + stock_codes: stockCodes.split(',').map(c => c.trim()), + start_date: startDate, + end_date: endDate, + presets, + }) + return res.data + } }, onSuccess: (data) => { setCompareResults(data) }, }) + const allCurves = compareResults + ? PRESET_CHART_CONFIG.map(cfg => compareResults.presets?.[cfg.key]?.equity_curve).filter(Boolean) + : [] + const longestCurve = allCurves.reduce((a, b) => ((a?.length ?? 0) >= (b?.length ?? 0) ? a : b), []) + const chartData = compareResults ? { - labels: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.date) || [], - datasets: [ - { - label: '보수적', - data: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.return_pct * 100) || [], - borderColor: 'rgb(34, 197, 94)', - backgroundColor: 'rgba(34, 197, 94, 0.1)', - }, - { - label: '균형형', - data: compareResults.presets?.balanced?.equity_curve?.map((p: any) => p.return_pct * 100) || [], - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - }, - { - label: '공격적', - data: compareResults.presets?.aggressive?.equity_curve?.map((p: any) => p.return_pct * 100) || [], - borderColor: 'rgb(239, 68, 68)', - backgroundColor: 'rgba(239, 68, 68, 0.1)', - }, - ], + labels: longestCurve?.map((p: any) => p.date) || [], + datasets: PRESET_CHART_CONFIG + .filter(cfg => compareResults.presets?.[cfg.key]) + .map(cfg => ({ + label: cfg.label, + data: compareResults.presets[cfg.key].equity_curve?.map((p: any) => p.return_pct * 100) || [], + borderColor: cfg.color, + backgroundColor: cfg.bg, + })), } : null return ( @@ -251,15 +344,61 @@ function StrategyTestTab() { 프리셋 비교 백테스트 + + {/* 종목 선택 모드 토글 */} +
+ + +
+
- - setStockCodes(e.target.value)} - className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm" - /> + {mode === 'manual' ? ( + <> + + setStockCodes(e.target.value)} + className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm" + placeholder="005930,000660,035720" + /> + + ) : ( + <> + + + + )}
@@ -280,19 +419,39 @@ function StrategyTestTab() { />
- +
+ + +
{/* 결과 비교 */} {compareResults && ( <> + {compareResults.universe_preset && ( +
+ 유니버스: {compareResults.universe_preset} — {compareResults.stock_count}종목 대상 +
+ )}
{Object.entries(compareResults.presets || {}).map(([key, result]: any) => (
@@ -330,7 +489,7 @@ function StrategyTestTab() { {/* 차트 */} {chartData && (
-

수익 곱선 비교

+

수익 곡선 비교

보수적 +
@@ -468,6 +628,16 @@ function UniverseBacktestTab() { <>

테스트 개요

+ {result.total_tested === 0 && ( +
+ 선택한 유니버스의 종목들이 DB에 가격 데이터가 없습니다. 해당 기간 동안 시스템이 모니터링한 종목만 백테스트가 가능합니다. +
+ )} + {result.no_data_count > 0 && result.total_tested > 0 && ( +
+ DB 데이터 없음: {result.no_data_count}종목 제외됨 (실제 테스트: {result.total_tested}종목) +
+ )}

테스트 종목 수

diff --git a/frontend/src/pages/StrategySettings.tsx b/frontend/src/pages/StrategySettings.tsx index 5c23ddc..8786b87 100644 --- a/frontend/src/pages/StrategySettings.tsx +++ b/frontend/src/pages/StrategySettings.tsx @@ -52,11 +52,21 @@ const GROUP_ICONS: Record = { // 핵심 설정 파라미터 (기본 확장) const CORE_PARAMS = [ - 'MEAN_REVERSION_STOP_LOSS_PCT', - 'MEAN_REVERSION_TAKE_PROFIT_PCT', - 'MAX_TOTAL_POSITIONS', + // 리스크 + 'STOP_LOSS_PERCENT', + 'TAKE_PROFIT_PERCENT', + 'MAX_HOLDINGS_COUNT', + 'MAX_POSITION_SIZE', + 'MAX_DAILY_TRADES', + 'MDD_PAUSE_THRESHOLD', + 'MDD_FORCE_THRESHOLD', + // 전략 'MEAN_REVERSION_RSI_THRESHOLD', + 'RSI_OVERSOLD_LEVEL', + 'ATR_STOP_MULTIPLIER', + // 기능 토글 'DAY_TRADE_ENABLED', + 'ML_ENSEMBLE_ENABLED', ] // ── 프리셋 카드 컴포넌트 ──────────────────────────────────────── diff --git a/frontend/src/pages/TradeHistory.tsx b/frontend/src/pages/TradeHistory.tsx index b86391d..9a1bae0 100644 --- a/frontend/src/pages/TradeHistory.tsx +++ b/frontend/src/pages/TradeHistory.tsx @@ -1,15 +1,16 @@ import { useQuery } from '@tanstack/react-query' -import { format } from 'date-fns' +import { format, differenceInMinutes } from 'date-fns' import { ko } from 'date-fns/locale' -import { TrendingUp, TrendingDown, Clock, CheckCircle, XCircle } from 'lucide-react' +import { AlertCircle, List, BarChart2 } from 'lucide-react' import HtsPage from '../components/HtsPage' import api from '../lib/api' +import { useState, Fragment } from 'react' interface Trade { id: string created_at: string executed_at?: string - status: 'EXECUTED' | 'FAILED' | 'PENDING' | string + status: string stock_code: string stock_name?: string order_type: 'BUY' | 'SELL' @@ -26,176 +27,433 @@ interface Trade { profit_loss_rate?: number | null } -const TradeHistory = () => { - const { - data: trades = [], - isLoading, - isError, - } = useQuery({ - queryKey: ['trade-history'], - queryFn: async () => { - const response = await api.get('/trades', { - params: { limit: 100, days: 30 }, - }) - return response.data - }, - refetchInterval: 10000, - }) +// exit_reason 텍스트에서 손익 파싱 (예: "손익: 24,000원", "손익: -5,000원") +function parseProfitFromReason(exitReason: string | null | undefined): { amount: number | null; rate: number | null } { + if (!exitReason) return { amount: null, rate: null } + const amountMatch = exitReason.match(/손익:\s*([-\d,]+)원/) + const rateMatch = exitReason.match(/\(([-+]?\d+\.?\d*)%/) + return { + amount: amountMatch ? parseInt(amountMatch[1].replace(/,/g, ''), 10) : null, + rate: rateMatch ? parseFloat(rateMatch[1]) : null, + } +} - const getStatusIcon = (status: Trade['status']) => { - switch (status) { - case 'EXECUTED': - return - case 'FAILED': - return - case 'PENDING': - return - default: - return null - } +const fmtKRW = (v: number) => + new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 }).format(v) + +const fmtPrice = (v: number | null | string) => { + const n = v == null ? null : Number(v) + return n == null ? '-' : fmtKRW(n) +} + + +// ── 종목별 뷰: 매수→매도 페어 매칭 ────────────────────────────── +interface TradePair { + stockCode: string + stockName: string + buy: Trade + sell: Trade | null // null = 미청산 + holdMinutes: number | null + pl: { amount: number | null; rate: number | null } +} + +function buildPairs(trades: Trade[]): TradePair[] { + // 종목별로 분리, 시간순 정렬 + const byStock: Record = {} + for (const t of [...trades].reverse()) { // 오래된 순 + if (!byStock[t.stock_code]) byStock[t.stock_code] = [] + byStock[t.stock_code].push(t) } - const formatCurrency = (value: number | null) => { - if (value == null) { - return '시장가' - } + const pairs: TradePair[] = [] + for (const [code, list] of Object.entries(byStock)) { + const buys = list.filter(t => t.order_type === 'BUY') + const sells = list.filter(t => t.order_type === 'SELL') + const name = list[0].stock_name ?? code + const usedSells = new Set() - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: 'KRW', - maximumFractionDigits: 0, - }).format(value) + for (const buy of buys) { + // 매수 이후 가장 가까운 매도 찾기 + const sell = sells.find(s => + !usedSells.has(s.id) && + new Date(s.created_at) >= new Date(buy.created_at) + ) ?? null + + if (sell) usedSells.add(sell.id) + + const holdMinutes = sell + ? differenceInMinutes(new Date(sell.created_at), new Date(buy.created_at)) + : null + + const pl = sell + ? (sell.profit_loss != null + ? { amount: Number(sell.profit_loss), rate: sell.profit_loss_rate != null ? Number(sell.profit_loss_rate) : null } + : parseProfitFromReason(sell.exit_reason)) + : { amount: null, rate: null } + + pairs.push({ stockCode: code, stockName: name, buy, sell, holdMinutes, pl }) + } + } + // 최신 매수 기준 내림차순 + return pairs.sort((a, b) => + new Date(b.buy.created_at).getTime() - new Date(a.buy.created_at).getTime() + ) +} + +function fmtHold(minutes: number | null) { + if (minutes == null) return '보유중' + if (minutes < 60) return `${minutes}분` + if (minutes < 1440) return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분` + return `${Math.floor(minutes / 1440)}일` +} + +// ── 종목별 페어 테이블 ──────────────────────────────────────────── +function PairView({ pairs }: { pairs: TradePair[] }) { + if (!pairs.length) return
거래 이력이 없습니다.
+ + return ( + + + + + + + + + + + + + + + + {pairs.map((p) => { + const isOpen = p.sell === null + const plColor = p.pl.amount == null ? '' : p.pl.amount >= 0 ? 'text-emerald-400' : 'text-red-400' + return ( + + + + + + + + + + + + ) + })} + +
종목매수매수가매도매도가수량보유기간손익매도 이유
+
+ {p.stockName ? `${p.stockName}(${p.stockCode})` : p.stockCode} +
+
+ {format(new Date(p.buy.created_at), 'MM/dd HH:mm', { locale: ko })} + + {fmtPrice(p.buy.executed_price ?? p.buy.order_price)} + + {isOpen + ? 미청산 + : {format(new Date(p.sell!.created_at), 'MM/dd HH:mm', { locale: ko })} + } + + {isOpen ? '-' : fmtPrice(p.sell!.executed_price ?? p.sell!.order_price)} + + {(p.buy.executed_quantity ?? p.buy.order_quantity)?.toLocaleString()}주 + + {fmtHold(p.holdMinutes)} + + {!isOpen && p.pl.amount != null ? ( +
+
+ {p.pl.amount >= 0 ? '+' : ''}{fmtKRW(p.pl.amount)} +
+ {p.pl.rate != null && ( +
= 0 ? 'text-emerald-500' : 'text-red-500'}`}> + ({p.pl.rate >= 0 ? '+' : ''}{p.pl.rate.toFixed(2)}%) +
+ )} +
+ ) : ( + {isOpen ? '보유중' : '-'} + )} +
+ + {isOpen + ? (p.buy.entry_reason || p.buy.message || '-') + : (p.sell!.exit_reason || p.sell!.message || '-') + } + +
+ ) +} + +// ── 일별 총합 뷰 ───────────────────────────────────────────────── +interface DailySummary { + date: string + buyCount: number + sellCount: number + totalPnl: number + wins: number + losses: number + stocks: string[] +} + +function buildDailySummary(trades: Trade[]): DailySummary[] { + const byDate: Record = {} + for (const t of trades) { + const date = t.created_at.slice(0, 10) + if (!byDate[date]) byDate[date] = { date, buyCount: 0, sellCount: 0, totalPnl: 0, wins: 0, losses: 0, stocks: [] } + const s = byDate[date] + if (t.order_type === 'BUY') { + s.buyCount++ + } else { + s.sellCount++ + const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount + if (pl != null) { s.totalPnl += pl; if (pl > 0) s.wins++; else if (pl < 0) s.losses++ } + } + if (!s.stocks.includes(t.stock_code)) s.stocks.push(t.stock_code) + } + return Object.values(byDate).sort((a, b) => b.date.localeCompare(a.date)) +} + +function DailySummaryView({ trades }: { trades: Trade[] }) { + const [expandedDate, setExpandedDate] = useState(null) + const summaries = buildDailySummary(trades) + + if (!summaries.length) return
거래 이력이 없습니다.
+ + const getStockBreakdown = (date: string) => { + const byStock: Record = {} + for (const t of trades.filter(t => t.created_at.slice(0, 10) === date)) { + const code = t.stock_code + if (!byStock[code]) byStock[code] = { name: t.stock_name ?? code, buys: 0, sells: 0, pnl: 0, hasPnl: false } + if (t.order_type === 'BUY') { + byStock[code].buys++ + } else { + byStock[code].sells++ + const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount + if (pl != null) { byStock[code].pnl += pl; byStock[code].hasPnl = true } + } + } + return Object.entries(byStock).map(([code, v]) => ({ code, ...v })) } return ( - -
-

매매 로그

- {isLoading && 데이터 갱신 중...} + + + + + + + + + + + + + + {summaries.map(s => { + const total = s.wins + s.losses + const wr = total > 0 ? ((s.wins / total) * 100).toFixed(0) + '%' : '-' + const pnlColor = s.totalPnl > 0 ? 'text-emerald-400' : s.totalPnl < 0 ? 'text-red-400' : 'text-slate-400' + const hasPnl = s.wins > 0 || s.losses > 0 + const isExpanded = expandedDate === s.date + return ( + + + + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
날짜거래 종목 (클릭하여 상세보기)매수매도승 / 패승률실현 손익
+ {format(new Date(s.date + 'T00:00:00'), 'yyyy/MM/dd (EEE)', { locale: ko })} + setExpandedDate(isExpanded ? null : s.date)} + > + + {s.stocks.length}종목 {isExpanded ? '▲' : '▼'} + + + {s.stocks.slice(0, 5).join(', ')}{s.stocks.length > 5 ? ` 외 ${s.stocks.length - 5}` : ''} + + {s.buyCount}건{s.sellCount}건 + {s.wins}승 + / + {s.losses}패 + {wr} + {!hasPnl ? '-' : (s.totalPnl >= 0 ? '+' : '') + fmtKRW(s.totalPnl)} +
+
+ {getStockBreakdown(s.date).map(st => { + const pnlC = st.pnl > 0 ? 'text-emerald-400' : st.pnl < 0 ? 'text-red-400' : 'text-slate-500' + return ( +
+
+ {st.name !== st.code ? `${st.name}(${st.code})` : st.code} +
+
+ 매수 {st.buys}건 + · + 매도 {st.sells}건 +
+
+ {!st.hasPnl ? - : (st.pnl >= 0 ? '+' : '') + fmtKRW(st.pnl)} +
+
+ ) + })} +
+
+ ) +} + +const TradeHistory = () => { + const [viewMode, setViewMode] = useState<'log' | 'pair'>('pair') + const today = format(new Date(), 'yyyy-MM-dd') + const [dateFrom, setDateFrom] = useState(today) + const [dateTo, setDateTo] = useState(today) + + const { data: trades = [], isLoading, isError } = useQuery({ + queryKey: ['trade-history'], + queryFn: async () => (await api.get('/trades', { params: { limit: 200, days: 30 } })).data, + refetchInterval: 10000, + }) + + // 날짜 범위 필터 + const filteredTrades = trades.filter(t => { + const d = t.created_at.slice(0, 10) + if (dateFrom && d < dateFrom) return false + if (dateTo && d > dateTo) return false + return true + }) + + // 요약 통계 + const sells = filteredTrades.filter(t => t.order_type === 'SELL') + const buys = filteredTrades.filter(t => t.order_type === 'BUY') + const totalPnl = sells.reduce((acc, t) => { + const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount + return acc + (pl ?? 0) + }, 0) + const wins = sells.filter(t => { + const pl = t.profit_loss != null ? Number(t.profit_loss) : parseProfitFromReason(t.exit_reason).amount + return pl != null && pl > 0 + }).length + const winRate = sells.length > 0 ? ((wins / sells.length) * 100).toFixed(1) : '-' + + const pairs = buildPairs(filteredTrades) + + return ( + + {/* 헤더 */} +
+
+

매매 로그

+ {isLoading && 갱신 중...} +
+
+ {/* 뷰 토글 */} +
+ + +
+
{isError && ( -
- 최근 거래 내역을 불러오는 중 문제가 발생했습니다. +
+ 거래 내역을 불러오는 중 오류가 발생했습니다.
)} -
-
- - - - - - - - - - - - - - - - {trades.map((trade) => ( - - - - - - - - - - - - ))} - -
시간상태종목구분가격수량총액손익이유/신호
- {format(new Date(trade.created_at), 'MM/dd HH:mm:ss', { locale: ko })} - -
- {getStatusIcon(trade.status)} - {trade.status} -
-
-
-
{trade.stock_code}
- {trade.stock_name && ( -
{trade.stock_name}
- )} -
-
- - {trade.order_type === 'BUY' ? ( - <> - - 매수 - - ) : ( - <> - - 매도 - - )} - - - {formatCurrency(trade.executed_price || trade.order_price)} - - {(trade.executed_quantity || trade.order_quantity).toLocaleString()}주 - - {trade.total_amount != null ? formatCurrency(trade.total_amount) : '-'} - - {trade.order_type === 'SELL' && trade.profit_loss != null ? ( -
-
= 0 ? 'text-primary' : 'text-danger' - }`}> - {Number(trade.profit_loss) >= 0 ? '+' : ''}{formatCurrency(Number(trade.profit_loss))} -
- {trade.profit_loss_rate != null && ( -
= 0 ? 'text-primary' : 'text-danger' - }`}> - ({Number(trade.profit_loss_rate) >= 0 ? '+' : ''}{Number(trade.profit_loss_rate).toFixed(2)}%) -
- )} -
- ) : ( - - - )} -
- {trade.order_type === 'BUY' && trade.entry_reason && ( -
-
📈 진입 이유
-
{trade.entry_reason}
- {trade.signals && trade.signals.length > 0 && ( -
- {trade.signals.slice(0, 3).map((signal, idx) => ( - - {signal} - - ))} -
- )} -
- )} - {trade.order_type === 'SELL' && trade.exit_reason && ( -
-
📉 매도 이유
-
{trade.exit_reason}
-
- )} - {!trade.entry_reason && !trade.exit_reason && (trade.message || '-')} -
+ {/* 요약 카드 */} +
+ {[ + { label: '총 거래', value: filteredTrades.length + '건', cls: 'text-white' }, + { label: '매수', value: buys.length + '건', cls: 'text-blue-400' }, + { label: '매도', value: sells.length + '건', cls: 'text-red-400' }, + { + label: '실현 손익', + value: totalPnl === 0 ? '-' : (totalPnl > 0 ? '+' : '') + fmtKRW(totalPnl), + cls: totalPnl > 0 ? 'text-emerald-400' : totalPnl < 0 ? 'text-red-400' : 'text-slate-400', + }, + { label: '승률', value: winRate === '-' ? '-' : winRate + '%', cls: 'text-yellow-400' }, + ].map(({ label, value, cls }) => ( +
+
{label}
+
{value}
+
+ ))} +
- {!trades.length && !isLoading && ( -
-

거래 이력이 없습니다.

-
+ {/* 검색 필터 */} +
+ 기간 조회 +
+ setDateFrom(e.target.value)} + className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500" + /> + ~ + setDateTo(e.target.value)} + className="bg-slate-800 text-white text-xs rounded px-2 py-1.5 border border-slate-700 outline-none focus:border-slate-500" + /> + {(dateFrom || dateTo) && ( + + )} +
+ {(dateFrom || dateTo) && ( + + {filteredTrades.length}건 검색됨 + + )} +
+ + {/* 테이블 */} +
+
+ {viewMode === 'pair' ? ( + + ) : ( + )}