// 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}}
)}
{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 (
);
}
Object.assign(window, { Stocks, PortfolioHero, ScatterRiskReturn });