// PortfolioTools.jsx — 포트폴리오 공용 헬퍼 + 도구 패널(신규추가/AI최적화/시뮬레이션). const PAL = ['#0A4FA3', '#00A0E0', '#E0922A', '#6D5BD0', '#0E7C5A', '#C76A1F', '#1F8A8A', '#3E63B8', '#8A6D3B', '#B0476B', '#4C7BD9', '#D4A23A']; function fmtEok(v) {const n = Math.round(v);return n >= 10000 ? (n / 10000).toFixed(2) + '조' : n.toLocaleString() + '억';} function holdColor(h, i) {return PAL[i % PAL.length];} // 종목별 위험기여 비율 (합 = 1) function riskContribs(holds) { const arr = holds.map((h) => {const a = UMAP[h.id];let c = 0; for (const g of holds) {const b = UMAP[g.id];c += g.w / 100 * corr(a, b) * b.vol;} return { id: h.id, c: h.w / 100 * a.vol * c };}); const sum = arr.reduce((s, x) => s + x.c, 0) || 1; return arr.map((x) => ({ id: x.id, frac: x.c / sum })); } // 한도 소진율 미터 (80/100 눈금) function LimitMeter({ pct, h = 10 }) { const c = pct >= 100 ? T.alert : pct >= 80 ? T.warn : T.ok; return (
{[80, 100].map((t) => )}
); } function MiniBar({ pct, color, w = 64 }) { return
; } // 변경 전/후 비교 행. betterLow=true면 감소가 개선(초록). neutral이면 좋고나쁜 색 없이 표시. function CompareRow({ label, before, after, unit, digits = 0, betterLow, neutral }) { const diff = after - before; const improved = betterLow ? diff < 0 : diff > 0; const flat = Math.abs(diff) < (digits > 0 ? 0.005 : 0.5); const col = neutral ? T.c600 : flat ? T.c400 : improved ? T.ok : T.alert; const fmt = (v) => digits > 0 ? v.toFixed(digits) : Math.round(v).toLocaleString(); return (
{label} {fmt(before)}{unit} {fmt(after)}{unit} {flat ? '—' : (diff > 0 ? '+' : '') + fmt(diff) + unit}
); } // 비중 변경 후 dilute & normalize 하여 신규 종목 추가 function withAdded(holds, id, w) { const scaled = holds.map((h) => ({ id: h.id, w: h.w * (100 - w) / 100 })); const existing = scaled.find((h) => h.id === id); if (existing) {existing.w += w;return existing && scaled;} return [...scaled, { id, w }]; } // ============ 종목 매수·매도 패널 (유니버스 ↔ 내 포트폴리오) ============ function AddStockPanel({ pf, onCommit, asset }) { const held = new Set(pf.holds.map((h) => h.id)); const [q, setQ] = React.useState(''); const match = (c) => !q || (c.name + c.id + (c.sector || '') + (c.country || '')).toLowerCase().includes(q.toLowerCase()); const universe = UNIV.filter((u) => !held.has(u.id) && (!asset || u.type === asset)).map((u) => ({ id: u.id, name: u.name, sector: u.sector, country: u.country, type: u.type })).filter(match); const portfolio = pf.holds.map((h) => ({ id: h.id, name: UMAP[h.id].name, sector: UMAP[h.id].sector, country: UMAP[h.id].country, type: UMAP[h.id].type, _w: h.w, _amt: h.w / 100 * pf.value })).filter(match); const [sel, setSel] = React.useState({ id: universe[0] && universe[0].id, side: 'add' }); const [amt, setAmt] = React.useState(300); const action = sel.side; // 선택이 유효하지 않으면 목록 첫 항목으로 되돌림 React.useEffect(() => { if (action === 'add' && !universe.some((u) => u.id === sel.id)) setSel({ id: universe[0] && universe[0].id, side: 'add' }); if (action === 'remove' && !held.has(sel.id)) setSel({ id: portfolio[0] && portfolio[0].id, side: 'remove' }); }, [pf.holds.length]); // 선택 종목이 바뀌면 금액 기본값 조정 (매도는 보유 전량, 매수는 300억) React.useEffect(() => { if (sel.side === 'remove') {const h = pf.holds.find((x) => x.id === sel.id);setAmt(h ? Math.round(h.w / 100 * pf.value) : 0);} else setAmt(300); }, [sel.id, sel.side]); const pick = (id, side) => setSel({ id, side }); const selHold = pf.holds.find((h) => h.id === sel.id); const selAmt = selHold ? selHold.w / 100 * pf.value : 0; const base = summarize(pf); const u = UMAP[sel.id]; let nextHolds, nextValue, valid; if (action === 'add') { const a = Math.max(0, amt); nextValue = pf.value + a; nextHolds = normalize([...pf.holds.map((h) => ({ id: h.id, w: h.w * pf.value / nextValue })), { id: sel.id, w: a / nextValue * 100 }]); valid = a > 0 && !!sel.id; } else { const rem = Math.min(Math.max(0, amt), selAmt); nextValue = Math.max(1, pf.value - rem); const abs = pf.holds.map((h) => ({ id: h.id, w: h.id === sel.id ? h.w / 100 * pf.value - rem : h.w / 100 * pf.value })); const tot = abs.reduce((s, x) => s + Math.max(0, x.w), 0) || 1; nextHolds = abs.filter((x) => x.w > 0.01).map((x) => ({ id: x.id, w: +(x.w / tot * 100).toFixed(2) })); valid = rem > 0 && nextHolds.length >= 1 && !!sel.id; } const next = summarize({ ...pf, holds: nextHolds, value: nextValue }); // 요인 기여 변화 (before→after) const decB = {}; factorDecompose(pf).forEach((d) => decB[d.id] = d); const decA = {}; factorDecompose({ ...pf, holds: nextHolds, value: nextValue }).forEach((d) => decA[d.id] = d); const chips = action === 'add' ? [100, 300, 500, 1000] : [['¼', selAmt / 4], ['½', selAmt / 2], ['전량', selAmt]]; // 종목 목록 한 칸 const Column = ({ title, tag, tagColor, items, empty, side, meta }) => (
{title} {tag}
{items.length === 0 &&
{empty}
} {items.map((c) => {const on = c.id === sel.id && sel.side === side;const acc = side === 'remove' ? { l: T.alertL, s: T.alertS } : { l: T.blueLine, s: T.blueSurface }; return ;})}
); return (
{/* ── 좌: 종목 매수 · 매도 ── */}
종목 매수 · 매도 유니버스에서 매수 · 보유에서 매도
{/* 두 칸: 유니버스 / 내 포트폴리오 */}
`${c.country} · ${c.sector}`} /> `${c._w.toFixed(1)}%`} />
{/* 선택 종목 + 금액 + 실행 */} {sel.id &&
{action === 'add' ? '매수' : '매도'} {u ? u.name : ''} {action === 'remove' && 보유 {Math.round(selAmt).toLocaleString()}억}
{action === 'add' ? '매수' : '매도'} setAmt(Math.max(0, Math.round(+e.target.value || 0)))} style={{ flex: 1, border: 0, outline: 'none', background: 'transparent', textAlign: 'right', fontFamily: T.font, fontWeight: 800, fontSize: 20, color: T.c900, minWidth: 0 }} /> 억원
{chips.map((c, i) => {const lab = action === 'add' ? c.toLocaleString() + '억' : c[0];const v = action === 'add' ? c : Math.round(c[1]); return ;})}
{ onCommit(nextHolds, nextValue); showToast(`${u ? u.name : ''} ${Math.round(action === 'add' ? Math.max(0, amt) : Math.min(Math.max(0, amt), selAmt)).toLocaleString()}억 ${action === 'add' ? '매수' : '매도'}를 반영했어요 (모의)`); }} style={{ width: '100%', marginTop: 10 }}>{action === 'add' ? `${u ? u.name : ''} 매수하기` : `${u ? u.name : ''} 매도하기`}
}
{/* ── 우: 실시간 리스크 측정 결과 ── */}
리스크 측정 결과
{/* 요인 기여 변화 */}
팩터별 리스크 기여
{(asset === '주식' ? ['eq', 'fx'] : asset === '채권' ? ['rate', 'credit', 'fx'] : ['rate', 'credit', 'eq', 'fx']).map((fid) => )}
{action === 'add' ? '매수' : '매도'} 후 한도 소진율 = 100 ? T.alert : next.util >= 80 ? T.warn : T.ok }}>{next.util.toFixed(1)}%
{next.util >= 100 &&
리스크 한도를 초과해요. 금액 조정이 필요해요.
} {next.util >= 80 && next.util < 100 &&
한도 소진율이 80%를 넘어 한도 임박 구간이에요.
}
); } // 특정 요인 노출 축소 목표 최적화: 노출도 역가중 틸트 후 현재 비중과 블렌드 function optimizeFactor(pf, fid) { const expo = (u) => fid === 'rate' ? (u.type === '채권' ? u.dur || 3 : 0) : fid === 'credit' ? (u.type === '채권' ? (u.dur || 3) * 0.5 * (u.credit || 0) : 0) : fid === 'eq' ? (u.type === '주식' ? u.beta || 1 : 0) : (u.fx ? 1 : 0); const maxE = Math.max(...pf.holds.map((h) => expo(UMAP[h.id])), 0.01); const score = pf.holds.map((h) => Math.max(0.06, 1 - 0.85 * expo(UMAP[h.id]) / maxE)); let target = pf.holds.map((h, i) => h.w * score[i]); const tt = target.reduce((a, b) => a + b, 0) || 1; target = target.map((w) => w / tt * 100); let blended = pf.holds.map((h, i) => 0.55 * target[i] + 0.45 * h.w); const bt = blended.reduce((a, b) => a + b, 0); blended = blended.map((w) => w / bt * 100); const newHolds = pf.holds.map((h, i) => ({ id: h.id, w: +blended[i].toFixed(2) })); const before = summarize(pf); const after = summarize({ ...pf, holds: newHolds }); return { newHolds, before, after, deltas: pf.holds.map((h, i) => ({ id: h.id, name: UMAP[h.id].name, type: UMAP[h.id].type, from: h.w, to: +newHolds[i].w.toFixed(1), diff: +(newHolds[i].w - h.w).toFixed(1) })) }; } // ============ AI 최적화 패널 ============ function OptimizePanel({ pf, onCommit, asset, initialGoal }) { const [goal, setGoal] = React.useState(initialGoal || 'risk'); const [res, setRes] = React.useState(null); const [busy, setBusy] = React.useState(false); React.useEffect(() => { if (initialGoal) { setGoal(initialGoal); setRes(null); } }, [initialGoal]); const GOALS = asset === '주식' ? [ { v: 'risk', l: '수익 대비 변동성' }, { v: 'eq', l: '베타 축소' }, { v: 'fx', l: '환 노출 축소' }] : [ { v: 'risk', l: '수익 대비 변동성' }, { v: 'rate', l: '금리 노출 축소' }, { v: 'credit', l: '신용 노출 축소' }, { v: 'fx', l: '환 노출 축소' }]; const run = () => {setBusy(true);setRes(null);setTimeout(() => { const r = goal === 'risk' ? optimize(pf) : optimizeFactor(pf, goal); const fb = {}; factorDecompose(pf).forEach((d) => fb[d.id] = d); const fa = {}; factorDecompose({ ...pf, holds: r.newHolds }).forEach((d) => fa[d.id] = d); setRes({ ...r, fb, fa, goal });setBusy(false);}, 850);}; React.useEffect(() => {setRes(null);}, [pf]); if (!res) return (
AI 리스크 최적화
{goal === 'risk' ? '위험조정수익(수익률 대비 변동성)과 종목 간 상관관계를 분석해 동일 수익 수준에서 리스크를 낮추는 비중을 추천해요.' : `${FMAP[goal].name} 노출이 큰 종목의 비중을 줄여 ${FMAP[goal].name} 기여 위험액을 낮추는 비중을 추천해요.`}
최적화 목표
{busy ? '분석 중…' : 'AI 최적화 실행'}
); const { before, after, deltas } = res; const goalName = res.goal === 'risk' ? null : FMAP[res.goal].name; const facCut = goalName ? (1 - res.fa[res.goal].amt / (res.fb[res.goal].amt || 1)) * 100 : 0; const riskCut = before.risk - after.risk; const nChanged = deltas.filter((d) => Math.abs(d.diff) >= 0.05).length; const kpis = [ { l: '리스크 절감 (CVaR)', v: (riskCut >= 0 ? '−' : '+') + Math.abs(Math.round(riskCut)).toLocaleString(), u: '억', sub: `${((1 - after.risk / before.risk) * 100).toFixed(1)}% 감소`, c: riskCut >= 0 ? T.ok : T.alert }, goalName ? { l: goalName + ' 노출 절감', v: '−' + Math.abs(facCut).toFixed(0), u: '%', sub: `${Math.round(res.fb[res.goal].amt).toLocaleString()}억 → ${Math.round(res.fa[res.goal].amt).toLocaleString()}억`, c: T.ok } : { l: '변동성 (연)', v: after.vol.toFixed(1), u: '%', sub: `현재 ${before.vol.toFixed(1)}%`, c: T.c900 }, { l: '적용 후 소진율', v: after.util.toFixed(1), u: '%', sub: `현재 ${before.util.toFixed(1)}%`, c: after.util >= 100 ? T.alert : after.util >= 80 ? T.warn : T.ok }, { l: '수익률 영향 (연)', v: (after.ret - before.ret >= 0 ? '+' : '') + (after.ret - before.ret).toFixed(1), u: '%p', sub: `${before.ret.toFixed(1)}% → ${after.ret.toFixed(1)}%`, c: dirColor(after.ret - before.ret) }, { l: '리밸런싱 대상', v: String(nChanged), u: '종목', sub: `보유 ${deltas.length}종목 중`, c: T.c900 }]; return (
{/* 결과 요약 스트립 */}
{kpis.map((k) =>
{k.l}
{k.v} {k.u}
{k.sub}
)}
AI 추천 목표 · {GOALS.find((g) => g.v === res.goal).l} 종목별 권장 비중 변화
({ ...d, _key: d.id }))} initialSort={{ k: 'diff', dir: 'desc' }} cols={[ { k: 'name', t: '종목', render: (r) => {r.name} }, { k: 'from', t: '현재', num: true, render: (r) => {r.from.toFixed(1)}% }, { k: 'to', t: '추천', num: true, render: (r) => {r.to.toFixed(1)}% }, { k: 'diff', t: '변화', num: true, render: (r) => {const c = Math.abs(r.diff) < 0.05 ? T.c400 : r.diff > 0 ? T.ok : T.alert;return {Math.abs(r.diff) >= 0.05 && 0 ? 'trendUp' : 'trendDown'} size={12} color={c} stroke={2.4} />}{r.diff > 0 ? '+' : ''}{r.diff.toFixed(1)}%p;}, sortVal: (r) => r.diff }] } />
최적화 효과
팩터별 리스크 기여
{(asset === '주식' ? ['eq', 'fx'] : asset === '채권' ? ['rate', 'credit', 'fx'] : ['rate', 'credit', 'eq', 'fx']).map((fid) => )}
{goalName ? {goalName} 기여 위험액을 {facCut.toFixed(0)}% 줄이면서 수익률 영향은 {(after.ret - before.ret).toFixed(1)}%p로 최소화했어요. : 리스크를 {Math.round(before.risk - after.risk).toLocaleString()}억 ({((1 - after.risk / before.risk) * 100).toFixed(1)}%) 낮추면서 {Math.abs(after.ret - before.ret) < 0.8 ? '수익률은 거의 유지돼요.' : `수익률 영향은 ${(after.ret - before.ret).toFixed(1)}%p로 최소화했어요.`}}
다시 { onCommit(res.newHolds); showToast(`AI 추천 비중을 적용했어요 · 리스크 ${Math.round(riskCut).toLocaleString()}억 절감`); }} style={{ flex: 1 }}>추천 비중 적용
); } // ============ 리스크 시뮬레이션 패널 ============ function SimulatePanel({ pf }) { const [scId, setScId] = React.useState(SCENARIOS[0].id); const sc = SCENARIOS.find((s) => s.id === scId); const base = summarize(pf); const sim = simulate(pf, sc); const breach = sim.util >= 100; return (
위기 시나리오 선택
{SCENARIOS.map((s) => {const on = s.id === scId; return ;})}
{/* 충격 파라미터 */}
{[['주식', `${sc.eq}%`, T.down], ['금리', `${sc.rateBp > 0 ? '+' : ''}${sc.rateBp}bp`, sc.rateBp > 0 ? T.up : T.down], ['신용스프레드', `+${sc.creditBp}bp`, T.up], ['원/달러', `+${sc.fxPct}%`, T.up], ['변동성', `×${sc.volX}`, T.warn]].map(([k, v, c]) => {k} {v} )}
{/* 결과 요약 */}
{[ { l: '예상 손익', v: (sim.portPnl > 0 ? '+' : '') + sim.portPnl.toFixed(1) + '%', s: fmtEok(sim.lossAmt), c: sim.portPnl < 0 ? T.down : T.up }, { l: '시나리오 후 평가액', v: fmtEok(sim.newValue), s: `현재 ${fmtEok(pf.value)}`, c: T.c900 }, { l: '시나리오 후 위험액', v: fmtEok(sim.newRisk), s: `현재 ${fmtEok(base.risk)} · +${Math.round((sim.newRisk / base.risk - 1) * 100)}%`, c: T.alert }, { l: '한도 소진율', v: sim.util.toFixed(1) + '%', s: breach ? '한도 초과' : '한도 내', c: breach ? T.alert : sim.util >= 80 ? T.warn : T.ok }]. map((k) =>
{k.l}
{k.v}
{k.s}
)}
{breach &&
{sc.name} 발생 시 리스크 한도를 {(sim.util - 100).toFixed(0)}%p 초과해요. 헤지·비중축소 대응이 필요해요.
} {/* 종목별 영향 */}
종목별 시나리오 손익
{sim.perHold.slice().sort((a, b) => a.pnl - b.pnl).map((x) => {const neg = x.pnl < 0;const mag = Math.min(50, Math.abs(x.pnl)); return
{x.name} {x.w.toFixed(1)}%
{x.pnl > 0 ? '+' : ''}{x.pnl.toFixed(1)}%
;})}
); } Object.assign(window, { PAL, fmtEok, riskContribs, LimitMeter, MiniBar, CompareRow, withAdded, optimizeFactor, AddStockPanel, OptimizePanel, SimulatePanel });