// Detail.jsx — 종목 상세 (추가 탭). skeleton-level but presentable. function hashSeed(s) {let h = 2166136261;for (let i = 0; i < s.length; i++) {h ^= s.charCodeAt(i);h = Math.imul(h, 16777619);}return h >>> 0;} function genHist(seed, endVal, n = 30, vol = 0.06) { let r = seed;const rnd = () => {r = r * 1664525 + 1013904223 >>> 0;return r / 4294967296;}; const arr = new Array(n);arr[n - 1] = endVal; let v = endVal; for (let i = n - 2; i >= 0; i--) {const mean = endVal * (0.92 + 0.08 * (i / (n - 1)));v = v * 0.55 + mean * 0.45 + endVal * (rnd() - 0.5) * vol;arr[i] = v;} return arr.map((x) => +x); } function parseNum(s) { return s != null ? +String(s).replace(/[^0-9.]/g, '') : null; } // 가격 시계열 (임의 · 추후 매핑). 종가 endVal 에서 역방향 생성. function genPrice(seed, endVal, n = 30, vol = 0.018) { let r = (seed ^ 0x9e3779b9) >>> 0; const rnd = () => { r = r * 1664525 + 1013904223 >>> 0; return r / 4294967296; }; const arr = new Array(n); arr[n - 1] = endVal; let v = endVal; for (let i = n - 2; i >= 0; i--) { v = v * (1 + (rnd() - 0.5) * vol); arr[i] = v; } return arr.map((x) => +x.toFixed(endVal >= 1000 ? 0 : 2)); } // 차트 기간 설정 const PERIODS = { 'ALL': { days: 365 * 7, n: 28, fmt: 'ym' }, '5Y': { days: 365 * 5, n: 21, fmt: 'ym' }, '1Y': { days: 365, n: 13, fmt: 'ym' }, '6M': { days: 182, n: 14, fmt: 'md' }, '3M': { days: 91, n: 13, fmt: 'md' }, '1M': { days: 30, n: 12, fmt: 'md' }, '1W': { days: 7, n: 6, fmt: 'md' } }; function buildPeriod(period) { const cfg = PERIODS[period] || PERIODS['1Y']; const end = new Date('2026-06-04').getTime(), start = end - cfg.days * 86400000, labels = []; for (let i = 0; i < cfg.n; i++) { const d = new Date(start + (end - start) * i / (cfg.n - 1)); labels.push(cfg.fmt === 'ym' ? `${String(d.getFullYear()).slice(2)}.${String(d.getMonth() + 1).padStart(2, '0')}` : `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`); } return { n: cfg.n, start, end, labels }; } function genWalk(seed, endVal, n, stepVol, decimals) { let r = (seed >>> 0) || 1; const rnd = () => { r = Math.imul(r, 1664525) + 1013904223 >>> 0; return r / 4294967296; }; const arr = new Array(n); arr[n - 1] = endVal; let v = endVal; for (let i = n - 2; i >= 0; i--) { v = v * (1 + (rnd() - 0.5) * stepVol); arr[i] = v; } const p = Math.pow(10, decimals); return arr.map((x) => Math.round(x * p) / p); } function dangerDate(item) { if (item.status !== '위험') return null; const note = item.note || (item.bond && item.bond.note) || (item.stock && item.stock.note); if (!note) return null; // 새 포맷: (2026-03-27) const m1 = note.match(/\((\d{4}-\d{2}-\d{2})\)/); if (m1) return new Date(m1[1]); // 구 포맷: (220125) const m2 = note.match(/\((\d{6})\)/); if (!m2) return null; const s = m2[1]; return new Date(2000 + +s.slice(0, 2), +s.slice(2, 4) - 1, +s.slice(4, 6)); } function parseCoupon(item) { if (item.stock) return null; const m = item.name.match(/(\d+\.\d+|\d{4,5})/); // foreign "DFHOLD 2.125 ..." -> 2.125 ; domestic "국고03375" -> 3.375 const fm = item.name.match(/\s(\d\.\d{2,3})\s/);if (fm) return parseFloat(fm[1]); const dm = item.name.match(/(\d{5})/);if (dm) {return +(parseInt(dm[1]) / 1000).toFixed(3);} const dm4 = item.name.match(/(\d{4})/);if (dm4 && +dm4[1] > 1000) {return +(parseInt(dm4[1]) / 1000).toFixed(3);} return null; } // 종목 기본정보: DB에서 STOCK_FUND_DB 전역으로 주입, 없으면 빈 객체 function getStockFund(ticker) { return (typeof STOCK_FUND_DB !== 'undefined' && STOCK_FUND_DB[ticker]) || {}; } function InfoGrid({ rows }) { return (
{rows.map((r, i) =>
{r.k} {r.v}
)}
); } function Detail({ item, openDetail }) { const isStock = item.asset === '주식'; const cur = item.pending ? 0.03 : Math.abs(item.cvar); const [chartView, setChartView] = React.useState('CVaR'); const [period, setPeriod] = React.useState('ALL'); const [copied, setCopied] = React.useState(false); const seed = hashSeed(item.isin); const priceEnd = React.useMemo(() => { if (isStock) { const f = getStockFund(item.isin); const hi = parseNum(f.hi52) || 100000, lo = parseNum(f.lo52) || hi * 0.6; const p = parseNum(f.price); if (p) return p; const ratio = 0.42 + (seed % 100) / 100 * 0.42; return Math.round((lo + (hi - lo) * ratio) / 10) * 10; } const cv = Math.abs(item.cvar || 0.03); return +(100.6 - Math.min(28, cv * 100 * 0.32)).toFixed(2); }, [item.isin]); const priceUnit = isStock ? '원' : ''; const pd = React.useMemo(() => buildPeriod(period), [period]); const cvarSeries = React.useMemo(() => genWalk(seed, cur * 100, pd.n, 0.06, 2), [item.isin, period]); // 실제 가격 데이터 fetch (bond_price / stock_price) const [realPrices, setRealPrices] = React.useState(null); React.useEffect(() => { setRealPrices(null); fetch(`/api/prices?isin=${encodeURIComponent(item.isin)}&type=${isStock ? 'stock' : 'bond'}`) .then((r) => r.json()) .then((d) => { if (d && d.dates && d.dates.length >= 2) setRealPrices(d); }) .catch(() => {}); }, [item.isin]); const priceSeries = React.useMemo(() => { if (realPrices) { const pairs = realPrices.dates.map((dt, i) => ({ t: new Date(dt).getTime(), p: realPrices.prices[i] })) .filter((x) => x.t >= pd.start && x.t <= pd.end && x.p != null); if (pairs.length >= 2) { return Array.from({ length: pd.n }, (_, j) => { const target = pd.start + (j / (pd.n - 1)) * (pd.end - pd.start); let best = pairs[0]; for (const pt of pairs) { if (Math.abs(pt.t - target) < Math.abs(best.t - target)) best = pt; } return isStock ? Math.round(best.p) : +best.p.toFixed(2); }); } } return genWalk(seed + 7, priceEnd, pd.n, isStock ? 0.025 : 0.004, isStock ? 0 : 2); }, [item.isin, period, realPrices]); const marker = React.useMemo(() => { const dd = dangerDate(item); if (!dd) return null; const frac = (dd.getTime() - pd.start) / (pd.end - pd.start); if (frac < 0 || frac > 1) return null; return { frac, color: T.alert, label: `위험 지정 ${String(dd.getFullYear()).slice(2)}.${String(dd.getMonth() + 1).padStart(2, '0')}.${String(dd.getDate()).padStart(2, '0')}` }; }, [item.isin, period]); // DB 매도 시그널 (빨간 점) + REMARK 구분선 (회색 세로선) const chartMarkers = React.useMemo(() => { if (isStock) return []; const sigKey = `${item.isin}|${item.ledger || '—'}`; const sig = (typeof BOND_SIGS !== 'undefined' && BOND_SIGS) ? (BOND_SIGS[sigKey] || {}) : {}; const result = []; // 매도 시그널 → 빨간 점 for (const dateStr of (sig.sell || [])) { const d = new Date(dateStr); const frac = (d.getTime() - pd.start) / (pd.end - pd.start); if (frac >= 0.005 && frac <= 0.995) result.push({ frac, color: T.alert, type: 'dot' }); } // REMARK 날짜 → 회색 세로선 if (sig.remark) { const d = new Date(sig.remark); const frac = (d.getTime() - pd.start) / (pd.end - pd.start); const label = sig.remarkType === '유보' ? '유보' : sig.remarkType === '주의' ? '주의80%' : '비고'; if (frac >= 0.005 && frac <= 0.995) result.push({ frac, color: '#9ca3af', type: 'vline', label }); } return result; }, [item.isin, item.ledger, period]); // 범례: 시그널 요약 (차트 위 표시용) const sigSummary = React.useMemo(() => { if (isStock) return null; const sigKey = `${item.isin}|${item.ledger || '—'}`; const sig = (typeof BOND_SIGS !== 'undefined' && BOND_SIGS) ? (BOND_SIGS[sigKey] || {}) : {}; const parts = []; if ((sig.sell || []).length) parts.push({ color: T.alert, text: `매도 시그널 ${sig.sell.length}건` }); if (sig.remark) parts.push({ color: '#9ca3af', text: `${sig.remarkType || '비고'} ${sig.remark.slice(2, 4)}.${sig.remark.slice(5, 7)}.${sig.remark.slice(8, 10)}` }); return parts.length ? parts : null; }, [item.isin, item.ledger]); const coupon = parseCoupon(item); // peers const peers = React.useMemo(() => { if (isStock) {return STOCKS.filter((s) => s.sector === item.type && s.ticker !== item.isin).map((s) => ({ name: s.name, cvar: s.risk / 100, self: false }));} const b = item.bond || BONDS.find((x) => x.isin === item.isin); let pool = b && b.foreign ? BONDS.filter((x) => x.foreign && x.country === b.country) : BONDS.filter((x) => !x.foreign && x.cat === (b ? b.cat : item.type)); return pool.filter((x) => x.isin !== item.isin && x.cvar != null).map((x) => ({ name: x.name, cvar: x.cvar, self: false, isin: x.isin })); }, [item.isin]); const peerAvg = peers.length ? peers.reduce((s, p) => s + Math.abs(p.cvar), 0) / peers.length : cur; const cmpData = [{ name: item.name, cvar: cur, self: true }, ...peers.slice(0, 5).map((p) => ({ ...p, cvar: Math.abs(p.cvar) }))].sort((a, b) => b.cvar - a.cvar); const cmpMax = Math.max(...cmpData.map((d) => d.cvar)); // AI comment — DB 리포트 우선, fallback은 하드코딩 const dir = item.dod > 0 ? '상승' : item.dod < 0 ? '하락' : '보합'; const vs = cur > peerAvg ? '높은' : '낮은'; const note = item.note || item.bond && item.bond.note; const statusText = item.status === '유보' ? '유보 시행문이 발효된 종목으로, 한도·신용 점검과 보유 전략 재검토가 필요해요.' : item.status === '주의' ? '위험액이 내부 한도의 80%를 초과해 주의가 필요한 상태예요.' : '현재 분류 기준상 특이 신호는 관찰되지 않아요.'; const reportKey = isStock ? item.isin : `${item.isin}|${item.ledger || '—'}`; const dbReport = (typeof REPORTS !== 'undefined' && REPORTS) ? (isStock ? REPORTS.stocks?.[item.isin] : REPORTS.bonds?.[reportKey]) : null; const aiLines = dbReport ? [dbReport.line1, dbReport.line2, dbReport.line3].filter(Boolean) : [ `${item.name}의 현재 CVaR(99%, 1일)은 ${(cur * 100).toFixed(2)}%로, 동일 ${isStock ? '섹터' : '유형'} 평균(${(peerAvg * 100).toFixed(2)}%) 대비 ${vs} 수준이에요.`, item.pending ? '최근 시장 데이터가 일부 누락되어 위험액 산출이 보류된 상태예요. 데이터 정합성 확인 후 재산출이 필요해요.' : `전일 대비 CVaR이 ${Math.abs(item.dod * 100).toFixed(2)}%p ${dir}했어요. ${statusText}`, isStock ? '포지션 비중과 섹터 집중도를 함께 확인해 분산 효과를 점검해 보세요.' : '만기까지의 잔존 듀레이션과 금리 민감도를 함께 확인하면 좋아요.', ]; const copyText = dbReport?.report_text || aiLines.join('\n\n'); const handleCopy = () => { navigator.clipboard.writeText(copyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; return (
{/* header */}
{item.name} {isStock ? '주식' : '채권'} {note && {note}}
{isStock ? {item.isin}·{getStockFund(item.isin).ex || 'KOSPI'}·{item.ccy}·{item.type} : {item.isin}·{item.ledger || '—'}·{item.ccy}·{item.type}}
CVaR (99%/1일)
= 0.06 ? T.warn : T.c900, letterSpacing: '-.02em', marginTop: 2 }}>{item.pending ? '보류' : (cur * 100).toFixed(2) + '%'}
전일대비
{item.pending ? : }
{/* trend */} {chartView === 'CVaR' ? 'Historical Simulation' : realPrices ? (isStock ? '종가 실데이터' : '청산가 실데이터') : '임의 데이터'}
} />
{marker && {marker.label}} {sigSummary && sigSummary.map((s, i) => {s.color === T.alert ? : } {s.text} )}
{chartView === 'CVaR' ? : }
{/* basic info */}
{const f = getStockFund(item.isin);return [ { k: '거래소', v: f.ex || 'KOSPI' }, { k: '티커', v: item.isin, mono: true }, { k: '섹터', v: item.type }, { k: '시가총액', v: f.mktcap || '—' }, { k: '상장주식수', v: f.shares || '—' }, { k: '52주 고가', v: f.hi52 ? f.hi52 + '원' : '—' }, { k: '52주 저가', v: f.lo52 ? f.lo52 + '원' : '—' }, { k: '최근 종가', v: f.price ? f.price + '원' : '—' }, { k: '분류', v: }]; })() : [ { k: 'ISIN', v: item.isin, mono: true }, { k: '원장번호', v: item.ledger || '—', mono: true }, { k: '통화', v: item.ccy }, { k: '유형', v: item.type }, { k: '만기', v: fmtMat((item.bond || BONDS.find((x) => x.isin === item.isin) || {}).maturity) }, { k: '금리', v: coupon != null ? coupon.toFixed(3) + '%' : '—' }, { k: '신용등급', v: (item.bond || BONDS.find((x) => x.isin === item.isin) || {}).grade || '—' }, { k: '분류', v: }] } />
{/* peer comparison */}
{cmpData.length <= 1 ?
비교 가능한 동일군 종목이 없어요.
:
{cmpData.map((d, i) =>
{if (!d.self && d.isin) {const bb = BONDS.find((x) => x.isin === d.isin);if (bb) openDetail({ asset: '채권', isin: bb.isin, ledger: bb.ledger, name: bb.name, ccy: bb.ccy, type: bb.cat, cvar: bb.cvar, dod: bb.dod, status: bb.status, pending: bb.pending, bond: bb });}}} style={{ cursor: d.self ? 'default' : 'pointer' }}>
{d.name}{d.self && ' (현재)'} {(d.cvar * 100).toFixed(2)}%
)}
}
{/* AI comment */} AI
} />
{aiLines.map((l, i) =>
{l}
)}
본 코멘트는 자동 생성된 참고 자료로, 투자 판단의 근거가 아니에요.
); } Object.assign(window, { Detail });