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:
zephyrdark 2026-02-03 12:26:16 +09:00
parent 8842928363
commit 89bd8fea53
6 changed files with 938 additions and 0 deletions

View File

@ -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(

View 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
View 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
View 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")

View 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()

View 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>
);
}