// 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 });