galaxis-po/docs/plans/2026-02-02-phase1-foundation.md
zephyrdark 5c2f92438f docs: add Phase 1 foundation implementation plan
Detailed step-by-step plan for:
- Project structure initialization
- Docker Compose configuration
- Database models and migrations
- Authentication API
- Frontend base layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:40:38 +09:00

42 KiB

Phase 1: Foundation Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Set up the foundational project structure with FastAPI backend, Next.js frontend, PostgreSQL database, and Docker Compose orchestration.

Architecture: Monorepo structure with separate backend (FastAPI + Python 3.14) and frontend (Next.js 15 + Node 24) directories. PostgreSQL 15 for persistence. JWT-based authentication. All services containerized with Docker Compose.

Tech Stack: FastAPI, SQLAlchemy, Alembic, Next.js 15, React 19, TypeScript, Tailwind CSS, PostgreSQL 15, Docker


Task 1: Project Structure Initialization

Files:

  • Create: backend/app/__init__.py
  • Create: backend/app/main.py
  • Create: backend/requirements.txt
  • Create: frontend/package.json
  • Create: .gitignore
  • Create: README.md

Step 1: Create backend directory structure

mkdir -p backend/app/{api,core,models,schemas,services}
mkdir -p backend/app/api
mkdir -p backend/jobs/collectors
mkdir -p backend/tests

Step 2: Create backend/init.py files

Create backend/app/__init__.py:

Create backend/app/api/__init__.py:

Create backend/app/core/__init__.py:

Create backend/app/models/__init__.py:

Create backend/app/schemas/__init__.py:

Create backend/app/services/__init__.py:

Step 3: Create backend/requirements.txt

fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
apscheduler==3.10.4
pykrx==1.0.45
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.3.0
pandas==2.2.3
numpy==2.2.1
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.25.2

Step 4: Create backend/app/main.py

"""
Galaxy-PO Backend API
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(
    title="Galaxy-PO API",
    description="Quant Portfolio Management API",
    version="0.1.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/health")
async def health_check():
    return {"status": "healthy"}

Step 5: Create frontend with Next.js

cd /home/zephyrdark/workspace/quant/galaxy-po
npx create-next-app@latest frontend --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm

Step 6: Create .gitignore

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.venv/
venv/
ENV/

# Node
node_modules/
.next/
out/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# IDE
.idea/
.vscode/
*.swp
*.swo

# Environment
.env
.env.local
.env.*.local

# Docker
*.log

# OS
.DS_Store
Thumbs.db

# Database
*.db
*.sqlite3

# Test
.coverage
htmlcov/
.pytest_cache/

Step 7: Create README.md

# Galaxy-PO

Integrated Quant Portfolio Management Application

## Tech Stack

- **Backend:** FastAPI, Python 3.14, SQLAlchemy, PostgreSQL
- **Frontend:** Next.js 15, React 19, TypeScript, Tailwind CSS
- **Infrastructure:** Docker, Docker Compose

## Development

### Prerequisites

- Docker & Docker Compose
- Python 3.14
- Node.js 24

### Quick Start

```bash
# Start all services
docker-compose up -d

# Backend only (development)
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload

# Frontend only (development)
cd frontend
npm install
npm run dev

Project Structure

galaxy-po/
├── backend/           # FastAPI backend
├── frontend/          # Next.js frontend
├── docker-compose.yml
└── docs/plans/        # Implementation plans

**Step 8: Commit**

```bash
git add -A
git commit -m "feat: initialize project structure with backend and frontend scaffolding"

Task 2: Docker Compose Configuration

Files:

  • Create: docker-compose.yml
  • Create: backend/Dockerfile
  • Create: frontend/Dockerfile
  • Create: .env.example

Step 1: Create docker-compose.yml

version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    container_name: galaxy-po-db
    environment:
      POSTGRES_USER: galaxy
      POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
      POSTGRES_DB: galaxy_po
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U galaxy -d galaxy_po"]
      interval: 5s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: galaxy-po-backend
    environment:
      DATABASE_URL: postgresql://galaxy:${DB_PASSWORD:-devpassword}@postgres:5432/galaxy_po
      JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-in-production}
      KIS_APP_KEY: ${KIS_APP_KEY:-}
      KIS_APP_SECRET: ${KIS_APP_SECRET:-}
      DART_API_KEY: ${DART_API_KEY:-}
    ports:
      - "8000:8000"
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./backend:/app
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    container_name: galaxy-po-frontend
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8000
    ports:
      - "3000:3000"
    depends_on:
      - backend
    volumes:
      - ./frontend:/app
      - /app/node_modules
      - /app/.next

volumes:
  postgres_data:

Step 2: Create backend/Dockerfile

FROM python:3.14-slim

WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Expose port
EXPOSE 8000

# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Step 3: Create frontend/Dockerfile

FROM node:24-alpine

WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm ci

# Copy application code
COPY . .

# Expose port
EXPOSE 3000

# Development command
CMD ["npm", "run", "dev"]

Step 4: Create .env.example

# Database
DB_PASSWORD=your_secure_password_here

# JWT Authentication
JWT_SECRET=your_jwt_secret_key_here

# Korea Investment & Securities OpenAPI
KIS_APP_KEY=your_kis_app_key
KIS_APP_SECRET=your_kis_app_secret
KIS_ACCOUNT_NO=your_account_number

# DART OpenAPI (Financial Statements)
DART_API_KEY=your_dart_api_key

Step 5: Verify Docker Compose syntax

docker-compose config

Expected: Valid YAML output without errors

Step 6: Commit

git add docker-compose.yml backend/Dockerfile frontend/Dockerfile .env.example
git commit -m "feat: add Docker Compose configuration for all services"

Task 3: Database Configuration and Core Settings

Files:

  • Create: backend/app/core/config.py
  • Create: backend/app/core/database.py
  • Create: backend/app/core/security.py

Step 1: Create backend/app/core/config.py

"""
Application configuration using Pydantic Settings.
"""
from pydantic_settings import BaseSettings
from functools import lru_cache


class Settings(BaseSettings):
    # Application
    app_name: str = "Galaxy-PO"
    debug: bool = False

    # Database
    database_url: str = "postgresql://galaxy:devpassword@localhost:5432/galaxy_po"

    # JWT
    jwt_secret: str = "dev-jwt-secret-change-in-production"
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 60 * 24  # 24 hours

    # External APIs
    kis_app_key: str = ""
    kis_app_secret: str = ""
    kis_account_no: str = ""
    dart_api_key: str = ""

    class Config:
        env_file = ".env"
        case_sensitive = False


@lru_cache
def get_settings() -> Settings:
    return Settings()

Step 2: Create backend/app/core/database.py

"""
Database connection and session management.
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import get_settings

settings = get_settings()

engine = create_engine(
    settings.database_url,
    pool_pre_ping=True,
    pool_size=10,
    max_overflow=20,
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()


def get_db():
    """Dependency for getting database session."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Step 3: Create backend/app/core/security.py

"""
Security utilities for authentication.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional

from jose import JWTError, jwt
from passlib.context import CryptContext

from app.core.config import get_settings

settings = get_settings()

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def verify_password(plain_password: str, hashed_password: str) -> bool:
    """Verify a password against its hash."""
    return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password: str) -> str:
    """Hash a password."""
    return pwd_context.hash(password)


def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    """Create a JWT access token."""
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(
            minutes=settings.access_token_expire_minutes
        )
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(
        to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm
    )
    return encoded_jwt


def decode_access_token(token: str) -> Optional[dict]:
    """Decode and verify a JWT access token."""
    try:
        payload = jwt.decode(
            token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
        )
        return payload
    except JWTError:
        return None

Step 4: Update backend/app/core/init.py

from app.core.config import get_settings, Settings
from app.core.database import get_db, Base, engine, SessionLocal
from app.core.security import (
    verify_password,
    get_password_hash,
    create_access_token,
    decode_access_token,
)

__all__ = [
    "get_settings",
    "Settings",
    "get_db",
    "Base",
    "engine",
    "SessionLocal",
    "verify_password",
    "get_password_hash",
    "create_access_token",
    "decode_access_token",
]

Step 5: Commit

git add backend/app/core/
git commit -m "feat: add core configuration, database, and security modules"

Task 4: Database Models

Files:

  • Create: backend/app/models/user.py
  • Create: backend/app/models/portfolio.py
  • Create: backend/app/models/stock.py
  • Update: backend/app/models/__init__.py

Step 1: Create backend/app/models/user.py

"""
User model for authentication.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from app.core.database import Base


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String(50), unique=True, index=True, nullable=False)
    email = Column(String(100), unique=True, index=True, nullable=False)
    hashed_password = Column(String(255), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

Step 2: Create backend/app/models/portfolio.py

"""
Portfolio related models.
"""
from datetime import datetime
from sqlalchemy import (
    Column, Integer, String, Numeric, DateTime, Date,
    ForeignKey, Text, Enum as SQLEnum
)
from sqlalchemy.orm import relationship
import enum

from app.core.database import Base


class PortfolioType(str, enum.Enum):
    PENSION = "pension"
    GENERAL = "general"


class TransactionType(str, enum.Enum):
    BUY = "buy"
    SELL = "sell"


class Portfolio(Base):
    __tablename__ = "portfolios"

    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    name = Column(String(100), nullable=False)
    portfolio_type = Column(SQLEnum(PortfolioType), default=PortfolioType.GENERAL)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    # Relationships
    targets = relationship("Target", back_populates="portfolio", cascade="all, delete-orphan")
    holdings = relationship("Holding", back_populates="portfolio", cascade="all, delete-orphan")
    transactions = relationship("Transaction", back_populates="portfolio", cascade="all, delete-orphan")
    snapshots = relationship("PortfolioSnapshot", back_populates="portfolio", cascade="all, delete-orphan")


class Target(Base):
    __tablename__ = "targets"

    portfolio_id = Column(Integer, ForeignKey("portfolios.id"), primary_key=True)
    ticker = Column(String(20), primary_key=True)
    target_ratio = Column(Numeric(5, 2), nullable=False)  # e.g., 25.00%

    portfolio = relationship("Portfolio", back_populates="targets")


class Holding(Base):
    __tablename__ = "holdings"

    portfolio_id = Column(Integer, ForeignKey("portfolios.id"), primary_key=True)
    ticker = Column(String(20), primary_key=True)
    quantity = Column(Integer, nullable=False, default=0)
    avg_price = Column(Numeric(12, 2), nullable=False, default=0)

    portfolio = relationship("Portfolio", back_populates="holdings")


class Transaction(Base):
    __tablename__ = "transactions"

    id = Column(Integer, primary_key=True, index=True)
    portfolio_id = Column(Integer, ForeignKey("portfolios.id"), nullable=False)
    ticker = Column(String(20), nullable=False)
    tx_type = Column(SQLEnum(TransactionType), nullable=False)
    quantity = Column(Integer, nullable=False)
    price = Column(Numeric(12, 2), nullable=False)
    executed_at = Column(DateTime, nullable=False)
    memo = Column(Text, nullable=True)

    portfolio = relationship("Portfolio", back_populates="transactions")


class PortfolioSnapshot(Base):
    __tablename__ = "portfolio_snapshots"

    id = Column(Integer, primary_key=True, index=True)
    portfolio_id = Column(Integer, ForeignKey("portfolios.id"), nullable=False)
    total_value = Column(Numeric(15, 2), nullable=False)
    snapshot_date = Column(Date, nullable=False)

    portfolio = relationship("Portfolio", back_populates="snapshots")
    holdings = relationship("SnapshotHolding", back_populates="snapshot", cascade="all, delete-orphan")


class SnapshotHolding(Base):
    __tablename__ = "snapshot_holdings"

    snapshot_id = Column(Integer, ForeignKey("portfolio_snapshots.id"), primary_key=True)
    ticker = Column(String(20), primary_key=True)
    quantity = Column(Integer, nullable=False)
    price = Column(Numeric(12, 2), nullable=False)
    value = Column(Numeric(15, 2), nullable=False)
    current_ratio = Column(Numeric(5, 2), nullable=False)

    snapshot = relationship("PortfolioSnapshot", back_populates="holdings")

Step 3: Create backend/app/models/stock.py

"""
Stock and market data models.
"""
from datetime import datetime
from sqlalchemy import (
    Column, Integer, String, Numeric, DateTime, Date,
    BigInteger, Text, Enum as SQLEnum
)
import enum

from app.core.database import Base


class StockType(str, enum.Enum):
    COMMON = "common"
    SPAC = "spac"
    PREFERRED = "preferred"
    REIT = "reit"
    OTHER = "other"


class ReportType(str, enum.Enum):
    ANNUAL = "annual"
    QUARTERLY = "quarterly"


class AssetClass(str, enum.Enum):
    EQUITY = "equity"
    BOND = "bond"
    GOLD = "gold"
    MIXED = "mixed"


class Stock(Base):
    __tablename__ = "stocks"

    ticker = Column(String(20), primary_key=True)
    name = Column(String(100), nullable=False)
    market = Column(String(20), nullable=False)  # KOSPI, KOSDAQ
    close_price = Column(Numeric(12, 2), nullable=True)
    market_cap = Column(BigInteger, nullable=True)
    eps = Column(Numeric(12, 2), nullable=True)
    forward_eps = Column(Numeric(12, 2), nullable=True)
    bps = Column(Numeric(12, 2), nullable=True)
    dividend_per_share = Column(Numeric(12, 2), nullable=True)
    stock_type = Column(SQLEnum(StockType), default=StockType.COMMON)
    base_date = Column(Date, nullable=False)


class Sector(Base):
    __tablename__ = "sectors"

    ticker = Column(String(20), primary_key=True)
    sector_code = Column(String(10), nullable=False)
    company_name = Column(String(100), nullable=False)
    sector_name = Column(String(50), nullable=False)
    base_date = Column(Date, nullable=False)


class Valuation(Base):
    __tablename__ = "valuations"

    ticker = Column(String(20), primary_key=True)
    base_date = Column(Date, primary_key=True)
    per = Column(Numeric(10, 2), nullable=True)
    pbr = Column(Numeric(10, 2), nullable=True)
    psr = Column(Numeric(10, 2), nullable=True)
    pcr = Column(Numeric(10, 2), nullable=True)
    dividend_yield = Column(Numeric(6, 2), nullable=True)


class Price(Base):
    __tablename__ = "prices"

    ticker = Column(String(20), primary_key=True)
    date = Column(Date, primary_key=True)
    open = Column(Numeric(12, 2), nullable=False)
    high = Column(Numeric(12, 2), nullable=False)
    low = Column(Numeric(12, 2), nullable=False)
    close = Column(Numeric(12, 2), nullable=False)
    volume = Column(BigInteger, nullable=False)


class Financial(Base):
    __tablename__ = "financials"

    ticker = Column(String(20), primary_key=True)
    base_date = Column(Date, primary_key=True)
    report_type = Column(SQLEnum(ReportType), primary_key=True)
    account = Column(String(50), primary_key=True)
    value = Column(Numeric(20, 2), nullable=True)


class ETF(Base):
    __tablename__ = "etfs"

    ticker = Column(String(20), primary_key=True)
    name = Column(String(100), nullable=False)
    asset_class = Column(SQLEnum(AssetClass), nullable=False)
    market = Column(String(20), nullable=False)
    expense_ratio = Column(Numeric(5, 4), nullable=True)


class ETFPrice(Base):
    __tablename__ = "etf_prices"

    ticker = Column(String(20), primary_key=True)
    date = Column(Date, primary_key=True)
    close = Column(Numeric(12, 2), nullable=False)
    nav = Column(Numeric(12, 2), nullable=True)
    volume = Column(BigInteger, nullable=True)


class JobLog(Base):
    __tablename__ = "job_logs"

    id = Column(Integer, primary_key=True, index=True)
    job_name = Column(String(50), nullable=False)
    status = Column(String(20), nullable=False)  # running, success, failed
    started_at = Column(DateTime, default=datetime.utcnow)
    finished_at = Column(DateTime, nullable=True)
    records_count = Column(Integer, nullable=True)
    error_msg = Column(Text, nullable=True)

Step 4: Update backend/app/models/init.py

from app.models.user import User
from app.models.portfolio import (
    Portfolio,
    PortfolioType,
    Target,
    Holding,
    Transaction,
    TransactionType,
    PortfolioSnapshot,
    SnapshotHolding,
)
from app.models.stock import (
    Stock,
    StockType,
    Sector,
    Valuation,
    Price,
    Financial,
    ReportType,
    ETF,
    ETFPrice,
    AssetClass,
    JobLog,
)

__all__ = [
    "User",
    "Portfolio",
    "PortfolioType",
    "Target",
    "Holding",
    "Transaction",
    "TransactionType",
    "PortfolioSnapshot",
    "SnapshotHolding",
    "Stock",
    "StockType",
    "Sector",
    "Valuation",
    "Price",
    "Financial",
    "ReportType",
    "ETF",
    "ETFPrice",
    "AssetClass",
    "JobLog",
]

Step 5: Commit

git add backend/app/models/
git commit -m "feat: add database models for users, portfolios, and market data"

Task 5: Alembic Migration Setup

Files:

  • Create: backend/alembic.ini
  • Create: backend/alembic/env.py
  • Create: backend/alembic/script.py.mako
  • Create: backend/alembic/versions/ (directory)

Step 1: Initialize Alembic

cd /home/zephyrdark/workspace/quant/galaxy-po/backend
pip install alembic
alembic init alembic

Step 2: Update backend/alembic.ini

Replace the sqlalchemy.url line:

# Change this line:
sqlalchemy.url = driver://user:pass@localhost/dbname

# To:
sqlalchemy.url = postgresql://galaxy:devpassword@localhost:5432/galaxy_po

Step 3: Update backend/alembic/env.py

import os
import sys
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

# Add backend to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app.core.database import Base
from app.models import *  # noqa: Import all models

# this is the Alembic Config object
config = context.config

# Override sqlalchemy.url with environment variable if available
database_url = os.getenv("DATABASE_URL")
if database_url:
    config.set_main_option("sqlalchemy.url", database_url)

# Interpret the config file for Python logging.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# Model's MetaData object for 'autogenerate' support
target_metadata = Base.metadata


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode."""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    """Run migrations in 'online' mode."""
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

Step 4: Create initial migration

cd /home/zephyrdark/workspace/quant/galaxy-po/backend
alembic revision --autogenerate -m "Initial tables"

Step 5: Commit

git add backend/alembic.ini backend/alembic/
git commit -m "feat: add Alembic migration setup with initial tables"

Task 6: Authentication API

Files:

  • Create: backend/app/schemas/auth.py
  • Create: backend/app/schemas/user.py
  • Create: backend/app/api/auth.py
  • Create: backend/app/api/deps.py
  • Update: backend/app/main.py

Step 1: Create backend/app/schemas/user.py

"""
User schemas for request/response validation.
"""
from datetime import datetime
from pydantic import BaseModel, EmailStr


class UserBase(BaseModel):
    username: str
    email: EmailStr


class UserCreate(UserBase):
    password: str


class UserResponse(UserBase):
    id: int
    created_at: datetime

    class Config:
        from_attributes = True

Step 2: Create backend/app/schemas/auth.py

"""
Authentication schemas.
"""
from pydantic import BaseModel


class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"


class TokenPayload(BaseModel):
    sub: str | None = None


class LoginRequest(BaseModel):
    username: str
    password: str

Step 3: Update backend/app/schemas/init.py

from app.schemas.user import UserBase, UserCreate, UserResponse
from app.schemas.auth import Token, TokenPayload, LoginRequest

__all__ = [
    "UserBase",
    "UserCreate",
    "UserResponse",
    "Token",
    "TokenPayload",
    "LoginRequest",
]

Step 4: Create backend/app/api/deps.py

"""
API dependencies.
"""
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")


async def get_current_user(
    db: Annotated[Session, Depends(get_db)],
    token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
    """Get the current authenticated user."""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )

    payload = decode_access_token(token)
    if payload is None:
        raise credentials_exception

    username: str = payload.get("sub")
    if username is None:
        raise credentials_exception

    user = db.query(User).filter(User.username == username).first()
    if user is None:
        raise credentials_exception

    return user


CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[Session, Depends(get_db)]

Step 5: Create backend/app/api/auth.py

"""
Authentication API endpoints.
"""
from datetime import timedelta
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.security import (
    verify_password,
    get_password_hash,
    create_access_token,
)
from app.core.config import get_settings
from app.models.user import User
from app.schemas import Token, UserCreate, UserResponse, LoginRequest
from app.api.deps import CurrentUser

router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()


@router.post("/login", response_model=Token)
async def login(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: Annotated[Session, Depends(get_db)],
):
    """Login and get access token."""
    user = db.query(User).filter(User.username == form_data.username).first()

    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
    )

    return Token(access_token=access_token)


@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
    user_data: UserCreate,
    db: Annotated[Session, Depends(get_db)],
):
    """Register a new user."""
    # Check if username exists
    if db.query(User).filter(User.username == user_data.username).first():
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered",
        )

    # Check if email exists
    if db.query(User).filter(User.email == user_data.email).first():
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Email already registered",
        )

    # Create user
    user = User(
        username=user_data.username,
        email=user_data.email,
        hashed_password=get_password_hash(user_data.password),
    )
    db.add(user)
    db.commit()
    db.refresh(user)

    return user


@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser):
    """Get current user information."""
    return current_user


@router.post("/logout")
async def logout():
    """Logout (client should discard token)."""
    return {"message": "Successfully logged out"}

Step 6: Update backend/app/api/init.py

from app.api.auth import router as auth_router

__all__ = ["auth_router"]

Step 7: Update backend/app/main.py

"""
Galaxy-PO Backend API
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api import auth_router

app = FastAPI(
    title="Galaxy-PO API",
    description="Quant Portfolio Management API",
    version="0.1.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Include routers
app.include_router(auth_router)


@app.get("/health")
async def health_check():
    return {"status": "healthy"}

Step 8: Commit

git add backend/app/schemas/ backend/app/api/ backend/app/main.py
git commit -m "feat: add authentication API with login, register, and user endpoints"

Task 7: Frontend Base Layout

Files:

  • Update: frontend/src/app/layout.tsx
  • Update: frontend/src/app/page.tsx
  • Create: frontend/src/app/globals.css (update)
  • Create: frontend/src/components/layout/Sidebar.tsx
  • Create: frontend/src/components/layout/Header.tsx
  • Create: frontend/src/lib/api.ts

Step 1: Create frontend/src/lib/api.ts

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';

class ApiClient {
  private baseUrl: string;
  private token: string | null = null;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
    if (typeof window !== 'undefined') {
      this.token = localStorage.getItem('token');
    }
  }

  setToken(token: string) {
    this.token = token;
    if (typeof window !== 'undefined') {
      localStorage.setItem('token', token);
    }
  }

  clearToken() {
    this.token = null;
    if (typeof window !== 'undefined') {
      localStorage.removeItem('token');
    }
  }

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const headers: HeadersInit = {
      'Content-Type': 'application/json',
      ...options.headers,
    };

    if (this.token) {
      (headers as Record<string, string>)['Authorization'] = `Bearer ${this.token}`;
    }

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers,
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new Error(error.detail || 'API request failed');
    }

    return response.json();
  }

  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }

  async post<T>(endpoint: string, data?: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async put<T>(endpoint: string, data?: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'PUT',
      body: data ? JSON.stringify(data) : undefined,
    });
  }

  async delete<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'DELETE' });
  }

  // Auth methods
  async login(username: string, password: string) {
    const formData = new URLSearchParams();
    formData.append('username', username);
    formData.append('password', password);

    const response = await fetch(`${this.baseUrl}/api/auth/login`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: formData,
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new Error(error.detail || 'Login failed');
    }

    const data = await response.json();
    this.setToken(data.access_token);
    return data;
  }

  logout() {
    this.clearToken();
  }

  async getCurrentUser() {
    return this.get('/api/auth/me');
  }
}

export const api = new ApiClient(API_URL);

Step 2: Create frontend/src/components/layout/Sidebar.tsx

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

const menuItems = [
  { href: '/', label: '대시보드', icon: '📊' },
  { href: '/portfolio', label: '포트폴리오', icon: '💼' },
  { href: '/strategy', label: '퀀트 전략', icon: '📈' },
  { href: '/backtest', label: '백테스트', icon: '🔬' },
  { href: '/market', label: '시세 조회', icon: '💹' },
  { href: '/admin/data', label: '데이터 관리', icon: '⚙️' },
];

export default function Sidebar() {
  const pathname = usePathname();

  return (
    <aside className="w-64 bg-gray-900 text-white min-h-screen p-4">
      <div className="mb-8">
        <h1 className="text-xl font-bold">Galaxy-PO</h1>
        <p className="text-gray-400 text-sm">Quant Portfolio Manager</p>
      </div>

      <nav>
        <ul className="space-y-2">
          {menuItems.map((item) => {
            const isActive = pathname === item.href ||
              (item.href !== '/' && pathname.startsWith(item.href));

            return (
              <li key={item.href}>
                <Link
                  href={item.href}
                  className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
                    isActive
                      ? 'bg-blue-600 text-white'
                      : 'text-gray-300 hover:bg-gray-800'
                  }`}
                >
                  <span>{item.icon}</span>
                  <span>{item.label}</span>
                </Link>
              </li>
            );
          })}
        </ul>
      </nav>
    </aside>
  );
}

Step 3: Create frontend/src/components/layout/Header.tsx

'use client';

import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';

interface HeaderProps {
  username?: string;
}

export default function Header({ username }: HeaderProps) {
  const router = useRouter();

  const handleLogout = () => {
    api.logout();
    router.push('/login');
  };

  return (
    <header className="bg-white border-b border-gray-200 px-6 py-4">
      <div className="flex justify-between items-center">
        <div>
          <h2 className="text-lg font-semibold text-gray-800">
            환영합니다{username ? `, ${username}` : ''}
          </h2>
        </div>
        <div className="flex items-center gap-4">
          <button
            onClick={handleLogout}
            className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
          >
            로그아웃
          </button>
        </div>
      </div>
    </header>
  );
}

Step 4: Update frontend/src/app/globals.css

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --foreground-rgb: 0, 0, 0;
  --background-rgb: 249, 250, 251;
}

body {
  color: rgb(var(--foreground-rgb));
  background: rgb(var(--background-rgb));
}

Step 5: Update frontend/src/app/layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Galaxy-PO',
  description: 'Quant Portfolio Management Application',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Step 6: Update frontend/src/app/page.tsx

'use client';

import { useEffect, 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';

interface User {
  id: number;
  username: string;
  email: string;
}

export default function Dashboard() {
  const router = useRouter();
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const checkAuth = async () => {
      try {
        const userData = await api.getCurrentUser() as User;
        setUser(userData);
      } catch {
        router.push('/login');
      } finally {
        setLoading(false);
      }
    };

    checkAuth();
  }, [router]);

  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">
          <h1 className="text-2xl font-bold text-gray-800 mb-6">대시보드</h1>

          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
            <div className="bg-white rounded-lg shadow p-6">
              <h3 className="text-gray-500 text-sm mb-1"> 자산</h3>
              <p className="text-2xl font-bold text-gray-800">0</p>
            </div>
            <div className="bg-white rounded-lg shadow p-6">
              <h3 className="text-gray-500 text-sm mb-1"> 수익률</h3>
              <p className="text-2xl font-bold text-green-600">+0.00%</p>
            </div>
            <div className="bg-white rounded-lg shadow p-6">
              <h3 className="text-gray-500 text-sm mb-1">포트폴리오</h3>
              <p className="text-2xl font-bold text-gray-800">0</p>
            </div>
            <div className="bg-white rounded-lg shadow p-6">
              <h3 className="text-gray-500 text-sm mb-1">리밸런싱 필요</h3>
              <p className="text-2xl font-bold text-orange-600">0</p>
            </div>
          </div>

          <div className="bg-white rounded-lg shadow p-6">
            <h2 className="text-lg font-semibold text-gray-800 mb-4">최근 활동</h2>
            <p className="text-gray-500">아직 활동 내역이 없습니다.</p>
          </div>
        </main>
      </div>
    </div>
  );
}

Step 7: Create frontend/src/app/login/page.tsx

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';

export default function LoginPage() {
  const router = useRouter();
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      await api.login(username, password);
      router.push('/');
    } catch (err) {
      setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-100">
      <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
        <h1 className="text-2xl font-bold text-center text-gray-800 mb-8">
          Galaxy-PO
        </h1>

        <form onSubmit={handleSubmit} className="space-y-6">
          {error && (
            <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
              {error}
            </div>
          )}

          <div>
            <label
              htmlFor="username"
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              사용자명
            </label>
            <input
              id="username"
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
            />
          </div>

          <div>
            <label
              htmlFor="password"
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              비밀번호
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              required
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 transition-colors"
          >
            {loading ? '로그인 중...' : '로그인'}
          </button>
        </form>
      </div>
    </div>
  );
}

Step 8: Commit

git add frontend/src/
git commit -m "feat: add frontend base layout with sidebar, header, and login page"

Task 8: Verify Full Stack Integration

Step 1: Start PostgreSQL

cd /home/zephyrdark/workspace/quant/galaxy-po
docker-compose up -d postgres

Expected: PostgreSQL container running

Step 2: Run migrations

cd /home/zephyrdark/workspace/quant/galaxy-po/backend
pip install -r requirements.txt
alembic upgrade head

Expected: All migrations applied successfully

Step 3: Start backend

cd /home/zephyrdark/workspace/quant/galaxy-po/backend
uvicorn app.main:app --reload

Expected: Server running at http://localhost:8000

Step 4: Test health endpoint

curl http://localhost:8000/health

Expected: {"status":"healthy"}

Step 5: Test user registration

curl -X POST http://localhost:8000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"testuser","email":"test@example.com","password":"testpass123"}'

Expected: User created with id, username, email, created_at

Step 6: Test login

curl -X POST http://localhost:8000/api/auth/login \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=testuser&password=testpass123"

Expected: {"access_token":"...","token_type":"bearer"}

Step 7: Start frontend (in new terminal)

cd /home/zephyrdark/workspace/quant/galaxy-po/frontend
npm install
npm run dev

Expected: Frontend running at http://localhost:3000

Step 8: Final commit

git add -A
git commit -m "feat: complete Phase 1 foundation setup"

Summary

Phase 1 완료 시 구현된 기능:

  • FastAPI 백엔드 기본 구조
  • PostgreSQL 데이터베이스 연결
  • 모든 데이터베이스 모델 (users, portfolios, stocks, etc.)
  • Alembic 마이그레이션 설정
  • JWT 인증 (login, register, me)
  • Next.js 프론트엔드 기본 구조
  • 로그인 페이지
  • 대시보드 레이아웃 (사이드바, 헤더)
  • Docker Compose 설정

다음 Phase: 데이터 수집 기능 구현