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:
File diff suppressed because it is too large
Load Diff
108
src/pages/stock/hooks/useAdvisor.js
Normal file
108
src/pages/stock/hooks/useAdvisor.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAdvisor({ portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [advisorCopied, setAdvisorCopied] = useState(false);
|
||||||
|
|
||||||
|
const buildAdvisorPrompt = useCallback(() => {
|
||||||
|
const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
const holdingsLines = portfolioHoldings.map((h) => {
|
||||||
|
const cp = h.current_price != null ? `${formatNumber(h.current_price)}원` : '시세 미조회';
|
||||||
|
const rate = h.profit_rate != null ? formatPercent(h.profit_rate) : '미조회';
|
||||||
|
const profit = h.profit_amount != null ? `(${h.profit_amount >= 0 ? '+' : ''}${formatNumber(h.profit_amount)}원)` : '';
|
||||||
|
return `- **${h.name ?? h.ticker}** (${h.ticker ?? ''}) | 계좌: ${h.broker ?? '-'}
|
||||||
|
수량 ${h.quantity}주 | 평균매입가 ${formatNumber(h.avg_price)}원 | 현재가 ${cp} | 손익 ${rate} ${profit}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const cashLines = cashList.map((c) => `- ${c.broker}: ${formatNumber(c.cash)}원`).join('\n') || '- 없음';
|
||||||
|
|
||||||
|
const marketLines = marketCtx
|
||||||
|
? [
|
||||||
|
`VIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}`,
|
||||||
|
`공포탐욕지수: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}`,
|
||||||
|
`미 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}`,
|
||||||
|
`WTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`,
|
||||||
|
].join('\n')
|
||||||
|
: '시장 데이터 미로드';
|
||||||
|
|
||||||
|
return `당신은 15년 이상 경력의 한국 주식시장 전문 애널리스트입니다.
|
||||||
|
오늘은 ${today}입니다. 아래 포트폴리오 정보와 시장 환경을 바탕으로 전문가 분석을 제공해주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 현재 시장 환경
|
||||||
|
|
||||||
|
${marketLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💼 보유 포트폴리오
|
||||||
|
|
||||||
|
### 보유 종목 (${portfolioHoldings.length}개)
|
||||||
|
|
||||||
|
${holdingsLines || '보유 종목 없음'}
|
||||||
|
|
||||||
|
### 포트폴리오 요약
|
||||||
|
|
||||||
|
- 총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
- 총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
- 총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
- 예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
- 총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
|
||||||
|
### 예수금 현황
|
||||||
|
|
||||||
|
${cashLines}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 분석 요청
|
||||||
|
|
||||||
|
다음 형식으로 명확하게 작성해주세요:
|
||||||
|
|
||||||
|
### 📈 오늘의 시장 환경
|
||||||
|
시장 환경 데이터를 바탕으로 오늘 한국 주식시장의 전반적인 분위기와 주요 이슈를 2-3문장으로 요약하세요.
|
||||||
|
|
||||||
|
### 🔍 종목별 분석 및 행동 지침
|
||||||
|
각 보유 종목에 대해 아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[종목명 (티커)]**
|
||||||
|
- 현황: 현재 손익 상태와 포지션 평가
|
||||||
|
- 분석: 업황·섹터 동향, 주요 리스크/기회
|
||||||
|
- 🎯 행동 지침: **[매도 / 보유 / 추가매수 / 분할매도]** — 구체적 이유와 목표 참고 가격대
|
||||||
|
|
||||||
|
### 💼 포트폴리오 종합 의견
|
||||||
|
전체 포트폴리오의 섹터 편중, 리밸런싱 필요 여부, 현금 비중 조언을 작성하세요.
|
||||||
|
|
||||||
|
### ⚠️ 오늘 주의해야 할 리스크
|
||||||
|
매크로·섹터·개별 종목 측면에서 오늘 특히 주의할 리스크를 2-3가지 나열하세요.
|
||||||
|
|
||||||
|
### 🚀 추가 매수 유망 섹터 추천
|
||||||
|
현재 시장 환경과 포트폴리오 구성을 고려하여 추가 매수를 검토할 만한 유망 섹터를 추천해주세요.
|
||||||
|
아래 형식으로 작성하세요:
|
||||||
|
|
||||||
|
**[섹터명]**
|
||||||
|
- 추천 이유: 현재 시장 환경에서 이 섹터가 유망한 근거 (매크로 환경, 정책, 업황 사이클 등)
|
||||||
|
- 대표 종목 예시: 국내 대표 종목 2-3개 (현재 포트폴리오와 중복 여부 언급)
|
||||||
|
- 주의사항: 이 섹터 투자 시 고려해야 할 리스크
|
||||||
|
|
||||||
|
(현재 포트폴리오에 없거나 비중이 낮은 섹터를 우선 추천하고, 2-3개 섹터를 제시해주세요.)
|
||||||
|
|
||||||
|
---
|
||||||
|
분석은 반드시 한국어로, 구체적인 수치와 근거를 들어 전문적으로 작성해주세요.
|
||||||
|
투자 결정은 최종적으로 투자자 본인이 판단함을 명시하세요.`;
|
||||||
|
}, [portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx]);
|
||||||
|
|
||||||
|
const handleCopyPrompt = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(buildAdvisorPrompt());
|
||||||
|
setAdvisorCopied(true);
|
||||||
|
setTimeout(() => setAdvisorCopied(false), 2500);
|
||||||
|
} catch {
|
||||||
|
alert('클립보드 복사에 실패했습니다. 텍스트를 직접 선택해 복사하세요.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { advisorCopied, buildAdvisorPrompt, handleCopyPrompt };
|
||||||
|
}
|
||||||
84
src/pages/stock/hooks/useAiBalance.js
Normal file
84
src/pages/stock/hooks/useAiBalance.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { getTradeBalance, createTradeOrder } from '../../../api';
|
||||||
|
import { getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiBalance() {
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
|
const [balanceLoading, setBalanceLoading] = useState(false);
|
||||||
|
const [balanceError, setBalanceError] = useState('');
|
||||||
|
const [balanceLoaded, setBalanceLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [manualForm, setManualForm] = useState({
|
||||||
|
code: '',
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
type: 'buy',
|
||||||
|
});
|
||||||
|
const [manualLoading, setManualLoading] = useState(false);
|
||||||
|
const [manualError, setManualError] = useState('');
|
||||||
|
const [manualResult, setManualResult] = useState(null);
|
||||||
|
const [kisModal, setKisModal] = useState('');
|
||||||
|
|
||||||
|
const loadBalance = useCallback(async () => {
|
||||||
|
setBalanceLoading(true);
|
||||||
|
setBalanceError('');
|
||||||
|
try {
|
||||||
|
const data = await getTradeBalance();
|
||||||
|
setBalance(data);
|
||||||
|
setBalanceLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setBalanceError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setBalanceLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submitManualOrder = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setManualLoading(true);
|
||||||
|
setManualError('');
|
||||||
|
setManualResult(null);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
ticker: manualForm.code.trim(),
|
||||||
|
action: manualForm.type === 'sell' ? 'SELL' : 'BUY',
|
||||||
|
quantity: Number(manualForm.qty),
|
||||||
|
price: Number(manualForm.price),
|
||||||
|
};
|
||||||
|
const result = await createTradeOrder(payload);
|
||||||
|
setManualResult(result ?? { ok: true });
|
||||||
|
if (result?.kis_result !== undefined) {
|
||||||
|
const message =
|
||||||
|
typeof result.kis_result === 'string'
|
||||||
|
? result.kis_result
|
||||||
|
: JSON.stringify(result.kis_result, null, 2);
|
||||||
|
setKisModal(message);
|
||||||
|
}
|
||||||
|
await loadBalance();
|
||||||
|
} catch (err) {
|
||||||
|
setManualError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setManualLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* derived */
|
||||||
|
const holdings = useMemo(() => {
|
||||||
|
if (!balance) return [];
|
||||||
|
if (Array.isArray(balance.holdings)) return balance.holdings;
|
||||||
|
if (Array.isArray(balance.positions)) return balance.positions;
|
||||||
|
if (Array.isArray(balance.items)) return balance.items;
|
||||||
|
return [];
|
||||||
|
}, [balance]);
|
||||||
|
|
||||||
|
const summary = balance?.summary ?? {};
|
||||||
|
const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value;
|
||||||
|
const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash;
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance, balanceLoading, balanceError, balanceLoaded, loadBalance,
|
||||||
|
holdings, summary, totalEval, deposit,
|
||||||
|
manualForm, setManualForm, manualLoading, manualError, manualResult,
|
||||||
|
kisModal, setKisModal, submitManualOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
92
src/pages/stock/hooks/useAiCoach.js
Normal file
92
src/pages/stock/hooks/useAiCoach.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { formatNumber, formatPercent, getVixLabel, getFgLabel } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useAiCoach({ portfolioHoldings, portfolioSummary, totalCash, totalAssets, marketCtx }) {
|
||||||
|
const [aiModel, setAiModel] = useState(() => localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001');
|
||||||
|
const [aiResult, setAiResult] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cached = localStorage.getItem(`ai_coach_${today}`);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAiCoach = async () => {
|
||||||
|
if (portfolioHoldings.length === 0) return;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const cacheKey = `ai_coach_${today}`;
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setAiLoading(true);
|
||||||
|
setAiError('');
|
||||||
|
|
||||||
|
const holdingsText = portfolioHoldings
|
||||||
|
.map((item) =>
|
||||||
|
`- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const marketText = marketCtx
|
||||||
|
? `\n[현재 시장 환경]\nVIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}\nFear & Greed: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}\n미국 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}\nWTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오와 시장 환경을 종합 분석하여 JSON으로만 답하세요.
|
||||||
|
|
||||||
|
분석 일자: ${today}
|
||||||
|
총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원
|
||||||
|
총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원
|
||||||
|
총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)})
|
||||||
|
예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'}
|
||||||
|
총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
|
||||||
|
보유 종목 수: ${portfolioHoldings.length}개
|
||||||
|
보유 종목:
|
||||||
|
${holdingsText}${marketText}
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
|
||||||
|
{
|
||||||
|
"score": 85,
|
||||||
|
"grade": "A",
|
||||||
|
"summary": "30자 이내 한줄 평가",
|
||||||
|
"evaluation": "200자 이내 상세 평가",
|
||||||
|
"advice": [
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
|
||||||
|
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/stock/ai-coach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: aiModel, prompt, max_tokens: 1024 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `AI Coach 오류 (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data.content?.[0]?.text ?? '';
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.');
|
||||||
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
const final = { ...result, generated_at: new Date().toISOString(), cached: false };
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(final));
|
||||||
|
setAiResult(final);
|
||||||
|
} catch (err) {
|
||||||
|
setAiError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { aiModel, setAiModel, aiResult, setAiResult, aiLoading, aiError, handleAiCoach };
|
||||||
|
}
|
||||||
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
66
src/pages/stock/hooks/useAssetHistory.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getAssetHistory, saveAssetSnapshot } from '../../../api';
|
||||||
|
|
||||||
|
export default function useAssetHistory() {
|
||||||
|
const [assetHistory, setAssetHistory] = useState(null);
|
||||||
|
const [assetHistoryLoading, setAssetHistoryLoading] = useState(false);
|
||||||
|
const [assetHistoryDays, setAssetHistoryDays] = useState(30);
|
||||||
|
const [snapshotSaving, setSnapshotSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadAssetHistory = useCallback(async (days) => {
|
||||||
|
setAssetHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAssetHistory(days);
|
||||||
|
const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []);
|
||||||
|
const byDate = {};
|
||||||
|
for (const item of raw) byDate[item.date] = item.total_assets ?? 0;
|
||||||
|
|
||||||
|
const toLocalDate = (d) => {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let filled;
|
||||||
|
if (days > 0) {
|
||||||
|
const today = new Date();
|
||||||
|
filled = Array.from({ length: days }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(today.getDate() - (days - 1 - i));
|
||||||
|
const dateStr = toLocalDate(d);
|
||||||
|
const val = byDate[dateStr];
|
||||||
|
return val > 0 ? { date: dateStr, total_assets: val } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
filled = Object.entries(byDate)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
setAssetHistory(filled);
|
||||||
|
} catch {
|
||||||
|
setAssetHistory([]);
|
||||||
|
} finally {
|
||||||
|
setAssetHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveSnapshot = async (totalAssets, days) => {
|
||||||
|
setSnapshotSaving(true);
|
||||||
|
try {
|
||||||
|
await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined);
|
||||||
|
await loadAssetHistory(days);
|
||||||
|
} catch (err) {
|
||||||
|
alert('스냅샷 저장 실패: ' + (err?.message ?? String(err)));
|
||||||
|
} finally {
|
||||||
|
setSnapshotSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetHistory, assetHistoryLoading,
|
||||||
|
assetHistoryDays, setAssetHistoryDays,
|
||||||
|
snapshotSaving, loadAssetHistory, handleSaveSnapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/pages/stock/hooks/useMarketContext.js
Normal file
23
src/pages/stock/hooks/useMarketContext.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getFearAndGreed, getVix, getTreasury10Y, getWTI } from '../../../api';
|
||||||
|
|
||||||
|
export default function useMarketContext(shouldLoad) {
|
||||||
|
const [marketCtx, setMarketCtx] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLoad || marketCtx !== null) return;
|
||||||
|
Promise.allSettled([getFearAndGreed(), getVix(), getTreasury10Y(), getWTI()])
|
||||||
|
.then(([fg, vix, t, w]) => {
|
||||||
|
const fgRaw = fg.status === 'fulfilled' ? fg.value : null;
|
||||||
|
const fgScore = fgRaw?.fear_and_greed?.score ?? fgRaw?.score;
|
||||||
|
setMarketCtx({
|
||||||
|
fg: fgScore != null ? Math.round(Number(fgScore)) : null,
|
||||||
|
vix: vix.status === 'fulfilled' ? (vix.value?.value ?? null) : null,
|
||||||
|
treasury: t.status === 'fulfilled' ? (t.value?.value ?? null) : null,
|
||||||
|
wti: w.status === 'fulfilled' ? (w.value?.value ?? null) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [shouldLoad, marketCtx]);
|
||||||
|
|
||||||
|
return marketCtx;
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
111
src/pages/stock/hooks/useReportData.js
Normal file
111
src/pages/stock/hooks/useReportData.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { toNumeric } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useReportData({ portfolioHoldings, portfolioSummary, brokerGroups, getBrokerSummary }) {
|
||||||
|
const [reportSortField, setReportSortField] = useState('profit_rate');
|
||||||
|
const [reportSortDir, setReportSortDir] = useState('desc');
|
||||||
|
|
||||||
|
const handleReportSort = (field) => {
|
||||||
|
if (reportSortField === field) {
|
||||||
|
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setReportSortField(field);
|
||||||
|
setReportSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const brokerPieData = useMemo(() =>
|
||||||
|
brokerGroups
|
||||||
|
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
|
||||||
|
.filter((d) => d.value > 0),
|
||||||
|
[brokerGroups, getBrokerSummary]
|
||||||
|
);
|
||||||
|
|
||||||
|
const profitBarData = useMemo(() =>
|
||||||
|
portfolioHoldings
|
||||||
|
.filter((item) => item.profit_rate != null)
|
||||||
|
.map((item) => ({
|
||||||
|
name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5),
|
||||||
|
fullName: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
rate: toNumeric(item.profit_rate) ?? 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rate - a.rate),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxAbsRate = useMemo(() =>
|
||||||
|
Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))),
|
||||||
|
[portfolioHoldings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const brokerConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return brokerGroups
|
||||||
|
.map(([broker, items]) => {
|
||||||
|
const { totalEval: brokerEval } = getBrokerSummary(items);
|
||||||
|
const ratio = Math.round((brokerEval / totalEval) * 1000) / 10;
|
||||||
|
return { broker, eval: brokerEval, ratio };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.ratio - a.ratio);
|
||||||
|
}, [brokerGroups, portfolioSummary.total_eval, getBrokerSummary]);
|
||||||
|
|
||||||
|
const stockConcentration = useMemo(() => {
|
||||||
|
const totalEval = toNumeric(portfolioSummary.total_eval);
|
||||||
|
if (!totalEval || totalEval === 0) return [];
|
||||||
|
return portfolioHoldings
|
||||||
|
.map((item) => {
|
||||||
|
const evalAmt = item.eval_amount != null
|
||||||
|
? toNumeric(item.eval_amount)
|
||||||
|
: (item.current_price != null && item.quantity != null)
|
||||||
|
? toNumeric(item.current_price) * toNumeric(item.quantity)
|
||||||
|
: null;
|
||||||
|
if (!evalAmt) return null;
|
||||||
|
return {
|
||||||
|
name: item.name ?? item.ticker ?? 'N/A',
|
||||||
|
ticker: item.ticker ?? '',
|
||||||
|
eval: evalAmt,
|
||||||
|
ratio: Math.round((evalAmt / totalEval) * 1000) / 10,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.ratio - a.ratio)
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [portfolioHoldings, portfolioSummary.total_eval]);
|
||||||
|
|
||||||
|
const sortedHoldings = useMemo(() => {
|
||||||
|
const getVal = (item) => {
|
||||||
|
switch (reportSortField) {
|
||||||
|
case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity;
|
||||||
|
case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity;
|
||||||
|
case 'eval_amount': {
|
||||||
|
const ea = toNumeric(item.eval_amount);
|
||||||
|
if (ea != null) return ea;
|
||||||
|
const cp = toNumeric(item.current_price);
|
||||||
|
const qty = toNumeric(item.quantity);
|
||||||
|
return cp != null && qty != null ? cp * qty : -Infinity;
|
||||||
|
}
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [...portfolioHoldings].sort((a, b) => {
|
||||||
|
if (reportSortField === 'name')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.name ?? '').localeCompare(b.name ?? '')
|
||||||
|
: (b.name ?? '').localeCompare(a.name ?? '');
|
||||||
|
if (reportSortField === 'broker')
|
||||||
|
return reportSortDir === 'asc'
|
||||||
|
? (a.broker ?? '').localeCompare(b.broker ?? '')
|
||||||
|
: (b.broker ?? '').localeCompare(a.broker ?? '');
|
||||||
|
const av = getVal(a);
|
||||||
|
const bv = getVal(b);
|
||||||
|
return reportSortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
}, [portfolioHoldings, reportSortField, reportSortDir]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportSortField, reportSortDir, handleReportSort,
|
||||||
|
brokerPieData, profitBarData, maxAbsRate,
|
||||||
|
brokerConcentration, stockConcentration, sortedHoldings,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/pages/stock/hooks/useSellHistory.js
Normal file
131
src/pages/stock/hooks/useSellHistory.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getSellHistory, addSellHistory, updateSellHistory, deleteSellHistory } from '../../../api';
|
||||||
|
import { emptySellForm, toLocalDatetimeValue } from '../stockUtils';
|
||||||
|
|
||||||
|
export default function useSellHistory() {
|
||||||
|
const [sellHistory, setSellHistory] = useState([]);
|
||||||
|
const [sellHistoryLoading, setSellHistoryLoading] = useState(false);
|
||||||
|
const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL');
|
||||||
|
const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M');
|
||||||
|
|
||||||
|
const [sellDrawerOpen, setSellDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const [sellFormOpen, setSellFormOpen] = useState(false);
|
||||||
|
const [sellEditId, setSellEditId] = useState(null);
|
||||||
|
const [sellForm, setSellForm] = useState(emptySellForm());
|
||||||
|
const [sellFormSaving, setSellFormSaving] = useState(false);
|
||||||
|
const [sellFormError, setSellFormError] = useState('');
|
||||||
|
|
||||||
|
const loadSellHistory = useCallback(async () => {
|
||||||
|
setSellHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getSellHistory();
|
||||||
|
setSellHistory(data?.records ?? (Array.isArray(data) ? data : []));
|
||||||
|
} catch {
|
||||||
|
/* 백엔드 미구현 시 빈 배열 유지 */
|
||||||
|
} finally {
|
||||||
|
setSellHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 매도 후 실현손익 기록 추가 (usePortfolio.handleSell에서 호출) */
|
||||||
|
const addSellRecord = async (record) => {
|
||||||
|
try {
|
||||||
|
const saved = await addSellHistory(record);
|
||||||
|
setSellHistory((prev) => [saved ?? record, ...prev]);
|
||||||
|
} catch {
|
||||||
|
setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSellRecord = async (id) => {
|
||||||
|
setSellHistory((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
try {
|
||||||
|
await deleteSellHistory(id);
|
||||||
|
} catch {
|
||||||
|
loadSellHistory();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormOpen = () => {
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellForm(emptySellForm());
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellEditStart = (record) => {
|
||||||
|
setSellEditId(record.id);
|
||||||
|
setSellForm({
|
||||||
|
broker: record.broker ?? '',
|
||||||
|
ticker: record.ticker ?? '',
|
||||||
|
name: record.name ?? '',
|
||||||
|
quantity: String(record.quantity ?? ''),
|
||||||
|
avg_price: String(record.avg_price ?? ''),
|
||||||
|
sell_price: String(record.sell_price ?? ''),
|
||||||
|
commission: String(record.commission ?? ''),
|
||||||
|
sold_at: toLocalDatetimeValue(record.sold_at),
|
||||||
|
});
|
||||||
|
setSellFormError('');
|
||||||
|
setSellFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormClose = () => {
|
||||||
|
setSellFormOpen(false);
|
||||||
|
setSellEditId(null);
|
||||||
|
setSellFormError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSellFormSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSellFormSaving(true);
|
||||||
|
setSellFormError('');
|
||||||
|
|
||||||
|
const qty = Number(sellForm.quantity);
|
||||||
|
const avgPrice = Number(sellForm.avg_price);
|
||||||
|
const sellPrice = Number(sellForm.sell_price);
|
||||||
|
const commission = Number(sellForm.commission) || 0;
|
||||||
|
const buyAmount = avgPrice * qty;
|
||||||
|
const sellAmount = sellPrice * qty;
|
||||||
|
const realizedProfit = sellAmount - buyAmount - commission;
|
||||||
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
broker: sellForm.broker.trim(),
|
||||||
|
ticker: sellForm.ticker.trim(),
|
||||||
|
name: sellForm.name.trim(),
|
||||||
|
quantity: qty, avg_price: avgPrice, sell_price: sellPrice, commission,
|
||||||
|
buy_amount: buyAmount, sell_amount: sellAmount,
|
||||||
|
realized_profit: realizedProfit, realized_rate: realizedRate,
|
||||||
|
sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sellEditId != null) {
|
||||||
|
const updated = await updateSellHistory(sellEditId, payload);
|
||||||
|
setSellHistory((prev) =>
|
||||||
|
prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const saved = await addSellHistory(payload);
|
||||||
|
setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]);
|
||||||
|
}
|
||||||
|
handleSellFormClose();
|
||||||
|
} catch (err) {
|
||||||
|
setSellFormError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
setSellFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sellHistory, sellHistoryLoading, loadSellHistory, addSellRecord,
|
||||||
|
sellHistoryBroker, setSellHistoryBroker,
|
||||||
|
sellHistoryPeriod, setSellHistoryPeriod,
|
||||||
|
sellDrawerOpen, setSellDrawerOpen,
|
||||||
|
sellFormOpen, sellEditId, sellForm, setSellForm,
|
||||||
|
sellFormSaving, sellFormError,
|
||||||
|
handleDeleteSellRecord,
|
||||||
|
handleSellFormOpen, handleSellEditStart, handleSellFormClose, handleSellFormSubmit,
|
||||||
|
};
|
||||||
|
}
|
||||||
125
src/pages/stock/stockUtils.js
Normal file
125
src/pages/stock/stockUtils.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const formatNumber = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(numeric);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPercent = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return '-';
|
||||||
|
if (typeof value === 'string' && value.includes('%')) return value;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return value;
|
||||||
|
return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickFirst = (...values) =>
|
||||||
|
values.find((value) => value !== undefined && value !== null && value !== '');
|
||||||
|
|
||||||
|
export const getQty = (item) =>
|
||||||
|
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
|
||||||
|
|
||||||
|
export const getBuyPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.buy_price,
|
||||||
|
item?.avg_price,
|
||||||
|
item?.avg,
|
||||||
|
item?.purchase_price,
|
||||||
|
item?.buyPrice,
|
||||||
|
item?.price
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getCurrentPrice = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.current_price,
|
||||||
|
item?.current,
|
||||||
|
item?.cur_price,
|
||||||
|
item?.now_price,
|
||||||
|
item?.market_price
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProfitRate = (item) =>
|
||||||
|
pickFirst(
|
||||||
|
item?.profit_rate,
|
||||||
|
item?.profitRate,
|
||||||
|
item?.profit_pct,
|
||||||
|
item?.profitPercent,
|
||||||
|
item?.pnl_rate,
|
||||||
|
item?.return_rate,
|
||||||
|
item?.yield
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getProfitLoss = (item) =>
|
||||||
|
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
|
||||||
|
|
||||||
|
export const toNumeric = (value) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const numeric = Number(String(value).replace(/[^0-9.-]/g, ''));
|
||||||
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||||
|
|
||||||
|
export const profitColorClass = (numericValue) => {
|
||||||
|
if (numericValue > 0) return 'is-up';
|
||||||
|
if (numericValue < 0) return 'is-down';
|
||||||
|
if (numericValue === 0) return 'is-flat';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getVixLabel = (vix) => {
|
||||||
|
if (vix < 12) return '극히 낮음 (안일 주의)';
|
||||||
|
if (vix < 20) return '정상 (안정적)';
|
||||||
|
if (vix < 30) return '주의 (불확실성 증가)';
|
||||||
|
if (vix < 40) return '높음 (극도의 공포)';
|
||||||
|
return '극단 (패닉)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFgLabel = (score) => {
|
||||||
|
if (score <= 25) return '극단적 공포';
|
||||||
|
if (score <= 45) return '공포';
|
||||||
|
if (score <= 55) return '중립';
|
||||||
|
if (score <= 75) return '탐욕';
|
||||||
|
return '극단적 탐욕';
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── empty portfolio form ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const emptyPortfolioForm = {
|
||||||
|
broker: '',
|
||||||
|
ticker: '',
|
||||||
|
name: '',
|
||||||
|
quantity: '',
|
||||||
|
avg_price: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── empty sell-history form ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const toLocalDatetimeValue = (isoStr) => {
|
||||||
|
if (!isoStr) return '';
|
||||||
|
const d = new Date(isoStr);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptySellForm = () => ({
|
||||||
|
broker: '',
|
||||||
|
ticker: '',
|
||||||
|
name: '',
|
||||||
|
quantity: '',
|
||||||
|
avg_price: '',
|
||||||
|
sell_price: '',
|
||||||
|
commission: '',
|
||||||
|
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── TAB IDs ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const TAB_PORTFOLIO = 'portfolio';
|
||||||
|
export const TAB_AI = 'ai';
|
||||||
|
export const TAB_REPORT = 'report';
|
||||||
|
export const TAB_ADVISOR = 'advisor';
|
||||||
Reference in New Issue
Block a user