320 lines
12 KiB
TypeScript
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;
|