diff --git a/.env.example b/.env.example
index 645a13b..1445b76 100644
--- a/.env.example
+++ b/.env.example
@@ -2,9 +2,7 @@
# Copy this file to .env and fill in the values
# Database
-DB_USER=galaxy
-DB_PASSWORD=your_secure_password_here
-DB_NAME=galaxy_po
+DATABASE_URL=postgresql://galaxy:your_secure_password_here@localhost:5432/galaxy_po
# JWT Authentication
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
diff --git a/backend/alembic/versions/add_missing_indexes.py b/backend/alembic/versions/add_missing_indexes.py
new file mode 100644
index 0000000..b1bdfff
--- /dev/null
+++ b/backend/alembic/versions/add_missing_indexes.py
@@ -0,0 +1,42 @@
+"""add missing performance indexes
+
+Revision ID: c3d4e5f6a7b8
+Revises: b7c8d9e0f1a2
+Create Date: 2026-03-19 10:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "c3d4e5f6a7b8"
+down_revision: Union[str, None] = "59807c4e84ee"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Stock universe filtering (strategy engine uses market + market_cap frequently)
+ op.create_index("idx_stocks_market", "stocks", ["market"])
+ op.create_index(
+ "idx_stocks_market_cap", "stocks", [sa.text("market_cap DESC NULLS LAST")]
+ )
+
+ # Backtest listing by user (always filtered by user_id + ordered by created_at)
+ op.create_index(
+ "idx_backtests_user_created",
+ "backtests",
+ ["user_id", sa.text("created_at DESC")],
+ )
+ op.create_index("idx_backtests_status", "backtests", ["status"])
+
+
+def downgrade() -> None:
+ op.drop_index("idx_backtests_status", table_name="backtests")
+ op.drop_index("idx_backtests_user_created", table_name="backtests")
+ op.drop_index("idx_stocks_market_cap", table_name="stocks")
+ op.drop_index("idx_stocks_market", table_name="stocks")
diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py
index 58ae1ed..6a07b39 100644
--- a/backend/app/api/backtest.py
+++ b/backend/app/api/backtest.py
@@ -1,6 +1,7 @@
"""
Backtest API endpoints.
"""
+
from typing import List
from fastapi import APIRouter, Depends, HTTPException
@@ -9,14 +10,26 @@ from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.backtest import (
- Backtest, BacktestResult, BacktestEquityCurve,
- BacktestHolding, BacktestTransaction, BacktestStatus,
+ Backtest,
+ BacktestResult,
+ BacktestEquityCurve,
+ BacktestHolding,
+ BacktestTransaction,
+ BacktestStatus,
WalkForwardResult,
)
from app.schemas.backtest import (
- BacktestCreate, BacktestResponse, BacktestListItem, BacktestMetrics,
- EquityCurvePoint, RebalanceHoldings, HoldingItem, TransactionItem,
- WalkForwardRequest, WalkForwardWindowResult, WalkForwardResponse,
+ BacktestCreate,
+ BacktestResponse,
+ BacktestListItem,
+ BacktestMetrics,
+ EquityCurvePoint,
+ RebalanceHoldings,
+ HoldingItem,
+ TransactionItem,
+ WalkForwardRequest,
+ WalkForwardWindowResult,
+ WalkForwardResponse,
)
from app.services.backtest import submit_backtest
from app.services.backtest.walkforward_engine import WalkForwardEngine
@@ -97,14 +110,15 @@ async def get_backtest(
db: Session = Depends(get_db),
):
"""Get backtest details and results."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
result_metrics = None
if backtest.result:
result_metrics = BacktestMetrics(
@@ -144,14 +158,15 @@ async def get_equity_curve(
db: Session = Depends(get_db),
):
"""Get equity curve data for chart."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
curve = (
db.query(BacktestEquityCurve)
.filter(BacktestEquityCurve.backtest_id == backtest_id)
@@ -177,14 +192,15 @@ async def get_holdings(
db: Session = Depends(get_db),
):
"""Get holdings at each rebalance date."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
holdings = (
db.query(BacktestHolding)
.filter(BacktestHolding.backtest_id == backtest_id)
@@ -197,13 +213,15 @@ async def get_holdings(
for h in holdings:
if h.rebalance_date not in grouped:
grouped[h.rebalance_date] = []
- grouped[h.rebalance_date].append(HoldingItem(
- ticker=h.ticker,
- name=h.name,
- weight=h.weight,
- shares=h.shares,
- price=h.price,
- ))
+ grouped[h.rebalance_date].append(
+ HoldingItem(
+ ticker=h.ticker,
+ name=h.name,
+ weight=h.weight,
+ shares=h.shares,
+ price=h.price,
+ )
+ )
return [
RebalanceHoldings(rebalance_date=date, holdings=items)
@@ -218,14 +236,15 @@ async def get_transactions(
db: Session = Depends(get_db),
):
"""Get all transactions."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
transactions = (
db.query(BacktestTransaction)
.filter(BacktestTransaction.backtest_id == backtest_id)
@@ -261,14 +280,15 @@ async def run_walkforward(
db: Session = Depends(get_db),
):
"""Run walk-forward analysis on a completed backtest."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
if backtest.status != BacktestStatus.COMPLETED:
raise HTTPException(
status_code=400,
@@ -296,14 +316,15 @@ async def get_walkforward(
db: Session = Depends(get_db),
):
"""Get walk-forward analysis results."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
results = (
db.query(WalkForwardResult)
.filter(WalkForwardResult.backtest_id == backtest_id)
@@ -336,14 +357,15 @@ async def delete_backtest(
db: Session = Depends(get_db),
):
"""Delete a backtest and all its data."""
- backtest = db.query(Backtest).filter(Backtest.id == backtest_id).first()
+ backtest = (
+ db.query(Backtest)
+ .filter(Backtest.id == backtest_id, Backtest.user_id == current_user.id)
+ .first()
+ )
if not backtest:
raise HTTPException(status_code=404, detail="Backtest not found")
- if backtest.user_id != current_user.id:
- raise HTTPException(status_code=403, detail="Not authorized")
-
# Delete related data
db.query(WalkForwardResult).filter(
WalkForwardResult.backtest_id == backtest_id
@@ -357,9 +379,7 @@ async def delete_backtest(
db.query(BacktestEquityCurve).filter(
BacktestEquityCurve.backtest_id == backtest_id
).delete()
- db.query(BacktestResult).filter(
- BacktestResult.backtest_id == backtest_id
- ).delete()
+ db.query(BacktestResult).filter(BacktestResult.backtest_id == backtest_id).delete()
db.delete(backtest)
db.commit()
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index f53e367..ce87aa1 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -1,6 +1,7 @@
"""
Application configuration using Pydantic Settings.
"""
+
from pydantic_settings import BaseSettings
from functools import lru_cache
@@ -11,10 +12,10 @@ class Settings(BaseSettings):
debug: bool = False
# Database
- database_url: str = "postgresql://galaxy:devpassword@localhost:5432/galaxy_po"
+ database_url: str
# JWT
- jwt_secret: str = "dev-jwt-secret-change-in-production"
+ jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 hours
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index b7d952e..696656a 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -1,18 +1,18 @@
"""
Database connection and session management.
"""
+
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import get_settings
settings = get_settings()
-engine = create_engine(
- settings.database_url,
- pool_pre_ping=True,
- pool_size=10,
- max_overflow=20,
-)
+_engine_kwargs = {"pool_pre_ping": True}
+if settings.database_url.startswith("postgresql"):
+ _engine_kwargs.update(pool_size=10, max_overflow=20)
+
+engine = create_engine(settings.database_url, **_engine_kwargs)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
diff --git a/backend/app/services/backtest/engine.py b/backend/app/services/backtest/engine.py
index 1c63547..701bc3b 100644
--- a/backend/app/services/backtest/engine.py
+++ b/backend/app/services/backtest/engine.py
@@ -1,6 +1,7 @@
"""
Main backtest engine.
"""
+
import logging
from dataclasses import dataclass, field
from datetime import date, timedelta
@@ -12,13 +13,21 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.backtest import (
- Backtest, BacktestResult, BacktestEquityCurve,
- BacktestHolding, BacktestTransaction, RebalancePeriod,
+ Backtest,
+ BacktestResult,
+ BacktestEquityCurve,
+ BacktestHolding,
+ BacktestTransaction,
+ RebalancePeriod,
)
from app.models.stock import Stock, Price
from app.services.backtest.portfolio import VirtualPortfolio, Transaction
from app.services.backtest.metrics import MetricsCalculator
-from app.services.strategy import MultiFactorStrategy, QualityStrategy, ValueMomentumStrategy
+from app.services.strategy import (
+ MultiFactorStrategy,
+ QualityStrategy,
+ ValueMomentumStrategy,
+)
from app.schemas.strategy import UniverseFilter, FactorWeights
logger = logging.getLogger(__name__)
@@ -27,6 +36,7 @@ logger = logging.getLogger(__name__)
@dataclass
class DataValidationResult:
"""Result of pre-backtest data validation."""
+
is_valid: bool = True
errors: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@@ -85,9 +95,7 @@ class BacktestEngine:
logger.warning(f"Backtest {backtest_id}: {warning}")
if not validation.is_valid:
- raise ValueError(
- "데이터 검증 실패:\n" + "\n".join(validation.errors)
- )
+ raise ValueError("데이터 검증 실패:\n" + "\n".join(validation.errors))
# Create strategy instance
strategy = self._create_strategy(
@@ -105,20 +113,24 @@ class BacktestEngine:
if initial_benchmark == 0:
initial_benchmark = Decimal("1")
+ names = self._get_stock_names()
+ all_date_prices = self._load_all_prices_by_date(
+ backtest.start_date,
+ backtest.end_date,
+ )
+
for trading_date in trading_days:
- # Get prices for this date
- prices = self._get_prices_for_date(trading_date)
- names = self._get_stock_names()
+ prices = all_date_prices.get(trading_date, {})
# Warn about holdings with missing prices
missing = [
- t for t in portfolio.holdings
+ t
+ for t in portfolio.holdings
if portfolio.holdings[t] > 0 and t not in prices
]
if missing:
logger.warning(
- f"{trading_date}: 보유 종목 가격 누락 {missing} "
- f"(0원으로 처리됨)"
+ f"{trading_date}: 보유 종목 가격 누락 {missing} (0원으로 처리됨)"
)
# Rebalance if needed
@@ -141,16 +153,16 @@ class BacktestEngine:
slippage_rate=backtest.slippage_rate,
)
- all_transactions.extend([
- (trading_date, txn) for txn in transactions
- ])
+ all_transactions.extend([(trading_date, txn) for txn in transactions])
# Record holdings
holdings = portfolio.get_holdings_with_weights(prices, names)
- holdings_history.append({
- 'date': trading_date,
- 'holdings': holdings,
- })
+ holdings_history.append(
+ {
+ "date": trading_date,
+ "holdings": holdings,
+ }
+ )
# Record daily value
portfolio_value = portfolio.get_value(prices)
@@ -161,15 +173,21 @@ class BacktestEngine:
benchmark_value / initial_benchmark * backtest.initial_capital
)
- equity_curve_data.append({
- 'date': trading_date,
- 'portfolio_value': portfolio_value,
- 'benchmark_value': normalized_benchmark,
- })
+ equity_curve_data.append(
+ {
+ "date": trading_date,
+ "portfolio_value": portfolio_value,
+ "benchmark_value": normalized_benchmark,
+ }
+ )
# Calculate metrics
- portfolio_values = [Decimal(str(e['portfolio_value'])) for e in equity_curve_data]
- benchmark_values = [Decimal(str(e['benchmark_value'])) for e in equity_curve_data]
+ portfolio_values = [
+ Decimal(str(e["portfolio_value"])) for e in equity_curve_data
+ ]
+ benchmark_values = [
+ Decimal(str(e["benchmark_value"])) for e in equity_curve_data
+ ]
metrics = MetricsCalculator.calculate_all(portfolio_values, benchmark_values)
drawdowns = MetricsCalculator.calculate_drawdown_series(portfolio_values)
@@ -221,18 +239,13 @@ class BacktestEngine:
# 2. Benchmark data coverage
benchmark_ticker = "069500" if benchmark == "KOSPI" else "069500"
- benchmark_coverage = sum(
- 1 for d in total_days if d in benchmark_prices
- )
+ benchmark_coverage = sum(1 for d in total_days if d in benchmark_prices)
benchmark_pct = (
- benchmark_coverage / num_trading_days * 100
- if num_trading_days > 0 else 0
+ benchmark_coverage / num_trading_days * 100 if num_trading_days > 0 else 0
)
if benchmark_coverage == 0:
- result.errors.append(
- f"벤치마크({benchmark_ticker}) 가격 데이터 없음"
- )
+ result.errors.append(f"벤치마크({benchmark_ticker}) 가격 데이터 없음")
result.is_valid = False
elif benchmark_pct < 90:
result.warnings.append(
@@ -254,22 +267,17 @@ class BacktestEngine:
.scalar()
)
if ticker_count == 0:
- result.errors.append(
- f"{sample_date} 가격 데이터 없음 (종목 0개)"
- )
+ result.errors.append(f"{sample_date} 가격 데이터 없음 (종목 0개)")
result.is_valid = False
elif ticker_count < 100:
- result.warnings.append(
- f"{sample_date} 종목 수 적음: {ticker_count}개"
- )
+ result.warnings.append(f"{sample_date} 종목 수 적음: {ticker_count}개")
# 4. Large gaps in trading days (> 7 calendar days excluding normal weekends)
for i in range(1, num_trading_days):
gap = (total_days[i] - total_days[i - 1]).days
if gap > 7:
result.warnings.append(
- f"거래일 갭 발견: {total_days[i-1]} ~ {total_days[i]} "
- f"({gap}일)"
+ f"거래일 갭 발견: {total_days[i - 1]} ~ {total_days[i]} ({gap}일)"
)
if result.is_valid and not result.warnings:
@@ -338,18 +346,25 @@ class BacktestEngine:
return {p.date: p.close for p in prices}
- def _get_prices_for_date(self, trading_date: date) -> Dict[str, Decimal]:
- """Get all stock prices for a specific date."""
+ def _load_all_prices_by_date(
+ self,
+ start_date: date,
+ end_date: date,
+ ) -> Dict[date, Dict[str, Decimal]]:
prices = (
self.db.query(Price)
- .filter(Price.date == trading_date)
+ .filter(Price.date >= start_date, Price.date <= end_date)
.all()
)
- return {p.ticker: p.close for p in prices}
+ result: Dict[date, Dict[str, Decimal]] = {}
+ for p in prices:
+ if p.date not in result:
+ result[p.date] = {}
+ result[p.date][p.ticker] = p.close
+ return result
def _get_stock_names(self) -> Dict[str, str]:
- """Get all stock names."""
- stocks = self.db.query(Stock).all()
+ stocks = self.db.query(Stock.ticker, Stock.name).all()
return {s.ticker: s.name for s in stocks}
def _create_strategy(
@@ -367,8 +382,12 @@ class BacktestEngine:
strategy._min_fscore = strategy_params.get("min_fscore", 7)
elif strategy_type == "value_momentum":
strategy = ValueMomentumStrategy(self.db)
- strategy._value_weight = Decimal(str(strategy_params.get("value_weight", 0.5)))
- strategy._momentum_weight = Decimal(str(strategy_params.get("momentum_weight", 0.5)))
+ strategy._value_weight = Decimal(
+ str(strategy_params.get("value_weight", 0.5))
+ )
+ strategy._momentum_weight = Decimal(
+ str(strategy_params.get("momentum_weight", 0.5))
+ )
else:
raise ValueError(f"Unknown strategy type: {strategy_type}")
@@ -401,19 +420,19 @@ class BacktestEngine:
for i, point in enumerate(equity_curve_data):
curve_point = BacktestEquityCurve(
backtest_id=backtest_id,
- date=point['date'],
- portfolio_value=point['portfolio_value'],
- benchmark_value=point['benchmark_value'],
+ date=point["date"],
+ portfolio_value=point["portfolio_value"],
+ benchmark_value=point["benchmark_value"],
drawdown=drawdowns[i] if i < len(drawdowns) else Decimal("0"),
)
self.db.add(curve_point)
# Save holdings history
for record in holdings_history:
- for holding in record['holdings']:
+ for holding in record["holdings"]:
h = BacktestHolding(
backtest_id=backtest_id,
- rebalance_date=record['date'],
+ rebalance_date=record["date"],
ticker=holding.ticker,
name=holding.name,
weight=holding.weight,
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8833992..6df81ec 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -1,10 +1,14 @@
"""
Pytest configuration and fixtures for E2E tests.
"""
+
import os
import pytest
from typing import Generator
+os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("JWT_SECRET", "test-secret-key-for-pytest-only")
+
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
diff --git a/frontend/src/app/admin/data/explorer/page.tsx b/frontend/src/app/admin/data/explorer/page.tsx
index 9558df6..09427c4 100644
--- a/frontend/src/app/admin/data/explorer/page.tsx
+++ b/frontend/src/app/admin/data/explorer/page.tsx
@@ -97,8 +97,7 @@ export default function DataExplorerPage() {
const endpoint = `/api/data/${tab}?${params}`;
const result = await api.get
+ 완료된 백테스트를 선택하여 성과를 비교하세요 (최대 3개)
+
+ 완료된 백테스트가 없습니다.
+ 차트 데이터가 없습니다백테스트 비교
+
+
+
+
+
+
+
+ {METRICS.map((metric) => (
+
+ 지표
+
+ {compareData.map((cd, idx) => (
+
+ {getCompareLabel(idx)}
+
+ ))}
+
+
+ ))}
+
+
+ {metric.label}
+
+ {compareData.map((cd) => {
+ const value = cd.detail.result
+ ? cd.detail.result[metric.key as keyof typeof cd.detail.result]
+ : null;
+ const numValue = typeof value === 'number' ? value : null;
+ const isNegativeMetric = metric.key === 'mdd';
+ const colorClass = numValue !== null
+ ? isNegativeMetric
+ ? 'text-red-600'
+ : numValue >= 0
+ ? 'text-green-600'
+ : 'text-red-600'
+ : '';
+ return (
+
+ {formatNumber(numValue)}{numValue !== null ? metric.suffix : ''}
+
+ );
+ })}
+
DC형 퇴직연금 위험자산 비율 초과 경고
++ 현재 위험자산 비율: {currentRiskRatio.toFixed(1)}% (법적 한도: 70%). + 리밸런싱 시 채권형/금 ETF 비중을 늘려 위험자산 비율을 조정하세요. +
+