Compare commits

...

9 Commits

Author SHA1 Message Date
0a8d17a588 fix: use relative paths for postgres data and backups to avoid root permission issues
All checks were successful
Deploy to Production / deploy (push) Successful in 1m58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:42:23 +09:00
4afd01c947 feat: add data explorer frontend page for viewing collected data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:34:50 +09:00
aa3e2d40d2 feat: add data explorer API for viewing collected stocks/ETFs/prices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:33:11 +09:00
9fa97e595d feat: rebalance page with manual price input and strategy selection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:32:49 +09:00
bffca88ce9 feat: add POST /rebalance/calculate endpoint with manual prices
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:30:22 +09:00
8c00359a50 feat: add rebalance calculation with manual prices and additional_buy strategy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:28:40 +09:00
de77d5b2aa feat: add rebalance calculate schemas and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:27:47 +09:00
6d7cf340ea feat: add historical data import script from data.txt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:27:36 +09:00
75a408362e fix: use host bind mount for postgres data to prevent data loss on deploy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:26:55 +09:00
15 changed files with 1601 additions and 119 deletions

View File

@ -37,6 +37,17 @@ jobs:
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
EOF
- name: Backup database before deploy
run: |
mkdir -p ./data/backups
docker exec galaxis-po-db pg_dump -U ${{ secrets.DB_USER }} ${{ secrets.DB_NAME }} \
> ./data/backups/$(date +%Y%m%d_%H%M%S).sql 2>/dev/null || true
- name: Ensure data directories exist
run: |
mkdir -p ./data/postgres
mkdir -p ./data/backups
- name: Deploy with Docker Compose
run: |
docker compose --project-name galaxis-po --env-file .env.prod -f docker-compose.prod.yml down || true

1
.gitignore vendored
View File

@ -54,6 +54,7 @@ Thumbs.db
# Database
*.db
*.sqlite3
data/
# Test
.coverage

View File

@ -5,6 +5,7 @@ from app.api.strategy import router as strategy_router
from app.api.market import router as market_router
from app.api.backtest import router as backtest_router
from app.api.snapshot import router as snapshot_router
from app.api.data_explorer import router as data_explorer_router
__all__ = [
"auth_router",
@ -14,4 +15,5 @@ __all__ = [
"market_router",
"backtest_router",
"snapshot_router",
"data_explorer_router",
]

View File

@ -0,0 +1,237 @@
"""
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,
}

View File

@ -16,6 +16,7 @@ from app.schemas.portfolio import (
HoldingCreate, HoldingResponse, HoldingWithValue,
TransactionCreate, TransactionResponse,
RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse,
RebalanceCalculateRequest, RebalanceCalculateResponse,
)
from app.services.rebalance import RebalanceService
@ -319,6 +320,31 @@ async def simulate_rebalance(
return service.calculate_rebalance(portfolio, additional_amount=data.additional_amount)
@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,
)
@router.get("/{portfolio_id}/detail", response_model=PortfolioDetail)
async def get_portfolio_detail(
portfolio_id: int,

View File

@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api import (
auth_router, admin_router, portfolio_router, strategy_router,
market_router, backtest_router, snapshot_router,
market_router, backtest_router, snapshot_router, data_explorer_router,
)
# Configure logging
@ -111,6 +111,7 @@ app.include_router(strategy_router)
app.include_router(market_router)
app.include_router(backtest_router)
app.include_router(snapshot_router)
app.include_router(data_explorer_router)
@app.get("/health")

View File

@ -186,3 +186,35 @@ class RebalanceSimulationResponse(BaseModel):
additional_amount: Decimal
new_total: Decimal
items: List[RebalanceItem]
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]

View File

@ -152,3 +152,249 @@ class RebalanceService:
total_value=current_total,
items=items,
)
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

View File

View File

@ -0,0 +1,212 @@
"""
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()

View File

@ -0,0 +1,93 @@
"""
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

View File

@ -0,0 +1,113 @@
"""
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 float(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 float(data["total_assets"]) > 0
assert float(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

View File

@ -10,7 +10,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
@ -60,10 +60,6 @@ services:
networks:
- galaxy-net
volumes:
postgres_data:
driver: local
networks:
galaxy-net:
driver: bridge

View File

@ -0,0 +1,384 @@
'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.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>
);
}

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -9,6 +9,17 @@ 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;
@ -16,23 +27,23 @@ interface RebalanceItem {
current_ratio: number;
current_quantity: number;
current_value: number;
current_price: number;
target_value: number;
diff_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_value: number;
total_assets: number;
available_to_buy: number | null;
items: RebalanceItem[];
}
interface SimulationResponse extends RebalanceResponse {
current_total: number;
additional_amount: number;
new_total: number;
}
type Strategy = 'full_rebalance' | 'additional_buy';
export default function RebalancePage() {
const router = useRouter();
@ -40,27 +51,36 @@ export default function RebalancePage() {
const portfolioId = params.id as string;
const [loading, setLoading] = useState(true);
const [rebalance, setRebalance] = useState<RebalanceResponse | SimulationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
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 [simulating, setSimulating] = useState(false);
const fetchRebalance = useCallback(async () => {
try {
setError(null);
const data = await api.get<RebalanceResponse>(`/api/portfolios/${portfolioId}/rebalance`);
setRebalance(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to calculate rebalance';
setError(message);
}
}, [portfolioId]);
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();
await fetchRebalance();
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 {
@ -68,32 +88,50 @@ export default function RebalancePage() {
}
};
init();
}, [router, fetchRebalance]);
}, [portfolioId, router]);
const simulate = async () => {
if (!additionalAmount) return;
setSimulating(true);
const allPricesFilled = Object.values(prices).every((p) => p !== '' && parseFloat(p) > 0);
const calculate = async () => {
if (!allPricesFilled) return;
setCalculating(true);
setError(null);
try {
setError(null);
const data = await api.post<SimulationResponse>(
`/api/portfolios/${portfolioId}/rebalance/simulate`,
{ additional_amount: parseFloat(additionalAmount) }
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
);
setRebalance(data);
setResult(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Simulation failed';
setError(message);
setError(err instanceof Error ? err.message : 'Calculation failed');
} finally {
setSimulating(false);
setCalculating(false);
}
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('ko-KR', {
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) => {
@ -102,11 +140,7 @@ export default function RebalancePage() {
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: '유지',
};
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}
@ -114,13 +148,19 @@ export default function RebalancePage() {
);
};
if (loading) {
return null;
}
const getHoldingQty = (ticker: string) =>
holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;
if (loading) return null;
return (
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
<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">
@ -128,67 +168,109 @@ export default function RebalancePage() {
</div>
)}
{/* Simulation Input */}
{/* Price Input */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex gap-4 items-end">
<div className="flex-1">
<Label htmlFor="additional-amount"> ()</Label>
<Input
id="additional-amount"
type="number"
value={additionalAmount}
onChange={(e) => setAdditionalAmount(e.target.value)}
placeholder="예: 1000000"
className="mt-2"
/>
</div>
<Button
onClick={simulate}
disabled={!additionalAmount || simulating}
>
{simulating ? '계산 중...' : '시뮬레이션'}
</Button>
<Button variant="outline" onClick={fetchRebalance}>
</Button>
<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>
{rebalance && (
{/* 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 && (
<>
{/* Summary */}
<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('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
</p>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold">{formatCurrency(result.total_assets)}</p>
</div>
{'additional_amount' in rebalance && (
<>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold text-blue-600">
+{formatCurrency(rebalance.additional_amount)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold">
{formatCurrency(rebalance.new_total)}
</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>
{/* Rebalance Table */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
@ -198,36 +280,82 @@ export default function RebalancePage() {
<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-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>
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground"></th>
<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">
{rebalance.items.map((item) => (
{result.items.map((item) => (
<tr key={item.ticker}>
<td className="px-4 py-3">
<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>}
{item.name && (
<div className="text-xs text-muted-foreground">{item.name}</div>
)}
</td>
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td>
<td className="px-4 py-3 text-sm text-right">{item.current_quantity.toLocaleString()}</td>
<td className={`px-4 py-3 text-sm text-right ${
item.diff_value > 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : ''
}`}>
{item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)}
<td className="px-3 py-3 text-sm text-right">
{item.current_quantity.toLocaleString()}
</td>
<td className={`px-4 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 className="px-3 py-3 text-sm text-right">
{formatCurrency(item.current_price)}
</td>
<td className="px-4 py-3 text-center">{getActionBadge(item.action)}</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>