stock AI 어드바이저 추가 및 UX 개선
- Gemini Pro 기반 AI 어드바이저 탭 추가 (TAB_ADVISOR) - 보유 종목 현재가 + 뉴스 → 종목별 매도/매수/분할매도 지침 - 5분 캐시, 강제 새로고침 버튼 - 경량 마크다운 렌더러 (AdvisorMarkdown) - 실현손익 수수료 → 수수료 & 세금으로 레이블 변경 - 총 자산 추이 그래프: 0 데이터 제외 (장 미개장일 필터) - Todo 완료 패널 하단 이동 + 날짜 필터 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
addSellHistory,
|
||||
updateSellHistory,
|
||||
deleteSellHistory,
|
||||
getAiAnalysis,
|
||||
} from '../../api';
|
||||
import Loading from '../../components/Loading';
|
||||
import './Stock.css';
|
||||
@@ -91,6 +92,38 @@ const toNumeric = (value) => {
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
};
|
||||
|
||||
/* ── AdvisorMarkdown — 경량 마크다운 렌더러 ──────────────────────── */
|
||||
|
||||
const AdvisorMarkdown = ({ text }) => {
|
||||
if (!text) return null;
|
||||
const lines = text.split('\n');
|
||||
const elements = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith('### ')) {
|
||||
elements.push(<h4 key={i} className="adv-md-h3">{line.slice(4)}</h4>);
|
||||
} else if (line.startsWith('## ')) {
|
||||
elements.push(<h3 key={i} className="adv-md-h2">{line.slice(3)}</h3>);
|
||||
} else if (line.startsWith('---')) {
|
||||
elements.push(<hr key={i} className="adv-md-hr" />);
|
||||
} else if (line.trim() === '') {
|
||||
elements.push(<div key={i} className="adv-md-gap" />);
|
||||
} else {
|
||||
// 인라인 마크다운: **bold**, *italic*, 🎯 등 이모지 보존
|
||||
const rendered = line
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||
elements.push(
|
||||
<p key={i} className="adv-md-p" dangerouslySetInnerHTML={{ __html: rendered }} />
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return <div className="adv-md">{elements}</div>;
|
||||
};
|
||||
|
||||
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||
|
||||
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||
@@ -144,6 +177,7 @@ const emptySellForm = () => ({
|
||||
quantity: '',
|
||||
avg_price: '',
|
||||
sell_price: '',
|
||||
commission: '',
|
||||
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
||||
});
|
||||
|
||||
@@ -152,6 +186,7 @@ const emptySellForm = () => ({
|
||||
const TAB_PORTFOLIO = 'portfolio';
|
||||
const TAB_AI = 'ai';
|
||||
const TAB_REPORT = 'report';
|
||||
const TAB_ADVISOR = 'advisor';
|
||||
|
||||
/* ── component ───────────────────────────────────────────────────── */
|
||||
|
||||
@@ -202,6 +237,11 @@ const StockTrade = () => {
|
||||
const [sellFormSaving, setSellFormSaving] = useState(false);
|
||||
const [sellFormError, setSellFormError] = useState('');
|
||||
|
||||
/* AI 전문가 분석 (Gemini Pro Advisor) */
|
||||
const [advisorData, setAdvisorData] = useState(null); // { analysis, generated_at, cached, holdings_count }
|
||||
const [advisorLoading, setAdvisorLoading] = useState(false);
|
||||
const [advisorError, setAdvisorError] = useState('');
|
||||
|
||||
/* Cash (예수금) form */
|
||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||
const [cashSaving, setCashSaving] = useState(false);
|
||||
@@ -282,6 +322,20 @@ const StockTrade = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAdvisorAnalysis = useCallback(async (force = false) => {
|
||||
setAdvisorLoading(true);
|
||||
setAdvisorError('');
|
||||
try {
|
||||
const data = await getAiAnalysis(force);
|
||||
if (data?.error) throw new Error(data.error);
|
||||
setAdvisorData(data);
|
||||
} catch (err) {
|
||||
setAdvisorError(err?.message ?? '분석 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setAdvisorLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBalance = useCallback(async () => {
|
||||
setBalanceLoading(true);
|
||||
setBalanceError('');
|
||||
@@ -322,10 +376,12 @@ const StockTrade = () => {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - (days - 1 - i));
|
||||
const dateStr = toLocalDate(d);
|
||||
return { date: dateStr, total_assets: byDate[dateStr] ?? 0 };
|
||||
});
|
||||
const val = byDate[dateStr];
|
||||
return val > 0 ? { date: dateStr, total_assets: val } : null;
|
||||
}).filter(Boolean);
|
||||
} else {
|
||||
filled = Object.entries(byDate)
|
||||
.filter(([, total_assets]) => total_assets > 0)
|
||||
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
@@ -358,8 +414,10 @@ const StockTrade = () => {
|
||||
loadBalance();
|
||||
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
||||
loadPortfolio();
|
||||
} else if (activeTab === TAB_ADVISOR && !advisorData) {
|
||||
loadAdvisorAnalysis();
|
||||
}
|
||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
|
||||
}, [activeTab, portfolioLoaded, balanceLoaded, advisorData, loadPortfolio, loadBalance, loadSellHistory, loadAdvisorAnalysis]);
|
||||
|
||||
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
|
||||
useEffect(() => {
|
||||
@@ -622,6 +680,7 @@ const StockTrade = () => {
|
||||
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('');
|
||||
@@ -644,9 +703,10 @@ const StockTrade = () => {
|
||||
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;
|
||||
const realizedProfit = sellAmount - buyAmount - commission;
|
||||
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||
|
||||
const payload = {
|
||||
@@ -656,6 +716,7 @@ const StockTrade = () => {
|
||||
quantity: qty,
|
||||
avg_price: avgPrice,
|
||||
sell_price: sellPrice,
|
||||
commission,
|
||||
buy_amount: buyAmount,
|
||||
sell_amount: sellAmount,
|
||||
realized_profit: realizedProfit,
|
||||
@@ -994,8 +1055,9 @@ ${holdingsText}${marketText}
|
||||
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
|
||||
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
|
||||
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
|
||||
const totalCommission = filteredSellHistory.reduce((s, r) => s + (r.commission ?? 0), 0);
|
||||
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||
return { totalProfit, totalSell, totalBuy, rate, count: filteredSellHistory.length };
|
||||
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
|
||||
}, [filteredSellHistory]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────── */
|
||||
@@ -1123,6 +1185,15 @@ ${holdingsText}${marketText}
|
||||
<span className="stock-main-tab__label">리포트</span>
|
||||
<span className="stock-main-tab__sub">분석·AI코치</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`stock-main-tab stock-main-tab--advisor ${activeTab === TAB_ADVISOR ? 'is-active' : ''}`}
|
||||
onClick={() => setActiveTab(TAB_ADVISOR)}
|
||||
>
|
||||
<span className="stock-main-tab__icon">🧠</span>
|
||||
<span className="stock-main-tab__label">AI 어드바이저</span>
|
||||
<span className="stock-main-tab__sub">Gemini Pro</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
@@ -1974,6 +2045,83 @@ ${holdingsText}${marketText}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
TAB 4: AI 어드바이저 (Gemini Pro)
|
||||
════════════════════════════════════════════════════════ */}
|
||||
{activeTab === TAB_ADVISOR && (
|
||||
<section className="stock-panel stock-panel--wide advisor-panel">
|
||||
{/* 헤더 */}
|
||||
<div className="advisor-panel__head">
|
||||
<div className="advisor-panel__title-block">
|
||||
<span className="advisor-panel__badge">Gemini Pro</span>
|
||||
<h3 className="advisor-panel__title">AI 전문가 분석</h3>
|
||||
<p className="advisor-panel__sub">
|
||||
보유 종목 현재가와 오늘의 뉴스를 기반으로 전문가 관점의 매매 조언을 제공합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="advisor-panel__actions">
|
||||
{advisorData && (
|
||||
<span className="advisor-panel__timestamp">
|
||||
{advisorData.cached ? '🗂 캐시' : '✨ 신규'} ·{' '}
|
||||
{new Date(advisorData.generated_at).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})} 기준
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => loadAdvisorAnalysis(true)}
|
||||
disabled={advisorLoading}
|
||||
>
|
||||
{advisorLoading ? '분석 중...' : '🔄 새로 분석'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로딩 */}
|
||||
{advisorLoading && (
|
||||
<div className="advisor-panel__loading">
|
||||
<div className="advisor-loading-spinner" />
|
||||
<div className="advisor-loading-text">
|
||||
<p>Gemini Pro가 포트폴리오를 분석 중입니다...</p>
|
||||
<p className="advisor-loading-sub">현재가 조회 · 뉴스 분석 · 전략 수립</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 */}
|
||||
{!advisorLoading && advisorError && (
|
||||
<div className="advisor-panel__error">
|
||||
<span>⚠️ {advisorError}</span>
|
||||
<button className="button ghost small" onClick={() => loadAdvisorAnalysis(true)}>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분석 결과 */}
|
||||
{!advisorLoading && !advisorError && advisorData && (
|
||||
<div className="advisor-panel__body">
|
||||
<AdvisorMarkdown text={advisorData.analysis} />
|
||||
<p className="advisor-panel__disclaimer">
|
||||
※ 이 분석은 AI가 생성한 참고 자료이며 투자 결정은 본인의 판단과 책임 하에 이루어져야 합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 초기 상태 */}
|
||||
{!advisorLoading && !advisorError && !advisorData && (
|
||||
<div className="advisor-panel__empty">
|
||||
<span className="advisor-panel__empty-icon">🧠</span>
|
||||
<p>아직 분석 결과가 없습니다.</p>
|
||||
<button className="button primary" onClick={() => loadAdvisorAnalysis()}>
|
||||
분석 시작
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════
|
||||
TAB 3: 리포트 + AI 코치
|
||||
════════════════════════════════════════════════════════ */}
|
||||
@@ -2528,6 +2676,17 @@ ${holdingsText}${marketText}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
수수료 & 세금 (원)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={sellForm.commission}
|
||||
onChange={(e) => setSellForm((p) => ({ ...p, commission: e.target.value }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</label>
|
||||
<label className="sh-form__datetime">
|
||||
매도 일시
|
||||
<input
|
||||
@@ -2542,11 +2701,15 @@ ${holdingsText}${marketText}
|
||||
const qty = Number(sellForm.quantity);
|
||||
const buy = Number(sellForm.avg_price) * qty;
|
||||
const sell = Number(sellForm.sell_price) * qty;
|
||||
const profit = sell - buy;
|
||||
const commission = Number(sellForm.commission) || 0;
|
||||
const profit = sell - buy - commission;
|
||||
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
||||
return (
|
||||
<div className="sh-form__preview">
|
||||
<span>매도금액 <strong>{formatNumber(Math.round(sell))}원</strong></span>
|
||||
{commission > 0 && (
|
||||
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}원</strong></span>
|
||||
)}
|
||||
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
||||
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
||||
</div>
|
||||
@@ -2611,6 +2774,12 @@ ${holdingsText}${marketText}
|
||||
<span>총 매도금액</span>
|
||||
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>총 수수료 & 세금</span>
|
||||
<strong className="stock-profit is-negative">
|
||||
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
|
||||
</strong>
|
||||
</div>
|
||||
<div className="sell-history__summary-card">
|
||||
<span>실현손익 합계</span>
|
||||
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
||||
@@ -2684,6 +2853,14 @@ ${holdingsText}${marketText}
|
||||
<span>매도금액</span>
|
||||
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
||||
</div>
|
||||
{(r.commission > 0) && (
|
||||
<div>
|
||||
<span>수수료 & 세금</span>
|
||||
<strong className="stock-profit is-negative">
|
||||
-{formatNumber(Math.round(r.commission))}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span>실현손익</span>
|
||||
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
||||
|
||||
Reference in New Issue
Block a user