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'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { DashboardLayout } from '@/components/layout/dashboard-layout'; import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Label } from '@/components/ui/label';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
interface Target {
ticker: string;
target_ratio: number;
}
interface Holding {
ticker: string;
quantity: number;
avg_price: number;
}
interface RebalanceItem { interface RebalanceItem {
ticker: string; ticker: string;
name: string | null; name: string | null;
@ -16,23 +27,23 @@ interface RebalanceItem {
current_ratio: number; current_ratio: number;
current_quantity: number; current_quantity: number;
current_value: number; current_value: number;
current_price: number;
target_value: number; target_value: number;
diff_value: number; diff_ratio: number;
diff_quantity: number; diff_quantity: number;
action: string; action: string;
change_vs_prev_month: number | null;
change_vs_start: number | null;
} }
interface RebalanceResponse { interface RebalanceResponse {
portfolio_id: number; portfolio_id: number;
total_value: number; total_assets: number;
available_to_buy: number | null;
items: RebalanceItem[]; items: RebalanceItem[];
} }
interface SimulationResponse extends RebalanceResponse { type Strategy = 'full_rebalance' | 'additional_buy';
current_total: number;
additional_amount: number;
new_total: number;
}
export default function RebalancePage() { export default function RebalancePage() {
const router = useRouter(); const router = useRouter();
@ -40,27 +51,36 @@ export default function RebalancePage() {
const portfolioId = params.id as string; const portfolioId = params.id as string;
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [rebalance, setRebalance] = useState<RebalanceResponse | SimulationResponse | null>(null); const [targets, setTargets] = useState<Target[]>([]);
const [error, setError] = useState<string | null>(null); const [holdings, setHoldings] = useState<Holding[]>([]);
const [prices, setPrices] = useState<Record<string, string>>({});
const [strategy, setStrategy] = useState<Strategy>('full_rebalance');
const [additionalAmount, setAdditionalAmount] = useState(''); const [additionalAmount, setAdditionalAmount] = useState('');
const [simulating, setSimulating] = useState(false); const [result, setResult] = useState<RebalanceResponse | null>(null);
const [calculating, setCalculating] = useState(false);
const fetchRebalance = useCallback(async () => { const [error, setError] = useState<string | null>(null);
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]);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
try { try {
await api.getCurrentUser(); 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 { } catch {
router.push('/login'); router.push('/login');
} finally { } finally {
@ -68,32 +88,50 @@ export default function RebalancePage() {
} }
}; };
init(); init();
}, [router, fetchRebalance]); }, [portfolioId, router]);
const simulate = async () => { const allPricesFilled = Object.values(prices).every((p) => p !== '' && parseFloat(p) > 0);
if (!additionalAmount) return;
setSimulating(true); const calculate = async () => {
if (!allPricesFilled) return;
setCalculating(true);
setError(null);
try { try {
setError(null); const priceMap: Record<string, number> = {};
const data = await api.post<SimulationResponse>( for (const [ticker, price] of Object.entries(prices)) {
`/api/portfolios/${portfolioId}/rebalance/simulate`, priceMap[ticker] = parseFloat(price);
{ additional_amount: parseFloat(additionalAmount) } }
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) { } catch (err) {
const message = err instanceof Error ? err.message : 'Simulation failed'; setError(err instanceof Error ? err.message : 'Calculation failed');
setError(message);
} finally { } finally {
setSimulating(false); setCalculating(false);
} }
}; };
const formatCurrency = (value: number) => { const formatCurrency = (value: number) =>
return new Intl.NumberFormat('ko-KR', { new Intl.NumberFormat('ko-KR', {
style: 'currency', style: 'currency',
currency: 'KRW', currency: 'KRW',
maximumFractionDigits: 0, maximumFractionDigits: 0,
}).format(value); }).format(value);
const formatPct = (value: number | null) => {
if (value === null || value === undefined) return '-';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}; };
const getActionBadge = (action: string) => { 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', sell: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hold: 'bg-muted text-muted-foreground', hold: 'bg-muted text-muted-foreground',
}; };
const labels: Record<string, string> = { const labels: Record<string, string> = { buy: '매수', sell: '매도', hold: '유지' };
buy: '매수',
sell: '매도',
hold: '유지',
};
return ( return (
<span className={`px-2 py-1 rounded text-xs ${styles[action] || styles.hold}`}> <span className={`px-2 py-1 rounded text-xs ${styles[action] || styles.hold}`}>
{labels[action] || action} {labels[action] || action}
@ -114,13 +148,19 @@ export default function RebalancePage() {
); );
}; };
if (loading) { const getHoldingQty = (ticker: string) =>
return null; holdings.find((h) => h.ticker === ticker)?.quantity ?? 0;
}
if (loading) return null;
return ( return (
<DashboardLayout> <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 && ( {error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4"> <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> </div>
)} )}
{/* Simulation Input */} {/* Price Input */}
<Card className="mb-6"> <Card className="mb-6">
<CardContent className="pt-6"> <CardHeader>
<div className="flex gap-4 items-end"> <CardTitle> </CardTitle>
<div className="flex-1"> </CardHeader>
<Label htmlFor="additional-amount"> ()</Label> <CardContent>
<Input <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
id="additional-amount" {Object.keys(prices).map((ticker) => {
type="number" const target = targets.find((t) => t.ticker === ticker);
value={additionalAmount} return (
onChange={(e) => setAdditionalAmount(e.target.value)} <div key={ticker}>
placeholder="예: 1000000" <Label htmlFor={`price-${ticker}`}>
className="mt-2" {ticker} {target ? `(목표 ${target.target_ratio}%)` : ''} - {getHoldingQty(ticker)}
/> </Label>
</div> <Input
<Button id={`price-${ticker}`}
onClick={simulate} type="number"
disabled={!additionalAmount || simulating} value={prices[ticker]}
> onChange={(e) => setPrices((prev) => ({ ...prev, [ticker]: e.target.value }))}
{simulating ? '계산 중...' : '시뮬레이션'} placeholder="현재 가격"
</Button> className="mt-1"
<Button variant="outline" onClick={fetchRebalance}> />
</div>
</Button> );
})}
</div> </div>
</CardContent> </CardContent>
</Card> </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"> <Card className="mb-6">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<p className="text-sm text-muted-foreground"> </p> <p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold"> <p className="text-xl font-bold">{formatCurrency(result.total_assets)}</p>
{formatCurrency('current_total' in rebalance ? rebalance.current_total : rebalance.total_value)}
</p>
</div> </div>
{'additional_amount' in rebalance && ( {result.available_to_buy !== null && (
<> <div>
<div> <p className="text-sm text-muted-foreground"> </p>
<p className="text-sm text-muted-foreground"> </p> <p className="text-xl font-bold text-blue-600">
<p className="text-xl font-bold text-blue-600"> {formatCurrency(result.available_to_buy)}
+{formatCurrency(rebalance.additional_amount)} </p>
</p> </div>
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xl font-bold">
{formatCurrency(rebalance.new_total)}
</p>
</div>
</>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Rebalance Table */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
@ -198,36 +280,82 @@ export default function RebalancePage() {
<table className="w-full"> <table className="w-full">
<thead className="bg-muted"> <thead className="bg-muted">
<tr> <tr>
<th scope="col" className="px-4 py-3 text-left 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-4 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-4 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-4 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-4 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-4 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-4 py-3 text-center 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> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{rebalance.items.map((item) => ( {result.items.map((item) => (
<tr key={item.ticker}> <tr key={item.ticker}>
<td className="px-4 py-3"> <td className="px-3 py-3">
<div className="font-medium">{item.ticker}</div> <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>
<td className="px-4 py-3 text-sm text-right">{item.target_ratio.toFixed(2)}%</td> <td className="px-3 py-3 text-sm text-right">
<td className="px-4 py-3 text-sm text-right">{item.current_ratio.toFixed(2)}%</td> {item.current_quantity.toLocaleString()}
<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> </td>
<td className={`px-4 py-3 text-sm text-right font-medium ${ <td className="px-3 py-3 text-sm text-right">
item.diff_quantity > 0 ? 'text-green-600' : item.diff_quantity < 0 ? 'text-red-600' : '' {formatCurrency(item.current_price)}
}`}>
{item.diff_quantity > 0 ? '+' : ''}{item.diff_quantity}
</td> </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> </tr>
))} ))}
</tbody> </tbody>