feat: improve security, performance, and add missing features
- Remove hardcoded database_url/jwt_secret defaults, require env vars - Add DB indexes for stocks.market, market_cap, backtests.user_id - Optimize backtest engine: preload all prices, move stock_names out of loop - Fix backtest API auth: filter by user_id at query level (6 endpoints) - Add manual transaction entry modal on portfolio detail page - Replace console.error with toast.error in signals, backtest, data explorer - Add backtest delete button with confirmation dialog - Replace simulated sine chart with real snapshot data - Add strategy-to-portfolio apply flow with dialog - Add DC pension risk asset ratio >70% warning on rebalance page - Add backtest comparison page with metrics table and overlay chart
This commit is contained in:
parent
49bd0d8745
commit
f6db08c9bd
@ -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
|
||||
|
||||
42
backend/alembic/versions/add_missing_indexes.py
Normal file
42
backend/alembic/versions/add_missing_indexes.py
Normal file
@ -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")
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -97,8 +97,7 @@ export default function DataExplorerPage() {
|
||||
const endpoint = `/api/data/${tab}?${params}`;
|
||||
const result = await api.get<PaginatedResponse<unknown>>(endpoint);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
} catch {
|
||||
toast.error('데이터를 불러오는데 실패했습니다.');
|
||||
setData(null);
|
||||
} finally {
|
||||
@ -131,8 +130,7 @@ export default function DataExplorerPage() {
|
||||
: `/api/data/etfs/${ticker}/prices`;
|
||||
const result = await api.get<PricePoint[]>(endpoint);
|
||||
setPrices(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch prices:', err);
|
||||
} catch {
|
||||
toast.error('가격 데이터를 불러오는데 실패했습니다.');
|
||||
setPrices([]);
|
||||
} finally {
|
||||
|
||||
419
frontend/src/app/backtest/compare/page.tsx
Normal file
419
frontend/src/app/backtest/compare/page.tsx
Normal file
@ -0,0 +1,419 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Area,
|
||||
AreaChart as RechartsAreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BacktestListItem {
|
||||
id: number;
|
||||
strategy_type: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
rebalance_period: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
total_return: number | null;
|
||||
cagr: number | null;
|
||||
mdd: number | null;
|
||||
}
|
||||
|
||||
interface BacktestDetail {
|
||||
id: number;
|
||||
strategy_type: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: string;
|
||||
result: {
|
||||
total_return: number;
|
||||
cagr: number;
|
||||
mdd: number;
|
||||
sharpe_ratio: number;
|
||||
volatility: number;
|
||||
benchmark_return: number;
|
||||
excess_return: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface EquityCurvePoint {
|
||||
date: string;
|
||||
portfolio_value: number;
|
||||
benchmark_value: number;
|
||||
drawdown: number;
|
||||
}
|
||||
|
||||
interface CompareData {
|
||||
detail: BacktestDetail;
|
||||
equityCurve: EquityCurvePoint[];
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<string, string> = {
|
||||
multi_factor: '멀티 팩터',
|
||||
quality: '슈퍼 퀄리티',
|
||||
value_momentum: '밸류 모멘텀',
|
||||
kjb: '김종봉 단기매매',
|
||||
};
|
||||
|
||||
const COMPARE_COLORS = ['#3b82f6', '#ef4444', '#22c55e'];
|
||||
|
||||
const METRICS = [
|
||||
{ key: 'total_return', label: '총 수익률', suffix: '%' },
|
||||
{ key: 'cagr', label: 'CAGR', suffix: '%' },
|
||||
{ key: 'mdd', label: 'MDD', suffix: '%' },
|
||||
{ key: 'sharpe_ratio', label: '샤프 비율', suffix: '' },
|
||||
{ key: 'volatility', label: '변동성', suffix: '%' },
|
||||
{ key: 'benchmark_return', label: '벤치마크 수익률', suffix: '%' },
|
||||
{ key: 'excess_return', label: '초과 수익률', suffix: '%' },
|
||||
] as const;
|
||||
|
||||
export default function BacktestComparePage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backtests, setBacktests] = useState<BacktestListItem[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [compareData, setCompareData] = useState<CompareData[]>([]);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await api.getCurrentUser();
|
||||
const data = await api.get<BacktestListItem[]>('/api/backtest');
|
||||
setBacktests(data.filter((bt) => bt.status === 'completed'));
|
||||
} catch {
|
||||
router.push('/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [router]);
|
||||
|
||||
const toggleSelection = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else if (next.size < 3) {
|
||||
next.add(id);
|
||||
} else {
|
||||
toast.error('최대 3개까지 선택할 수 있습니다.');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCompare = async () => {
|
||||
if (selectedIds.size < 2) {
|
||||
toast.error('비교할 백테스트를 2개 이상 선택하세요.');
|
||||
return;
|
||||
}
|
||||
setComparing(true);
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const [detail, equityCurve] = await Promise.all([
|
||||
api.get<BacktestDetail>(`/api/backtest/${id}`),
|
||||
api.get<EquityCurvePoint[]>(`/api/backtest/${id}/equity-curve`),
|
||||
]);
|
||||
return { detail, equityCurve };
|
||||
})
|
||||
);
|
||||
setCompareData(results);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '비교 데이터를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setComparing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStrategyLabel = (type: string) => STRATEGY_LABELS[type] || type;
|
||||
|
||||
const formatNumber = (value: number | null | undefined, decimals: number = 2) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return value.toFixed(decimals);
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('ko-KR', {
|
||||
style: 'currency',
|
||||
currency: 'KRW',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const buildChartData = () => {
|
||||
if (compareData.length === 0) return [];
|
||||
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
|
||||
compareData.forEach((cd, idx) => {
|
||||
for (const point of cd.equityCurve) {
|
||||
const existing = dateMap.get(point.date) || {};
|
||||
existing[`value_${idx}`] = point.portfolio_value;
|
||||
dateMap.set(point.date, existing);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(dateMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, values]) => ({ date, ...values }));
|
||||
};
|
||||
|
||||
const getCompareLabel = (idx: number) => {
|
||||
const cd = compareData[idx];
|
||||
return `${getStrategyLabel(cd.detail.strategy_type)} (${cd.detail.start_date.slice(0, 4)}~${cd.detail.end_date.slice(0, 4)})`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96 rounded-xl" />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = buildChartData();
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">백테스트 비교</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
완료된 백테스트를 선택하여 성과를 비교하세요 (최대 3개)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/backtest">백테스트 목록</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>백테스트 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{backtests.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
완료된 백테스트가 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto mb-4">
|
||||
{backtests.map((bt) => (
|
||||
<label
|
||||
key={bt.id}
|
||||
className={`flex items-center gap-3 p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedIds.has(bt.id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(bt.id)}
|
||||
onChange={() => toggleSelection(bt.id)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm">
|
||||
{getStrategyLabel(bt.strategy_type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{bt.start_date} ~ {bt.end_date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<span>수익률: <span className={(bt.total_return ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}>{formatNumber(bt.total_return)}%</span></span>
|
||||
<span>CAGR: <span className={(bt.cagr ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'}>{formatNumber(bt.cagr)}%</span></span>
|
||||
<span>MDD: <span className="text-red-600">{formatNumber(bt.mdd)}%</span></span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
disabled={selectedIds.size < 2 || comparing}
|
||||
>
|
||||
{comparing ? '비교 중...' : `비교하기 (${selectedIds.size}개 선택)`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{compareData.length >= 2 && (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>성과 비교</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
지표
|
||||
</th>
|
||||
{compareData.map((cd, idx) => (
|
||||
<th
|
||||
key={cd.detail.id}
|
||||
scope="col"
|
||||
className="px-4 py-3 text-right text-sm font-medium"
|
||||
style={{ color: COMPARE_COLORS[idx] }}
|
||||
>
|
||||
{getCompareLabel(idx)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{METRICS.map((metric) => (
|
||||
<tr key={metric.key}>
|
||||
<td className="px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
{metric.label}
|
||||
</td>
|
||||
{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 (
|
||||
<td
|
||||
key={cd.detail.id}
|
||||
className={`px-4 py-3 text-sm text-right font-medium ${colorClass}`}
|
||||
>
|
||||
{formatNumber(numValue)}{numValue !== null ? metric.suffix : ''}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>자산 추이 비교</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<div style={{ height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsAreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
{compareData.map((_, idx) => (
|
||||
<linearGradient
|
||||
key={idx}
|
||||
id={`compareGradient_${idx}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="5%" stopColor={COMPARE_COLORS[idx]} stopOpacity={0.15} />
|
||||
<stop offset="95%" stopColor={COMPARE_COLORS[idx]} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => v.slice(5)}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v: number) => formatCurrency(v)}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={100}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '8px',
|
||||
color: 'hsl(var(--popover-foreground))',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
formatter={(value, name) => {
|
||||
const idx = parseInt(String(name).replace('value_', ''));
|
||||
return [formatCurrency(Number(value)), getCompareLabel(idx)];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: 'hsl(var(--foreground))' }}
|
||||
formatter={(value: string) => {
|
||||
const idx = parseInt(value.replace('value_', ''));
|
||||
return getCompareLabel(idx);
|
||||
}}
|
||||
/>
|
||||
{compareData.map((_, idx) => (
|
||||
<Area
|
||||
key={idx}
|
||||
type="monotone"
|
||||
dataKey={`value_${idx}`}
|
||||
stroke={COMPARE_COLORS[idx]}
|
||||
strokeWidth={2}
|
||||
fill={`url(#compareGradient_${idx})`}
|
||||
name={`value_${idx}`}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</RechartsAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground">차트 데이터가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -17,9 +17,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { AreaChart } from '@/components/charts/area-chart';
|
||||
import { api } from '@/lib/api';
|
||||
import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { TrendingUp, TrendingDown, Activity, Target, Calendar, Settings, Trash2, GitCompareArrows } from 'lucide-react';
|
||||
|
||||
interface BacktestResult {
|
||||
id: number;
|
||||
@ -148,7 +157,7 @@ export default function BacktestPage() {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch backtests:', err);
|
||||
toast.error(err instanceof Error ? err.message : '백테스트 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
@ -238,6 +247,34 @@ export default function BacktestPage() {
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDeleteClick = (id: number) => {
|
||||
setDeleteTargetId(id);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deleteTargetId === null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/api/backtest/${deleteTargetId}`);
|
||||
setBacktests((prev) => prev.filter((bt) => bt.id !== deleteTargetId));
|
||||
if (currentResult?.id === deleteTargetId) {
|
||||
setCurrentResult(null);
|
||||
}
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
toast.success('백테스트가 삭제되었습니다.');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '백테스트 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayResult = currentResult;
|
||||
|
||||
if (loading) {
|
||||
@ -263,13 +300,21 @@ export default function BacktestPage() {
|
||||
전략의 과거 성과를 분석하세요
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{showHistory ? '새 백테스트' : '이전 기록'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/backtest/compare">
|
||||
<GitCompareArrows className="mr-2 h-4 w-4" />
|
||||
비교
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
{showHistory ? '새 백테스트' : '이전 기록'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@ -297,6 +342,7 @@ export default function BacktestPage() {
|
||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">MDD</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">상태</th>
|
||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">생성일</th>
|
||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@ -328,11 +374,21 @@ export default function BacktestPage() {
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{new Date(bt.created_at).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(bt.id)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{backtests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
||||
아직 백테스트가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@ -771,6 +827,24 @@ export default function BacktestPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>백테스트 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
이 백테스트를 삭제하면 복구할 수 없습니다. 계속하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)} disabled={deleting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
|
||||
{deleting ? '삭제 중...' : '삭제'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -127,6 +127,7 @@ export default function PortfolioDetailPage() {
|
||||
tx_type: 'buy',
|
||||
quantity: '',
|
||||
price: '',
|
||||
executed_at: '',
|
||||
memo: '',
|
||||
});
|
||||
|
||||
@ -237,7 +238,7 @@ export default function PortfolioDetailPage() {
|
||||
};
|
||||
|
||||
const handleAddTransaction = async () => {
|
||||
if (!txForm.ticker || !txForm.quantity || !txForm.price) return;
|
||||
if (!txForm.ticker || !txForm.quantity || !txForm.price || !txForm.executed_at) return;
|
||||
setTxSubmitting(true);
|
||||
try {
|
||||
await api.post(`/api/portfolios/${portfolioId}/transactions`, {
|
||||
@ -245,11 +246,11 @@ export default function PortfolioDetailPage() {
|
||||
tx_type: txForm.tx_type,
|
||||
quantity: parseInt(txForm.quantity, 10),
|
||||
price: parseFloat(txForm.price),
|
||||
executed_at: new Date().toISOString(),
|
||||
executed_at: new Date(txForm.executed_at).toISOString(),
|
||||
memo: txForm.memo || null,
|
||||
});
|
||||
setTxModalOpen(false);
|
||||
setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', memo: '' });
|
||||
setTxForm({ ticker: '', tx_type: 'buy', quantity: '', price: '', executed_at: '', memo: '' });
|
||||
await Promise.all([fetchPortfolio(), fetchTransactions()]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '거래 추가 실패';
|
||||
@ -790,6 +791,15 @@ export default function PortfolioDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tx-executed-at">거래 일시</Label>
|
||||
<Input
|
||||
id="tx-executed-at"
|
||||
type="datetime-local"
|
||||
value={txForm.executed_at}
|
||||
onChange={(e) => setTxForm({ ...txForm, executed_at: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tx-memo">메모 (선택)</Label>
|
||||
<Input
|
||||
@ -806,7 +816,7 @@ export default function PortfolioDetailPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddTransaction}
|
||||
disabled={txSubmitting || !txForm.ticker || !txForm.quantity || !txForm.price}
|
||||
disabled={txSubmitting || !txForm.ticker || !txForm.quantity || !txForm.price || !txForm.executed_at}
|
||||
>
|
||||
{txSubmitting ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
|
||||
@ -64,6 +64,8 @@ export default function RebalancePage() {
|
||||
const [applyPrices, setApplyPrices] = useState<Record<string, string>>({});
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
const [portfolioType, setPortfolioType] = useState<string>('general');
|
||||
const [currentRiskRatio, setCurrentRiskRatio] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@ -87,16 +89,21 @@ export default function RebalancePage() {
|
||||
});
|
||||
setPrices(initialPrices);
|
||||
|
||||
// Fetch stock names from portfolio detail
|
||||
try {
|
||||
const detail = await api.get<{ holdings: { ticker: string; name: string | null }[] }>(`/api/portfolios/${portfolioId}/detail`);
|
||||
const detail = await api.get<{
|
||||
portfolio_type: string;
|
||||
risk_asset_ratio: number | null;
|
||||
holdings: { ticker: string; name: string | null }[];
|
||||
}>(`/api/portfolios/${portfolioId}/detail`);
|
||||
const names: Record<string, string> = {};
|
||||
for (const h of detail.holdings) {
|
||||
if (h.name) names[h.ticker] = h.name;
|
||||
}
|
||||
setNameMap(names);
|
||||
setPortfolioType(detail.portfolio_type);
|
||||
setCurrentRiskRatio(detail.risk_asset_ratio);
|
||||
} catch {
|
||||
// Names are optional, continue without
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
router.push('/login');
|
||||
@ -316,7 +323,19 @@ export default function RebalancePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{result && portfolioType === 'pension' && currentRiskRatio !== null && currentRiskRatio > 70 && (
|
||||
<div className="bg-amber-50 border border-amber-300 text-amber-800 dark:bg-amber-950 dark:border-amber-700 dark:text-amber-200 px-4 py-3 rounded mb-4 flex items-start gap-2">
|
||||
<span className="text-lg">⚠</span>
|
||||
<div>
|
||||
<p className="font-medium">DC형 퇴직연금 위험자산 비율 초과 경고</p>
|
||||
<p className="text-sm mt-1">
|
||||
현재 위험자산 비율: <strong>{currentRiskRatio.toFixed(1)}%</strong> (법적 한도: 70%).
|
||||
리밸런싱 시 채권형/금 ETF 비중을 늘려 위험자산 비율을 조정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
|
||||
@ -158,8 +158,7 @@ export default function SignalsPage() {
|
||||
try {
|
||||
const data = await api.get<Signal[]>('/api/signal/kjb/today');
|
||||
setTodaySignals(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch today signals:', err);
|
||||
} catch {
|
||||
toast.error('오늘의 신호를 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
@ -174,8 +173,7 @@ export default function SignalsPage() {
|
||||
const url = `/api/signal/kjb/history${query ? `?${query}` : ''}`;
|
||||
const data = await api.get<Signal[]>(url);
|
||||
setHistorySignals(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch signal history:', err);
|
||||
} catch {
|
||||
toast.error('신호 이력을 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
@ -184,8 +182,7 @@ export default function SignalsPage() {
|
||||
try {
|
||||
const data = await api.get<Portfolio[]>('/api/portfolios');
|
||||
setPortfolios(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch portfolios:', err);
|
||||
} catch {
|
||||
toast.error('포트폴리오 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
@ -254,8 +251,7 @@ export default function SignalsPage() {
|
||||
if (ps.recommended_quantity > 0) {
|
||||
setExecuteQuantity(String(ps.recommended_quantity));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch position sizing:', err);
|
||||
} catch {
|
||||
toast.error('포지션 사이징 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio';
|
||||
|
||||
interface StockFactor {
|
||||
ticker: string;
|
||||
@ -191,6 +192,9 @@ export default function KJBStrategyPage() {
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="px-4 pb-4">
|
||||
<ApplyToPortfolio stocks={result.stocks.map((s) => ({ ticker: s.ticker, name: s.name }))} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
|
||||
@ -1,9 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Portfolio {
|
||||
id: number;
|
||||
@ -21,19 +38,18 @@ interface ApplyToPortfolioProps {
|
||||
}
|
||||
|
||||
export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) {
|
||||
const router = useRouter();
|
||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string>('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await api.get<Portfolio[]>('/api/portfolios');
|
||||
setPortfolios(data);
|
||||
if (data.length > 0) setSelectedId(data[0].id);
|
||||
if (data.length > 0) setSelectedId(String(data[0].id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@ -41,10 +57,10 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const apply = async () => {
|
||||
if (!selectedId || stocks.length === 0) return;
|
||||
const handleApply = async () => {
|
||||
const portfolioId = Number(selectedId);
|
||||
if (!portfolioId || stocks.length === 0) return;
|
||||
setApplying(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ratio = parseFloat((100 / stocks.length).toFixed(2));
|
||||
const targets: TargetItem[] = stocks.map((s, i) => ({
|
||||
@ -54,12 +70,16 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) {
|
||||
: ratio,
|
||||
}));
|
||||
|
||||
await api.put(`/api/portfolios/${selectedId}/targets`, targets);
|
||||
setShowConfirm(false);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
await api.put(`/api/portfolios/${portfolioId}/targets`, targets);
|
||||
setDialogOpen(false);
|
||||
toast.success('목표 배분이 적용되었습니다.', {
|
||||
action: {
|
||||
label: '리밸런싱으로 이동',
|
||||
onClick: () => router.push(`/portfolio/${portfolioId}/rebalance`),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '적용 실패');
|
||||
toast.error(err instanceof Error ? err.message : '목표 배분 적용에 실패했습니다.');
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
@ -68,71 +88,58 @@ export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) {
|
||||
if (portfolios.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-end gap-3">
|
||||
<div>
|
||||
<Label htmlFor="portfolio-select">포트폴리오 선택</Label>
|
||||
<select
|
||||
id="portfolio-select"
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={selectedId ?? ''}
|
||||
onChange={(e) => setSelectedId(Number(e.target.value))}
|
||||
>
|
||||
{portfolios.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.portfolio_type === 'pension' ? '퇴직연금' : '일반'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={() => setShowConfirm(true)}>
|
||||
목표 배분으로 적용
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<Button className="mt-4" onClick={() => setDialogOpen(true)}>
|
||||
포트폴리오에 적용
|
||||
</Button>
|
||||
|
||||
{success && (
|
||||
<div className="mt-2 text-sm text-green-600 dark:text-green-400">
|
||||
목표 배분이 적용되었습니다.
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>목표 배분 적용</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택한 포트폴리오의 기존 목표 배분을 덮어씁니다.
|
||||
{stocks.length}개 종목이 동일 비중({(100 / stocks.length).toFixed(2)}%)으로 설정됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background rounded-lg shadow-lg max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-bold mb-2">목표 배분 적용 확인</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
선택한 포트폴리오의 기존 목표 배분을 덮어씁니다.
|
||||
{stocks.length}개 종목이 동일 비중({(100 / stocks.length).toFixed(2)}%)으로 설정됩니다.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>포트폴리오 선택</Label>
|
||||
<Select value={selectedId} onValueChange={setSelectedId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="포트폴리오를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{portfolios.map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
{p.name} ({p.portfolio_type === 'pension' ? '퇴직연금' : '일반'})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive text-destructive px-3 py-2 rounded mb-4 text-sm">
|
||||
{error}
|
||||
<div className="max-h-48 overflow-y-auto border rounded p-2">
|
||||
{stocks.map((s) => (
|
||||
<div key={s.ticker} className="text-sm py-1 flex justify-between">
|
||||
<span>{s.name || s.ticker}</span>
|
||||
<span className="text-muted-foreground">{(100 / stocks.length).toFixed(2)}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-48 overflow-y-auto mb-4 border rounded p-2">
|
||||
{stocks.map((s) => (
|
||||
<div key={s.ticker} className="text-sm py-1 flex justify-between">
|
||||
<span>{s.name || s.ticker}</span>
|
||||
<span className="text-muted-foreground">{(100 / stocks.length).toFixed(2)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowConfirm(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={apply} disabled={applying}>
|
||||
{applying ? '적용 중...' : '적용'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={applying || !selectedId}>
|
||||
{applying ? '적용 중...' : '적용'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user