Compare commits
9 Commits
08710a6dba
...
0a8d17a588
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8d17a588 | |||
| 4afd01c947 | |||
| aa3e2d40d2 | |||
| 9fa97e595d | |||
| bffca88ce9 | |||
| 8c00359a50 | |||
| de77d5b2aa | |||
| 6d7cf340ea | |||
| 75a408362e |
@ -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
1
.gitignore
vendored
@ -54,6 +54,7 @@ Thumbs.db
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
# Test
|
||||
.coverage
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
237
backend/app/api/data_explorer.py
Normal file
237
backend/app/api/data_explorer.py
Normal 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,
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
212
backend/scripts/seed_data.py
Normal file
212
backend/scripts/seed_data.py
Normal 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()
|
||||
93
backend/tests/e2e/test_data_explorer.py
Normal file
93
backend/tests/e2e/test_data_explorer.py
Normal 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
|
||||
113
backend/tests/e2e/test_rebalance_flow.py
Normal file
113
backend/tests/e2e/test_rebalance_flow.py
Normal 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
|
||||
@ -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
|
||||
|
||||
384
frontend/src/app/admin/data/explorer/page.tsx
Normal file
384
frontend/src/app/admin/data/explorer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user