penti/frontend/src/components/rebalancing/RebalancingDashboard.tsx

320 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import { portfolioAPI, rebalancingAPI } from '../../api/client';
interface PortfolioAsset {
ticker: string;
target_ratio: number;
}
interface CurrentHolding {
ticker: string;
quantity: number;
}
const RebalancingDashboard: React.FC = () => {
const [portfolioName, setPortfolioName] = useState('');
const [assets, setAssets] = useState<PortfolioAsset[]>([
{ ticker: '', target_ratio: 0 },
]);
const [currentHoldings, setCurrentHoldings] = useState<CurrentHolding[]>([]);
const [cash, setCash] = useState(0);
const [portfolioId, setPortfolioId] = useState<string | null>(null);
const [recommendations, setRecommendations] = useState<any>(null);
const [loading, setLoading] = useState(false);
const addAsset = () => {
setAssets([...assets, { ticker: '', target_ratio: 0 }]);
};
const removeAsset = (index: number) => {
setAssets(assets.filter((_, i) => i !== index));
};
const updateAsset = (index: number, field: keyof PortfolioAsset, value: any) => {
const newAssets = [...assets];
newAssets[index] = { ...newAssets[index], [field]: value };
setAssets(newAssets);
};
const createPortfolio = async () => {
try {
setLoading(true);
// 목표 비율 합계 검증
const totalRatio = assets.reduce((sum, asset) => sum + asset.target_ratio, 0);
if (Math.abs(totalRatio - 100) > 0.01) {
alert(`목표 비율의 합은 100%여야 합니다 (현재: ${totalRatio}%)`);
return;
}
const response = await portfolioAPI.create({
name: portfolioName,
description: '퇴직연금 포트폴리오',
assets: assets,
});
setPortfolioId(response.data.id);
alert('포트폴리오가 생성되었습니다!');
// 현재 보유량 초기화
const initialHoldings = assets.map(asset => ({
ticker: asset.ticker,
quantity: 0,
}));
setCurrentHoldings(initialHoldings);
} catch (error: any) {
alert(`포트폴리오 생성 오류: ${error.response?.data?.detail || error.message}`);
} finally {
setLoading(false);
}
};
const updateHolding = (index: number, field: keyof CurrentHolding, value: any) => {
const newHoldings = [...currentHoldings];
newHoldings[index] = { ...newHoldings[index], [field]: value };
setCurrentHoldings(newHoldings);
};
const calculateRebalancing = async () => {
if (!portfolioId) {
alert('먼저 포트폴리오를 생성하세요.');
return;
}
try {
setLoading(true);
const response = await rebalancingAPI.calculate({
portfolio_id: portfolioId,
current_holdings: currentHoldings,
cash: cash,
});
setRecommendations(response.data);
} catch (error: any) {
alert(`리밸런싱 계산 오류: ${error.response?.data?.detail || error.message}`);
} finally {
setLoading(false);
}
};
const totalRatio = assets.reduce((sum, asset) => sum + asset.target_ratio, 0);
return (
<div className="space-y-6">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-2xl font-bold mb-6"> </h2>
{/* 포트폴리오 생성 */}
{!portfolioId ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={portfolioName}
onChange={e => setPortfolioName(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="예: 내 퇴직연금 포트폴리오"
/>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<label className="block text-sm font-medium text-gray-700">
( )
</label>
<span className={`text-sm font-semibold ${
Math.abs(totalRatio - 100) < 0.01 ? 'text-green-600' : 'text-red-600'
}`}>
: {totalRatio}%
</span>
</div>
{assets.map((asset, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={asset.ticker}
onChange={e => updateAsset(index, 'ticker', e.target.value)}
placeholder="종목코드 (예: 005930)"
className="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<input
type="number"
value={asset.target_ratio}
onChange={e => updateAsset(index, 'target_ratio', parseFloat(e.target.value))}
placeholder="비율 (%)"
step="0.1"
className="w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<button
type="button"
onClick={() => removeAsset(index)}
className="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
</button>
</div>
))}
<button
type="button"
onClick={addAsset}
className="mt-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
>
+
</button>
</div>
<button
onClick={createPortfolio}
disabled={loading || totalRatio !== 100}
className={`w-full py-2 px-4 rounded-md text-white ${
loading || totalRatio !== 100
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{loading ? '생성 중...' : '포트폴리오 생성'}
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded p-4">
<p className="text-green-800">
: {portfolioName}
</p>
</div>
{/* 현재 보유량 입력 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
{currentHoldings.map((holding, index) => (
<div key={index} className="flex gap-2 mb-2">
<input
type="text"
value={holding.ticker}
readOnly
className="flex-1 rounded-md border-gray-300 bg-gray-100"
/>
<input
type="number"
value={holding.quantity}
onChange={e => updateHolding(index, 'quantity', parseFloat(e.target.value))}
placeholder="보유 수량"
step="1"
className="w-32 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
))}
</div>
{/* 현금 */}
<div>
<label className="block text-sm font-medium text-gray-700">
()
</label>
<input
type="number"
value={cash}
onChange={e => setCash(parseFloat(e.target.value))}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="0"
step="10000"
/>
</div>
<button
onClick={calculateRebalancing}
disabled={loading}
className={`w-full py-2 px-4 rounded-md text-white ${
loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{loading ? '계산 중...' : '리밸런싱 계산'}
</button>
</div>
)}
</div>
{/* 리밸런싱 결과 */}
{recommendations && (
<div className="bg-white shadow rounded-lg p-6">
<h3 className="text-xl font-bold mb-4"> </h3>
<div className="mb-4">
<p className="text-sm text-gray-600">
: <span className="font-semibold">{recommendations.total_value.toLocaleString()}</span>
</p>
<p className="text-sm text-gray-600">
: <span className="font-semibold">{recommendations.cash.toLocaleString()}</span>
</p>
<p className="text-sm text-gray-600 mt-2">
: <span className="text-green-600 font-semibold">{recommendations.summary.buy}</span>,
: <span className="text-red-600 font-semibold">{recommendations.summary.sell}</span>,
: <span className="font-semibold">{recommendations.summary.hold}</span>
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{recommendations.recommendations.map((rec: any, index: number) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{rec.ticker}
<br />
<span className="text-xs text-gray-500">{rec.name}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right">
{rec.current_ratio.toFixed(2)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right">
{rec.target_ratio.toFixed(2)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right">
{rec.delta_quantity}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
rec.action === 'buy'
? 'bg-green-100 text-green-800'
: rec.action === 'sell'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{rec.action === 'buy' ? '매수' : rec.action === 'sell' ? '매도' : '유지'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
export default RebalancingDashboard;