galaxis-po/docs/plans/2026-02-11-portfolio-rebalancing-design.md
2026-02-18 21:44:45 +09:00

71 KiB

Portfolio Rebalancing & Data Viewer Implementation Plan

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

Goal: Fix prod data persistence, add manual-price rebalancing with two strategies, import historical data, and build a collected-data explorer view.

Architecture: FastAPI backend with SQLAlchemy/PostgreSQL, Next.js frontend. New rebalancing endpoint accepts optional manual prices and strategy selection. Data explorer adds read-only API endpoints for stocks/ETFs/prices with a tabbed frontend page.

Tech Stack: Python 3.12, FastAPI, SQLAlchemy, Pydantic, PostgreSQL, Next.js 16, React 19, TypeScript, Tailwind CSS, Radix UI, lightweight-charts


Task 1: Fix Deployment Data Persistence

Files:

  • Modify: docker-compose.prod.yml:13 (postgres volumes section)
  • Modify: .gitea/workflows/deploy.yml:42-44 (deploy step)

Step 1: Change postgres volume to host bind mount

In docker-compose.prod.yml, replace the named volume with a host path bind mount, and remove the top-level volumes: declaration:

# In the postgres service, change:
    volumes:
      - postgres_data:/var/lib/postgresql/data
# To:
    volumes:
      - /opt/galaxis-po/postgres-data:/var/lib/postgresql/data

# Remove the top-level volumes section entirely:
# volumes:
#   postgres_data:
#     driver: local

Step 2: Add DB backup step to deploy workflow

In .gitea/workflows/deploy.yml, add a backup step before the existing "Deploy with Docker Compose" step:

      - name: Backup database before deploy
        run: |
          mkdir -p /opt/galaxis-po/backups
          docker exec galaxis-po-db pg_dump -U ${{ secrets.DB_USER }} ${{ secrets.DB_NAME }} \
            > /opt/galaxis-po/backups/$(date +%Y%m%d_%H%M%S).sql 2>/dev/null || true

Also add a step to ensure the data directory exists:

      - name: Ensure data directories exist
        run: |
          mkdir -p /opt/galaxis-po/postgres-data
          mkdir -p /opt/galaxis-po/backups

Step 3: Commit

git add docker-compose.prod.yml .gitea/workflows/deploy.yml
git commit -m "fix: use host bind mount for postgres data to prevent data loss on deploy"

Task 2: Add Rebalancing Schemas

Files:

  • Modify: backend/app/schemas/portfolio.py:159-189 (rebalancing schemas section)
  • Test: backend/tests/e2e/test_rebalance_flow.py (new file)

Step 1: Write the failing test

Create backend/tests/e2e/test_rebalance_flow.py:

"""
E2E tests for rebalancing calculation flow.
"""
from fastapi.testclient import TestClient


def _setup_portfolio_with_holdings(client: TestClient, auth_headers: dict) -> int:
    """Helper: create portfolio with targets and holdings."""
    # Create portfolio
    resp = client.post(
        "/api/portfolios",
        json={"name": "Rebalance Test", "portfolio_type": "pension"},
        headers=auth_headers,
    )
    pid = resp.json()["id"]

    # Set targets (sum = 100)
    client.put(
        f"/api/portfolios/{pid}/targets",
        json=[
            {"ticker": "069500", "target_ratio": 50},
            {"ticker": "148070", "target_ratio": 50},
        ],
        headers=auth_headers,
    )

    # Set holdings
    client.put(
        f"/api/portfolios/{pid}/holdings",
        json=[
            {"ticker": "069500", "quantity": 10, "avg_price": 40000},
            {"ticker": "148070", "quantity": 5, "avg_price": 100000},
        ],
        headers=auth_headers,
    )
    return pid


def test_calculate_rebalance_with_manual_prices(client: TestClient, auth_headers):
    """Test rebalance calculation with manually provided prices."""
    pid = _setup_portfolio_with_holdings(client, auth_headers)

    response = client.post(
        f"/api/portfolios/{pid}/rebalance/calculate",
        json={
            "strategy": "full_rebalance",
            "prices": {"069500": 50000, "148070": 110000},
        },
        headers=auth_headers,
    )
    assert response.status_code == 200
    data = response.json()
    assert data["portfolio_id"] == pid
    assert data["total_assets"] > 0
    assert len(data["items"]) == 2
    # Verify items have required fields
    item = data["items"][0]
    assert "ticker" in item
    assert "target_ratio" in item
    assert "current_ratio" in item
    assert "diff_quantity" in item
    assert "action" in item
    assert "change_vs_prev_month" in item


def test_calculate_additional_buy_strategy(client: TestClient, auth_headers):
    """Test additional buy strategy: buy-only, no sells."""
    pid = _setup_portfolio_with_holdings(client, auth_headers)

    response = client.post(
        f"/api/portfolios/{pid}/rebalance/calculate",
        json={
            "strategy": "additional_buy",
            "prices": {"069500": 50000, "148070": 110000},
            "additional_amount": 1000000,
        },
        headers=auth_headers,
    )
    assert response.status_code == 200
    data = response.json()
    assert data["total_assets"] > 0
    assert data["available_to_buy"] == 1000000
    # Additional buy should never have "sell" actions
    for item in data["items"]:
        assert item["action"] in ("buy", "hold")


def test_additional_buy_requires_amount(client: TestClient, auth_headers):
    """Test that additional_buy strategy requires additional_amount."""
    pid = _setup_portfolio_with_holdings(client, auth_headers)

    response = client.post(
        f"/api/portfolios/{pid}/rebalance/calculate",
        json={
            "strategy": "additional_buy",
            "prices": {"069500": 50000, "148070": 110000},
        },
        headers=auth_headers,
    )
    assert response.status_code == 400


def test_calculate_rebalance_without_prices_fallback(client: TestClient, auth_headers):
    """Test rebalance calculation without manual prices falls back to DB."""
    pid = _setup_portfolio_with_holdings(client, auth_headers)

    # Without prices, should still work (may have 0 prices from DB in test env)
    response = client.post(
        f"/api/portfolios/{pid}/rebalance/calculate",
        json={"strategy": "full_rebalance"},
        headers=auth_headers,
    )
    assert response.status_code == 200

Step 2: Run test to verify it fails

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_rebalance_flow.py -v Expected: FAIL - endpoint does not exist (404 or validation error)

Step 3: Add new schemas to backend/app/schemas/portfolio.py

Append after the existing RebalanceSimulationResponse class (after line 189):

class RebalanceCalculateRequest(BaseModel):
    """Request for manual rebalance calculation."""
    strategy: str = Field(..., pattern="^(full_rebalance|additional_buy)$")
    prices: Optional[dict[str, Decimal]] = None
    additional_amount: Optional[Decimal] = Field(None, ge=0)


class RebalanceCalculateItem(BaseModel):
    """Extended rebalance item with price change info."""
    ticker: str
    name: str | None = None
    target_ratio: Decimal
    current_ratio: Decimal
    current_quantity: int
    current_value: Decimal
    current_price: Decimal
    target_value: Decimal
    diff_ratio: Decimal
    diff_quantity: int
    action: str  # "buy", "sell", or "hold"
    change_vs_prev_month: Decimal | None = None
    change_vs_start: Decimal | None = None


class RebalanceCalculateResponse(BaseModel):
    """Response for manual rebalance calculation."""
    portfolio_id: int
    total_assets: Decimal
    available_to_buy: Decimal | None = None
    items: List[RebalanceCalculateItem]

Step 4: Commit

git add backend/app/schemas/portfolio.py backend/tests/e2e/test_rebalance_flow.py
git commit -m "feat: add rebalance calculate schemas and tests"

Task 3: Implement Full Rebalance with Manual Prices

Files:

  • Modify: backend/app/services/rebalance.py (add calculate_with_prices method)
  • Test: backend/tests/e2e/test_rebalance_flow.py (already created)

Step 1: Add calculate_with_prices method to RebalanceService

Add after the existing calculate_rebalance method in backend/app/services/rebalance.py:

    def _get_prev_month_prices(self, portfolio_id: int, tickers: List[str]) -> Dict[str, Decimal]:
        """Get prices from the most recent snapshot for change calculation."""
        from app.models.portfolio import PortfolioSnapshot, SnapshotHolding
        latest_snapshot = (
            self.db.query(PortfolioSnapshot)
            .filter(PortfolioSnapshot.portfolio_id == portfolio_id)
            .order_by(PortfolioSnapshot.snapshot_date.desc())
            .first()
        )
        if not latest_snapshot:
            return {}
        prices = {}
        for sh in latest_snapshot.holdings:
            if sh.ticker in tickers:
                prices[sh.ticker] = Decimal(str(sh.price))
        return prices

    def _get_start_prices(self, portfolio_id: int, tickers: List[str]) -> Dict[str, Decimal]:
        """Get prices from the earliest snapshot for change calculation."""
        from app.models.portfolio import PortfolioSnapshot, SnapshotHolding
        earliest_snapshot = (
            self.db.query(PortfolioSnapshot)
            .filter(PortfolioSnapshot.portfolio_id == portfolio_id)
            .order_by(PortfolioSnapshot.snapshot_date.asc())
            .first()
        )
        if not earliest_snapshot:
            return {}
        prices = {}
        for sh in earliest_snapshot.holdings:
            if sh.ticker in tickers:
                prices[sh.ticker] = Decimal(str(sh.price))
        return prices

    def calculate_with_prices(
        self,
        portfolio: "Portfolio",
        strategy: str,
        manual_prices: Optional[Dict[str, Decimal]] = None,
        additional_amount: Optional[Decimal] = None,
    ):
        """Calculate rebalance with optional manual prices and strategy selection."""
        from app.schemas.portfolio import RebalanceCalculateItem, RebalanceCalculateResponse

        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()))

        # Use manual prices if provided, else fall back to DB
        if manual_prices:
            current_prices = {t: manual_prices.get(t, Decimal("0")) for t in all_tickers}
        else:
            current_prices = self.get_current_prices(all_tickers)

        stock_names = self.get_stock_names(all_tickers)

        # Calculate current values
        current_values = {}
        for ticker in all_tickers:
            qty = holdings.get(ticker, (0, Decimal("0")))[0]
            price = current_prices.get(ticker, Decimal("0"))
            current_values[ticker] = price * qty

        total_assets = sum(current_values.values())

        # Get snapshot prices for change calculation
        prev_prices = self._get_prev_month_prices(portfolio.id, all_tickers)
        start_prices = self._get_start_prices(portfolio.id, all_tickers)

        if strategy == "full_rebalance":
            items = self._calc_full_rebalance(
                all_tickers, targets, holdings, current_prices,
                current_values, total_assets, stock_names,
                prev_prices, start_prices,
            )
            return RebalanceCalculateResponse(
                portfolio_id=portfolio.id,
                total_assets=total_assets,
                items=items,
            )
        else:  # additional_buy
            items = self._calc_additional_buy(
                all_tickers, targets, holdings, current_prices,
                current_values, total_assets, additional_amount,
                stock_names, prev_prices, start_prices,
            )
            return RebalanceCalculateResponse(
                portfolio_id=portfolio.id,
                total_assets=total_assets,
                available_to_buy=additional_amount,
                items=items,
            )

    def _calc_change_pct(
        self, current_price: Decimal, ref_price: Optional[Decimal]
    ) -> Optional[Decimal]:
        if ref_price and ref_price > 0:
            return ((current_price - ref_price) / ref_price * 100).quantize(Decimal("0.01"))
        return None

    def _calc_full_rebalance(
        self, all_tickers, targets, holdings, current_prices,
        current_values, total_assets, stock_names,
        prev_prices, start_prices,
    ):
        from app.schemas.portfolio import RebalanceCalculateItem

        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 total_assets > 0:
                current_ratio = (current_value / total_assets * 100).quantize(Decimal("0.01"))
            else:
                current_ratio = Decimal("0")

            target_value = (total_assets * target_ratio / 100).quantize(Decimal("0.01"))
            diff_ratio = (target_ratio - current_ratio).quantize(Decimal("0.01"))

            if current_price > 0:
                diff_quantity = int((target_value - current_value) / current_price)
            else:
                diff_quantity = 0

            if diff_quantity > 0:
                action = "buy"
            elif diff_quantity < 0:
                action = "sell"
            else:
                action = "hold"

            items.append(RebalanceCalculateItem(
                ticker=ticker,
                name=stock_names.get(ticker),
                target_ratio=target_ratio,
                current_ratio=current_ratio,
                current_quantity=current_quantity,
                current_value=current_value,
                current_price=current_price,
                target_value=target_value,
                diff_ratio=diff_ratio,
                diff_quantity=diff_quantity,
                action=action,
                change_vs_prev_month=self._calc_change_pct(
                    current_price, prev_prices.get(ticker)
                ),
                change_vs_start=self._calc_change_pct(
                    current_price, start_prices.get(ticker)
                ),
            ))

        action_order = {"buy": 0, "sell": 1, "hold": 2}
        items.sort(key=lambda x: (action_order.get(x.action, 3), -abs(x.diff_quantity)))
        return items

    def _calc_additional_buy(
        self, all_tickers, targets, holdings, current_prices,
        current_values, total_assets, additional_amount,
        stock_names, prev_prices, start_prices,
    ):
        from app.schemas.portfolio import RebalanceCalculateItem

        remaining = additional_amount or Decimal("0")

        # Calculate change vs prev month for sorting
        ticker_changes = {}
        for ticker in all_tickers:
            cp = current_prices.get(ticker, Decimal("0"))
            pp = prev_prices.get(ticker)
            if pp and pp > 0:
                ticker_changes[ticker] = ((cp - pp) / pp * 100).quantize(Decimal("0.01"))
            else:
                ticker_changes[ticker] = Decimal("0")

        # Sort by drop (most negative first)
        sorted_tickers = sorted(all_tickers, key=lambda t: ticker_changes.get(t, Decimal("0")))

        buy_quantities = {t: 0 for t in all_tickers}

        # Allocate additional amount to tickers sorted by drop
        for ticker in sorted_tickers:
            if remaining <= 0:
                break
            target_ratio = targets.get(ticker, Decimal("0"))
            current_value = current_values.get(ticker, Decimal("0"))
            current_price = current_prices.get(ticker, Decimal("0"))
            if current_price <= 0:
                continue

            target_value = (total_assets * target_ratio / 100).quantize(Decimal("0.01"))
            deficit = target_value - current_value
            if deficit <= 0:
                continue

            buy_amount = min(deficit, remaining)
            buy_qty = int(buy_amount / current_price)
            if buy_qty <= 0:
                continue

            actual_cost = current_price * buy_qty
            buy_quantities[ticker] = buy_qty
            remaining -= actual_cost

        # Build response 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 total_assets > 0:
                current_ratio = (current_value / total_assets * 100).quantize(Decimal("0.01"))
            else:
                current_ratio = Decimal("0")

            target_value = (total_assets * target_ratio / 100).quantize(Decimal("0.01"))
            diff_ratio = (target_ratio - current_ratio).quantize(Decimal("0.01"))
            diff_quantity = buy_quantities.get(ticker, 0)

            items.append(RebalanceCalculateItem(
                ticker=ticker,
                name=stock_names.get(ticker),
                target_ratio=target_ratio,
                current_ratio=current_ratio,
                current_quantity=current_quantity,
                current_value=current_value,
                current_price=current_price,
                target_value=target_value,
                diff_ratio=diff_ratio,
                diff_quantity=diff_quantity,
                action="buy" if diff_quantity > 0 else "hold",
                change_vs_prev_month=self._calc_change_pct(
                    current_price, prev_prices.get(ticker)
                ),
                change_vs_start=self._calc_change_pct(
                    current_price, start_prices.get(ticker)
                ),
            ))

        items.sort(key=lambda x: (-x.diff_quantity, x.ticker))
        return items

Step 2: Run test to verify it still fails (endpoint not yet registered)

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_rebalance_flow.py::test_calculate_rebalance_with_manual_prices -v Expected: FAIL - 404 (endpoint not registered yet)

Step 3: Commit service logic

git add backend/app/services/rebalance.py
git commit -m "feat: add rebalance calculation with manual prices and additional_buy strategy"

Task 4: Add Rebalance Calculate API Endpoint

Files:

  • Modify: backend/app/api/portfolio.py:309-319 (after existing rebalance endpoints)
  • Test: backend/tests/e2e/test_rebalance_flow.py (already created)

Step 1: Add the endpoint to backend/app/api/portfolio.py

Add imports at the top (update the existing import from schemas):

# Update the import line to include new schemas:
from app.schemas.portfolio import (
    PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail,
    TargetCreate, TargetResponse,
    HoldingCreate, HoldingResponse, HoldingWithValue,
    TransactionCreate, TransactionResponse,
    RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
    RebalanceCalculateRequest, RebalanceCalculateResponse,
)

Add new endpoint after the simulate_rebalance endpoint (after line 319):

@router.post("/{portfolio_id}/rebalance/calculate", response_model=RebalanceCalculateResponse)
async def calculate_rebalance_manual(
    portfolio_id: int,
    data: RebalanceCalculateRequest,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Calculate rebalancing with manual prices and strategy selection."""
    portfolio = _get_portfolio(db, portfolio_id, current_user.id)

    if data.strategy == "additional_buy" and not data.additional_amount:
        raise HTTPException(
            status_code=400,
            detail="additional_amount is required for additional_buy strategy"
        )

    service = RebalanceService(db)
    return service.calculate_with_prices(
        portfolio,
        strategy=data.strategy,
        manual_prices=data.prices,
        additional_amount=data.additional_amount,
    )

Step 2: Run tests to verify they pass

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_rebalance_flow.py -v Expected: ALL PASS

Step 3: Run full test suite to check for regressions

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/ -v Expected: ALL PASS

Step 4: Commit

git add backend/app/api/portfolio.py
git commit -m "feat: add POST /rebalance/calculate endpoint with manual prices"

Task 5: Update Rebalance Frontend Page

Files:

  • Modify: frontend/src/app/portfolio/[id]/rebalance/page.tsx (full rewrite)

Step 1: Rewrite the rebalance page

Replace the entire contents of frontend/src/app/portfolio/[id]/rebalance/page.tsx:

'use client';

import { useEffect, useState, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/lib/api';

interface Target {
  ticker: string;
  target_ratio: number;
}

interface Holding {
  ticker: string;
  quantity: number;
  avg_price: number;
}

interface RebalanceItem {
  ticker: string;
  name: string | null;
  target_ratio: number;
  current_ratio: number;
  current_quantity: number;
  current_value: number;
  current_price: number;
  target_value: number;
  diff_ratio: number;
  diff_quantity: number;
  action: string;
  change_vs_prev_month: number | null;
  change_vs_start: number | null;
}

interface RebalanceResponse {
  portfolio_id: number;
  total_assets: number;
  available_to_buy: number | null;
  items: RebalanceItem[];
}

type Strategy = 'full_rebalance' | 'additional_buy';

export default function RebalancePage() {
  const router = useRouter();
  const params = useParams();
  const portfolioId = params.id as string;

  const [loading, setLoading] = useState(true);
  const [targets, setTargets] = useState<Target[]>([]);
  const [holdings, setHoldings] = useState<Holding[]>([]);
  const [prices, setPrices] = useState<Record<string, string>>({});
  const [strategy, setStrategy] = useState<Strategy>('full_rebalance');
  const [additionalAmount, setAdditionalAmount] = useState('');
  const [result, setResult] = useState<RebalanceResponse | null>(null);
  const [calculating, setCalculating] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const init = async () => {
      try {
        await api.getCurrentUser();
        const [targetsData, holdingsData] = await Promise.all([
          api.get<Target[]>(`/api/portfolios/${portfolioId}/targets`),
          api.get<Holding[]>(`/api/portfolios/${portfolioId}/holdings`),
        ]);
        setTargets(targetsData);
        setHoldings(holdingsData);

        // Initialize price fields for all tickers
        const allTickers = new Set([
          ...targetsData.map((t) => t.ticker),
          ...holdingsData.map((h) => h.ticker),
        ]);
        const initialPrices: Record<string, string> = {};
        allTickers.forEach((ticker) => {
          initialPrices[ticker] = '';
        });
        setPrices(initialPrices);
      } catch {
        router.push('/login');
      } finally {
        setLoading(false);
      }
    };
    init();
  }, [portfolioId, router]);

  const allPricesFilled = Object.values(prices).every((p) => p !== '' && parseFloat(p) > 0);

  const calculate = async () => {
    if (!allPricesFilled) return;
    setCalculating(true);
    setError(null);
    try {
      const priceMap: Record<string, number> = {};
      for (const [ticker, price] of Object.entries(prices)) {
        priceMap[ticker] = parseFloat(price);
      }

      const body: Record<string, unknown> = {
        strategy,
        prices: priceMap,
      };
      if (strategy === 'additional_buy' && additionalAmount) {
        body.additional_amount = parseFloat(additionalAmount);
      }

      const data = await api.post<RebalanceResponse>(
        `/api/portfolios/${portfolioId}/rebalance/calculate`,
        body
      );
      setResult(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Calculation failed');
    } finally {
      setCalculating(false);
    }
  };

  const formatCurrency = (value: number) =>
    new Intl.NumberFormat('ko-KR', {
      style: 'currency',
      currency: 'KRW',
      maximumFractionDigits: 0,
    }).format(value);

  const formatPct = (value: number | null) => {
    if (value === null || value === undefined) return '-';
    return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
  };

  const getActionBadge = (action: string) => {
    const styles: Record<string, string> = {
      buy: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
      sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
      hold: 'bg-muted text-muted-foreground',
    };
    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>
    );
  };

  const getHoldingQty = (ticker: string) =>
    holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;

  if (loading) return null;

  return (
    <DashboardLayout>
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-foreground">리밸런싱 계산</h1>
        <Button variant="outline" onClick={() => router.push(`/portfolio/${portfolioId}`)}>
          포트폴리오로 돌아가기
        </Button>
      </div>

      {error && (
        <div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">
          {error}
        </div>
      )}

      {/* Price Input */}
      <Card className="mb-6">
        <CardHeader>
          <CardTitle>현재 가격 입력</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {Object.keys(prices).map((ticker) => {
              const target = targets.find((t) => t.ticker === ticker);
              return (
                <div key={ticker}>
                  <Label htmlFor={`price-${ticker}`}>
                    {ticker} {target ? `(목표 ${target.target_ratio}%)` : ''} - 보유 {getHoldingQty(ticker)}
                  </Label>
                  <Input
                    id={`price-${ticker}`}
                    type="number"
                    value={prices[ticker]}
                    onChange={(e) => setPrices((prev) => ({ ...prev, [ticker]: e.target.value }))}
                    placeholder="현재 가격"
                    className="mt-1"
                  />
                </div>
              );
            })}
          </div>
        </CardContent>
      </Card>

      {/* Strategy Selection */}
      <Card className="mb-6">
        <CardContent className="pt-6">
          <div className="flex flex-col gap-4">
            <div>
              <Label>전략 선택</Label>
              <div className="flex gap-2 mt-2">
                <Button
                  variant={strategy === 'full_rebalance' ? 'default' : 'outline'}
                  onClick={() => setStrategy('full_rebalance')}
                >
                  전체 리밸런싱
                </Button>
                <Button
                  variant={strategy === 'additional_buy' ? 'default' : 'outline'}
                  onClick={() => setStrategy('additional_buy')}
                >
                  추가 입금 매수
                </Button>
              </div>
            </div>

            {strategy === 'additional_buy' && (
              <div className="max-w-md">
                <Label htmlFor="additional-amount">매수 가능 금액</Label>
                <Input
                  id="additional-amount"
                  type="number"
                  value={additionalAmount}
                  onChange={(e) => setAdditionalAmount(e.target.value)}
                  placeholder="예: 1000000"
                  className="mt-1"
                />
              </div>
            )}

            <div>
              <Button
                onClick={calculate}
                disabled={
                  !allPricesFilled ||
                  calculating ||
                  (strategy === 'additional_buy' && !additionalAmount)
                }
              >
                {calculating ? '계산 중...' : '계산'}
              </Button>
            </div>
          </div>
        </CardContent>
      </Card>

      {/* Results */}
      {result && (
        <>
          <Card className="mb-6">
            <CardContent className="pt-6">
              <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
                <div>
                  <p className="text-sm text-muted-foreground"> 자산</p>
                  <p className="text-xl font-bold">{formatCurrency(result.total_assets)}</p>
                </div>
                {result.available_to_buy !== null && (
                  <div>
                    <p className="text-sm text-muted-foreground">매수 가능</p>
                    <p className="text-xl font-bold text-blue-600">
                      {formatCurrency(result.available_to_buy)}
                    </p>
                  </div>
                )}
              </div>
            </CardContent>
          </Card>

          <Card>
            <CardHeader>
              <CardTitle>리밸런싱 내역</CardTitle>
            </CardHeader>
            <CardContent className="p-0">
              <div className="overflow-x-auto">
                <table className="w-full">
                  <thead className="bg-muted">
                    <tr>
                      <th scope="col" className="px-3 py-3 text-left text-sm font-medium text-muted-foreground">종목</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">보유량</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">현재가</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">평가금액</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">현재 비중</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">목표 비중</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">비중 차이</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">조정 수량</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">전월비</th>
                      <th scope="col" className="px-3 py-3 text-right text-sm font-medium text-muted-foreground">시작일비</th>
                      <th scope="col" className="px-3 py-3 text-center text-sm font-medium text-muted-foreground">액션</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y divide-border">
                    {result.items.map((item) => (
                      <tr key={item.ticker}>
                        <td className="px-3 py-3">
                          <div className="font-medium">{item.ticker}</div>
                          {item.name && (
                            <div className="text-xs text-muted-foreground">{item.name}</div>
                          )}
                        </td>
                        <td className="px-3 py-3 text-sm text-right">
                          {item.current_quantity.toLocaleString()}
                        </td>
                        <td className="px-3 py-3 text-sm text-right">
                          {formatCurrency(item.current_price)}
                        </td>
                        <td className="px-3 py-3 text-sm text-right">
                          {formatCurrency(item.current_value)}
                        </td>
                        <td className="px-3 py-3 text-sm text-right">
                          {item.current_ratio.toFixed(2)}%
                        </td>
                        <td className="px-3 py-3 text-sm text-right">
                          {item.target_ratio.toFixed(2)}%
                        </td>
                        <td
                          className={`px-3 py-3 text-sm text-right ${
                            item.diff_ratio > 0
                              ? 'text-green-600'
                              : item.diff_ratio < 0
                              ? 'text-red-600'
                              : ''
                          }`}
                        >
                          {item.diff_ratio > 0 ? '+' : ''}
                          {item.diff_ratio.toFixed(2)}%
                        </td>
                        <td
                          className={`px-3 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-3 py-3 text-sm text-right ${
                            (item.change_vs_prev_month ?? 0) < 0 ? 'text-red-600' : (item.change_vs_prev_month ?? 0) > 0 ? 'text-green-600' : ''
                          }`}
                        >
                          {formatPct(item.change_vs_prev_month)}
                        </td>
                        <td
                          className={`px-3 py-3 text-sm text-right ${
                            (item.change_vs_start ?? 0) < 0 ? 'text-red-600' : (item.change_vs_start ?? 0) > 0 ? 'text-green-600' : ''
                          }`}
                        >
                          {formatPct(item.change_vs_start)}
                        </td>
                        <td className="px-3 py-3 text-center">{getActionBadge(item.action)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </CardContent>
          </Card>
        </>
      )}
    </DashboardLayout>
  );
}

Step 2: Verify frontend builds

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/frontend && npx next build 2>&1 | tail -20 Expected: Build succeeds without errors

Step 3: Commit

git add frontend/src/app/portfolio/\[id\]/rebalance/page.tsx
git commit -m "feat: rebalance page with manual price input and strategy selection"

Task 6: Historical Data Import Script

Files:

  • Create: backend/scripts/seed_data.py

Step 1: Create the seed script

Create backend/scripts/seed_data.py:

"""
One-time script to import historical portfolio data from data.txt.

Usage:
    cd backend && python -m scripts.seed_data

Requires: DATABASE_URL environment variable or default dev connection.
"""
import sys
import os
from datetime import date
from decimal import Decimal

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

from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.portfolio import (
    Portfolio, PortfolioType, Target, Holding,
    PortfolioSnapshot, SnapshotHolding,
)
from app.models.user import User


# ETF name -> ticker mapping
ETF_MAP = {
    "TIGER 200": "069500",
    "KIWOOM 국고채10년": "148070",
    "KODEX 200미국채혼합": "284430",
    "TIGER 미국S&P500": "360750",
    "ACE KRX금현물": "411060",
}

# Target ratios
TARGETS = {
    "069500": Decimal("0.83"),
    "148070": Decimal("25"),
    "284430": Decimal("41.67"),
    "360750": Decimal("17.5"),
    "411060": Decimal("15"),
}

# Historical snapshots from data.txt
SNAPSHOTS = [
    {
        "date": date(2025, 4, 28),
        "total_assets": Decimal("42485834"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("33815"), "value": Decimal("541040")},
            {"ticker": "148070", "qty": 1, "price": Decimal("118000"), "value": Decimal("118000")},
            {"ticker": "284430", "qty": 355, "price": Decimal("13235"), "value": Decimal("4698435")},
            {"ticker": "360750", "qty": 329, "price": Decimal("19770"), "value": Decimal("6504330")},
            {"ticker": "411060", "qty": 1, "price": Decimal("21620"), "value": Decimal("21620")},
        ],
    },
    {
        "date": date(2025, 5, 13),
        "total_assets": Decimal("42485834"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("34805"), "value": Decimal("556880")},
            {"ticker": "148070", "qty": 1, "price": Decimal("117010"), "value": Decimal("117010")},
            {"ticker": "284430", "qty": 369, "price": Decimal("13175"), "value": Decimal("4861575")},
            {"ticker": "360750", "qty": 329, "price": Decimal("20490"), "value": Decimal("6741210")},
            {"ticker": "411060", "qty": 261, "price": Decimal("20840"), "value": Decimal("5439240")},
        ],
    },
    {
        "date": date(2025, 6, 11),
        "total_assets": Decimal("44263097"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("39110"), "value": Decimal("625760")},
            {"ticker": "148070", "qty": 91, "price": Decimal("115790"), "value": Decimal("10536890")},
            {"ticker": "284430", "qty": 1271, "price": Decimal("13570"), "value": Decimal("17247470")},
            {"ticker": "360750", "qty": 374, "price": Decimal("20570"), "value": Decimal("7693180")},
            {"ticker": "411060", "qty": 306, "price": Decimal("20670"), "value": Decimal("6325020")},
        ],
    },
    {
        "date": date(2025, 7, 30),
        "total_assets": Decimal("47395573"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("43680"), "value": Decimal("698880")},
            {"ticker": "148070", "qty": 96, "price": Decimal("116470"), "value": Decimal("11181120")},
            {"ticker": "284430", "qty": 1359, "price": Decimal("14550"), "value": Decimal("19773450")},
            {"ticker": "360750", "qty": 377, "price": Decimal("22085"), "value": Decimal("8326045")},
            {"ticker": "411060", "qty": 320, "price": Decimal("20870"), "value": Decimal("6678400")},
        ],
    },
    {
        "date": date(2025, 8, 13),
        "total_assets": Decimal("47997732"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("43795"), "value": Decimal("700720")},
            {"ticker": "148070", "qty": 102, "price": Decimal("116800"), "value": Decimal("11913600")},
            {"ticker": "284430", "qty": 1359, "price": Decimal("14435"), "value": Decimal("19617165")},
            {"ticker": "360750", "qty": 377, "price": Decimal("22090"), "value": Decimal("8327930")},
            {"ticker": "411060", "qty": 320, "price": Decimal("20995"), "value": Decimal("6718400")},
        ],
    },
    {
        "date": date(2025, 10, 12),
        "total_assets": Decimal("54188966"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("50850"), "value": Decimal("813600")},
            {"ticker": "148070", "qty": 103, "price": Decimal("116070"), "value": Decimal("11955210")},
            {"ticker": "284430", "qty": 1386, "price": Decimal("15665"), "value": Decimal("21711690")},
            {"ticker": "360750", "qty": 380, "price": Decimal("23830"), "value": Decimal("9055400")},
            {"ticker": "411060", "qty": 328, "price": Decimal("27945"), "value": Decimal("9165960")},
        ],
    },
    {
        "date": date(2025, 12, 4),
        "total_assets": Decimal("56860460"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("57190"), "value": Decimal("915040")},
            {"ticker": "148070", "qty": 115, "price": Decimal("112900"), "value": Decimal("12983500")},
            {"ticker": "284430", "qty": 1386, "price": Decimal("16825"), "value": Decimal("23319450")},
            {"ticker": "360750", "qty": 383, "price": Decimal("25080"), "value": Decimal("9605640")},
            {"ticker": "411060", "qty": 328, "price": Decimal("27990"), "value": Decimal("9180720")},
        ],
    },
    {
        "date": date(2026, 1, 6),
        "total_assets": Decimal("58949962"),
        "holdings": [
            {"ticker": "069500", "qty": 16, "price": Decimal("66255"), "value": Decimal("1060080")},
            {"ticker": "148070", "qty": 122, "price": Decimal("108985"), "value": Decimal("13296170")},
            {"ticker": "284430", "qty": 1386, "price": Decimal("17595"), "value": Decimal("24386670")},
            {"ticker": "360750", "qty": 383, "price": Decimal("24840"), "value": Decimal("9513720")},
            {"ticker": "411060", "qty": 328, "price": Decimal("29605"), "value": Decimal("9710440")},
        ],
    },
]


def seed(db: Session):
    """Import historical data into database."""
    # Find admin user (first user in DB)
    user = db.query(User).first()
    if not user:
        print("ERROR: No user found in database. Create a user first.")
        return

    # Check if portfolio already exists
    existing = db.query(Portfolio).filter(
        Portfolio.user_id == user.id,
        Portfolio.name == "연금 포트폴리오",
    ).first()
    if existing:
        print(f"Portfolio '연금 포트폴리오' already exists (id={existing.id}). Skipping.")
        return

    # Create portfolio
    portfolio = Portfolio(
        user_id=user.id,
        name="연금 포트폴리오",
        portfolio_type=PortfolioType.PENSION,
    )
    db.add(portfolio)
    db.flush()
    print(f"Created portfolio id={portfolio.id}")

    # Set targets
    for ticker, ratio in TARGETS.items():
        db.add(Target(portfolio_id=portfolio.id, ticker=ticker, target_ratio=ratio))
    print(f"Set {len(TARGETS)} targets")

    # Create snapshots
    for snap in SNAPSHOTS:
        snapshot = PortfolioSnapshot(
            portfolio_id=portfolio.id,
            total_value=snap["total_assets"],
            snapshot_date=snap["date"],
        )
        db.add(snapshot)
        db.flush()

        total = snap["total_assets"]
        for h in snap["holdings"]:
            ratio = (h["value"] / total * 100).quantize(Decimal("0.01")) if total > 0 else Decimal("0")
            db.add(SnapshotHolding(
                snapshot_id=snapshot.id,
                ticker=h["ticker"],
                quantity=h["qty"],
                price=h["price"],
                value=h["value"],
                current_ratio=ratio,
            ))
        print(f"  Snapshot {snap['date']}: {len(snap['holdings'])} holdings")

    # Set current holdings from latest snapshot
    latest = SNAPSHOTS[-1]
    for h in latest["holdings"]:
        db.add(Holding(
            portfolio_id=portfolio.id,
            ticker=h["ticker"],
            quantity=h["qty"],
            avg_price=h["price"],  # Using current price as avg (best available)
        ))
    print(f"Set {len(latest['holdings'])} current holdings from {latest['date']}")

    db.commit()
    print("Done!")


if __name__ == "__main__":
    db = SessionLocal()
    try:
        seed(db)
    finally:
        db.close()

Step 2: Create backend/scripts/__init__.py

# empty

Step 3: Verify script syntax

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -c "import ast; ast.parse(open('scripts/seed_data.py').read()); print('OK')" Expected: OK

Step 4: Commit

git add backend/scripts/
git commit -m "feat: add historical data import script from data.txt"

Task 7: Data Explorer Backend API

Files:

  • Create: backend/app/api/data_explorer.py
  • Modify: backend/app/api/__init__.py:1-17
  • Modify: backend/app/main.py:10-13 (router imports)
  • Modify: backend/app/main.py:107-113 (include router)
  • Test: backend/tests/e2e/test_data_explorer.py (new file)

Step 1: Write the failing test

Create backend/tests/e2e/test_data_explorer.py:

"""
E2E tests for data explorer API.
"""
from datetime import date
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session

from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation, AssetClass


def _seed_stock(db: Session):
    """Add test stock data."""
    stock = Stock(
        ticker="005930", name="삼성전자", market="KOSPI",
        close_price=70000, market_cap=400000000000000,
        stock_type="common", base_date=date(2025, 1, 1),
    )
    db.add(stock)
    db.add(Price(ticker="005930", date=date(2025, 1, 2), open=69000, high=71000, low=68500, close=70000, volume=10000000))
    db.add(Price(ticker="005930", date=date(2025, 1, 3), open=70000, high=72000, low=69000, close=71000, volume=12000000))
    db.commit()


def _seed_etf(db: Session):
    """Add test ETF data."""
    etf = ETF(ticker="069500", name="TIGER 200", asset_class=AssetClass.EQUITY, market="ETF")
    db.add(etf)
    db.add(ETFPrice(ticker="069500", date=date(2025, 1, 2), close=43000, volume=500000))
    db.add(ETFPrice(ticker="069500", date=date(2025, 1, 3), close=43500, volume=600000))
    db.commit()


def _seed_sector(db: Session):
    db.add(Sector(ticker="005930", sector_code="G45", company_name="삼성전자", sector_name="반도체", base_date=date(2025, 1, 1)))
    db.commit()


def _seed_valuation(db: Session):
    db.add(Valuation(ticker="005930", base_date=date(2025, 1, 1), per=12.5, pbr=1.3, dividend_yield=2.1))
    db.commit()


def test_list_stocks(client: TestClient, auth_headers, db: Session):
    _seed_stock(db)
    resp = client.get("/api/data/stocks", headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert data["total"] >= 1
    assert len(data["items"]) >= 1
    assert data["items"][0]["ticker"] == "005930"


def test_list_stocks_search(client: TestClient, auth_headers, db: Session):
    _seed_stock(db)
    resp = client.get("/api/data/stocks?search=삼성", headers=auth_headers)
    assert resp.status_code == 200
    assert resp.json()["total"] >= 1


def test_stock_prices(client: TestClient, auth_headers, db: Session):
    _seed_stock(db)
    resp = client.get("/api/data/stocks/005930/prices", headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 2


def test_list_etfs(client: TestClient, auth_headers, db: Session):
    _seed_etf(db)
    resp = client.get("/api/data/etfs", headers=auth_headers)
    assert resp.status_code == 200
    assert resp.json()["total"] >= 1


def test_etf_prices(client: TestClient, auth_headers, db: Session):
    _seed_etf(db)
    resp = client.get("/api/data/etfs/069500/prices", headers=auth_headers)
    assert resp.status_code == 200
    assert len(resp.json()) == 2


def test_list_sectors(client: TestClient, auth_headers, db: Session):
    _seed_sector(db)
    resp = client.get("/api/data/sectors", headers=auth_headers)
    assert resp.status_code == 200
    assert resp.json()["total"] >= 1


def test_list_valuations(client: TestClient, auth_headers, db: Session):
    _seed_valuation(db)
    resp = client.get("/api/data/valuations", headers=auth_headers)
    assert resp.status_code == 200
    assert resp.json()["total"] >= 1

Step 2: Run test to verify it fails

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_data_explorer.py -v Expected: FAIL - 404 (endpoints don't exist)

Step 3: Create backend/app/api/data_explorer.py

"""
Data explorer API endpoints for viewing collected stock/ETF data.
"""
from datetime import date
from decimal import Decimal
from typing import Optional, List

from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_
from sqlalchemy.orm import Session
from pydantic import BaseModel

from app.core.database import get_db
from app.api.deps import CurrentUser
from app.models.stock import Stock, ETF, Price, ETFPrice, Sector, Valuation

router = APIRouter(prefix="/api/data", tags=["data-explorer"])


# --- Response schemas ---

class PaginatedResponse(BaseModel):
    items: list
    total: int
    page: int
    size: int


class StockItem(BaseModel):
    ticker: str
    name: str
    market: str
    close_price: Decimal | None = None
    market_cap: int | None = None

    class Config:
        from_attributes = True


class ETFItem(BaseModel):
    ticker: str
    name: str
    asset_class: str
    market: str
    expense_ratio: Decimal | None = None

    class Config:
        from_attributes = True


class PriceItem(BaseModel):
    date: date
    open: Decimal | None = None
    high: Decimal | None = None
    low: Decimal | None = None
    close: Decimal
    volume: int | None = None

    class Config:
        from_attributes = True


class ETFPriceItem(BaseModel):
    date: date
    close: Decimal
    nav: Decimal | None = None
    volume: int | None = None

    class Config:
        from_attributes = True


class SectorItem(BaseModel):
    ticker: str
    company_name: str
    sector_code: str
    sector_name: str

    class Config:
        from_attributes = True


class ValuationItem(BaseModel):
    ticker: str
    base_date: date
    per: Decimal | None = None
    pbr: Decimal | None = None
    psr: Decimal | None = None
    pcr: Decimal | None = None
    dividend_yield: Decimal | None = None

    class Config:
        from_attributes = True


# --- Endpoints ---

@router.get("/stocks")
async def list_stocks(
    current_user: CurrentUser,
    db: Session = Depends(get_db),
    page: int = Query(1, ge=1),
    size: int = Query(50, ge=1, le=200),
    search: Optional[str] = None,
    market: Optional[str] = None,
):
    """List collected stocks with pagination and search."""
    query = db.query(Stock)
    if search:
        query = query.filter(
            or_(Stock.ticker.contains(search), Stock.name.contains(search))
        )
    if market:
        query = query.filter(Stock.market == market)

    total = query.count()
    items = query.order_by(Stock.ticker).offset((page - 1) * size).limit(size).all()

    return {
        "items": [StockItem.model_validate(s) for s in items],
        "total": total,
        "page": page,
        "size": size,
    }


@router.get("/stocks/{ticker}/prices")
async def get_stock_prices(
    ticker: str,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Get daily prices for a stock."""
    prices = (
        db.query(Price)
        .filter(Price.ticker == ticker)
        .order_by(Price.date.asc())
        .all()
    )
    return [PriceItem.model_validate(p) for p in prices]


@router.get("/etfs")
async def list_etfs(
    current_user: CurrentUser,
    db: Session = Depends(get_db),
    page: int = Query(1, ge=1),
    size: int = Query(50, ge=1, le=200),
    search: Optional[str] = None,
):
    """List collected ETFs with pagination and search."""
    query = db.query(ETF)
    if search:
        query = query.filter(
            or_(ETF.ticker.contains(search), ETF.name.contains(search))
        )

    total = query.count()
    items = query.order_by(ETF.ticker).offset((page - 1) * size).limit(size).all()

    return {
        "items": [ETFItem.model_validate(e) for e in items],
        "total": total,
        "page": page,
        "size": size,
    }


@router.get("/etfs/{ticker}/prices")
async def get_etf_prices(
    ticker: str,
    current_user: CurrentUser,
    db: Session = Depends(get_db),
):
    """Get daily prices for an ETF."""
    prices = (
        db.query(ETFPrice)
        .filter(ETFPrice.ticker == ticker)
        .order_by(ETFPrice.date.asc())
        .all()
    )
    return [ETFPriceItem.model_validate(p) for p in prices]


@router.get("/sectors")
async def list_sectors(
    current_user: CurrentUser,
    db: Session = Depends(get_db),
    page: int = Query(1, ge=1),
    size: int = Query(50, ge=1, le=200),
    search: Optional[str] = None,
):
    """List sector classification data."""
    query = db.query(Sector)
    if search:
        query = query.filter(
            or_(Sector.ticker.contains(search), Sector.company_name.contains(search), Sector.sector_name.contains(search))
        )

    total = query.count()
    items = query.order_by(Sector.ticker).offset((page - 1) * size).limit(size).all()

    return {
        "items": [SectorItem.model_validate(s) for s in items],
        "total": total,
        "page": page,
        "size": size,
    }


@router.get("/valuations")
async def list_valuations(
    current_user: CurrentUser,
    db: Session = Depends(get_db),
    page: int = Query(1, ge=1),
    size: int = Query(50, ge=1, le=200),
    search: Optional[str] = None,
):
    """List valuation metrics data."""
    query = db.query(Valuation)
    if search:
        query = query.filter(Valuation.ticker.contains(search))

    total = query.count()
    items = (
        query.order_by(Valuation.ticker, Valuation.base_date.desc())
        .offset((page - 1) * size)
        .limit(size)
        .all()
    )

    return {
        "items": [ValuationItem.model_validate(v) for v in items],
        "total": total,
        "page": page,
        "size": size,
    }

Step 4: Register the router

In backend/app/api/__init__.py, add:

from app.api.data_explorer import router as data_explorer_router

And add to __all__:

"data_explorer_router",

In backend/app/main.py, add import:

from app.api import (
    auth_router, admin_router, portfolio_router, strategy_router,
    market_router, backtest_router, snapshot_router, data_explorer_router,
)

And add include:

app.include_router(data_explorer_router)

Step 5: Run tests to verify they pass

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_data_explorer.py -v Expected: ALL PASS

Step 6: Run full test suite

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/ -v Expected: ALL PASS

Step 7: Commit

git add backend/app/api/data_explorer.py backend/app/api/__init__.py backend/app/main.py backend/tests/e2e/test_data_explorer.py
git commit -m "feat: add data explorer API for viewing collected stocks/ETFs/prices"

Task 8: Data Explorer Frontend Page

Files:

  • Create: frontend/src/app/admin/data/explorer/page.tsx

Step 1: Create the data explorer page

Create frontend/src/app/admin/data/explorer/page.tsx:

'use client';

import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { api } from '@/lib/api';

type Tab = 'stocks' | 'etfs' | 'sectors' | 'valuations';

interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  size: number;
}

interface StockItem {
  ticker: string;
  name: string;
  market: string;
  close_price: number | null;
  market_cap: number | null;
}

interface ETFItem {
  ticker: string;
  name: string;
  asset_class: string;
  market: string;
  expense_ratio: number | null;
}

interface SectorItem {
  ticker: string;
  company_name: string;
  sector_code: string;
  sector_name: string;
}

interface ValuationItem {
  ticker: string;
  base_date: string;
  per: number | null;
  pbr: number | null;
  psr: number | null;
  pcr: number | null;
  dividend_yield: number | null;
}

interface PricePoint {
  date: string;
  close: number;
  open?: number;
  high?: number;
  low?: number;
  volume?: number;
}

export default function DataExplorerPage() {
  const router = useRouter();
  const [loading, setLoading] = useState(true);
  const [tab, setTab] = useState<Tab>('stocks');
  const [search, setSearch] = useState('');
  const [page, setPage] = useState(1);
  const [data, setData] = useState<PaginatedResponse<unknown> | null>(null);
  const [fetching, setFetching] = useState(false);

  // Price chart state
  const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
  const [priceType, setPriceType] = useState<'stock' | 'etf'>('stock');
  const [prices, setPrices] = useState<PricePoint[]>([]);
  const [priceLoading, setPriceLoading] = useState(false);

  useEffect(() => {
    const init = async () => {
      try {
        await api.getCurrentUser();
      } catch {
        router.push('/login');
        return;
      }
      setLoading(false);
    };
    init();
  }, [router]);

  const fetchData = useCallback(async () => {
    setFetching(true);
    try {
      const params = new URLSearchParams({ page: String(page), size: '50' });
      if (search) params.set('search', search);

      const endpoint = `/api/data/${tab}?${params}`;
      const result = await api.get<PaginatedResponse<unknown>>(endpoint);
      setData(result);
    } catch {
      setData(null);
    } finally {
      setFetching(false);
    }
  }, [tab, page, search]);

  useEffect(() => {
    if (!loading) fetchData();
  }, [loading, fetchData]);

  const handleTabChange = (newTab: Tab) => {
    setTab(newTab);
    setPage(1);
    setSearch('');
  };

  const handleSearch = () => {
    setPage(1);
    fetchData();
  };

  const viewPrices = async (ticker: string, type: 'stock' | 'etf') => {
    setSelectedTicker(ticker);
    setPriceType(type);
    setPriceLoading(true);
    try {
      const endpoint = type === 'stock'
        ? `/api/data/stocks/${ticker}/prices`
        : `/api/data/etfs/${ticker}/prices`;
      const result = await api.get<PricePoint[]>(endpoint);
      setPrices(result);
    } catch {
      setPrices([]);
    } finally {
      setPriceLoading(false);
    }
  };

  const formatNumber = (v: number | null) =>
    v !== null && v !== undefined ? v.toLocaleString('ko-KR') : '-';

  const totalPages = data ? Math.ceil(data.total / data.size) : 0;

  if (loading) return null;

  return (
    <DashboardLayout>
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-foreground">수집 데이터 탐색</h1>
        <Button variant="outline" onClick={() => router.push('/admin/data')}>
          수집 관리
        </Button>
      </div>

      {/* Tabs */}
      <div className="flex gap-2 mb-4">
        {([
          ['stocks', '주식'],
          ['etfs', 'ETF'],
          ['sectors', '섹터'],
          ['valuations', '밸류에이션'],
        ] as [Tab, string][]).map(([key, label]) => (
          <Button
            key={key}
            variant={tab === key ? 'default' : 'outline'}
            onClick={() => handleTabChange(key)}
          >
            {label}
          </Button>
        ))}
      </div>

      {/* Search */}
      <div className="flex gap-2 mb-4">
        <Input
          placeholder="검색 (종목코드, 이름...)"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
          className="max-w-sm"
        />
        <Button onClick={handleSearch} variant="outline">검색</Button>
      </div>

      {/* Data Table */}
      <Card className="mb-6">
        <CardHeader>
          <CardTitle>
            {tab === 'stocks' && '주식 마스터'}
            {tab === 'etfs' && 'ETF 마스터'}
            {tab === 'sectors' && '섹터 분류'}
            {tab === 'valuations' && '밸류에이션'}
            {data && ` (${data.total}건)`}
          </CardTitle>
        </CardHeader>
        <CardContent className="p-0">
          <div className="overflow-x-auto">
            {tab === 'stocks' && (
              <table className="w-full">
                <thead className="bg-muted">
                  <tr>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목명</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">시장</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종가</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가총액</th>
                    <th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">가격</th>
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {(data?.items as StockItem[] || []).map((s) => (
                    <tr key={s.ticker}>
                      <td className="px-4 py-3 text-sm font-mono">{s.ticker}</td>
                      <td className="px-4 py-3 text-sm">{s.name}</td>
                      <td className="px-4 py-3 text-sm">{s.market}</td>
                      <td className="px-4 py-3 text-sm text-right">{formatNumber(s.close_price)}</td>
                      <td className="px-4 py-3 text-sm text-right">{s.market_cap ? (s.market_cap / 100000000).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '억' : '-'}</td>
                      <td className="px-4 py-3 text-center">
                        <button className="text-primary text-sm hover:underline" onClick={() => viewPrices(s.ticker, 'stock')}>차트</button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            )}

            {tab === 'etfs' && (
              <table className="w-full">
                <thead className="bg-muted">
                  <tr>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목명</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">자산유형</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">보수율</th>
                    <th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">가격</th>
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {(data?.items as ETFItem[] || []).map((e) => (
                    <tr key={e.ticker}>
                      <td className="px-4 py-3 text-sm font-mono">{e.ticker}</td>
                      <td className="px-4 py-3 text-sm">{e.name}</td>
                      <td className="px-4 py-3 text-sm">{e.asset_class}</td>
                      <td className="px-4 py-3 text-sm text-right">{e.expense_ratio !== null ? `${(e.expense_ratio * 100).toFixed(2)}%` : '-'}</td>
                      <td className="px-4 py-3 text-center">
                        <button className="text-primary text-sm hover:underline" onClick={() => viewPrices(e.ticker, 'etf')}>차트</button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            )}

            {tab === 'sectors' && (
              <table className="w-full">
                <thead className="bg-muted">
                  <tr>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">회사명</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터코드</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">섹터명</th>
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {(data?.items as SectorItem[] || []).map((s) => (
                    <tr key={s.ticker}>
                      <td className="px-4 py-3 text-sm font-mono">{s.ticker}</td>
                      <td className="px-4 py-3 text-sm">{s.name ?? s.company_name}</td>
                      <td className="px-4 py-3 text-sm">{s.sector_code}</td>
                      <td className="px-4 py-3 text-sm">{s.sector_name}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            )}

            {tab === 'valuations' && (
              <table className="w-full">
                <thead className="bg-muted">
                  <tr>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">종목코드</th>
                    <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">기준일</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PER</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">PBR</th>
                    <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">배당수익률</th>
                  </tr>
                </thead>
                <tbody className="divide-y divide-border">
                  {(data?.items as ValuationItem[] || []).map((v, i) => (
                    <tr key={`${v.ticker}-${v.base_date}-${i}`}>
                      <td className="px-4 py-3 text-sm font-mono">{v.ticker}</td>
                      <td className="px-4 py-3 text-sm">{v.base_date}</td>
                      <td className="px-4 py-3 text-sm text-right">{v.per?.toFixed(2) ?? '-'}</td>
                      <td className="px-4 py-3 text-sm text-right">{v.pbr?.toFixed(2) ?? '-'}</td>
                      <td className="px-4 py-3 text-sm text-right">{v.dividend_yield ? `${v.dividend_yield.toFixed(2)}%` : '-'}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            )}

            {(!data || data.items.length === 0) && !fetching && (
              <div className="px-4 py-8 text-center text-muted-foreground">데이터가 없습니다.</div>
            )}
          </div>

          {/* Pagination */}
          {totalPages > 1 && (
            <div className="flex items-center justify-between px-4 py-3 border-t border-border">
              <span className="text-sm text-muted-foreground">
                {data?.total}  {((page - 1) * 50) + 1}-{Math.min(page * 50, data?.total ?? 0)}
              </span>
              <div className="flex gap-2">
                <Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
                  이전
                </Button>
                <span className="text-sm py-1">{page} / {totalPages}</span>
                <Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
                  다음
                </Button>
              </div>
            </div>
          )}
        </CardContent>
      </Card>

      {/* Price Chart / Table */}
      {selectedTicker && (
        <Card>
          <CardHeader className="flex flex-row items-center justify-between">
            <CardTitle>{selectedTicker} 가격 데이터 ({prices.length})</CardTitle>
            <button
              className="text-sm text-muted-foreground hover:text-foreground"
              onClick={() => { setSelectedTicker(null); setPrices([]); }}
            >
              닫기
            </button>
          </CardHeader>
          <CardContent className="p-0">
            {priceLoading ? (
              <div className="px-4 py-8 text-center text-muted-foreground">로딩 ...</div>
            ) : prices.length === 0 ? (
              <div className="px-4 py-8 text-center text-muted-foreground">가격 데이터가 없습니다.</div>
            ) : (
              <div className="overflow-x-auto max-h-96 overflow-y-auto">
                <table className="w-full">
                  <thead className="bg-muted sticky top-0">
                    <tr>
                      <th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">날짜</th>
                      {priceType === 'stock' && (
                        <>
                          <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">시가</th>
                          <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">고가</th>
                          <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">저가</th>
                        </>
                      )}
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">종가</th>
                      <th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">거래량</th>
                    </tr>
                  </thead>
                  <tbody className="divide-y divide-border">
                    {[...prices].reverse().map((p) => (
                      <tr key={p.date}>
                        <td className="px-4 py-2 text-sm">{p.date}</td>
                        {priceType === 'stock' && (
                          <>
                            <td className="px-4 py-2 text-sm text-right">{formatNumber(p.open ?? null)}</td>
                            <td className="px-4 py-2 text-sm text-right">{formatNumber(p.high ?? null)}</td>
                            <td className="px-4 py-2 text-sm text-right">{formatNumber(p.low ?? null)}</td>
                          </>
                        )}
                        <td className="px-4 py-2 text-sm text-right">{formatNumber(p.close)}</td>
                        <td className="px-4 py-2 text-sm text-right">{formatNumber(p.volume ?? null)}</td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}
          </CardContent>
        </Card>
      )}
    </DashboardLayout>
  );
}

Step 2: Verify frontend builds

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/frontend && npx next build 2>&1 | tail -20 Expected: Build succeeds

Step 3: Commit

git add frontend/src/app/admin/data/explorer/page.tsx
git commit -m "feat: add data explorer frontend page for viewing collected data"

Task 9: Final Integration Test & Cleanup

Files:

  • Test: all existing tests

Step 1: Run full backend test suite

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/ -v Expected: ALL PASS

Step 2: Run frontend build

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/frontend && npx next build 2>&1 | tail -30 Expected: Build succeeds

Step 3: Verify seed script parses

Run: cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -c "import ast; ast.parse(open('scripts/seed_data.py').read()); print('OK')" Expected: OK

Step 4: Final commit if any fixes needed

git add -A
git commit -m "chore: final integration fixes"