// Stocks.jsx — 주식 메인. // 추천종목 → Detail item 변환 (STOCK_RISK_MAP에서 실시간 리스크 조회) function recoToItem(r) { const live = (typeof STOCK_RISK_MAP !== 'undefined') ? (STOCK_RISK_MAP[r.ticker] || null) : null; return { asset: '주식', isin: r.ticker, ledger: '—', name: r.name, ccy: 'KRW', type: r.sector, status: live ? live.status : '적정', cvar: live ? +(live.risk / 100).toFixed(4) : 0.05, dod: live ? +(live.dod / 100).toFixed(4) : 0, pending: !live, note: null, stock: { ticker: r.ticker, name: r.name, eval: 0, ret: live ? live.ret : 0, risk: live ? live.risk : 5, dod: live ? live.dod : 0, sector: r.sector, status: live ? live.status : '적정', riskAmt: 0, note: null, }, }; } const STOCK_SECTOR_COLORS = ['#0A4FA3', '#00A0E0', '#6D5BD0', '#11865B', '#E0922A', '#C8363C', '#4F6272', '#8B5CF6', '#0E8C8C']; // 주식 전용 상태 색상 규칙 const SST = { '안전': { fg: '#1DB56C', bg: '#DCF6ED', line: '#A8E6C8' }, '적정': { fg: '#F98701', bg: '#FFEFD4', line: '#FFD9A0' }, '주의': { fg: '#FF3D00', bg: '#FFE4D9', line: '#FFBFAA' }, '위험': { fg: '#FF1C27', bg: '#FFE0E5', line: '#FFB3BC' }, }; const sstColor = (status) => (SST[status] || SST['적정']).fg; function StockPill({ status }) { const s = SST[status] || SST['적정']; return ( {status} ); } function PortfolioHero({ p, retLabel = '가중 수익률', extra }) { const metrics = [ { label: retLabel, render: , sub: '보유 기간 평가 기준' }, { label: '포트폴리오 리스크', value: p.risk, unit: '%', color: T.c900, sub: 'CVaR 99% / 1일 · 평가액 대비' }, { label: '구성 비중', value: p.weight, unit: '%', color: T.blue, sub: `전체 유가증권 중 · ${(p.hold / 10000).toFixed(1)}조원` }]; return (
{metrics.map((m, i) =>
{m.render || {m.value}{m.unit}}
{m.label}
{m.sub}
)} {extra}
); } function Stocks({ openDetail }) { const p = PORTFOLIO.stock; const [q, setQ] = React.useState(''); const [filter, setFilter] = React.useState('전체'); const sectorComp = React.useMemo(() => Object.entries(STOCK_SEC_COMP).sort((a, b) => b[1] - a[1]). map(([label, value], i) => ({ label, value, color: STOCK_SECTOR_COLORS[i % STOCK_SECTOR_COLORS.length] })), []); const statusCounts = { 전체: STOCKS.length, 안전: STOCKS.filter((s) => s.status === '안전').length, 적정: STOCKS.filter((s) => s.status === '적정').length, 주의: STOCKS.filter((s) => s.status === '주의').length, 위험: STOCKS.filter((s) => s.status === '위험').length }; const rows = STOCKS.filter((s) => { if (filter !== '전체' && s.status !== filter) return false; // 구형 더미 상태도 호환: '정상'→'안전', '유보'→'적정'으로 표시 허용 if (q && !(s.name.includes(q) || s.ticker.includes(q) || s.sector.includes(q))) return false; return true; }).map((s) => ({ ...s, _key: s.ticker, weight: +(s.eval / p.hold * 100).toFixed(1), item: { asset: '주식', isin: s.ticker, ledger: '—', name: s.name, ccy: 'KRW', type: s.sector, cvar: +(s.risk / 100).toFixed(4), dod: +(s.dod / 100 * 0.3).toFixed(4), status: s.status, pending: false, stock: s } })); return (
평가액 {(p.hold / 10000).toFixed(1)}조} />
openDetail({ asset: '주식', isin: s.ticker, name: s.name, ccy: 'KRW', type: s.sector, cvar: +(s.risk / 100).toFixed(4), dod: 0, status: s.status, pending: false, stock: s })} />
} />
{rows.length}개 표시 · 헤더 클릭 정렬
openDetail(r.item)} initialSort={{ k: 'eval', dir: 'desc' }} rows={rows} cols={[ { k: 'ticker', t: '티커', w: 72, render: (r) => {r.ticker} }, { k: 'name', t: '종목명', w: '18%', render: (r) => {r.name} }, { k: 'sector', t: '섹터', render: (r) => {r.sector} }, { k: 'eval', t: '평가금액(억)', num: true, render: (r) => {r.eval.toLocaleString()} }, { k: 'weight', t: '비중', num: true, render: (r) => {r.weight}% }, { k: 'ret', t: '수익률', num: true, render: (r) => , sortVal: (r) => r.ret }, { k: 'risk', t: '리스크(CVaR)', num: true, render: (r) => = 5 ? T.warn : T.c800 }}>{r.risk.toFixed(1)}% }, { k: 'dod', t: '전일대비', num: true, render: (r) => , sortVal: (r) => r.dod }, { k: 'status', t: '상태', w: 72, render: (r) => }] } />
AI 모델 추천} /> openDetail(recoToItem(r))} rows={STOCK_RECO.map((r) => ({ ...r, _key: r.ticker }))} cols={[ { k: 'ticker', t: '티커', w: 72, render: (r) => {r.ticker} }, { k: 'name', t: '종목명', w: '16%', render: (r) => {r.name} }, { k: 'sector', t: '섹터', render: (r) => {r.sector} }, { k: 'score', t: '추천 점수', num: true, render: (r) => {const c = r.score >= 92 ? T.ok : r.score >= 90 ? T.blue : T.warn;return {r.score}점;}, sortVal: (r) => r.score }, { k: 'reason', t: '추천 사유', w: '40%', sortable: false, render: (r) => {r.reason} }, { k: 'act', t: '', w: 88, sortable: false, render: (r) => { e.stopPropagation(); openDetail(recoToItem(r)); }}>상세보기 }] } />
); } // risk-return scatter (mini) function ScatterRiskReturn({ stocks, onPick }) { const w = 460, h = 224, padL = 44, padR = 14, padT = 16, padB = 44; const iw = w - padL - padR, ih = h - padT - padB; // ── 데이터 기반 동적 스케일 ─────────────────────────────────────────────── const risks = stocks.map(s => s.risk); const rets = stocks.map(s => s.ret); const xPad = (v) => v * 0.18; // 양끝에 여백 18% const yPad = (v) => Math.max(v * 0.22, 2); const xMin = Math.min(...risks); const xMax = Math.max(...risks); const yMin = Math.min(...rets); const yMax = Math.max(...rets); const xSpan = xMax - xMin || 1; const ySpan = yMax - yMin || 1; const xr = [xMin - xPad(xSpan), xMax + xPad(xSpan)]; const yr = [yMin - yPad(ySpan), yMax + yPad(ySpan)]; const x = (v) => padL + (v - xr[0]) / (xr[1] - xr[0]) * iw; const y = (v) => padT + ih - (v - yr[0]) / (yr[1] - yr[0]) * ih; // ── 눈금 자동 생성 ──────────────────────────────────────────────────────── const niceTick = (min, max, n) => { const raw = (max - min) / (n - 1); const mag = Math.pow(10, Math.floor(Math.log10(raw))); const step = [1,2,2.5,5,10].map(f => f * mag).find(f => f >= raw) || raw; const start = Math.ceil(min / step) * step; const ticks = []; for (let t = start; t <= max + step * 0.01; t = +(t + step).toFixed(10)) ticks.push(+t.toFixed(4)); return ticks; }; const xTicks = niceTick(xr[0], xr[1], 5); const yTicks = niceTick(yr[0], yr[1], 4); const fmt = (v) => Number.isInteger(v) ? v : v.toFixed(1); return ( {/* Y 그리드·눈금 */} {yTicks.map((g) => {fmt(g)}% )} {/* X 눈금 */} {xTicks.map((g) => {fmt(g)}%)} {/* 축 레이블 */} 수익률 리스크 {/* 종목 점 */} {stocks.map((s) => { const c = sstColor(s.status); const r = Math.max(5, Math.sqrt(s.eval) / 7); return onPick(s)}> {s.name.length > 4 ? s.name.slice(0, 4) : s.name} ; })} ); } Object.assign(window, { Stocks, PortfolioHero, ScatterRiskReturn });