feat: rebalance page with manual price input and strategy selection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bffca88ce9
commit
9fa97e595d
@ -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