StockTrade 컴포넌트 훅 분리 (Phase 4): 2,788→1,932줄
8개 커스텀 훅으로 state/handler 로직 추출: - usePortfolio: 포트폴리오 CRUD, 예수금, 브로커 그룹 - useSellHistory: 매도 내역 CRUD, 드로어/폼 상태 - useAiCoach: AI 코치 분석 + 캐시 - useAssetHistory: 자산 추이 차트 데이터 - useMarketContext: VIX/F&G/국채/WTI 시장 데이터 - useAiBalance: AI 모의투자 잔고, 수동 주문 - useReportData: 리포트 정렬, 차트, 집중도 분석 - useAdvisor: 어드바이저 프롬프트 빌더 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
269
src/pages/stock/hooks/usePortfolio.js
Normal file
269
src/pages/stock/hooks/usePortfolio.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
getPortfolio, addPortfolio, updatePortfolio, deletePortfolio,
|
||||
upsertCash, deleteCash,
|
||||
} from '../../../api';
|
||||
import { emptyPortfolioForm } from '../stockUtils';
|
||||
|
||||
export default function usePortfolio() {
|
||||
const [portfolio, setPortfolio] = useState(null);
|
||||
const [portfolioLoading, setPortfolioLoading] = useState(false);
|
||||
const [portfolioError, setPortfolioError] = useState('');
|
||||
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
|
||||
|
||||
/* add form */
|
||||
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
|
||||
const [addFormOpen, setAddFormOpen] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
/* edit */
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editForm, setEditForm] = useState({});
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const editOrigRef = useRef({});
|
||||
|
||||
/* delete / sell confirm */
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
|
||||
const [sellConfirmId, setSellConfirmId] = useState(null);
|
||||
const [sellLoading, setSellLoading] = useState(false);
|
||||
|
||||
/* cash */
|
||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||
const [cashSaving, setCashSaving] = useState(false);
|
||||
const [cashError, setCashError] = useState('');
|
||||
const [cashEditingBroker, setCashEditingBroker] = useState(null);
|
||||
const [cashEditingValue, setCashEditingValue] = useState('');
|
||||
const [cashEditSaving, setCashEditSaving] = useState(false);
|
||||
|
||||
/* derived */
|
||||
const portfolioHoldings = portfolio?.holdings ?? [];
|
||||
const portfolioSummary = portfolio?.summary ?? {};
|
||||
const cashList = portfolio?.cash ?? [];
|
||||
const totalCash = portfolioSummary.total_cash ?? null;
|
||||
const totalAssets = portfolioSummary.total_assets ?? null;
|
||||
|
||||
const brokerGroups = useMemo(() => {
|
||||
const map = {};
|
||||
for (const item of portfolioHoldings) {
|
||||
const broker = item.broker || '기타';
|
||||
if (!map[broker]) map[broker] = [];
|
||||
map[broker].push(item);
|
||||
}
|
||||
return Object.entries(map).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [portfolioHoldings]);
|
||||
|
||||
const brokerColors = useMemo(() => {
|
||||
const palette = [
|
||||
{ border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' },
|
||||
{ border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' },
|
||||
{ border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' },
|
||||
{ border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' },
|
||||
{ border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' },
|
||||
{ border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' },
|
||||
];
|
||||
const map = {};
|
||||
brokerGroups.forEach(([broker], i) => {
|
||||
map[broker] = palette[i % palette.length];
|
||||
});
|
||||
return map;
|
||||
}, [brokerGroups]);
|
||||
|
||||
const getBrokerSummary = (items) => {
|
||||
let totalBuy = 0, totalEvalAmt = 0, hasNullPrice = false;
|
||||
for (const item of items) {
|
||||
totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0);
|
||||
if (item.eval_amount != null) totalEvalAmt += item.eval_amount;
|
||||
else hasNullPrice = true;
|
||||
}
|
||||
const totalProfit = totalEvalAmt - totalBuy;
|
||||
const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||
return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice };
|
||||
};
|
||||
|
||||
/* loaders */
|
||||
const loadPortfolio = useCallback(async () => {
|
||||
setPortfolioLoading(true);
|
||||
setPortfolioError('');
|
||||
try {
|
||||
const data = await getPortfolio();
|
||||
setPortfolio(data);
|
||||
setPortfolioLoaded(true);
|
||||
} catch (err) {
|
||||
setPortfolioError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setPortfolioLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* actions */
|
||||
const handleAddSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setAddLoading(true);
|
||||
setAddError('');
|
||||
try {
|
||||
await addPortfolio({
|
||||
broker: addForm.broker.trim(),
|
||||
ticker: addForm.ticker.trim(),
|
||||
name: addForm.name.trim(),
|
||||
quantity: Number(addForm.quantity),
|
||||
avg_price: Number(addForm.avg_price),
|
||||
});
|
||||
setAddForm({ ...emptyPortfolioForm });
|
||||
setAddFormOpen(false);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setAddError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStart = (item) => {
|
||||
setEditingId(item.id);
|
||||
const data = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name };
|
||||
setEditForm(data);
|
||||
editOrigRef.current = { ...data };
|
||||
};
|
||||
|
||||
const handleEditSave = async (id) => {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
const orig = editOrigRef.current ?? {};
|
||||
const diff = {};
|
||||
for (const key of Object.keys(editForm)) {
|
||||
if (editForm[key] !== orig[key]) diff[key] = editForm[key];
|
||||
}
|
||||
if (Object.keys(diff).length === 0) { setEditingId(null); return; }
|
||||
await updatePortfolio(id, diff);
|
||||
setEditingId(null);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
const msg = err?.message ?? String(err);
|
||||
if (msg.includes('404') || msg.includes('not found')) {
|
||||
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||
await loadPortfolio();
|
||||
} else {
|
||||
alert('수정 실패: ' + msg);
|
||||
}
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deletePortfolio(id);
|
||||
setDeleteConfirmId(null);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
const msg = err?.message ?? String(err);
|
||||
if (msg.includes('404') || msg.includes('not found')) {
|
||||
alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.');
|
||||
setDeleteConfirmId(null);
|
||||
await loadPortfolio();
|
||||
} else {
|
||||
alert('삭제 실패: ' + msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* cash actions */
|
||||
const handleCashSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!cashForm.broker.trim() || cashForm.cash === '') return;
|
||||
setCashSaving(true);
|
||||
setCashError('');
|
||||
try {
|
||||
await upsertCash(cashForm.broker.trim(), Number(cashForm.cash));
|
||||
setCashForm({ broker: '', cash: '' });
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setCashError(err?.message ?? String(err));
|
||||
} finally {
|
||||
setCashSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashDelete = async (broker) => {
|
||||
try {
|
||||
await deleteCash(broker);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('예수금 삭제 실패: ' + (err?.message ?? String(err)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashInlineEdit = (item) => {
|
||||
setCashEditingBroker(item.broker);
|
||||
setCashEditingValue(String(item.cash ?? ''));
|
||||
};
|
||||
|
||||
const handleCashInlineSave = async (broker) => {
|
||||
if (cashEditingValue === '') return;
|
||||
setCashEditSaving(true);
|
||||
try {
|
||||
await upsertCash(broker, Number(cashEditingValue));
|
||||
setCashEditingBroker(null);
|
||||
setCashEditingValue('');
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
alert('예수금 수정 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setCashEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCashInlineCancel = () => {
|
||||
setCashEditingBroker(null);
|
||||
setCashEditingValue('');
|
||||
};
|
||||
|
||||
/* sell (현재가 매도) */
|
||||
const handleSell = async (item, { cashList: cl, loadSellHistoryAfter }) => {
|
||||
const sellPrice = item.current_price ?? item.avg_price;
|
||||
const avgPrice = item.avg_price ?? 0;
|
||||
const qty = item.quantity ?? 0;
|
||||
const saleAmount = sellPrice * qty;
|
||||
const buyAmount = avgPrice * qty;
|
||||
const realizedProfit = saleAmount - buyAmount;
|
||||
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||
const broker = item.broker ?? '';
|
||||
|
||||
setSellLoading(true);
|
||||
try {
|
||||
const existing = cl.find((c) => c.broker === broker);
|
||||
const newCash = (existing?.cash ?? 0) + saleAmount;
|
||||
await upsertCash(broker, newCash);
|
||||
await deletePortfolio(item.id);
|
||||
setSellConfirmId(null);
|
||||
await loadPortfolio();
|
||||
if (loadSellHistoryAfter) {
|
||||
await loadSellHistoryAfter({
|
||||
broker, ticker: item.ticker ?? '', name: item.name ?? item.ticker ?? 'N/A',
|
||||
quantity: qty, avg_price: avgPrice, sell_price: sellPrice,
|
||||
buy_amount: buyAmount, sell_amount: saleAmount,
|
||||
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||
sold_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
alert('매도 처리 실패: ' + (err?.message ?? String(err)));
|
||||
} finally {
|
||||
setSellLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
portfolio, portfolioLoading, portfolioError, portfolioLoaded, loadPortfolio,
|
||||
portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets,
|
||||
addForm, setAddForm, addFormOpen, setAddFormOpen, addLoading, addError, handleAddSubmit,
|
||||
editingId, setEditingId, editForm, setEditForm, editLoading, handleEditStart, handleEditSave,
|
||||
deleteConfirmId, setDeleteConfirmId, handleDelete,
|
||||
sellConfirmId, setSellConfirmId, sellLoading, handleSell,
|
||||
cashForm, setCashForm, cashSaving, cashError, handleCashSave, handleCashDelete,
|
||||
cashEditingBroker, cashEditingValue, setCashEditingValue, cashEditSaving,
|
||||
handleCashInlineEdit, handleCashInlineSave, handleCashInlineCancel,
|
||||
brokerGroups, brokerColors, getBrokerSummary,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user