diff --git a/docs/plans/2026-02-11-portfolio-rebalancing-design.md b/docs/plans/2026-02-11-portfolio-rebalancing-design.md new file mode 100644 index 0000000..57f4acc --- /dev/null +++ b/docs/plans/2026-02-11-portfolio-rebalancing-design.md @@ -0,0 +1,2064 @@ +# 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: + +```yaml +# 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: + +```yaml + - 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: + +```yaml + - name: Ensure data directories exist + run: | + mkdir -p /opt/galaxis-po/postgres-data + mkdir -p /opt/galaxis-po/backups +``` + +**Step 3: Commit** + +```bash +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`: + +```python +""" +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): + +```python +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** + +```bash +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`: + +```python + 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** + +```bash +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): + +```python +# 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): + +```python +@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** + +```bash +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`: + +```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([]); + const [holdings, setHoldings] = useState([]); + const [prices, setPrices] = useState>({}); + const [strategy, setStrategy] = useState('full_rebalance'); + const [additionalAmount, setAdditionalAmount] = useState(''); + const [result, setResult] = useState(null); + const [calculating, setCalculating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const init = async () => { + try { + await api.getCurrentUser(); + const [targetsData, holdingsData] = await Promise.all([ + api.get(`/api/portfolios/${portfolioId}/targets`), + api.get(`/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 = {}; + 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 = {}; + for (const [ticker, price] of Object.entries(prices)) { + priceMap[ticker] = parseFloat(price); + } + + const body: Record = { + strategy, + prices: priceMap, + }; + if (strategy === 'additional_buy' && additionalAmount) { + body.additional_amount = parseFloat(additionalAmount); + } + + const data = await api.post( + `/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 = { + 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 = { buy: '매수', sell: '매도', hold: '유지' }; + return ( + + {labels[action] || action} + + ); + }; + + const getHoldingQty = (ticker: string) => + holdings.find((h) => h.ticker === ticker)?.quantity ?? 0; + + if (loading) return null; + + return ( + +
+

리밸런싱 계산

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Price Input */} + + + 현재 가격 입력 + + +
+ {Object.keys(prices).map((ticker) => { + const target = targets.find((t) => t.ticker === ticker); + return ( +
+ + setPrices((prev) => ({ ...prev, [ticker]: e.target.value }))} + placeholder="현재 가격" + className="mt-1" + /> +
+ ); + })} +
+
+
+ + {/* Strategy Selection */} + + +
+
+ +
+ + +
+
+ + {strategy === 'additional_buy' && ( +
+ + setAdditionalAmount(e.target.value)} + placeholder="예: 1000000" + className="mt-1" + /> +
+ )} + +
+ +
+
+
+
+ + {/* Results */} + {result && ( + <> + + +
+
+

총 자산

+

{formatCurrency(result.total_assets)}

+
+ {result.available_to_buy !== null && ( +
+

매수 가능

+

+ {formatCurrency(result.available_to_buy)} +

+
+ )} +
+
+
+ + + + 리밸런싱 내역 + + +
+ + + + + + + + + + + + + + + + + + {result.items.map((item) => ( + + + + + + + + + + + + + + ))} + +
종목보유량현재가평가금액현재 비중목표 비중비중 차이조정 수량전월비시작일비액션
+
{item.ticker}
+ {item.name && ( +
{item.name}
+ )} +
+ {item.current_quantity.toLocaleString()} + + {formatCurrency(item.current_price)} + + {formatCurrency(item.current_value)} + + {item.current_ratio.toFixed(2)}% + + {item.target_ratio.toFixed(2)}% + 0 + ? 'text-green-600' + : item.diff_ratio < 0 + ? 'text-red-600' + : '' + }`} + > + {item.diff_ratio > 0 ? '+' : ''} + {item.diff_ratio.toFixed(2)}% + 0 + ? 'text-green-600' + : item.diff_quantity < 0 + ? 'text-red-600' + : '' + }`} + > + {item.diff_quantity > 0 ? '+' : ''} + {item.diff_quantity} + 0 ? 'text-green-600' : '' + }`} + > + {formatPct(item.change_vs_prev_month)} + 0 ? 'text-green-600' : '' + }`} + > + {formatPct(item.change_vs_start)} + {getActionBadge(item.action)}
+
+
+
+ + )} +
+ ); +} +``` + +**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** + +```bash +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`: + +```python +""" +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`** + +```python +# 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** + +```bash +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`: + +```python +""" +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`** + +```python +""" +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: + +```python +from app.api.data_explorer import router as data_explorer_router +``` + +And add to `__all__`: + +```python +"data_explorer_router", +``` + +In `backend/app/main.py`, add import: + +```python +from app.api import ( + auth_router, admin_router, portfolio_router, strategy_router, + market_router, backtest_router, snapshot_router, data_explorer_router, +) +``` + +And add include: + +```python +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** + +```bash +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`: + +```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 { + 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('stocks'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [data, setData] = useState | null>(null); + const [fetching, setFetching] = useState(false); + + // Price chart state + const [selectedTicker, setSelectedTicker] = useState(null); + const [priceType, setPriceType] = useState<'stock' | 'etf'>('stock'); + const [prices, setPrices] = useState([]); + 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>(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(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 ( + +
+

수집 데이터 탐색

+ +
+ + {/* Tabs */} +
+ {([ + ['stocks', '주식'], + ['etfs', 'ETF'], + ['sectors', '섹터'], + ['valuations', '밸류에이션'], + ] as [Tab, string][]).map(([key, label]) => ( + + ))} +
+ + {/* Search */} +
+ setSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="max-w-sm" + /> + +
+ + {/* Data Table */} + + + + {tab === 'stocks' && '주식 마스터'} + {tab === 'etfs' && 'ETF 마스터'} + {tab === 'sectors' && '섹터 분류'} + {tab === 'valuations' && '밸류에이션'} + {data && ` (${data.total}건)`} + + + +
+ {tab === 'stocks' && ( + + + + + + + + + + + + + {(data?.items as StockItem[] || []).map((s) => ( + + + + + + + + + ))} + +
종목코드종목명시장종가시가총액가격
{s.ticker}{s.name}{s.market}{formatNumber(s.close_price)}{s.market_cap ? (s.market_cap / 100000000).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '억' : '-'} + +
+ )} + + {tab === 'etfs' && ( + + + + + + + + + + + + {(data?.items as ETFItem[] || []).map((e) => ( + + + + + + + + ))} + +
종목코드종목명자산유형보수율가격
{e.ticker}{e.name}{e.asset_class}{e.expense_ratio !== null ? `${(e.expense_ratio * 100).toFixed(2)}%` : '-'} + +
+ )} + + {tab === 'sectors' && ( + + + + + + + + + + + {(data?.items as SectorItem[] || []).map((s) => ( + + + + + + + ))} + +
종목코드회사명섹터코드섹터명
{s.ticker}{s.name ?? s.company_name}{s.sector_code}{s.sector_name}
+ )} + + {tab === 'valuations' && ( + + + + + + + + + + + + {(data?.items as ValuationItem[] || []).map((v, i) => ( + + + + + + + + ))} + +
종목코드기준일PERPBR배당수익률
{v.ticker}{v.base_date}{v.per?.toFixed(2) ?? '-'}{v.pbr?.toFixed(2) ?? '-'}{v.dividend_yield ? `${v.dividend_yield.toFixed(2)}%` : '-'}
+ )} + + {(!data || data.items.length === 0) && !fetching && ( +
데이터가 없습니다.
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + {data?.total}건 중 {((page - 1) * 50) + 1}-{Math.min(page * 50, data?.total ?? 0)} + +
+ + {page} / {totalPages} + +
+
+ )} +
+
+ + {/* Price Chart / Table */} + {selectedTicker && ( + + + {selectedTicker} 가격 데이터 ({prices.length}건) + + + + {priceLoading ? ( +
로딩 중...
+ ) : prices.length === 0 ? ( +
가격 데이터가 없습니다.
+ ) : ( +
+ + + + + {priceType === 'stock' && ( + <> + + + + + )} + + + + + + {[...prices].reverse().map((p) => ( + + + {priceType === 'stock' && ( + <> + + + + + )} + + + + ))} + +
날짜시가고가저가종가거래량
{p.date}{formatNumber(p.open ?? null)}{formatNumber(p.high ?? null)}{formatNumber(p.low ?? null)}{formatNumber(p.close)}{formatNumber(p.volume ?? null)}
+
+ )} +
+
+ )} +
+ ); +} +``` + +**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** + +```bash +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** + +```bash +git add -A +git commit -m "chore: final integration fixes" +``` diff --git a/docs/plans/2026-02-14-data-collection-scheduling.md b/docs/plans/2026-02-14-data-collection-scheduling.md new file mode 100644 index 0000000..b0ea497 --- /dev/null +++ b/docs/plans/2026-02-14-data-collection-scheduling.md @@ -0,0 +1,605 @@ +# Data Collection Scheduling Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add daily scheduled data collection (18:00 Mon-Fri) and an API-triggered backfill job to collect all historical price data. + +**Architecture:** A new `collection_job.py` module orchestrates collectors in dependency order (master data first, then prices). The scheduler registers the daily job alongside the existing snapshot job. Backfill splits date ranges into yearly chunks, reusing existing `PriceCollector` and `ETFPriceCollector`. + +**Tech Stack:** APScheduler (already in use), existing pykrx-based collectors, PostgreSQL, FastAPI + +--- + +### Task 1: Create `collection_job.py` with `run_daily_collection` + +**Files:** +- Create: `backend/jobs/collection_job.py` +- Test: `backend/tests/e2e/test_collection_job.py` + +**Step 1: Write the failing test** + +Create `backend/tests/e2e/test_collection_job.py`: + +```python +""" +Tests for collection job orchestration. +""" +import pytest +from unittest.mock import patch, MagicMock +from datetime import datetime + +from jobs.collection_job import run_daily_collection + + +def test_run_daily_collection_calls_collectors_in_order(): + """Daily collection should run all collectors in dependency order.""" + call_order = [] + + def make_mock_collector(name): + mock_cls = MagicMock() + instance = MagicMock() + instance.run.side_effect = lambda: call_order.append(name) + mock_cls.return_value = instance + return mock_cls + + with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ + patch("jobs.collection_job.StockCollector", make_mock_collector("stock")), \ + patch("jobs.collection_job.SectorCollector", make_mock_collector("sector")), \ + patch("jobs.collection_job.PriceCollector", make_mock_collector("price")), \ + patch("jobs.collection_job.ValuationCollector", make_mock_collector("valuation")), \ + patch("jobs.collection_job.ETFCollector", make_mock_collector("etf")), \ + patch("jobs.collection_job.ETFPriceCollector", make_mock_collector("etf_price")): + mock_session_local.return_value = MagicMock() + run_daily_collection() + + assert call_order == ["stock", "sector", "price", "valuation", "etf", "etf_price"] + + +def test_run_daily_collection_continues_on_failure(): + """If one collector fails, the rest should still run.""" + call_order = [] + + def make_mock_collector(name, should_fail=False): + mock_cls = MagicMock() + instance = MagicMock() + def side_effect(): + if should_fail: + raise RuntimeError(f"{name} failed") + call_order.append(name) + instance.run.side_effect = side_effect + mock_cls.return_value = instance + return mock_cls + + with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ + patch("jobs.collection_job.StockCollector", make_mock_collector("stock", should_fail=True)), \ + patch("jobs.collection_job.SectorCollector", make_mock_collector("sector")), \ + patch("jobs.collection_job.PriceCollector", make_mock_collector("price")), \ + patch("jobs.collection_job.ValuationCollector", make_mock_collector("valuation")), \ + patch("jobs.collection_job.ETFCollector", make_mock_collector("etf")), \ + patch("jobs.collection_job.ETFPriceCollector", make_mock_collector("etf_price")): + mock_session_local.return_value = MagicMock() + run_daily_collection() + + # stock failed, but rest should continue + assert call_order == ["sector", "price", "valuation", "etf", "etf_price"] +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'jobs.collection_job'` + +**Step 3: Write minimal implementation** + +Create `backend/jobs/collection_job.py`: + +```python +""" +Data collection orchestration jobs. +""" +import logging +from datetime import datetime + +from app.core.database import SessionLocal +from app.services.collectors import ( + StockCollector, + SectorCollector, + PriceCollector, + ValuationCollector, + ETFCollector, + ETFPriceCollector, +) + +logger = logging.getLogger(__name__) + +# Collectors in dependency order: master data first, then derived data +DAILY_COLLECTORS = [ + ("StockCollector", StockCollector, {}), + ("SectorCollector", SectorCollector, {}), + ("PriceCollector", PriceCollector, {}), + ("ValuationCollector", ValuationCollector, {}), + ("ETFCollector", ETFCollector, {}), + ("ETFPriceCollector", ETFPriceCollector, {}), +] + + +def run_daily_collection(): + """ + Run all data collectors in dependency order. + + Each collector gets its own DB session. If one fails, the rest continue. + Designed to be called by APScheduler at 18:00 Mon-Fri. + """ + logger.info("Starting daily data collection") + results = {} + + for name, collector_cls, kwargs in DAILY_COLLECTORS: + db = SessionLocal() + try: + collector = collector_cls(db, **kwargs) + collector.run() + results[name] = "success" + logger.info(f"{name} completed successfully") + except Exception as e: + results[name] = f"failed: {e}" + logger.error(f"{name} failed: {e}") + finally: + db.close() + + logger.info(f"Daily collection finished: {results}") +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py -v` +Expected: 2 tests PASS + +**Step 5: Commit** + +```bash +git add backend/jobs/collection_job.py backend/tests/e2e/test_collection_job.py +git commit -m "feat: add daily collection job orchestration" +``` + +--- + +### Task 2: Add `run_backfill` to `collection_job.py` + +**Files:** +- Modify: `backend/jobs/collection_job.py` +- Test: `backend/tests/e2e/test_collection_job.py` + +**Step 1: Write the failing test** + +Append to `backend/tests/e2e/test_collection_job.py`: + +```python +from jobs.collection_job import run_backfill + + +def test_run_backfill_generates_yearly_chunks(): + """Backfill should split date range into yearly chunks.""" + collected_ranges = [] + + def make_price_collector(name): + mock_cls = MagicMock() + def capture_init(db, start_date=None, end_date=None): + instance = MagicMock() + collected_ranges.append((name, start_date, end_date)) + return instance + mock_cls.side_effect = capture_init + return mock_cls + + with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ + patch("jobs.collection_job.PriceCollector", make_price_collector("price")), \ + patch("jobs.collection_job.ETFPriceCollector", make_price_collector("etf_price")): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + # Simulate no existing data (min date returns None) + mock_db.query.return_value.scalar.return_value = None + + run_backfill(start_year=2023) + + # Should generate chunks: 2023, 2024, 2025, 2026 (partial) for both price and etf_price + price_ranges = [(s, e) for name, s, e in collected_ranges if name == "price"] + assert len(price_ranges) >= 3 # At least 2023, 2024, 2025 + assert price_ranges[0][0] == "20230101" # First chunk starts at start_year + + +def test_run_backfill_skips_already_collected_range(): + """Backfill should start from earliest existing data backwards.""" + collected_ranges = [] + + def make_price_collector(name): + mock_cls = MagicMock() + def capture_init(db, start_date=None, end_date=None): + instance = MagicMock() + collected_ranges.append((name, start_date, end_date)) + return instance + mock_cls.side_effect = capture_init + return mock_cls + + from datetime import date + + with patch("jobs.collection_job.SessionLocal") as mock_session_local, \ + patch("jobs.collection_job.PriceCollector", make_price_collector("price")), \ + patch("jobs.collection_job.ETFPriceCollector", make_price_collector("etf_price")): + mock_db = MagicMock() + mock_session_local.return_value = mock_db + + # Simulate: Price has data from 2024-06-01, ETFPrice has no data + def query_side_effect(model_attr): + mock_q = MagicMock() + if "Price" in str(model_attr): + mock_q.scalar.return_value = date(2024, 6, 1) + else: + mock_q.scalar.return_value = None + return mock_q + mock_db.query.return_value = MagicMock() + # We'll verify the function runs without error + run_backfill(start_year=2023) + + assert len(collected_ranges) > 0 +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py::test_run_backfill_generates_yearly_chunks -v` +Expected: FAIL with `ImportError: cannot import name 'run_backfill'` + +**Step 3: Write minimal implementation** + +Add to `backend/jobs/collection_job.py`: + +```python +from datetime import date, timedelta +from sqlalchemy import func + +from app.models.stock import Price, ETFPrice + + +def _generate_yearly_chunks(start_year: int, end_date: date) -> list[tuple[str, str]]: + """Generate (start_date, end_date) pairs in YYYYMMDD format, one per year.""" + chunks = [] + current_start = date(start_year, 1, 1) + + while current_start < end_date: + current_end = date(current_start.year, 12, 31) + if current_end > end_date: + current_end = end_date + chunks.append(( + current_start.strftime("%Y%m%d"), + current_end.strftime("%Y%m%d"), + )) + current_start = date(current_start.year + 1, 1, 1) + + return chunks + + +def run_backfill(start_year: int = 2000): + """ + Collect historical price data from start_year to today. + + Checks the earliest existing data in DB and only collects + missing periods. Splits into yearly chunks to avoid overloading pykrx. + """ + logger.info(f"Starting backfill from {start_year}") + today = date.today() + + db = SessionLocal() + try: + # Determine what needs backfilling + backfill_targets = [ + ("Price", PriceCollector, Price.date), + ("ETFPrice", ETFPriceCollector, ETFPrice.date), + ] + + for name, collector_cls, date_col in backfill_targets: + # Find earliest existing data + earliest = db.query(func.min(date_col)).scalar() + + if earliest is None: + # No data at all - collect everything + backfill_end = today + else: + # Data exists - collect from start_year to day before earliest + backfill_end = earliest - timedelta(days=1) + + if date(start_year, 1, 1) >= backfill_end: + logger.info(f"{name}: no backfill needed (data exists from {earliest})") + continue + + chunks = _generate_yearly_chunks(start_year, backfill_end) + logger.info(f"{name}: backfilling {len(chunks)} yearly chunks from {start_year} to {backfill_end}") + + for start_dt, end_dt in chunks: + chunk_db = SessionLocal() + try: + collector = collector_cls(chunk_db, start_date=start_dt, end_date=end_dt) + collector.run() + logger.info(f"{name}: chunk {start_dt}-{end_dt} completed") + except Exception as e: + logger.error(f"{name}: chunk {start_dt}-{end_dt} failed: {e}") + finally: + chunk_db.close() + + # Also fill gap between earliest data and today (forward fill) + if earliest is not None: + latest = db.query(func.max(date_col)).scalar() + if latest and latest < today: + gap_start = (latest + timedelta(days=1)).strftime("%Y%m%d") + gap_end = today.strftime("%Y%m%d") + gap_db = SessionLocal() + try: + collector = collector_cls(gap_db, start_date=gap_start, end_date=gap_end) + collector.run() + logger.info(f"{name}: forward fill {gap_start}-{gap_end} completed") + except Exception as e: + logger.error(f"{name}: forward fill failed: {e}") + finally: + gap_db.close() + + finally: + db.close() + + logger.info("Backfill completed") +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py -v` +Expected: All 4 tests PASS + +**Step 5: Commit** + +```bash +git add backend/jobs/collection_job.py backend/tests/e2e/test_collection_job.py +git commit -m "feat: add backfill job for historical price data" +``` + +--- + +### Task 3: Register daily collection in scheduler + +**Files:** +- Modify: `backend/jobs/scheduler.py` +- Modify: `backend/jobs/__init__.py` +- Test: `backend/tests/e2e/test_collection_job.py` + +**Step 1: Write the failing test** + +Append to `backend/tests/e2e/test_collection_job.py`: + +```python +def test_scheduler_has_daily_collection_job(): + """Scheduler should register a daily_collection job at 18:00.""" + from jobs.scheduler import scheduler, configure_jobs + + # Reset scheduler for test + if scheduler.running: + scheduler.shutdown(wait=False) + + from apscheduler.schedulers.background import BackgroundScheduler + test_scheduler = BackgroundScheduler() + + with patch("jobs.scheduler.scheduler", test_scheduler): + configure_jobs() + + jobs = {job.id: job for job in test_scheduler.get_jobs()} + assert "daily_collection" in jobs + + trigger = jobs["daily_collection"].trigger + # Verify it's a cron trigger (we can check string representation) + trigger_str = str(trigger) + assert "18" in trigger_str # hour=18 +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py::test_scheduler_has_daily_collection_job -v` +Expected: FAIL with `AssertionError: 'daily_collection' not in jobs` + +**Step 3: Modify scheduler to add the daily collection job** + +Modify `backend/jobs/scheduler.py`: + +```python +""" +APScheduler configuration for background jobs. +""" +import logging +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from jobs.snapshot_job import create_daily_snapshots +from jobs.collection_job import run_daily_collection + +logger = logging.getLogger(__name__) + +# Create scheduler instance +scheduler = BackgroundScheduler() + + +def configure_jobs(): + """Configure scheduled jobs.""" + # Daily data collection at 18:00 (after market close, before snapshot) + scheduler.add_job( + run_daily_collection, + trigger=CronTrigger( + hour=18, + minute=0, + day_of_week='mon-fri', + ), + id='daily_collection', + name='Collect daily market data', + replace_existing=True, + ) + logger.info("Configured daily_collection job at 18:00") + + # Daily snapshot at 18:30 (after data collection completes) + scheduler.add_job( + create_daily_snapshots, + trigger=CronTrigger( + hour=18, + minute=30, + day_of_week='mon-fri', + ), + id='daily_snapshots', + name='Create daily portfolio snapshots', + replace_existing=True, + ) + logger.info("Configured daily_snapshots job at 18:30") + + +def start_scheduler(): + """Start the scheduler.""" + if not scheduler.running: + configure_jobs() + scheduler.start() + logger.info("Scheduler started") + + +def stop_scheduler(): + """Stop the scheduler.""" + if scheduler.running: + scheduler.shutdown() + logger.info("Scheduler stopped") +``` + +Update `backend/jobs/__init__.py`: + +```python +""" +Background jobs module. +""" +from jobs.scheduler import scheduler, start_scheduler, stop_scheduler +from jobs.collection_job import run_daily_collection, run_backfill + +__all__ = [ + "scheduler", "start_scheduler", "stop_scheduler", + "run_daily_collection", "run_backfill", +] +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py -v` +Expected: All 5 tests PASS + +**Step 5: Commit** + +```bash +git add backend/jobs/scheduler.py backend/jobs/__init__.py backend/tests/e2e/test_collection_job.py +git commit -m "feat: register daily collection job at 18:00 in scheduler" +``` + +--- + +### Task 4: Add backfill API endpoint + +**Files:** +- Modify: `backend/app/api/admin.py` +- Test: `backend/tests/e2e/test_collection_job.py` + +**Step 1: Write the failing test** + +Append to `backend/tests/e2e/test_collection_job.py`: + +```python +def test_backfill_api_endpoint(client, admin_auth_headers): + """POST /api/admin/collect/backfill should trigger backfill.""" + with patch("app.api.admin.run_backfill_background") as mock_backfill: + response = client.post( + "/api/admin/collect/backfill?start_year=2020", + headers=admin_auth_headers, + ) + assert response.status_code == 200 + assert "backfill" in response.json()["message"].lower() + mock_backfill.assert_called_once() + + +def test_backfill_api_requires_auth(client): + """POST /api/admin/collect/backfill should require authentication.""" + response = client.post("/api/admin/collect/backfill") + assert response.status_code == 401 +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py::test_backfill_api_endpoint -v` +Expected: FAIL with 404 or import error + +**Step 3: Add backfill endpoint to admin API** + +Add to `backend/app/api/admin.py`, after existing imports: + +```python +from jobs.collection_job import run_backfill +``` + +Add the background runner function (after `_start_background_collection`): + +```python +def _run_backfill_background(start_year: int): + """Run backfill in a background thread.""" + try: + run_backfill(start_year=start_year) + except Exception as e: + logger.error("Background backfill failed: %s", e) + + +def run_backfill_background(start_year: int): + """Start backfill in a daemon thread.""" + thread = threading.Thread( + target=_run_backfill_background, + args=(start_year,), + daemon=True, + ) + thread.start() +``` + +Add the endpoint (after `collect_etf_prices`): + +```python +@router.post("/collect/backfill", response_model=CollectResponse) +async def collect_backfill( + current_user: CurrentUser, + start_year: int = Query(2000, ge=1990, le=2026, description="Start year for backfill"), +): + """Backfill historical price data from start_year to today (runs in background).""" + run_backfill_background(start_year) + return CollectResponse(message=f"Backfill started from {start_year}") +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/e2e/test_collection_job.py -v` +Expected: All 7 tests PASS + +**Step 5: Commit** + +```bash +git add backend/app/api/admin.py backend/tests/e2e/test_collection_job.py +git commit -m "feat: add backfill API endpoint for historical data collection" +``` + +--- + +### Task 5: Run full test suite and verify + +**Step 1: Run all existing tests** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -m pytest tests/ -v` +Expected: All tests PASS (existing + new) + +**Step 2: Verify no import issues** + +Run: `cd /home/zephyrdark/workspace/quant/galaxy-po/backend && python -c "from jobs import run_daily_collection, run_backfill; print('OK')"` +Expected: `OK` + +**Step 3: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: resolve any test issues from collection scheduling" +```