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:
2026-03-25 03:54:50 +09:00
parent 2c4b1e2e3a
commit d1ecf13400
4 changed files with 596 additions and 17 deletions

View File

@@ -354,3 +354,11 @@ export function updateBlogPost(id, data) {
export function deleteBlogPost(id) {
return apiDelete(`/api/blog/posts/${id}`);
}
// ── AI 포트폴리오 분석 (Gemini Pro) ──────────────────────────────────────────
// GET /api/stock/ai-analysis?force=true
// response: { analysis: string, generated_at: string, cached: bool, holdings_count: int }
export function getAiAnalysis(force = false) {
return apiGet(`/api/stock/ai-analysis${force ? '?force=true' : ''}`);
}

View File

@@ -462,6 +462,10 @@
color: var(--muted);
}
.stock-profit.is-negative {
color: #3b82f6;
}
.stock-result {
border: 1px solid var(--line);
border-radius: 14px;
@@ -2650,3 +2654,259 @@
grid-column: span 1;
}
}
/* ═══════════════════════════════════════════════════════════════════════
AI Advisor Tab (TAB_ADVISOR)
═══════════════════════════════════════════════════════════════════════ */
/* ── Tab button ──────────────────────────────────────────────────────── */
.stock-main-tab--advisor {
position: relative;
}
.stock-main-tab--advisor::after {
content: 'AI';
position: absolute;
top: 4px;
right: 4px;
font-size: 8px;
font-weight: 800;
letter-spacing: 0.05em;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border-radius: 4px;
padding: 1px 4px;
line-height: 1.4;
}
/* ── Panel layout ────────────────────────────────────────────────────── */
.advisor-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.advisor-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.advisor-panel__title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.advisor-panel__badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #818cf8;
background: rgba(129, 140, 248, 0.12);
border: 1px solid rgba(129, 140, 248, 0.3);
border-radius: 6px;
padding: 2px 8px;
width: fit-content;
}
.advisor-panel__title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-bright);
}
.advisor-panel__sub {
margin: 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
max-width: 480px;
}
.advisor-panel__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.advisor-panel__timestamp {
font-size: 11px;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
border-radius: 8px;
padding: 4px 10px;
}
/* ── Loading state ───────────────────────────────────────────────────── */
.advisor-panel__loading {
display: flex;
align-items: center;
gap: 16px;
padding: 32px 0;
}
.advisor-loading-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(129, 140, 248, 0.15);
border-top-color: #818cf8;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.advisor-loading-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.advisor-loading-text p {
margin: 0;
font-size: 14px;
color: var(--text-bright);
}
.advisor-loading-sub {
font-size: 12px !important;
color: var(--text-muted) !important;
}
/* ── Error state ─────────────────────────────────────────────────────── */
.advisor-panel__error {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(249, 182, 177, 0.08);
border: 1px solid rgba(249, 182, 177, 0.3);
color: #f9b6b1;
font-size: 13px;
}
/* ── Empty state ─────────────────────────────────────────────────────── */
.advisor-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: var(--text-muted);
}
.advisor-panel__empty-icon {
font-size: 40px;
opacity: 0.4;
}
/* ── Analysis body ───────────────────────────────────────────────────── */
.advisor-panel__body {
display: flex;
flex-direction: column;
gap: 16px;
}
.advisor-panel__disclaimer {
margin: 0;
font-size: 11px;
color: var(--text-muted);
opacity: 0.6;
border-top: 1px solid var(--line);
padding-top: 12px;
}
/* ── Markdown renderer ───────────────────────────────────────────────── */
.adv-md {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.75;
}
.adv-md-h2 {
margin: 20px 0 6px;
font-size: 16px;
font-weight: 700;
color: var(--text-bright);
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.adv-md-h3 {
margin: 14px 0 4px;
font-size: 14px;
font-weight: 700;
color: #818cf8;
}
.adv-md-p {
margin: 0;
font-size: 13px;
color: var(--text);
line-height: 1.75;
}
.adv-md-p strong {
color: var(--text-bright);
font-weight: 700;
}
.adv-md-p em {
color: #fbbf24;
font-style: normal;
}
.adv-md-p code {
font-family: monospace;
font-size: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--line);
border-radius: 4px;
padding: 1px 5px;
color: #34d399;
}
.adv-md-hr {
border: none;
border-top: 1px solid var(--line);
margin: 12px 0;
}
.adv-md-gap {
height: 6px;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.advisor-panel__head {
flex-direction: column;
}
.advisor-panel__actions {
width: 100%;
justify-content: flex-end;
}
}

View File

@@ -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)}`}>