diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_signal_execution_fields.py b/backend/alembic/versions/a1b2c3d4e5f6_add_signal_execution_fields.py new file mode 100644 index 0000000..1e81331 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_signal_execution_fields.py @@ -0,0 +1,30 @@ +"""add signal execution tracking fields + +Revision ID: a1b2c3d4e5f6 +Revises: 6c09aa4368e5 +Create Date: 2026-02-19 14:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '6c09aa4368e5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('signals', sa.Column('executed_price', sa.Numeric(precision=12, scale=2), nullable=True)) + op.add_column('signals', sa.Column('executed_quantity', sa.Integer(), nullable=True)) + op.add_column('signals', sa.Column('executed_at', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('signals', 'executed_at') + op.drop_column('signals', 'executed_quantity') + op.drop_column('signals', 'executed_price') diff --git a/backend/app/api/portfolio.py b/backend/app/api/portfolio.py index ad6882d..ffacb40 100644 --- a/backend/app/api/portfolio.py +++ b/backend/app/api/portfolio.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.api.deps import CurrentUser from app.models.portfolio import Portfolio, PortfolioType, Target, Holding, Transaction, TransactionType +from app.models.stock import ETF from app.schemas.portfolio import ( PortfolioCreate, PortfolioUpdate, PortfolioResponse, PortfolioDetail, TargetCreate, TargetResponse, @@ -17,6 +18,7 @@ from app.schemas.portfolio import ( TransactionCreate, TransactionResponse, RebalanceResponse, RebalanceSimulationRequest, RebalanceSimulationResponse, RebalanceCalculateRequest, RebalanceCalculateResponse, + RebalanceApplyRequest, RebalanceApplyResponse, ) from app.services.rebalance import RebalanceService @@ -363,6 +365,90 @@ async def calculate_rebalance_manual( ) +@router.post("/{portfolio_id}/rebalance/apply", response_model=RebalanceApplyResponse, status_code=status.HTTP_201_CREATED) +async def apply_rebalance( + portfolio_id: int, + data: RebalanceApplyRequest, + current_user: CurrentUser, + db: Session = Depends(get_db), +): + """리밸런싱 결과를 적용하여 거래를 일괄 생성한다.""" + from datetime import datetime + + portfolio = _get_portfolio(db, portfolio_id, current_user.id) + transactions = [] + service = RebalanceService(db) + + for item in data.items: + tx_type = TransactionType(item.action) + transaction = Transaction( + portfolio_id=portfolio_id, + ticker=item.ticker, + tx_type=tx_type, + quantity=item.quantity, + price=item.price, + executed_at=datetime.utcnow(), + memo="리밸런싱 적용", + ) + db.add(transaction) + + # Update holding + holding = db.query(Holding).filter( + Holding.portfolio_id == portfolio_id, + Holding.ticker == item.ticker, + ).first() + + if tx_type == TransactionType.BUY: + if holding: + total_value = (holding.quantity * holding.avg_price) + (item.quantity * item.price) + new_quantity = holding.quantity + item.quantity + holding.quantity = new_quantity + holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0 + else: + holding = Holding( + portfolio_id=portfolio_id, + ticker=item.ticker, + quantity=item.quantity, + avg_price=item.price, + ) + db.add(holding) + elif tx_type == TransactionType.SELL: + if not holding or holding.quantity < item.quantity: + raise HTTPException(status_code=400, detail=f"Insufficient quantity for {item.ticker}") + holding.quantity -= item.quantity + if holding.quantity == 0: + db.delete(holding) + + transactions.append(transaction) + + db.commit() + for tx in transactions: + db.refresh(tx) + + # Resolve stock names + tickers = list({tx.ticker for tx in transactions}) + names = service.get_stock_names(tickers) + + tx_responses = [ + TransactionResponse( + id=tx.id, + ticker=tx.ticker, + name=names.get(tx.ticker), + tx_type=tx.tx_type.value, + quantity=tx.quantity, + price=tx.price, + executed_at=tx.executed_at, + memo=tx.memo, + ) + for tx in transactions + ] + + return RebalanceApplyResponse( + transactions=tx_responses, + holdings_updated=len(transactions), + ) + + @router.get("/{portfolio_id}/detail", response_model=PortfolioDetail) async def get_portfolio_detail( portfolio_id: int, @@ -410,6 +496,23 @@ async def get_portfolio_detail( if total_value > 0: h.current_ratio = (h.value / total_value * 100).quantize(Decimal("0.01")) + # Calculate risk asset ratio for pension portfolios + risk_asset_ratio = None + if portfolio.portfolio_type == PortfolioType.PENSION and total_value > 0: + # Look up ETF asset classes + etf_tickers = [h.ticker for h in holdings_with_value] + etfs = db.query(ETF).filter(ETF.ticker.in_(etf_tickers)).all() if etf_tickers else [] + safe_classes = {"bond", "gold"} + etf_class_map = {e.ticker: e.asset_class.value for e in etfs} + + risk_value = Decimal("0") + for h in holdings_with_value: + asset_class = etf_class_map.get(h.ticker) + if asset_class not in safe_classes: + risk_value += h.value or Decimal("0") + + risk_asset_ratio = (risk_value / total_value * 100).quantize(Decimal("0.01")) + return PortfolioDetail( id=portfolio.id, user_id=portfolio.user_id, @@ -422,4 +525,5 @@ async def get_portfolio_detail( total_value=total_value, total_invested=total_invested, total_profit_loss=total_value - total_invested, + risk_asset_ratio=risk_asset_ratio, ) diff --git a/backend/app/api/signal.py b/backend/app/api/signal.py index 4dd29c4..8ad625d 100644 --- a/backend/app/api/signal.py +++ b/backend/app/api/signal.py @@ -126,8 +126,11 @@ async def execute_signal( if holding.quantity == 0: db.delete(holding) - # 6. Update signal status to executed + # 6. Update signal status to executed with execution details signal.status = SignalStatus.EXECUTED + signal.executed_price = data.price + signal.executed_quantity = data.quantity + signal.executed_at = datetime.utcnow() db.commit() db.refresh(transaction) diff --git a/backend/app/api/snapshot.py b/backend/app/api/snapshot.py index b3ac29f..9a756df 100644 --- a/backend/app/api/snapshot.py +++ b/backend/app/api/snapshot.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.api.deps import CurrentUser from app.models.portfolio import Portfolio, PortfolioSnapshot, SnapshotHolding +from app.models.stock import ETFPrice from app.schemas.portfolio import ( SnapshotListItem, SnapshotResponse, SnapshotHoldingResponse, ReturnsResponse, ReturnDataPoint, @@ -239,6 +240,22 @@ async def get_returns( data=[], ) + # Get benchmark (KOSPI ETF 069500) prices for the same date range + snapshot_dates = [s.snapshot_date for s in snapshots] + benchmark_ticker = "069500" # KODEX 200 (KOSPI benchmark) + benchmark_prices = ( + db.query(ETFPrice) + .filter( + ETFPrice.ticker == benchmark_ticker, + ETFPrice.date.in_(snapshot_dates), + ) + .all() + ) + benchmark_map = {bp.date: Decimal(str(bp.close)) for bp in benchmark_prices} + + # Get first benchmark price for cumulative calculation + first_benchmark = benchmark_map.get(snapshots[0].snapshot_date) + # Calculate returns data_points = [] first_value = Decimal(str(snapshots[0].total_value)) @@ -259,11 +276,18 @@ async def get_returns( else: cumulative_return = Decimal("0") + # Benchmark cumulative return + benchmark_return = None + bench_price = benchmark_map.get(snapshot.snapshot_date) + if bench_price and first_benchmark and first_benchmark > 0: + benchmark_return = ((bench_price - first_benchmark) / first_benchmark * 100).quantize(Decimal("0.01")) + data_points.append(ReturnDataPoint( date=snapshot.snapshot_date, total_value=current_value, daily_return=daily_return, cumulative_return=cumulative_return, + benchmark_return=benchmark_return, )) prev_value = current_value @@ -275,6 +299,7 @@ async def get_returns( total_return = None cagr = None + benchmark_total_return = None if first_value > 0: total_return = ((last_value - first_value) / first_value * 100).quantize(Decimal("0.01")) @@ -289,11 +314,17 @@ async def get_returns( cagr_value = (float(ratio) ** (1 / float(years)) - 1) * 100 cagr = Decimal(str(cagr_value)).quantize(Decimal("0.01")) + # Benchmark total return + last_benchmark = benchmark_map.get(end_date) + if first_benchmark and last_benchmark and first_benchmark > 0: + benchmark_total_return = ((last_benchmark - first_benchmark) / first_benchmark * 100).quantize(Decimal("0.01")) + return ReturnsResponse( portfolio_id=portfolio_id, start_date=start_date, end_date=end_date, total_return=total_return, cagr=cagr, + benchmark_total_return=benchmark_total_return, data=data_points, ) diff --git a/backend/app/models/signal.py b/backend/app/models/signal.py index fb8e4e7..d91873f 100644 --- a/backend/app/models/signal.py +++ b/backend/app/models/signal.py @@ -38,3 +38,7 @@ class Signal(Base): reason = Column(String(200)) status = Column(SQLEnum(SignalStatus), default=SignalStatus.ACTIVE) created_at = Column(DateTime, default=datetime.utcnow) + # Execution tracking fields + executed_price = Column(Numeric(12, 2), nullable=True) + executed_quantity = Column(Integer, nullable=True) + executed_at = Column(DateTime, nullable=True) diff --git a/backend/app/schemas/portfolio.py b/backend/app/schemas/portfolio.py index 05e61e4..0ecd835 100644 --- a/backend/app/schemas/portfolio.py +++ b/backend/app/schemas/portfolio.py @@ -109,6 +109,7 @@ class PortfolioDetail(PortfolioResponse): total_value: FloatDecimal | None = None total_invested: FloatDecimal | None = None total_profit_loss: FloatDecimal | None = None + risk_asset_ratio: FloatDecimal | None = None # Snapshot schemas @@ -153,6 +154,7 @@ class ReturnDataPoint(BaseModel): total_value: FloatDecimal daily_return: FloatDecimal | None = None cumulative_return: FloatDecimal | None = None + benchmark_return: FloatDecimal | None = None class ReturnsResponse(BaseModel): @@ -162,6 +164,7 @@ class ReturnsResponse(BaseModel): end_date: date | None = None total_return: FloatDecimal | None = None cagr: FloatDecimal | None = None + benchmark_total_return: FloatDecimal | None = None data: List[ReturnDataPoint] = [] @@ -227,3 +230,20 @@ class RebalanceCalculateResponse(BaseModel): total_assets: FloatDecimal available_to_buy: FloatDecimal | None = None items: List[RebalanceCalculateItem] + + +# Rebalance apply schemas +class RebalanceApplyItem(BaseModel): + ticker: str + action: str # "buy" or "sell" + quantity: int = Field(..., gt=0) + price: FloatDecimal = Field(..., gt=0) + + +class RebalanceApplyRequest(BaseModel): + items: List[RebalanceApplyItem] + + +class RebalanceApplyResponse(BaseModel): + transactions: List[TransactionResponse] + holdings_updated: int diff --git a/backend/app/schemas/signal.py b/backend/app/schemas/signal.py index e0531a5..2036d44 100644 --- a/backend/app/schemas/signal.py +++ b/backend/app/schemas/signal.py @@ -41,6 +41,9 @@ class SignalResponse(BaseModel): reason: Optional[str] = None status: str created_at: datetime + executed_price: Optional[FloatDecimal] = None + executed_quantity: Optional[int] = None + executed_at: Optional[datetime] = None class Config: from_attributes = True diff --git a/backend/tests/e2e/test_rebalance_flow.py b/backend/tests/e2e/test_rebalance_flow.py index af1c1aa..f795104 100644 --- a/backend/tests/e2e/test_rebalance_flow.py +++ b/backend/tests/e2e/test_rebalance_flow.py @@ -111,3 +111,50 @@ def test_calculate_rebalance_without_prices_fallback(client: TestClient, auth_he headers=auth_headers, ) assert response.status_code == 200 + + +def test_apply_rebalance(client: TestClient, auth_headers): + """리밸런싱 결과를 적용하면 거래가 일괄 생성된다.""" + pid = _setup_portfolio_with_holdings(client, auth_headers) + + response = client.post( + f"/api/portfolios/{pid}/rebalance/apply", + json={ + "items": [ + {"ticker": "069500", "action": "buy", "quantity": 5, "price": 50000}, + {"ticker": "148070", "action": "sell", "quantity": 2, "price": 110000}, + ] + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert len(data["transactions"]) == 2 + assert data["transactions"][0]["tx_type"] == "buy" + assert data["transactions"][1]["tx_type"] == "sell" + assert data["holdings_updated"] == 2 + + # Verify holdings were updated + holdings_resp = client.get( + f"/api/portfolios/{pid}/holdings", + headers=auth_headers, + ) + holdings = {h["ticker"]: h for h in holdings_resp.json()} + assert holdings["069500"]["quantity"] == 15 # 10 + 5 + assert holdings["148070"]["quantity"] == 3 # 5 - 2 + + +def test_apply_rebalance_insufficient_quantity(client: TestClient, auth_headers): + """매도 수량이 보유량을 초과하면 400 에러.""" + pid = _setup_portfolio_with_holdings(client, auth_headers) + + response = client.post( + f"/api/portfolios/{pid}/rebalance/apply", + json={ + "items": [ + {"ticker": "148070", "action": "sell", "quantity": 10, "price": 110000}, + ] + }, + headers=auth_headers, + ) + assert response.status_code == 400 diff --git a/frontend/src/app/portfolio/[id]/history/page.tsx b/frontend/src/app/portfolio/[id]/history/page.tsx index 8b69895..6aeb2a3 100644 --- a/frontend/src/app/portfolio/[id]/history/page.tsx +++ b/frontend/src/app/portfolio/[id]/history/page.tsx @@ -35,6 +35,7 @@ interface ReturnDataPoint { total_value: string; daily_return: string | null; cumulative_return: string | null; + benchmark_return: string | null; } interface ReturnsData { @@ -43,6 +44,7 @@ interface ReturnsData { end_date: string | null; total_return: string | null; cagr: string | null; + benchmark_total_return: string | null; data: ReturnDataPoint[]; } @@ -169,7 +171,7 @@ export default function PortfolioHistoryPage() { {/* Summary Cards */} {returns && returns.total_return !== null && ( -
+
총 수익률
@@ -198,6 +200,22 @@ export default function PortfolioHistoryPage() {
+ + +
벤치마크 (KOSPI)
+
= 0 + ? 'text-green-600' + : 'text-red-600' + }`} + > + {returns.benchmark_total_return !== null + ? formatPercent(returns.benchmark_total_return) + : '-'} +
+
+
시작일
@@ -329,6 +347,9 @@ export default function PortfolioHistoryPage() { 누적 수익률 + + 벤치마크(KOSPI) + @@ -358,6 +379,15 @@ export default function PortfolioHistoryPage() { > {formatPercent(point.cumulative_return)} + = 0 + ? 'text-green-600' + : 'text-red-600' + }`} + > + {formatPercent(point.benchmark_return)} + ))} diff --git a/frontend/src/app/portfolio/[id]/page.tsx b/frontend/src/app/portfolio/[id]/page.tsx index 290a142..b67e1ef 100644 --- a/frontend/src/app/portfolio/[id]/page.tsx +++ b/frontend/src/app/portfolio/[id]/page.tsx @@ -51,6 +51,7 @@ interface PortfolioDetail { total_value: number | null; total_invested: number | null; total_profit_loss: number | null; + risk_asset_ratio: number | null; } const CHART_COLORS = [ @@ -219,6 +220,22 @@ export default function PortfolioDetailPage() {
+ {/* DC Risk Asset Warning */} + {portfolio.portfolio_type === 'pension' && + portfolio.risk_asset_ratio !== null && + portfolio.risk_asset_ratio > 70 && ( +
+ +
+

DC형 퇴직연금 위험자산 비율 초과

+

+ 현재 위험자산 비율: {portfolio.risk_asset_ratio.toFixed(1)}% (법적 한도: 70%). + 채권형/금 ETF 비중을 늘려 위험자산 비율을 조정하세요. +

+
+
+ )} + {/* Summary Cards */}
diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx index 574938f..e9f43ae 100644 --- a/frontend/src/app/portfolio/[id]/rebalance/page.tsx +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -60,6 +60,10 @@ export default function RebalancePage() { const [calculating, setCalculating] = useState(false); const [error, setError] = useState(null); const [nameMap, setNameMap] = useState>({}); + const [showApplyModal, setShowApplyModal] = useState(false); + const [applyPrices, setApplyPrices] = useState>({}); + const [applying, setApplying] = useState(false); + const [applyError, setApplyError] = useState(null); useEffect(() => { const init = async () => { @@ -175,6 +179,43 @@ export default function RebalancePage() { const getHoldingQty = (ticker: string) => holdings.find((h) => h.ticker === ticker)?.quantity ?? 0; + const openApplyModal = () => { + if (!result) return; + const initialPrices: Record = {}; + for (const item of result.items) { + if (item.action !== 'hold' && item.diff_quantity !== 0) { + initialPrices[item.ticker] = String(item.current_price); + } + } + setApplyPrices(initialPrices); + setApplyError(null); + setShowApplyModal(true); + }; + + const applyRebalance = async () => { + if (!result) return; + setApplying(true); + setApplyError(null); + try { + const items = result.items + .filter((item) => item.action !== 'hold' && item.diff_quantity !== 0) + .map((item) => ({ + ticker: item.ticker, + action: item.action, + quantity: Math.abs(item.diff_quantity), + price: parseFloat(applyPrices[item.ticker] || String(item.current_price)), + })); + + await api.post(`/api/portfolios/${portfolioId}/rebalance/apply`, { items }); + setShowApplyModal(false); + router.push(`/portfolio/${portfolioId}`); + } catch (err) { + setApplyError(err instanceof Error ? err.message : '적용 실패'); + } finally { + setApplying(false); + } + }; + if (loading) return null; return ( @@ -386,8 +427,72 @@ export default function RebalancePage() {
+ + {/* 적용 버튼 */} + {result.items.some((item) => item.action !== 'hold' && item.diff_quantity !== 0) && ( +
+ +
+ )} )} + + {/* 적용 확인 모달 */} + {showApplyModal && result && ( +
+
+
+

리밸런싱 적용 확인

+

+ 아래 거래가 일괄 생성됩니다. 체결가를 수정할 수 있습니다. +

+ + {applyError && ( +
+ {applyError} +
+ )} + +
+ {result.items + .filter((item) => item.action !== 'hold' && item.diff_quantity !== 0) + .map((item) => ( +
+
+
{item.name || item.ticker}
+
+ {getActionBadge(item.action)} {Math.abs(item.diff_quantity)}주 +
+
+
+ + + setApplyPrices((prev) => ({ ...prev, [item.ticker]: e.target.value })) + } + /> +
+
+ ))} +
+ +
+ + +
+
+
+
+ )} ); } diff --git a/frontend/src/app/signals/page.tsx b/frontend/src/app/signals/page.tsx index cca61e4..2896434 100644 --- a/frontend/src/app/signals/page.tsx +++ b/frontend/src/app/signals/page.tsx @@ -39,6 +39,9 @@ interface Signal { reason: string | null; status: string; created_at: string; + executed_price: number | null; + executed_quantity: number | null; + executed_at: string | null; } interface Portfolio { @@ -46,6 +49,12 @@ interface Portfolio { name: string; } +interface Holding { + ticker: string; + quantity: number; + avg_price: number; +} + const signalTypeConfig: Record = { buy: { label: '매수', @@ -106,6 +115,7 @@ export default function SignalsPage() { const [executePrice, setExecutePrice] = useState(''); const [executing, setExecuting] = useState(false); const [executeError, setExecuteError] = useState(''); + const [currentHoldings, setCurrentHoldings] = useState([]); useEffect(() => { const init = async () => { @@ -185,10 +195,35 @@ export default function SignalsPage() { setExecuteQuantity(''); setSelectedPortfolioId(''); setExecuteError(''); + setCurrentHoldings([]); await fetchPortfolios(); setExecuteModalOpen(true); }; + const handlePortfolioChange = async (portfolioId: string) => { + setSelectedPortfolioId(portfolioId); + if (portfolioId) { + try { + const holdings = await api.get(`/api/portfolios/${portfolioId}/holdings`); + setCurrentHoldings(holdings); + // 매도/부분매도 신호일 때 보유량 기반 기본값 설정 + if (executeSignal) { + const holding = holdings.find((h) => h.ticker === executeSignal.ticker); + if (holding && (executeSignal.signal_type === 'sell' || executeSignal.signal_type === 'partial_sell')) { + const defaultQty = executeSignal.signal_type === 'sell' + ? holding.quantity + : Math.floor(holding.quantity / 2); + setExecuteQuantity(String(defaultQty)); + } + } + } catch { + setCurrentHoldings([]); + } + } else { + setCurrentHoldings([]); + } + }; + const handleExecute = async () => { if (!executeSignal || !selectedPortfolioId || !executeQuantity || !executePrice) { setExecuteError('모든 필드를 입력해주세요.'); @@ -243,6 +278,8 @@ export default function SignalsPage() { 목표가 손절가 사유 + 체결가 + 체결수량 상태 실행 @@ -277,6 +314,12 @@ export default function SignalsPage() { {signal.reason || '-'} + + {signal.executed_price ? formatPrice(signal.executed_price) : '-'} + + + {signal.executed_quantity ? signal.executed_quantity.toLocaleString() : '-'} + {statConf.label} @@ -297,7 +340,7 @@ export default function SignalsPage() { })} {signals.length === 0 && ( - + 신호가 없습니다. @@ -500,7 +543,7 @@ export default function SignalsPage() { {/* Portfolio selection */}
- @@ -514,6 +557,23 @@ export default function SignalsPage() {
+ {/* Current holdings info */} + {selectedPortfolioId && executeSignal && (() => { + const holding = currentHoldings.find((h) => h.ticker === executeSignal.ticker); + return ( +
+ 현재 보유: + {holding ? ( + + {holding.quantity.toLocaleString()}주 (평균단가: {formatPrice(holding.avg_price)}원) + + ) : ( + 미보유 + )} +
+ ); + })()} + {/* Quantity */}
diff --git a/frontend/src/app/strategy/multi-factor/page.tsx b/frontend/src/app/strategy/multi-factor/page.tsx index e3ec23c..df72f6d 100644 --- a/frontend/src/app/strategy/multi-factor/page.tsx +++ b/frontend/src/app/strategy/multi-factor/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio'; interface StockFactor { ticker: string; @@ -224,6 +225,9 @@ export default function MultiFactorPage() {
+
+ ({ ticker: s.ticker, name: s.name }))} /> +
)} diff --git a/frontend/src/app/strategy/quality/page.tsx b/frontend/src/app/strategy/quality/page.tsx index ed17242..bd62a94 100644 --- a/frontend/src/app/strategy/quality/page.tsx +++ b/frontend/src/app/strategy/quality/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio'; interface StockFactor { ticker: string; @@ -196,6 +197,9 @@ export default function QualityStrategyPage() { +
+ ({ ticker: s.ticker, name: s.name }))} /> +
)} diff --git a/frontend/src/app/strategy/value-momentum/page.tsx b/frontend/src/app/strategy/value-momentum/page.tsx index dfda757..dbe2282 100644 --- a/frontend/src/app/strategy/value-momentum/page.tsx +++ b/frontend/src/app/strategy/value-momentum/page.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +import { ApplyToPortfolio } from '@/components/strategy/apply-to-portfolio'; interface StockFactor { ticker: string; @@ -204,6 +205,9 @@ export default function ValueMomentumPage() { +
+ ({ ticker: s.ticker, name: s.name }))} /> +
)} diff --git a/frontend/src/components/strategy/apply-to-portfolio.tsx b/frontend/src/components/strategy/apply-to-portfolio.tsx new file mode 100644 index 0000000..c66ae7b --- /dev/null +++ b/frontend/src/components/strategy/apply-to-portfolio.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { api } from '@/lib/api'; + +interface Portfolio { + id: number; + name: string; + portfolio_type: string; +} + +interface TargetItem { + ticker: string; + target_ratio: number; +} + +interface ApplyToPortfolioProps { + stocks: { ticker: string; name: string }[]; +} + +export function ApplyToPortfolio({ stocks }: ApplyToPortfolioProps) { + const [portfolios, setPortfolios] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + const load = async () => { + try { + const data = await api.get('/api/portfolios'); + setPortfolios(data); + if (data.length > 0) setSelectedId(data[0].id); + } catch { + // ignore + } + }; + load(); + }, []); + + const apply = async () => { + if (!selectedId || stocks.length === 0) return; + setApplying(true); + setError(null); + try { + const ratio = parseFloat((100 / stocks.length).toFixed(2)); + const targets: TargetItem[] = stocks.map((s, i) => ({ + ticker: s.ticker, + target_ratio: i === stocks.length - 1 + ? parseFloat((100 - ratio * (stocks.length - 1)).toFixed(2)) + : ratio, + })); + + await api.put(`/api/portfolios/${selectedId}/targets`, targets); + setShowConfirm(false); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : '적용 실패'); + } finally { + setApplying(false); + } + }; + + if (portfolios.length === 0) return null; + + return ( +
+
+
+ + +
+ +
+ + {success && ( +
+ 목표 배분이 적용되었습니다. +
+ )} + + {showConfirm && ( +
+
+
+

목표 배분 적용 확인

+

+ 선택한 포트폴리오의 기존 목표 배분을 덮어씁니다. + {stocks.length}개 종목이 동일 비중({(100 / stocks.length).toFixed(2)}%)으로 설정됩니다. +

+ + {error && ( +
+ {error} +
+ )} + +
+ {stocks.map((s) => ( +
+ {s.name || s.ticker} + {(100 / stocks.length).toFixed(2)}% +
+ ))} +
+ +
+ + +
+
+
+
+ )} +
+ ); +}