Files
web-page/src/pages/stock/StockTrade.jsx

1704 lines
90 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
createTradeOrder,
getTradeBalance,
getPortfolio,
addPortfolio,
updatePortfolio,
deletePortfolio,
upsertCash,
deleteCash,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
import {
PieChart, Pie, Cell,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip as ChartTooltip, Legend, ResponsiveContainer,
} from 'recharts';
/* ── helpers ─────────────────────────────────────────────────────── */
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);
};
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)}%`;
};
const pickFirst = (...values) =>
values.find((value) => value !== undefined && value !== null && value !== '');
const getQty = (item) =>
pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty);
const getBuyPrice = (item) =>
pickFirst(
item?.buy_price,
item?.avg_price,
item?.avg,
item?.purchase_price,
item?.buyPrice,
item?.price
);
const getCurrentPrice = (item) =>
pickFirst(
item?.current_price,
item?.current,
item?.cur_price,
item?.now_price,
item?.market_price
);
const getProfitRate = (item) =>
pickFirst(
item?.profit_rate,
item?.profitRate,
item?.profit_pct,
item?.profitPercent,
item?.pnl_rate,
item?.return_rate,
item?.yield
);
const getProfitLoss = (item) =>
pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss);
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 ──────────────────────────────────────────────── */
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
const profitColorClass = (numericValue) => {
if (numericValue > 0) return 'is-up';
if (numericValue < 0) return 'is-down';
if (numericValue === 0) return 'is-flat';
return '';
};
/* ── empty portfolio form ────────────────────────────────────────── */
const emptyPortfolioForm = {
broker: '',
ticker: '',
name: '',
quantity: '',
avg_price: '',
};
/* ── TAB IDs ─────────────────────────────────────────────────────── */
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
const TAB_REPORT = 'report';
/* ── component ───────────────────────────────────────────────────── */
const StockTrade = () => {
/* Active tab */
const [activeTab, setActiveTab] = useState(TAB_REPORT);
/* ────────────────────────────────────────────────────────────── */
/* 쟁승토리 계좌 (Portfolio) state */
/* ────────────────────────────────────────────────────────────── */
const [portfolio, setPortfolio] = useState(null);
const [portfolioLoading, setPortfolioLoading] = useState(false);
const [portfolioError, setPortfolioError] = useState('');
const [portfolioLoaded, setPortfolioLoaded] = useState(false);
/* Portfolio add form */
const [addForm, setAddForm] = useState({ ...emptyPortfolioForm });
const [addFormOpen, setAddFormOpen] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState('');
/* Portfolio edit */
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const [editLoading, setEditLoading] = useState(false);
const editOrigRef = useRef({});
/* Portfolio delete */
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
/* Cash (예수금) form */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
const [cashSaving, setCashSaving] = useState(false);
const [cashError, setCashError] = useState('');
/* ────────────────────────────────────────────────────────────── */
/* 리포트 탭 state */
/* ────────────────────────────────────────────────────────────── */
const [reportSortField, setReportSortField] = useState('profit_rate');
const [reportSortDir, setReportSortDir] = useState('desc');
/* AI Coach */
const [aiApiKey, setAiApiKey] = useState('');
const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001');
const [aiResult, setAiResult] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState('');
/* ────────────────────────────────────────────────────────────── */
/* AI 투자 (Balance) state */
/* ────────────────────────────────────────────────────────────── */
const [balance, setBalance] = useState(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const [balanceError, setBalanceError] = useState('');
const [balanceLoaded, setBalanceLoaded] = useState(false);
/* Manual order state */
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('');
/* ── 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);
}
}, []);
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);
}
}, []);
/* Lazy load: 탭 전환 시 해당 API만 호출 */
useEffect(() => {
if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) {
loadPortfolio();
} else if (activeTab === TAB_AI && !balanceLoaded) {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]);
/* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */
useEffect(() => {
const savedKey = localStorage.getItem('ai_coach_key') ?? '';
const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001';
setAiApiKey(savedKey);
setAiModel(savedModel);
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 */ }
}
}, []);
/* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */
useEffect(() => {
if (activeTab !== TAB_PORTFOLIO) return;
const timer = window.setInterval(loadPortfolio, 180000);
return () => window.clearInterval(timer);
}, [activeTab, loadPortfolio]);
/* ── portfolio 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);
setEditForm({
quantity: item.quantity,
avg_price: item.avg_price,
broker: item.broker,
name: item.name,
});
editOrigRef.current = {
quantity: item.quantity,
avg_price: item.avg_price,
broker: item.broker,
name: item.name,
};
};
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)));
}
};
/* ── report sort ─────────────────────────────────────────────── */
const handleReportSort = (field) => {
if (reportSortField === field) {
setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setReportSortField(field);
setReportSortDir('desc');
}
};
/* ── AI coach ────────────────────────────────────────────────── */
const handleAiCoach = async () => {
if (!aiApiKey.trim() || 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 cache */ }
}
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 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}
반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로):
{
"score": 85,
"grade": "A",
"summary": "30자 이내 한줄 평가",
"evaluation": "200자 이내 상세 평가",
"advice": [
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
{ "title": "조언 제목", "body": "50자 이내 조언 내용" },
{ "title": "조언 제목", "body": "50자 이내 조언 내용" }
]
}`;
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': aiApiKey.trim(),
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: aiModel,
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`);
}
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);
}
};
/* ── manual order ────────────────────────────────────────────── */
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: AI balance ──────────────────────────────────────── */
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;
/* ── derived: Portfolio ───────────────────────────────────────── */
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 getBrokerSummary = (items) => {
let totalBuy = 0;
let totalEvalAmt = 0;
let 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 };
};
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]);
/* ── derived: Report ──────────────────────────────────────────── */
const brokerPieData = useMemo(() =>
brokerGroups
.map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval }))
.filter((d) => d.value > 0),
[brokerGroups]
);
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 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]);
/* ── render ───────────────────────────────────────────────────── */
return (
<div className="stock">
{/* ── Header ──────────────────────────────────────────── */}
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>거래 데스크</h1>
<p className="stock-sub">
실제 계좌와 AI 모의투자를 곳에서 관리하세요.
</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">
주식 랩으로 돌아가기
</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
</p>
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
/* Portfolio summary */
<div className="stock-status">
<div>
<span> 매입</span>
<strong>{formatNumber(portfolioSummary.total_buy)}</strong>
</div>
<div>
<span> 평가</span>
<strong>{formatNumber(portfolioSummary.total_eval)}</strong>
</div>
<div>
<span> 손익</span>
<strong
className={`stock-profit ${profitColorClass(
toNumeric(portfolioSummary.total_profit)
)}`}
>
{formatNumber(portfolioSummary.total_profit)}
{portfolioSummary.total_profit_rate != null && (
<small style={{ marginLeft: 4, fontSize: 11 }}>
({formatPercent(portfolioSummary.total_profit_rate)})
</small>
)}
</strong>
</div>
<div>
<span>보유 종목</span>
<strong>{portfolioHoldings.length}</strong>
</div>
{totalCash != null && (
<div>
<span>예수금 합계</span>
<strong style={{ color: '#93c5fd' }}>
{formatNumber(totalCash)}
</strong>
</div>
)}
{totalAssets != null && (
<div>
<span> 자산</span>
<strong style={{ fontWeight: 700 }}>
{formatNumber(totalAssets)}
</strong>
</div>
)}
</div>
) : (
/* AI balance summary */
<div className="stock-status">
<div>
<span> 평가금액</span>
<strong>{formatNumber(totalEval)}</strong>
</div>
<div>
<span>예수금</span>
<strong>{formatNumber(deposit)}</strong>
</div>
<div>
<span>보유 종목</span>
<strong>{holdings.length}</strong>
</div>
</div>
)}
{activeTab === TAB_AI && summary.note ? (
<p className="stock-status__note">{summary.note}</p>
) : null}
</div>
</header>
{/* ── Main Tabs ───────────────────────────────────────── */}
<div className="stock-main-tabs">
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_PORTFOLIO ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_PORTFOLIO)}
>
<span className="stock-main-tab__icon">💼</span>
<span className="stock-main-tab__label">쟁승토리 계좌</span>
{portfolioHoldings.length > 0 && (
<span className="stock-main-tab__badge">
{portfolioHoldings.length}
</span>
)}
</button>
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_AI ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_AI)}
>
<span className="stock-main-tab__icon">🤖</span>
<span className="stock-main-tab__label">AI 투자</span>
<span className="stock-main-tab__sub">모의투자</span>
</button>
<button
type="button"
className={`stock-main-tab ${activeTab === TAB_REPORT ? 'is-active' : ''}`}
onClick={() => setActiveTab(TAB_REPORT)}
>
<span className="stock-main-tab__icon">📊</span>
<span className="stock-main-tab__label">리포트</span>
<span className="stock-main-tab__sub">분석·AI코치</span>
</button>
</div>
{/* ════════════════════════════════════════════════════════
TAB 1: 쟁승토리 계좌
════════════════════════════════════════════════════════ */}
{activeTab === TAB_PORTFOLIO && (
<>
{portfolioError ? (
<p className="stock-error">{portfolioError}</p>
) : null}
{/* 포트폴리오 관리 헤더 + 추가 폼 */}
<section className="stock-panel stock-panel--wide pf-section">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">포트폴리오</p>
<h3>수동 입력 종목 관리</h3>
<p className="stock-panel__sub">
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3 캐시)
</p>
</div>
<div className="stock-panel__actions">
{portfolioLoading ? (
<Loading type="spinner" message="" />
) : null}
<button
className="button ghost small"
onClick={loadPortfolio}
disabled={portfolioLoading}
>
새로고침
</button>
<button
className="button primary small"
onClick={() => setAddFormOpen((v) => !v)}
>
{addFormOpen ? '취소' : '+ 종목 추가'}
</button>
</div>
</div>
{/* Add form */}
{addFormOpen && (
<form className="pf-add-form" onSubmit={handleAddSubmit}>
<label>
증권사
<input
type="text"
value={addForm.broker}
onChange={(e) =>
setAddForm((p) => ({ ...p, broker: e.target.value }))
}
placeholder="KB증권"
required
/>
</label>
<label>
종목코드
<input
type="text"
value={addForm.ticker}
onChange={(e) =>
setAddForm((p) => ({ ...p, ticker: e.target.value }))
}
placeholder="005930"
required
maxLength={6}
/>
</label>
<label>
종목명
<input
type="text"
value={addForm.name}
onChange={(e) =>
setAddForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="삼성전자"
required
/>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={addForm.quantity}
onChange={(e) =>
setAddForm((p) => ({ ...p, quantity: e.target.value }))
}
required
/>
</label>
<label>
평균 매입가 ()
<input
type="number"
min={0}
step={1}
value={addForm.avg_price}
onChange={(e) =>
setAddForm((p) => ({ ...p, avg_price: e.target.value }))
}
required
/>
</label>
<button
className="button primary"
type="submit"
disabled={addLoading}
>
{addLoading ? '등록 중...' : '종목 등록'}
</button>
{addError && <p className="stock-error">{addError}</p>}
</form>
)}
{/* Portfolio total summary */}
{portfolioHoldings.length > 0 && (
<div className="pf-total-summary">
{[
{ label: '총 매입', value: portfolioSummary.total_buy },
{ label: '총 평가', value: portfolioSummary.total_eval },
{ label: '총 손익', value: portfolioSummary.total_profit, isProfit: true },
{ label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true },
].map((s) => (
<div key={s.label} className="pf-total-summary__card">
<span>{s.label}</span>
<strong
className={
s.isProfit || s.isRate
? `stock-profit ${profitColorClass(toNumeric(s.value))}`
: ''
}
>
{s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
</strong>
</div>
))}
{totalCash != null && (
<div className="pf-total-summary__card is-cash">
<span>예수금 합계</span>
<strong>{formatNumber(totalCash)}</strong>
</div>
)}
{totalAssets != null && (
<div className="pf-total-summary__card is-assets">
<span> 자산</span>
<strong>{formatNumber(totalAssets)}</strong>
</div>
)}
</div>
)}
</section>
{/* 예수금 패널 */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">예수금 관리</p>
<h3>증권사별 예수금</h3>
<p className="stock-panel__sub">
증권사별 예수금을 입력하면 자산에 자동 반영됩니다.
</p>
</div>
</div>
{cashList.length > 0 && (
<div className="pf-cash-table">
{cashList.map((item) => (
<div key={item.id ?? item.broker} className="pf-cash-row">
<span className="pf-cash-broker">{item.broker}</span>
<strong className="pf-cash-amount">
{formatNumber(item.cash)}
</strong>
<span className="pf-cash-date">
{item.updated_at
? new Date(item.updated_at).toLocaleDateString('ko-KR')
: ''}
</span>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleCashDelete(item.broker)}
title="삭제"
>
🗑
</button>
</div>
))}
</div>
)}
{cashList.length === 0 && (
<p className="stock-empty" style={{ fontSize: 13 }}>
등록된 예수금이 없습니다.
</p>
)}
<form className="pf-cash-form" onSubmit={handleCashSave}>
<label>
증권사명
<input
type="text"
value={cashForm.broker}
onChange={(e) =>
setCashForm((p) => ({ ...p, broker: e.target.value }))
}
placeholder="KB증권"
required
/>
</label>
<label>
예수금 ()
<input
type="number"
min={0}
step={1}
value={cashForm.cash}
onChange={(e) =>
setCashForm((p) => ({ ...p, cash: e.target.value }))
}
placeholder="1500000"
required
/>
</label>
<button
className="button primary"
type="submit"
disabled={cashSaving}
>
{cashSaving ? '저장 중...' : '저장'}
</button>
{cashError && <p className="stock-error">{cashError}</p>}
</form>
</section>
{/* Broker cards stacked */}
{brokerGroups.map(([broker, items]) => {
const bSummary = getBrokerSummary(items);
const color = brokerColors[broker];
return (
<section
key={broker}
className="stock-panel stock-panel--wide pf-broker-section"
style={{
borderColor: color?.border,
background: color?.bg,
}}
>
<div className="stock-panel__head">
<div>
<p
className="stock-panel__eyebrow"
style={{ color: color?.border }}
>
{broker}
</p>
<h3>{broker} 보유 현황</h3>
<p className="stock-panel__sub">
{items.length}종목 · 평가{' '}
{formatNumber(bSummary.totalEval)} · 손익{' '}
<span
className={`stock-profit ${profitColorClass(
bSummary.totalProfit
)}`}
>
{formatNumber(bSummary.totalProfit)} (
{formatPercent(bSummary.totalProfitRate)})
</span>
{(() => {
const bc = cashList.find(
(c) => c.broker === broker
);
return bc ? (
<span className="pf-cash-badge">
예수금 {formatNumber(bc.cash)}
</span>
) : null;
})()}
</p>
</div>
</div>
<div className="stock-holdings">
{items.map((item) => {
const profitAmt = item.profit_amount;
const profitRate = item.profit_rate;
const profitAmtN = toNumeric(profitAmt);
const profitRateN = toNumeric(profitRate);
const isEditing = editingId === item.id;
const isDeleting = deleteConfirmId === item.id;
return (
<div
key={item.id}
className="stock-holdings__item pf-item"
>
{isEditing ? (
<div className="pf-edit-row">
<div className="pf-edit-fields">
<label>
수량
<input
type="number"
min={1}
value={editForm.quantity ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
}
/>
</label>
<label>
평균매입가
<input
type="number"
min={0}
value={editForm.avg_price ?? ''}
onChange={(e) =>
setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
}
/>
</label>
</div>
<div className="pf-edit-actions">
<button
className="button primary small"
onClick={() => handleEditSave(item.id)}
disabled={editLoading}
>
{editLoading ? '저장 중...' : '저장'}
</button>
<button
className="button ghost small"
onClick={() => setEditingId(null)}
>
취소
</button>
</div>
</div>
) : (
<>
<div>
<p className="stock-holdings__name">
{item.name ?? item.ticker ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.ticker ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>{formatNumber(item.quantity)}</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>{formatNumber(item.avg_price)}</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong
className={item.current_price == null ? 'pf-null-price' : ''}
>
{item.current_price != null
? formatNumber(item.current_price)
: '조회 실패'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{item.current_price != null && item.quantity != null
? formatNumber(item.current_price * item.quantity)
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitColorClass(profitRateN)}`}
>
{profitRate != null
? formatPercent(profitRate)
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitColorClass(profitAmtN)}`}
>
{profitAmt != null
? formatNumber(profitAmt)
: '-'}
</strong>
</div>
<div className="pf-item-actions">
<button
className="button ghost small"
onClick={() => handleEditStart(item)}
title="수정"
>
</button>
{isDeleting ? (
<>
<button
className="button ghost small pf-btn-danger"
onClick={() => handleDelete(item.id)}
>
확인
</button>
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(null)
}
>
취소
</button>
</>
) : (
<button
className="button ghost small"
onClick={() =>
setDeleteConfirmId(item.id)
}
title="삭제"
>
🗑
</button>
)}
</div>
</>
)}
</div>
);
})}
</div>
</section>
);
})}
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
<section className="stock-panel stock-panel--wide">
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
등록된 종목이 없습니다. 상단의 <strong>+ 종목 추가</strong> .
</p>
</section>
)}
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 2: AI 투자 (모의투자)
════════════════════════════════════════════════════════ */}
{activeTab === TAB_AI && (
<>
{balanceError ? <p className="stock-error">{balanceError}</p> : null}
{/* AI Balance section */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">AI 모의투자</p>
<h3>보유 현황</h3>
<p className="stock-panel__sub">
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
</p>
</div>
<div className="stock-panel__actions">
{balanceLoading ? (
<span className="stock-chip">조회 </span>
) : null}
<button
className="button ghost small"
onClick={loadBalance}
disabled={balanceLoading}
>
새로고침
</button>
</div>
</div>
<div className="stock-balance">
<div className="stock-balance__summary">
{[
{ label: '총 평가', value: totalEval },
{ label: '예수금', value: deposit },
].map((item) => (
<div
key={item.label}
className="stock-balance__card"
>
<span>{item.label}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
))}
</div>
{holdings.length ? (
<div className="stock-holdings">
{holdings.map((item, idx) => {
const profitLoss = getProfitLoss(item);
const profitLossNumeric = toNumeric(profitLoss);
const profitClass = profitColorClass(profitLossNumeric);
const profitRate = getProfitRate(item);
const profitRateNumeric = toNumeric(profitRate);
const profitRateClass = profitColorClass(profitRateNumeric);
return (
<div
key={item.code ?? `${item.name}-${idx}`}
className="stock-holdings__item"
>
<div>
<p className="stock-holdings__name">
{item.name ?? item.code ?? 'N/A'}
</p>
<span className="stock-holdings__code">
{item.code ?? ''}
</span>
</div>
<div className="stock-holdings__metric">
<span>수량</span>
<strong>
{formatNumber(getQty(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>매입가</span>
<strong>
{formatNumber(getBuyPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>현재가</span>
<strong>
{formatNumber(getCurrentPrice(item))}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가금액</span>
<strong>
{getCurrentPrice(item) != null && getQty(item) != null
? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item)))
: '-'}
</strong>
</div>
<div className="stock-holdings__metric">
<span>수익률</span>
<strong
className={`stock-profit ${profitRateClass}`}
>
{formatPercent(profitRate)}
</strong>
</div>
<div className="stock-holdings__metric">
<span>평가손익</span>
<strong
className={`stock-profit ${profitClass}`}
>
{formatNumber(profitLoss)}
</strong>
</div>
</div>
);
})}
</div>
) : (
<p className="stock-empty">보유 종목이 없습니다.</p>
)}
</div>
</section>
{/* Manual order section */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">수동 주문</p>
<h3>직접 매수/매도</h3>
<p className="stock-panel__sub">
종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
</p>
</div>
</div>
<form className="stock-order" onSubmit={submitManualOrder}>
<label>
종목명/코드
<input
type="text"
value={manualForm.code}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
code: event.target.value,
}))
}
placeholder="005930 또는 삼성전자"
required
/>
</label>
<label>
매수/매도
<select
value={manualForm.type}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
type: event.target.value,
}))
}
>
<option value="buy">매수</option>
<option value="sell">매도</option>
</select>
</label>
<label>
수량
<input
type="number"
min={1}
step={1}
value={manualForm.qty}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
qty: Number(event.target.value),
}))
}
required
/>
</label>
<label>
금액()
<input
type="number"
min={0}
step={1}
value={manualForm.price}
onChange={(event) =>
setManualForm((prev) => ({
...prev,
price: Number(event.target.value),
}))
}
/>
</label>
<button
className="button primary"
type="submit"
disabled={manualLoading}
>
{manualLoading ? '요청 중...' : '주문 요청'}
</button>
{manualError ? (
<p className="stock-error">{manualError}</p>
) : null}
{manualResult ? (
<div className="stock-result">
<p className="stock-result__title">요청 결과</p>
<pre>
{typeof manualResult === 'string'
? manualResult
: JSON.stringify(manualResult, null, 2)}
</pre>
</div>
) : null}
</form>
</section>
</>
)}
{/* ════════════════════════════════════════════════════════
TAB 3: 리포트 + AI 코치
════════════════════════════════════════════════════════ */}
{activeTab === TAB_REPORT && (
<>
{portfolioLoading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Loading type="spinner" message="포트폴리오 로딩 중..." />
</div>
)}
{portfolioError && <p className="stock-error">{portfolioError}</p>}
{/* ── 자산 배분 + 수익률 차트 ────────────────────── */}
{portfolioHoldings.length > 0 && (
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">포트폴리오 분석</p>
<h3>자산 배분 현황</h3>
</div>
</div>
<div className="report-charts-row">
<div className="report-chart-box">
<p className="report-chart-title">증권사별 자산 배분</p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={brokerPieData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={84}
dataKey="value"
paddingAngle={2}
>
{brokerPieData.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<ChartTooltip
formatter={(v) => [formatNumber(v) + '원', '평가금액']}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
<Legend
iconType="circle"
iconSize={8}
formatter={(v) => <span style={{ color: '#9ca3af', fontSize: 12 }}>{v}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="report-chart-box">
<p className="report-chart-title">종목별 수익률 (%)</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={profitBarData} margin={{ top: 0, right: 8, left: -16, bottom: 48 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis
dataKey="name"
tick={{ fill: '#9ca3af', fontSize: 10 }}
angle={-40}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={(v) => `${v}%`}
/>
<ChartTooltip
formatter={(v, _n, props) => [`${v.toFixed(2)}%`, props.payload.fullName]}
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }}
/>
<Bar dataKey="rate" radius={[4, 4, 0, 0]}>
{profitBarData.map((entry, i) => (
<Cell key={i} fill={entry.rate >= 0 ? '#34d399' : '#f87171'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</section>
)}
{/* ── 수익률 랭킹 테이블 ─────────────────────────── */}
{portfolioHoldings.length > 0 && (
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">수익률 랭킹</p>
<h3>종목별 상세 현황</h3>
<p className="stock-panel__sub">헤더 클릭으로 정렬</p>
</div>
</div>
<div className="report-table-wrapper">
<table className="report-table">
<thead>
<tr>
{[
{ key: 'name', label: '종목명' },
{ key: 'broker', label: '증권사' },
{ key: 'profit_rate', label: '수익률' },
{ key: 'profit_amount', label: '평가손익' },
{ key: 'eval_amount', label: '평가금액' },
].map(({ key, label }) => (
<th key={key} onClick={() => handleReportSort(key)}>
{label}{' '}
<span className="report-sort-icon">
{reportSortField === key
? reportSortDir === 'asc' ? '↑' : '↓'
: '↕'}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedHoldings.map((item) => {
const rateN = toNumeric(item.profit_rate);
const pnlN = toNumeric(item.profit_amount);
const evalAmt = item.eval_amount != null
? item.eval_amount
: item.current_price != null
? item.current_price * item.quantity
: null;
return (
<tr key={item.id}>
<td>
<p className="report-table-name">{item.name ?? item.ticker ?? 'N/A'}</p>
<span className="report-table-code">{item.ticker ?? ''}</span>
</td>
<td className="report-td-muted">{item.broker ?? '-'}</td>
<td className={`stock-profit ${profitColorClass(rateN)}`}>
<div className="report-rate-cell">
<span>{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'}</span>
{rateN != null && (
<div className="report-rate-bar">
<div
className={`report-rate-bar__fill ${rateN >= 0 ? 'is-up' : 'is-down'}`}
style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }}
/>
</div>
)}
</div>
</td>
<td className={`stock-profit ${profitColorClass(pnlN)}`}>
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'}
</td>
<td className="report-td-muted">
{evalAmt != null ? formatNumber(evalAmt) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
{portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (
<section className="stock-panel stock-panel--wide">
<p className="stock-empty" style={{ textAlign: 'center', padding: 24 }}>
등록된 종목이 없습니다. <strong>쟁승토리 계좌</strong> .
</p>
</section>
)}
{/* ── AI 투자 코치 ───────────────────────────────── */}
<section className="stock-panel stock-panel--wide">
<div className="stock-panel__head">
<div>
<p className="stock-panel__eyebrow">AI 투자 코치</p>
<h3>오늘의 투자 평가</h3>
<p className="stock-panel__sub">
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
</p>
</div>
</div>
{/* API Key 설정 */}
<div className="ai-coach-settings">
<label>
Anthropic API Key
<div className="ai-coach-key-row">
<input
type="password"
className="ai-coach-key-input"
value={aiApiKey}
onChange={(e) => setAiApiKey(e.target.value)}
placeholder="sk-ant-api03-..."
/>
<button
className="button ghost small"
type="button"
onClick={() => {
localStorage.setItem('ai_coach_key', aiApiKey);
localStorage.setItem('ai_coach_model', aiModel);
}}
>
저장
</button>
</div>
</label>
<label>
AI 모델
<select
value={aiModel}
onChange={(e) => {
setAiModel(e.target.value);
localStorage.setItem('ai_coach_model', e.target.value);
}}
>
<option value="claude-haiku-4-5-20251001">Claude Haiku (빠름·저렴)</option>
<option value="claude-sonnet-4-6">Claude Sonnet (고성능)</option>
</select>
</label>
</div>
<div className="ai-coach-actions">
<button
className="button primary"
type="button"
onClick={handleAiCoach}
disabled={aiLoading || !aiApiKey.trim() || portfolioHoldings.length === 0}
>
{aiLoading ? 'AI 분석 중...' : '오늘 투자 평가 받기'}
</button>
{portfolioHoldings.length === 0 && (
<span className="ai-coach-note">종목 등록 이용 가능합니다.</span>
)}
{aiResult?.generated_at && (
<span className="ai-coach-note">
{aiResult.cached ? '오늘 캐시 결과 · ' : ''}
{new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성
</span>
)}
</div>
{aiError && <p className="stock-error" style={{ marginTop: 8 }}>{aiError}</p>}
{aiResult && !aiLoading && (
<div className="ai-coach-result">
<div className="ai-coach-header">
<div className={`ai-grade-badge grade-${(aiResult.grade ?? 'c').toLowerCase()}`}>
{aiResult.grade ?? '?'}
</div>
<div className="ai-score-wrap">
<span className="ai-score-num">{aiResult.score ?? 0}</span>
<span className="ai-score-unit">/ 100</span>
</div>
<p className="ai-summary-text">{aiResult.summary}</p>
</div>
<p className="ai-evaluation-text">{aiResult.evaluation}</p>
{aiResult.advice?.length > 0 && (
<div className="ai-advice-list">
{aiResult.advice.map((a, i) => (
<div key={i} className="ai-advice-card">
<p className="ai-advice-title">{a.title}</p>
<p className="ai-advice-body">{a.body}</p>
</div>
))}
</div>
)}
<button
className="button ghost small"
type="button"
style={{ marginTop: 16, fontSize: 11 }}
onClick={() => {
const today = new Date().toISOString().slice(0, 10);
localStorage.removeItem(`ai_coach_${today}`);
setAiResult(null);
}}
>
다시 평가받기 (캐시 삭제)
</button>
</div>
)}
</section>
</>
)}
{/* KIS modal */}
{kisModal ? (
<div className="stock-modal" role="dialog" aria-modal="true">
<div
className="stock-modal__backdrop"
onClick={() => setKisModal('')}
/>
<div className="stock-modal__card">
<div className="stock-modal__head">
<h4>주문 결과</h4>
<button
type="button"
className="button ghost small"
onClick={() => setKisModal('')}
>
닫기
</button>
</div>
<pre>{kisModal}</pre>
</div>
</div>
) : null}
</div>
);
};
export default StockTrade;