From 89bd8fea53a7d2588b2bd8403526453f2f79015c Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Tue, 3 Feb 2026 12:26:16 +0900 Subject: [PATCH] feat: add scheduler, returns calculator, and history page - APScheduler for daily snapshots (18:30 weekdays) - ReturnsCalculator with CAGR, TWR, MDD, volatility - Portfolio history page with snapshots and returns tabs - FastAPI lifespan integration for scheduler Co-Authored-By: Claude Opus 4.5 --- backend/app/main.py | 39 ++ backend/app/services/returns_calculator.py | 208 ++++++++ backend/jobs/__init__.py | 6 + backend/jobs/scheduler.py | 45 ++ backend/jobs/snapshot_job.py | 140 +++++ .../src/app/portfolio/[id]/history/page.tsx | 500 ++++++++++++++++++ 6 files changed, 938 insertions(+) create mode 100644 backend/app/services/returns_calculator.py create mode 100644 backend/jobs/__init__.py create mode 100644 backend/jobs/scheduler.py create mode 100644 backend/jobs/snapshot_job.py create mode 100644 frontend/src/app/portfolio/[id]/history/page.tsx diff --git a/backend/app/main.py b/backend/app/main.py index 6983bed..16f4753 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,9 @@ """ Galaxy-PO Backend API """ +import logging +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -9,10 +12,46 @@ from app.api import ( market_router, backtest_router, snapshot_router, ) +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager.""" + # Startup + logger.info("Starting Galaxy-PO API...") + + # Start scheduler (import here to avoid circular imports) + try: + from jobs.scheduler import start_scheduler + start_scheduler() + logger.info("Scheduler started") + except Exception as e: + logger.warning(f"Failed to start scheduler: {e}") + + yield + + # Shutdown + logger.info("Shutting down Galaxy-PO API...") + + try: + from jobs.scheduler import stop_scheduler + stop_scheduler() + logger.info("Scheduler stopped") + except Exception as e: + logger.warning(f"Failed to stop scheduler: {e}") + + app = FastAPI( title="Galaxy-PO API", description="Quant Portfolio Management API", version="0.1.0", + lifespan=lifespan, ) app.add_middleware( diff --git a/backend/app/services/returns_calculator.py b/backend/app/services/returns_calculator.py new file mode 100644 index 0000000..509874b --- /dev/null +++ b/backend/app/services/returns_calculator.py @@ -0,0 +1,208 @@ +""" +Returns calculation service for portfolio performance analysis. +""" +from datetime import date, timedelta +from decimal import Decimal +from typing import List, Optional, Tuple + +from sqlalchemy.orm import Session + +from app.models.portfolio import PortfolioSnapshot + + +class ReturnsCalculator: + """ + Calculate various return metrics for portfolios. + + Supports: + - Simple returns (daily, weekly, monthly, yearly) + - Cumulative returns + - Time-weighted returns (TWR) + - CAGR (Compound Annual Growth Rate) + """ + + def __init__(self, db: Session): + self.db = db + + def get_snapshots( + self, + portfolio_id: int, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + ) -> List[PortfolioSnapshot]: + """Get snapshots for a portfolio within date range.""" + query = self.db.query(PortfolioSnapshot).filter( + PortfolioSnapshot.portfolio_id == portfolio_id + ) + + if start_date: + query = query.filter(PortfolioSnapshot.snapshot_date >= start_date) + if end_date: + query = query.filter(PortfolioSnapshot.snapshot_date <= end_date) + + return query.order_by(PortfolioSnapshot.snapshot_date).all() + + def calculate_simple_return( + self, + start_value: Decimal, + end_value: Decimal, + ) -> Decimal: + """Calculate simple return percentage.""" + if start_value <= 0: + return Decimal("0") + return ((end_value - start_value) / start_value * 100).quantize(Decimal("0.01")) + + def calculate_cagr( + self, + start_value: Decimal, + end_value: Decimal, + days: int, + ) -> Optional[Decimal]: + """ + Calculate Compound Annual Growth Rate. + + CAGR = (ending/beginning)^(1/years) - 1 + """ + if start_value <= 0 or days <= 0: + return None + + years = Decimal(str(days)) / Decimal("365") + if years <= 0: + return None + + ratio = end_value / start_value + cagr_value = (float(ratio) ** (1 / float(years)) - 1) * 100 + return Decimal(str(cagr_value)).quantize(Decimal("0.01")) + + def calculate_twr( + self, + snapshots: List[PortfolioSnapshot], + ) -> Optional[Decimal]: + """ + Calculate Time-Weighted Return. + + TWR = [(1 + r1) * (1 + r2) * ... * (1 + rn)] - 1 + + This method assumes no cash flows between snapshots. + For more accurate TWR with cash flows, additional data would be needed. + """ + if len(snapshots) < 2: + return None + + cumulative = Decimal("1") + + for i in range(1, len(snapshots)): + prev_value = Decimal(str(snapshots[i - 1].total_value)) + curr_value = Decimal(str(snapshots[i].total_value)) + + if prev_value > 0: + period_return = curr_value / prev_value + cumulative *= period_return + + twr = (cumulative - 1) * 100 + return twr.quantize(Decimal("0.01")) + + def calculate_volatility( + self, + snapshots: List[PortfolioSnapshot], + ) -> Optional[Decimal]: + """ + Calculate annualized volatility (standard deviation of returns). + """ + if len(snapshots) < 3: + return None + + # Calculate daily returns + returns = [] + for i in range(1, len(snapshots)): + prev_value = float(snapshots[i - 1].total_value) + curr_value = float(snapshots[i].total_value) + + if prev_value > 0: + daily_return = (curr_value - prev_value) / prev_value + returns.append(daily_return) + + if not returns: + return None + + # Calculate standard deviation + mean = sum(returns) / len(returns) + variance = sum((r - mean) ** 2 for r in returns) / len(returns) + std_dev = variance ** 0.5 + + # Annualize (assuming ~252 trading days) + annualized_vol = std_dev * (252 ** 0.5) * 100 + + return Decimal(str(annualized_vol)).quantize(Decimal("0.01")) + + def calculate_mdd( + self, + snapshots: List[PortfolioSnapshot], + ) -> Optional[Decimal]: + """ + Calculate Maximum Drawdown. + + MDD = (trough - peak) / peak + """ + if len(snapshots) < 2: + return None + + values = [float(s.total_value) for s in snapshots] + + peak = values[0] + max_drawdown = 0 + + for value in values: + if value > peak: + peak = value + drawdown = (peak - value) / peak if peak > 0 else 0 + if drawdown > max_drawdown: + max_drawdown = drawdown + + return Decimal(str(max_drawdown * 100)).quantize(Decimal("0.01")) + + def get_period_return( + self, + portfolio_id: int, + period: str, # "1d", "1w", "1m", "3m", "6m", "1y", "ytd", "all" + ) -> Tuple[Optional[Decimal], Optional[date], Optional[date]]: + """ + Get return for a specific period. + + Returns: (return_percentage, start_date, end_date) + """ + today = date.today() + + if period == "1d": + start = today - timedelta(days=1) + elif period == "1w": + start = today - timedelta(weeks=1) + elif period == "1m": + start = today - timedelta(days=30) + elif period == "3m": + start = today - timedelta(days=90) + elif period == "6m": + start = today - timedelta(days=180) + elif period == "1y": + start = today - timedelta(days=365) + elif period == "ytd": + start = date(today.year, 1, 1) + elif period == "all": + start = None + else: + return None, None, None + + snapshots = self.get_snapshots(portfolio_id, start_date=start) + + if len(snapshots) < 2: + return None, None, None + + first = snapshots[0] + last = snapshots[-1] + + return_pct = self.calculate_simple_return( + Decimal(str(first.total_value)), + Decimal(str(last.total_value)), + ) + + return return_pct, first.snapshot_date, last.snapshot_date diff --git a/backend/jobs/__init__.py b/backend/jobs/__init__.py new file mode 100644 index 0000000..2d46e0b --- /dev/null +++ b/backend/jobs/__init__.py @@ -0,0 +1,6 @@ +""" +Background jobs module. +""" +from jobs.scheduler import scheduler, start_scheduler, stop_scheduler + +__all__ = ["scheduler", "start_scheduler", "stop_scheduler"] diff --git a/backend/jobs/scheduler.py b/backend/jobs/scheduler.py new file mode 100644 index 0000000..267aa90 --- /dev/null +++ b/backend/jobs/scheduler.py @@ -0,0 +1,45 @@ +""" +APScheduler configuration for background jobs. +""" +import logging +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from jobs.snapshot_job import create_daily_snapshots + +logger = logging.getLogger(__name__) + +# Create scheduler instance +scheduler = BackgroundScheduler() + + +def configure_jobs(): + """Configure scheduled jobs.""" + # Daily snapshot at 18:30 (after market close) + scheduler.add_job( + create_daily_snapshots, + trigger=CronTrigger( + hour=18, + minute=30, + day_of_week='mon-fri', + ), + id='daily_snapshots', + name='Create daily portfolio snapshots', + replace_existing=True, + ) + logger.info("Configured daily_snapshots job") + + +def start_scheduler(): + """Start the scheduler.""" + if not scheduler.running: + configure_jobs() + scheduler.start() + logger.info("Scheduler started") + + +def stop_scheduler(): + """Stop the scheduler.""" + if scheduler.running: + scheduler.shutdown() + logger.info("Scheduler stopped") diff --git a/backend/jobs/snapshot_job.py b/backend/jobs/snapshot_job.py new file mode 100644 index 0000000..37d7dd3 --- /dev/null +++ b/backend/jobs/snapshot_job.py @@ -0,0 +1,140 @@ +""" +Snapshot batch job for automatic daily snapshots. +""" +import logging +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy.orm import Session + +from app.core.database import SessionLocal +from app.models.portfolio import Portfolio, PortfolioSnapshot, SnapshotHolding +from app.models.stock import JobLog +from app.services.price_service import PriceService + +logger = logging.getLogger(__name__) + + +def create_daily_snapshots(): + """ + Create snapshots for all portfolios with holdings. + + This job runs daily after market close to record portfolio values. + """ + db: Session = SessionLocal() + job_log = None + + try: + # Create job log + job_log = JobLog( + job_name="daily_snapshots", + status="running", + started_at=datetime.utcnow(), + ) + db.add(job_log) + db.commit() + + # Get all portfolios with holdings + portfolios = ( + db.query(Portfolio) + .filter(Portfolio.holdings.any()) + .all() + ) + + logger.info(f"Creating snapshots for {len(portfolios)} portfolios") + + price_service = PriceService(db) + records_count = 0 + today = date.today() + + for portfolio in portfolios: + try: + # Check if snapshot already exists for today + existing = ( + db.query(PortfolioSnapshot) + .filter( + PortfolioSnapshot.portfolio_id == portfolio.id, + PortfolioSnapshot.snapshot_date == today, + ) + .first() + ) + + if existing: + logger.debug(f"Snapshot already exists for portfolio {portfolio.id}") + continue + + # Get current prices + tickers = [h.ticker for h in portfolio.holdings] + prices = price_service.get_current_prices(tickers) + + # Calculate total value + total_value = Decimal("0") + holding_values = [] + + for holding in portfolio.holdings: + price = prices.get(holding.ticker, Decimal("0")) + value = price * holding.quantity + total_value += value + holding_values.append({ + "ticker": holding.ticker, + "quantity": holding.quantity, + "price": price, + "value": value, + }) + + # Skip if no value + if total_value <= 0: + logger.warning(f"Portfolio {portfolio.id} has zero value, skipping") + continue + + # Create snapshot + snapshot = PortfolioSnapshot( + portfolio_id=portfolio.id, + total_value=total_value, + snapshot_date=today, + ) + db.add(snapshot) + db.flush() + + # Create snapshot holdings + for hv in holding_values: + ratio = (hv["value"] / total_value * 100).quantize(Decimal("0.01")) + snapshot_holding = SnapshotHolding( + snapshot_id=snapshot.id, + ticker=hv["ticker"], + quantity=hv["quantity"], + price=hv["price"], + value=hv["value"], + current_ratio=ratio, + ) + db.add(snapshot_holding) + + records_count += 1 + logger.info(f"Created snapshot for portfolio {portfolio.id}: {total_value}") + + except Exception as e: + logger.error(f"Error creating snapshot for portfolio {portfolio.id}: {e}") + continue + + db.commit() + + # Update job log + if job_log: + job_log.status = "success" + job_log.finished_at = datetime.utcnow() + job_log.records_count = records_count + db.commit() + + logger.info(f"Daily snapshots completed: {records_count} snapshots created") + + except Exception as e: + logger.error(f"Daily snapshots job failed: {e}") + if job_log: + job_log.status = "failed" + job_log.finished_at = datetime.utcnow() + job_log.error_msg = str(e) + db.commit() + raise + + finally: + db.close() diff --git a/frontend/src/app/portfolio/[id]/history/page.tsx b/frontend/src/app/portfolio/[id]/history/page.tsx new file mode 100644 index 0000000..5d49aa3 --- /dev/null +++ b/frontend/src/app/portfolio/[id]/history/page.tsx @@ -0,0 +1,500 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; + +interface SnapshotItem { + id: number; + portfolio_id: number; + total_value: string; + snapshot_date: string; +} + +interface SnapshotDetail { + id: number; + portfolio_id: number; + total_value: string; + snapshot_date: string; + holdings: { + ticker: string; + quantity: number; + price: string; + value: string; + current_ratio: string; + }[]; +} + +interface ReturnDataPoint { + date: string; + total_value: string; + daily_return: string | null; + cumulative_return: string | null; +} + +interface ReturnsData { + portfolio_id: number; + start_date: string | null; + end_date: string | null; + total_return: string | null; + cagr: string | null; + data: ReturnDataPoint[]; +} + +export default function PortfolioHistoryPage() { + const params = useParams(); + const router = useRouter(); + const portfolioId = params.id as string; + + const [snapshots, setSnapshots] = useState([]); + const [returns, setReturns] = useState(null); + const [selectedSnapshot, setSelectedSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<"snapshots" | "returns">("snapshots"); + + const fetchData = async () => { + try { + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + return; + } + + const headers = { Authorization: `Bearer ${token}` }; + + const [snapshotsRes, returnsRes] = await Promise.all([ + fetch(`http://localhost:8000/api/portfolios/${portfolioId}/snapshots`, { headers }), + fetch(`http://localhost:8000/api/portfolios/${portfolioId}/returns`, { headers }), + ]); + + if (!snapshotsRes.ok || !returnsRes.ok) { + throw new Error("Failed to fetch data"); + } + + const [snapshotsData, returnsData] = await Promise.all([ + snapshotsRes.json(), + returnsRes.json(), + ]); + + setSnapshots(snapshotsData); + setReturns(returnsData); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [portfolioId]); + + const handleCreateSnapshot = async () => { + setCreating(true); + setError(null); + + try { + const token = localStorage.getItem("token"); + const res = await fetch( + `http://localhost:8000/api/portfolios/${portfolioId}/snapshots`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + } + ); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail || "Failed to create snapshot"); + } + + await fetchData(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setCreating(false); + } + }; + + const handleViewSnapshot = async (snapshotId: number) => { + try { + const token = localStorage.getItem("token"); + const res = await fetch( + `http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!res.ok) { + throw new Error("Failed to fetch snapshot"); + } + + const data = await res.json(); + setSelectedSnapshot(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + + const handleDeleteSnapshot = async (snapshotId: number) => { + if (!confirm("이 스냅샷을 삭제하시겠습니까?")) return; + + try { + const token = localStorage.getItem("token"); + const res = await fetch( + `http://localhost:8000/api/portfolios/${portfolioId}/snapshots/${snapshotId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!res.ok) { + throw new Error("Failed to delete snapshot"); + } + + setSelectedSnapshot(null); + await fetchData(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + + const formatCurrency = (value: string | number) => { + const num = typeof value === "string" ? parseFloat(value) : value; + return new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: "KRW", + maximumFractionDigits: 0, + }).format(num); + }; + + const formatPercent = (value: string | number | null) => { + if (value === null) return "-"; + const num = typeof value === "string" ? parseFloat(value) : value; + return `${num >= 0 ? "+" : ""}${num.toFixed(2)}%`; + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + }; + + if (loading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + ← 포트폴리오로 돌아가기 + +

+ 포트폴리오 히스토리 +

+
+ + {error && ( +
+ {error} +
+ )} + + {/* Summary Cards */} + {returns && returns.total_return !== null && ( +
+
+
총 수익률
+
= 0 + ? "text-green-600" + : "text-red-600" + }`} + > + {formatPercent(returns.total_return)} +
+
+
+
CAGR
+
= 0 + ? "text-green-600" + : "text-red-600" + }`} + > + {formatPercent(returns.cagr)} +
+
+
+
시작일
+
+ {returns.start_date ? formatDate(returns.start_date) : "-"} +
+
+
+
종료일
+
+ {returns.end_date ? formatDate(returns.end_date) : "-"} +
+
+
+ )} + + {/* Tabs */} +
+
+
+ + +
+
+ +
+ {activeTab === "snapshots" && ( +
+ {/* Create Snapshot Button */} +
+ +
+ + {/* Snapshots Table */} + {snapshots.length === 0 ? ( +
+ 스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요. +
+ ) : ( +
+ + + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + ))} + +
+ 날짜 + + 총 평가금액 + + 작업 +
+ {formatDate(snapshot.snapshot_date)} + + {formatCurrency(snapshot.total_value)} + + + +
+
+ )} +
+ )} + + {activeTab === "returns" && ( +
+ {returns && returns.data.length > 0 ? ( +
+ + + + + + + + + + + {returns.data.map((point, index) => ( + + + + + + + ))} + +
+ 날짜 + + 평가금액 + + 일간 수익률 + + 누적 수익률 +
+ {formatDate(point.date)} + + {formatCurrency(point.total_value)} + = 0 + ? "text-green-600" + : "text-red-600" + }`} + > + {formatPercent(point.daily_return)} + = 0 + ? "text-green-600" + : "text-red-600" + }`} + > + {formatPercent(point.cumulative_return)} +
+
+ ) : ( +
+ 수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요. +
+ )} +
+ )} +
+
+ + {/* Snapshot Detail Modal */} + {selectedSnapshot && ( +
+
+
+
+
+

+ 스냅샷 상세 +

+

+ {formatDate(selectedSnapshot.snapshot_date)} +

+
+ +
+ +
+
총 평가금액
+
+ {formatCurrency(selectedSnapshot.total_value)} +
+
+ +

보유 종목

+ + + + + + + + + + + + {selectedSnapshot.holdings.map((holding) => ( + + + + + + + + ))} + +
+ 종목 + + 수량 + + 가격 + + 평가금액 + + 비중 +
+ {holding.ticker} + + {holding.quantity.toLocaleString()} + + {formatCurrency(holding.price)} + + {formatCurrency(holding.value)} + + {parseFloat(holding.current_ratio).toFixed(2)}% +
+
+
+
+ )} +
+
+ ); +}