diff --git a/docs/plans/2026-02-03-phase3-portfolio-management.md b/docs/plans/2026-02-03-phase3-portfolio-management.md new file mode 100644 index 0000000..940f801 --- /dev/null +++ b/docs/plans/2026-02-03-phase3-portfolio-management.md @@ -0,0 +1,1676 @@ +# 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** + +```python +""" +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** + +```python +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** + +```bash +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** + +```python +""" +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +""" +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** + +```bash +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** + +```python +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** + +```bash +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** + +```python +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** + +```bash +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** + +```typescript +'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(null); + const [loading, setLoading] = useState(true); + const [portfolios, setPortfolios] = useState([]); + const [error, setError] = useState(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('/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+
+

포트폴리오

+ + 새 포트폴리오 + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {portfolios.map((portfolio) => ( + +
+

+ {portfolio.name} +

+ + {getTypeLabel(portfolio.portfolio_type)} + +
+

+ 생성일: {new Date(portfolio.created_at).toLocaleDateString('ko-KR')} +

+ + ))} + + {portfolios.length === 0 && !error && ( +
+ 아직 포트폴리오가 없습니다. 새 포트폴리오를 생성해보세요. +
+ )} +
+
+
+
+ ); +} +``` + +**Step 2: Run frontend build** + +```bash +cd /home/zephyrdark/workspace/quant/galaxy-po/frontend +npm run build +``` + +**Step 3: Commit** + +```bash +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** + +```typescript +'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(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 ( +
+ +
+
+
+

새 포트폴리오

+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + 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 + /> +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +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** + +```typescript +'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(null); + const [error, setError] = useState(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(`/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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+ {error && ( +
+ {error} +
+ )} + + {portfolio && ( + <> +
+
+

{portfolio.name}

+ + {portfolio.portfolio_type === 'pension' ? '퇴직연금' : '일반'} + +
+ + 리밸런싱 + +
+ + {/* Summary Cards */} +
+
+

총 평가금액

+

+ {formatCurrency(portfolio.total_value)} +

+
+
+

총 투자금액

+

+ {formatCurrency(portfolio.total_invested)} +

+
+
+

총 손익

+

= 0 ? 'text-green-600' : 'text-red-600' + }`}> + {formatCurrency(portfolio.total_profit_loss)} +

+
+
+ + {/* Holdings Table */} +
+
+

보유 자산

+
+
+ + + + + + + + + + + + + + {portfolio.holdings.map((holding) => ( + + + + + + + + + + ))} + {portfolio.holdings.length === 0 && ( + + + + )} + +
종목수량평균단가현재가평가금액비중손익률
{holding.ticker}{holding.quantity.toLocaleString()}{formatCurrency(holding.avg_price)}{formatCurrency(holding.current_price)}{formatCurrency(holding.value)}{holding.current_ratio?.toFixed(2)}%= 0 ? 'text-green-600' : 'text-red-600' + }`}> + {formatPercent(holding.profit_loss_ratio)} +
+ 보유 자산이 없습니다. +
+
+
+ + )} +
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +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** + +```typescript +'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(null); + const [error, setError] = useState(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(`/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( + `/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 = { + buy: 'bg-green-100 text-green-800', + sell: 'bg-red-100 text-red-800', + hold: 'bg-gray-100 text-gray-800', + }; + const labels: Record = { + buy: '매수', + sell: '매도', + hold: '유지', + }; + return ( + + {labels[action] || action} + + ); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

리밸런싱 계산

+ + {error && ( +
+ {error} +
+ )} + + {/* Simulation Input */} +
+
+
+ + 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" + /> +
+ + +
+
+ + {rebalance && ( + <> + {/* Summary */} +
+
+
+

현재 총액

+

+ {formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)} +

+
+ {'additional_amount' in rebalance && ( + <> +
+

추가 입금

+

+ +{formatCurrency(rebalance.additional_amount)} +

+
+
+

새 총액

+

+ {formatCurrency(rebalance.new_total)} +

+
+ + )} +
+
+ + {/* Rebalance Table */} +
+
+

리밸런싱 내역

+
+
+ + + + + + + + + + + + + + {rebalance.items.map((item) => ( + + + + + + + + + + ))} + +
종목목표 비중현재 비중현재 수량조정 금액조정 수량액션
+
{item.ticker}
+ {item.name &&
{item.name}
} +
{item.target_ratio.toFixed(2)}%{item.current_ratio.toFixed(2)}%{item.current_quantity.toLocaleString()} 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : '' + }`}> + {item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)} + 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' + }`}> + {item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity} + {getActionBadge(item.action)}
+
+
+ + )} +
+
+
+ ); +} +``` + +**Step 2: Commit** + +```bash +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** + +```bash +cd /home/zephyrdark/workspace/quant/galaxy-po/frontend +npm run build +``` + +**Step 4: Show git log** + +```bash +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: 퀀트 전략 엔진 구현