머니페니 12d235a1f1 feat: add 9 new modules - notification alerts, trading journal, position sizing, pension allocation, drawdown monitoring, benchmark dashboard, tax simulation, correlation analysis, parameter optimizer
Phase 1:
- Real-time signal alerts (Discord/Telegram webhook)
- Trading journal with entry/exit tracking
- Position sizing calculator (Fixed/Kelly/ATR)

Phase 2:
- Pension asset allocation (DC/IRP 70% risk limit)
- Drawdown monitoring with SVG gauge
- Benchmark dashboard (portfolio vs KOSPI vs deposit)

Phase 3:
- Tax benefit simulation (Korean pension tax rules)
- Correlation matrix heatmap
- Parameter optimizer with grid search + overfit detection
2026-03-29 10:03:08 +09:00

278 lines
8.8 KiB
Python

"""
Benchmark comparison service.
Compares portfolio performance against KOSPI index and deposit rate.
"""
import logging
import math
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Optional
from pykrx import stock as pykrx_stock
from sqlalchemy.orm import Session
from app.models.portfolio import Portfolio, PortfolioSnapshot
from app.schemas.benchmark import (
BenchmarkCompareResponse,
PerformanceMetrics,
TimeSeriesPoint,
)
logger = logging.getLogger(__name__)
KOSPI_INDEX_CODE = "1001"
DEPOSIT_ANNUAL_RATE = 3.5
RISK_FREE_RATE = 3.5
class BenchmarkService:
def __init__(self, db: Session):
self.db = db
def get_deposit_rate(self) -> float:
return DEPOSIT_ANNUAL_RATE
def get_benchmark_data(
self, benchmark_type: str, start_date: date, end_date: date
) -> List[dict]:
start_str = start_date.strftime("%Y%m%d")
end_str = end_date.strftime("%Y%m%d")
if benchmark_type == "kospi":
df = pykrx_stock.get_index_ohlcv(start_str, end_str, KOSPI_INDEX_CODE)
else:
return []
if df.empty:
return []
result = []
for idx, row in df.iterrows():
result.append({
"date": idx.date() if hasattr(idx, "date") else idx,
"close": row["종가"],
})
return result
def compare_with_benchmark(
self,
portfolio_id: int,
benchmark_type: str,
period: str,
user_id: int,
) -> BenchmarkCompareResponse:
portfolio = (
self.db.query(Portfolio)
.filter(Portfolio.id == portfolio_id, Portfolio.user_id == user_id)
.first()
)
if not portfolio:
raise ValueError("Portfolio not found")
snapshots = (
self.db.query(PortfolioSnapshot)
.filter(PortfolioSnapshot.portfolio_id == portfolio_id)
.order_by(PortfolioSnapshot.snapshot_date)
.all()
)
if not snapshots:
raise ValueError("스냅샷 데이터가 없습니다")
end_date = snapshots[-1].snapshot_date
start_date = self._calc_start_date(period, snapshots[0].snapshot_date, end_date)
filtered = [s for s in snapshots if s.snapshot_date >= start_date]
if len(filtered) < 2:
filtered = snapshots
start_date = filtered[0].snapshot_date
num_days = (end_date - start_date).days
# Portfolio daily returns
portfolio_values = [float(s.total_value) for s in filtered]
portfolio_returns = self._values_to_returns(portfolio_values)
# Benchmark data
benchmark_data = self.get_benchmark_data(benchmark_type, start_date, end_date)
benchmark_closes = [d["close"] for d in benchmark_data]
benchmark_returns = self._values_to_returns(benchmark_closes)
# Deposit returns (daily)
daily_deposit_rate = (1 + DEPOSIT_ANNUAL_RATE / 100) ** (1 / 365) - 1
deposit_returns = [daily_deposit_rate] * max(num_days, 0)
# Cumulative return time series
time_series = self._build_time_series(
filtered, benchmark_data, start_date, end_date
)
# Metrics
portfolio_metrics = self._calculate_metrics(portfolio_returns, num_days)
benchmark_metrics = self._calculate_metrics(benchmark_returns, num_days)
deposit_metrics = self._calculate_metrics(deposit_returns, num_days)
alpha = portfolio_metrics.cumulative_return - benchmark_metrics.cumulative_return
info_ratio = self._calculate_information_ratio(
portfolio_returns, benchmark_returns
)
return BenchmarkCompareResponse(
portfolio_name=portfolio.name,
benchmark_type=benchmark_type,
period=period,
start_date=start_date,
end_date=end_date,
time_series=time_series,
portfolio_metrics=portfolio_metrics,
benchmark_metrics=benchmark_metrics,
deposit_metrics=deposit_metrics,
alpha=round(alpha, 2),
information_ratio=round(info_ratio, 4) if info_ratio is not None else None,
)
def _calculate_metrics(
self, returns: List[float], num_days: int
) -> PerformanceMetrics:
if not returns:
return PerformanceMetrics(
cumulative_return=0.0,
annualized_return=0.0,
sharpe_ratio=None,
max_drawdown=0.0,
)
# Cumulative return
cum = 1.0
for r in returns:
cum *= 1 + r
cum_return = (cum - 1) * 100
# Annualized return
if num_days > 0:
ann_return = (cum ** (365 / num_days) - 1) * 100
else:
ann_return = 0.0
# Sharpe ratio
if len(returns) >= 2:
mean_r = sum(returns) / len(returns)
variance = sum((r - mean_r) ** 2 for r in returns) / (len(returns) - 1)
std_r = math.sqrt(variance)
if std_r > 1e-10:
daily_rf = (1 + RISK_FREE_RATE / 100) ** (1 / 365) - 1
sharpe = (mean_r - daily_rf) / std_r * math.sqrt(252)
sharpe = round(sharpe, 4)
else:
sharpe = None
else:
sharpe = None
# Max drawdown
peak = 1.0
max_dd = 0.0
cum_val = 1.0
for r in returns:
cum_val *= 1 + r
if cum_val > peak:
peak = cum_val
dd = (cum_val - peak) / peak
if dd < max_dd:
max_dd = dd
max_dd_pct = max_dd * 100
return PerformanceMetrics(
cumulative_return=round(cum_return, 2),
annualized_return=round(ann_return, 2),
sharpe_ratio=sharpe,
max_drawdown=round(max_dd_pct, 2),
)
def _calculate_information_ratio(
self, portfolio_returns: List[float], benchmark_returns: List[float]
) -> Optional[float]:
if not portfolio_returns or not benchmark_returns:
return None
min_len = min(len(portfolio_returns), len(benchmark_returns))
excess = [
portfolio_returns[i] - benchmark_returns[i] for i in range(min_len)
]
if len(excess) < 2:
return None
mean_excess = sum(excess) / len(excess)
variance = sum((e - mean_excess) ** 2 for e in excess) / (len(excess) - 1)
tracking_error = math.sqrt(variance)
if tracking_error < 1e-10:
return None
return (mean_excess / tracking_error) * math.sqrt(252)
def _values_to_returns(self, values: List[float]) -> List[float]:
if len(values) < 2:
return []
return [
(values[i] - values[i - 1]) / values[i - 1]
for i in range(1, len(values))
if values[i - 1] != 0
]
def _calc_start_date(
self, period: str, first_snapshot: date, end_date: date
) -> date:
period_map = {
"1m": timedelta(days=30),
"3m": timedelta(days=90),
"6m": timedelta(days=180),
"1y": timedelta(days=365),
}
if period == "all":
return first_snapshot
delta = period_map.get(period, timedelta(days=365))
return max(end_date - delta, first_snapshot)
def _build_time_series(
self,
snapshots: list,
benchmark_data: List[dict],
start_date: date,
end_date: date,
) -> List[TimeSeriesPoint]:
if not snapshots:
return []
base_portfolio = float(snapshots[0].total_value)
portfolio_map = {}
for s in snapshots:
val = float(s.total_value)
ret = ((val / base_portfolio) - 1) * 100 if base_portfolio else 0
portfolio_map[s.snapshot_date] = ret
benchmark_map = {}
if benchmark_data:
base_bench = benchmark_data[0]["close"]
for d in benchmark_data:
ret = ((d["close"] / base_bench) - 1) * 100 if base_bench else 0
benchmark_map[d["date"]] = ret
daily_deposit_rate = DEPOSIT_ANNUAL_RATE / 100 / 365
all_dates = sorted(set(list(portfolio_map.keys()) + list(benchmark_map.keys())))
result = []
for d in all_dates:
days_elapsed = (d - start_date).days
deposit_ret = ((1 + DEPOSIT_ANNUAL_RATE / 100) ** (days_elapsed / 365) - 1) * 100
result.append(TimeSeriesPoint(
date=d,
portfolio_return=round(portfolio_map[d], 2) if d in portfolio_map else None,
benchmark_return=round(benchmark_map[d], 2) if d in benchmark_map else None,
deposit_return=round(deposit_ret, 2),
))
return result