diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx new file mode 100644 index 0000000..1e50b72 --- /dev/null +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { api } from '@/lib/api'; + +interface RebalanceItem { + ticker: string; + name: string | null; + target_ratio: number; + current_ratio: number; + current_quantity: number; + current_value: number; + target_value: number; + diff_value: number; + diff_quantity: number; + action: string; +} + +interface RebalanceResponse { + portfolio_id: number; + total_value: number; + items: RebalanceItem[]; +} + +interface SimulationResponse extends RebalanceResponse { + current_total: number; + additional_amount: number; + new_total: number; +} + +export default function RebalancePage() { + const router = useRouter(); + const params = useParams(); + const portfolioId = params.id as string; + + const [loading, setLoading] = useState(true); + const [rebalance, setRebalance] = useState(null); + const [error, setError] = useState(null); + const [additionalAmount, setAdditionalAmount] = useState(''); + const [simulating, setSimulating] = useState(false); + + useEffect(() => { + const init = async () => { + try { + await api.getCurrentUser(); + await fetchRebalance(); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + init(); + }, [router, portfolioId]); + + const fetchRebalance = async () => { + try { + setError(null); + const data = await api.get(`/api/portfolios/${portfolioId}/rebalance`); + setRebalance(data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to calculate rebalance'; + setError(message); + } + }; + + const simulate = async () => { + if (!additionalAmount) return; + setSimulating(true); + try { + setError(null); + const data = await api.post( + `/api/portfolios/${portfolioId}/rebalance/simulate`, + { additional_amount: parseFloat(additionalAmount) } + ); + setRebalance(data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Simulation failed'; + setError(message); + } finally { + setSimulating(false); + } + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW', + maximumFractionDigits: 0, + }).format(value); + }; + + const getActionBadge = (action: string) => { + const styles: Record = { + buy: 'bg-green-100 text-green-800', + sell: 'bg-red-100 text-red-800', + hold: 'bg-gray-100 text-gray-800', + }; + const labels: Record = { + buy: '매수', + sell: '매도', + hold: '유지', + }; + return ( + + {labels[action] || action} + + ); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

리밸런싱 계산

+ + {error && ( +
+ {error} +
+ )} + + {/* Simulation Input */} +
+
+
+ + setAdditionalAmount(e.target.value)} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="예: 1000000" + /> +
+ + +
+
+ + {rebalance && ( + <> + {/* Summary */} +
+
+
+

현재 총액

+

+ {formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)} +

+
+ {'additional_amount' in rebalance && ( + <> +
+

추가 입금

+

+ +{formatCurrency(rebalance.additional_amount)} +

+
+
+

새 총액

+

+ {formatCurrency(rebalance.new_total)} +

+
+ + )} +
+
+ + {/* Rebalance Table */} +
+
+

리밸런싱 내역

+
+
+ + + + + + + + + + + + + + {rebalance.items.map((item) => ( + + + + + + + + + + ))} + +
종목목표 비중현재 비중현재 수량조정 금액조정 수량액션
+
{item.ticker}
+ {item.name &&
{item.name}
} +
{item.target_ratio.toFixed(2)}%{item.current_ratio.toFixed(2)}%{item.current_quantity.toLocaleString()} 0 ? 'text-green-600' : item.diff_value < 0 ? 'text-red-600' : '' + }`}> + {item.diff_value > 0 ? '+' : ''}{formatCurrency(item.diff_value)} + 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' + }`}> + {item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity} + {getActionBadge(item.action)}
+
+
+ + )} +
+
+
+ ); +}