50 KiB
Phase 3: Portfolio Management Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement portfolio management features including CRUD operations, target allocation, holdings management, transaction recording, and rebalancing calculation.
Architecture: RESTful API endpoints for portfolio operations. Rebalancing engine calculates buy/sell quantities based on target vs current allocation. Frontend provides dashboard, detail, and rebalancing UI.
Tech Stack: FastAPI, SQLAlchemy, Pydantic, Next.js, React, TypeScript, Tailwind CSS
Task 1: Portfolio Pydantic Schemas
Files:
- Create:
backend/app/schemas/portfolio.py
Step 1: Create portfolio schemas
"""
Portfolio related Pydantic schemas.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel, Field
# Target schemas
class TargetBase(BaseModel):
ticker: str
target_ratio: Decimal = Field(..., ge=0, le=100)
class TargetCreate(TargetBase):
pass
class TargetResponse(TargetBase):
class Config:
from_attributes = True
# Holding schemas
class HoldingBase(BaseModel):
ticker: str
quantity: int = Field(..., ge=0)
avg_price: Decimal = Field(..., ge=0)
class HoldingCreate(HoldingBase):
pass
class HoldingResponse(HoldingBase):
class Config:
from_attributes = True
class HoldingWithValue(HoldingResponse):
"""Holding with calculated values."""
current_price: Decimal | None = None
value: Decimal | None = None
current_ratio: Decimal | None = None
profit_loss: Decimal | None = None
profit_loss_ratio: Decimal | None = None
# Transaction schemas
class TransactionBase(BaseModel):
ticker: str
tx_type: str # "buy" or "sell"
quantity: int = Field(..., gt=0)
price: Decimal = Field(..., gt=0)
executed_at: datetime
memo: Optional[str] = None
class TransactionCreate(TransactionBase):
pass
class TransactionResponse(TransactionBase):
id: int
class Config:
from_attributes = True
# Portfolio schemas
class PortfolioBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
portfolio_type: str = "general" # "pension" or "general"
class PortfolioCreate(PortfolioBase):
pass
class PortfolioUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
portfolio_type: Optional[str] = None
class PortfolioResponse(PortfolioBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PortfolioDetail(PortfolioResponse):
"""Portfolio with targets and holdings."""
targets: List[TargetResponse] = []
holdings: List[HoldingWithValue] = []
total_value: Decimal | None = None
total_invested: Decimal | None = None
total_profit_loss: Decimal | None = None
# Snapshot schemas
class SnapshotHoldingResponse(BaseModel):
ticker: str
quantity: int
price: Decimal
value: Decimal
current_ratio: Decimal
class Config:
from_attributes = True
class SnapshotResponse(BaseModel):
id: int
portfolio_id: int
total_value: Decimal
snapshot_date: date
holdings: List[SnapshotHoldingResponse] = []
class Config:
from_attributes = True
# Rebalancing schemas
class RebalanceItem(BaseModel):
ticker: str
name: str | None = None
target_ratio: Decimal
current_ratio: Decimal
current_quantity: int
current_value: Decimal
target_value: Decimal
diff_value: Decimal
diff_quantity: int
action: str # "buy", "sell", or "hold"
class RebalanceResponse(BaseModel):
portfolio_id: int
total_value: Decimal
items: List[RebalanceItem]
class RebalanceSimulationRequest(BaseModel):
additional_amount: Decimal = Field(..., gt=0)
class RebalanceSimulationResponse(BaseModel):
portfolio_id: int
current_total: Decimal
additional_amount: Decimal
new_total: Decimal
items: List[RebalanceItem]
Step 2: Update schemas init.py
from app.schemas.user import UserCreate, UserResponse, Token
from app.schemas.auth import LoginRequest
from app.schemas.portfolio import (
TargetCreate, TargetResponse,
HoldingCreate, HoldingResponse, HoldingWithValue,
TransactionCreate, TransactionResponse,
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
SnapshotResponse, SnapshotHoldingResponse,
RebalanceItem, RebalanceResponse,
RebalanceSimulationRequest, RebalanceSimulationResponse,
)
__all__ = [
"UserCreate", "UserResponse", "Token", "LoginRequest",
"TargetCreate", "TargetResponse",
"HoldingCreate", "HoldingResponse", "HoldingWithValue",
"TransactionCreate", "TransactionResponse",
"PortfolioCreate", "PortfolioUpdate", "PortfolioResponse", "PortfolioDetail",
"SnapshotResponse", "SnapshotHoldingResponse",
"RebalanceItem", "RebalanceResponse",
"RebalanceSimulationRequest", "RebalanceSimulationResponse",
]
Step 3: Commit
git add backend/app/schemas/
git commit -m "feat: add portfolio Pydantic schemas"
Task 2: Portfolio CRUD API
Files:
- Create:
backend/app/api/portfolio.py - Update:
backend/app/api/__init__.py - Update:
backend/app/main.py
Step 1: Create portfolio API endpoints
"""
Portfolio management API endpoints.
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.portfolio import Portfolio, PortfolioType
from app.schemas.portfolio import (
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
)
router = APIRouter(prefix="/api/portfolios", tags=["portfolios"])
@router.get("", response_model=List[PortfolioResponse])
async def list_portfolios(
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get all portfolios for current user."""
portfolios = (
db.query(Portfolio)
.filter(Portfolio.user_id == current_user.id)
.order_by(Portfolio.created_at.desc())
.all()
)
return portfolios
@router.post("", response_model=PortfolioResponse, status_code=status.HTTP_201_CREATED)
async def create_portfolio(
data: PortfolioCreate,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Create a new portfolio."""
portfolio_type = PortfolioType(data.portfolio_type)
portfolio = Portfolio(
user_id=current_user.id,
name=data.name,
portfolio_type=portfolio_type,
)
db.add(portfolio)
db.commit()
db.refresh(portfolio)
return portfolio
@router.get("/{portfolio_id}", response_model=PortfolioResponse)
async def get_portfolio(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get a portfolio by ID."""
portfolio = db.query(Portfolio).filter(
Portfolio.id == portfolio_id,
Portfolio.user_id == current_user.id,
).first()
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
@router.put("/{portfolio_id}", response_model=PortfolioResponse)
async def update_portfolio(
portfolio_id: int,
data: PortfolioUpdate,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Update a portfolio."""
portfolio = db.query(Portfolio).filter(
Portfolio.id == portfolio_id,
Portfolio.user_id == current_user.id,
).first()
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
if data.name is not None:
portfolio.name = data.name
if data.portfolio_type is not None:
portfolio.portfolio_type = PortfolioType(data.portfolio_type)
db.commit()
db.refresh(portfolio)
return portfolio
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_portfolio(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Delete a portfolio."""
portfolio = db.query(Portfolio).filter(
Portfolio.id == portfolio_id,
Portfolio.user_id == current_user.id,
).first()
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
db.delete(portfolio)
db.commit()
return None
Step 2: Update api/init.py
from app.api.auth import router as auth_router
from app.api.admin import router as admin_router
from app.api.portfolio import router as portfolio_router
__all__ = ["auth_router", "admin_router", "portfolio_router"]
Step 3: Update main.py to include portfolio_router
Add to imports and include the router.
Step 4: Commit
git add backend/app/api/
git commit -m "feat: add portfolio CRUD API endpoints"
Task 3: Targets and Holdings API
Files:
- Update:
backend/app/api/portfolio.py
Step 1: Add targets endpoints
from app.models.portfolio import Portfolio, Target, Holding, PortfolioType
from app.schemas.portfolio import (
PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
TargetCreate, TargetResponse,
HoldingCreate, HoldingResponse,
)
@router.get("/{portfolio_id}/targets", response_model=List[TargetResponse])
async def get_targets(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get target allocations for a portfolio."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
return portfolio.targets
@router.put("/{portfolio_id}/targets", response_model=List[TargetResponse])
async def set_targets(
portfolio_id: int,
targets: List[TargetCreate],
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Set target allocations for a portfolio (replaces all existing)."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# Validate total ratio
total_ratio = sum(t.target_ratio for t in targets)
if total_ratio != 100:
raise HTTPException(
status_code=400,
detail=f"Target ratios must sum to 100%, got {total_ratio}%"
)
# Delete existing targets
db.query(Target).filter(Target.portfolio_id == portfolio_id).delete()
# Create new targets
new_targets = []
for t in targets:
target = Target(
portfolio_id=portfolio_id,
ticker=t.ticker,
target_ratio=t.target_ratio,
)
db.add(target)
new_targets.append(target)
db.commit()
return new_targets
@router.get("/{portfolio_id}/holdings", response_model=List[HoldingResponse])
async def get_holdings(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get holdings for a portfolio."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
return portfolio.holdings
@router.put("/{portfolio_id}/holdings", response_model=List[HoldingResponse])
async def set_holdings(
portfolio_id: int,
holdings: List[HoldingCreate],
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Set holdings for a portfolio (replaces all existing)."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# Delete existing holdings
db.query(Holding).filter(Holding.portfolio_id == portfolio_id).delete()
# Create new holdings
new_holdings = []
for h in holdings:
holding = Holding(
portfolio_id=portfolio_id,
ticker=h.ticker,
quantity=h.quantity,
avg_price=h.avg_price,
)
db.add(holding)
new_holdings.append(holding)
db.commit()
return new_holdings
def _get_portfolio(db: Session, portfolio_id: int, user_id: int) -> Portfolio:
"""Helper to get portfolio with ownership check."""
portfolio = db.query(Portfolio).filter(
Portfolio.id == portfolio_id,
Portfolio.user_id == user_id,
).first()
if not portfolio:
raise HTTPException(status_code=404, detail="Portfolio not found")
return portfolio
Step 2: Commit
git add backend/app/api/portfolio.py
git commit -m "feat: add targets and holdings API endpoints"
Task 4: Transactions API
Files:
- Update:
backend/app/api/portfolio.py
Step 1: Add transactions endpoints
from app.models.portfolio import Transaction, TransactionType
from app.schemas.portfolio import TransactionCreate, TransactionResponse
@router.get("/{portfolio_id}/transactions", response_model=List[TransactionResponse])
async def get_transactions(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
limit: int = 50,
):
"""Get transaction history for a portfolio."""
_get_portfolio(db, portfolio_id, current_user.id)
transactions = (
db.query(Transaction)
.filter(Transaction.portfolio_id == portfolio_id)
.order_by(Transaction.executed_at.desc())
.limit(limit)
.all()
)
return transactions
@router.post("/{portfolio_id}/transactions", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)
async def add_transaction(
portfolio_id: int,
data: TransactionCreate,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Add a transaction and update holdings accordingly."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
tx_type = TransactionType(data.tx_type)
# Create transaction
transaction = Transaction(
portfolio_id=portfolio_id,
ticker=data.ticker,
tx_type=tx_type,
quantity=data.quantity,
price=data.price,
executed_at=data.executed_at,
memo=data.memo,
)
db.add(transaction)
# Update holding
holding = db.query(Holding).filter(
Holding.portfolio_id == portfolio_id,
Holding.ticker == data.ticker,
).first()
if tx_type == TransactionType.BUY:
if holding:
# Update average price
total_value = (holding.quantity * holding.avg_price) + (data.quantity * data.price)
new_quantity = holding.quantity + data.quantity
holding.quantity = new_quantity
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
else:
# Create new holding
holding = Holding(
portfolio_id=portfolio_id,
ticker=data.ticker,
quantity=data.quantity,
avg_price=data.price,
)
db.add(holding)
elif tx_type == TransactionType.SELL:
if not holding or holding.quantity < data.quantity:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity for {data.ticker}"
)
holding.quantity -= data.quantity
if holding.quantity == 0:
db.delete(holding)
db.commit()
db.refresh(transaction)
return transaction
Step 2: Commit
git add backend/app/api/portfolio.py
git commit -m "feat: add transactions API with holdings update"
Task 5: Rebalancing Service
Files:
- Create:
backend/app/services/rebalance.py
Step 1: Create rebalancing service
"""
Rebalancing calculation service.
"""
from decimal import Decimal
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.models.portfolio import Portfolio, Target, Holding
from app.models.stock import Stock, ETF
from app.schemas.portfolio import RebalanceItem, RebalanceResponse, RebalanceSimulationResponse
class RebalanceService:
"""Service for calculating portfolio rebalancing."""
def __init__(self, db: Session):
self.db = db
def get_current_prices(self, tickers: List[str]) -> Dict[str, Decimal]:
"""Get current prices for tickers from database."""
prices = {}
# Check stocks
stocks = self.db.query(Stock).filter(Stock.ticker.in_(tickers)).all()
for stock in stocks:
if stock.close_price:
prices[stock.ticker] = Decimal(str(stock.close_price))
# Check ETFs for missing tickers
missing = [t for t in tickers if t not in prices]
if missing:
etfs = self.db.query(ETF).filter(ETF.ticker.in_(missing)).all()
for etf in etfs:
# ETF price would come from etf_prices table
# For now, we'll need to fetch from a separate query
pass
return prices
def get_stock_names(self, tickers: List[str]) -> Dict[str, str]:
"""Get stock names for tickers."""
names = {}
stocks = self.db.query(Stock).filter(Stock.ticker.in_(tickers)).all()
for stock in stocks:
names[stock.ticker] = stock.name
return names
def calculate_rebalance(
self,
portfolio: Portfolio,
additional_amount: Optional[Decimal] = None,
) -> RebalanceResponse | RebalanceSimulationResponse:
"""Calculate rebalancing for a portfolio."""
targets = {t.ticker: Decimal(str(t.target_ratio)) for t in portfolio.targets}
holdings = {h.ticker: (h.quantity, Decimal(str(h.avg_price))) for h in portfolio.holdings}
all_tickers = list(set(targets.keys()) | set(holdings.keys()))
current_prices = self.get_current_prices(all_tickers)
stock_names = self.get_stock_names(all_tickers)
# Calculate current values
current_values = {}
for ticker, (quantity, _) in holdings.items():
price = current_prices.get(ticker, Decimal("0"))
current_values[ticker] = price * quantity
current_total = sum(current_values.values())
if additional_amount:
new_total = current_total + additional_amount
else:
new_total = current_total
# Calculate rebalance items
items = []
for ticker in all_tickers:
target_ratio = targets.get(ticker, Decimal("0"))
current_value = current_values.get(ticker, Decimal("0"))
current_quantity = holdings.get(ticker, (0, Decimal("0")))[0]
current_price = current_prices.get(ticker, Decimal("0"))
if new_total > 0:
current_ratio = (current_value / new_total * 100).quantize(Decimal("0.01"))
else:
current_ratio = Decimal("0")
target_value = (new_total * target_ratio / 100).quantize(Decimal("0.01"))
diff_value = target_value - current_value
if current_price > 0:
diff_quantity = int(diff_value / current_price)
else:
diff_quantity = 0
if diff_quantity > 0:
action = "buy"
elif diff_quantity < 0:
action = "sell"
else:
action = "hold"
items.append(RebalanceItem(
ticker=ticker,
name=stock_names.get(ticker),
target_ratio=target_ratio,
current_ratio=current_ratio,
current_quantity=current_quantity,
current_value=current_value,
target_value=target_value,
diff_value=diff_value,
diff_quantity=diff_quantity,
action=action,
))
# Sort by action priority (buy first, then sell, then hold)
action_order = {"buy": 0, "sell": 1, "hold": 2}
items.sort(key=lambda x: (action_order.get(x.action, 3), -abs(x.diff_quantity)))
if additional_amount:
return RebalanceSimulationResponse(
portfolio_id=portfolio.id,
current_total=current_total,
additional_amount=additional_amount,
new_total=new_total,
items=items,
)
else:
return RebalanceResponse(
portfolio_id=portfolio.id,
total_value=current_total,
items=items,
)
Step 2: Commit
git add backend/app/services/
git commit -m "feat: add rebalancing calculation service"
Task 6: Rebalancing API
Files:
- Update:
backend/app/api/portfolio.py
Step 1: Add rebalancing endpoints
from app.services.rebalance import RebalanceService
from app.schemas.portfolio import (
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
)
@router.get("/{portfolio_id}/rebalance", response_model=RebalanceResponse)
async def calculate_rebalance(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Calculate rebalancing for a portfolio."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
service = RebalanceService(db)
return service.calculate_rebalance(portfolio)
@router.post("/{portfolio_id}/rebalance/simulate", response_model=RebalanceSimulationResponse)
async def simulate_rebalance(
portfolio_id: int,
data: RebalanceSimulationRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Simulate rebalancing with additional investment amount."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
service = RebalanceService(db)
return service.calculate_rebalance(portfolio, additional_amount=data.additional_amount)
Step 2: Commit
git add backend/app/api/portfolio.py
git commit -m "feat: add rebalancing API endpoints"
Task 7: Portfolio Detail API with Values
Files:
- Update:
backend/app/api/portfolio.py
Step 1: Add portfolio detail endpoint with calculated values
from app.schemas.portfolio import PortfolioDetail, HoldingWithValue
@router.get("/{portfolio_id}/detail", response_model=PortfolioDetail)
async def get_portfolio_detail(
portfolio_id: int,
current_user: CurrentUser,
db: Session = Depends(get_db),
):
"""Get portfolio with calculated values."""
portfolio = _get_portfolio(db, portfolio_id, current_user.id)
# Get current prices
tickers = [h.ticker for h in portfolio.holdings]
service = RebalanceService(db)
prices = service.get_current_prices(tickers)
# Calculate holding values
holdings_with_value = []
total_value = Decimal("0")
total_invested = Decimal("0")
for holding in portfolio.holdings:
current_price = prices.get(holding.ticker, Decimal("0"))
value = current_price * holding.quantity
invested = Decimal(str(holding.avg_price)) * holding.quantity
profit_loss = value - invested
profit_loss_ratio = (profit_loss / invested * 100) if invested > 0 else Decimal("0")
total_value += value
total_invested += invested
holdings_with_value.append(HoldingWithValue(
ticker=holding.ticker,
quantity=holding.quantity,
avg_price=Decimal(str(holding.avg_price)),
current_price=current_price,
value=value,
current_ratio=Decimal("0"), # Will be calculated after total
profit_loss=profit_loss,
profit_loss_ratio=profit_loss_ratio.quantize(Decimal("0.01")),
))
# Calculate current ratios
for h in holdings_with_value:
if total_value > 0:
h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01"))
return PortfolioDetail(
id=portfolio.id,
user_id=portfolio.user_id,
name=portfolio.name,
portfolio_type=portfolio.portfolio_type.value,
created_at=portfolio.created_at,
updated_at=portfolio.updated_at,
targets=portfolio.targets,
holdings=holdings_with_value,
total_value=total_value,
total_invested=total_invested,
total_profit_loss=total_value - total_invested,
)
Step 2: Commit
git add backend/app/api/portfolio.py
git commit -m "feat: add portfolio detail API with calculated values"
Task 8: Frontend Portfolio List Page
Files:
- Create:
frontend/src/app/portfolio/page.tsx
Step 1: Create portfolio list page
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface Portfolio {
id: number;
name: string;
portfolio_type: string;
created_at: string;
updated_at: string;
}
interface User {
id: number;
username: string;
email: string;
}
export default function PortfolioListPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
const userData = await api.getCurrentUser() as User;
setUser(userData);
await fetchPortfolios();
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router]);
const fetchPortfolios = async () => {
try {
setError(null);
const data = await api.get<Portfolio[]>('/api/portfolios');
setPortfolios(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch portfolios';
setError(message);
}
};
const getTypeLabel = (type: string) => {
return type === 'pension' ? '퇴직연금' : '일반';
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header username={user?.username} />
<main className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">포트폴리오</h1>
<Link
href="/portfolio/new"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
새 포트폴리오
</Link>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{portfolios.map((portfolio) => (
<Link
key={portfolio.id}
href={`/portfolio/${portfolio.id}`}
className="bg-white rounded-lg shadow p-6 hover:shadow-lg transition-shadow"
>
<div className="flex justify-between items-start mb-2">
<h2 className="text-lg font-semibold text-gray-800">
{portfolio.name}
</h2>
<span className={`text-xs px-2 py-1 rounded ${
portfolio.portfolio_type === 'pension'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{getTypeLabel(portfolio.portfolio_type)}
</span>
</div>
<p className="text-sm text-gray-500">
생성일: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')}
</p>
</Link>
))}
{portfolios.length === 0 && !error && (
<div className="col-span-full text-center py-12 text-gray-500">
아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요.
</div>
)}
</div>
</main>
</div>
</div>
);
}
Step 2: Run frontend build
cd /home/zephyrdark/workspace/quant/galaxy-po/frontend
npm run build
Step 3: Commit
git add frontend/src/app/portfolio/
git commit -m "feat: add portfolio list page"
Task 9: Frontend New Portfolio Page
Files:
- Create:
frontend/src/app/portfolio/new/page.tsx
Step 1: Create new portfolio page
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
export default function NewPortfolioPage() {
const router = useRouter();
const [name, setName] = useState('');
const [portfolioType, setPortfolioType] = useState('general');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const portfolio = await api.post('/api/portfolios', {
name,
portfolio_type: portfolioType,
});
router.push(`/portfolio/${(portfolio as { id: number }).id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create portfolio';
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">새 포트폴리오</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="max-w-md">
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
포트폴리오 이름
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="예: 퇴직연금 포트폴리오"
required
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
유형
</label>
<select
value={portfolioType}
onChange={(e) => setPortfolioType(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="general">일반</option>
<option value="pension">퇴직연금</option>
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => router.back()}
className="flex-1 px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
>
취소
</button>
<button
type="submit"
disabled={loading || !name}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
>
{loading ? '생성 중...' : '생성'}
</button>
</div>
</form>
</div>
</main>
</div>
</div>
);
}
Step 2: Commit
git add frontend/src/app/portfolio/new/
git commit -m "feat: add new portfolio creation page"
Task 10: Frontend Portfolio Detail Page
Files:
- Create:
frontend/src/app/portfolio/[id]/page.tsx
Step 1: Create portfolio detail page
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface HoldingWithValue {
ticker: string;
quantity: number;
avg_price: number;
current_price: number | null;
value: number | null;
current_ratio: number | null;
profit_loss: number | null;
profit_loss_ratio: number | null;
}
interface Target {
ticker: string;
target_ratio: number;
}
interface PortfolioDetail {
id: number;
name: string;
portfolio_type: string;
created_at: string;
updated_at: string;
targets: Target[];
holdings: HoldingWithValue[];
total_value: number | null;
total_invested: number | null;
total_profit_loss: number | null;
}
export default function PortfolioDetailPage() {
const router = useRouter();
const params = useParams();
const portfolioId = params.id as string;
const [loading, setLoading] = useState(true);
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
await fetchPortfolio();
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router, portfolioId]);
const fetchPortfolio = async () => {
try {
setError(null);
const data = await api.get<PortfolioDetail>(`/api/portfolios/${portfolioId}/detail`);
setPortfolio(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch portfolio';
setError(message);
}
};
const formatCurrency = (value: number | null) => {
if (value === null) return '-';
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(value);
};
const formatPercent = (value: number | null) => {
if (value === null) return '-';
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{portfolio && (
<>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800">{portfolio.name}</h1>
<span className={`text-xs px-2 py-1 rounded ${
portfolio.portfolio_type === 'pension'
? 'bg-purple-100 text-purple-800'
: 'bg-gray-100 text-gray-800'
}`}>
{portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'}
</span>
</div>
<Link
href={`/portfolio/${portfolioId}/rebalance`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
리밸런싱
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1">총 평가금액</p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_value)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1">총 투자금액</p>
<p className="text-2xl font-bold text-gray-800">
{formatCurrency(portfolio.total_invested)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-500 mb-1">총 손익</p>
<p className={`text-2xl font-bold ${
(portfolio.total_profit_loss ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(portfolio.total_profit_loss)}
</p>
</div>
</div>
{/* Holdings Table */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">보유 자산</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">수량</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">평균단가</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재가</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">평가금액</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">비중</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">손익률</th>
</tr>
</thead>
<tbody className="divide-y">
{portfolio.holdings.map((holding) => (
<tr key={holding.ticker}>
<td className="px-4 py-3 text-sm font-medium">{holding.ticker}</td>
<td className="px-4 py-3 text-sm text-right">{holding.quantity.toLocaleString()}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.avg_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.current_price)}</td>
<td className="px-4 py-3 text-sm text-right">{formatCurrency(holding.value)}</td>
<td className="px-4 py-3 text-sm text-right">{holding.current_ratio?.toFixed(2)}%</td>
<td className={`px-4 py-3 text-sm text-right ${
(holding.profit_loss_ratio ?? 0) >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{formatPercent(holding.profit_loss_ratio)}
</td>
</tr>
))}
{portfolio.holdings.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
보유 자산이 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
)}
</main>
</div>
</div>
);
}
Step 2: Commit
git add frontend/src/app/portfolio/
git commit -m "feat: add portfolio detail page with holdings"
Task 11: Frontend Rebalancing Page
Files:
- Create:
frontend/src/app/portfolio/[id]/rebalance/page.tsx
Step 1: Create rebalancing page
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { api } from '@/lib/api';
interface RebalanceItem {
ticker: string;
name: string | null;
target_ratio: number;
current_ratio: number;
current_quantity: number;
current_value: number;
target_value: number;
diff_value: number;
diff_quantity: number;
action: string;
}
interface RebalanceResponse {
portfolio_id: number;
total_value: number;
items: RebalanceItem[];
}
interface SimulationResponse extends RebalanceResponse {
current_total: number;
additional_amount: number;
new_total: number;
}
export default function RebalancePage() {
const router = useRouter();
const params = useParams();
const portfolioId = params.id as string;
const [loading, setLoading] = useState(true);
const [rebalance, setRebalance] = useState<RebalanceResponse | SimulationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [additionalAmount, setAdditionalAmount] = useState('');
const [simulating, setSimulating] = useState(false);
useEffect(() => {
const init = async () => {
try {
await api.getCurrentUser();
await fetchRebalance();
} catch {
router.push('/login');
} finally {
setLoading(false);
}
};
init();
}, [router, portfolioId]);
const fetchRebalance = async () => {
try {
setError(null);
const data = await api.get<RebalanceResponse>(`/api/portfolios/${portfolioId}/rebalance`);
setRebalance(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to calculate rebalance';
setError(message);
}
};
const simulate = async () => {
if (!additionalAmount) return;
setSimulating(true);
try {
setError(null);
const data = await api.post<SimulationResponse>(
`/api/portfolios/${portfolioId}/rebalance/simulate`,
{ additional_amount: parseFloat(additionalAmount) }
);
setRebalance(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Simulation failed';
setError(message);
} finally {
setSimulating(false);
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(value);
};
const getActionBadge = (action: string) => {
const styles: Record<string, string> = {
buy: 'bg-green-100 text-green-800',
sell: 'bg-red-100 text-red-800',
hold: 'bg-gray-100 text-gray-800',
};
const labels: Record<string, string> = {
buy: '매수',
sell: '매도',
hold: '유지',
};
return (
<span className={`px-2 py-1 rounded text-xs ${styles[action] || styles.hold}`}>
{labels[action] || action}
</span>
);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">
<Header />
<main className="p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">리밸런싱 계산</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
{/* Simulation Input */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
추가 입금액 (시뮬레이션)
</label>
<input
type="number"
value={additionalAmount}
onChange={(e) => setAdditionalAmount(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="예: 1000000"
/>
</div>
<button
onClick={simulate}
disabled={!additionalAmount || simulating}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
>
{simulating ? '계산 중...' : '시뮬레이션'}
</button>
<button
onClick={fetchRebalance}
className="px-4 py-2 border rounded hover:bg-gray-50 transition-colors"
>
초기화
</button>
</div>
</div>
{rebalance && (
<>
{/* Summary */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-500">현재 총액</p>
<p className="text-xl font-bold">
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
</p>
</div>
{'additional_amount' in rebalance && (
<>
<div>
<p className="text-sm text-gray-500">추가 입금</p>
<p className="text-xl font-bold text-blue-600">
+{formatCurrency(rebalance.additional_amount)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">새 총액</p>
<p className="text-xl font-bold">
{formatCurrency(rebalance.new_total)}
</p>
</div>
</>
)}
</div>
</div>
{/* Rebalance Table */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">리밸런싱 내역</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-gray-600">종목</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">목표 비중</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재 비중</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">현재 수량</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">조정 금액</th>
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-gray-600">조정 수량</th>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-gray-600">액션</th>
</tr>
</thead>
<tbody className="divide-y">
{rebalance.items.map((item) => (
<tr key={item.ticker}>
<td className="px-4 py-3">
<div className="font-medium">{item.ticker}</div>
{item.name && <div className="text-xs text-gray-500">{item.name}</div>}
</td>
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
<td className={`px-4 py-3 text-sm text-right ${
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
}`}>
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
</td>
<td className={`px-4 py-3 text-sm text-right font-medium ${
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : ''
}`}>
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
</td>
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</main>
</div>
</div>
);
}
Step 2: Commit
git add frontend/src/app/portfolio/
git commit -m "feat: add rebalancing calculation page"
Task 12: Verify Phase 3 Integration
Step 1: Verify all backend files exist
Check:
- backend/app/schemas/portfolio.py
- backend/app/api/portfolio.py
- backend/app/services/rebalance.py
Step 2: Verify all frontend files exist
Check:
- frontend/src/app/portfolio/page.tsx
- frontend/src/app/portfolio/new/page.tsx
- frontend/src/app/portfolio/[id]/page.tsx
- frontend/src/app/portfolio/[id]/rebalance/page.tsx
Step 3: Run frontend build
cd /home/zephyrdark/workspace/quant/galaxy-po/frontend
npm run build
Step 4: Show git log
cd /home/zephyrdark/workspace/quant/galaxy-po
git log --oneline -15
Summary
Phase 3 완료 시 구현된 기능:
- Portfolio Pydantic 스키마 (Target, Holding, Transaction, Portfolio, Rebalance)
- Portfolio CRUD API (생성, 조회, 수정, 삭제)
- Target 관리 API (목표 비율 설정)
- Holding 관리 API (보유 자산 설정)
- Transaction API (거래 기록 및 자동 보유량 업데이트)
- Rebalancing Service (리밸런싱 계산 로직)
- Rebalancing API (현재 상태 계산, 추가 입금 시뮬레이션)
- Portfolio Detail API (평가금액, 손익 계산 포함)
- Frontend 포트폴리오 목록 페이지
- Frontend 새 포트폴리오 생성 페이지
- Frontend 포트폴리오 상세 페이지
- Frontend 리밸런싱 계산 페이지
다음 Phase: 퀀트 전략 엔진 구현