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:
zephyrdark 2026-02-11 23:32:49 +09:00
parent bffca88ce9
commit 9fa97e595d

View File

@ -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>