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 <noreply@anthropic.com>
This commit is contained in:
parent
8842928363
commit
89bd8fea53
@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Galaxy-PO Backend API
|
Galaxy-PO Backend API
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
@ -9,10 +12,46 @@ from app.api import (
|
|||||||
market_router, backtest_router, snapshot_router,
|
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(
|
app = FastAPI(
|
||||||
title="Galaxy-PO API",
|
title="Galaxy-PO API",
|
||||||
description="Quant Portfolio Management API",
|
description="Quant Portfolio Management API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
208
backend/app/services/returns_calculator.py
Normal file
208
backend/app/services/returns_calculator.py
Normal file
@ -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
|
||||||
6
backend/jobs/__init__.py
Normal file
6
backend/jobs/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Background jobs module.
|
||||||
|
"""
|
||||||
|
from jobs.scheduler import scheduler, start_scheduler, stop_scheduler
|
||||||
|
|
||||||
|
__all__ = ["scheduler", "start_scheduler", "stop_scheduler"]
|
||||||
45
backend/jobs/scheduler.py
Normal file
45
backend/jobs/scheduler.py
Normal file
@ -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")
|
||||||
140
backend/jobs/snapshot_job.py
Normal file
140
backend/jobs/snapshot_job.py
Normal file
@ -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()
|
||||||
500
frontend/src/app/portfolio/[id]/history/page.tsx
Normal file
500
frontend/src/app/portfolio/[id]/history/page.tsx
Normal file
@ -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<SnapshotItem[]>([]);
|
||||||
|
const [returns, setReturns] = useState<ReturnsData | null>(null);
|
||||||
|
const [selectedSnapshot, setSelectedSnapshot] = useState<SnapshotDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-gray-500">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={`/portfolio/${portfolioId}`}
|
||||||
|
className="text-blue-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
← 포트폴리오로 돌아가기
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
|
포트폴리오 히스토리
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{returns && returns.total_return !== null && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-sm text-gray-500">총 수익률</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
parseFloat(returns.total_return || "0") >= 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPercent(returns.total_return)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-sm text-gray-500">CAGR</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
parseFloat(returns.cagr || "0") >= 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPercent(returns.cagr)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-sm text-gray-500">시작일</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{returns.start_date ? formatDate(returns.start_date) : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<div className="text-sm text-gray-500">종료일</div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900">
|
||||||
|
{returns.end_date ? formatDate(returns.end_date) : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="border-b border-gray-200">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("snapshots")}
|
||||||
|
className={`px-6 py-3 text-sm font-medium ${
|
||||||
|
activeTab === "snapshots"
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
스냅샷 목록
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("returns")}
|
||||||
|
className={`px-6 py-3 text-sm font-medium ${
|
||||||
|
activeTab === "returns"
|
||||||
|
? "border-b-2 border-blue-500 text-blue-600"
|
||||||
|
: "text-gray-500 hover:text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
수익률 추이
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === "snapshots" && (
|
||||||
|
<div>
|
||||||
|
{/* Create Snapshot Button */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateSnapshot}
|
||||||
|
disabled={creating}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? "생성 중..." : "스냅샷 생성"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snapshots Table */}
|
||||||
|
{snapshots.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
스냅샷이 없습니다. 첫 번째 스냅샷을 생성해보세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
날짜
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
총 평가금액
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">
|
||||||
|
작업
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatDate(snapshot.snapshot_date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
|
||||||
|
{formatCurrency(snapshot.total_value)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewSnapshot(snapshot.id)}
|
||||||
|
className="text-blue-600 hover:underline mr-4"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSnapshot(snapshot.id)}
|
||||||
|
className="text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "returns" && (
|
||||||
|
<div>
|
||||||
|
{returns && returns.data.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
날짜
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
평가금액
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
일간 수익률
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
누적 수익률
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{returns.data.map((point, index) => (
|
||||||
|
<tr key={index} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatDate(point.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-right font-medium text-gray-900">
|
||||||
|
{formatCurrency(point.total_value)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-6 py-4 whitespace-nowrap text-sm text-right ${
|
||||||
|
parseFloat(point.daily_return || "0") >= 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPercent(point.daily_return)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`px-6 py-4 whitespace-nowrap text-sm text-right font-medium ${
|
||||||
|
parseFloat(point.cumulative_return || "0") >= 0
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPercent(point.cumulative_return)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
수익률 데이터가 없습니다. 스냅샷을 먼저 생성해주세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snapshot Detail Modal */}
|
||||||
|
{selectedSnapshot && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">
|
||||||
|
스냅샷 상세
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatDate(selectedSnapshot.snapshot_date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnapshot(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-sm text-gray-500">총 평가금액</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{formatCurrency(selectedSnapshot.total_value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-medium text-gray-900 mb-2">보유 종목</h3>
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
종목
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
수량
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
가격
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
평가금액
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
비중
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{selectedSnapshot.holdings.map((holding) => (
|
||||||
|
<tr key={holding.ticker}>
|
||||||
|
<td className="px-4 py-2 text-sm text-gray-900">
|
||||||
|
{holding.ticker}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||||
|
{holding.quantity.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||||
|
{formatCurrency(holding.price)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right font-medium text-gray-900">
|
||||||
|
{formatCurrency(holding.value)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-sm text-right text-gray-900">
|
||||||
|
{parseFloat(holding.current_ratio).toFixed(2)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user