From 5c2f92438f75c67a14374b4678f0255b6dab44d9 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Mon, 2 Feb 2026 22:40:38 +0900 Subject: [PATCH] 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 --- docs/plans/2026-02-02-phase1-foundation.md | 1819 ++++++++++++++++++++ 1 file changed, 1819 insertions(+) create mode 100644 docs/plans/2026-02-02-phase1-foundation.md diff --git a/docs/plans/2026-02-02-phase1-foundation.md b/docs/plans/2026-02-02-phase1-foundation.md new file mode 100644 index 0000000..50559b5 --- /dev/null +++ b/docs/plans/2026-02-02-phase1-foundation.md @@ -0,0 +1,1819 @@ +# 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** + +```bash +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`: +```python +``` + +Create `backend/app/api/__init__.py`: +```python +``` + +Create `backend/app/core/__init__.py`: +```python +``` + +Create `backend/app/models/__init__.py`: +```python +``` + +Create `backend/app/schemas/__init__.py`: +```python +``` + +Create `backend/app/services/__init__.py`: +```python +``` + +**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** + +```python +""" +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** + +```bash +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** + +```markdown +# 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** + +```yaml +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** + +```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** + +```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** + +```bash +# 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** + +```bash +docker-compose config +``` + +Expected: Valid YAML output without errors + +**Step 6: Commit** + +```bash +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** + +```python +""" +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** + +```python +""" +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** + +```python +""" +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** + +```python +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** + +```bash +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** + +```python +""" +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** + +```python +""" +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** + +```python +""" +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** + +```python +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** + +```bash +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** + +```bash +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: + +```ini +# 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** + +```python +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** + +```bash +cd /home/zephyrdark/workspace/quant/galaxy-po/backend +alembic revision --autogenerate -m "Initial tables" +``` + +**Step 5: Commit** + +```bash +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** + +```python +""" +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** + +```python +""" +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** + +```python +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** + +```python +""" +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** + +```python +""" +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** + +```python +from app.api.auth import router as auth_router + +__all__ = ["auth_router"] +``` + +**Step 7: Update backend/app/main.py** + +```python +""" +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** + +```bash +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** + +```typescript +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( + endpoint: string, + options: RequestInit = {} + ): Promise { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (this.token) { + (headers as Record)['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(endpoint: string): Promise { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(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** + +```typescript +'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 ( + + ); +} +``` + +**Step 3: Create frontend/src/components/layout/Header.tsx** + +```typescript +'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 ( +
+
+
+

+ 환영합니다{username ? `, ${username}` : ''} +

+
+
+ +
+
+
+ ); +} +``` + +**Step 4: Update frontend/src/app/globals.css** + +```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** + +```typescript +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 ( + + {children} + + ); +} +``` + +**Step 6: Update frontend/src/app/page.tsx** + +```typescript +'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(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 ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

대시보드

+ +
+
+

총 자산

+

₩0

+
+
+

총 수익률

+

+0.00%

+
+
+

포트폴리오

+

0개

+
+
+

리밸런싱 필요

+

0건

+
+
+ +
+

최근 활동

+

아직 활동 내역이 없습니다.

+
+
+
+
+ ); +} +``` + +**Step 7: Create frontend/src/app/login/page.tsx** + +```typescript +'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 ( +
+
+

+ Galaxy-PO +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + +
+
+
+ ); +} +``` + +**Step 8: Commit** + +```bash +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** + +```bash +cd /home/zephyrdark/workspace/quant/galaxy-po +docker-compose up -d postgres +``` + +Expected: PostgreSQL container running + +**Step 2: Run migrations** + +```bash +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** + +```bash +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** + +```bash +curl http://localhost:8000/health +``` + +Expected: `{"status":"healthy"}` + +**Step 5: Test user registration** + +```bash +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** + +```bash +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)** + +```bash +cd /home/zephyrdark/workspace/quant/galaxy-po/frontend +npm install +npm run dev +``` + +Expected: Frontend running at http://localhost:3000 + +**Step 8: Final commit** + +```bash +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: 데이터 수집 기능 구현