galaxis-po/docs/plans/2026-02-03-phase3-portfolio-management.md
zephyrdark 914f0f7367 docs: add Phase 3 portfolio management implementation plan
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 06:42:06 +09:00

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: 퀀트 전략 엔진 구현