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