From 9fa97e595d153066f71ee43cc8846e28bf2a6ca1 Mon Sep 17 00:00:00 2001 From: zephyrdark Date: Wed, 11 Feb 2026 23:32:49 +0900 Subject: [PATCH] feat: rebalance page with manual price input and strategy selection Co-Authored-By: Claude Opus 4.6 --- .../src/app/portfolio/[id]/rebalance/page.tsx | 354 ++++++++++++------ 1 file changed, 241 insertions(+), 113 deletions(-) diff --git a/frontend/src/app/portfolio/[id]/rebalance/page.tsx b/frontend/src/app/portfolio/[id]/rebalance/page.tsx index 0039280..8bd1123 100644 --- a/frontend/src/app/portfolio/[id]/rebalance/page.tsx +++ b/frontend/src/app/portfolio/[id]/rebalance/page.tsx @@ -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(null); - const [error, setError] = useState(null); + const [targets, setTargets] = useState([]); + const [holdings, setHoldings] = useState([]); + const [prices, setPrices] = useState>({}); + const [strategy, setStrategy] = useState('full_rebalance'); const [additionalAmount, setAdditionalAmount] = useState(''); - const [simulating, setSimulating] = useState(false); - - const fetchRebalance = useCallback(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); - } - }, [portfolioId]); + const [result, setResult] = useState(null); + const [calculating, setCalculating] = useState(false); + const [error, setError] = useState(null); useEffect(() => { const init = async () => { try { await api.getCurrentUser(); - await fetchRebalance(); + const [targetsData, holdingsData] = await Promise.all([ + api.get(`/api/portfolios/${portfolioId}/targets`), + api.get(`/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 = {}; + 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( - `/api/portfolios/${portfolioId}/rebalance/simulate`, - { additional_amount: parseFloat(additionalAmount) } + const priceMap: Record = {}; + for (const [ticker, price] of Object.entries(prices)) { + priceMap[ticker] = parseFloat(price); + } + + const body: Record = { + strategy, + prices: priceMap, + }; + if (strategy === 'additional_buy' && additionalAmount) { + body.additional_amount = parseFloat(additionalAmount); + } + + const data = await api.post( + `/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 = { - buy: '매수', - sell: '매도', - hold: '유지', - }; + const labels: Record = { buy: '매수', sell: '매도', hold: '유지' }; return ( {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 ( -

리밸런싱 계산

+
+

리밸런싱 계산

+ +
{error && (
@@ -128,67 +168,109 @@ export default function RebalancePage() {
)} - {/* Simulation Input */} + {/* Price Input */} - -
-
- - setAdditionalAmount(e.target.value)} - placeholder="예: 1000000" - className="mt-2" - /> -
- - + + 현재 가격 입력 + + +
+ {Object.keys(prices).map((ticker) => { + const target = targets.find((t) => t.ticker === ticker); + return ( +
+ + setPrices((prev) => ({ ...prev, [ticker]: e.target.value }))} + placeholder="현재 가격" + className="mt-1" + /> +
+ ); + })}
- {rebalance && ( + {/* Strategy Selection */} + + +
+
+ +
+ + +
+
+ + {strategy === 'additional_buy' && ( +
+ + setAdditionalAmount(e.target.value)} + placeholder="예: 1000000" + className="mt-1" + /> +
+ )} + +
+ +
+
+
+
+ + {/* Results */} + {result && ( <> - {/* Summary */}
-

현재 총액

-

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

+

총 자산

+

{formatCurrency(result.total_assets)}

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

추가 입금

-

- +{formatCurrency(rebalance.additional_amount)} -

-
-
-

새 총액

-

- {formatCurrency(rebalance.new_total)} -

-
- + {result.available_to_buy !== null && ( +
+

매수 가능

+

+ {formatCurrency(result.available_to_buy)} +

+
)}
- {/* Rebalance Table */} 리밸런싱 내역 @@ -198,36 +280,82 @@ export default function RebalancePage() { - - - - - - - + + + + + + + + + + + - {rebalance.items.map((item) => ( + {result.items.map((item) => ( - - - - - - - + + + + + + + + ))}
종목목표 비중현재 비중현재 수량조정 금액조정 수량액션종목보유량현재가평가금액현재 비중목표 비중비중 차이조정 수량전월비시작일비액션
+
{item.ticker}
- {item.name &&
{item.name}
} + {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)} + + {item.current_quantity.toLocaleString()} 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' - }`}> - {item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity} + + {formatCurrency(item.current_price)} {getActionBadge(item.action)} + {formatCurrency(item.current_value)} + + {item.current_ratio.toFixed(2)}% + + {item.target_ratio.toFixed(2)}% + 0 + ? 'text-green-600' + : item.diff_ratio < 0 + ? 'text-red-600' + : '' + }`} + > + {item.diff_ratio > 0 ? '+' : ''} + {item.diff_ratio.toFixed(2)}% + 0 + ? 'text-green-600' + : item.diff_quantity < 0 + ? 'text-red-600' + : '' + }`} + > + {item.diff_quantity > 0 ? '+' : ''} + {item.diff_quantity} + 0 ? 'text-green-600' : '' + }`} + > + {formatPct(item.change_vs_prev_month)} + 0 ? 'text-green-600' : '' + }`} + > + {formatPct(item.change_vs_start)} + {getActionBadge(item.action)}